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    pub state: ExecutionState,
109}
110
111#[derive(Clone, Debug)]
112pub struct FlowWait {
113    pub reason: Option<String>,
114    pub snapshot: FlowSnapshot,
115}
116
117#[derive(Clone, Debug)]
118pub enum FlowStatus {
119    Completed,
120    Waiting(Box<FlowWait>),
121}
122
123#[derive(Clone, Debug)]
124pub struct FlowExecution {
125    pub output: Value,
126    pub status: FlowStatus,
127}
128
129#[derive(Clone, Debug)]
130struct HostFlow {
131    id: String,
132    start: Option<NodeId>,
133    nodes: IndexMap<NodeId, HostNode>,
134    /// Flow-level slot definitions extracted from `metadata.extra["greentic.slot_schema"]`.
135    /// Injected into slot-extractor component invocations at dispatch time (Phase D).
136    slot_schema: Option<Value>,
137}
138
139#[derive(Clone, Debug)]
140pub struct HostNode {
141    kind: NodeKind,
142    /// Backwards-compatible component label for observers/transcript.
143    pub component: String,
144    component_id: String,
145    operation_name: Option<String>,
146    operation_in_mapping: Option<String>,
147    payload_expr: Value,
148    routing: Routing,
149}
150
151impl HostNode {
152    pub fn component_id(&self) -> &str {
153        &self.component_id
154    }
155
156    pub fn operation_name(&self) -> Option<&str> {
157        self.operation_name.as_deref()
158    }
159
160    pub fn operation_in_mapping(&self) -> Option<&str> {
161        self.operation_in_mapping.as_deref()
162    }
163}
164
165#[cfg(test)]
166impl HostNode {
167    /// Test-only constructor. `HostNode`'s fields (and `NodeKind`/`Routing`
168    /// literals) are private to this module, so sibling-module unit tests
169    /// (e.g. `trace::recorder`) that need to build a `NodeEvent` for the
170    /// `ExecutionObserver` trait cannot construct one via struct literal.
171    /// This is additive test scaffolding only — no production behavior change.
172    pub(crate) fn for_test(component_id: &str, operation_name: Option<&str>) -> Self {
173        HostNode {
174            kind: NodeKind::Exec {
175                target_component: component_id.to_string(),
176            },
177            component: component_id.to_string(),
178            component_id: component_id.to_string(),
179            operation_name: operation_name.map(str::to_string),
180            operation_in_mapping: None,
181            payload_expr: Value::Null,
182            routing: Routing::End,
183        }
184    }
185}
186
187#[derive(Clone, Debug)]
188enum NodeKind {
189    Exec {
190        target_component: String,
191    },
192    PackComponent {
193        component_ref: String,
194    },
195    ProviderInvoke,
196    FlowCall,
197    BuiltinEmit {
198        kind: EmitKind,
199    },
200    BuiltinStateGet,
201    BuiltinStateSet,
202    Wait,
203    DwAgent {
204        agent_id: String,
205    },
206    DwAgentGraph {
207        graph_id: String,
208    },
209    /// Native runtime-dispatch node: publishes work to a separate runtime
210    /// (e.g. sorx) via the injected [`RemoteDispatchHandler`]. `target` is the
211    /// node operation (the logical runtime target).
212    SorlaCall {
213        target: String,
214    },
215    /// Native runtime-dispatch node for the Operala runtime. Mirrors
216    /// [`SorlaCall`] but routes to the `"operala"` runtime name.
217    OperalaCall {
218        target: String,
219    },
220    /// Native runtime-dispatch node for an out-of-process agentic runtime.
221    /// Mirrors [`SorlaCall`] but routes to the `"agentic"` runtime name.
222    /// This is an ADDITIONAL path: the in-process `dw.agent` node is
223    /// completely separate and untouched.
224    AgenticCall {
225        target: String,
226    },
227    /// Native runtime-dispatch node for the Telco-X runtime. Mirrors
228    /// [`SorlaCall`] but routes to the `"telco-x"` runtime name. Wire-ready: the
229    /// runtime side (a telco-x NATS dispatch service) is not built yet, so an
230    /// `await: true` node pauses until that runtime exists.
231    TelcoXCall {
232        target: String,
233    },
234    /// Native runtime-dispatch node for the Human-in-the-Loop approval runtime.
235    /// Mirrors [`SorlaCall`] but routes to the `"approval"` runtime name and
236    /// applies an autonomy gate (auto-approve below the configured risk /
237    /// above the configured confidence) before dispatching.
238    ApprovalCall {
239        target: String,
240    },
241    /// Flow-execution MCP node (LOCKED ENCODING v2): `component == "mcp"` with
242    /// `server`/`tool` carried in the node payload/config. Invokes the named
243    /// MCP tool through the tenant's `flow_editor` MCP catalog (reusing
244    /// `greentic-aw-runtime`'s `McpToolSource`). Completely separate from the
245    /// agent-loop MCP path (role `agentic_worker`).
246    ///
247    /// `server_id`/`tool` here are the values resolved at flow-load time;
248    /// `execute_mcp` re-reads them from the rendered payload (source of truth)
249    /// and only uses these as a fallback for the legacy `operation` encoding.
250    Mcp {
251        server_id: String,
252        tool: String,
253    },
254}
255
256#[derive(Clone, Debug)]
257enum EmitKind {
258    Log,
259    Response,
260    Other(String),
261}
262
263struct ComponentOverrides<'a> {
264    component: Option<&'a str>,
265    operation: Option<&'a str>,
266}
267
268struct ComponentCall {
269    component_ref: String,
270    operation: String,
271    input: Value,
272    config: Value,
273    /// Whether the originating node has an `on_error`-family route, so a
274    /// component failure is surfaced as a node_io `{errors}` output and routed
275    /// to that branch instead of aborting the flow (see `node_has_error_route`).
276    has_error_route: bool,
277}
278
279impl FlowExecution {
280    fn completed(output: Value) -> Self {
281        Self {
282            output,
283            status: FlowStatus::Completed,
284        }
285    }
286
287    fn waiting(output: Value, wait: FlowWait) -> Self {
288        Self {
289            output,
290            status: FlowStatus::Waiting(Box::new(wait)),
291        }
292    }
293}
294
295impl FlowEngine {
296    pub async fn new(packs: Vec<Arc<PackRuntime>>, config: Arc<HostConfig>) -> Result<Self> {
297        let mut flow_sources: HashMap<FlowKey, usize> = HashMap::new();
298        let mut messaging_provider_pack_ids: std::collections::HashSet<String> =
299            std::collections::HashSet::new();
300        let mut descriptors = Vec::new();
301        let mut bindings = HashMap::new();
302        for pack in &config.pack_bindings {
303            bindings.insert(pack.pack_id.clone(), pack.flows.clone());
304        }
305        let enforce_bindings = !bindings.is_empty();
306        for (idx, pack) in packs.iter().enumerate() {
307            let pack_id = pack.metadata().pack_id.clone();
308            if enforce_bindings && !bindings.contains_key(&pack_id) {
309                bail!("no gtbind entries found for pack {}", pack_id);
310            }
311            // Mark packs that declare a `messaging.*` provider so their ingress
312            // flows are excluded from type-only entry-flow routing (see
313            // `messaging_provider_pack_ids`). Derived once here, off the hot path.
314            let declares_messaging_provider = pack
315                .provider_registry_optional()
316                .ok()
317                .flatten()
318                .map(|registry| {
319                    registry
320                        .operator_metadata()
321                        .iter()
322                        .any(|meta| meta.provider_type.starts_with("messaging."))
323                })
324                .unwrap_or(false);
325            if declares_messaging_provider {
326                messaging_provider_pack_ids.insert(pack_id.clone());
327            }
328            let flows = pack.list_flows().await?;
329            let allowed = bindings.get(&pack_id).map(|flows| {
330                flows
331                    .iter()
332                    .cloned()
333                    .collect::<std::collections::HashSet<_>>()
334            });
335            let mut seen = std::collections::HashSet::new();
336            for flow in flows {
337                if let Some(ref allow) = allowed
338                    && !allow.contains(&flow.id)
339                {
340                    continue;
341                }
342                seen.insert(flow.id.clone());
343                tracing::info!(
344                    flow_id = %flow.id,
345                    flow_type = %flow.flow_type,
346                    pack_id = %flow.pack_id,
347                    pack_index = idx,
348                    "registered flow"
349                );
350                if let Ok(flow_ir) = pack.load_flow(&flow.id) {
351                    for node in flow_ir.nodes.values() {
352                        config
353                            .secrets_policy
354                            .register_flow_secret_refs(&node.input.mapping);
355                        config
356                            .secrets_policy
357                            .register_flow_secret_refs(&node.output.mapping);
358                    }
359                }
360                flow_sources.insert(
361                    FlowKey {
362                        pack_id: flow.pack_id.clone(),
363                        flow_id: flow.id.clone(),
364                    },
365                    idx,
366                );
367                descriptors.retain(|existing: &FlowDescriptor| {
368                    !(existing.id == flow.id && existing.pack_id == flow.pack_id)
369                });
370                descriptors.push(flow);
371            }
372            if let Some(allow) = allowed {
373                let missing = allow.difference(&seen).cloned().collect::<Vec<_>>();
374                if !missing.is_empty() {
375                    bail!(
376                        "gtbind flow ids missing in pack {}: {}",
377                        pack_id,
378                        missing.join(", ")
379                    );
380                }
381            }
382        }
383
384        let mut flow_map = HashMap::new();
385        for flow in &descriptors {
386            let pack_id = flow.pack_id.clone();
387            if let Some(&pack_idx) = flow_sources.get(&FlowKey {
388                pack_id: pack_id.clone(),
389                flow_id: flow.id.clone(),
390            }) {
391                let pack_clone = Arc::clone(&packs[pack_idx]);
392                let flow_id = flow.id.clone();
393                let task_flow_id = flow_id.clone();
394                match task::spawn_blocking(move || pack_clone.load_flow(&task_flow_id)).await {
395                    Ok(Ok(loaded_flow)) => {
396                        flow_map.insert(
397                            FlowKey {
398                                pack_id: pack_id.clone(),
399                                flow_id,
400                            },
401                            HostFlow::from(loaded_flow),
402                        );
403                    }
404                    Ok(Err(err)) => {
405                        tracing::warn!(flow_id = %flow.id, error = %err, "failed to load flow metadata");
406                    }
407                    Err(err) => {
408                        tracing::warn!(flow_id = %flow.id, error = %err, "join error loading flow metadata");
409                    }
410                }
411            }
412        }
413
414        Ok(Self {
415            packs,
416            flows: descriptors,
417            flow_sources,
418            messaging_provider_pack_ids,
419            flow_cache: RwLock::new(flow_map),
420            default_env: env::var("GREENTIC_ENV").unwrap_or_else(|_| "local".to_string()),
421            validation: config.validation.clone(),
422            cross_pack_resolver: None,
423            rollout_ids: RolloutIds::default(),
424            remote_dispatch_handler: None,
425            #[cfg(feature = "agentic-worker")]
426            dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
427            #[cfg(feature = "agentic-worker")]
428            agent_node_handler: None,
429            #[cfg(feature = "agentic-worker")]
430            graph_node_handler: None,
431            #[cfg(feature = "agentic-worker")]
432            mcp_tool_source: crate::runner::mcp_node::source_from_env(),
433        })
434    }
435
436    /// Bind the rollout identifiers of the revision-keyed runtime this engine
437    /// serves, so every invocation's telemetry carries deployment/bundle/
438    /// revision attribution (C5.4). Called by the Phase-D revision dispatcher
439    /// when it constructs a revision runtime; tenant-only runtimes leave the
440    /// default (empty) IDs.
441    pub fn with_rollout_ids(mut self, rollout_ids: RolloutIds) -> Self {
442        self.rollout_ids = rollout_ids;
443        self
444    }
445
446    /// The rollout identifiers bound to this engine (read counterpart to
447    /// [`with_rollout_ids`](Self::with_rollout_ids)). Empty by default for the
448    /// legacy tenant-only path.
449    pub fn rollout_ids(&self) -> &RolloutIds {
450        &self.rollout_ids
451    }
452
453    /// Set an optional cross-pack resolver for `provider.invoke` nodes that
454    /// reference providers in other packs (resolved via capability registry).
455    pub fn set_cross_pack_resolver(&mut self, resolver: Arc<dyn CrossPackResolver>) {
456        self.cross_pack_resolver = Some(resolver);
457    }
458
459    /// Set the handler that bridges `sorla.call` flow nodes into a separate
460    /// runtime over pub/sub. Constructed by the runner binary when a transport
461    /// (e.g. NATS) is configured.
462    pub fn set_remote_dispatch_handler(
463        &mut self,
464        handler: Arc<dyn crate::runner::remote_dispatch::RemoteDispatchHandler>,
465    ) {
466        self.remote_dispatch_handler = Some(handler);
467    }
468
469    /// Set the handler that bridges `DwAgent` flow nodes into the agentic-worker
470    /// runtime. Constructed by the runner binary (Task 4.3).
471    #[cfg(feature = "agentic-worker")]
472    pub fn set_agent_node_handler(
473        &mut self,
474        handler: Arc<dyn crate::runner::agent_node::AgentNodeHandler>,
475    ) {
476        self.agent_node_handler = Some(handler);
477    }
478
479    /// Set the handler that bridges `DwAgentGraph` flow nodes into the durable
480    /// graph executor. Constructed by the pack loader (Task 8). Mirrors
481    /// [`set_agent_node_handler`].
482    ///
483    /// [`set_agent_node_handler`]: FlowEngine::set_agent_node_handler
484    #[cfg(feature = "agentic-worker")]
485    pub fn set_graph_node_handler(
486        &mut self,
487        handler: Arc<dyn crate::runner::graph_node::GraphNodeHandler>,
488    ) {
489        self.graph_node_handler = Some(handler);
490    }
491
492    /// Set the dispatch mode for `dw.agent` nodes.
493    ///
494    /// - [`DwAgentDispatch::InProcess`] (default): runs the agent in-process via
495    ///   [`AgentNodeHandler`]. Zero configuration overhead; today's behaviour.
496    /// - [`DwAgentDispatch::Nats`]: reroutes the node over the durable agentic
497    ///   NATS path (`greentic.agentic.request.v1`), identical to an `agentic.call`
498    ///   node. Requires [`set_remote_dispatch_handler`] to also be set.
499    ///
500    /// Called by `runtime.rs` when `GREENTIC_AW_DISPATCH=nats`.
501    ///
502    /// [`AgentNodeHandler`]: crate::runner::agent_node::AgentNodeHandler
503    /// [`set_remote_dispatch_handler`]: FlowEngine::set_remote_dispatch_handler
504    #[cfg(feature = "agentic-worker")]
505    pub fn set_dw_agent_dispatch(&mut self, mode: crate::runner::agent_node::DwAgentDispatch) {
506        self.dw_agent_dispatch = mode;
507    }
508
509    async fn get_or_load_flow(&self, pack_id: &str, flow_id: &str) -> Result<HostFlow> {
510        let key = FlowKey {
511            pack_id: pack_id.to_string(),
512            flow_id: flow_id.to_string(),
513        };
514        if let Some(flow) = self.flow_cache.read().get(&key).cloned() {
515            return Ok(flow);
516        }
517
518        let pack_idx = *self
519            .flow_sources
520            .get(&key)
521            .with_context(|| format!("flow {pack_id}:{flow_id} not registered"))?;
522        let pack = Arc::clone(&self.packs[pack_idx]);
523        let flow_id_owned = flow_id.to_string();
524        let task_flow_id = flow_id_owned.clone();
525        let flow = task::spawn_blocking(move || pack.load_flow(&task_flow_id))
526            .await
527            .context("failed to join flow metadata task")??;
528        let host_flow = HostFlow::from(flow);
529        self.flow_cache.write().insert(
530            FlowKey {
531                pack_id: pack_id.to_string(),
532                flow_id: flow_id_owned.clone(),
533            },
534            host_flow.clone(),
535        );
536        Ok(host_flow)
537    }
538
539    /// Create the `flow.execute` span and install per-invocation telemetry:
540    /// declared span fields, the task-local tenant context, and the **exported**
541    /// `gt.*` attribution — the live `pack_id` plus any rollout identifiers from
542    /// the owning revision runtime (C5.4). Returned for the caller to
543    /// `.instrument()`. Both `execute` and `resume` route through here so every
544    /// per-invocation entry point carries the same attribution.
545    fn flow_execute_span(&self, ctx: &FlowContext<'_>) -> tracing::Span {
546        let span = tracing::info_span!(
547            "flow.execute",
548            tenant = tracing::field::Empty,
549            flow_id = tracing::field::Empty,
550            node_id = tracing::field::Empty,
551            tool = tracing::field::Empty,
552            action = tracing::field::Empty
553        );
554        annotate_span(
555            &span,
556            &FlowSpanAttributes {
557                tenant: ctx.tenant,
558                flow_id: ctx.flow_id,
559                node_id: ctx.node_id,
560                tool: ctx.tool,
561                action: ctx.action,
562            },
563        );
564        set_flow_context(
565            &span,
566            &self.default_env,
567            ctx.tenant,
568            ctx.flow_id,
569            ctx.node_id,
570            ctx.provider_id,
571            ctx.session_id,
572            ctx.pack_id,
573            &self.rollout_ids,
574        );
575        span
576    }
577
578    pub async fn execute(&self, ctx: FlowContext<'_>, input: Value) -> Result<FlowExecution> {
579        let span = self.flow_execute_span(&ctx);
580        let retry_config = ctx.retry_config;
581        let original_input = input;
582        let mut ctx = ctx;
583        let metric_tenant = ctx.tenant.to_string();
584        let metric_flow_id = ctx.flow_id.to_string();
585        let started = std::time::Instant::now();
586        let result = async move {
587            let mut attempt = 0u32;
588            loop {
589                attempt += 1;
590                ctx.attempt = attempt;
591                #[cfg(feature = "fault-injection")]
592                {
593                    let fault_ctx = FaultContext {
594                        pack_id: ctx.pack_id,
595                        flow_id: ctx.flow_id,
596                        node_id: ctx.node_id,
597                        attempt: ctx.attempt,
598                    };
599                    maybe_fail(FaultPoint::Timeout, fault_ctx)
600                        .map_err(|err| anyhow!(err.to_string()))?;
601                }
602                match self.execute_once(&ctx, original_input.clone()).await {
603                    Ok(value) => return Ok(value),
604                    Err(err) => {
605                        if attempt >= retry_config.max_attempts || !should_retry(&err) {
606                            // User-facing session flows surface the terminal
607                            // error as a metadata-only Ok envelope so the
608                            // messaging provider renders it instead of leaking
609                            // raw engine text to the chat.
610                            if ctx.session_id.is_some() {
611                                return Ok(FlowExecution::completed(json!({
612                                    "metadata": {
613                                        "error_kind": "flow_execution_failed",
614                                        "error_message": err.to_string(),
615                                        "flow_id": ctx.flow_id,
616                                    }
617                                })));
618                            }
619                            return Err(err);
620                        }
621                        let delay = backoff_delay_ms(retry_config.base_delay_ms, attempt - 1);
622                        tracing::warn!(
623                            tenant = ctx.tenant,
624                            flow_id = ctx.flow_id,
625                            attempt,
626                            max_attempts = retry_config.max_attempts,
627                            delay_ms = delay,
628                            error = %err,
629                            "transient flow execution failure, backing off"
630                        );
631                        tokio::time::sleep(Duration::from_millis(delay)).await;
632                    }
633                }
634            }
635        }
636        .instrument(span)
637        .await;
638        let status = if result.is_ok() { "ok" } else { "err" };
639        let duration_ms = started.elapsed().as_secs_f64() * 1000.0;
640        crate::metrics::record_flow_execution(&metric_tenant, &metric_flow_id, status, duration_ms);
641        result
642    }
643
644    pub async fn resume(
645        &self,
646        ctx: FlowContext<'_>,
647        snapshot: FlowSnapshot,
648        input: Value,
649    ) -> Result<FlowExecution> {
650        if snapshot.pack_id != ctx.pack_id {
651            bail!(
652                "snapshot pack {} does not match requested {}",
653                snapshot.pack_id,
654                ctx.pack_id
655            );
656        }
657        let resume_flow = snapshot
658            .next_flow
659            .clone()
660            .unwrap_or_else(|| snapshot.flow_id.clone());
661        let flow_ir = self.get_or_load_flow(ctx.pack_id, &resume_flow).await?;
662        let mut state = snapshot.state;
663        // Replace BOTH `input` AND `entry` with the new activity. The
664        // routing context (built by `build_routing_context`) reads
665        // `entry.input.metadata.*` for the synthesised `response.*` fields
666        // that conditional routes test against — keeping the snapshot's
667        // stale entry would make `response.action` perpetually empty and
668        // every condition fail, looping the user back to the wait point
669        // forever. `replace_input` only touches `state.input`, so we have
670        // to refresh `entry` ourselves; `ensure_entry` is a no-op once
671        // entry is non-null.
672        state.replace_input(input.clone());
673        state.entry = input;
674        let span = self.flow_execute_span(&ctx);
675        self.drive_flow(&ctx, flow_ir, state, Some(snapshot.next_node), resume_flow)
676            .instrument(span)
677            .await
678    }
679
680    async fn execute_once(&self, ctx: &FlowContext<'_>, input: Value) -> Result<FlowExecution> {
681        let flow_ir = self.get_or_load_flow(ctx.pack_id, ctx.flow_id).await?;
682        let state = ExecutionState::new(input);
683        self.drive_flow(ctx, flow_ir, state, None, ctx.flow_id.to_string())
684            .await
685    }
686
687    async fn drive_flow(
688        &self,
689        ctx: &FlowContext<'_>,
690        mut flow_ir: HostFlow,
691        mut state: ExecutionState,
692        resume_from: Option<String>,
693        mut current_flow_id: String,
694    ) -> Result<FlowExecution> {
695        let mut current = match resume_from {
696            Some(node) => NodeId::from_str(&node)
697                .with_context(|| format!("invalid resume node id `{node}`"))?,
698            None => flow_ir
699                .start
700                .clone()
701                .or_else(|| flow_ir.nodes.keys().next().cloned())
702                .with_context(|| format!("flow {} has no start node", flow_ir.id))?,
703        };
704
705        loop {
706            let step_ctx = FlowContext {
707                tenant: ctx.tenant,
708                pack_id: ctx.pack_id,
709                flow_id: current_flow_id.as_str(),
710                node_id: ctx.node_id,
711                tool: ctx.tool,
712                action: ctx.action,
713                session_id: ctx.session_id,
714                provider_id: ctx.provider_id,
715                reply_scope: ctx.reply_scope,
716                retry_config: ctx.retry_config,
717                attempt: ctx.attempt,
718                observer: ctx.observer,
719                mocks: ctx.mocks,
720            };
721            let node = flow_ir
722                .nodes
723                .get(&current)
724                .with_context(|| format!("node {} not found", current.as_str()))?;
725
726            let payload_template = node.payload_expr.clone();
727            let prev = state
728                .last_output
729                .as_ref()
730                .cloned()
731                .unwrap_or_else(|| Value::Object(JsonMap::new()));
732            let ctx_value = template_context(&state, prev);
733            #[cfg(feature = "fault-injection")]
734            {
735                let fault_ctx = FaultContext {
736                    pack_id: ctx.pack_id,
737                    flow_id: ctx.flow_id,
738                    node_id: Some(current.as_str()),
739                    attempt: ctx.attempt,
740                };
741                maybe_fail(FaultPoint::TemplateRender, fault_ctx)
742                    .map_err(|err| anyhow!(err.to_string()))?;
743            }
744            let mut payload =
745                render_template_value(&payload_template, &ctx_value, TemplateOptions::default())
746                    .context("failed to render node input template")?;
747            let node_id = current.clone();
748
749            // Phase D: inject flow-level slot_schema as slot_definitions into
750            // the slot-extractor's input when the author omitted inline
751            // definitions. Explicit inline `slot_definitions` win
752            // (back-compat with M2.4 NDA demo).
753            if let NodeKind::Exec { target_component } = &node.kind
754                && target_component == SLOT_EXTRACTOR_COMPONENT_ID
755                && let Some(schema) = flow_ir.slot_schema.as_ref()
756                && let Some(map) = payload.as_object_mut()
757            {
758                let input = map.entry("input").or_insert(Value::Null);
759                inject_slot_definitions(input, schema, step_ctx.flow_id, node_id.as_str());
760            }
761
762            let observed_payload = payload.clone();
763            let event = NodeEvent {
764                context: &step_ctx,
765                node_id: node_id.as_str(),
766                node,
767                payload: &observed_payload,
768            };
769            if let Some(observer) = step_ctx.observer {
770                observer.on_node_start(&event);
771            }
772            let dispatch = self
773                .dispatch_node(
774                    &step_ctx,
775                    node_id.as_str(),
776                    node,
777                    &mut state,
778                    payload,
779                    &event,
780                )
781                .await;
782            let DispatchOutcome { output, control } = match dispatch {
783                Ok(outcome) => outcome,
784                Err(err) => {
785                    if let Some(observer) = step_ctx.observer {
786                        observer.on_node_error(&event, err.as_ref());
787                    }
788                    // Propagate so `execute()`'s retry loop can retry transient
789                    // failures, then convert to a metadata-only Ok envelope at
790                    // the top level once retries are exhausted (session flows).
791                    return Err(err);
792                }
793            };
794
795            state.nodes.insert(node_id.clone().into(), output.clone());
796            state.last_output = Some(output.payload.clone());
797            if let Some(observer) = step_ctx.observer {
798                observer.on_node_end(&event, &output.payload);
799            }
800
801            match control {
802                NodeControl::Continue => {
803                    enum NextDecision {
804                        Next(NodeId),
805                        End,
806                        Wait,
807                    }
808                    let decision = match &node.routing {
809                        Routing::Next { node_id } => NextDecision::Next(node_id.clone()),
810                        Routing::End | Routing::Reply => NextDecision::End,
811                        Routing::Branch { default, .. } => match default {
812                            Some(target) => NextDecision::Next(target.clone()),
813                            None => NextDecision::End,
814                        },
815                        Routing::Custom(raw) => {
816                            match evaluate_custom_routing(raw, &output, &state, &flow_ir, &node_id)
817                            {
818                                CustomRoutingDecision::Next(nid) => NextDecision::Next(nid),
819                                CustomRoutingDecision::End => NextDecision::End,
820                                CustomRoutingDecision::Wait => NextDecision::Wait,
821                            }
822                        }
823                    };
824
825                    match decision {
826                        NextDecision::Next(n) => current = n,
827                        NextDecision::End => {
828                            let nodes_snapshot = state.nodes.clone();
829                            let final_output = state.finalize_with(Some(output.payload.clone()));
830                            return Ok(FlowExecution::completed(lift_first_node_error_from_nodes(
831                                final_output,
832                                &nodes_snapshot,
833                            )));
834                        }
835                        NextDecision::Wait => {
836                            // Conditional routing fell through. Pause at the
837                            // current node so the next inbound activity
838                            // resumes here and re-evaluates this node's
839                            // routing with the user's new submit payload.
840                            let mut snapshot_state = state.clone();
841                            snapshot_state.clear_egress();
842                            let snapshot = FlowSnapshot {
843                                pack_id: step_ctx.pack_id.to_string(),
844                                flow_id: step_ctx.flow_id.to_string(),
845                                next_flow: (current_flow_id != step_ctx.flow_id)
846                                    .then_some(current_flow_id.clone()),
847                                next_node: node_id.as_str().to_string(),
848                                state: snapshot_state,
849                            };
850                            let output_value = state.finalize_with(Some(output.payload.clone()));
851                            return Ok(FlowExecution::waiting(
852                                output_value,
853                                FlowWait {
854                                    reason: Some(format!(
855                                        "awaiting user submit at node `{}`",
856                                        node_id.as_str()
857                                    )),
858                                    snapshot,
859                                },
860                            ));
861                        }
862                    }
863                }
864                NodeControl::Wait { reason } => {
865                    let next: Option<NodeId> = match &node.routing {
866                        Routing::Next { node_id } => Some(node_id.clone()),
867                        Routing::End | Routing::Reply => None,
868                        Routing::Branch { default, .. } => default.clone(),
869                        Routing::Custom(raw) => {
870                            match evaluate_custom_routing(raw, &output, &state, &flow_ir, &node_id)
871                            {
872                                CustomRoutingDecision::Next(nid) => Some(nid),
873                                // session.wait operator must have an
874                                // explicit forward target — both End and
875                                // Wait decisions collapse to "no next" and
876                                // surface the same error below.
877                                CustomRoutingDecision::End | CustomRoutingDecision::Wait => None,
878                            }
879                        }
880                    };
881                    let resume_target = next.ok_or_else(|| {
882                        anyhow!(
883                            "session.wait node {} requires a non-empty route",
884                            current.as_str()
885                        )
886                    })?;
887                    let mut snapshot_state = state.clone();
888                    snapshot_state.clear_egress();
889                    let snapshot = FlowSnapshot {
890                        pack_id: step_ctx.pack_id.to_string(),
891                        flow_id: step_ctx.flow_id.to_string(),
892                        next_flow: (current_flow_id != step_ctx.flow_id)
893                            .then_some(current_flow_id.clone()),
894                        next_node: resume_target.as_str().to_string(),
895                        state: snapshot_state,
896                    };
897                    let output_value = state.clone().finalize_with(None);
898                    return Ok(FlowExecution::waiting(
899                        output_value,
900                        FlowWait { reason, snapshot },
901                    ));
902                }
903                NodeControl::Jump(jump) => {
904                    let jump_target = self.apply_jump(&step_ctx, &mut state, jump).await?;
905                    flow_ir = jump_target.flow;
906                    current_flow_id = jump_target.flow_id;
907                    current = jump_target.node_id;
908                }
909                NodeControl::Respond {
910                    text,
911                    card_cbor,
912                    needs_user,
913                } => {
914                    let response = json!({
915                        "text": text,
916                        "card_cbor": card_cbor,
917                        "needs_user": needs_user,
918                    });
919                    state.push_egress(response);
920                    let nodes_snapshot = state.nodes.clone();
921                    let final_output = state.finalize_with(None);
922                    return Ok(FlowExecution::completed(lift_first_node_error_from_nodes(
923                        final_output,
924                        &nodes_snapshot,
925                    )));
926                }
927            }
928        }
929    }
930
931    async fn dispatch_node(
932        &self,
933        ctx: &FlowContext<'_>,
934        node_id: &str,
935        node: &HostNode,
936        state: &mut ExecutionState,
937        mut payload: Value,
938        event: &NodeEvent<'_>,
939    ) -> Result<DispatchOutcome> {
940        inject_card_locale(&mut payload, &state.entry);
941        inject_card_route(&mut payload, &state.entry, node);
942        match &node.kind {
943            NodeKind::Exec { target_component } => self
944                .execute_component_exec(
945                    ctx,
946                    node_id,
947                    node,
948                    payload,
949                    event,
950                    ComponentOverrides {
951                        component: Some(target_component.as_str()),
952                        operation: node.operation_name.as_deref(),
953                    },
954                )
955                .await
956                .and_then(component_dispatch_outcome),
957            NodeKind::PackComponent { component_ref } => self
958                .execute_component_call(ctx, node_id, node, payload, component_ref.as_str(), event)
959                .await
960                .and_then(component_dispatch_outcome),
961            NodeKind::FlowCall => self
962                .execute_flow_call(ctx, payload)
963                .await
964                .map(DispatchOutcome::complete),
965            NodeKind::ProviderInvoke => self
966                .execute_provider_invoke(ctx, node_id, state, payload, event)
967                .await
968                .map(DispatchOutcome::complete),
969            NodeKind::BuiltinEmit { kind } => {
970                match kind {
971                    EmitKind::Log | EmitKind::Response => {}
972                    EmitKind::Other(component) => {
973                        tracing::debug!(%component, "handling emit.* as builtin");
974                    }
975                }
976                state.push_egress(payload.clone());
977                Ok(DispatchOutcome::complete(NodeOutput::new(payload)))
978            }
979            NodeKind::BuiltinStateGet => self
980                .execute_state_get(ctx, payload)
981                .await
982                .map(DispatchOutcome::complete),
983            NodeKind::BuiltinStateSet => self
984                .execute_state_set(ctx, payload)
985                .await
986                .map(DispatchOutcome::complete),
987            NodeKind::Wait => {
988                let reason = extract_wait_reason(&payload);
989                Ok(DispatchOutcome::wait(NodeOutput::new(payload), reason))
990            }
991            NodeKind::DwAgent { agent_id } => {
992                #[cfg(feature = "agentic-worker")]
993                match self.dw_agent_dispatch {
994                    crate::runner::agent_node::DwAgentDispatch::Nats => {
995                        // Reroute to the durable out-of-process agentic path.
996                        // Wrap the raw node payload as the dispatch `input` (the
997                        // serve invoker reads `input.user_text`); `await=true` →
998                        // pause+resume, identical to `agentic.call`.
999                        let remote_payload = serde_json::json!({ "await": true, "input": payload });
1000                        self.execute_remote_dispatch(ctx, "agentic", agent_id, remote_payload)
1001                            .await
1002                    }
1003                    crate::runner::agent_node::DwAgentDispatch::InProcess => self
1004                        .execute_dw_agent(ctx, agent_id, payload)
1005                        .await
1006                        .map(DispatchOutcome::complete),
1007                }
1008                #[cfg(not(feature = "agentic-worker"))]
1009                self.execute_dw_agent(ctx, agent_id, payload)
1010                    .await
1011                    .map(DispatchOutcome::complete)
1012            }
1013            NodeKind::DwAgentGraph { graph_id } => self
1014                .execute_dw_agent_graph(ctx, graph_id, payload)
1015                .await
1016                .map(DispatchOutcome::complete),
1017            NodeKind::SorlaCall { target } => self.execute_sorla_call(ctx, target, payload).await,
1018            NodeKind::OperalaCall { target } => {
1019                self.execute_operala_call(ctx, target, payload).await
1020            }
1021            NodeKind::AgenticCall { target } => {
1022                self.execute_agentic_call(ctx, target, payload).await
1023            }
1024            NodeKind::TelcoXCall { target } => {
1025                self.execute_telco_x_call(ctx, target, payload).await
1026            }
1027            NodeKind::ApprovalCall { target } => {
1028                self.execute_approval_call(ctx, target, payload).await
1029            }
1030            NodeKind::Mcp { server_id, tool } => self
1031                .execute_mcp(ctx, server_id, tool, payload)
1032                .await
1033                .map(DispatchOutcome::complete),
1034        }
1035    }
1036
1037    #[cfg(feature = "agentic-worker")]
1038    async fn execute_dw_agent(
1039        &self,
1040        ctx: &FlowContext<'_>,
1041        agent_id: &str,
1042        payload: Value,
1043    ) -> Result<NodeOutput> {
1044        let handler = self
1045            .agent_node_handler
1046            .as_ref()
1047            .context("DwAgent node dispatched but no AgentNodeHandler configured on FlowEngine")?;
1048        let session_id = ctx.session_id.unwrap_or("");
1049        let result = handler
1050            .execute(
1051                ctx.tenant,
1052                &self.default_env,
1053                agent_id,
1054                session_id,
1055                &payload,
1056            )
1057            .await?;
1058        Ok(NodeOutput::new(result))
1059    }
1060
1061    #[cfg(not(feature = "agentic-worker"))]
1062    async fn execute_dw_agent(
1063        &self,
1064        _ctx: &FlowContext<'_>,
1065        agent_id: &str,
1066        _payload: Value,
1067    ) -> Result<NodeOutput> {
1068        anyhow::bail!(
1069            "DwAgent node '{agent_id}' cannot run: this build was compiled without the \
1070             `agentic-worker` feature. Rebuild with --features agentic-worker."
1071        )
1072    }
1073
1074    /// Dispatch a `sorla.call` flow node to the configured
1075    /// [`RemoteDispatchHandler`], publishing the work to a separate runtime.
1076    ///
1077    /// Input payload contract (JSON):
1078    /// `{ "await": bool (default true), "operation": str, "deadline_ms": u64?,
1079    ///    "input": any }`.
1080    ///
1081    /// The correlation id is the canonical session hint (`ctx.session_id`)
1082    /// suffixed with `::pack=<pack_id>::flow=<flow_id>` markers. The bare hint
1083    /// already encodes the conversation; the markers let the resume path
1084    /// (`RuntimeSessionResumer`) route the response back to a registered
1085    /// `(pack_id, flow_id)` and re-derive the store key. The markers are the
1086    /// exact inverse of the resumer's parsing (`::flow=` then `::pack=`,
1087    /// split off the trailing end).
1088    ///
1089    /// - `await=true`  -> publish + PAUSE the flow ([`DispatchOutcome::wait`]).
1090    /// - `await=false` -> publish + complete immediately with
1091    ///   `{ "dispatched": true, "correlation_id": <marked hint> }`.
1092    ///
1093    /// [`RemoteDispatchHandler`]: crate::runner::remote_dispatch::RemoteDispatchHandler
1094    async fn execute_sorla_call(
1095        &self,
1096        ctx: &FlowContext<'_>,
1097        target: &str,
1098        payload: Value,
1099    ) -> Result<DispatchOutcome> {
1100        self.execute_remote_dispatch(ctx, "sorla", target, payload)
1101            .await
1102    }
1103
1104    /// Dispatch an `operala.call` flow node via the shared remote-dispatch seam.
1105    /// Identical to [`execute_sorla_call`] except the runtime name is `"operala"`.
1106    async fn execute_operala_call(
1107        &self,
1108        ctx: &FlowContext<'_>,
1109        target: &str,
1110        payload: Value,
1111    ) -> Result<DispatchOutcome> {
1112        self.execute_remote_dispatch(ctx, "operala", target, payload)
1113            .await
1114    }
1115
1116    /// Dispatch an `agentic.call` flow node via the shared remote-dispatch seam.
1117    /// Identical to [`execute_sorla_call`] except the runtime name is `"agentic"`.
1118    /// This is the out-of-process agentic path; the in-process `dw.agent` node
1119    /// is completely separate and untouched.
1120    async fn execute_agentic_call(
1121        &self,
1122        ctx: &FlowContext<'_>,
1123        target: &str,
1124        payload: Value,
1125    ) -> Result<DispatchOutcome> {
1126        self.execute_remote_dispatch(ctx, "agentic", target, payload)
1127            .await
1128    }
1129
1130    /// Dispatch a `telco-x.call` flow node via the shared remote-dispatch seam.
1131    /// Mirrors [`execute_operala_call`] with runtime name `"telco-x"`. Wire-ready:
1132    /// no telco-x runtime is deployed yet, so an awaiting node pauses until one is.
1133    async fn execute_telco_x_call(
1134        &self,
1135        ctx: &FlowContext<'_>,
1136        target: &str,
1137        payload: Value,
1138    ) -> Result<DispatchOutcome> {
1139        self.execute_remote_dispatch(ctx, "telco-x", target, payload)
1140            .await
1141    }
1142
1143    /// Dispatch an `approval.call` flow node. Applies the autonomy gate first:
1144    /// when the gate says a human is NOT required, complete immediately on the
1145    /// `approved` branch WITHOUT creating a pending approval; otherwise dispatch
1146    /// to the `"approval"` runtime over the shared remote-dispatch seam (which
1147    /// durably pauses the flow until the human resolves it).
1148    async fn execute_approval_call(
1149        &self,
1150        ctx: &FlowContext<'_>,
1151        target: &str,
1152        payload: Value,
1153    ) -> Result<DispatchOutcome> {
1154        let input = payload.get("input").cloned().unwrap_or(Value::Null);
1155        if !approval_requires_human(&input) {
1156            // Match the shape the resume path injects ({ok, output, error})
1157            // so downstream conditions read the decision at the same
1158            // relative path regardless of whether a human was involved.
1159            let output = NodeOutput::new(serde_json::json!({
1160                "ok": true,
1161                "output": { "decision": "approved", "auto": true },
1162                "error": serde_json::Value::Null,
1163            }));
1164            return Ok(DispatchOutcome::complete(output));
1165        }
1166        self.execute_remote_dispatch(ctx, "approval", target, payload)
1167            .await
1168    }
1169
1170    /// Execute a `component == "mcp"` flow node (LOCKED ENCODING v2).
1171    ///
1172    /// `payload` is the already-rendered node input mapping (the engine
1173    /// templates `{{ }}` against flow state before dispatch), shaped
1174    /// `{ "server": <id>, "tool": <name>, "arguments": <object>,
1175    ///    "output": <optional string state key> }`.
1176    ///
1177    /// `server`/`tool` are sourced from this payload (the source of truth);
1178    /// the `server_id`/`tool` parsed at flow-load time are passed in only as a
1179    /// fallback for the legacy `operation = "<server>/<tool>"` encoding. The MCP
1180    /// tool is invoked through the tenant's `flow_editor` catalog (reusing
1181    /// `greentic-aw-runtime`'s `McpToolSource`); the result value is bound under
1182    /// `output` when present, else returned as the node payload.
1183    ///
1184    /// Graceful by contract: MCP being unconfigured or the tool being
1185    /// unreachable yields a structured `{"error": ...}` value — never a panic,
1186    /// never an aborted runtime.
1187    #[cfg(feature = "agentic-worker")]
1188    async fn execute_mcp(
1189        &self,
1190        ctx: &FlowContext<'_>,
1191        server_id: &str,
1192        tool: &str,
1193        payload: Value,
1194    ) -> Result<NodeOutput> {
1195        // Payload is the source of truth: prefer the rendered `server`/`tool`
1196        // from config, falling back to the values resolved at flow-load time
1197        // (legacy `operation`/`mcp:` encoding).
1198        let payload_server = crate::runner::mcp_node::str_field(&payload, "server");
1199        let payload_tool = crate::runner::mcp_node::str_field(&payload, "tool");
1200        let server_id = payload_server.as_deref().unwrap_or(server_id);
1201        let tool = payload_tool.as_deref().unwrap_or(tool);
1202
1203        // `arguments` defaults to `{}` so a no-arg tool needs no config.
1204        let arguments = payload
1205            .get("arguments")
1206            .cloned()
1207            .unwrap_or_else(|| Value::Object(JsonMap::new()));
1208
1209        let result = crate::runner::mcp_node::invoke(
1210            self.mcp_tool_source.as_ref(),
1211            ctx.tenant,
1212            &self.default_env,
1213            server_id,
1214            tool,
1215            &arguments,
1216        )
1217        .await;
1218
1219        // Bind the result under the optional `output` state key. When absent,
1220        // the raw tool result becomes the node payload (still addressable via
1221        // the standard `node.<id>.payload` mechanism).
1222        let bound = match payload.get("output").and_then(Value::as_str) {
1223            Some(key) if !key.is_empty() => json!({ key: result }),
1224            _ => result,
1225        };
1226        Ok(NodeOutput::new(bound))
1227    }
1228
1229    /// Compile-time stub for the MCP flow node when the agentic-worker feature
1230    /// (which carries the MCP runtime deps) is disabled. The node degrades to a
1231    /// clear error value rather than failing the build or the run.
1232    #[cfg(not(feature = "agentic-worker"))]
1233    async fn execute_mcp(
1234        &self,
1235        _ctx: &FlowContext<'_>,
1236        server_id: &str,
1237        tool: &str,
1238        _payload: Value,
1239    ) -> Result<NodeOutput> {
1240        Ok(NodeOutput::new(json!({
1241            "error": format!(
1242                "mcp node '{server_id}/{tool}' requires the agentic-worker feature (MCP runtime not compiled in)"
1243            )
1244        })))
1245    }
1246
1247    /// Shared body for all native remote-dispatch flow nodes (`sorla.call`,
1248    /// `operala.call`, `agentic.call`). Routes through the injected
1249    /// [`RemoteDispatchHandler`] with the given `runtime` name.
1250    ///
1251    /// Input payload contract (JSON):
1252    /// `{ "await": bool (default true), "operation": str, "deadline_ms": u64?,
1253    ///    "input": any }`.
1254    ///
1255    /// The correlation id is the canonical session hint (`ctx.session_id`)
1256    /// suffixed with `::pack=<pack_id>::flow=<flow_id>` markers so the resume
1257    /// path (`RuntimeSessionResumer`) can route the response back.
1258    ///
1259    /// - `await=true`  -> publish + PAUSE the flow ([`DispatchOutcome::wait`]).
1260    /// - `await=false` -> publish + complete immediately with
1261    ///   `{ "dispatched": true, "correlation_id": <marked hint> }`.
1262    ///
1263    /// [`RemoteDispatchHandler`]: crate::runner::remote_dispatch::RemoteDispatchHandler
1264    async fn execute_remote_dispatch(
1265        &self,
1266        ctx: &FlowContext<'_>,
1267        runtime: &str,
1268        target: &str,
1269        payload: Value,
1270    ) -> Result<DispatchOutcome> {
1271        let handler = self.remote_dispatch_handler.as_ref().with_context(|| {
1272            format!("{runtime}.call node dispatched but no RemoteDispatchHandler configured")
1273        })?;
1274
1275        let await_mode = payload
1276            .get("await")
1277            .and_then(Value::as_bool)
1278            .unwrap_or(true);
1279        let operation = payload
1280            .get("operation")
1281            .and_then(Value::as_str)
1282            .unwrap_or_default()
1283            .to_string();
1284        let deadline_ms = payload.get("deadline_ms").and_then(Value::as_u64);
1285        let inner_input = payload.get("input").cloned().unwrap_or(Value::Null);
1286
1287        // The resume path (`RuntimeSessionResumer`) recovers `pack_id` and
1288        // `flow_id` from `::pack=`/`::flow=` markers on the correlation id to
1289        // route the synthesized resume envelope, then strips them to recover the
1290        // bare canonical hint used as the store key. So the published
1291        // correlation id MUST carry those markers and preserve the bare hint.
1292        //
1293        // Bare canonical hint = everything before the first `::` marker. This is
1294        // robust whether `ctx.session_id` is already bare (the production case)
1295        // or has accreted a marker.
1296        let raw_hint = ctx.session_id.unwrap_or_default();
1297        let bare_hint = raw_hint.split("::").next().unwrap_or_default();
1298        // The store key (`FlowResumeStore::save`) hashes the inbound reply
1299        // scope's `conversation`/`thread`/`reply_to`. The bare canonical hint
1300        // only encodes `conversation`, so a wait saved against a non-empty
1301        // `thread`/`reply_to` would be un-keyable on resume. Append OPAQUE
1302        // `::thread=`/`::reply=` markers so `RuntimeSessionResumer` can rebuild
1303        // the EXACT reply scope and recompute the same `scope_hash`. The remote
1304        // bridge echoes the correlation verbatim, so this needs no bridge change.
1305        // Markers are omitted when their value is empty (back-compat with the
1306        // no-thread case).
1307        let mut correlation_id =
1308            format!("{}::pack={}::flow={}", bare_hint, ctx.pack_id, ctx.flow_id);
1309        if let Some(scope) = ctx.reply_scope {
1310            if let Some(thread) = scope.thread.as_deref().filter(|value| !value.is_empty()) {
1311                correlation_id.push_str("::thread=");
1312                correlation_id.push_str(thread);
1313            }
1314            if let Some(reply_to) = scope.reply_to.as_deref().filter(|value| !value.is_empty()) {
1315                correlation_id.push_str("::reply=");
1316                correlation_id.push_str(reply_to);
1317            }
1318        }
1319        let mode = if await_mode {
1320            greentic_types::DispatchMode::Await
1321        } else {
1322            greentic_types::DispatchMode::FireAndForget
1323        };
1324
1325        let action = handler
1326            .dispatch(crate::runner::remote_dispatch::RemoteDispatch {
1327                tenant: ctx.tenant.to_string(),
1328                env: self.default_env.clone(),
1329                runtime: runtime.to_string(),
1330                target: target.to_string(),
1331                operation,
1332                mode,
1333                correlation_id: correlation_id.clone(),
1334                input: inner_input,
1335                deadline_ms,
1336            })
1337            .await?;
1338
1339        match action {
1340            crate::runner::remote_dispatch::RemoteDispatchAction::AwaitingResponse {
1341                correlation_id,
1342            } => {
1343                let reason = format!("await-runtime:{correlation_id}");
1344                let output = NodeOutput::new(serde_json::json!({
1345                    "pending": true,
1346                    "correlation_id": correlation_id,
1347                }));
1348                Ok(DispatchOutcome::wait(output, Some(reason)))
1349            }
1350            crate::runner::remote_dispatch::RemoteDispatchAction::Dispatched => {
1351                let output = NodeOutput::new(serde_json::json!({
1352                    "dispatched": true,
1353                    "correlation_id": correlation_id,
1354                }));
1355                Ok(DispatchOutcome::complete(output))
1356            }
1357        }
1358    }
1359
1360    /// Dispatch a `DwAgentGraph` flow node to the configured
1361    /// [`GraphNodeHandler`]. Mirrors [`execute_dw_agent`]: same tenant/env/
1362    /// session-id derivation, same envelope, same "handler not configured"
1363    /// error path.
1364    ///
1365    /// [`execute_dw_agent`]: FlowEngine::execute_dw_agent
1366    #[cfg(feature = "agentic-worker")]
1367    async fn execute_dw_agent_graph(
1368        &self,
1369        ctx: &FlowContext<'_>,
1370        graph_id: &str,
1371        payload: Value,
1372    ) -> Result<NodeOutput> {
1373        let handler = self.graph_node_handler.as_ref().context(
1374            "DwAgentGraph node dispatched but no GraphNodeHandler configured on FlowEngine",
1375        )?;
1376        let session_id = ctx.session_id.unwrap_or("");
1377        let result = handler
1378            .execute(
1379                ctx.tenant,
1380                &self.default_env,
1381                graph_id,
1382                session_id,
1383                &payload,
1384            )
1385            .await?;
1386        Ok(NodeOutput::new(result))
1387    }
1388
1389    #[cfg(not(feature = "agentic-worker"))]
1390    async fn execute_dw_agent_graph(
1391        &self,
1392        _ctx: &FlowContext<'_>,
1393        graph_id: &str,
1394        _payload: Value,
1395    ) -> Result<NodeOutput> {
1396        anyhow::bail!(
1397            "DwAgentGraph node '{graph_id}' cannot run: this build was compiled without the \
1398             `agentic-worker` feature. Rebuild with --features agentic-worker."
1399        )
1400    }
1401
1402    async fn execute_state_get(&self, ctx: &FlowContext<'_>, payload: Value) -> Result<NodeOutput> {
1403        let key = Self::extract_state_key_helper(&payload)?;
1404        let pack = self.pack_for_flow(ctx)?;
1405        let store = pack
1406            .state_store_handle()
1407            .context("state store is not configured for this runtime")?;
1408        let tenant_ctx = self.state_tenant_ctx(ctx)?;
1409        let state_key = greentic_state::StateKey::new(&key);
1410        let value = store
1411            .get_json(
1412                &tenant_ctx,
1413                crate::storage::state::STATE_PREFIX,
1414                &state_key,
1415                None,
1416            )
1417            .with_context(|| format!("state.get failed for key `{key}`"))?;
1418        let payload = serde_json::json!({
1419            "key": key,
1420            "value": value,
1421            "found": value.is_some(),
1422        });
1423        Ok(NodeOutput::new(payload))
1424    }
1425
1426    async fn execute_state_set(&self, ctx: &FlowContext<'_>, payload: Value) -> Result<NodeOutput> {
1427        let key = Self::extract_state_key_helper(&payload)?;
1428        let value = payload.get("value").cloned().unwrap_or(Value::Null);
1429        let pack = self.pack_for_flow(ctx)?;
1430        let store = pack
1431            .state_store_handle()
1432            .context("state store is not configured for this runtime")?;
1433        let tenant_ctx = self.state_tenant_ctx(ctx)?;
1434        let state_key = greentic_state::StateKey::new(&key);
1435        store
1436            .set_json(
1437                &tenant_ctx,
1438                crate::storage::state::STATE_PREFIX,
1439                &state_key,
1440                None,
1441                &value,
1442                None,
1443            )
1444            .with_context(|| format!("state.set failed for key `{key}`"))?;
1445        let payload = serde_json::json!({ "key": key, "value": value });
1446        Ok(NodeOutput::new(payload))
1447    }
1448
1449    fn pack_for_flow(&self, ctx: &FlowContext<'_>) -> Result<&Arc<PackRuntime>> {
1450        let key = FlowKey {
1451            pack_id: ctx.pack_id.to_string(),
1452            flow_id: ctx.flow_id.to_string(),
1453        };
1454        let idx = self.flow_sources.get(&key).with_context(|| {
1455            format!("flow {} (pack {}) not registered", ctx.flow_id, ctx.pack_id)
1456        })?;
1457        Ok(&self.packs[*idx])
1458    }
1459
1460    fn extract_state_key_helper(payload: &Value) -> Result<String> {
1461        payload
1462            .get("key")
1463            .and_then(Value::as_str)
1464            .map(String::from)
1465            .filter(|k| !k.is_empty())
1466            .context("state node payload missing required `key` (non-empty string)")
1467    }
1468
1469    fn state_tenant_ctx(&self, ctx: &FlowContext<'_>) -> Result<greentic_types::TenantCtx> {
1470        let env = greentic_types::EnvId::from_str(&self.default_env)
1471            .with_context(|| format!("invalid env id `{}`", self.default_env))?;
1472        let tenant = greentic_types::TenantId::from_str(ctx.tenant)
1473            .with_context(|| format!("invalid tenant id `{}`", ctx.tenant))?;
1474        Ok(greentic_types::TenantCtx::new(env, tenant))
1475    }
1476
1477    async fn apply_jump(
1478        &self,
1479        ctx: &FlowContext<'_>,
1480        state: &mut ExecutionState,
1481        jump: JumpControl,
1482    ) -> Result<JumpTarget> {
1483        let target_flow = jump.flow.trim();
1484        if target_flow.is_empty() {
1485            bail!("missing_flow");
1486        }
1487
1488        let flow = self
1489            .get_or_load_flow(ctx.pack_id, target_flow)
1490            .await
1491            .with_context(|| format!("unknown_flow:{target_flow}"))?;
1492
1493        let target_node = if let Some(node) = jump.node.as_deref() {
1494            let parsed = NodeId::from_str(node).with_context(|| format!("unknown_node:{node}"))?;
1495            if !flow.nodes.contains_key(&parsed) {
1496                bail!("unknown_node:{node}");
1497            }
1498            parsed
1499        } else {
1500            flow.start
1501                .clone()
1502                .or_else(|| flow.nodes.keys().next().cloned())
1503                .ok_or_else(|| anyhow!("jump_failed: flow {target_flow} has no start node"))?
1504        };
1505
1506        let max_redirects = jump.max_redirects.unwrap_or(3);
1507        if state.redirect_count() >= max_redirects {
1508            bail!("redirect_limit");
1509        }
1510        state.increment_redirect_count();
1511        state.replace_input(jump.payload.clone());
1512        state.last_output = Some(jump.payload);
1513        tracing::info!(
1514            flow_id = %ctx.flow_id,
1515            target_flow = %target_flow,
1516            target_node = %target_node.as_str(),
1517            reason = ?jump.reason,
1518            redirects = state.redirect_count(),
1519            "flow.jump.applied"
1520        );
1521
1522        Ok(JumpTarget {
1523            flow_id: target_flow.to_string(),
1524            flow,
1525            node_id: target_node,
1526        })
1527    }
1528
1529    async fn execute_flow_call(&self, ctx: &FlowContext<'_>, payload: Value) -> Result<NodeOutput> {
1530        #[derive(Deserialize)]
1531        struct FlowCallPayload {
1532            #[serde(alias = "flow")]
1533            flow_id: String,
1534            #[serde(default)]
1535            input: Value,
1536        }
1537
1538        let call: FlowCallPayload =
1539            serde_json::from_value(payload).context("invalid payload for flow.call node")?;
1540        if call.flow_id.trim().is_empty() {
1541            bail!("flow.call requires a non-empty flow_id");
1542        }
1543
1544        let sub_input = if call.input.is_null() {
1545            Value::Null
1546        } else {
1547            call.input
1548        };
1549
1550        let flow_id_owned = call.flow_id;
1551        let action = "flow.call";
1552        let sub_ctx = FlowContext {
1553            tenant: ctx.tenant,
1554            pack_id: ctx.pack_id,
1555            flow_id: flow_id_owned.as_str(),
1556            node_id: None,
1557            tool: ctx.tool,
1558            action: Some(action),
1559            session_id: ctx.session_id,
1560            provider_id: ctx.provider_id,
1561            reply_scope: ctx.reply_scope,
1562            retry_config: ctx.retry_config,
1563            attempt: ctx.attempt,
1564            observer: ctx.observer,
1565            mocks: ctx.mocks,
1566        };
1567
1568        let execution = Box::pin(self.execute(sub_ctx, sub_input))
1569            .await
1570            .with_context(|| format!("flow.call failed for {}", flow_id_owned))?;
1571        match execution.status {
1572            FlowStatus::Completed => Ok(NodeOutput::new(execution.output)),
1573            FlowStatus::Waiting(wait) => bail!(
1574                "flow.call cannot pause (flow {} waiting {:?})",
1575                flow_id_owned,
1576                wait.reason
1577            ),
1578        }
1579    }
1580
1581    async fn execute_component_exec(
1582        &self,
1583        ctx: &FlowContext<'_>,
1584        node_id: &str,
1585        node: &HostNode,
1586        payload: Value,
1587        event: &NodeEvent<'_>,
1588        overrides: ComponentOverrides<'_>,
1589    ) -> Result<NodeOutput> {
1590        #[derive(Deserialize)]
1591        struct ComponentPayload {
1592            #[serde(default, alias = "component_ref", alias = "component")]
1593            component: Option<String>,
1594            #[serde(alias = "op")]
1595            operation: Option<String>,
1596            #[serde(default)]
1597            input: Value,
1598            #[serde(default)]
1599            config: Value,
1600        }
1601
1602        let payload: ComponentPayload =
1603            serde_json::from_value(payload).context("invalid payload for component.exec")?;
1604        let component_ref = overrides
1605            .component
1606            .map(str::to_string)
1607            .or_else(|| payload.component.filter(|v| !v.trim().is_empty()))
1608            .with_context(|| "component.exec requires a component_ref")?;
1609        let operation = resolve_component_operation(
1610            node_id,
1611            node.component_id.as_str(),
1612            payload.operation,
1613            overrides.operation,
1614            node.operation_in_mapping.as_deref(),
1615        )?;
1616
1617        let call = ComponentCall {
1618            component_ref,
1619            operation,
1620            input: payload.input,
1621            config: payload.config,
1622            has_error_route: node_has_error_route(&node.routing),
1623        };
1624
1625        self.invoke_component_call(ctx, node_id, call, event).await
1626    }
1627
1628    async fn execute_component_call(
1629        &self,
1630        ctx: &FlowContext<'_>,
1631        node_id: &str,
1632        node: &HostNode,
1633        payload: Value,
1634        component_ref: &str,
1635        event: &NodeEvent<'_>,
1636    ) -> Result<NodeOutput> {
1637        let payload_operation = extract_operation_from_mapping(&payload);
1638        let (input, config) = split_operation_payload(payload);
1639        let operation = resolve_component_operation(
1640            node_id,
1641            node.component_id.as_str(),
1642            payload_operation,
1643            node.operation_name.as_deref(),
1644            node.operation_in_mapping.as_deref(),
1645        )?;
1646        let call = ComponentCall {
1647            component_ref: component_ref.to_string(),
1648            operation,
1649            input,
1650            config,
1651            has_error_route: node_has_error_route(&node.routing),
1652        };
1653        self.invoke_component_call(ctx, node_id, call, event).await
1654    }
1655
1656    async fn invoke_component_call(
1657        &self,
1658        ctx: &FlowContext<'_>,
1659        node_id: &str,
1660        mut call: ComponentCall,
1661        event: &NodeEvent<'_>,
1662    ) -> Result<NodeOutput> {
1663        self.validate_component(ctx, event, &call)?;
1664        let key = FlowKey {
1665            pack_id: ctx.pack_id.to_string(),
1666            flow_id: ctx.flow_id.to_string(),
1667        };
1668        let pack_idx = *self.flow_sources.get(&key).with_context(|| {
1669            format!("flow {} (pack {}) not registered", ctx.flow_id, ctx.pack_id)
1670        })?;
1671        let pack = Arc::clone(&self.packs[pack_idx]);
1672
1673        // Promote adaptive-card defaults from node config (default_card_asset /
1674        // default_card_inline / default_source) into the invocation, so the
1675        // component receives a valid `card_spec` field even when the user input
1676        // is empty (e.g. webchat ConversationStart with no text). Without this,
1677        // schema validation in the component reports AC_INVOCATION_MISSING_FIELD
1678        // and the renderer falls back to a generic "Welcome" placeholder.
1679        promote_card_config_to_invocation(&mut call.input, &call.config);
1680
1681        // Pre-resolve card asset paths: read JSON files from the pack's assets
1682        // directory and inject as inline_json so the component doesn't need
1683        // WASI filesystem access.
1684        resolve_card_assets(&mut call.input, &pack);
1685
1686        // When the input is a card-like invocation (has card_source/card_spec),
1687        // pass it directly to the component instead of wrapping in an
1688        // InvocationEnvelope.  The envelope serialises the payload field as a
1689        // byte array which the component cannot decode back, and the
1690        // InvocationPayload::parse heuristic strips domain fields when a
1691        // `payload` key is present (e.g.  the card's Handlebars template
1692        // context `payload: {}`).
1693        let is_card = is_card_invocation(&call.input);
1694
1695        let input_json = if is_card {
1696            serde_json::to_string(&call.input)?
1697        } else {
1698            // Runtime owns ctx; flows must not embed ctx, even if they provide envelopes.
1699            let meta = InvocationMeta {
1700                env: &self.default_env,
1701                tenant: ctx.tenant,
1702                flow_id: ctx.flow_id,
1703                node_id: Some(node_id),
1704                provider_id: ctx.provider_id,
1705                session_id: ctx.session_id,
1706                attempt: ctx.attempt,
1707            };
1708            let invocation_envelope =
1709                build_invocation_envelope(meta, call.operation.as_str(), call.input)
1710                    .context("build invocation envelope for component call")?;
1711            serde_json::to_string(&invocation_envelope)?
1712        };
1713        let config_json = if call.config.is_null() {
1714            None
1715        } else {
1716            Some(serde_json::to_string(&call.config)?)
1717        };
1718
1719        let exec_ctx = component_exec_ctx(ctx, node_id);
1720        #[cfg(feature = "fault-injection")]
1721        {
1722            let fault_ctx = FaultContext {
1723                pack_id: ctx.pack_id,
1724                flow_id: ctx.flow_id,
1725                node_id: Some(node_id),
1726                attempt: ctx.attempt,
1727            };
1728            maybe_fail(FaultPoint::BeforeComponentCall, fault_ctx)
1729                .map_err(|err| anyhow!(err.to_string()))?;
1730        }
1731        let value = pack
1732            .invoke_component(
1733                call.component_ref.as_str(),
1734                exec_ctx,
1735                call.operation.as_str(),
1736                config_json,
1737                input_json,
1738            )
1739            .await?;
1740        #[cfg(feature = "fault-injection")]
1741        {
1742            let fault_ctx = FaultContext {
1743                pack_id: ctx.pack_id,
1744                flow_id: ctx.flow_id,
1745                node_id: Some(node_id),
1746                attempt: ctx.attempt,
1747            };
1748            maybe_fail(FaultPoint::AfterComponentCall, fault_ctx)
1749                .map_err(|err| anyhow!(err.to_string()))?;
1750        }
1751
1752        if let Some((code, message)) = component_error(&value) {
1753            // node_io error routing: a node with an `on_error`-family route
1754            // surfaces the failure as an `{errors}` output and lets its error
1755            // branch handle it. Nodes without such a route keep the historical
1756            // hard-fail, so this is purely additive.
1757            if call.has_error_route {
1758                return Ok(NodeOutput::errored(value));
1759            }
1760            bail!(
1761                "component {} failed: {}: {}",
1762                call.component_ref,
1763                code,
1764                message
1765            );
1766        }
1767        // MCP-shaped tool errors (greentic-mcp-generator's tool_error_with_status)
1768        // come back as a top-level `{ "error": { "code", "message", "status" } }`
1769        // value with the WIT envelope still ok=true (because the wasm guest
1770        // returned normally). Treat them the same as a component_error so the
1771        // engine error-envelope lift path surfaces the failure to the user.
1772        if let Some((code, message)) = mcp_tool_error(&value) {
1773            bail!(
1774                "component {} returned tool error: {}: {}",
1775                call.component_ref,
1776                code,
1777                message
1778            );
1779        }
1780        let meta = outcome_meta(&value);
1781        Ok(NodeOutput::with_meta(value, meta))
1782    }
1783
1784    async fn execute_provider_invoke(
1785        &self,
1786        ctx: &FlowContext<'_>,
1787        node_id: &str,
1788        state: &ExecutionState,
1789        payload: Value,
1790        event: &NodeEvent<'_>,
1791    ) -> Result<NodeOutput> {
1792        #[derive(Deserialize)]
1793        struct ProviderPayload {
1794            #[serde(default)]
1795            provider_id: Option<String>,
1796            #[serde(default)]
1797            provider_type: Option<String>,
1798            #[serde(default, alias = "operation")]
1799            op: Option<String>,
1800            #[serde(default)]
1801            input: Value,
1802            #[serde(default)]
1803            in_map: Value,
1804            #[serde(default)]
1805            out_map: Value,
1806            #[serde(default)]
1807            err_map: Value,
1808        }
1809
1810        let payload: ProviderPayload =
1811            serde_json::from_value(payload).context("invalid payload for provider.invoke")?;
1812        let op = payload
1813            .op
1814            .as_deref()
1815            .filter(|v| !v.trim().is_empty())
1816            .with_context(|| "provider.invoke requires an op")?
1817            .to_string();
1818
1819        let prev = state
1820            .last_output
1821            .as_ref()
1822            .cloned()
1823            .unwrap_or_else(|| Value::Object(JsonMap::new()));
1824        let base_ctx = template_context(state, prev);
1825
1826        let input_value = if !payload.in_map.is_null() {
1827            let mut ctx_value = base_ctx.clone();
1828            if let Value::Object(ref mut map) = ctx_value {
1829                map.insert("input".into(), payload.input.clone());
1830                map.insert("result".into(), payload.input.clone());
1831            }
1832            render_template_value(
1833                &payload.in_map,
1834                &ctx_value,
1835                TemplateOptions {
1836                    allow_pointer: true,
1837                },
1838            )
1839            .context("failed to render provider.invoke in_map")?
1840        } else if !payload.input.is_null() {
1841            payload.input
1842        } else {
1843            Value::Null
1844        };
1845        let input_json = serde_json::to_vec(&input_value)?;
1846
1847        self.validate_tool(
1848            ctx,
1849            event,
1850            payload.provider_id.as_deref(),
1851            payload.provider_type.as_deref(),
1852            &op,
1853            &input_value,
1854        )?;
1855
1856        let key = FlowKey {
1857            pack_id: ctx.pack_id.to_string(),
1858            flow_id: ctx.flow_id.to_string(),
1859        };
1860        let pack_idx = *self.flow_sources.get(&key).with_context(|| {
1861            format!("flow {} (pack {}) not registered", ctx.flow_id, ctx.pack_id)
1862        })?;
1863        let pack = Arc::clone(&self.packs[pack_idx]);
1864        let binding = pack.resolve_provider(
1865            payload.provider_id.as_deref(),
1866            payload.provider_type.as_deref(),
1867        );
1868
1869        // If pack-local resolution fails, try the cross-pack resolver (capability registry).
1870        if binding.is_err()
1871            && let Some(output) = self.try_invoke_cross_pack_resolver(
1872                payload.provider_id.as_deref(),
1873                payload.provider_type.as_deref(),
1874                &op,
1875                &input_json,
1876                ctx.tenant,
1877            )?
1878        {
1879            return Ok(output);
1880        }
1881
1882        let binding = binding?;
1883        let exec_ctx = component_exec_ctx(ctx, node_id);
1884        #[cfg(feature = "fault-injection")]
1885        {
1886            let fault_ctx = FaultContext {
1887                pack_id: ctx.pack_id,
1888                flow_id: ctx.flow_id,
1889                node_id: Some(node_id),
1890                attempt: ctx.attempt,
1891            };
1892            maybe_fail(FaultPoint::BeforeToolCall, fault_ctx)
1893                .map_err(|err| anyhow!(err.to_string()))?;
1894        }
1895        let provider_metric_id = payload
1896            .provider_id
1897            .as_deref()
1898            .or(payload.provider_type.as_deref())
1899            .unwrap_or("unknown");
1900        let invoke_started = std::time::Instant::now();
1901        let invoke_result = pack
1902            .invoke_provider(&binding, exec_ctx, &op, input_json)
1903            .await;
1904        let invoke_duration_ms = invoke_started.elapsed().as_secs_f64() * 1000.0;
1905        crate::metrics::record_provider_invocation(
1906            ctx.tenant,
1907            provider_metric_id,
1908            &op,
1909            if invoke_result.is_ok() { "ok" } else { "err" },
1910            invoke_duration_ms,
1911        );
1912        let result = invoke_result?;
1913        #[cfg(feature = "fault-injection")]
1914        {
1915            let fault_ctx = FaultContext {
1916                pack_id: ctx.pack_id,
1917                flow_id: ctx.flow_id,
1918                node_id: Some(node_id),
1919                attempt: ctx.attempt,
1920            };
1921            maybe_fail(FaultPoint::AfterToolCall, fault_ctx)
1922                .map_err(|err| anyhow!(err.to_string()))?;
1923        }
1924
1925        let output = if payload.out_map.is_null() {
1926            result
1927        } else {
1928            let mut ctx_value = base_ctx;
1929            if let Value::Object(ref mut map) = ctx_value {
1930                map.insert("input".into(), result.clone());
1931                map.insert("result".into(), result.clone());
1932            }
1933            render_template_value(
1934                &payload.out_map,
1935                &ctx_value,
1936                TemplateOptions {
1937                    allow_pointer: true,
1938                },
1939            )
1940            .context("failed to render provider.invoke out_map")?
1941        };
1942        let _ = payload.err_map;
1943        Ok(NodeOutput::new(output))
1944    }
1945
1946    fn try_invoke_cross_pack_resolver(
1947        &self,
1948        provider_id: Option<&str>,
1949        provider_type: Option<&str>,
1950        op: &str,
1951        input_json: &[u8],
1952        tenant: &str,
1953    ) -> Result<Option<NodeOutput>> {
1954        eprintln!(
1955            "[DEBUG] provider.invoke: pack-local failed, has_resolver={}",
1956            self.cross_pack_resolver.is_some()
1957        );
1958        let Some(resolver) = self.cross_pack_resolver.as_ref() else {
1959            return Ok(None);
1960        };
1961        let provider_id = provider_id.unwrap_or("unknown");
1962        tracing::info!(
1963            provider_id,
1964            op = %op,
1965            "provider.invoke: pack-local resolution failed, trying cross-pack resolver"
1966        );
1967        let result_value =
1968            resolver.invoke(provider_id, provider_type, op, input_json, tenant, None)?;
1969        Ok(Some(NodeOutput::new(result_value)))
1970    }
1971
1972    fn validate_component(
1973        &self,
1974        ctx: &FlowContext<'_>,
1975        event: &NodeEvent<'_>,
1976        call: &ComponentCall,
1977    ) -> Result<()> {
1978        if self.validation.mode == ValidationMode::Off {
1979            return Ok(());
1980        }
1981        let mut metadata = JsonMap::new();
1982        metadata.insert("tenant_id".to_string(), json!(ctx.tenant));
1983        if let Some(id) = ctx.session_id {
1984            metadata.insert("session".to_string(), json!({ "id": id }));
1985        }
1986        let envelope = json!({
1987            "component_id": call.component_ref,
1988            "operation": call.operation,
1989            "input": call.input,
1990            "config": call.config,
1991            "metadata": Value::Object(metadata),
1992        });
1993        let issues = validate_component_envelope(&envelope);
1994        self.report_validation(ctx, event, "component", issues)
1995    }
1996
1997    fn validate_tool(
1998        &self,
1999        ctx: &FlowContext<'_>,
2000        event: &NodeEvent<'_>,
2001        provider_id: Option<&str>,
2002        provider_type: Option<&str>,
2003        operation: &str,
2004        input: &Value,
2005    ) -> Result<()> {
2006        if self.validation.mode == ValidationMode::Off {
2007            return Ok(());
2008        }
2009        let tool_id = provider_id.or(provider_type).unwrap_or("provider.invoke");
2010        let mut metadata = JsonMap::new();
2011        metadata.insert("tenant_id".to_string(), json!(ctx.tenant));
2012        if let Some(id) = ctx.session_id {
2013            metadata.insert("session".to_string(), json!({ "id": id }));
2014        }
2015        let envelope = json!({
2016            "tool_id": tool_id,
2017            "operation": operation,
2018            "input": input,
2019            "metadata": Value::Object(metadata),
2020        });
2021        let issues = validate_tool_envelope(&envelope);
2022        self.report_validation(ctx, event, "tool", issues)
2023    }
2024
2025    fn report_validation(
2026        &self,
2027        ctx: &FlowContext<'_>,
2028        event: &NodeEvent<'_>,
2029        kind: &str,
2030        issues: Vec<ValidationIssue>,
2031    ) -> Result<()> {
2032        if issues.is_empty() {
2033            return Ok(());
2034        }
2035        if let Some(observer) = ctx.observer {
2036            observer.on_validation(event, &issues);
2037        }
2038        match self.validation.mode {
2039            ValidationMode::Warn => {
2040                tracing::warn!(
2041                    tenant = ctx.tenant,
2042                    flow_id = ctx.flow_id,
2043                    node_id = event.node_id,
2044                    kind,
2045                    issues = ?issues,
2046                    "invocation envelope validation issues"
2047                );
2048                Ok(())
2049            }
2050            ValidationMode::Error => {
2051                tracing::error!(
2052                    tenant = ctx.tenant,
2053                    flow_id = ctx.flow_id,
2054                    node_id = event.node_id,
2055                    kind,
2056                    issues = ?issues,
2057                    "invocation envelope validation failed"
2058                );
2059                bail!("invocation_validation_failed");
2060            }
2061            ValidationMode::Off => Ok(()),
2062        }
2063    }
2064
2065    pub fn flows(&self) -> &[FlowDescriptor] {
2066        &self.flows
2067    }
2068
2069    pub fn flow_by_key(&self, pack_id: &str, flow_id: &str) -> Option<&FlowDescriptor> {
2070        self.flows
2071            .iter()
2072            .find(|descriptor| descriptor.pack_id == pack_id && descriptor.id == flow_id)
2073    }
2074
2075    pub fn flow_by_type(&self, flow_type: &str) -> Option<&FlowDescriptor> {
2076        let mut matches = self
2077            .flows
2078            .iter()
2079            .filter(|descriptor| descriptor.flow_type == flow_type);
2080        let first = matches.next()?;
2081        if matches.next().is_some() {
2082            return None;
2083        }
2084        Some(first)
2085    }
2086
2087    /// Resolve a flow by type, considering only application entrypoint flows.
2088    ///
2089    /// Used to disambiguate an inbound provider event (routed by flow type,
2090    /// with no explicit `pack_id`/`flow_id`) when a pack registers one public
2091    /// entrypoint plus internal helper flows of the same type — the common
2092    /// "dispatcher + sub-flows" shape. Internal flows are only reachable via
2093    /// `flow.call`, so they must never win a type-only route.
2094    ///
2095    /// Flows owned by a messaging **provider** pack (its manifest declares a
2096    /// `messaging.*` provider) are also excluded: a provider ships its own
2097    /// ingress `main`/`default` flow that is plumbing for *that provider*, not
2098    /// the application. In a multi-provider bundle that flow would otherwise
2099    /// compete with the app's real entrypoint and make the route ambiguous.
2100    ///
2101    /// Returns `None` when zero or more than one *application* entry flow of the
2102    /// type exists (genuinely ambiguous — the caller must then require a
2103    /// `pack_id`).
2104    pub fn entry_flow_by_type(&self, flow_type: &str) -> Option<&FlowDescriptor> {
2105        let mut matches = self.flows.iter().filter(|descriptor| {
2106            descriptor.flow_type == flow_type
2107                && descriptor.entry
2108                && !self
2109                    .messaging_provider_pack_ids
2110                    .contains(&descriptor.pack_id)
2111        });
2112        let first = matches.next()?;
2113        if matches.next().is_some() {
2114            return None;
2115        }
2116        Some(first)
2117    }
2118
2119    pub fn flow_by_id(&self, flow_id: &str) -> Option<&FlowDescriptor> {
2120        let mut matches = self
2121            .flows
2122            .iter()
2123            .filter(|descriptor| descriptor.id == flow_id);
2124        let first = matches.next()?;
2125        if matches.next().is_some() {
2126            return None;
2127        }
2128        Some(first)
2129    }
2130}
2131
2132pub trait ExecutionObserver: Send + Sync {
2133    fn on_node_start(&self, event: &NodeEvent<'_>);
2134    fn on_node_end(&self, event: &NodeEvent<'_>, output: &Value);
2135    fn on_node_error(&self, event: &NodeEvent<'_>, error: &dyn StdError);
2136    fn on_validation(&self, _event: &NodeEvent<'_>, _issues: &[ValidationIssue]) {}
2137}
2138
2139pub struct NodeEvent<'a> {
2140    pub context: &'a FlowContext<'a>,
2141    pub node_id: &'a str,
2142    pub node: &'a HostNode,
2143    pub payload: &'a Value,
2144}
2145
2146#[derive(Clone, Debug, Serialize, Deserialize)]
2147pub struct ExecutionState {
2148    #[serde(default)]
2149    entry: Value,
2150    #[serde(default)]
2151    input: Value,
2152    #[serde(default)]
2153    nodes: HashMap<String, NodeOutput>,
2154    #[serde(default)]
2155    egress: Vec<Value>,
2156    #[serde(default, skip_serializing_if = "Option::is_none")]
2157    last_output: Option<Value>,
2158    #[serde(default)]
2159    redirect_count: u32,
2160}
2161
2162impl ExecutionState {
2163    fn new(input: Value) -> Self {
2164        Self {
2165            entry: input.clone(),
2166            input,
2167            nodes: HashMap::new(),
2168            egress: Vec::new(),
2169            last_output: None,
2170            redirect_count: 0,
2171        }
2172    }
2173
2174    /// Refresh `entry` from `input` if the snapshot was loaded without an
2175    /// entry value. Kept for backwards compatibility with snapshots
2176    /// persisted before the entry-refresh fix in `FlowEngine::resume`.
2177    #[allow(dead_code)]
2178    fn ensure_entry(&mut self) {
2179        if self.entry.is_null() {
2180            self.entry = self.input.clone();
2181        }
2182    }
2183
2184    fn context(&self) -> Value {
2185        let mut nodes = JsonMap::new();
2186        for (id, output) in &self.nodes {
2187            nodes.insert(
2188                id.clone(),
2189                json!({
2190                    "ok": output.ok,
2191                    "payload": output.payload.clone(),
2192                    "meta": output.meta.clone(),
2193                }),
2194            );
2195        }
2196        json!({
2197            "entry": self.entry.clone(),
2198            "input": self.input.clone(),
2199            "nodes": nodes,
2200            "redirect_count": self.redirect_count,
2201        })
2202    }
2203
2204    fn outputs_map(&self) -> JsonMap<String, Value> {
2205        let mut outputs = JsonMap::new();
2206        for (id, output) in &self.nodes {
2207            outputs.insert(id.clone(), node_output_view(&output.payload));
2208        }
2209        outputs
2210    }
2211    fn push_egress(&mut self, payload: Value) {
2212        self.egress.push(payload);
2213    }
2214
2215    fn replace_input(&mut self, input: Value) {
2216        self.input = input;
2217    }
2218
2219    fn clear_egress(&mut self) {
2220        self.egress.clear();
2221    }
2222
2223    fn redirect_count(&self) -> u32 {
2224        self.redirect_count
2225    }
2226
2227    fn increment_redirect_count(&mut self) {
2228        self.redirect_count = self.redirect_count.saturating_add(1);
2229    }
2230
2231    fn finalize_with(mut self, final_payload: Option<Value>) -> Value {
2232        if self.egress.is_empty() {
2233            return final_payload.unwrap_or(Value::Null);
2234        }
2235        let mut emitted = std::mem::take(&mut self.egress);
2236        if let Some(value) = final_payload {
2237            match value {
2238                Value::Null => {}
2239                Value::Array(items) => emitted.extend(items),
2240                // A terminal `emit.response` node BOTH pushes its payload to
2241                // egress (see `push_egress` in `dispatch_node`) AND returns that
2242                // same payload as its node output, which the `End` path passes
2243                // here as `final_payload`. Appending it unconditionally would
2244                // emit the response twice (the webchat "double card"). Skip the
2245                // re-append when it merely repeats the last emitted response;
2246                // a genuinely distinct terminal output is still appended.
2247                other if emitted.last() == Some(&other) => {}
2248                other => emitted.push(other),
2249            }
2250        }
2251        Value::Array(emitted)
2252    }
2253}
2254
2255#[derive(Clone, Debug, Serialize, Deserialize)]
2256struct NodeOutput {
2257    ok: bool,
2258    payload: Value,
2259    meta: Value,
2260}
2261
2262impl NodeOutput {
2263    fn new(payload: Value) -> Self {
2264        Self {
2265            ok: true,
2266            payload,
2267            meta: Value::Null,
2268        }
2269    }
2270
2271    /// `ok=false` output stashing error context in `meta.error`. Currently
2272    /// only used by `lift_first_node_error_from_nodes` tests — kept around so
2273    /// drive_flow can resume populating it once we have a hook for it.
2274    #[allow(dead_code)]
2275    fn with_error(node_id: &str, err: &(dyn std::error::Error + 'static)) -> Self {
2276        Self {
2277            ok: false,
2278            payload: Value::Null,
2279            meta: json!({
2280                "error": {
2281                    "kind": "flow_node_failed",
2282                    "message": err.to_string(),
2283                    "node_id": node_id,
2284                }
2285            }),
2286        }
2287    }
2288}
2289
2290struct DispatchOutcome {
2291    output: NodeOutput,
2292    control: NodeControl,
2293}
2294
2295impl DispatchOutcome {
2296    fn complete(output: NodeOutput) -> Self {
2297        Self {
2298            output,
2299            control: NodeControl::Continue,
2300        }
2301    }
2302
2303    fn wait(output: NodeOutput, reason: Option<String>) -> Self {
2304        Self {
2305            output,
2306            control: NodeControl::Wait { reason },
2307        }
2308    }
2309
2310    fn with_control(output: NodeOutput, control: NodeControl) -> Self {
2311        Self { output, control }
2312    }
2313}
2314
2315#[derive(Clone, Debug)]
2316enum NodeControl {
2317    Continue,
2318    Wait {
2319        reason: Option<String>,
2320    },
2321    Jump(JumpControl),
2322    Respond {
2323        text: Option<String>,
2324        card_cbor: Option<Vec<u8>>,
2325        needs_user: Option<bool>,
2326    },
2327}
2328
2329#[derive(Clone, Debug)]
2330struct JumpControl {
2331    flow: String,
2332    node: Option<String>,
2333    payload: Value,
2334    hints: Value,
2335    max_redirects: Option<u32>,
2336    reason: Option<String>,
2337}
2338
2339#[derive(Clone, Debug)]
2340struct JumpTarget {
2341    flow_id: String,
2342    flow: HostFlow,
2343    node_id: NodeId,
2344}
2345
2346impl NodeOutput {
2347    fn with_meta(payload: Value, meta: Value) -> Self {
2348        Self {
2349            ok: true,
2350            payload,
2351            meta,
2352        }
2353    }
2354
2355    /// A failure output (`ok == false`). `build_routing_context` derives the
2356    /// `error_event` from this, so a node with an `on_error`-family route lands
2357    /// on its failure branch; `node_output_view` exposes the `{errors}` envelope.
2358    fn errored(payload: Value) -> Self {
2359        Self {
2360            ok: false,
2361            payload,
2362            meta: Value::Null,
2363        }
2364    }
2365}
2366
2367fn component_exec_ctx(ctx: &FlowContext<'_>, node_id: &str) -> ComponentExecCtx {
2368    ComponentExecCtx {
2369        tenant: ComponentTenantCtx {
2370            tenant: ctx.tenant.to_string(),
2371            team: None,
2372            user: ctx.provider_id.map(str::to_string),
2373            trace_id: None,
2374            i18n_id: None,
2375            correlation_id: ctx.session_id.map(str::to_string),
2376            deadline_unix_ms: None,
2377            attempt: ctx.attempt,
2378            idempotency_key: ctx.session_id.map(str::to_string),
2379        },
2380        i18n_id: None,
2381        flow_id: ctx.flow_id.to_string(),
2382        node_id: Some(node_id.to_string()),
2383    }
2384}
2385
2386/// Surface a component-emitted `outcome` (from its output envelope) as node
2387/// metadata, so the routing context can match `event == "<outcome>"`. A
2388/// component opts in by adding `"outcome": "<name>"` to its output envelope
2389/// (alongside `ok`); `<name>` must be one of its declared
2390/// `ComponentDescribe.outcomes`. Returns `Value::Null` when the component does
2391/// not emit one — the engine then falls back to the `ok`-derived default
2392/// (`on_success`/`on_error`) in `build_routing_context`.
2393/// Adapt a raw component/node result `Value` into the typed node_io [`NodeOutput`]
2394/// (`greentic_types::node_io`). Native `{data}` / `{errors}` envelopes parse straight
2395/// through; legacy `{ok, error}` results are shimmed (`ok:false` + `error` → `Errors`,
2396/// otherwise → `Data{data: <value>}`) so existing packs keep routing unchanged.
2397fn to_node_output(value: &Value) -> greentic_types::node_io::NodeOutput {
2398    use greentic_types::node_io::{ErrorKind, NodeError, NodeOutput as NioOutput};
2399
2400    if let Value::Object(map) = value {
2401        // Native node_io envelopes carry a sole `errors` or `data` key and no legacy
2402        // `ok` flag — deserialize them directly so `kind`/`retryable`/etc. round-trip.
2403        let native_errors = map.contains_key("errors") && !map.contains_key("ok");
2404        let native_data = map.contains_key("data") && !map.contains_key("ok") && map.len() == 1;
2405        if (native_errors || native_data)
2406            && let Ok(parsed) = serde_json::from_value::<NioOutput>(value.clone())
2407        {
2408            return parsed;
2409        }
2410        // Legacy failure envelope `{ok:false, error:{code,message}}` → Errors.
2411        if let Some((code, message)) = component_error(value) {
2412            return NioOutput::failed(vec![NodeError {
2413                code,
2414                message,
2415                kind: ErrorKind::Internal,
2416                retryable: false,
2417                source: None,
2418                details: Value::Null,
2419            }]);
2420        }
2421    }
2422    // Default: a bare result (or `{ok:true, ...}`) is success data.
2423    NioOutput::ok(value.clone())
2424}
2425
2426/// Build the per-node template view exposed under `{{node.<id>...}}`. Object payloads
2427/// keep their fields at the top level (legacy `{{node.<id>.<field>}}`) and additionally
2428/// gain canonical node_io surfaces `data` (`{{node.<id>.data.<field>}}`) and `errors`
2429/// (`{{node.<id>.errors}}`). Non-object payloads are exposed verbatim, as before.
2430fn node_output_view(payload: &Value) -> Value {
2431    let nio = to_node_output(payload);
2432    let data = nio.data().cloned().unwrap_or(Value::Null);
2433    let errors = serde_json::to_value(nio.errors()).unwrap_or_else(|_| Value::Array(Vec::new()));
2434    match payload {
2435        Value::Object(map) => {
2436            let mut view = map.clone();
2437            view.insert("data".to_string(), data);
2438            view.insert("errors".to_string(), errors);
2439            Value::Object(view)
2440        }
2441        other => other.clone(),
2442    }
2443}
2444
2445fn outcome_meta(output: &Value) -> Value {
2446    match output.get("outcome").and_then(Value::as_str) {
2447        Some(outcome) => json!({ "outcome": outcome }),
2448        None => Value::Null,
2449    }
2450}
2451
2452fn component_error(value: &Value) -> Option<(String, String)> {
2453    let obj = value.as_object()?;
2454    let ok = obj.get("ok").and_then(Value::as_bool)?;
2455    if ok {
2456        return None;
2457    }
2458    let err = obj.get("error")?.as_object()?;
2459    let code = err
2460        .get("code")
2461        .and_then(Value::as_str)
2462        .unwrap_or("component_error");
2463    let message = err
2464        .get("message")
2465        .and_then(Value::as_str)
2466        .unwrap_or("component reported error");
2467    Some((code.to_string(), message.to_string()))
2468}
2469
2470/// MCP tool-error wire shape from greentic-mcp-generator's `tool_error_with_status`:
2471/// `{ "error": { "code", "message", "status" } }`. The component returned ok=true at
2472/// the WIT level (the HTTP failure was caught and serialized), so the regular
2473/// component_error path doesn't catch it.
2474fn mcp_tool_error(value: &Value) -> Option<(String, String)> {
2475    let obj = value.as_object()?;
2476    // Must be the error shape: no `result` field, just `error`.
2477    if obj.contains_key("result") {
2478        return None;
2479    }
2480    let err = obj.get("error")?.as_object()?;
2481    let code = err
2482        .get("code")
2483        .and_then(Value::as_str)
2484        .unwrap_or("tool_error");
2485    let raw_message = err
2486        .get("message")
2487        .and_then(Value::as_str)
2488        .unwrap_or("tool returned an error");
2489    let status = err.get("status").and_then(Value::as_u64);
2490    let message = match status {
2491        Some(s) => format!("{raw_message} (status {s})"),
2492        None => raw_message.to_string(),
2493    };
2494    Some((code.to_string(), message))
2495}
2496
2497fn extract_wait_reason(payload: &Value) -> Option<String> {
2498    match payload {
2499        Value::String(s) => Some(s.clone()),
2500        Value::Object(map) => map
2501            .get("reason")
2502            .and_then(Value::as_str)
2503            .map(|value| value.to_string()),
2504        _ => None,
2505    }
2506}
2507
2508fn component_dispatch_outcome(output: NodeOutput) -> Result<DispatchOutcome> {
2509    if let Some(control) = parse_component_control(&output.payload)? {
2510        return Ok(match control {
2511            NodeControl::Jump(jump) => {
2512                let adjusted = NodeOutput::with_meta(jump.payload.clone(), jump.hints.clone());
2513                DispatchOutcome::with_control(adjusted, NodeControl::Jump(jump))
2514            }
2515            NodeControl::Respond {
2516                text,
2517                card_cbor,
2518                needs_user,
2519            } => DispatchOutcome::with_control(
2520                output,
2521                NodeControl::Respond {
2522                    text,
2523                    card_cbor,
2524                    needs_user,
2525                },
2526            ),
2527            other => DispatchOutcome::with_control(output, other),
2528        });
2529    }
2530    Ok(DispatchOutcome::complete(output))
2531}
2532
2533fn parse_component_control(payload: &Value) -> Result<Option<NodeControl>> {
2534    let Value::Object(map) = payload else {
2535        return Ok(None);
2536    };
2537    let Some(control_value) = map.get("greentic_control") else {
2538        return Ok(None);
2539    };
2540    let control = control_value
2541        .as_object()
2542        .ok_or_else(|| anyhow!("jump_failed: greentic_control must be an object"))?;
2543    let action = control
2544        .get("action")
2545        .and_then(Value::as_str)
2546        .ok_or_else(|| anyhow!("jump_failed: greentic_control.action is required"))?;
2547    let version = control
2548        .get("v")
2549        .and_then(Value::as_u64)
2550        .ok_or_else(|| anyhow!("jump_failed: greentic_control.v is required"))?;
2551    if version != 1 {
2552        bail!("jump_failed: unsupported greentic_control.v={version}");
2553    }
2554
2555    match action {
2556        "jump" => {
2557            let flow = control
2558                .get("flow")
2559                .and_then(Value::as_str)
2560                .map(str::trim)
2561                .filter(|value| !value.is_empty())
2562                .ok_or_else(|| anyhow!("jump_failed: jump flow is required"))?
2563                .to_string();
2564            let node = control
2565                .get("node")
2566                .and_then(Value::as_str)
2567                .map(str::trim)
2568                .filter(|value| !value.is_empty())
2569                .map(str::to_string);
2570            let payload = control.get("payload").cloned().unwrap_or(Value::Null);
2571            let hints = control.get("hints").cloned().unwrap_or(Value::Null);
2572            let max_redirects = control
2573                .get("max_redirects")
2574                .and_then(Value::as_u64)
2575                .and_then(|value| u32::try_from(value).ok());
2576            let reason = control
2577                .get("reason")
2578                .and_then(Value::as_str)
2579                .map(str::to_string);
2580            Ok(Some(NodeControl::Jump(JumpControl {
2581                flow,
2582                node,
2583                payload,
2584                hints,
2585                max_redirects,
2586                reason,
2587            })))
2588        }
2589        "respond" => {
2590            let text = control
2591                .get("text")
2592                .and_then(Value::as_str)
2593                .map(str::to_string);
2594            let card_cbor = control
2595                .get("card_cbor")
2596                .and_then(Value::as_array)
2597                .map(|bytes| {
2598                    bytes
2599                        .iter()
2600                        .filter_map(Value::as_u64)
2601                        .filter_map(|value| u8::try_from(value).ok())
2602                        .collect::<Vec<_>>()
2603                });
2604            let needs_user = control.get("needs_user").and_then(Value::as_bool);
2605            Ok(Some(NodeControl::Respond {
2606                text,
2607                card_cbor,
2608                needs_user,
2609            }))
2610        }
2611        _ => Ok(None),
2612    }
2613}
2614
2615/// Make `in.input.*` resolve even when the flow entry IS the message (the
2616/// env/revision path passes `envelope.payload` — the message — directly), not
2617/// the legacy `{ "input": <message> }` wrapper. Packs are compiled against the
2618/// legacy shape and read `in.input.metadata.*` (e.g. a card button's dispatch
2619/// `flow_{{in.input.metadata.operation}}`); on the direct path `in.input` was
2620/// null, so metadata-based routing fell through to the entry/welcome flow.
2621///
2622/// When the entry is an object without an `input` key, alias `input` to the
2623/// entry itself so both `in.input.X` and `in.X` resolve. Entries that already
2624/// carry an explicit `input` (the legacy wrapper) are left untouched.
2625fn alias_input_to_entry(mut entry: Value) -> Value {
2626    if let Value::Object(map) = &mut entry
2627        && !map.contains_key("input")
2628    {
2629        let base = Value::Object(map.clone());
2630        map.insert("input".into(), base);
2631    }
2632    entry
2633}
2634
2635fn template_context(state: &ExecutionState, prev: Value) -> Value {
2636    let entry = if state.entry.is_null() {
2637        Value::Object(JsonMap::new())
2638    } else {
2639        alias_input_to_entry(state.entry.clone())
2640    };
2641    let mut ctx = JsonMap::new();
2642    ctx.insert("entry".into(), entry.clone());
2643    ctx.insert("in".into(), entry); // alias for entry - used in flow templates
2644    ctx.insert("prev".into(), prev);
2645    ctx.insert("node".into(), Value::Object(state.outputs_map()));
2646    ctx.insert("state".into(), state.context());
2647    Value::Object(ctx)
2648}
2649
2650impl From<Flow> for HostFlow {
2651    fn from(value: Flow) -> Self {
2652        let mut nodes = IndexMap::new();
2653        for (id, node) in value.nodes {
2654            nodes.insert(id.clone(), HostNode::from(node));
2655        }
2656        let start = value
2657            .entrypoints
2658            .get("default")
2659            .and_then(Value::as_str)
2660            .and_then(|id| NodeId::from_str(id).ok())
2661            .or_else(|| nodes.keys().next().cloned());
2662        // Extract flow-level slot_schema from metadata.extra (Phase D).
2663        // The producer side (greentic-flow compile_flow) stores it under
2664        // "greentic.slot_schema" when the FlowDoc has a `slot_schema` field.
2665        let slot_schema = value
2666            .metadata
2667            .extra
2668            .get(SLOT_SCHEMA_METADATA_KEY)
2669            .filter(|v| !v.is_null())
2670            .cloned();
2671        Self {
2672            id: value.id.as_str().to_string(),
2673            start,
2674            nodes,
2675            slot_schema,
2676        }
2677    }
2678}
2679
2680impl From<Node> for HostNode {
2681    fn from(node: Node) -> Self {
2682        let full_ref = node.component.id.as_str().to_string();
2683        let operation_in_mapping = extract_operation_from_mapping(&node.input.mapping);
2684        // A dotted component id is only a packed "<component>.<operation>" string
2685        // when the operation isn't carried structurally elsewhere. greentic-pack
2686        // resolves a component node to a bare component symbol (e.g.
2687        // `ai.greentic.component-templates`) and keeps the operation in the input
2688        // mapping, so splitting on the last dot here would corrupt the reference
2689        // (→ `ai.greentic`, "not found in pack"). Prefer the structured operation —
2690        // from `component.operation` or the input mapping — and only fall back to
2691        // the legacy single-ID split when neither is present.
2692        let is_builtin = full_ref.starts_with("component.exec")
2693            || full_ref.starts_with("flow.")
2694            || full_ref.starts_with("emit.")
2695            || full_ref.starts_with("session.")
2696            || full_ref.starts_with("provider.")
2697            || full_ref.starts_with("dw.")
2698            || full_ref.starts_with("sorla.")
2699            || full_ref.starts_with("operala.")
2700            || full_ref.starts_with("agentic.")
2701            // `mcp:<server>/<tool>` is a self-contained ref; never dot-split it
2702            // into a `component.operation` pair.
2703            || full_ref.starts_with("mcp:");
2704        let (component_ref, raw_operation) =
2705            if node.component.operation.is_some() || is_builtin || operation_in_mapping.is_some() {
2706                (full_ref, node.component.operation.clone())
2707            } else if let Some(dot) = full_ref.rfind('.') {
2708                let comp = full_ref[..dot].to_string();
2709                let op = full_ref[dot + 1..].to_string();
2710                (comp, Some(op))
2711            } else {
2712                (full_ref, None)
2713            };
2714        let operation_is_component_exec = raw_operation.as_deref() == Some("component.exec");
2715        let operation_is_emit = raw_operation
2716            .as_deref()
2717            .map(|op| op.starts_with("emit."))
2718            .unwrap_or(false);
2719        let is_component_exec = component_ref == "component.exec" || operation_is_component_exec;
2720
2721        let kind = if is_component_exec {
2722            let target = if component_ref == "component.exec" {
2723                if let Some(op) = raw_operation
2724                    .as_deref()
2725                    .filter(|op| op.starts_with("emit."))
2726                {
2727                    op.to_string()
2728                } else {
2729                    extract_target_component(&node.input.mapping)
2730                        .unwrap_or_else(|| "component.exec".to_string())
2731                }
2732            } else {
2733                extract_target_component(&node.input.mapping)
2734                    .unwrap_or_else(|| component_ref.clone())
2735            };
2736            if target.starts_with("emit.") {
2737                NodeKind::BuiltinEmit {
2738                    kind: emit_kind_from_ref(&target),
2739                }
2740            } else {
2741                NodeKind::Exec {
2742                    target_component: target,
2743                }
2744            }
2745        } else if operation_is_emit {
2746            NodeKind::BuiltinEmit {
2747                kind: emit_kind_from_ref(raw_operation.as_deref().unwrap_or("emit.log")),
2748            }
2749        } else {
2750            match component_ref.as_str() {
2751                "flow.call" => NodeKind::FlowCall,
2752                "provider.invoke" => NodeKind::ProviderInvoke,
2753                "session.wait" => NodeKind::Wait,
2754                "state.get" => NodeKind::BuiltinStateGet,
2755                "state.set" => NodeKind::BuiltinStateSet,
2756                "dw.agent" => NodeKind::DwAgent {
2757                    agent_id: raw_operation.clone().unwrap_or_default(),
2758                },
2759                "dw.agent_graph" => NodeKind::DwAgentGraph {
2760                    graph_id: raw_operation.clone().unwrap_or_default(),
2761                },
2762                "sorla.call" => NodeKind::SorlaCall {
2763                    target: raw_operation.clone().unwrap_or_default(),
2764                },
2765                "operala.call" => NodeKind::OperalaCall {
2766                    target: raw_operation.clone().unwrap_or_default(),
2767                },
2768                "agentic.call" => NodeKind::AgenticCall {
2769                    target: raw_operation.clone().unwrap_or_default(),
2770                },
2771                "telco-x.call" => NodeKind::TelcoXCall {
2772                    target: raw_operation.clone().unwrap_or_default(),
2773                },
2774                "approval.call" => NodeKind::ApprovalCall {
2775                    target: raw_operation.clone().unwrap_or_default(),
2776                },
2777                comp if comp.starts_with("emit.") => NodeKind::BuiltinEmit {
2778                    kind: emit_kind_from_ref(comp),
2779                },
2780                // LOCKED ENCODING v2 (shared with greentic-flow + designer):
2781                // `component == "mcp"` (a valid `ComponentId`) with `server` and
2782                // `tool` carried in the node PAYLOAD/config:
2783                //   payload = { server, tool, arguments, output? }.
2784                // The payload is the source of truth. A legacy
2785                // `operation = "<server>/<tool>"` (or an `mcp:<server>/<tool>`
2786                // component ref) is honored only as a defensive fallback when the
2787                // payload lacks the fields, so older packs keep loading.
2788                "mcp" => mcp_node_kind(&node.input.mapping, raw_operation.as_deref()),
2789                // `mcp:<server>/<tool>` carried verbatim in `component.id`.
2790                // `greentic_types::ComponentId` rejects `:`/`/`, so this form
2791                // only survives when the node-type string bypasses ComponentId
2792                // validation; it is still recognized as a fallback for older
2793                // packs.
2794                comp if comp.starts_with("mcp:") => mcp_node_kind(&node.input.mapping, Some(comp)),
2795                other => NodeKind::PackComponent {
2796                    component_ref: other.to_string(),
2797                },
2798            }
2799        };
2800        let component_label = match &kind {
2801            NodeKind::Exec { .. } => "component.exec".to_string(),
2802            NodeKind::PackComponent { component_ref } => component_ref.clone(),
2803            NodeKind::ProviderInvoke => "provider.invoke".to_string(),
2804            NodeKind::FlowCall => "flow.call".to_string(),
2805            NodeKind::BuiltinEmit { kind } => emit_ref_from_kind(kind),
2806            NodeKind::BuiltinStateGet => "state.get".to_string(),
2807            NodeKind::BuiltinStateSet => "state.set".to_string(),
2808            NodeKind::Wait => "session.wait".to_string(),
2809            NodeKind::DwAgent { .. } => "dw.agent".to_string(),
2810            NodeKind::DwAgentGraph { .. } => "dw.agent_graph".to_string(),
2811            NodeKind::SorlaCall { .. } => "sorla.call".to_string(),
2812            NodeKind::OperalaCall { .. } => "operala.call".to_string(),
2813            NodeKind::AgenticCall { .. } => "agentic.call".to_string(),
2814            NodeKind::TelcoXCall { .. } => "telco-x.call".to_string(),
2815            NodeKind::ApprovalCall { .. } => "approval.call".to_string(),
2816            NodeKind::Mcp { server_id, tool } => format!("mcp:{server_id}/{tool}"),
2817        };
2818        let operation_name = if is_component_exec && operation_is_component_exec {
2819            None
2820        } else {
2821            raw_operation.clone()
2822        };
2823        let payload_expr = match kind {
2824            NodeKind::BuiltinEmit { .. } => extract_emit_payload(&node.input.mapping),
2825            _ => node.input.mapping.clone(),
2826        };
2827        Self {
2828            kind,
2829            component: component_label,
2830            component_id: if is_component_exec {
2831                "component.exec".to_string()
2832            } else {
2833                component_ref
2834            },
2835            operation_name,
2836            operation_in_mapping,
2837            payload_expr,
2838            routing: node.routing,
2839        }
2840    }
2841}
2842
2843/// Classify a `component == "mcp"` node into [`NodeKind::Mcp`].
2844///
2845/// LOCKED ENCODING v2: `server` and `tool` are read from the node
2846/// `payload`/config object (the source of truth). When the payload omits them,
2847/// a legacy `operation = "<server>/<tool>"` string (or an
2848/// `mcp:<server>/<tool>` component ref) is parsed as a defensive fallback for
2849/// older packs.
2850///
2851/// When neither source yields a usable `(server, tool)` pair the node falls
2852/// back to an ordinary [`NodeKind::PackComponent`], so a malformed MCP node
2853/// surfaces as a normal unknown-component error at run time rather than
2854/// panicking at load. Flow loading stays total.
2855fn mcp_node_kind(payload: &Value, legacy_ref: Option<&str>) -> NodeKind {
2856    if let Some((server_id, tool)) = crate::runner::mcp_node::server_tool_from_payload(payload) {
2857        return NodeKind::Mcp { server_id, tool };
2858    }
2859    if let Some((server_id, tool)) = legacy_ref.and_then(parse_legacy_mcp_ref) {
2860        return NodeKind::Mcp { server_id, tool };
2861    }
2862    NodeKind::PackComponent {
2863        component_ref: "mcp".to_string(),
2864    }
2865}
2866
2867/// Parse a legacy MCP server/tool reference, accepting either the bare
2868/// `"<server>/<tool>"` operation form or the prefixed `mcp:<server>/<tool>`
2869/// component-ref form. Returns `None` when either part is missing or empty.
2870fn parse_legacy_mcp_ref(reference: &str) -> Option<(String, String)> {
2871    let rest = reference.strip_prefix("mcp:").unwrap_or(reference);
2872    let (server, tool) = rest.split_once('/')?;
2873    if server.is_empty() || tool.is_empty() {
2874        return None;
2875    }
2876    Some((server.to_string(), tool.to_string()))
2877}
2878
2879fn extract_target_component(payload: &Value) -> Option<String> {
2880    match payload {
2881        Value::Object(map) => map
2882            .get("component")
2883            .or_else(|| map.get("component_ref"))
2884            .and_then(Value::as_str)
2885            .map(|s| s.to_string()),
2886        _ => None,
2887    }
2888}
2889
2890fn extract_operation_from_mapping(payload: &Value) -> Option<String> {
2891    match payload {
2892        Value::Object(map) => map
2893            .get("operation")
2894            .or_else(|| map.get("op"))
2895            .and_then(Value::as_str)
2896            .map(str::trim)
2897            .filter(|value| !value.is_empty())
2898            .map(|value| value.to_string()),
2899        _ => None,
2900    }
2901}
2902
2903fn extract_emit_payload(payload: &Value) -> Value {
2904    if let Value::Object(map) = payload {
2905        if let Some(input) = map.get("input") {
2906            return input.clone();
2907        }
2908        if let Some(inner) = map.get("payload") {
2909            return inner.clone();
2910        }
2911    }
2912    payload.clone()
2913}
2914
2915fn split_operation_payload(payload: Value) -> (Value, Value) {
2916    if let Value::Object(mut map) = payload.clone()
2917        && map.contains_key("input")
2918    {
2919        let input = map.remove("input").unwrap_or(Value::Null);
2920        let config = map.remove("config").unwrap_or(Value::Null);
2921        let legacy_only = map.keys().all(|key| {
2922            matches!(
2923                key.as_str(),
2924                "operation" | "op" | "component" | "component_ref"
2925            )
2926        });
2927        if legacy_only {
2928            return (input, config);
2929        }
2930    }
2931    (payload, Value::Null)
2932}
2933
2934fn resolve_component_operation(
2935    node_id: &str,
2936    component_label: &str,
2937    payload_operation: Option<String>,
2938    operation_override: Option<&str>,
2939    operation_in_mapping: Option<&str>,
2940) -> Result<String> {
2941    if let Some(op) = operation_override
2942        .map(str::trim)
2943        .filter(|value| !value.is_empty())
2944    {
2945        return Ok(op.to_string());
2946    }
2947
2948    if let Some(op) = payload_operation
2949        .as_deref()
2950        .map(str::trim)
2951        .filter(|value| !value.is_empty())
2952    {
2953        return Ok(op.to_string());
2954    }
2955
2956    let mut message = format!(
2957        "missing operation for node `{}` (component `{}`); expected node.component.operation to be set",
2958        node_id, component_label,
2959    );
2960    if let Some(found) = operation_in_mapping {
2961        message.push_str(&format!(
2962            ". Found operation in input.mapping (`{}`) but this is not used; pack compiler must preserve node.component.operation.",
2963            found
2964        ));
2965    }
2966    bail!(message);
2967}
2968
2969fn emit_kind_from_ref(component_ref: &str) -> EmitKind {
2970    match component_ref {
2971        "emit.log" => EmitKind::Log,
2972        "emit.response" => EmitKind::Response,
2973        other => EmitKind::Other(other.to_string()),
2974    }
2975}
2976
2977fn emit_ref_from_kind(kind: &EmitKind) -> String {
2978    match kind {
2979        EmitKind::Log => "emit.log".to_string(),
2980        EmitKind::Response => "emit.response".to_string(),
2981        EmitKind::Other(other) => other.clone(),
2982    }
2983}
2984
2985/// Returns `true` when `input` looks like an Adaptive Card invocation
2986/// (contains `card_source` or `card_spec` at the top level).
2987fn is_card_invocation(input: &Value) -> bool {
2988    if let Value::Object(map) = input {
2989        return map.contains_key("card_source") || map.contains_key("card_spec");
2990    }
2991    false
2992}
2993
2994/// When the node config declares adaptive-card defaults (`default_card_asset`,
2995/// `default_card_inline`, or `default_source`) but the runtime invocation has
2996/// no `card_source`/`card_spec` yet, lift those defaults into the invocation.
2997/// This produces a schema-valid invocation envelope so the component does not
2998/// fall back to its generic "Welcome" placeholder.
2999///
3000/// Adaptive-card defaults can arrive in either of two places depending on how
3001/// the pack was compiled:
3002/// - top-level `call.config` (post `split_operation_payload`)
3003/// - nested `call.input.config` (when the node mapping kept the
3004///   `{component, config}` shape and `split_operation_payload` left it intact)
3005fn promote_card_config_to_invocation(input: &mut Value, config: &Value) {
3006    if is_card_invocation(input) {
3007        return;
3008    }
3009
3010    let cfg_map = card_defaults_source(input, config);
3011    let Some(cfg) = cfg_map else { return };
3012
3013    let default_asset = cfg
3014        .get("default_card_asset")
3015        .and_then(Value::as_str)
3016        .map(str::trim)
3017        .filter(|value| !value.is_empty())
3018        .map(str::to_string);
3019    let default_inline = cfg
3020        .get("default_card_inline")
3021        .filter(|value| value.is_object() || value.is_array())
3022        .cloned();
3023    let default_source = cfg
3024        .get("default_source")
3025        .and_then(Value::as_str)
3026        .map(str::trim)
3027        .filter(|value| !value.is_empty())
3028        .map(str::to_lowercase);
3029
3030    if default_asset.is_none() && default_inline.is_none() && default_source.is_none() {
3031        return;
3032    }
3033
3034    let card_source = default_source.unwrap_or_else(|| {
3035        if default_inline.is_some() {
3036            "inline".to_string()
3037        } else {
3038            "asset".to_string()
3039        }
3040    });
3041
3042    let mut card_spec = serde_json::Map::new();
3043    match card_source.as_str() {
3044        "asset" => {
3045            if let Some(path) = default_asset {
3046                card_spec.insert("asset_path".into(), Value::String(path));
3047            }
3048        }
3049        "inline" => {
3050            if let Some(inline) = default_inline {
3051                card_spec.insert("inline_json".into(), inline);
3052            }
3053        }
3054        _ => {}
3055    }
3056
3057    if !matches!(input, Value::Object(_)) {
3058        *input = Value::Object(serde_json::Map::new());
3059    }
3060    if let Value::Object(map) = input {
3061        map.insert("card_source".into(), Value::String(card_source));
3062        map.insert("card_spec".into(), Value::Object(card_spec));
3063    }
3064}
3065
3066/// Locate the adaptive-card defaults config object, preferring the top-level
3067/// `call.config` when present, then falling back to a nested `input.config`
3068/// (the shape produced when `split_operation_payload` leaves the mapping
3069/// intact).
3070fn card_defaults_source<'a>(
3071    input: &'a Value,
3072    config: &'a Value,
3073) -> Option<&'a serde_json::Map<String, Value>> {
3074    if let Value::Object(map) = config {
3075        return Some(map);
3076    }
3077    if let Value::Object(map) = input
3078        && let Some(Value::Object(nested)) = map.get("config")
3079    {
3080        return Some(nested);
3081    }
3082    None
3083}
3084
3085fn inject_card_locale(payload: &mut Value, entry: &Value) {
3086    if !is_card_invocation(payload) {
3087        return;
3088    }
3089    let Value::Object(map) = payload else { return };
3090    if map.contains_key("locale") {
3091        return;
3092    }
3093    let locale = entry
3094        .pointer("/input/metadata/locale")
3095        .or_else(|| entry.pointer("/metadata/locale"))
3096        .and_then(Value::as_str);
3097    if let Some(locale) = locale {
3098        map.insert("locale".into(), Value::String(locale.to_string()));
3099    }
3100}
3101
3102/// Select an adaptive-card node's card from a `routeToCardId`/`toCardId`/
3103/// `nextCardId` carried on the flow entry (a card button's submit), so the flow
3104/// renders the routed card instead of the node's `default_card_asset`.
3105///
3106/// This keeps card navigation *inside* the flow — the runner sets the node's
3107/// `card_spec.asset_path` from the routing key, which makes
3108/// [`promote_card_config_to_invocation`] treat the input as an explicit card
3109/// invocation (so it does not overwrite it with the default), and
3110/// [`resolve_card_assets`] then inlines the routed card. It replaces the legacy
3111/// host-side "read the card from the pack and bypass the flow" shortcut.
3112///
3113/// No-ops (leaving the node's default card) when: the node is not the
3114/// adaptive-card component, the payload already carries an explicit
3115/// `card_source`/`card_spec` (author-set), or no routing key is present.
3116fn inject_card_route(payload: &mut Value, entry: &Value, node: &HostNode) {
3117    let is_adaptive_card =
3118        node.component_id().contains("adaptive-card") || node.component.contains("adaptive-card");
3119    if !is_adaptive_card || is_card_invocation(payload) {
3120        return;
3121    }
3122    let route = entry
3123        .pointer("/input/metadata/routeToCardId")
3124        .or_else(|| entry.pointer("/metadata/routeToCardId"))
3125        .or_else(|| entry.pointer("/input/metadata/toCardId"))
3126        .or_else(|| entry.pointer("/metadata/toCardId"))
3127        .or_else(|| entry.pointer("/input/metadata/nextCardId"))
3128        .or_else(|| entry.pointer("/metadata/nextCardId"))
3129        .and_then(Value::as_str)
3130        .map(str::trim)
3131        .filter(|value| !value.is_empty());
3132    let Some(route) = route else {
3133        return;
3134    };
3135
3136    if !matches!(payload, Value::Object(_)) {
3137        *payload = Value::Object(serde_json::Map::new());
3138    }
3139    if let Value::Object(map) = payload {
3140        let mut card_spec = serde_json::Map::new();
3141        card_spec.insert(
3142            "asset_path".into(),
3143            Value::String(format!("assets/cards/{route}.json")),
3144        );
3145        map.insert("card_source".into(), Value::String("asset".into()));
3146        map.insert("card_spec".into(), Value::Object(card_spec));
3147        tracing::debug!(route_to_card = %route, "inject_card_route: routed card asset selected");
3148    }
3149}
3150
3151/// Inject flow-level `slot_schema` as `slot_definitions` into the
3152/// slot-extractor component's input value. Skips injection when the input
3153/// already contains an explicit `slot_definitions` key (back-compat with
3154/// M2.4 NDA demo inline definitions). When the input is `Null`, promotes it
3155/// to an empty object first.
3156fn inject_slot_definitions(input: &mut Value, slot_schema: &Value, flow_id: &str, node_id: &str) {
3157    if input.is_null() {
3158        *input = Value::Object(serde_json::Map::new());
3159    }
3160    let Some(map) = input.as_object_mut() else {
3161        tracing::warn!(
3162            flow_id,
3163            node_id,
3164            "slot-extractor input is not an object; cannot inject slot_definitions"
3165        );
3166        return;
3167    };
3168    if map.contains_key("slot_definitions") {
3169        return;
3170    }
3171    let slot_count = slot_schema.as_array().map_or(0, Vec::len);
3172    tracing::debug!(
3173        flow_id,
3174        slot_count,
3175        "injecting flow-level slot_schema as slot_definitions into slot-extractor input"
3176    );
3177    map.insert("slot_definitions".to_string(), slot_schema.clone());
3178}
3179
3180/// Pre-resolve `card_source: "asset"` entries by reading the referenced JSON
3181/// file from the pack's assets directory and converting to
3182/// `card_source: "inline"` with `inline_json` populated.
3183///
3184/// This handles both top-level card fields and the nested `call.payload`
3185/// structure emitted by cards2pack.
3186fn resolve_card_assets(input: &mut Value, pack: &crate::pack::PackRuntime) {
3187    resolve_card_spec_asset(input, pack);
3188
3189    // Also resolve inside `call.payload` (cards2pack duplicates the card
3190    // invocation there).
3191    if let Value::Object(map) = input
3192        && let Some(Value::Object(call)) = map.get_mut("call")
3193        && let Some(payload) = call.get_mut("payload")
3194    {
3195        resolve_card_spec_asset(payload, pack);
3196    }
3197}
3198
3199/// Resolve a single card_spec asset_path → inline_json.
3200fn resolve_card_spec_asset(value: &mut Value, pack: &crate::pack::PackRuntime) {
3201    let Value::Object(map) = value else { return };
3202
3203    let is_asset = map
3204        .get("card_source")
3205        .and_then(Value::as_str)
3206        .map(|s| s.eq_ignore_ascii_case("asset"))
3207        .unwrap_or(false);
3208    if !is_asset {
3209        return;
3210    }
3211
3212    let asset_path = map
3213        .get("card_spec")
3214        .and_then(|spec| spec.get("asset_path"))
3215        .and_then(Value::as_str)
3216        .map(str::to_string);
3217
3218    let Some(asset_path) = asset_path else { return };
3219
3220    match pack.read_asset(&asset_path) {
3221        Ok(bytes) => {
3222            let card_json: Value = match serde_json::from_slice(&bytes) {
3223                Ok(v) => v,
3224                Err(err) => {
3225                    tracing::warn!(
3226                        asset_path,
3227                        %err,
3228                        "failed to parse card asset as JSON; leaving as asset reference"
3229                    );
3230                    return;
3231                }
3232            };
3233            tracing::debug!(asset_path, "pre-resolved card asset to inline_json");
3234            map.insert("card_source".into(), Value::String("inline".into()));
3235            if let Some(Value::Object(spec)) = map.get_mut("card_spec") {
3236                spec.insert("inline_json".into(), card_json);
3237                spec.remove("asset_path");
3238            }
3239        }
3240        Err(err) => {
3241            tracing::warn!(
3242                asset_path,
3243                %err,
3244                "card asset not found in pack; leaving as asset reference"
3245            );
3246        }
3247    }
3248
3249    // Pre-resolve i18n bundle: the WASM component cannot read pack assets
3250    // directly (no host resolver registered), so inline the i18n JSON into
3251    // the invocation under `card_spec.i18n_inline`. Defense-in-depth: when
3252    // the card omits an explicit `i18n_bundle_path` we still try the
3253    // conventional `assets/i18n/` location so cards that rely on
3254    // auto-generated i18n keys (e.g. cards2pack output) keep working.
3255    let configured_bundle_path = map
3256        .get("card_spec")
3257        .and_then(|spec| spec.get("i18n_bundle_path"))
3258        .and_then(Value::as_str)
3259        .map(|s| s.trim().trim_end_matches('/').to_string())
3260        .filter(|s| !s.is_empty());
3261
3262    let bundle_path = configured_bundle_path
3263        .clone()
3264        .unwrap_or_else(|| "assets/i18n".to_string());
3265
3266    let i18n_entries = load_i18n_bundle_entries(&bundle_path, |path| pack.read_asset(path));
3267
3268    if !i18n_entries.is_empty() {
3269        let locale_keys: Vec<_> = i18n_entries.keys().cloned().collect();
3270        if let Some(Value::Object(spec)) = map.get_mut("card_spec") {
3271            spec.insert("i18n_inline".into(), Value::Object(i18n_entries));
3272            if configured_bundle_path.is_some() {
3273                tracing::info!(%bundle_path, ?locale_keys, "pre-resolved i18n bundle into card_spec.i18n_inline");
3274            } else {
3275                tracing::info!(%bundle_path, ?locale_keys, "auto-discovered i18n bundle and inlined into card_spec.i18n_inline");
3276            }
3277        }
3278    }
3279}
3280
3281fn load_i18n_bundle_entries<F>(bundle_path: &str, mut read_asset: F) -> JsonMap<String, Value>
3282where
3283    F: FnMut(&str) -> Result<Vec<u8>>,
3284{
3285    let mut i18n_entries = JsonMap::new();
3286
3287    if bundle_path.ends_with(".json") {
3288        if let Ok(bytes) = read_asset(bundle_path)
3289            && let Ok(Value::Object(entries)) = serde_json::from_slice::<Value>(&bytes)
3290        {
3291            i18n_entries.insert("en".to_string(), Value::Object(entries));
3292        }
3293        return i18n_entries;
3294    }
3295
3296    let manifest_path = format!("{bundle_path}/_manifest.json");
3297    let locale_codes: Vec<String> = read_asset(&manifest_path)
3298        .ok()
3299        .and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
3300        .and_then(|value| {
3301            let locales = value
3302                .get("locales")
3303                .and_then(Value::as_array)
3304                .cloned()
3305                .or_else(|| value.as_array().cloned());
3306            locales.map(|items| {
3307                items
3308                    .iter()
3309                    .filter_map(Value::as_str)
3310                    .map(String::from)
3311                    .collect()
3312            })
3313        })
3314        .unwrap_or_default();
3315
3316    tracing::info!(%bundle_path, ?locale_codes, "i18n manifest discovered locales");
3317
3318    for locale in &locale_codes {
3319        let candidate = format!("{bundle_path}/{locale}.json");
3320        if let Ok(bytes) = read_asset(&candidate)
3321            && let Ok(Value::Object(entries)) = serde_json::from_slice::<Value>(&bytes)
3322        {
3323            i18n_entries.insert(locale.clone(), Value::Object(entries));
3324        }
3325    }
3326    if !i18n_entries.contains_key("en") {
3327        let en_path = format!("{bundle_path}/en.json");
3328        if let Ok(bytes) = read_asset(&en_path)
3329            && let Ok(Value::Object(entries)) = serde_json::from_slice::<Value>(&bytes)
3330        {
3331            i18n_entries.insert("en".to_string(), Value::Object(entries));
3332        }
3333    }
3334
3335    i18n_entries
3336}
3337
3338/// Outcome of `evaluate_custom_routing` for a node's `Routing::Custom` array.
3339///
3340/// `Next` advances the flow to the named target. `End` terminates the run.
3341/// `Wait` pauses the run at the current node so the next inbound activity
3342/// resumes here and re-evaluates the routing with the new context — this is
3343/// what allows messaging flows (welcome → ... → confirm) to behave like a
3344/// live conversation instead of restarting at the entry point on every
3345/// click.
3346#[derive(Debug)]
3347pub(crate) enum CustomRoutingDecision {
3348    Next(NodeId),
3349    End,
3350    Wait,
3351}
3352
3353/// Evaluate a node's `Routing::Custom` array against the current execution
3354/// context.
3355///
3356/// Parses `Routing::Custom(Value)` as an array of `{condition, to}` objects.
3357/// Conditions are simple equality expressions like `response.action == "about"`.
3358/// Falls back to the first route without a condition (default route).
3359///
3360/// The evaluation context includes:
3361/// - All fields from the node output payload (top-level)
3362/// - `entry` / `in` — the original flow entry (incoming message)
3363/// - `response` — synthesized from entry metadata for convenient condition checks
3364///   (e.g. `response.action` maps to `metadata.action` from the incoming envelope)
3365fn evaluate_custom_routing(
3366    raw: &Value,
3367    output: &NodeOutput,
3368    state: &ExecutionState,
3369    flow_ir: &HostFlow,
3370    node_id: &NodeId,
3371) -> CustomRoutingDecision {
3372    let routes = match raw.as_array() {
3373        Some(arr) => arr,
3374        None => {
3375            tracing::warn!(
3376                flow_id = %flow_ir.id,
3377                node_id = %node_id,
3378                "custom routing is not an array; terminating"
3379            );
3380            return CustomRoutingDecision::End;
3381        }
3382    };
3383
3384    // Build a rich context for condition evaluation:
3385    // Start with output payload, then overlay entry and synthesised "response".
3386    // The default `event` is chosen from the success/error-family port this node
3387    // actually routes on, so happy paths named `on_complete`/`on_submit` and
3388    // failure paths named `on_cancel`/`on_timeout` resolve instead of stalling
3389    // at `Wait`.
3390    let ctx = build_routing_context(
3391        output,
3392        state,
3393        default_success_event(routes),
3394        default_error_event(routes),
3395    );
3396
3397    let mut has_condition = false;
3398    for route in routes {
3399        let condition = route.get("condition").and_then(|v| v.as_str());
3400        let to = route.get("to").and_then(|v| v.as_str());
3401
3402        if let Some(cond) = condition {
3403            has_condition = true;
3404            if evaluate_simple_condition(cond, &ctx)
3405                && let Some(target) = to
3406                && let Ok(nid) = NodeId::new(target)
3407            {
3408                tracing::debug!(
3409                    flow_id = %flow_ir.id,
3410                    node_id = %node_id,
3411                    condition = cond,
3412                    target = target,
3413                    "conditional route matched"
3414                );
3415                return CustomRoutingDecision::Next(nid);
3416            }
3417        } else if let Some(target) = to
3418            && let Ok(nid) = NodeId::new(target)
3419        {
3420            tracing::debug!(
3421                flow_id = %flow_ir.id,
3422                node_id = %node_id,
3423                target = target,
3424                "default route taken"
3425            );
3426            return CustomRoutingDecision::Next(nid);
3427        }
3428    }
3429
3430    // Fall-through. When the routing array contained at least one
3431    // conditional entry, treat the unmatched fall-through as a pause: the
3432    // user's next submission should be re-evaluated against this same
3433    // node's routing rather than restarting the flow from the entry point.
3434    // Routing arrays with no conditions at all (pure unconditional `out`
3435    // terminators) remain true ends.
3436    if has_condition {
3437        tracing::debug!(
3438            flow_id = %flow_ir.id,
3439            node_id = %node_id,
3440            "no conditional route matched; pausing run at current node for resume"
3441        );
3442        CustomRoutingDecision::Wait
3443    } else {
3444        tracing::warn!(
3445            flow_id = %flow_ir.id,
3446            node_id = %node_id,
3447            "no route matched and no conditions present; terminating"
3448        );
3449        CustomRoutingDecision::End
3450    }
3451}
3452
3453/// Evaluate a simple condition expression used by `Routing::Custom` entries and
3454/// `conditional_branch` guards (e.g. `response.action == "about"`,
3455/// `register.q_age >= 18`, `msg.text contains "hello"`).
3456///
3457/// Dotted paths resolve against the JSON context; an unresolved path is false.
3458/// Operators (detected longest-token-first so `>=`/`<=` win over `>`/`<`):
3459/// - `== ` / `!=` — case-insensitive string equality.
3460/// - `>=` / `<=` / `>` / `<` — numeric ordering; both operands are parsed as
3461///   `f64`, and a non-numeric operand makes the condition false (never a panic).
3462/// - `contains` — case-insensitive substring of the resolved string.
3463fn evaluate_simple_condition(condition: &str, ctx: &Value) -> bool {
3464    if let Some((path, expected)) = split_condition(condition, "==") {
3465        return string_eq(ctx, path, expected, false);
3466    }
3467    if let Some((path, expected)) = split_condition(condition, "!=") {
3468        return string_eq(ctx, path, expected, true);
3469    }
3470    if let Some((path, expected)) = split_condition(condition, ">=") {
3471        return numeric_cmp(ctx, path, expected, |a, b| a >= b);
3472    }
3473    if let Some((path, expected)) = split_condition(condition, "<=") {
3474        return numeric_cmp(ctx, path, expected, |a, b| a <= b);
3475    }
3476    if let Some((path, expected)) = split_condition(condition, ">") {
3477        return numeric_cmp(ctx, path, expected, |a, b| a > b);
3478    }
3479    if let Some((path, expected)) = split_condition(condition, "<") {
3480        return numeric_cmp(ctx, path, expected, |a, b| a < b);
3481    }
3482    if let Some((path, expected)) = split_condition(condition, " contains ") {
3483        let needle = expected.to_lowercase();
3484        return resolve_dotted_path(ctx, path)
3485            .is_some_and(|actual| actual.to_lowercase().contains(&needle));
3486    }
3487    false
3488}
3489
3490/// Split a condition on the first occurrence of `op` into a trimmed
3491/// `(path, value)`, with surrounding quotes stripped from the value.
3492/// `None` when `op` is absent.
3493fn split_condition<'a>(condition: &'a str, op: &str) -> Option<(&'a str, &'a str)> {
3494    let idx = condition.find(op)?;
3495    let path = condition[..idx].trim();
3496    let value = condition[idx + op.len()..].trim().trim_matches('"');
3497    Some((path, value))
3498}
3499
3500/// Case-insensitive string equality of the resolved path against `expected`,
3501/// optionally negated. An unresolved path is treated as not-equal.
3502fn string_eq(ctx: &Value, path: &str, expected: &str, negate: bool) -> bool {
3503    let matches = resolve_dotted_path(ctx, path)
3504        .as_deref()
3505        .is_some_and(|a| a.eq_ignore_ascii_case(expected));
3506    if negate { !matches } else { matches }
3507}
3508
3509/// Numeric comparison of the resolved path against `expected`. Both sides are
3510/// parsed as `f64`; if either fails to parse the condition is false.
3511fn numeric_cmp(ctx: &Value, path: &str, expected: &str, cmp: impl Fn(f64, f64) -> bool) -> bool {
3512    let Some(actual) = resolve_dotted_path(ctx, path).and_then(|a| a.trim().parse::<f64>().ok())
3513    else {
3514        return false;
3515    };
3516    let Ok(rhs) = expected.parse::<f64>() else {
3517        return false;
3518    };
3519    cmp(actual, rhs)
3520}
3521
3522/// Resolve a dotted path like `response.action` against a JSON value.
3523fn resolve_dotted_path(value: &Value, path: &str) -> Option<String> {
3524    let parts: Vec<&str> = path.split('.').collect();
3525    let mut current = value;
3526    for part in &parts {
3527        current = current.get(part)?;
3528    }
3529    match current {
3530        Value::String(s) => Some(s.clone()),
3531        Value::Bool(b) => Some(b.to_string()),
3532        Value::Number(n) => Some(n.to_string()),
3533        _ => Some(current.to_string()),
3534    }
3535}
3536
3537/// Build a context object for routing condition evaluation.
3538///
3539/// The context merges the node output with the flow entry so that conditions
3540/// can reference both component results and incoming message data.
3541///
3542/// Layout:
3543/// ```text
3544/// {
3545///   ...output.payload...,     // top-level fields from component output
3546///   "entry": <flow entry>,
3547///   "in":    <flow entry>,    // alias
3548///   "response": {             // synthesised from envelope metadata
3549///     <key>: <value>,         // e.g. "action": "about"
3550///     ...
3551///   }
3552/// }
3553/// ```
3554/// Success-family outcome ports, in the priority order used to pick the default
3555/// success `event` for a node that succeeded without emitting an explicit
3556/// `outcome`. `on_success` is first so components whose success name is the
3557/// historical default keep routing unchanged (e.g. http).
3558const SUCCESS_EVENT_PORTS: [&str; 3] = ["on_success", "on_complete", "on_submit"];
3559
3560/// Error-family outcome ports, priority order, mirroring [`SUCCESS_EVENT_PORTS`]
3561/// for the failure (`ok == false`) branch. `on_error` is first so the historical
3562/// default is preserved; `on_cancel` / `on_timeout` let a node whose failure
3563/// port is named differently (qa cancel, http timeout) route instead of stalling.
3564const ERROR_EVENT_PORTS: [&str; 3] = ["on_error", "on_cancel", "on_timeout"];
3565
3566/// Whether a node opts into node_io error routing: a `Routing::Custom` array with
3567/// at least one route targeting an error-family port (`on_error` / `on_cancel` /
3568/// `on_timeout`), either as an explicit `event` field or via an `event == "<port>"`
3569/// condition (the form the designer emits). Such a node surfaces a component
3570/// failure as an `{errors}` output routed to that branch; every other node keeps
3571/// the historical hard-fail (`bail!`) on error — so this change is purely additive.
3572fn node_has_error_route(routing: &Routing) -> bool {
3573    let Routing::Custom(raw) = routing else {
3574        return false;
3575    };
3576    let Some(routes) = raw.as_array() else {
3577        return false;
3578    };
3579    routes.iter().any(|route| {
3580        let by_event = route
3581            .get("event")
3582            .and_then(Value::as_str)
3583            .is_some_and(|e| ERROR_EVENT_PORTS.contains(&e));
3584        let by_condition = route
3585            .get("condition")
3586            .and_then(Value::as_str)
3587            .is_some_and(|c| ERROR_EVENT_PORTS.iter().any(|port| c.contains(port)));
3588        by_event || by_condition
3589    })
3590}
3591
3592/// Derive the success `event` to default to when a node succeeds (`ok == true`)
3593/// but emits no explicit `outcome`. Designer-built nodes whose happy port is
3594/// `on_complete` (native `qa.process` / `llm.openai.chat` / `template_render`)
3595/// or `on_submit` (forms) compile to `event == "<port>"` conditions; with a
3596/// blanket `on_success` default those never match and the node stalls at
3597/// `Wait`. We instead pick the first success-family port the node actually has
3598/// an outgoing `event == "<port>"` edge for, so the happy path routes. Falls
3599/// back to `on_success` when no success-family port is referenced (preserving
3600/// the prior behaviour).
3601fn default_success_event(routes: &[Value]) -> &'static str {
3602    default_event(routes, &SUCCESS_EVENT_PORTS, "on_success")
3603}
3604
3605/// Failure-branch counterpart of [`default_success_event`]: the `event` to
3606/// default to when a node fails (`ok == false`) without an explicit `outcome`.
3607/// Picks the first error-family port the node actually routes on, falling back
3608/// to `on_error`.
3609fn default_error_event(routes: &[Value]) -> &'static str {
3610    default_event(routes, &ERROR_EVENT_PORTS, "on_error")
3611}
3612
3613/// Pick the first port in `ports` (priority order) that the node has an outgoing
3614/// `event == "<port>"` edge for; `fallback` when none is referenced.
3615fn default_event(routes: &[Value], ports: &[&'static str], fallback: &'static str) -> &'static str {
3616    let referenced: Vec<&str> = routes
3617        .iter()
3618        .filter_map(|route| route.get("condition").and_then(Value::as_str))
3619        .filter_map(condition_event_eq)
3620        .collect();
3621    ports
3622        .iter()
3623        .copied()
3624        .find(|port| referenced.contains(port))
3625        .unwrap_or(fallback)
3626}
3627
3628/// Extract `<value>` from an `event == "<value>"` condition; `None` for any
3629/// other shape (different path, `!=`, no `==`).
3630fn condition_event_eq(condition: &str) -> Option<&str> {
3631    let idx = condition.find("==")?;
3632    if condition[..idx].trim() != "event" {
3633        return None;
3634    }
3635    Some(condition[idx + 2..].trim().trim_matches('"'))
3636}
3637
3638fn build_routing_context(
3639    output: &NodeOutput,
3640    state: &ExecutionState,
3641    success_event: &str,
3642    error_event: &str,
3643) -> Value {
3644    let mut ctx = match &output.payload {
3645        Value::Object(map) => map.clone(),
3646        _ => JsonMap::new(),
3647    };
3648
3649    // Alias `in.input` to the entry itself when the entry is the bare message
3650    // (env/revision path) so routing templates that read `in.input.*` resolve,
3651    // mirroring `template_context`. Legacy `{input: <message>}` entries are
3652    // left untouched.
3653    let entry = alias_input_to_entry(state.entry.clone());
3654    ctx.insert("entry".into(), entry.clone());
3655    ctx.insert("in".into(), entry.clone());
3656
3657    // Synthesise "response" from the envelope metadata.
3658    // greentic-start demo path: entry.input.metadata.*
3659    // greentic-runner direct path: entry.metadata.*
3660    let metadata = entry
3661        .pointer("/input/metadata")
3662        .or_else(|| entry.pointer("/metadata"));
3663
3664    let mut response = JsonMap::new();
3665    if let Some(Value::Object(meta)) = metadata {
3666        for (k, v) in meta {
3667            // Flatten string values; stringify others
3668            match v {
3669                Value::String(s) => {
3670                    response.insert(k.clone(), Value::String(s.clone()));
3671                }
3672                other => {
3673                    response.insert(k.clone(), other.clone());
3674                }
3675            }
3676        }
3677    }
3678    // Also pull text from the envelope for convenience
3679    if let Some(text) = entry
3680        .pointer("/input/text")
3681        .or_else(|| entry.pointer("/text"))
3682        .filter(|t| !t.is_null())
3683    {
3684        response.insert("text".into(), text.clone());
3685    }
3686    ctx.insert("response".into(), Value::Object(response));
3687
3688    // Inject the node's outcome as `event` so port-name routing
3689    // (`event == "<outcome>"`, emitted by the designer for nodes with multiple
3690    // outgoing edges) resolves. Prefer an explicit outcome the node emitted in
3691    // its output metadata; otherwise derive a default from `ok` — `success_event`
3692    // on success / `error_event` on failure (the success/error-family port the
3693    // node actually has an edge for; see `default_success_event` /
3694    // `default_error_event`). Without this, a multi-edge node falls through to
3695    // `Wait` at runtime.
3696    let event = output
3697        .meta
3698        .get("outcome")
3699        .and_then(Value::as_str)
3700        .map(str::to_string)
3701        .unwrap_or_else(|| {
3702            if output.ok {
3703                success_event
3704            } else {
3705                error_event
3706            }
3707            .to_string()
3708        });
3709    ctx.insert("event".into(), Value::String(event));
3710
3711    Value::Object(ctx)
3712}
3713
3714/// Pure autonomy-gate decision for an `approval.call` node. Returns `true` when
3715/// the request must go to a human (dispatch), `false` when it auto-approves.
3716///
3717/// The gate config fields (`mode`, `risk_threshold`, `confidence_threshold`)
3718/// are compiled by the designer as FLAT fields directly on the node input
3719/// (not nested under a `gate` object); `risk`/`confidence` are already
3720/// flat/dynamic values populated at flow render time.
3721fn approval_requires_human(input: &Value) -> bool {
3722    let mode = input
3723        .get("mode")
3724        .and_then(Value::as_str)
3725        .unwrap_or("always");
3726    match mode {
3727        "above_risk" => {
3728            let risk = input.get("risk").and_then(Value::as_f64).unwrap_or(0.0);
3729            let threshold = input
3730                .get("risk_threshold")
3731                .and_then(Value::as_f64)
3732                .unwrap_or(1.0);
3733            risk >= threshold
3734        }
3735        "above_confidence" => {
3736            let confidence = input
3737                .get("confidence")
3738                .and_then(Value::as_f64)
3739                .unwrap_or(0.0);
3740            let threshold = input
3741                .get("confidence_threshold")
3742                .and_then(Value::as_f64)
3743                .unwrap_or(1.0);
3744            confidence < threshold
3745        }
3746        // "always" and any unknown mode fail safe: require a human.
3747        _ => true,
3748    }
3749}
3750
3751#[cfg(test)]
3752mod approval_gate_tests {
3753    use super::approval_requires_human;
3754    use serde_json::json;
3755
3756    #[test]
3757    fn above_risk_auto_approves_below_threshold() {
3758        let input = json!({ "risk": 0.5, "mode": "above_risk", "risk_threshold": 0.7 });
3759        assert!(!approval_requires_human(&input));
3760    }
3761
3762    #[test]
3763    fn above_risk_requires_human_at_or_above_threshold() {
3764        let input = json!({ "risk": 0.9, "mode": "above_risk", "risk_threshold": 0.7 });
3765        assert!(approval_requires_human(&input));
3766    }
3767
3768    #[test]
3769    fn above_confidence_requires_human_when_low_confidence() {
3770        let input =
3771            json!({ "confidence": 0.4, "mode": "above_confidence", "confidence_threshold": 0.8 });
3772        assert!(approval_requires_human(&input));
3773    }
3774
3775    #[test]
3776    fn always_and_missing_gate_require_human() {
3777        assert!(approval_requires_human(&json!({ "mode": "always" })));
3778        assert!(approval_requires_human(&json!({})));
3779    }
3780}
3781
3782#[cfg(test)]
3783mod tests {
3784    use super::*;
3785    use crate::validate::{ValidationConfig, ValidationMode};
3786    use greentic_types::{
3787        Flow, FlowComponentRef, FlowId, FlowKind, InputMapping, Node, NodeId, OutputMapping,
3788        Routing, TelemetryHints,
3789    };
3790    use serde_json::json;
3791    use std::collections::{BTreeMap, HashMap as StdHashMap};
3792    use std::str::FromStr;
3793    use std::sync::Mutex;
3794    use tokio::runtime::Runtime;
3795
3796    fn minimal_engine() -> FlowEngine {
3797        FlowEngine {
3798            packs: Vec::new(),
3799            flows: Vec::new(),
3800            flow_sources: HashMap::new(),
3801            messaging_provider_pack_ids: std::collections::HashSet::new(),
3802            flow_cache: RwLock::new(HashMap::new()),
3803            default_env: "local".to_string(),
3804            validation: ValidationConfig {
3805                mode: ValidationMode::Off,
3806            },
3807            cross_pack_resolver: None,
3808            rollout_ids: RolloutIds::default(),
3809            remote_dispatch_handler: None,
3810            #[cfg(feature = "agentic-worker")]
3811            dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
3812            #[cfg(feature = "agentic-worker")]
3813            agent_node_handler: None,
3814            #[cfg(feature = "agentic-worker")]
3815            graph_node_handler: None,
3816            #[cfg(feature = "agentic-worker")]
3817            mcp_tool_source: None,
3818        }
3819    }
3820
3821    fn flow_desc(id: &str, pack_id: &str, flow_type: &str, entry: bool) -> FlowDescriptor {
3822        FlowDescriptor {
3823            id: id.into(),
3824            flow_type: flow_type.into(),
3825            pack_id: pack_id.into(),
3826            profile: pack_id.into(),
3827            version: "0.0.0".into(),
3828            description: None,
3829            entry,
3830        }
3831    }
3832
3833    #[test]
3834    fn entry_flow_by_type_disambiguates_entrypoint_from_internal_helpers() {
3835        // Regression: a pack with one public messaging entrypoint (`default`)
3836        // plus internal helper flows of the same type (dispatcher sub-flows)
3837        // must route an inbound, type-only provider event to the entrypoint —
3838        // NOT fail as "flow type messaging is ambiguous; pack_id is required".
3839        let mut engine = minimal_engine();
3840        engine.flows = vec![
3841            flow_desc("default", "weatherapi-pack", "messaging", true),
3842            flow_desc("flow_", "weatherapi-pack", "messaging", false),
3843            flow_desc("flow_error", "weatherapi-pack", "messaging", false),
3844            flow_desc("flow_get_weather", "weatherapi-pack", "messaging", false),
3845        ];
3846
3847        // Multiple flows of the type => the plain lookup is ambiguous...
3848        assert!(
3849            engine.flow_by_type("messaging").is_none(),
3850            "multiple messaging flows must be ambiguous for the plain lookup"
3851        );
3852        // ...but exactly one is an entrypoint, so entry-aware routing resolves.
3853        let resolved = engine
3854            .entry_flow_by_type("messaging")
3855            .expect("single entry flow must resolve");
3856        assert_eq!(resolved.id, "default");
3857        assert_eq!(resolved.pack_id, "weatherapi-pack");
3858    }
3859
3860    #[test]
3861    fn entry_flow_by_type_still_ambiguous_across_two_entrypoints() {
3862        // Two entrypoints of the same type across packs is genuinely ambiguous
3863        // and must still require a pack_id (no silent, arbitrary pick).
3864        let mut engine = minimal_engine();
3865        engine.flows = vec![
3866            flow_desc("default", "pack.a", "messaging", true),
3867            flow_desc("default", "pack.b", "messaging", true),
3868            flow_desc("helper", "pack.a", "messaging", false),
3869        ];
3870        assert!(engine.entry_flow_by_type("messaging").is_none());
3871    }
3872
3873    #[test]
3874    fn entry_flow_by_type_excludes_messaging_provider_pack_flows() {
3875        // Multi-provider bundle: the app pack's entry flow AND a messaging
3876        // *provider* pack's ingress `main` are both entry `messaging` flows.
3877        // The provider flow is that provider's plumbing, not the application
3878        // entrypoint, so a type-only webchat event must resolve to the app flow
3879        // — not bail "flow type messaging is ambiguous; pack_id is required".
3880        let mut engine = minimal_engine();
3881        engine.flows = vec![
3882            flow_desc("main", "hr-onboarding-pack", "messaging", true),
3883            flow_desc("main", "messaging-teams", "messaging", true),
3884        ];
3885        // `messaging-teams` declares a `messaging.*` provider in its manifest;
3886        // the engine records that at build time.
3887        engine
3888            .messaging_provider_pack_ids
3889            .insert("messaging-teams".to_string());
3890
3891        // Plain lookup is still ambiguous (two flows of the type)...
3892        assert!(engine.flow_by_type("messaging").is_none());
3893        // ...but only the app pack's flow is an *application* entrypoint.
3894        let resolved = engine
3895            .entry_flow_by_type("messaging")
3896            .expect("app entry flow must resolve past the provider flow");
3897        assert_eq!(resolved.id, "main");
3898        assert_eq!(resolved.pack_id, "hr-onboarding-pack");
3899    }
3900
3901    #[test]
3902    fn entry_flow_by_type_matches_plain_lookup_for_single_flow() {
3903        // Backward-compat: a lone flow of a type resolves the same way through
3904        // both paths, tagged entry or not.
3905        let mut engine = minimal_engine();
3906        engine.flows = vec![flow_desc("only", "pack.a", "messaging", true)];
3907        assert_eq!(
3908            engine.flow_by_type("messaging").map(|f| f.id.as_str()),
3909            Some("only")
3910        );
3911        assert_eq!(
3912            engine
3913                .entry_flow_by_type("messaging")
3914                .map(|f| f.id.as_str()),
3915            Some("only")
3916        );
3917    }
3918
3919    #[test]
3920    fn to_node_output_legacy_success_becomes_data() {
3921        // Legacy `{ok:true, ...fields}` (no node_io envelope) → Data{data}.
3922        let out = to_node_output(&json!({ "ok": true, "temp": "20C" }));
3923        assert!(out.is_ok(), "legacy ok:true must classify as Data");
3924        let data = out.data().expect("data present");
3925        assert_eq!(data.get("temp").and_then(Value::as_str), Some("20C"));
3926    }
3927
3928    #[test]
3929    fn to_node_output_legacy_error_becomes_errors() {
3930        // Legacy `{ok:false, error:{code,message}}` → Errors{errors:[NodeError]}.
3931        let out = to_node_output(
3932            &json!({ "ok": false, "error": { "code": "E_BAD", "message": "boom" } }),
3933        );
3934        assert!(!out.is_ok(), "legacy ok:false must classify as Errors");
3935        let errs = out.errors();
3936        assert_eq!(errs.len(), 1);
3937        assert_eq!(errs[0].code, "E_BAD");
3938        assert_eq!(errs[0].message, "boom");
3939    }
3940
3941    #[test]
3942    fn to_node_output_native_data_envelope_roundtrips() {
3943        // A node_io-native `{data:{...}}` envelope parses straight to Data.
3944        let out = to_node_output(&json!({ "data": { "x": 1 } }));
3945        assert!(out.is_ok());
3946        assert_eq!(
3947            out.data().and_then(|d| d.get("x")).and_then(Value::as_i64),
3948            Some(1)
3949        );
3950    }
3951
3952    #[test]
3953    fn to_node_output_native_errors_envelope_roundtrips() {
3954        // A node_io-native `{errors:[...]}` envelope parses straight to Errors.
3955        let out = to_node_output(&json!({
3956            "errors": [ { "code": "C", "message": "m", "kind": "validation",
3957                          "retryable": false, "details": {} } ]
3958        }));
3959        assert!(!out.is_ok());
3960        assert_eq!(out.errors()[0].code, "C");
3961        assert_eq!(
3962            out.errors()[0].kind,
3963            greentic_types::node_io::ErrorKind::Validation
3964        );
3965    }
3966
3967    #[test]
3968    fn to_node_output_bare_object_becomes_data() {
3969        // A bare result with no envelope keys → Data{data: <whole value>}.
3970        let out = to_node_output(&json!({ "foo": 1 }));
3971        assert!(out.is_ok());
3972        assert_eq!(
3973            out.data()
3974                .and_then(|d| d.get("foo"))
3975                .and_then(Value::as_i64),
3976            Some(1)
3977        );
3978    }
3979
3980    #[test]
3981    fn templating_renders_with_partials_and_data() {
3982        let mut state = ExecutionState::new(json!({ "city": "London" }));
3983        state.nodes.insert(
3984            "forecast".to_string(),
3985            NodeOutput::new(json!({ "temp": "20C" })),
3986        );
3987
3988        // templating context includes node outputs for runner-side payload rendering.
3989        let ctx = state.context();
3990        assert_eq!(ctx["nodes"]["forecast"]["payload"]["temp"], json!("20C"));
3991    }
3992
3993    #[test]
3994    fn outputs_map_exposes_node_io_data_and_errors_alongside_flat() {
3995        let mut state = ExecutionState::new(json!({}));
3996        state.nodes.insert(
3997            "forecast".to_string(),
3998            NodeOutput::new(json!({ "temp": "20C" })),
3999        );
4000        let outs = state.outputs_map();
4001        // Legacy flat ref `{{node.forecast.temp}}` keeps working.
4002        assert_eq!(outs["forecast"]["temp"], json!("20C"));
4003        // Canonical node_io ref `{{node.forecast.data.temp}}` resolves to the same.
4004        assert_eq!(outs["forecast"]["data"]["temp"], json!("20C"));
4005        // `{{node.forecast.errors}}` is present and empty for a success output.
4006        assert_eq!(outs["forecast"]["errors"], json!([]));
4007    }
4008
4009    #[test]
4010    fn finalize_wraps_emitted_payloads() {
4011        let mut state = ExecutionState::new(json!({}));
4012        state.push_egress(json!({ "text": "first" }));
4013        state.push_egress(json!({ "text": "second" }));
4014        let result = state.finalize_with(Some(json!({ "text": "final" })));
4015        assert_eq!(
4016            result,
4017            json!([
4018                { "text": "first" },
4019                { "text": "second" },
4020                { "text": "final" }
4021            ])
4022        );
4023    }
4024
4025    #[test]
4026    fn finalize_does_not_double_terminal_emit_response() {
4027        // Regression: a terminal `emit.response` node pushes its card to egress
4028        // AND returns it as the node output, which the `End` path passes as
4029        // `final_payload`. The card must appear ONCE, not twice (the webchat
4030        // "double card").
4031        let card = json!({ "renderedCard": { "type": "AdaptiveCard" } });
4032        let mut state = ExecutionState::new(json!({}));
4033        state.push_egress(card.clone());
4034        let result = state.finalize_with(Some(card.clone()));
4035        assert_eq!(result, json!([card]));
4036    }
4037
4038    #[test]
4039    fn finalize_still_appends_distinct_terminal_output() {
4040        // A terminal output that differs from the last emitted response is a
4041        // genuine additional reply and must still be appended.
4042        let mut state = ExecutionState::new(json!({}));
4043        state.push_egress(json!({ "text": "emitted" }));
4044        let result = state.finalize_with(Some(json!({ "text": "final" })));
4045        assert_eq!(result, json!([{ "text": "emitted" }, { "text": "final" }]));
4046    }
4047
4048    #[test]
4049    fn alias_input_to_entry_exposes_input_for_bare_message() {
4050        // Env/revision path: the flow entry IS the message — metadata at the
4051        // top level, no `input` wrapper. After aliasing, the pack's
4052        // `in.input.metadata.*` template resolves the same as `in.metadata.*`.
4053        let msg = json!({ "text": "hi", "metadata": { "operation": "get_weather" } });
4054        let aliased = alias_input_to_entry(msg);
4055        assert_eq!(
4056            aliased.pointer("/metadata/operation"),
4057            Some(&json!("get_weather"))
4058        );
4059        assert_eq!(
4060            aliased.pointer("/input/metadata/operation"),
4061            Some(&json!("get_weather"))
4062        );
4063    }
4064
4065    #[test]
4066    fn alias_input_to_entry_preserves_explicit_input_wrapper() {
4067        // Legacy `{input: <message>}` entries must not be double-wrapped.
4068        let wrapped = json!({ "input": { "metadata": { "operation": "x" } } });
4069        assert_eq!(alias_input_to_entry(wrapped.clone()), wrapped);
4070    }
4071
4072    #[test]
4073    fn alias_input_to_entry_ignores_non_objects() {
4074        assert_eq!(alias_input_to_entry(json!("hi")), json!("hi"));
4075        assert_eq!(alias_input_to_entry(json!(null)), json!(null));
4076    }
4077
4078    #[test]
4079    fn finalize_flattens_final_array() {
4080        let mut state = ExecutionState::new(json!({}));
4081        state.push_egress(json!({ "text": "only" }));
4082        let result = state.finalize_with(Some(json!([
4083            { "text": "extra-1" },
4084            { "text": "extra-2" }
4085        ])));
4086        assert_eq!(
4087            result,
4088            json!([
4089                { "text": "only" },
4090                { "text": "extra-1" },
4091                { "text": "extra-2" }
4092            ])
4093        );
4094    }
4095
4096    #[test]
4097    fn inject_card_locale_uses_entry_metadata_without_overwriting_payload() {
4098        let mut payload = json!({
4099            "card_source": "inline",
4100            "card_spec": { "title": "Hello" }
4101        });
4102        inject_card_locale(
4103            &mut payload,
4104            &json!({"input": {"metadata": {"locale": "nl-NL"}}}),
4105        );
4106        assert_eq!(payload["locale"], json!("nl-NL"));
4107
4108        let mut existing = json!({
4109            "card_source": "inline",
4110            "card_spec": { "title": "Hello" },
4111            "locale": "en-GB"
4112        });
4113        inject_card_locale(&mut existing, &json!({"metadata": {"locale": "nl-NL"}}));
4114        assert_eq!(existing["locale"], json!("en-GB"));
4115    }
4116
4117    #[test]
4118    fn load_i18n_bundle_entries_reads_manifest_and_falls_back_to_en() {
4119        let assets = StdHashMap::from([
4120            (
4121                "cards/i18n/_manifest.json".to_string(),
4122                br#"{"locales":["de"]}"#.to_vec(),
4123            ),
4124            (
4125                "cards/i18n/de.json".to_string(),
4126                br#"{"title":"Hallo"}"#.to_vec(),
4127            ),
4128            (
4129                "cards/i18n/en.json".to_string(),
4130                br#"{"title":"Hello"}"#.to_vec(),
4131            ),
4132        ]);
4133
4134        let entries = load_i18n_bundle_entries("cards/i18n", |path| {
4135            assets
4136                .get(path)
4137                .cloned()
4138                .with_context(|| format!("missing asset {path}"))
4139        });
4140
4141        assert_eq!(entries["de"]["title"], json!("Hallo"));
4142        assert_eq!(entries["en"]["title"], json!("Hello"));
4143    }
4144
4145    #[test]
4146    fn load_i18n_bundle_entries_reads_single_file_bundle() {
4147        let entries = load_i18n_bundle_entries("cards/i18n.json", |path| {
4148            if path == "cards/i18n.json" {
4149                Ok(br#"{"title":"Hello"}"#.to_vec())
4150            } else {
4151                bail!("unexpected asset {path}");
4152            }
4153        });
4154
4155        assert_eq!(entries["en"]["title"], json!("Hello"));
4156    }
4157
4158    struct TestCrossPackResolver;
4159
4160    impl CrossPackResolver for TestCrossPackResolver {
4161        fn invoke(
4162            &self,
4163            provider_id: &str,
4164            provider_type: Option<&str>,
4165            op: &str,
4166            input: &[u8],
4167            tenant: &str,
4168            team: Option<&str>,
4169        ) -> Result<Value> {
4170            Ok(json!({
4171                "provider_id": provider_id,
4172                "provider_type": provider_type,
4173                "op": op,
4174                "tenant": tenant,
4175                "team": team,
4176                "input": serde_json::from_slice::<Value>(input)?,
4177            }))
4178        }
4179    }
4180
4181    #[test]
4182    fn cross_pack_resolver_returns_node_output_when_present() {
4183        let mut engine = minimal_engine();
4184        engine.set_cross_pack_resolver(Arc::new(TestCrossPackResolver));
4185
4186        let output = engine
4187            .try_invoke_cross_pack_resolver(
4188                Some("mail"),
4189                Some("messaging"),
4190                "send",
4191                br#"{"subject":"hello"}"#,
4192                "demo",
4193            )
4194            .expect("resolver invocation")
4195            .expect("resolver output");
4196
4197        assert_eq!(
4198            output.payload,
4199            json!({
4200                "provider_id": "mail",
4201                "provider_type": "messaging",
4202                "op": "send",
4203                "tenant": "demo",
4204                "team": null,
4205                "input": { "subject": "hello" },
4206            })
4207        );
4208    }
4209
4210    #[test]
4211    fn parse_component_control_ignores_plain_payload() {
4212        let payload = json!({
4213            "flow": "not-a-control-field",
4214            "node": "n1"
4215        });
4216        let control = parse_component_control(&payload).expect("parse control");
4217        assert!(control.is_none());
4218    }
4219
4220    #[test]
4221    fn parse_component_control_parses_jump_marker() {
4222        let payload = json!({
4223            "greentic_control": {
4224                "action": "jump",
4225                "v": 1,
4226                "flow": "flow.b",
4227                "node": "node-2",
4228                "payload": { "message": "hi" },
4229                "hints": { "k": "v" },
4230                "max_redirects": 2,
4231                "reason": "handoff"
4232            }
4233        });
4234        let control = parse_component_control(&payload)
4235            .expect("parse control")
4236            .expect("missing control");
4237        match control {
4238            NodeControl::Jump(jump) => {
4239                assert_eq!(jump.flow, "flow.b");
4240                assert_eq!(jump.node.as_deref(), Some("node-2"));
4241                assert_eq!(jump.payload, json!({ "message": "hi" }));
4242                assert_eq!(jump.hints, json!({ "k": "v" }));
4243                assert_eq!(jump.max_redirects, Some(2));
4244                assert_eq!(jump.reason.as_deref(), Some("handoff"));
4245            }
4246            other => panic!("expected jump control, got {other:?}"),
4247        }
4248    }
4249
4250    #[test]
4251    fn parse_component_control_rejects_invalid_marker() {
4252        let payload = json!({
4253            "greentic_control": "bad-shape"
4254        });
4255        let err = parse_component_control(&payload).expect_err("expected invalid marker error");
4256        assert!(err.to_string().contains("greentic_control"));
4257    }
4258
4259    #[test]
4260    fn missing_operation_reports_node_and_component() {
4261        let engine = minimal_engine();
4262        let rt = Runtime::new().unwrap();
4263        let retry_config = RetryConfig {
4264            max_attempts: 1,
4265            base_delay_ms: 1,
4266        };
4267        let ctx = FlowContext {
4268            tenant: "tenant",
4269            pack_id: "test-pack",
4270            flow_id: "flow",
4271            node_id: Some("missing-op"),
4272            tool: None,
4273            action: None,
4274            session_id: None,
4275            provider_id: None,
4276            reply_scope: None,
4277            retry_config,
4278            attempt: 1,
4279            observer: None,
4280            mocks: None,
4281        };
4282        let node = HostNode {
4283            kind: NodeKind::Exec {
4284                target_component: "qa.process".into(),
4285            },
4286            component: "component.exec".into(),
4287            component_id: "component.exec".into(),
4288            operation_name: None,
4289            operation_in_mapping: None,
4290            payload_expr: Value::Null,
4291            routing: Routing::End,
4292        };
4293        let _state = ExecutionState::new(Value::Null);
4294        let payload = json!({ "component": "qa.process" });
4295        let event = NodeEvent {
4296            context: &ctx,
4297            node_id: "missing-op",
4298            node: &node,
4299            payload: &payload,
4300        };
4301        let err = rt
4302            .block_on(engine.execute_component_exec(
4303                &ctx,
4304                "missing-op",
4305                &node,
4306                payload.clone(),
4307                &event,
4308                ComponentOverrides {
4309                    component: None,
4310                    operation: None,
4311                },
4312            ))
4313            .unwrap_err();
4314        let message = err.to_string();
4315        assert!(
4316            message.contains("missing operation for node `missing-op`"),
4317            "unexpected message: {message}"
4318        );
4319        assert!(
4320            message.contains("(component `component.exec`)"),
4321            "unexpected message: {message}"
4322        );
4323    }
4324
4325    #[test]
4326    fn missing_operation_mentions_mapping_hint() {
4327        let engine = minimal_engine();
4328        let rt = Runtime::new().unwrap();
4329        let retry_config = RetryConfig {
4330            max_attempts: 1,
4331            base_delay_ms: 1,
4332        };
4333        let ctx = FlowContext {
4334            tenant: "tenant",
4335            pack_id: "test-pack",
4336            flow_id: "flow",
4337            node_id: Some("missing-op-hint"),
4338            tool: None,
4339            action: None,
4340            session_id: None,
4341            provider_id: None,
4342            reply_scope: None,
4343            retry_config,
4344            attempt: 1,
4345            observer: None,
4346            mocks: None,
4347        };
4348        let node = HostNode {
4349            kind: NodeKind::Exec {
4350                target_component: "qa.process".into(),
4351            },
4352            component: "component.exec".into(),
4353            component_id: "component.exec".into(),
4354            operation_name: None,
4355            operation_in_mapping: Some("render".into()),
4356            payload_expr: Value::Null,
4357            routing: Routing::End,
4358        };
4359        let _state = ExecutionState::new(Value::Null);
4360        let payload = json!({ "component": "qa.process" });
4361        let event = NodeEvent {
4362            context: &ctx,
4363            node_id: "missing-op-hint",
4364            node: &node,
4365            payload: &payload,
4366        };
4367        let err = rt
4368            .block_on(engine.execute_component_exec(
4369                &ctx,
4370                "missing-op-hint",
4371                &node,
4372                payload.clone(),
4373                &event,
4374                ComponentOverrides {
4375                    component: None,
4376                    operation: None,
4377                },
4378            ))
4379            .unwrap_err();
4380        let message = err.to_string();
4381        assert!(
4382            message.contains("missing operation for node `missing-op-hint`"),
4383            "unexpected message: {message}"
4384        );
4385        assert!(
4386            message.contains("Found operation in input.mapping (`render`)"),
4387            "unexpected message: {message}"
4388        );
4389    }
4390
4391    struct CountingObserver {
4392        starts: Mutex<Vec<String>>,
4393        ends: Mutex<Vec<Value>>,
4394    }
4395
4396    impl CountingObserver {
4397        fn new() -> Self {
4398            Self {
4399                starts: Mutex::new(Vec::new()),
4400                ends: Mutex::new(Vec::new()),
4401            }
4402        }
4403    }
4404
4405    impl ExecutionObserver for CountingObserver {
4406        fn on_node_start(&self, event: &NodeEvent<'_>) {
4407            self.starts.lock().unwrap().push(event.node_id.to_string());
4408        }
4409
4410        fn on_node_end(&self, _event: &NodeEvent<'_>, output: &Value) {
4411            self.ends.lock().unwrap().push(output.clone());
4412        }
4413
4414        fn on_node_error(&self, _event: &NodeEvent<'_>, _error: &dyn StdError) {}
4415    }
4416
4417    #[test]
4418    fn emits_end_event_for_successful_node() {
4419        let node_id = NodeId::from_str("emit").unwrap();
4420        let node = Node {
4421            id: node_id.clone(),
4422            component: FlowComponentRef {
4423                id: "emit.log".parse().unwrap(),
4424                pack_alias: None,
4425                operation: None,
4426            },
4427            input: InputMapping {
4428                mapping: json!({ "message": "logged" }),
4429            },
4430            output: OutputMapping {
4431                mapping: Value::Null,
4432            },
4433            err_map: None,
4434            routing: Routing::End,
4435            telemetry: TelemetryHints::default(),
4436            conversational: false,
4437        };
4438        let mut nodes = indexmap::IndexMap::default();
4439        nodes.insert(node_id.clone(), node);
4440        let flow = Flow {
4441            schema_version: "1.0".into(),
4442            id: FlowId::from_str("emit.flow").unwrap(),
4443            kind: FlowKind::Messaging,
4444            entrypoints: BTreeMap::from([(
4445                "default".to_string(),
4446                Value::String(node_id.to_string()),
4447            )]),
4448            nodes,
4449            metadata: Default::default(),
4450        };
4451        let host_flow = HostFlow::from(flow);
4452
4453        let engine = FlowEngine {
4454            packs: Vec::new(),
4455            flows: Vec::new(),
4456            flow_sources: HashMap::new(),
4457            messaging_provider_pack_ids: std::collections::HashSet::new(),
4458            flow_cache: RwLock::new(HashMap::from([(
4459                FlowKey {
4460                    pack_id: "test-pack".to_string(),
4461                    flow_id: "emit.flow".to_string(),
4462                },
4463                host_flow,
4464            )])),
4465            default_env: "local".to_string(),
4466            validation: ValidationConfig {
4467                mode: ValidationMode::Off,
4468            },
4469            cross_pack_resolver: None,
4470            rollout_ids: RolloutIds::default(),
4471            remote_dispatch_handler: None,
4472            #[cfg(feature = "agentic-worker")]
4473            dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
4474            #[cfg(feature = "agentic-worker")]
4475            agent_node_handler: None,
4476            #[cfg(feature = "agentic-worker")]
4477            graph_node_handler: None,
4478            #[cfg(feature = "agentic-worker")]
4479            mcp_tool_source: None,
4480        };
4481        let observer = CountingObserver::new();
4482        let ctx = FlowContext {
4483            tenant: "demo",
4484            pack_id: "test-pack",
4485            flow_id: "emit.flow",
4486            node_id: None,
4487            tool: None,
4488            action: None,
4489            session_id: None,
4490            provider_id: None,
4491            reply_scope: None,
4492            retry_config: RetryConfig {
4493                max_attempts: 1,
4494                base_delay_ms: 1,
4495            },
4496            attempt: 1,
4497            observer: Some(&observer),
4498            mocks: None,
4499        };
4500
4501        let rt = Runtime::new().unwrap();
4502        let result = rt.block_on(engine.execute(ctx, Value::Null)).unwrap();
4503        assert!(matches!(result.status, FlowStatus::Completed));
4504
4505        let starts = observer.starts.lock().unwrap();
4506        let ends = observer.ends.lock().unwrap();
4507        assert_eq!(starts.len(), 1);
4508        assert_eq!(ends.len(), 1);
4509        assert_eq!(ends[0], json!({ "message": "logged" }));
4510    }
4511
4512    #[test]
4513    fn dotted_component_id_with_mapping_operation_is_not_split() {
4514        // greentic-pack resolves a component node to a bare component symbol and
4515        // keeps the operation in the input mapping. The runtime must NOT split the
4516        // dotted symbol on the last dot (which would yield `ai.greentic`, "not
4517        // found in pack"); the structured mapping operation makes the id a
4518        // complete reference.
4519        let node = Node {
4520            id: NodeId::from_str("render").unwrap(),
4521            component: FlowComponentRef {
4522                id: "ai.greentic.component-templates".parse().unwrap(),
4523                pack_alias: None,
4524                operation: None,
4525            },
4526            input: InputMapping {
4527                mapping: json!({ "operation": "handle_message", "input": "hi" }),
4528            },
4529            output: OutputMapping {
4530                mapping: Value::Null,
4531            },
4532            err_map: None,
4533            routing: Routing::End,
4534            telemetry: TelemetryHints::default(),
4535            conversational: false,
4536        };
4537        let host = HostNode::from(node);
4538        assert!(
4539            matches!(&host.kind, NodeKind::PackComponent { component_ref } if component_ref == "ai.greentic.component-templates"),
4540            "dotted component id must stay intact, got kind {:?}",
4541            host.kind
4542        );
4543        assert_eq!(host.component, "ai.greentic.component-templates");
4544        assert_eq!(host.operation_in_mapping(), Some("handle_message"));
4545    }
4546
4547    #[test]
4548    fn packed_component_operation_id_still_splits_without_mapping_operation() {
4549        // Legacy encoding: the operation is packed into the id as
4550        // `<component>.<operation>` and absent from the mapping. The last-dot
4551        // split must still recover it.
4552        let node = Node {
4553            id: NodeId::from_str("render").unwrap(),
4554            component: FlowComponentRef {
4555                id: "templating.handlebars".parse().unwrap(),
4556                pack_alias: None,
4557                operation: None,
4558            },
4559            input: InputMapping {
4560                mapping: json!({ "text": "hello" }),
4561            },
4562            output: OutputMapping {
4563                mapping: Value::Null,
4564            },
4565            err_map: None,
4566            routing: Routing::End,
4567            telemetry: TelemetryHints::default(),
4568            conversational: false,
4569        };
4570        let host = HostNode::from(node);
4571        assert!(
4572            matches!(&host.kind, NodeKind::PackComponent { component_ref } if component_ref == "templating"),
4573            "packed <component>.<operation> id must split, got kind {:?}",
4574            host.kind
4575        );
4576        assert_eq!(host.operation_name(), Some("handlebars"));
4577    }
4578
4579    #[cfg(feature = "agentic-worker")]
4580    #[test]
4581    fn dw_agent_node_routes_to_handler_and_returns_reply() {
4582        use crate::runner::agent_node::{AgentNodeHandler, RuntimeAgentNodeHandler};
4583        use greentic_aw_runtime::cost::MockTokenMeter;
4584        use greentic_aw_runtime::llm::LlmResponse;
4585        use greentic_aw_runtime::mock::{
4586            MockAgentStateStore, MockConfigProvider, MockLlmBackend, MockTelemetry, NoopToolLedger,
4587        };
4588        use greentic_aw_runtime::{
4589            AgentConfig, AgentLimits, AgentRuntime, LlmProviderRef, TenantContext,
4590        };
4591
4592        // --- mock-backed AgentRuntime: the LLM replies "pong" in one step ---
4593        let llm = Arc::new(MockLlmBackend::new(vec![Ok(LlmResponse {
4594            content: Some("pong".into()),
4595            tool_calls: vec![],
4596            tokens_in: 1,
4597            tokens_out: 1,
4598        })]));
4599        let store = Arc::new(MockAgentStateStore::new());
4600        let telemetry = Arc::new(MockTelemetry::new());
4601
4602        // The dispatch builds TenantContext::new(ctx.tenant, default_env) =
4603        // ("demo", "local"). MockConfigProvider keys by
4604        // `format!("{}:{agent_id}", tenant.key_prefix())` = "aw:demo:local:greeter",
4605        // so seed with the SAME tenant+env+agent_id the engine will look up.
4606        let config_provider = MockConfigProvider::new();
4607        let tenant = TenantContext::new("demo", "local");
4608        config_provider.insert(
4609            &tenant,
4610            "greeter",
4611            AgentConfig {
4612                agent_id: "greeter".into(),
4613                system_prompt: "sys".into(),
4614                tools: vec![],
4615                guardrails: vec![],
4616                llm: LlmProviderRef {
4617                    provider: "mock".into(),
4618                    model: "m".into(),
4619                    credential_ref: None,
4620                },
4621                limits: AgentLimits::default(),
4622                memory: None,
4623                knowledge: None,
4624            },
4625        );
4626        let config_provider = Arc::new(config_provider);
4627        let token_meter = Arc::new(MockTokenMeter::new(0));
4628        let ledger = Arc::new(NoopToolLedger);
4629        let ext_runtime = Arc::new(greentic_ext_runtime::ExtensionRuntime::for_test());
4630        let runtime = Arc::new(AgentRuntime::new(
4631            config_provider,
4632            store,
4633            ext_runtime,
4634            llm,
4635            telemetry,
4636            token_meter,
4637            ledger,
4638            None,
4639        ));
4640        let handler: Arc<dyn AgentNodeHandler> =
4641            Arc::new(RuntimeAgentNodeHandler::new(runtime, None));
4642
4643        // --- flow with a single dw.agent node (operation = agent_id) ---
4644        let node_id = NodeId::from_str("agent").unwrap();
4645        let node = Node {
4646            id: node_id.clone(),
4647            component: FlowComponentRef {
4648                id: "dw.agent".parse().unwrap(),
4649                pack_alias: None,
4650                operation: Some("greeter".to_string()),
4651            },
4652            input: InputMapping {
4653                mapping: json!({ "user_text": "ping" }),
4654            },
4655            output: OutputMapping {
4656                mapping: Value::Null,
4657            },
4658            err_map: None,
4659            routing: Routing::End,
4660            telemetry: TelemetryHints::default(),
4661        };
4662        let mut nodes = indexmap::IndexMap::default();
4663        nodes.insert(node_id.clone(), node);
4664        let flow = Flow {
4665            schema_version: "1.0".into(),
4666            id: FlowId::from_str("dw.flow").unwrap(),
4667            kind: FlowKind::Messaging,
4668            entrypoints: BTreeMap::from([(
4669                "default".to_string(),
4670                Value::String(node_id.to_string()),
4671            )]),
4672            nodes,
4673            metadata: Default::default(),
4674        };
4675        let host_flow = HostFlow::from(flow);
4676
4677        let engine = FlowEngine {
4678            packs: Vec::new(),
4679            flows: Vec::new(),
4680            flow_sources: HashMap::new(),
4681            messaging_provider_pack_ids: std::collections::HashSet::new(),
4682            flow_cache: RwLock::new(HashMap::from([(
4683                FlowKey {
4684                    pack_id: "test-pack".to_string(),
4685                    flow_id: "dw.flow".to_string(),
4686                },
4687                host_flow,
4688            )])),
4689            default_env: "local".to_string(),
4690            validation: ValidationConfig {
4691                mode: ValidationMode::Off,
4692            },
4693            cross_pack_resolver: None,
4694            rollout_ids: RolloutIds::default(),
4695            remote_dispatch_handler: None,
4696            #[cfg(feature = "agentic-worker")]
4697            dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
4698            #[cfg(feature = "agentic-worker")]
4699            agent_node_handler: Some(handler),
4700            #[cfg(feature = "agentic-worker")]
4701            graph_node_handler: None,
4702            #[cfg(feature = "agentic-worker")]
4703            mcp_tool_source: None,
4704        };
4705        let ctx = FlowContext {
4706            tenant: "demo",
4707            pack_id: "test-pack",
4708            flow_id: "dw.flow",
4709            node_id: None,
4710            tool: None,
4711            action: None,
4712            session_id: Some("sess-1"),
4713            provider_id: None,
4714            reply_scope: None,
4715            retry_config: RetryConfig {
4716                max_attempts: 1,
4717                base_delay_ms: 1,
4718            },
4719            attempt: 1,
4720            observer: None,
4721            mocks: None,
4722        };
4723
4724        let rt = Runtime::new().unwrap();
4725        let result = rt
4726            .block_on(engine.execute(ctx, json!({ "user_text": "ping" })))
4727            .unwrap();
4728        assert!(matches!(result.status, FlowStatus::Completed));
4729
4730        // The dw.agent node output is {"reply", "trail", "terminated_by"}; the
4731        // engine finalises a single-node flow's egress into an array wrapping it.
4732        let output_str = serde_json::to_string(&result.output).unwrap();
4733        assert!(
4734            output_str.contains("pong"),
4735            "expected agent reply in flow output, got: {output_str}"
4736        );
4737    }
4738
4739    /// Engine twin of [`dw_agent_node_routes_to_handler_and_returns_reply`]:
4740    /// asserts a `dw.agent_graph` node is detected, routed to the configured
4741    /// [`GraphNodeHandler`] with the engine-derived tenant/env/session and the
4742    /// node's `operation` as the `graph_id`, and its reply lands in the flow
4743    /// output. A lightweight recording stub stands in for the durable executor.
4744    #[cfg(feature = "agentic-worker")]
4745    #[test]
4746    fn dw_agent_graph_node_routes_to_handler_and_returns_reply() {
4747        use std::sync::Mutex;
4748
4749        use crate::runner::graph_node::GraphNodeHandler;
4750
4751        /// Records the dispatch arguments and returns a fixed DwAgent envelope.
4752        struct RecordingGraphHandler {
4753            seen: Mutex<Option<(String, String, String, String)>>,
4754        }
4755
4756        #[async_trait::async_trait]
4757        impl GraphNodeHandler for RecordingGraphHandler {
4758            async fn execute(
4759                &self,
4760                tenant_id: &str,
4761                env_id: &str,
4762                graph_id: &str,
4763                session_id: &str,
4764                _flow_input: &Value,
4765            ) -> Result<Value> {
4766                *self.seen.lock().unwrap() = Some((
4767                    tenant_id.to_string(),
4768                    env_id.to_string(),
4769                    graph_id.to_string(),
4770                    session_id.to_string(),
4771                ));
4772                Ok(json!({
4773                    "reply": "graph-pong",
4774                    "trail": [],
4775                    "terminated_by": "respond",
4776                }))
4777            }
4778        }
4779
4780        let handler = Arc::new(RecordingGraphHandler {
4781            seen: Mutex::new(None),
4782        });
4783        let handler_dyn: Arc<dyn GraphNodeHandler> = handler.clone();
4784
4785        // --- flow with a single dw.agent_graph node (operation = graph_id) ---
4786        let node_id = NodeId::from_str("graph").unwrap();
4787        let node = Node {
4788            id: node_id.clone(),
4789            component: FlowComponentRef {
4790                id: "dw.agent_graph".parse().unwrap(),
4791                pack_alias: None,
4792                operation: Some("triage".to_string()),
4793            },
4794            input: InputMapping {
4795                mapping: json!({ "user_text": "ping" }),
4796            },
4797            output: OutputMapping {
4798                mapping: Value::Null,
4799            },
4800            err_map: None,
4801            routing: Routing::End,
4802            telemetry: TelemetryHints::default(),
4803        };
4804        let mut nodes = indexmap::IndexMap::default();
4805        nodes.insert(node_id.clone(), node);
4806        let flow = Flow {
4807            schema_version: "1.0".into(),
4808            id: FlowId::from_str("dwg.flow").unwrap(),
4809            kind: FlowKind::Messaging,
4810            entrypoints: BTreeMap::from([(
4811                "default".to_string(),
4812                Value::String(node_id.to_string()),
4813            )]),
4814            nodes,
4815            metadata: Default::default(),
4816        };
4817        let host_flow = HostFlow::from(flow);
4818
4819        let engine = FlowEngine {
4820            packs: Vec::new(),
4821            flows: Vec::new(),
4822            flow_sources: HashMap::new(),
4823            messaging_provider_pack_ids: std::collections::HashSet::new(),
4824            flow_cache: RwLock::new(HashMap::from([(
4825                FlowKey {
4826                    pack_id: "test-pack".to_string(),
4827                    flow_id: "dwg.flow".to_string(),
4828                },
4829                host_flow,
4830            )])),
4831            default_env: "local".to_string(),
4832            validation: ValidationConfig {
4833                mode: ValidationMode::Off,
4834            },
4835            cross_pack_resolver: None,
4836            rollout_ids: RolloutIds::default(),
4837            remote_dispatch_handler: None,
4838            #[cfg(feature = "agentic-worker")]
4839            dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
4840            #[cfg(feature = "agentic-worker")]
4841            agent_node_handler: None,
4842            #[cfg(feature = "agentic-worker")]
4843            graph_node_handler: Some(handler_dyn),
4844            #[cfg(feature = "agentic-worker")]
4845            mcp_tool_source: None,
4846        };
4847        let ctx = FlowContext {
4848            tenant: "demo",
4849            pack_id: "test-pack",
4850            flow_id: "dwg.flow",
4851            node_id: None,
4852            tool: None,
4853            action: None,
4854            session_id: Some("sess-1"),
4855            provider_id: None,
4856            reply_scope: None,
4857            retry_config: RetryConfig {
4858                max_attempts: 1,
4859                base_delay_ms: 1,
4860            },
4861            attempt: 1,
4862            observer: None,
4863            mocks: None,
4864        };
4865
4866        let rt = Runtime::new().unwrap();
4867        let result = rt
4868            .block_on(engine.execute(ctx, json!({ "user_text": "ping" })))
4869            .unwrap();
4870        assert!(matches!(result.status, FlowStatus::Completed));
4871
4872        // The handler must have been called with the engine-derived
4873        // tenant/env/session and the node's operation as graph_id.
4874        let seen = handler.seen.lock().unwrap().clone();
4875        assert_eq!(
4876            seen,
4877            Some((
4878                "demo".to_string(),
4879                "local".to_string(),
4880                "triage".to_string(),
4881                "sess-1".to_string(),
4882            )),
4883            "dw.agent_graph dispatch must mirror dw.agent's tenant/env/graph_id/session derivation"
4884        );
4885
4886        let output_str = serde_json::to_string(&result.output).unwrap();
4887        assert!(
4888            output_str.contains("graph-pong"),
4889            "expected graph reply in flow output, got: {output_str}"
4890        );
4891    }
4892
4893    /// When `GREENTIC_AW_DISPATCH=nats` is set, a `dw.agent` node must be
4894    /// rerouted through the remote-dispatch path (`"agentic"` runtime) rather
4895    /// than calling the in-process `AgentNodeHandler`. The node payload is
4896    /// wrapped as `input`, `await=true` is injected, and the engine pauses
4897    /// (returns a wait outcome, not a complete one).
4898    #[cfg(feature = "agentic-worker")]
4899    #[test]
4900    fn dw_agent_nats_mode_dispatches_remote() {
4901        use std::sync::Mutex;
4902
4903        use crate::runner::agent_node::DwAgentDispatch;
4904        use crate::runner::remote_dispatch::{
4905            RemoteDispatch, RemoteDispatchAction, RemoteDispatchHandler,
4906        };
4907
4908        /// Recording stub: captures the last dispatch and returns
4909        /// `AwaitingResponse` so the engine pauses.
4910        struct RecordingDispatcher {
4911            seen: Mutex<Option<RemoteDispatch>>,
4912        }
4913
4914        #[async_trait::async_trait]
4915        impl RemoteDispatchHandler for RecordingDispatcher {
4916            async fn dispatch(
4917                &self,
4918                request: RemoteDispatch,
4919            ) -> anyhow::Result<RemoteDispatchAction> {
4920                let corr = request.correlation_id.clone();
4921                *self.seen.lock().unwrap() = Some(request);
4922                Ok(RemoteDispatchAction::AwaitingResponse {
4923                    correlation_id: corr,
4924                })
4925            }
4926        }
4927
4928        let dispatcher = Arc::new(RecordingDispatcher {
4929            seen: Mutex::new(None),
4930        });
4931
4932        // --- two-node flow: dw.agent → emit (resume target) ---
4933        // The agent node must have Routing::Next so the engine knows where to
4934        // resume once the async response arrives (same requirement as sorla.call /
4935        // agentic.call nodes in production).
4936        let resume_id = NodeId::from_str("after-agent").unwrap();
4937        let node_id = NodeId::from_str("agent-nats").unwrap();
4938        let agent_node = Node {
4939            id: node_id.clone(),
4940            component: FlowComponentRef {
4941                id: "dw.agent".parse().unwrap(),
4942                pack_alias: None,
4943                operation: Some("greeter".to_string()),
4944            },
4945            input: InputMapping {
4946                mapping: json!({ "user_text": "hi" }),
4947            },
4948            output: OutputMapping {
4949                mapping: Value::Null,
4950            },
4951            err_map: None,
4952            routing: Routing::Next {
4953                node_id: resume_id.clone(),
4954            },
4955            telemetry: TelemetryHints::default(),
4956        };
4957        let resume_node = Node {
4958            id: resume_id.clone(),
4959            component: FlowComponentRef {
4960                id: "emit.log".parse().unwrap(),
4961                pack_alias: None,
4962                operation: None,
4963            },
4964            input: InputMapping {
4965                mapping: json!({ "message": "done" }),
4966            },
4967            output: OutputMapping {
4968                mapping: Value::Null,
4969            },
4970            err_map: None,
4971            routing: Routing::End,
4972            telemetry: TelemetryHints::default(),
4973        };
4974        let mut nodes = indexmap::IndexMap::default();
4975        nodes.insert(node_id.clone(), agent_node);
4976        nodes.insert(resume_id.clone(), resume_node);
4977        let flow = Flow {
4978            schema_version: "1.0".into(),
4979            id: FlowId::from_str("nats-agent.flow").unwrap(),
4980            kind: FlowKind::Messaging,
4981            entrypoints: BTreeMap::from([(
4982                "default".to_string(),
4983                Value::String(node_id.to_string()),
4984            )]),
4985            nodes,
4986            metadata: Default::default(),
4987        };
4988        let host_flow = HostFlow::from(flow);
4989
4990        let engine = FlowEngine {
4991            packs: Vec::new(),
4992            flows: Vec::new(),
4993            flow_sources: HashMap::new(),
4994            messaging_provider_pack_ids: std::collections::HashSet::new(),
4995            flow_cache: RwLock::new(HashMap::from([(
4996                FlowKey {
4997                    pack_id: "test-pack".to_string(),
4998                    flow_id: "nats-agent.flow".to_string(),
4999                },
5000                host_flow,
5001            )])),
5002            default_env: "local".to_string(),
5003            validation: ValidationConfig {
5004                mode: ValidationMode::Off,
5005            },
5006            cross_pack_resolver: None,
5007            rollout_ids: RolloutIds::default(),
5008            remote_dispatch_handler: Some(dispatcher.clone() as Arc<dyn crate::runner::remote_dispatch::RemoteDispatchHandler>),
5009            #[cfg(feature = "agentic-worker")]
5010            dw_agent_dispatch: DwAgentDispatch::Nats,
5011            #[cfg(feature = "agentic-worker")]
5012            // No in-process handler wired — Nats path must NOT call it.
5013            agent_node_handler: None,
5014            #[cfg(feature = "agentic-worker")]
5015            graph_node_handler: None,
5016            #[cfg(feature = "agentic-worker")]
5017            mcp_tool_source: None,
5018        };
5019
5020        let ctx = FlowContext {
5021            tenant: "demo",
5022            pack_id: "test-pack",
5023            flow_id: "nats-agent.flow",
5024            node_id: None,
5025            tool: None,
5026            action: None,
5027            session_id: Some("sess-nats"),
5028            provider_id: None,
5029            reply_scope: None,
5030            retry_config: RetryConfig {
5031                max_attempts: 1,
5032                base_delay_ms: 1,
5033            },
5034            attempt: 1,
5035            observer: None,
5036            mocks: None,
5037        };
5038
5039        let rt = Runtime::new().unwrap();
5040        let result = rt
5041            .block_on(engine.execute(ctx, json!({ "user_text": "hi" })))
5042            .unwrap();
5043
5044        // The Nats path pauses the flow (await=true → DispatchOutcome::wait).
5045        assert!(
5046            matches!(result.status, FlowStatus::Waiting(_)),
5047            "expected Waiting outcome from dw.agent Nats mode, got: {:?}",
5048            result.status
5049        );
5050
5051        // The dispatcher must have been called with runtime="agentic" and
5052        // target=<agent_id>, and the node payload wrapped as `input`.
5053        let seen = dispatcher.seen.lock().unwrap();
5054        let dispatch = seen.as_ref().expect("dispatcher was not called");
5055        assert_eq!(
5056            dispatch.runtime, "agentic",
5057            "runtime name must be 'agentic'"
5058        );
5059        assert_eq!(dispatch.target, "greeter", "target must be the agent_id");
5060        assert_eq!(
5061            dispatch.input,
5062            json!({ "user_text": "hi" }),
5063            "node payload must be forwarded as dispatch input"
5064        );
5065    }
5066
5067    fn host_flow_for_test(
5068        flow_id: &str,
5069        node_ids: &[&str],
5070        default_start: Option<&str>,
5071    ) -> HostFlow {
5072        let mut nodes = indexmap::IndexMap::default();
5073        for node_id in node_ids {
5074            let id = NodeId::from_str(node_id).unwrap();
5075            let node = Node {
5076                id: id.clone(),
5077                component: FlowComponentRef {
5078                    id: "emit.log".parse().unwrap(),
5079                    pack_alias: None,
5080                    operation: None,
5081                },
5082                input: InputMapping {
5083                    mapping: json!({ "message": node_id }),
5084                },
5085                output: OutputMapping {
5086                    mapping: Value::Null,
5087                },
5088                err_map: None,
5089                routing: Routing::End,
5090                telemetry: TelemetryHints::default(),
5091                conversational: false,
5092            };
5093            nodes.insert(id, node);
5094        }
5095        let mut entrypoints = BTreeMap::new();
5096        if let Some(start) = default_start {
5097            entrypoints.insert("default".to_string(), Value::String(start.to_string()));
5098        }
5099        HostFlow::from(Flow {
5100            schema_version: "1.0".into(),
5101            id: FlowId::from_str(flow_id).unwrap(),
5102            kind: FlowKind::Messaging,
5103            entrypoints,
5104            nodes,
5105            metadata: Default::default(),
5106        })
5107    }
5108
5109    fn jump_test_engine() -> FlowEngine {
5110        let target_flow = host_flow_for_test("flow.target", &["node-a", "node-b"], None);
5111        FlowEngine {
5112            packs: Vec::new(),
5113            flows: Vec::new(),
5114            flow_sources: HashMap::new(),
5115            messaging_provider_pack_ids: std::collections::HashSet::new(),
5116            flow_cache: RwLock::new(HashMap::from([(
5117                FlowKey {
5118                    pack_id: "test-pack".to_string(),
5119                    flow_id: "flow.target".to_string(),
5120                },
5121                target_flow,
5122            )])),
5123            default_env: "local".to_string(),
5124            validation: ValidationConfig {
5125                mode: ValidationMode::Off,
5126            },
5127            cross_pack_resolver: None,
5128            rollout_ids: RolloutIds::default(),
5129            remote_dispatch_handler: None,
5130            #[cfg(feature = "agentic-worker")]
5131            dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
5132            #[cfg(feature = "agentic-worker")]
5133            agent_node_handler: None,
5134            #[cfg(feature = "agentic-worker")]
5135            graph_node_handler: None,
5136            #[cfg(feature = "agentic-worker")]
5137            mcp_tool_source: None,
5138        }
5139    }
5140
5141    fn jump_ctx<'a>(flow_id: &'a str) -> FlowContext<'a> {
5142        FlowContext {
5143            tenant: "demo",
5144            pack_id: "test-pack",
5145            flow_id,
5146            node_id: None,
5147            tool: None,
5148            action: None,
5149            session_id: None,
5150            provider_id: None,
5151            reply_scope: None,
5152            retry_config: RetryConfig {
5153                max_attempts: 1,
5154                base_delay_ms: 1,
5155            },
5156            attempt: 1,
5157            observer: None,
5158            mocks: None,
5159        }
5160    }
5161
5162    #[test]
5163    fn with_rollout_ids_binds_revision_identity() {
5164        let engine = minimal_engine().with_rollout_ids(RolloutIds {
5165            customer_id: Some("cust-acme".into()),
5166            deployment_id: Some("01JTKS".into()),
5167            bundle_id: Some("customer.support".into()),
5168            revision_id: Some("01JTKR".into()),
5169        });
5170        assert_eq!(engine.rollout_ids.revision_id.as_deref(), Some("01JTKR"));
5171        assert_eq!(engine.rollout_ids.deployment_id.as_deref(), Some("01JTKS"));
5172        // A freshly-built engine carries no rollout identity (legacy runtime).
5173        assert!(minimal_engine().rollout_ids.is_empty());
5174    }
5175
5176    #[test]
5177    fn apply_jump_unknown_flow_errors() {
5178        let engine = minimal_engine();
5179        let mut state = ExecutionState::new(Value::Null);
5180        let rt = Runtime::new().unwrap();
5181        let err = rt
5182            .block_on(engine.apply_jump(
5183                &jump_ctx("flow.source"),
5184                &mut state,
5185                JumpControl {
5186                    flow: "flow.missing".into(),
5187                    node: None,
5188                    payload: json!({ "ok": true }),
5189                    hints: Value::Null,
5190                    max_redirects: None,
5191                    reason: None,
5192                },
5193            ))
5194            .unwrap_err();
5195        assert!(
5196            err.to_string().contains("unknown_flow"),
5197            "unexpected error: {err}"
5198        );
5199    }
5200
5201    #[test]
5202    fn apply_jump_unknown_node_errors() {
5203        let engine = jump_test_engine();
5204        let mut state = ExecutionState::new(Value::Null);
5205        let rt = Runtime::new().unwrap();
5206        let err = rt
5207            .block_on(engine.apply_jump(
5208                &jump_ctx("flow.source"),
5209                &mut state,
5210                JumpControl {
5211                    flow: "flow.target".into(),
5212                    node: Some("node-missing".into()),
5213                    payload: json!({ "ok": true }),
5214                    hints: Value::Null,
5215                    max_redirects: None,
5216                    reason: None,
5217                },
5218            ))
5219            .unwrap_err();
5220        assert!(
5221            err.to_string().contains("unknown_node"),
5222            "unexpected error: {err}"
5223        );
5224    }
5225
5226    #[test]
5227    fn apply_jump_uses_default_start_fallback() {
5228        let engine = jump_test_engine();
5229        let mut state = ExecutionState::new(Value::Null);
5230        let rt = Runtime::new().unwrap();
5231        let target = rt
5232            .block_on(engine.apply_jump(
5233                &jump_ctx("flow.source"),
5234                &mut state,
5235                JumpControl {
5236                    flow: "flow.target".into(),
5237                    node: None,
5238                    payload: json!({ "k": "v" }),
5239                    hints: Value::Null,
5240                    max_redirects: None,
5241                    reason: None,
5242                },
5243            ))
5244            .expect("jump target");
5245        assert_eq!(target.flow_id, "flow.target");
5246        assert_eq!(target.node_id.as_str(), "node-a");
5247    }
5248
5249    #[test]
5250    fn apply_jump_redirect_limit_enforced() {
5251        let engine = jump_test_engine();
5252        let mut state = ExecutionState::new(Value::Null);
5253        state.redirect_count = 3;
5254        let rt = Runtime::new().unwrap();
5255        let err = rt
5256            .block_on(engine.apply_jump(
5257                &jump_ctx("flow.source"),
5258                &mut state,
5259                JumpControl {
5260                    flow: "flow.target".into(),
5261                    node: None,
5262                    payload: json!({ "k": "v" }),
5263                    hints: Value::Null,
5264                    max_redirects: Some(3),
5265                    reason: None,
5266                },
5267            ))
5268            .unwrap_err();
5269        assert_eq!(err.to_string(), "redirect_limit");
5270    }
5271
5272    /// Regression: a `Routing::Custom` array containing at least one
5273    /// conditional entry must pause (return `Wait`) when no condition
5274    /// matches, instead of terminating. Concrete bug it guards against:
5275    /// every card click used to terminate the flow because the entry-card's
5276    /// routing array didn't enumerate every downstream action, so users got
5277    /// looped back to the entry on every interaction.
5278    #[test]
5279    fn evaluate_custom_routing_waits_when_conditional_falls_through() {
5280        let raw_routing = json!([
5281            { "condition": "response.action == \"go\"", "to": "next" },
5282            { "out": true }
5283        ]);
5284        let flow_ir = HostFlow {
5285            id: "flow.test".to_string(),
5286            start: None,
5287            nodes: IndexMap::new(),
5288            slot_schema: None,
5289        };
5290        let current_node = NodeId::from_str("current").unwrap();
5291        let output = NodeOutput::new(Value::Null);
5292
5293        // First case: empty action -> conditional does not match, must wait.
5294        let mut state_empty = ExecutionState::new(json!({ "metadata": { "action": "" } }));
5295        state_empty.entry = json!({ "metadata": { "action": "" } });
5296        let decision_empty =
5297            evaluate_custom_routing(&raw_routing, &output, &state_empty, &flow_ir, &current_node);
5298        assert!(
5299            matches!(decision_empty, CustomRoutingDecision::Wait),
5300            "expected Wait on conditional fall-through, got {decision_empty:?}"
5301        );
5302
5303        // Second case: action == "go" -> conditional matches, must advance.
5304        let mut state_go = ExecutionState::new(json!({ "metadata": { "action": "go" } }));
5305        state_go.entry = json!({ "metadata": { "action": "go" } });
5306        let decision_go =
5307            evaluate_custom_routing(&raw_routing, &output, &state_go, &flow_ir, &current_node);
5308        match decision_go {
5309            CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "next"),
5310            other => panic!("expected Next(\"next\"), got {other:?}"),
5311        }
5312    }
5313
5314    #[test]
5315    fn node_output_with_error_marks_ok_false_and_stashes_in_meta() {
5316        let err: Box<dyn std::error::Error + 'static> =
5317            Box::<dyn std::error::Error + 'static>::from("weatherapi returned 401 Unauthorized");
5318        let out = NodeOutput::with_error("call_weather", err.as_ref());
5319        assert!(!out.ok);
5320        assert_eq!(out.payload, Value::Null);
5321        assert_eq!(out.meta["error"]["kind"], "flow_node_failed");
5322        assert_eq!(out.meta["error"]["node_id"], "call_weather");
5323        assert_eq!(
5324            out.meta["error"]["message"],
5325            "weatherapi returned 401 Unauthorized"
5326        );
5327    }
5328
5329    #[test]
5330    fn lift_first_node_error_promotes_node_meta_to_output_metadata() {
5331        // Two nodes ran; the first failed, the second produced a default-
5332        // looking output (flow author wrote no error routing). The executor
5333        // must lift the first failure into output.metadata so the messaging
5334        // provider renders the error card without any flow-author changes.
5335        let mut nodes: HashMap<String, NodeOutput> = HashMap::new();
5336        let err: Box<dyn std::error::Error + 'static> =
5337            Box::<dyn std::error::Error + 'static>::from("weatherapi returned 401 Unauthorized");
5338        nodes.insert(
5339            "call_weather".to_string(),
5340            NodeOutput::with_error("call_weather", err.as_ref()),
5341        );
5342        nodes.insert(
5343            "render_current_card".to_string(),
5344            NodeOutput::new(json!({ "text": "message" })),
5345        );
5346
5347        let final_output = json!({ "text": "message" });
5348        let enriched = lift_first_node_error_from_nodes(final_output, &nodes);
5349        assert_eq!(
5350            enriched["metadata"]["error_kind"], "flow_node_failed",
5351            "first failing node's kind must be lifted"
5352        );
5353        assert_eq!(
5354            enriched["metadata"]["error_message"],
5355            "weatherapi returned 401 Unauthorized"
5356        );
5357        assert_eq!(enriched["metadata"]["node_id"], "call_weather");
5358        // Preserves the original payload bits so downstream renderers still
5359        // see what the flow produced.
5360        assert_eq!(enriched["text"], "message");
5361    }
5362
5363    #[test]
5364    fn lift_first_node_error_is_noop_when_all_nodes_ok() {
5365        let mut nodes: HashMap<String, NodeOutput> = HashMap::new();
5366        nodes.insert(
5367            "ok_node".to_string(),
5368            NodeOutput::new(json!({ "text": "all good" })),
5369        );
5370        let output = json!({ "text": "all good" });
5371        let lifted = lift_first_node_error_from_nodes(output.clone(), &nodes);
5372        assert_eq!(lifted, output);
5373    }
5374
5375    #[tokio::test]
5376    async fn execute_user_facing_flow_failure_returns_completed_with_error_envelope() {
5377        // Flow whose start node is missing — drive_flow will return Err on
5378        // node lookup. With session_id present, execute() must convert that
5379        // to a Completed FlowExecution carrying error_kind/error_message in
5380        // output.metadata so the chat user sees the error card.
5381        let flow_id_str = "broken.flow";
5382        let pack_id_str = "test-pack";
5383        let host_flow = host_flow_for_test(flow_id_str, &["only-node"], Some("does-not-exist"));
5384        let engine = FlowEngine {
5385            packs: Vec::new(),
5386            flows: Vec::new(),
5387            flow_sources: HashMap::new(),
5388            messaging_provider_pack_ids: std::collections::HashSet::new(),
5389            flow_cache: RwLock::new(HashMap::from([(
5390                FlowKey {
5391                    pack_id: pack_id_str.to_string(),
5392                    flow_id: flow_id_str.to_string(),
5393                },
5394                host_flow,
5395            )])),
5396            default_env: "local".to_string(),
5397            validation: ValidationConfig {
5398                mode: ValidationMode::Off,
5399            },
5400            cross_pack_resolver: None,
5401            rollout_ids: RolloutIds::default(),
5402            remote_dispatch_handler: None,
5403            #[cfg(feature = "agentic-worker")]
5404            dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
5405            #[cfg(feature = "agentic-worker")]
5406            agent_node_handler: None,
5407            #[cfg(feature = "agentic-worker")]
5408            graph_node_handler: None,
5409            #[cfg(feature = "agentic-worker")]
5410            mcp_tool_source: None,
5411        };
5412        let ctx = FlowContext {
5413            tenant: "demo",
5414            pack_id: pack_id_str,
5415            flow_id: flow_id_str,
5416            node_id: None,
5417            tool: None,
5418            action: None,
5419            session_id: Some("conv-1"),
5420            provider_id: None,
5421            reply_scope: None,
5422            retry_config: RetryConfig {
5423                max_attempts: 1,
5424                base_delay_ms: 1,
5425            },
5426            attempt: 1,
5427            observer: None,
5428            mocks: None,
5429        };
5430        let result = engine
5431            .execute(ctx, Value::Null)
5432            .await
5433            .expect("must not propagate Err");
5434        assert!(matches!(result.status, FlowStatus::Completed));
5435        assert_eq!(
5436            result.output["metadata"]["error_kind"],
5437            "flow_execution_failed"
5438        );
5439        let msg = result.output["metadata"]["error_message"]
5440            .as_str()
5441            .unwrap_or("");
5442        assert!(!msg.is_empty(), "error_message must be populated");
5443        assert_eq!(result.output["metadata"]["flow_id"], "broken.flow");
5444    }
5445
5446    #[test]
5447    fn mcp_tool_error_recognises_generator_error_shape() {
5448        // greentic-mcp-generator's tool_error_with_status emits this exact
5449        // shape when the upstream HTTP call to weatherapi.com returns 401.
5450        let value = json!({
5451            "error": {
5452                "code": "tool_error",
5453                "message": "API request returned status 401",
5454                "status": 401
5455            }
5456        });
5457        let (code, message) = mcp_tool_error(&value).expect("must detect MCP error shape");
5458        assert_eq!(code, "tool_error");
5459        assert!(message.contains("API request returned status 401"));
5460        assert!(message.contains("(status 401)"));
5461    }
5462
5463    #[test]
5464    fn mcp_tool_error_skips_success_responses() {
5465        // A success response uses `result`, not `error`.
5466        let value = json!({ "result": { "current": { "temp_c": 19.0 } } });
5467        assert!(mcp_tool_error(&value).is_none());
5468    }
5469
5470    #[test]
5471    fn mcp_tool_error_skips_non_object_and_unrelated_shapes() {
5472        assert!(mcp_tool_error(&Value::Null).is_none());
5473        assert!(mcp_tool_error(&json!({"unrelated": true})).is_none());
5474        // `error` must be an object; a string isn't enough.
5475        assert!(mcp_tool_error(&json!({"error": "oops"})).is_none());
5476    }
5477
5478    #[tokio::test]
5479    async fn execute_non_user_facing_flow_failure_still_propagates() {
5480        // No session_id => internal job. Errors still propagate as Err so
5481        // operator alerting / metrics pipelines stay intact.
5482        let flow_id_str = "broken.flow";
5483        let pack_id_str = "test-pack";
5484        let host_flow = host_flow_for_test(flow_id_str, &["only-node"], Some("does-not-exist"));
5485        let engine = FlowEngine {
5486            packs: Vec::new(),
5487            flows: Vec::new(),
5488            flow_sources: HashMap::new(),
5489            messaging_provider_pack_ids: std::collections::HashSet::new(),
5490            flow_cache: RwLock::new(HashMap::from([(
5491                FlowKey {
5492                    pack_id: pack_id_str.to_string(),
5493                    flow_id: flow_id_str.to_string(),
5494                },
5495                host_flow,
5496            )])),
5497            default_env: "local".to_string(),
5498            validation: ValidationConfig {
5499                mode: ValidationMode::Off,
5500            },
5501            cross_pack_resolver: None,
5502            rollout_ids: RolloutIds::default(),
5503            remote_dispatch_handler: None,
5504            #[cfg(feature = "agentic-worker")]
5505            dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
5506            #[cfg(feature = "agentic-worker")]
5507            agent_node_handler: None,
5508            #[cfg(feature = "agentic-worker")]
5509            graph_node_handler: None,
5510            #[cfg(feature = "agentic-worker")]
5511            mcp_tool_source: None,
5512        };
5513        let ctx = FlowContext {
5514            tenant: "demo",
5515            pack_id: pack_id_str,
5516            flow_id: flow_id_str,
5517            node_id: None,
5518            tool: None,
5519            action: None,
5520            session_id: None,
5521            provider_id: None,
5522            reply_scope: None,
5523            retry_config: RetryConfig {
5524                max_attempts: 1,
5525                base_delay_ms: 1,
5526            },
5527            attempt: 1,
5528            observer: None,
5529            mocks: None,
5530        };
5531        let result = engine.execute(ctx, Value::Null).await;
5532        assert!(result.is_err(), "non-user-facing flow must propagate Err");
5533    }
5534
5535    // ---- Phase D: slot_schema injection tests ----
5536
5537    #[test]
5538    fn host_flow_extracts_slot_schema_from_metadata_extra() {
5539        use greentic_types::FlowMetadata;
5540        use std::collections::BTreeSet;
5541
5542        let schema = json!([
5543            {"name": "counterparty", "slot_type": "string", "required": true},
5544            {"name": "due_date", "slot_type": "date", "required": true}
5545        ]);
5546        let flow = Flow {
5547            schema_version: "flow-v1".into(),
5548            id: FlowId::from_str("test.flow").unwrap(),
5549            kind: FlowKind::Messaging,
5550            entrypoints: BTreeMap::new(),
5551            nodes: IndexMap::default(),
5552            metadata: FlowMetadata {
5553                title: None,
5554                description: None,
5555                tags: BTreeSet::new(),
5556                extra: json!({(SLOT_SCHEMA_METADATA_KEY): schema}),
5557            },
5558        };
5559        let host = HostFlow::from(flow);
5560        assert_eq!(
5561            host.slot_schema.as_ref(),
5562            Some(&schema),
5563            "HostFlow must extract slot_schema from metadata.extra"
5564        );
5565    }
5566
5567    #[test]
5568    fn host_flow_slot_schema_is_none_when_absent() {
5569        let flow = Flow {
5570            schema_version: "flow-v1".into(),
5571            id: FlowId::from_str("test.flow").unwrap(),
5572            kind: FlowKind::Messaging,
5573            entrypoints: BTreeMap::new(),
5574            nodes: IndexMap::default(),
5575            metadata: Default::default(),
5576        };
5577        let host = HostFlow::from(flow);
5578        assert!(
5579            host.slot_schema.is_none(),
5580            "HostFlow.slot_schema must be None when metadata.extra has no greentic.slot_schema"
5581        );
5582    }
5583
5584    #[test]
5585    fn inject_slot_definitions_adds_to_object_input() {
5586        let schema = json!([
5587            {"name": "city", "slot_type": "string"}
5588        ]);
5589        let mut input = json!({"utterance": "hello"});
5590        inject_slot_definitions(&mut input, &schema, "f", "n");
5591        assert_eq!(
5592            input,
5593            json!({"utterance": "hello", "slot_definitions": schema}),
5594            "slot_definitions must be injected into existing object"
5595        );
5596    }
5597
5598    #[test]
5599    fn inject_slot_definitions_wraps_null_input() {
5600        let schema = json!([{"name": "x", "slot_type": "string"}]);
5601        let mut input = Value::Null;
5602        inject_slot_definitions(&mut input, &schema, "f", "n");
5603        assert_eq!(
5604            input,
5605            json!({"slot_definitions": schema}),
5606            "null input must become an object with slot_definitions"
5607        );
5608    }
5609
5610    #[test]
5611    fn inject_slot_definitions_preserves_explicit_inline() {
5612        let flow_schema = json!([{"name": "city", "slot_type": "string"}]);
5613        let inline_defs = json!([{"name": "country", "slot_type": "string"}]);
5614        let mut input = json!({
5615            "utterance": "hello",
5616            "slot_definitions": inline_defs
5617        });
5618        inject_slot_definitions(&mut input, &flow_schema, "f", "n");
5619        assert_eq!(
5620            input["slot_definitions"], inline_defs,
5621            "explicit inline slot_definitions must not be overwritten"
5622        );
5623    }
5624
5625    #[test]
5626    fn inject_slot_definitions_skips_non_object_input() {
5627        let schema = json!([{"name": "x", "slot_type": "string"}]);
5628        let mut input = json!("a string");
5629        inject_slot_definitions(&mut input, &schema, "f", "n");
5630        assert_eq!(
5631            input,
5632            json!("a string"),
5633            "non-object input must be left unchanged"
5634        );
5635    }
5636
5637    fn make_flow_doc_for_test(
5638        id: &str,
5639        node_name: &str,
5640        component: &str,
5641        slot_schema: Option<Value>,
5642    ) -> greentic_flow::model::FlowDoc {
5643        use greentic_flow::model::{FlowDoc, NodeDoc};
5644
5645        let mut nodes = IndexMap::new();
5646        nodes.insert(
5647            node_name.to_string(),
5648            NodeDoc {
5649                raw: {
5650                    let mut m = IndexMap::new();
5651                    m.insert(
5652                        "component.exec".to_string(),
5653                        json!({ "component": component }),
5654                    );
5655                    m
5656                },
5657                routing: json!([{ "out": true }]),
5658                ..Default::default()
5659            },
5660        );
5661
5662        FlowDoc {
5663            id: id.into(),
5664            title: None,
5665            description: None,
5666            flow_type: "messaging".into(),
5667            start: Some(node_name.into()),
5668            parameters: json!({}),
5669            tags: Vec::new(),
5670            schema_version: None,
5671            entrypoints: IndexMap::new(),
5672            meta: None,
5673            slot_schema,
5674            nodes,
5675        }
5676    }
5677
5678    /// Integration test: exercises the real `greentic_flow::compile_flow`
5679    /// producer path with a `FlowDoc` carrying `slot_schema`, then converts
5680    /// through `HostFlow::from` and verifies the runtime-side `slot_schema`
5681    /// field is populated — closing the gap Codex flagged where the existing
5682    /// unit tests constructed `FlowMetadata` directly.
5683    #[test]
5684    fn compile_flow_round_trips_slot_schema_into_host_flow() {
5685        let slot_defs = json!([
5686            { "name": "counterparty", "slot_type": "string", "required": true,
5687              "pattern": ".+" },
5688            { "name": "due_date", "slot_type": "date", "required": true,
5689              "pattern": "\\d{4}-\\d{2}-\\d{2}" }
5690        ]);
5691        let doc = make_flow_doc_for_test(
5692            "slot-test",
5693            "extractor",
5694            "slot-extractor",
5695            Some(slot_defs.clone()),
5696        );
5697
5698        let flow = greentic_flow::compile_flow(doc).expect("compile_flow must succeed");
5699        assert_eq!(
5700            flow.metadata.extra.get(SLOT_SCHEMA_METADATA_KEY),
5701            Some(&slot_defs),
5702            "compile_flow must forward slot_schema into metadata.extra"
5703        );
5704
5705        let host = HostFlow::from(flow);
5706        assert_eq!(
5707            host.slot_schema.as_ref(),
5708            Some(&slot_defs),
5709            "HostFlow.slot_schema must survive the compile_flow -> HostFlow round-trip"
5710        );
5711    }
5712
5713    /// Verify that `compile_flow` without `slot_schema` produces a `Flow`
5714    /// whose `metadata.extra` has no `greentic.slot_schema` key, and that
5715    /// `HostFlow.slot_schema` stays `None` through the real compile path.
5716    #[test]
5717    fn compile_flow_without_slot_schema_leaves_host_flow_none() {
5718        let doc = make_flow_doc_for_test("no-slots", "echo", "echo", None);
5719
5720        let flow = greentic_flow::compile_flow(doc).expect("compile_flow must succeed");
5721        assert!(
5722            flow.metadata.extra.get(SLOT_SCHEMA_METADATA_KEY).is_none(),
5723            "metadata.extra must not contain greentic.slot_schema when FlowDoc.slot_schema is None"
5724        );
5725
5726        let host = HostFlow::from(flow);
5727        assert!(
5728            host.slot_schema.is_none(),
5729            "HostFlow.slot_schema must be None when FlowDoc has no slot_schema"
5730        );
5731    }
5732
5733    #[test]
5734    fn multi_edge_node_routes_on_injected_event() {
5735        let raw_routing = json!([
5736            { "condition": "event == \"on_success\"", "to": "next" },
5737            { "condition": "event == \"on_error\"", "to": "err" }
5738        ]);
5739        let flow_ir = HostFlow {
5740            id: "flow.test".to_string(),
5741            start: None,
5742            nodes: IndexMap::new(),
5743            slot_schema: None,
5744        };
5745        let current = NodeId::from_str("current").unwrap();
5746        let state = ExecutionState::new(json!({}));
5747
5748        // ok:true with no explicit outcome → default event "on_success" → "next".
5749        let ok_out = NodeOutput::new(json!({ "x": 1 }));
5750        match evaluate_custom_routing(&raw_routing, &ok_out, &state, &flow_ir, &current) {
5751            CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "next"),
5752            other => panic!("expected Next(\"next\"), got {other:?}"),
5753        }
5754
5755        // An explicit outcome in the node metadata wins over the ok-default.
5756        let routed = NodeOutput::with_meta(json!({}), json!({ "outcome": "on_error" }));
5757        match evaluate_custom_routing(&raw_routing, &routed, &state, &flow_ir, &current) {
5758            CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "err"),
5759            other => panic!("expected Next(\"err\"), got {other:?}"),
5760        }
5761    }
5762
5763    /// A node whose component reports a failure (`{ok:false, error}`) and which
5764    /// has an `on_error`-family route must surface a node_io `Errors` output
5765    /// (`ok == false`) and route to that branch instead of aborting the flow.
5766    #[test]
5767    fn errored_output_routes_to_on_error_branch() {
5768        let raw_routing = json!([
5769            { "condition": "event == \"on_success\"", "to": "ok_node" },
5770            { "condition": "event == \"on_error\"", "to": "err_node" }
5771        ]);
5772        let flow_ir = HostFlow {
5773            id: "flow.test".to_string(),
5774            start: None,
5775            nodes: IndexMap::new(),
5776            slot_schema: None,
5777        };
5778        let current = NodeId::from_str("current").unwrap();
5779        let state = ExecutionState::new(json!({}));
5780
5781        let errored =
5782            NodeOutput::errored(json!({ "ok": false, "error": { "code": "E", "message": "m" } }));
5783        match evaluate_custom_routing(&raw_routing, &errored, &state, &flow_ir, &current) {
5784            CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "err_node"),
5785            other => panic!("expected on_error route, got {other:?}"),
5786        }
5787    }
5788
5789    #[test]
5790    fn node_has_error_route_detects_error_family_ports() {
5791        let with_err = Routing::Custom(json!([
5792            { "condition": "event == \"on_success\"", "to": "n" },
5793            { "condition": "event == \"on_error\"", "to": "e" }
5794        ]));
5795        assert!(
5796            node_has_error_route(&with_err),
5797            "on_error route must be detected"
5798        );
5799
5800        let only_success = Routing::Custom(json!([
5801            { "condition": "event == \"on_success\"", "to": "n" }
5802        ]));
5803        assert!(
5804            !node_has_error_route(&only_success),
5805            "a success-only Custom routing has no error branch"
5806        );
5807
5808        let plain = Routing::Next {
5809            node_id: NodeId::from_str("n").unwrap(),
5810        };
5811        assert!(
5812            !node_has_error_route(&plain),
5813            "Routing::Next has no error branch"
5814        );
5815    }
5816
5817    /// When a successful node emits no explicit `outcome`, the runner must
5818    /// derive the success `event` from the success-family port the node
5819    /// actually has an outgoing edge for (priority `on_success` → `on_complete`
5820    /// → `on_submit`), not blindly default to `on_success`. This is what lets
5821    /// native nodes whose happy port is `on_complete` (qa.process,
5822    /// llm.openai.chat, template_render) — or `on_submit` (forms) — route
5823    /// instead of silently stalling at `Wait`, while leaving `on_success`
5824    /// components (e.g. http) unchanged.
5825    #[test]
5826    fn success_default_matches_available_outcome_port() {
5827        let flow_ir = HostFlow {
5828            id: "flow.test".to_string(),
5829            start: None,
5830            nodes: IndexMap::new(),
5831            slot_schema: None,
5832        };
5833        let current = NodeId::from_str("current").unwrap();
5834        let state = ExecutionState::new(json!({}));
5835        // ok:true, no explicit outcome — the case every native happy path hits.
5836        let ok_out = NodeOutput::new(json!({ "answer": "hi" }));
5837
5838        // qa/llm/template shape: happy port is `on_complete`, no `on_success` edge.
5839        let on_complete_routing = json!([
5840            { "condition": "event == \"on_complete\"", "to": "next" },
5841            { "condition": "event == \"on_cancel\"", "to": "cancelled" }
5842        ]);
5843        match evaluate_custom_routing(&on_complete_routing, &ok_out, &state, &flow_ir, &current) {
5844            CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "next"),
5845            other => panic!("expected Next(\"next\") via on_complete default, got {other:?}"),
5846        }
5847
5848        // form shape: happy port is `on_submit`.
5849        let on_submit_routing = json!([
5850            { "condition": "event == \"on_submit\"", "to": "saved" },
5851            { "condition": "event == \"on_cancel\"", "to": "cancelled" }
5852        ]);
5853        match evaluate_custom_routing(&on_submit_routing, &ok_out, &state, &flow_ir, &current) {
5854            CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "saved"),
5855            other => panic!("expected Next(\"saved\") via on_submit default, got {other:?}"),
5856        }
5857
5858        // http shape: `on_success` present → still routes on_success (priority,
5859        // no regression for components whose success name is the old default).
5860        let on_success_routing = json!([
5861            { "condition": "event == \"on_success\"", "to": "ok" },
5862            { "condition": "event == \"on_error\"", "to": "err" }
5863        ]);
5864        match evaluate_custom_routing(&on_success_routing, &ok_out, &state, &flow_ir, &current) {
5865            CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "ok"),
5866            other => panic!("expected Next(\"ok\") via on_success default, got {other:?}"),
5867        }
5868    }
5869
5870    /// `evaluate_simple_condition` backs the user-authored `conditional_branch`
5871    /// expressions the catalog documents (e.g. `register.q_age >= 18`,
5872    /// `submit.status == "ok"`). Beyond `==`/`!=` it must handle numeric
5873    /// ordering (`>=` `<=` `>` `<`) and `contains` (case-insensitive substring);
5874    /// otherwise those conditions silently evaluate to false and route wrong.
5875    #[test]
5876    fn condition_evaluator_supports_comparisons_and_contains() {
5877        let ctx = json!({
5878            "register": { "q_age": 18 },
5879            "submit": { "status": "ok" },
5880            "msg": { "text": "Hello World" }
5881        });
5882
5883        // Numeric ordering (operands parsed as numbers).
5884        assert!(evaluate_simple_condition("register.q_age >= 18", &ctx));
5885        assert!(!evaluate_simple_condition("register.q_age > 18", &ctx));
5886        assert!(evaluate_simple_condition("register.q_age <= 18", &ctx));
5887        assert!(!evaluate_simple_condition("register.q_age < 18", &ctx));
5888
5889        // contains: case-insensitive substring over the resolved string.
5890        assert!(evaluate_simple_condition(
5891            "msg.text contains \"world\"",
5892            &ctx
5893        ));
5894        assert!(!evaluate_simple_condition(
5895            "msg.text contains \"bye\"",
5896            &ctx
5897        ));
5898
5899        // Existing equality semantics unchanged (regression guard).
5900        assert!(evaluate_simple_condition("submit.status == \"ok\"", &ctx));
5901        assert!(!evaluate_simple_condition("submit.status != \"ok\"", &ctx));
5902        // A non-numeric operand on an ordering op is false, not a panic.
5903        assert!(!evaluate_simple_condition("submit.status >= 1", &ctx));
5904    }
5905
5906    /// Symmetric to the success default: when a node FAILS (`ok == false`)
5907    /// without an explicit outcome, route to the error-family port the node
5908    /// actually has an edge for (priority `on_error` → `on_cancel` →
5909    /// `on_timeout`), not blindly `on_error`. Lets a node whose failure port is
5910    /// `on_cancel` (qa) or `on_timeout` (http) route instead of stalling.
5911    #[test]
5912    fn failure_default_matches_available_outcome_port() {
5913        let flow_ir = HostFlow {
5914            id: "flow.test".to_string(),
5915            start: None,
5916            nodes: IndexMap::new(),
5917            slot_schema: None,
5918        };
5919        let current = NodeId::from_str("current").unwrap();
5920        let state = ExecutionState::new(json!({}));
5921        // ok:false, no explicit outcome — the failure case.
5922        let err_out = NodeOutput {
5923            ok: false,
5924            payload: json!({}),
5925            meta: Value::Null,
5926        };
5927
5928        // qa shape: failure port is `on_cancel`, no `on_error` edge.
5929        let on_cancel_routing = json!([
5930            { "condition": "event == \"on_complete\"", "to": "next" },
5931            { "condition": "event == \"on_cancel\"", "to": "cancelled" }
5932        ]);
5933        match evaluate_custom_routing(&on_cancel_routing, &err_out, &state, &flow_ir, &current) {
5934            CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "cancelled"),
5935            other => panic!("expected Next(\"cancelled\") via on_cancel default, got {other:?}"),
5936        }
5937
5938        // http shape: `on_error` present → on_error (priority, unchanged).
5939        let on_error_routing = json!([
5940            { "condition": "event == \"on_success\"", "to": "ok" },
5941            { "condition": "event == \"on_error\"", "to": "err" }
5942        ]);
5943        match evaluate_custom_routing(&on_error_routing, &err_out, &state, &flow_ir, &current) {
5944            CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "err"),
5945            other => panic!("expected Next(\"err\") via on_error default, got {other:?}"),
5946        }
5947
5948        // on_timeout-only failure port.
5949        let on_timeout_routing = json!([
5950            { "condition": "event == \"on_success\"", "to": "ok" },
5951            { "condition": "event == \"on_timeout\"", "to": "timed_out" }
5952        ]);
5953        match evaluate_custom_routing(&on_timeout_routing, &err_out, &state, &flow_ir, &current) {
5954            CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "timed_out"),
5955            other => panic!("expected Next(\"timed_out\") via on_timeout default, got {other:?}"),
5956        }
5957    }
5958
5959    #[test]
5960    fn outcome_meta_surfaces_component_emitted_outcome() {
5961        // A component opts into outcome routing by adding `outcome` to its
5962        // output envelope; the runner surfaces it as node meta for routing.
5963        assert_eq!(
5964            outcome_meta(&json!({ "ok": true, "outcome": "on_complete" })),
5965            json!({ "outcome": "on_complete" })
5966        );
5967        // No `outcome` → null meta → engine uses the ok-derived default.
5968        assert_eq!(
5969            outcome_meta(&json!({ "ok": true, "body": {} })),
5970            Value::Null
5971        );
5972    }
5973
5974    /// Live end-to-end test: `dw.agent` NATS dispatch path.
5975    ///
5976    /// Requires a real NATS server (JetStream not needed for this test — core
5977    /// NATS pub/sub is sufficient) and an `aw-serve` consumer (or the in-process
5978    /// fake bridge below acts as one).
5979    ///
5980    /// # Run recipe
5981    ///
5982    /// ```text
5983    /// # Terminal 1 – NATS server (JetStream-enabled for prod parity, but core works too)
5984    /// nats-server -js
5985    ///
5986    /// # Terminal 2 – aw-serve test-mock (replies "pong" for any agent)
5987    /// AW_SERVE_AGENT_ID=greeter AW_SERVE_REPLY=pong \
5988    ///   GREENTIC_EVENTS_NATS_URL=nats://127.0.0.1:4222 \
5989    ///   GREENTIC_AW_JETSTREAM=off \
5990    ///   cargo run -p greentic-aw-runtime --features serve,test-mock --bin aw-serve
5991    ///
5992    /// # Terminal 3 – run this ignored test
5993    /// GREENTIC_EVENTS_NATS_URL=nats://127.0.0.1:4222 \
5994    ///   cargo test -p greentic-runner-host --lib \
5995    ///   tests::dw_agent_scale_to_zero_nats_e2e \
5996    ///   -- --nocapture --ignored
5997    /// ```
5998    ///
5999    /// When `GREENTIC_EVENTS_NATS_URL` is unset the test skips immediately.
6000    /// The test wires its own in-process fake bridge so the `aw-serve` binary is
6001    /// optional; running with the real `aw-serve` exercises the full out-of-process
6002    /// path. Both variants must produce a resumed reply of `"pong"`.
6003    #[cfg(feature = "agentic-worker")]
6004    #[tokio::test]
6005    #[ignore = "requires live NATS; run with --ignored after `nats-server -js`"]
6006    async fn dw_agent_scale_to_zero_nats_e2e() {
6007        use crate::runner::agent_node::DwAgentDispatch;
6008        use crate::runner::dispatch_listener::{SessionResumer, run_response_listener};
6009        use crate::runner::remote_dispatch::NatsDispatcher;
6010        use futures::StreamExt as _;
6011        use greentic_types::{
6012            RuntimeDispatchResponse, TenantCtx as DispatchTenantCtx, request_topic, response_topic,
6013        };
6014        use tokio::sync::Notify;
6015
6016        let nats_url = match std::env::var("GREENTIC_EVENTS_NATS_URL") {
6017            Ok(url) => url,
6018            Err(_) => {
6019                eprintln!(
6020                    "skipping dw_agent_scale_to_zero_nats_e2e: GREENTIC_EVENTS_NATS_URL not set"
6021                );
6022                return;
6023            }
6024        };
6025
6026        // ── 1. Build a two-node flow: dw.agent → emit.log (resume target) ──
6027        // The agent node must have Routing::Next so the engine knows the resume
6028        // target (same requirement as agentic.call / sorla.call in production).
6029        let resume_id = NodeId::from_str("after-agent").unwrap();
6030        let agent_node_id = NodeId::from_str("agent-e2e").unwrap();
6031        let agent_node = Node {
6032            id: agent_node_id.clone(),
6033            component: FlowComponentRef {
6034                id: "dw.agent".parse().unwrap(),
6035                pack_alias: None,
6036                operation: Some("greeter".to_string()),
6037            },
6038            input: InputMapping {
6039                mapping: json!({ "user_text": "ping" }),
6040            },
6041            output: OutputMapping {
6042                mapping: Value::Null,
6043            },
6044            err_map: None,
6045            routing: Routing::Next {
6046                node_id: resume_id.clone(),
6047            },
6048            telemetry: TelemetryHints::default(),
6049        };
6050        let resume_node = Node {
6051            id: resume_id.clone(),
6052            component: FlowComponentRef {
6053                id: "emit.log".parse().unwrap(),
6054                pack_alias: None,
6055                operation: None,
6056            },
6057            input: InputMapping {
6058                mapping: json!({ "message": "resumed" }),
6059            },
6060            output: OutputMapping {
6061                mapping: Value::Null,
6062            },
6063            err_map: None,
6064            routing: Routing::End,
6065            telemetry: TelemetryHints::default(),
6066        };
6067        let mut nodes = indexmap::IndexMap::default();
6068        nodes.insert(agent_node_id.clone(), agent_node);
6069        nodes.insert(resume_id.clone(), resume_node);
6070        let flow = greentic_types::Flow {
6071            schema_version: "1.0".into(),
6072            id: greentic_types::FlowId::from_str("e2e-agent.flow").unwrap(),
6073            kind: greentic_types::FlowKind::Messaging,
6074            entrypoints: BTreeMap::from([(
6075                "default".to_string(),
6076                Value::String(agent_node_id.to_string()),
6077            )]),
6078            nodes,
6079            metadata: Default::default(),
6080        };
6081        let host_flow = HostFlow::from(flow);
6082
6083        // ── 2. Connect NATS clients ──
6084        let dispatcher_client = async_nats::connect(&nats_url)
6085            .await
6086            .expect("NATS: dispatcher client");
6087        let bridge_client = async_nats::connect(&nats_url)
6088            .await
6089            .expect("NATS: fake bridge client");
6090        let listener_client = async_nats::connect(&nats_url)
6091            .await
6092            .expect("NATS: response listener client");
6093
6094        // ── 3. Fake bridge: subscribe to agentic request subject, reply "pong" ──
6095        let agentic_request_subject = request_topic("agentic");
6096        let agentic_response_subject = response_topic("agentic");
6097        let mut req_sub = bridge_client
6098            .subscribe(agentic_request_subject.clone())
6099            .await
6100            .expect("fake bridge: subscribe to agentic request subject");
6101        let bridge_reply_client = bridge_client.clone();
6102        let reply_subject = agentic_response_subject.clone();
6103        tokio::spawn(async move {
6104            while let Some(msg) = req_sub.next().await {
6105                let headers = msg.headers.as_ref();
6106                let get_hdr = |name: &str| {
6107                    headers
6108                        .and_then(|h| h.get(name))
6109                        .map(|v| v.as_str().to_owned())
6110                        .unwrap_or_default()
6111                };
6112                let correlation_id = get_hdr("Greentic-Correlation-Id");
6113                let tenant = get_hdr("Greentic-Tenant");
6114                let env = get_hdr("Greentic-Env");
6115
6116                let response_payload = RuntimeDispatchResponse {
6117                    ok: true,
6118                    output: json!({
6119                        "reply": "pong",
6120                        "trail": [],
6121                        "terminated_by": "final_reply"
6122                    }),
6123                    events: vec![],
6124                    error: None,
6125                };
6126                let body =
6127                    serde_json::to_vec(&response_payload).expect("serialize fake bridge response");
6128
6129                let mut resp_headers = async_nats::HeaderMap::new();
6130                resp_headers.insert("Greentic-Correlation-Id", correlation_id.as_str());
6131                resp_headers.insert("Greentic-Tenant", tenant.as_str());
6132                resp_headers.insert("Greentic-Env", env.as_str());
6133
6134                bridge_reply_client
6135                    .publish_with_headers(reply_subject.clone(), resp_headers, body.into())
6136                    .await
6137                    .expect("fake bridge: publish response");
6138            }
6139        });
6140
6141        // ── 4. Recording resumer + run_response_listener ──
6142        struct RecordingResumer {
6143            calls: std::sync::Mutex<Vec<(String, Value)>>,
6144            notify: Notify,
6145        }
6146
6147        impl RecordingResumer {
6148            fn new() -> Self {
6149                Self {
6150                    calls: std::sync::Mutex::new(vec![]),
6151                    notify: Notify::new(),
6152                }
6153            }
6154        }
6155
6156        #[async_trait::async_trait]
6157        impl SessionResumer for RecordingResumer {
6158            async fn resume(
6159                &self,
6160                _tenant: DispatchTenantCtx,
6161                correlation_id: &str,
6162                output: Value,
6163            ) -> anyhow::Result<()> {
6164                self.calls
6165                    .lock()
6166                    .unwrap()
6167                    .push((correlation_id.to_string(), output));
6168                self.notify.notify_one();
6169                Ok(())
6170            }
6171        }
6172
6173        let resumer = Arc::new(RecordingResumer::new());
6174        let resumer_for_listener = resumer.clone();
6175        tokio::spawn(async move {
6176            run_response_listener(listener_client, "agentic".to_owned(), resumer_for_listener)
6177                .await
6178                .expect("response listener exited unexpectedly");
6179        });
6180
6181        // Give subscriptions a moment to register.
6182        tokio::time::sleep(tokio::time::Duration::from_millis(150)).await;
6183
6184        // ── 5. Build FlowEngine with NatsDispatcher + DwAgentDispatch::Nats ──
6185        let nats_engine_dispatcher = Arc::new(NatsDispatcher::new(dispatcher_client));
6186        let engine = FlowEngine {
6187            packs: Vec::new(),
6188            flows: Vec::new(),
6189            flow_sources: StdHashMap::new(),
6190            messaging_provider_pack_ids: std::collections::HashSet::new(),
6191            flow_cache: RwLock::new(StdHashMap::from([(
6192                FlowKey {
6193                    pack_id: "e2e-pack".to_string(),
6194                    flow_id: "e2e-agent.flow".to_string(),
6195                },
6196                host_flow,
6197            )])),
6198            default_env: "local".to_string(),
6199            validation: crate::validate::ValidationConfig {
6200                mode: crate::validate::ValidationMode::Off,
6201            },
6202            cross_pack_resolver: None,
6203            rollout_ids: RolloutIds::default(),
6204            remote_dispatch_handler: Some(
6205                nats_engine_dispatcher
6206                    as Arc<dyn crate::runner::remote_dispatch::RemoteDispatchHandler>,
6207            ),
6208            dw_agent_dispatch: DwAgentDispatch::Nats,
6209            agent_node_handler: None,
6210            graph_node_handler: None,
6211            mcp_tool_source: None,
6212        };
6213
6214        let ctx = FlowContext {
6215            tenant: "demo",
6216            pack_id: "e2e-pack",
6217            flow_id: "e2e-agent.flow",
6218            node_id: None,
6219            tool: None,
6220            action: None,
6221            session_id: Some("e2e-sess-1"),
6222            provider_id: None,
6223            reply_scope: None,
6224            retry_config: RetryConfig {
6225                max_attempts: 1,
6226                base_delay_ms: 1,
6227            },
6228            attempt: 1,
6229            observer: None,
6230            mocks: None,
6231        };
6232
6233        // ── 6. Execute: the dw.agent NATS path must PAUSE the flow ──
6234        let result = engine
6235            .execute(ctx, json!({ "user_text": "ping" }))
6236            .await
6237            .expect("engine.execute succeeded");
6238
6239        assert!(
6240            matches!(result.status, FlowStatus::Waiting(_)),
6241            "expected FlowStatus::Waiting from dw.agent Nats path, got: {:?}",
6242            result.status
6243        );
6244        eprintln!("dw.agent: flow paused (Waiting) — dispatch published to NATS");
6245
6246        // ── 7. Wait for the fake bridge reply to reach the resumer (up to 5 s) ──
6247        let wait = tokio::time::timeout(
6248            tokio::time::Duration::from_secs(5),
6249            resumer.notify.notified(),
6250        )
6251        .await;
6252
6253        assert!(
6254            wait.is_ok(),
6255            "timed out waiting for fake bridge reply — is NATS running? ({nats_url})"
6256        );
6257
6258        // ── 8. Assert the resumed reply == "pong" ──
6259        let calls = resumer.calls.lock().unwrap();
6260        assert_eq!(
6261            calls.len(),
6262            1,
6263            "resumer should have been called exactly once"
6264        );
6265        let (ref _corr, ref output) = calls[0];
6266        assert_eq!(
6267            output["output"]["reply"],
6268            json!("pong"),
6269            "resumed reply must match the aw-serve canned reply"
6270        );
6271        eprintln!(
6272            "PASSED: dw.agent scale-to-zero NATS e2e — reply={:?}",
6273            output["output"]["reply"]
6274        );
6275    }
6276}
6277
6278use tracing::Instrument;
6279
6280pub struct FlowContext<'a> {
6281    pub tenant: &'a str,
6282    pub pack_id: &'a str,
6283    pub flow_id: &'a str,
6284    pub node_id: Option<&'a str>,
6285    pub tool: Option<&'a str>,
6286    pub action: Option<&'a str>,
6287    pub session_id: Option<&'a str>,
6288    pub provider_id: Option<&'a str>,
6289    /// Reply scope of the originating inbound activity, when known.
6290    ///
6291    /// Carried so async-dispatch nodes (`sorla.call await`) can encode the
6292    /// inbound `thread`/`reply_to` into the published correlation id. Without
6293    /// it, a wait saved against a threaded scope cannot be re-keyed on resume
6294    /// (the resumer would synthesize an empty thread/reply_to and miss the
6295    /// saved wait). See `execute_sorla_call` and `RuntimeSessionResumer`.
6296    pub reply_scope: Option<&'a greentic_types::ReplyScope>,
6297    pub retry_config: RetryConfig,
6298    pub attempt: u32,
6299    pub observer: Option<&'a dyn ExecutionObserver>,
6300    pub mocks: Option<&'a MockLayer>,
6301}
6302
6303#[derive(Copy, Clone)]
6304pub struct RetryConfig {
6305    pub max_attempts: u32,
6306    pub base_delay_ms: u64,
6307}
6308
6309/// Look across all node outputs, find the first one that finished with
6310/// `ok=false`, and lift its `meta.error` fields into
6311/// `output.metadata.error_kind` / `.error_message` / `.node_id`. Returns the
6312/// (possibly enriched) output unchanged otherwise.
6313///
6314/// This is how the executor "shows" an unhandled flow-node failure to the
6315/// caller without the flow author having to add error routing: the chat-side
6316/// provider (messaging-providers `extract_error_envelope`) picks the lifted
6317/// fields off `output.metadata` and renders a styled error card.
6318///
6319/// Takes a borrow of the node-output map rather than the whole
6320/// `ExecutionState` because the callers have already consumed `state` via
6321/// `state.finalize_with(...)`; we capture a cheap clone of `state.nodes` up
6322/// front and pass it in here.
6323fn lift_first_node_error_from_nodes(output: Value, nodes: &HashMap<String, NodeOutput>) -> Value {
6324    let Some((node_id, failed)) = nodes.iter().find(|(_, out)| !out.ok) else {
6325        return output;
6326    };
6327    let err_meta = failed.meta.get("error");
6328    let message = err_meta
6329        .and_then(|e| e.get("message"))
6330        .and_then(|v| v.as_str())
6331        .unwrap_or("flow node failed");
6332    let kind = err_meta
6333        .and_then(|e| e.get("kind"))
6334        .and_then(|v| v.as_str())
6335        .unwrap_or("flow_node_failed");
6336
6337    let mut output = match output {
6338        Value::Object(map) => map,
6339        Value::Null => JsonMap::new(),
6340        other => {
6341            let mut wrap = JsonMap::new();
6342            wrap.insert("payload".to_string(), other);
6343            wrap
6344        }
6345    };
6346    let metadata_entry = output
6347        .entry("metadata".to_string())
6348        .or_insert_with(|| Value::Object(JsonMap::new()));
6349    let metadata_map = match metadata_entry {
6350        Value::Object(map) => map,
6351        _ => {
6352            *metadata_entry = Value::Object(JsonMap::new());
6353            metadata_entry.as_object_mut().unwrap()
6354        }
6355    };
6356    metadata_map
6357        .entry("error_kind".to_string())
6358        .or_insert(Value::String(kind.to_string()));
6359    metadata_map
6360        .entry("error_message".to_string())
6361        .or_insert(Value::String(message.to_string()));
6362    metadata_map
6363        .entry("node_id".to_string())
6364        .or_insert(Value::String(node_id.clone()));
6365    Value::Object(output)
6366}
6367
6368fn should_retry(err: &anyhow::Error) -> bool {
6369    let lower = err.to_string().to_lowercase();
6370    lower.contains("transient")
6371        || lower.contains("unavailable")
6372        || lower.contains("internal")
6373        || lower.contains("timeout")
6374}
6375
6376impl From<FlowRetryConfig> for RetryConfig {
6377    fn from(value: FlowRetryConfig) -> Self {
6378        Self {
6379            max_attempts: value.max_attempts.max(1),
6380            base_delay_ms: value.base_delay_ms.max(50),
6381        }
6382    }
6383}