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::{FlowSpanAttributes, annotate_span, backoff_delay_ms, set_flow_context};
22#[cfg(feature = "fault-injection")]
23use crate::testing::fault_injection::{FaultContext, FaultPoint, maybe_fail};
24use crate::validate::{
25    ValidationConfig, ValidationIssue, ValidationMode, validate_component_envelope,
26    validate_tool_envelope,
27};
28use greentic_types::{Flow, Node, NodeId, Routing};
29
30/// Callback trait for resolving cross-pack provider invocations.
31///
32/// When a `provider.invoke` node references a provider that is not in the
33/// current pack, the flow engine calls this resolver as a fallback.
34/// Implementations typically delegate to a capability registry that knows
35/// about all packs in the bundle.
36pub trait CrossPackResolver: Send + Sync {
37    fn invoke(
38        &self,
39        provider_id: &str,
40        provider_type: Option<&str>,
41        op: &str,
42        input: &[u8],
43        tenant: &str,
44        team: Option<&str>,
45    ) -> Result<Value>;
46}
47
48pub struct FlowEngine {
49    packs: Vec<Arc<PackRuntime>>,
50    flows: Vec<FlowDescriptor>,
51    flow_sources: HashMap<FlowKey, usize>,
52    flow_cache: RwLock<HashMap<FlowKey, HostFlow>>,
53    default_env: String,
54    validation: ValidationConfig,
55    cross_pack_resolver: Option<Arc<dyn CrossPackResolver>>,
56}
57
58#[derive(Clone, Debug, PartialEq, Eq, Hash)]
59struct FlowKey {
60    pack_id: String,
61    flow_id: String,
62}
63
64#[derive(Clone, Debug, Serialize, Deserialize)]
65pub struct FlowSnapshot {
66    pub pack_id: String,
67    pub flow_id: String,
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub next_flow: Option<String>,
70    pub next_node: String,
71    pub state: ExecutionState,
72}
73
74#[derive(Clone, Debug)]
75pub struct FlowWait {
76    pub reason: Option<String>,
77    pub snapshot: FlowSnapshot,
78}
79
80#[derive(Clone, Debug)]
81pub enum FlowStatus {
82    Completed,
83    Waiting(Box<FlowWait>),
84}
85
86#[derive(Clone, Debug)]
87pub struct FlowExecution {
88    pub output: Value,
89    pub status: FlowStatus,
90}
91
92#[derive(Clone, Debug)]
93struct HostFlow {
94    id: String,
95    start: Option<NodeId>,
96    nodes: IndexMap<NodeId, HostNode>,
97}
98
99#[derive(Clone, Debug)]
100pub struct HostNode {
101    kind: NodeKind,
102    /// Backwards-compatible component label for observers/transcript.
103    pub component: String,
104    component_id: String,
105    operation_name: Option<String>,
106    operation_in_mapping: Option<String>,
107    payload_expr: Value,
108    routing: Routing,
109}
110
111impl HostNode {
112    pub fn component_id(&self) -> &str {
113        &self.component_id
114    }
115
116    pub fn operation_name(&self) -> Option<&str> {
117        self.operation_name.as_deref()
118    }
119
120    pub fn operation_in_mapping(&self) -> Option<&str> {
121        self.operation_in_mapping.as_deref()
122    }
123}
124
125#[derive(Clone, Debug)]
126enum NodeKind {
127    Exec { target_component: String },
128    PackComponent { component_ref: String },
129    ProviderInvoke,
130    FlowCall,
131    BuiltinEmit { kind: EmitKind },
132    BuiltinStateGet,
133    BuiltinStateSet,
134    Wait,
135}
136
137#[derive(Clone, Debug)]
138enum EmitKind {
139    Log,
140    Response,
141    Other(String),
142}
143
144struct ComponentOverrides<'a> {
145    component: Option<&'a str>,
146    operation: Option<&'a str>,
147}
148
149struct ComponentCall {
150    component_ref: String,
151    operation: String,
152    input: Value,
153    config: Value,
154}
155
156impl FlowExecution {
157    fn completed(output: Value) -> Self {
158        Self {
159            output,
160            status: FlowStatus::Completed,
161        }
162    }
163
164    fn waiting(output: Value, wait: FlowWait) -> Self {
165        Self {
166            output,
167            status: FlowStatus::Waiting(Box::new(wait)),
168        }
169    }
170}
171
172impl FlowEngine {
173    pub async fn new(packs: Vec<Arc<PackRuntime>>, config: Arc<HostConfig>) -> Result<Self> {
174        let mut flow_sources: HashMap<FlowKey, usize> = HashMap::new();
175        let mut descriptors = Vec::new();
176        let mut bindings = HashMap::new();
177        for pack in &config.pack_bindings {
178            bindings.insert(pack.pack_id.clone(), pack.flows.clone());
179        }
180        let enforce_bindings = !bindings.is_empty();
181        for (idx, pack) in packs.iter().enumerate() {
182            let pack_id = pack.metadata().pack_id.clone();
183            if enforce_bindings && !bindings.contains_key(&pack_id) {
184                bail!("no gtbind entries found for pack {}", pack_id);
185            }
186            let flows = pack.list_flows().await?;
187            let allowed = bindings.get(&pack_id).map(|flows| {
188                flows
189                    .iter()
190                    .cloned()
191                    .collect::<std::collections::HashSet<_>>()
192            });
193            let mut seen = std::collections::HashSet::new();
194            for flow in flows {
195                if let Some(ref allow) = allowed
196                    && !allow.contains(&flow.id)
197                {
198                    continue;
199                }
200                seen.insert(flow.id.clone());
201                tracing::info!(
202                    flow_id = %flow.id,
203                    flow_type = %flow.flow_type,
204                    pack_id = %flow.pack_id,
205                    pack_index = idx,
206                    "registered flow"
207                );
208                if let Ok(flow_ir) = pack.load_flow(&flow.id) {
209                    for node in flow_ir.nodes.values() {
210                        config
211                            .secrets_policy
212                            .register_flow_secret_refs(&node.input.mapping);
213                        config
214                            .secrets_policy
215                            .register_flow_secret_refs(&node.output.mapping);
216                    }
217                }
218                flow_sources.insert(
219                    FlowKey {
220                        pack_id: flow.pack_id.clone(),
221                        flow_id: flow.id.clone(),
222                    },
223                    idx,
224                );
225                descriptors.retain(|existing: &FlowDescriptor| {
226                    !(existing.id == flow.id && existing.pack_id == flow.pack_id)
227                });
228                descriptors.push(flow);
229            }
230            if let Some(allow) = allowed {
231                let missing = allow.difference(&seen).cloned().collect::<Vec<_>>();
232                if !missing.is_empty() {
233                    bail!(
234                        "gtbind flow ids missing in pack {}: {}",
235                        pack_id,
236                        missing.join(", ")
237                    );
238                }
239            }
240        }
241
242        let mut flow_map = HashMap::new();
243        for flow in &descriptors {
244            let pack_id = flow.pack_id.clone();
245            if let Some(&pack_idx) = flow_sources.get(&FlowKey {
246                pack_id: pack_id.clone(),
247                flow_id: flow.id.clone(),
248            }) {
249                let pack_clone = Arc::clone(&packs[pack_idx]);
250                let flow_id = flow.id.clone();
251                let task_flow_id = flow_id.clone();
252                match task::spawn_blocking(move || pack_clone.load_flow(&task_flow_id)).await {
253                    Ok(Ok(loaded_flow)) => {
254                        flow_map.insert(
255                            FlowKey {
256                                pack_id: pack_id.clone(),
257                                flow_id,
258                            },
259                            HostFlow::from(loaded_flow),
260                        );
261                    }
262                    Ok(Err(err)) => {
263                        tracing::warn!(flow_id = %flow.id, error = %err, "failed to load flow metadata");
264                    }
265                    Err(err) => {
266                        tracing::warn!(flow_id = %flow.id, error = %err, "join error loading flow metadata");
267                    }
268                }
269            }
270        }
271
272        Ok(Self {
273            packs,
274            flows: descriptors,
275            flow_sources,
276            flow_cache: RwLock::new(flow_map),
277            default_env: env::var("GREENTIC_ENV").unwrap_or_else(|_| "local".to_string()),
278            validation: config.validation.clone(),
279            cross_pack_resolver: None,
280        })
281    }
282
283    /// Set an optional cross-pack resolver for `provider.invoke` nodes that
284    /// reference providers in other packs (resolved via capability registry).
285    pub fn set_cross_pack_resolver(&mut self, resolver: Arc<dyn CrossPackResolver>) {
286        self.cross_pack_resolver = Some(resolver);
287    }
288
289    async fn get_or_load_flow(&self, pack_id: &str, flow_id: &str) -> Result<HostFlow> {
290        let key = FlowKey {
291            pack_id: pack_id.to_string(),
292            flow_id: flow_id.to_string(),
293        };
294        if let Some(flow) = self.flow_cache.read().get(&key).cloned() {
295            return Ok(flow);
296        }
297
298        let pack_idx = *self
299            .flow_sources
300            .get(&key)
301            .with_context(|| format!("flow {pack_id}:{flow_id} not registered"))?;
302        let pack = Arc::clone(&self.packs[pack_idx]);
303        let flow_id_owned = flow_id.to_string();
304        let task_flow_id = flow_id_owned.clone();
305        let flow = task::spawn_blocking(move || pack.load_flow(&task_flow_id))
306            .await
307            .context("failed to join flow metadata task")??;
308        let host_flow = HostFlow::from(flow);
309        self.flow_cache.write().insert(
310            FlowKey {
311                pack_id: pack_id.to_string(),
312                flow_id: flow_id_owned.clone(),
313            },
314            host_flow.clone(),
315        );
316        Ok(host_flow)
317    }
318
319    pub async fn execute(&self, ctx: FlowContext<'_>, input: Value) -> Result<FlowExecution> {
320        let span = tracing::info_span!(
321            "flow.execute",
322            tenant = tracing::field::Empty,
323            flow_id = tracing::field::Empty,
324            node_id = tracing::field::Empty,
325            tool = tracing::field::Empty,
326            action = tracing::field::Empty
327        );
328        annotate_span(
329            &span,
330            &FlowSpanAttributes {
331                tenant: ctx.tenant,
332                flow_id: ctx.flow_id,
333                node_id: ctx.node_id,
334                tool: ctx.tool,
335                action: ctx.action,
336            },
337        );
338        set_flow_context(
339            &self.default_env,
340            ctx.tenant,
341            ctx.flow_id,
342            ctx.node_id,
343            ctx.provider_id,
344            ctx.session_id,
345        );
346        let retry_config = ctx.retry_config;
347        let original_input = input;
348        let mut ctx = ctx;
349        async move {
350            let mut attempt = 0u32;
351            loop {
352                attempt += 1;
353                ctx.attempt = attempt;
354                #[cfg(feature = "fault-injection")]
355                {
356                    let fault_ctx = FaultContext {
357                        pack_id: ctx.pack_id,
358                        flow_id: ctx.flow_id,
359                        node_id: ctx.node_id,
360                        attempt: ctx.attempt,
361                    };
362                    maybe_fail(FaultPoint::Timeout, fault_ctx)
363                        .map_err(|err| anyhow!(err.to_string()))?;
364                }
365                match self.execute_once(&ctx, original_input.clone()).await {
366                    Ok(value) => return Ok(value),
367                    Err(err) => {
368                        if attempt >= retry_config.max_attempts || !should_retry(&err) {
369                            return Err(err);
370                        }
371                        let delay = backoff_delay_ms(retry_config.base_delay_ms, attempt - 1);
372                        tracing::warn!(
373                            tenant = ctx.tenant,
374                            flow_id = ctx.flow_id,
375                            attempt,
376                            max_attempts = retry_config.max_attempts,
377                            delay_ms = delay,
378                            error = %err,
379                            "transient flow execution failure, backing off"
380                        );
381                        tokio::time::sleep(Duration::from_millis(delay)).await;
382                    }
383                }
384            }
385        }
386        .instrument(span)
387        .await
388    }
389
390    pub async fn resume(
391        &self,
392        ctx: FlowContext<'_>,
393        snapshot: FlowSnapshot,
394        input: Value,
395    ) -> Result<FlowExecution> {
396        if snapshot.pack_id != ctx.pack_id {
397            bail!(
398                "snapshot pack {} does not match requested {}",
399                snapshot.pack_id,
400                ctx.pack_id
401            );
402        }
403        let resume_flow = snapshot
404            .next_flow
405            .clone()
406            .unwrap_or_else(|| snapshot.flow_id.clone());
407        let flow_ir = self.get_or_load_flow(ctx.pack_id, &resume_flow).await?;
408        let mut state = snapshot.state;
409        state.replace_input(input);
410        state.ensure_entry();
411        self.drive_flow(&ctx, flow_ir, state, Some(snapshot.next_node), resume_flow)
412            .await
413    }
414
415    async fn execute_once(&self, ctx: &FlowContext<'_>, input: Value) -> Result<FlowExecution> {
416        let flow_ir = self.get_or_load_flow(ctx.pack_id, ctx.flow_id).await?;
417        let state = ExecutionState::new(input);
418        self.drive_flow(ctx, flow_ir, state, None, ctx.flow_id.to_string())
419            .await
420    }
421
422    async fn drive_flow(
423        &self,
424        ctx: &FlowContext<'_>,
425        mut flow_ir: HostFlow,
426        mut state: ExecutionState,
427        resume_from: Option<String>,
428        mut current_flow_id: String,
429    ) -> Result<FlowExecution> {
430        let mut current = match resume_from {
431            Some(node) => NodeId::from_str(&node)
432                .with_context(|| format!("invalid resume node id `{node}`"))?,
433            None => flow_ir
434                .start
435                .clone()
436                .or_else(|| flow_ir.nodes.keys().next().cloned())
437                .with_context(|| format!("flow {} has no start node", flow_ir.id))?,
438        };
439
440        loop {
441            let step_ctx = FlowContext {
442                tenant: ctx.tenant,
443                pack_id: ctx.pack_id,
444                flow_id: current_flow_id.as_str(),
445                node_id: ctx.node_id,
446                tool: ctx.tool,
447                action: ctx.action,
448                session_id: ctx.session_id,
449                provider_id: ctx.provider_id,
450                retry_config: ctx.retry_config,
451                attempt: ctx.attempt,
452                observer: ctx.observer,
453                mocks: ctx.mocks,
454            };
455            let node = flow_ir
456                .nodes
457                .get(&current)
458                .with_context(|| format!("node {} not found", current.as_str()))?;
459
460            let payload_template = node.payload_expr.clone();
461            let prev = state
462                .last_output
463                .as_ref()
464                .cloned()
465                .unwrap_or_else(|| Value::Object(JsonMap::new()));
466            let ctx_value = template_context(&state, prev);
467            #[cfg(feature = "fault-injection")]
468            {
469                let fault_ctx = FaultContext {
470                    pack_id: ctx.pack_id,
471                    flow_id: ctx.flow_id,
472                    node_id: Some(current.as_str()),
473                    attempt: ctx.attempt,
474                };
475                maybe_fail(FaultPoint::TemplateRender, fault_ctx)
476                    .map_err(|err| anyhow!(err.to_string()))?;
477            }
478            let payload =
479                render_template_value(&payload_template, &ctx_value, TemplateOptions::default())
480                    .context("failed to render node input template")?;
481            let observed_payload = payload.clone();
482            let node_id = current.clone();
483            let event = NodeEvent {
484                context: &step_ctx,
485                node_id: node_id.as_str(),
486                node,
487                payload: &observed_payload,
488            };
489            if let Some(observer) = step_ctx.observer {
490                observer.on_node_start(&event);
491            }
492            let dispatch = self
493                .dispatch_node(
494                    &step_ctx,
495                    node_id.as_str(),
496                    node,
497                    &mut state,
498                    payload,
499                    &event,
500                )
501                .await;
502            let DispatchOutcome { output, control } = match dispatch {
503                Ok(outcome) => outcome,
504                Err(err) => {
505                    if let Some(observer) = step_ctx.observer {
506                        observer.on_node_error(&event, err.as_ref());
507                    }
508                    return Err(err);
509                }
510            };
511
512            state.nodes.insert(node_id.clone().into(), output.clone());
513            state.last_output = Some(output.payload.clone());
514            if let Some(observer) = step_ctx.observer {
515                observer.on_node_end(&event, &output.payload);
516            }
517
518            match control {
519                NodeControl::Continue => {
520                    let (next, should_exit) = match &node.routing {
521                        Routing::Next { node_id } => (Some(node_id.clone()), false),
522                        Routing::End | Routing::Reply => (None, true),
523                        Routing::Branch { default, .. } => (default.clone(), default.is_none()),
524                        Routing::Custom(raw) => {
525                            evaluate_custom_routing(raw, &output, &state, &flow_ir, &node_id)
526                        }
527                    };
528
529                    if should_exit {
530                        return Ok(FlowExecution::completed(
531                            state.finalize_with(Some(output.payload.clone())),
532                        ));
533                    }
534
535                    match next {
536                        Some(n) => current = n,
537                        None => {
538                            return Ok(FlowExecution::completed(
539                                state.finalize_with(Some(output.payload.clone())),
540                            ));
541                        }
542                    }
543                }
544                NodeControl::Wait { reason } => {
545                    let (next, _) = match &node.routing {
546                        Routing::Next { node_id } => (Some(node_id.clone()), false),
547                        Routing::End | Routing::Reply => (None, true),
548                        Routing::Branch { default, .. } => (default.clone(), default.is_none()),
549                        Routing::Custom(raw) => {
550                            evaluate_custom_routing(raw, &output, &state, &flow_ir, &node_id)
551                        }
552                    };
553                    let resume_target = next.ok_or_else(|| {
554                        anyhow!(
555                            "session.wait node {} requires a non-empty route",
556                            current.as_str()
557                        )
558                    })?;
559                    let mut snapshot_state = state.clone();
560                    snapshot_state.clear_egress();
561                    let snapshot = FlowSnapshot {
562                        pack_id: step_ctx.pack_id.to_string(),
563                        flow_id: step_ctx.flow_id.to_string(),
564                        next_flow: (current_flow_id != step_ctx.flow_id)
565                            .then_some(current_flow_id.clone()),
566                        next_node: resume_target.as_str().to_string(),
567                        state: snapshot_state,
568                    };
569                    let output_value = state.clone().finalize_with(None);
570                    return Ok(FlowExecution::waiting(
571                        output_value,
572                        FlowWait { reason, snapshot },
573                    ));
574                }
575                NodeControl::Jump(jump) => {
576                    let jump_target = self.apply_jump(&step_ctx, &mut state, jump).await?;
577                    flow_ir = jump_target.flow;
578                    current_flow_id = jump_target.flow_id;
579                    current = jump_target.node_id;
580                }
581                NodeControl::Respond {
582                    text,
583                    card_cbor,
584                    needs_user,
585                } => {
586                    let response = json!({
587                        "text": text,
588                        "card_cbor": card_cbor,
589                        "needs_user": needs_user,
590                    });
591                    state.push_egress(response);
592                    return Ok(FlowExecution::completed(state.finalize_with(None)));
593                }
594            }
595        }
596    }
597
598    async fn dispatch_node(
599        &self,
600        ctx: &FlowContext<'_>,
601        node_id: &str,
602        node: &HostNode,
603        state: &mut ExecutionState,
604        mut payload: Value,
605        event: &NodeEvent<'_>,
606    ) -> Result<DispatchOutcome> {
607        inject_card_locale(&mut payload, &state.entry);
608        match &node.kind {
609            NodeKind::Exec { target_component } => self
610                .execute_component_exec(
611                    ctx,
612                    node_id,
613                    node,
614                    payload,
615                    event,
616                    ComponentOverrides {
617                        component: Some(target_component.as_str()),
618                        operation: node.operation_name.as_deref(),
619                    },
620                )
621                .await
622                .and_then(component_dispatch_outcome),
623            NodeKind::PackComponent { component_ref } => self
624                .execute_component_call(ctx, node_id, node, payload, component_ref.as_str(), event)
625                .await
626                .and_then(component_dispatch_outcome),
627            NodeKind::FlowCall => self
628                .execute_flow_call(ctx, payload)
629                .await
630                .map(DispatchOutcome::complete),
631            NodeKind::ProviderInvoke => self
632                .execute_provider_invoke(ctx, node_id, state, payload, event)
633                .await
634                .map(DispatchOutcome::complete),
635            NodeKind::BuiltinEmit { kind } => {
636                match kind {
637                    EmitKind::Log | EmitKind::Response => {}
638                    EmitKind::Other(component) => {
639                        tracing::debug!(%component, "handling emit.* as builtin");
640                    }
641                }
642                state.push_egress(payload.clone());
643                Ok(DispatchOutcome::complete(NodeOutput::new(payload)))
644            }
645            NodeKind::BuiltinStateGet => self
646                .execute_state_get(ctx, payload)
647                .await
648                .map(DispatchOutcome::complete),
649            NodeKind::BuiltinStateSet => self
650                .execute_state_set(ctx, payload)
651                .await
652                .map(DispatchOutcome::complete),
653            NodeKind::Wait => {
654                let reason = extract_wait_reason(&payload);
655                Ok(DispatchOutcome::wait(NodeOutput::new(payload), reason))
656            }
657        }
658    }
659
660    async fn execute_state_get(&self, ctx: &FlowContext<'_>, payload: Value) -> Result<NodeOutput> {
661        let key = Self::extract_state_key_helper(&payload)?;
662        let pack = self.pack_for_flow(ctx)?;
663        let store = pack
664            .state_store_handle()
665            .context("state store is not configured for this runtime")?;
666        let tenant_ctx = self.state_tenant_ctx(ctx)?;
667        let state_key = greentic_state::StateKey::new(&key);
668        let value = store
669            .get_json(
670                &tenant_ctx,
671                crate::storage::state::STATE_PREFIX,
672                &state_key,
673                None,
674            )
675            .with_context(|| format!("state.get failed for key `{key}`"))?;
676        let payload = serde_json::json!({
677            "key": key,
678            "value": value,
679            "found": value.is_some(),
680        });
681        Ok(NodeOutput::new(payload))
682    }
683
684    async fn execute_state_set(&self, ctx: &FlowContext<'_>, payload: Value) -> Result<NodeOutput> {
685        let key = Self::extract_state_key_helper(&payload)?;
686        let value = payload.get("value").cloned().unwrap_or(Value::Null);
687        let pack = self.pack_for_flow(ctx)?;
688        let store = pack
689            .state_store_handle()
690            .context("state store is not configured for this runtime")?;
691        let tenant_ctx = self.state_tenant_ctx(ctx)?;
692        let state_key = greentic_state::StateKey::new(&key);
693        store
694            .set_json(
695                &tenant_ctx,
696                crate::storage::state::STATE_PREFIX,
697                &state_key,
698                None,
699                &value,
700                None,
701            )
702            .with_context(|| format!("state.set failed for key `{key}`"))?;
703        let payload = serde_json::json!({ "key": key, "value": value });
704        Ok(NodeOutput::new(payload))
705    }
706
707    fn pack_for_flow(&self, ctx: &FlowContext<'_>) -> Result<&Arc<PackRuntime>> {
708        let key = FlowKey {
709            pack_id: ctx.pack_id.to_string(),
710            flow_id: ctx.flow_id.to_string(),
711        };
712        let idx = self.flow_sources.get(&key).with_context(|| {
713            format!("flow {} (pack {}) not registered", ctx.flow_id, ctx.pack_id)
714        })?;
715        Ok(&self.packs[*idx])
716    }
717
718    fn extract_state_key_helper(payload: &Value) -> Result<String> {
719        payload
720            .get("key")
721            .and_then(Value::as_str)
722            .map(String::from)
723            .filter(|k| !k.is_empty())
724            .context("state node payload missing required `key` (non-empty string)")
725    }
726
727    fn state_tenant_ctx(&self, ctx: &FlowContext<'_>) -> Result<greentic_types::TenantCtx> {
728        let env = greentic_types::EnvId::from_str(&self.default_env)
729            .with_context(|| format!("invalid env id `{}`", self.default_env))?;
730        let tenant = greentic_types::TenantId::from_str(ctx.tenant)
731            .with_context(|| format!("invalid tenant id `{}`", ctx.tenant))?;
732        Ok(greentic_types::TenantCtx::new(env, tenant))
733    }
734
735    async fn apply_jump(
736        &self,
737        ctx: &FlowContext<'_>,
738        state: &mut ExecutionState,
739        jump: JumpControl,
740    ) -> Result<JumpTarget> {
741        let target_flow = jump.flow.trim();
742        if target_flow.is_empty() {
743            bail!("missing_flow");
744        }
745
746        let flow = self
747            .get_or_load_flow(ctx.pack_id, target_flow)
748            .await
749            .with_context(|| format!("unknown_flow:{target_flow}"))?;
750
751        let target_node = if let Some(node) = jump.node.as_deref() {
752            let parsed = NodeId::from_str(node).with_context(|| format!("unknown_node:{node}"))?;
753            if !flow.nodes.contains_key(&parsed) {
754                bail!("unknown_node:{node}");
755            }
756            parsed
757        } else {
758            flow.start
759                .clone()
760                .or_else(|| flow.nodes.keys().next().cloned())
761                .ok_or_else(|| anyhow!("jump_failed: flow {target_flow} has no start node"))?
762        };
763
764        let max_redirects = jump.max_redirects.unwrap_or(3);
765        if state.redirect_count() >= max_redirects {
766            bail!("redirect_limit");
767        }
768        state.increment_redirect_count();
769        state.replace_input(jump.payload.clone());
770        state.last_output = Some(jump.payload);
771        tracing::info!(
772            flow_id = %ctx.flow_id,
773            target_flow = %target_flow,
774            target_node = %target_node.as_str(),
775            reason = ?jump.reason,
776            redirects = state.redirect_count(),
777            "flow.jump.applied"
778        );
779
780        Ok(JumpTarget {
781            flow_id: target_flow.to_string(),
782            flow,
783            node_id: target_node,
784        })
785    }
786
787    async fn execute_flow_call(&self, ctx: &FlowContext<'_>, payload: Value) -> Result<NodeOutput> {
788        #[derive(Deserialize)]
789        struct FlowCallPayload {
790            #[serde(alias = "flow")]
791            flow_id: String,
792            #[serde(default)]
793            input: Value,
794        }
795
796        let call: FlowCallPayload =
797            serde_json::from_value(payload).context("invalid payload for flow.call node")?;
798        if call.flow_id.trim().is_empty() {
799            bail!("flow.call requires a non-empty flow_id");
800        }
801
802        let sub_input = if call.input.is_null() {
803            Value::Null
804        } else {
805            call.input
806        };
807
808        let flow_id_owned = call.flow_id;
809        let action = "flow.call";
810        let sub_ctx = FlowContext {
811            tenant: ctx.tenant,
812            pack_id: ctx.pack_id,
813            flow_id: flow_id_owned.as_str(),
814            node_id: None,
815            tool: ctx.tool,
816            action: Some(action),
817            session_id: ctx.session_id,
818            provider_id: ctx.provider_id,
819            retry_config: ctx.retry_config,
820            attempt: ctx.attempt,
821            observer: ctx.observer,
822            mocks: ctx.mocks,
823        };
824
825        let execution = Box::pin(self.execute(sub_ctx, sub_input))
826            .await
827            .with_context(|| format!("flow.call failed for {}", flow_id_owned))?;
828        match execution.status {
829            FlowStatus::Completed => Ok(NodeOutput::new(execution.output)),
830            FlowStatus::Waiting(wait) => bail!(
831                "flow.call cannot pause (flow {} waiting {:?})",
832                flow_id_owned,
833                wait.reason
834            ),
835        }
836    }
837
838    async fn execute_component_exec(
839        &self,
840        ctx: &FlowContext<'_>,
841        node_id: &str,
842        node: &HostNode,
843        payload: Value,
844        event: &NodeEvent<'_>,
845        overrides: ComponentOverrides<'_>,
846    ) -> Result<NodeOutput> {
847        #[derive(Deserialize)]
848        struct ComponentPayload {
849            #[serde(default, alias = "component_ref", alias = "component")]
850            component: Option<String>,
851            #[serde(alias = "op")]
852            operation: Option<String>,
853            #[serde(default)]
854            input: Value,
855            #[serde(default)]
856            config: Value,
857        }
858
859        let payload: ComponentPayload =
860            serde_json::from_value(payload).context("invalid payload for component.exec")?;
861        let component_ref = overrides
862            .component
863            .map(str::to_string)
864            .or_else(|| payload.component.filter(|v| !v.trim().is_empty()))
865            .with_context(|| "component.exec requires a component_ref")?;
866        let operation = resolve_component_operation(
867            node_id,
868            node.component_id.as_str(),
869            payload.operation,
870            overrides.operation,
871            node.operation_in_mapping.as_deref(),
872        )?;
873        let call = ComponentCall {
874            component_ref,
875            operation,
876            input: payload.input,
877            config: payload.config,
878        };
879
880        self.invoke_component_call(ctx, node_id, call, event).await
881    }
882
883    async fn execute_component_call(
884        &self,
885        ctx: &FlowContext<'_>,
886        node_id: &str,
887        node: &HostNode,
888        payload: Value,
889        component_ref: &str,
890        event: &NodeEvent<'_>,
891    ) -> Result<NodeOutput> {
892        let payload_operation = extract_operation_from_mapping(&payload);
893        let (input, config) = split_operation_payload(payload);
894        let operation = resolve_component_operation(
895            node_id,
896            node.component_id.as_str(),
897            payload_operation,
898            node.operation_name.as_deref(),
899            node.operation_in_mapping.as_deref(),
900        )?;
901        let call = ComponentCall {
902            component_ref: component_ref.to_string(),
903            operation,
904            input,
905            config,
906        };
907        self.invoke_component_call(ctx, node_id, call, event).await
908    }
909
910    async fn invoke_component_call(
911        &self,
912        ctx: &FlowContext<'_>,
913        node_id: &str,
914        mut call: ComponentCall,
915        event: &NodeEvent<'_>,
916    ) -> Result<NodeOutput> {
917        self.validate_component(ctx, event, &call)?;
918        let key = FlowKey {
919            pack_id: ctx.pack_id.to_string(),
920            flow_id: ctx.flow_id.to_string(),
921        };
922        let pack_idx = *self.flow_sources.get(&key).with_context(|| {
923            format!("flow {} (pack {}) not registered", ctx.flow_id, ctx.pack_id)
924        })?;
925        let pack = Arc::clone(&self.packs[pack_idx]);
926
927        // Promote adaptive-card defaults from node config (default_card_asset /
928        // default_card_inline / default_source) into the invocation, so the
929        // component receives a valid `card_spec` field even when the user input
930        // is empty (e.g. webchat ConversationStart with no text). Without this,
931        // schema validation in the component reports AC_INVOCATION_MISSING_FIELD
932        // and the renderer falls back to a generic "Welcome" placeholder.
933        promote_card_config_to_invocation(&mut call.input, &call.config);
934
935        // Pre-resolve card asset paths: read JSON files from the pack's assets
936        // directory and inject as inline_json so the component doesn't need
937        // WASI filesystem access.
938        resolve_card_assets(&mut call.input, &pack);
939
940        // When the input is a card-like invocation (has card_source/card_spec),
941        // pass it directly to the component instead of wrapping in an
942        // InvocationEnvelope.  The envelope serialises the payload field as a
943        // byte array which the component cannot decode back, and the
944        // InvocationPayload::parse heuristic strips domain fields when a
945        // `payload` key is present (e.g.  the card's Handlebars template
946        // context `payload: {}`).
947        let is_card = is_card_invocation(&call.input);
948
949        let input_json = if is_card {
950            serde_json::to_string(&call.input)?
951        } else {
952            // Runtime owns ctx; flows must not embed ctx, even if they provide envelopes.
953            let meta = InvocationMeta {
954                env: &self.default_env,
955                tenant: ctx.tenant,
956                flow_id: ctx.flow_id,
957                node_id: Some(node_id),
958                provider_id: ctx.provider_id,
959                session_id: ctx.session_id,
960                attempt: ctx.attempt,
961            };
962            let invocation_envelope =
963                build_invocation_envelope(meta, call.operation.as_str(), call.input)
964                    .context("build invocation envelope for component call")?;
965            serde_json::to_string(&invocation_envelope)?
966        };
967        let config_json = if call.config.is_null() {
968            None
969        } else {
970            Some(serde_json::to_string(&call.config)?)
971        };
972
973        let exec_ctx = component_exec_ctx(ctx, node_id);
974        #[cfg(feature = "fault-injection")]
975        {
976            let fault_ctx = FaultContext {
977                pack_id: ctx.pack_id,
978                flow_id: ctx.flow_id,
979                node_id: Some(node_id),
980                attempt: ctx.attempt,
981            };
982            maybe_fail(FaultPoint::BeforeComponentCall, fault_ctx)
983                .map_err(|err| anyhow!(err.to_string()))?;
984        }
985        let value = pack
986            .invoke_component(
987                call.component_ref.as_str(),
988                exec_ctx,
989                call.operation.as_str(),
990                config_json,
991                input_json,
992            )
993            .await?;
994        #[cfg(feature = "fault-injection")]
995        {
996            let fault_ctx = FaultContext {
997                pack_id: ctx.pack_id,
998                flow_id: ctx.flow_id,
999                node_id: Some(node_id),
1000                attempt: ctx.attempt,
1001            };
1002            maybe_fail(FaultPoint::AfterComponentCall, fault_ctx)
1003                .map_err(|err| anyhow!(err.to_string()))?;
1004        }
1005
1006        if let Some((code, message)) = component_error(&value) {
1007            bail!(
1008                "component {} failed: {}: {}",
1009                call.component_ref,
1010                code,
1011                message
1012            );
1013        }
1014        Ok(NodeOutput::new(value))
1015    }
1016
1017    async fn execute_provider_invoke(
1018        &self,
1019        ctx: &FlowContext<'_>,
1020        node_id: &str,
1021        state: &ExecutionState,
1022        payload: Value,
1023        event: &NodeEvent<'_>,
1024    ) -> Result<NodeOutput> {
1025        #[derive(Deserialize)]
1026        struct ProviderPayload {
1027            #[serde(default)]
1028            provider_id: Option<String>,
1029            #[serde(default)]
1030            provider_type: Option<String>,
1031            #[serde(default, alias = "operation")]
1032            op: Option<String>,
1033            #[serde(default)]
1034            input: Value,
1035            #[serde(default)]
1036            in_map: Value,
1037            #[serde(default)]
1038            out_map: Value,
1039            #[serde(default)]
1040            err_map: Value,
1041        }
1042
1043        let payload: ProviderPayload =
1044            serde_json::from_value(payload).context("invalid payload for provider.invoke")?;
1045        let op = payload
1046            .op
1047            .as_deref()
1048            .filter(|v| !v.trim().is_empty())
1049            .with_context(|| "provider.invoke requires an op")?
1050            .to_string();
1051
1052        let prev = state
1053            .last_output
1054            .as_ref()
1055            .cloned()
1056            .unwrap_or_else(|| Value::Object(JsonMap::new()));
1057        let base_ctx = template_context(state, prev);
1058
1059        let input_value = if !payload.in_map.is_null() {
1060            let mut ctx_value = base_ctx.clone();
1061            if let Value::Object(ref mut map) = ctx_value {
1062                map.insert("input".into(), payload.input.clone());
1063                map.insert("result".into(), payload.input.clone());
1064            }
1065            render_template_value(
1066                &payload.in_map,
1067                &ctx_value,
1068                TemplateOptions {
1069                    allow_pointer: true,
1070                },
1071            )
1072            .context("failed to render provider.invoke in_map")?
1073        } else if !payload.input.is_null() {
1074            payload.input
1075        } else {
1076            Value::Null
1077        };
1078        let input_json = serde_json::to_vec(&input_value)?;
1079
1080        self.validate_tool(
1081            ctx,
1082            event,
1083            payload.provider_id.as_deref(),
1084            payload.provider_type.as_deref(),
1085            &op,
1086            &input_value,
1087        )?;
1088
1089        let key = FlowKey {
1090            pack_id: ctx.pack_id.to_string(),
1091            flow_id: ctx.flow_id.to_string(),
1092        };
1093        let pack_idx = *self.flow_sources.get(&key).with_context(|| {
1094            format!("flow {} (pack {}) not registered", ctx.flow_id, ctx.pack_id)
1095        })?;
1096        let pack = Arc::clone(&self.packs[pack_idx]);
1097        let binding = pack.resolve_provider(
1098            payload.provider_id.as_deref(),
1099            payload.provider_type.as_deref(),
1100        );
1101
1102        // If pack-local resolution fails, try the cross-pack resolver (capability registry).
1103        if binding.is_err()
1104            && let Some(output) = self.try_invoke_cross_pack_resolver(
1105                payload.provider_id.as_deref(),
1106                payload.provider_type.as_deref(),
1107                &op,
1108                &input_json,
1109                ctx.tenant,
1110            )?
1111        {
1112            return Ok(output);
1113        }
1114
1115        let binding = binding?;
1116        let exec_ctx = component_exec_ctx(ctx, node_id);
1117        #[cfg(feature = "fault-injection")]
1118        {
1119            let fault_ctx = FaultContext {
1120                pack_id: ctx.pack_id,
1121                flow_id: ctx.flow_id,
1122                node_id: Some(node_id),
1123                attempt: ctx.attempt,
1124            };
1125            maybe_fail(FaultPoint::BeforeToolCall, fault_ctx)
1126                .map_err(|err| anyhow!(err.to_string()))?;
1127        }
1128        let result = pack
1129            .invoke_provider(&binding, exec_ctx, &op, input_json)
1130            .await?;
1131        #[cfg(feature = "fault-injection")]
1132        {
1133            let fault_ctx = FaultContext {
1134                pack_id: ctx.pack_id,
1135                flow_id: ctx.flow_id,
1136                node_id: Some(node_id),
1137                attempt: ctx.attempt,
1138            };
1139            maybe_fail(FaultPoint::AfterToolCall, fault_ctx)
1140                .map_err(|err| anyhow!(err.to_string()))?;
1141        }
1142
1143        let output = if payload.out_map.is_null() {
1144            result
1145        } else {
1146            let mut ctx_value = base_ctx;
1147            if let Value::Object(ref mut map) = ctx_value {
1148                map.insert("input".into(), result.clone());
1149                map.insert("result".into(), result.clone());
1150            }
1151            render_template_value(
1152                &payload.out_map,
1153                &ctx_value,
1154                TemplateOptions {
1155                    allow_pointer: true,
1156                },
1157            )
1158            .context("failed to render provider.invoke out_map")?
1159        };
1160        let _ = payload.err_map;
1161        Ok(NodeOutput::new(output))
1162    }
1163
1164    fn try_invoke_cross_pack_resolver(
1165        &self,
1166        provider_id: Option<&str>,
1167        provider_type: Option<&str>,
1168        op: &str,
1169        input_json: &[u8],
1170        tenant: &str,
1171    ) -> Result<Option<NodeOutput>> {
1172        eprintln!(
1173            "[DEBUG] provider.invoke: pack-local failed, has_resolver={}",
1174            self.cross_pack_resolver.is_some()
1175        );
1176        let Some(resolver) = self.cross_pack_resolver.as_ref() else {
1177            return Ok(None);
1178        };
1179        let provider_id = provider_id.unwrap_or("unknown");
1180        tracing::info!(
1181            provider_id,
1182            op = %op,
1183            "provider.invoke: pack-local resolution failed, trying cross-pack resolver"
1184        );
1185        let result_value =
1186            resolver.invoke(provider_id, provider_type, op, input_json, tenant, None)?;
1187        Ok(Some(NodeOutput::new(result_value)))
1188    }
1189
1190    fn validate_component(
1191        &self,
1192        ctx: &FlowContext<'_>,
1193        event: &NodeEvent<'_>,
1194        call: &ComponentCall,
1195    ) -> Result<()> {
1196        if self.validation.mode == ValidationMode::Off {
1197            return Ok(());
1198        }
1199        let mut metadata = JsonMap::new();
1200        metadata.insert("tenant_id".to_string(), json!(ctx.tenant));
1201        if let Some(id) = ctx.session_id {
1202            metadata.insert("session".to_string(), json!({ "id": id }));
1203        }
1204        let envelope = json!({
1205            "component_id": call.component_ref,
1206            "operation": call.operation,
1207            "input": call.input,
1208            "config": call.config,
1209            "metadata": Value::Object(metadata),
1210        });
1211        let issues = validate_component_envelope(&envelope);
1212        self.report_validation(ctx, event, "component", issues)
1213    }
1214
1215    fn validate_tool(
1216        &self,
1217        ctx: &FlowContext<'_>,
1218        event: &NodeEvent<'_>,
1219        provider_id: Option<&str>,
1220        provider_type: Option<&str>,
1221        operation: &str,
1222        input: &Value,
1223    ) -> Result<()> {
1224        if self.validation.mode == ValidationMode::Off {
1225            return Ok(());
1226        }
1227        let tool_id = provider_id.or(provider_type).unwrap_or("provider.invoke");
1228        let mut metadata = JsonMap::new();
1229        metadata.insert("tenant_id".to_string(), json!(ctx.tenant));
1230        if let Some(id) = ctx.session_id {
1231            metadata.insert("session".to_string(), json!({ "id": id }));
1232        }
1233        let envelope = json!({
1234            "tool_id": tool_id,
1235            "operation": operation,
1236            "input": input,
1237            "metadata": Value::Object(metadata),
1238        });
1239        let issues = validate_tool_envelope(&envelope);
1240        self.report_validation(ctx, event, "tool", issues)
1241    }
1242
1243    fn report_validation(
1244        &self,
1245        ctx: &FlowContext<'_>,
1246        event: &NodeEvent<'_>,
1247        kind: &str,
1248        issues: Vec<ValidationIssue>,
1249    ) -> Result<()> {
1250        if issues.is_empty() {
1251            return Ok(());
1252        }
1253        if let Some(observer) = ctx.observer {
1254            observer.on_validation(event, &issues);
1255        }
1256        match self.validation.mode {
1257            ValidationMode::Warn => {
1258                tracing::warn!(
1259                    tenant = ctx.tenant,
1260                    flow_id = ctx.flow_id,
1261                    node_id = event.node_id,
1262                    kind,
1263                    issues = ?issues,
1264                    "invocation envelope validation issues"
1265                );
1266                Ok(())
1267            }
1268            ValidationMode::Error => {
1269                tracing::error!(
1270                    tenant = ctx.tenant,
1271                    flow_id = ctx.flow_id,
1272                    node_id = event.node_id,
1273                    kind,
1274                    issues = ?issues,
1275                    "invocation envelope validation failed"
1276                );
1277                bail!("invocation_validation_failed");
1278            }
1279            ValidationMode::Off => Ok(()),
1280        }
1281    }
1282
1283    pub fn flows(&self) -> &[FlowDescriptor] {
1284        &self.flows
1285    }
1286
1287    pub fn flow_by_key(&self, pack_id: &str, flow_id: &str) -> Option<&FlowDescriptor> {
1288        self.flows
1289            .iter()
1290            .find(|descriptor| descriptor.pack_id == pack_id && descriptor.id == flow_id)
1291    }
1292
1293    pub fn flow_by_type(&self, flow_type: &str) -> Option<&FlowDescriptor> {
1294        let mut matches = self
1295            .flows
1296            .iter()
1297            .filter(|descriptor| descriptor.flow_type == flow_type);
1298        let first = matches.next()?;
1299        if matches.next().is_some() {
1300            return None;
1301        }
1302        Some(first)
1303    }
1304
1305    pub fn flow_by_id(&self, flow_id: &str) -> Option<&FlowDescriptor> {
1306        let mut matches = self
1307            .flows
1308            .iter()
1309            .filter(|descriptor| descriptor.id == flow_id);
1310        let first = matches.next()?;
1311        if matches.next().is_some() {
1312            return None;
1313        }
1314        Some(first)
1315    }
1316}
1317
1318pub trait ExecutionObserver: Send + Sync {
1319    fn on_node_start(&self, event: &NodeEvent<'_>);
1320    fn on_node_end(&self, event: &NodeEvent<'_>, output: &Value);
1321    fn on_node_error(&self, event: &NodeEvent<'_>, error: &dyn StdError);
1322    fn on_validation(&self, _event: &NodeEvent<'_>, _issues: &[ValidationIssue]) {}
1323}
1324
1325pub struct NodeEvent<'a> {
1326    pub context: &'a FlowContext<'a>,
1327    pub node_id: &'a str,
1328    pub node: &'a HostNode,
1329    pub payload: &'a Value,
1330}
1331
1332#[derive(Clone, Debug, Serialize, Deserialize)]
1333pub struct ExecutionState {
1334    #[serde(default)]
1335    entry: Value,
1336    #[serde(default)]
1337    input: Value,
1338    #[serde(default)]
1339    nodes: HashMap<String, NodeOutput>,
1340    #[serde(default)]
1341    egress: Vec<Value>,
1342    #[serde(default, skip_serializing_if = "Option::is_none")]
1343    last_output: Option<Value>,
1344    #[serde(default)]
1345    redirect_count: u32,
1346}
1347
1348impl ExecutionState {
1349    fn new(input: Value) -> Self {
1350        Self {
1351            entry: input.clone(),
1352            input,
1353            nodes: HashMap::new(),
1354            egress: Vec::new(),
1355            last_output: None,
1356            redirect_count: 0,
1357        }
1358    }
1359
1360    fn ensure_entry(&mut self) {
1361        if self.entry.is_null() {
1362            self.entry = self.input.clone();
1363        }
1364    }
1365
1366    fn context(&self) -> Value {
1367        let mut nodes = JsonMap::new();
1368        for (id, output) in &self.nodes {
1369            nodes.insert(
1370                id.clone(),
1371                json!({
1372                    "ok": output.ok,
1373                    "payload": output.payload.clone(),
1374                    "meta": output.meta.clone(),
1375                }),
1376            );
1377        }
1378        json!({
1379            "entry": self.entry.clone(),
1380            "input": self.input.clone(),
1381            "nodes": nodes,
1382            "redirect_count": self.redirect_count,
1383        })
1384    }
1385
1386    fn outputs_map(&self) -> JsonMap<String, Value> {
1387        let mut outputs = JsonMap::new();
1388        for (id, output) in &self.nodes {
1389            outputs.insert(id.clone(), output.payload.clone());
1390        }
1391        outputs
1392    }
1393    fn push_egress(&mut self, payload: Value) {
1394        self.egress.push(payload);
1395    }
1396
1397    fn replace_input(&mut self, input: Value) {
1398        self.input = input;
1399    }
1400
1401    fn clear_egress(&mut self) {
1402        self.egress.clear();
1403    }
1404
1405    fn redirect_count(&self) -> u32 {
1406        self.redirect_count
1407    }
1408
1409    fn increment_redirect_count(&mut self) {
1410        self.redirect_count = self.redirect_count.saturating_add(1);
1411    }
1412
1413    fn finalize_with(mut self, final_payload: Option<Value>) -> Value {
1414        if self.egress.is_empty() {
1415            return final_payload.unwrap_or(Value::Null);
1416        }
1417        let mut emitted = std::mem::take(&mut self.egress);
1418        if let Some(value) = final_payload {
1419            match value {
1420                Value::Null => {}
1421                Value::Array(items) => emitted.extend(items),
1422                other => emitted.push(other),
1423            }
1424        }
1425        Value::Array(emitted)
1426    }
1427}
1428
1429#[derive(Clone, Debug, Serialize, Deserialize)]
1430struct NodeOutput {
1431    ok: bool,
1432    payload: Value,
1433    meta: Value,
1434}
1435
1436impl NodeOutput {
1437    fn new(payload: Value) -> Self {
1438        Self {
1439            ok: true,
1440            payload,
1441            meta: Value::Null,
1442        }
1443    }
1444}
1445
1446struct DispatchOutcome {
1447    output: NodeOutput,
1448    control: NodeControl,
1449}
1450
1451impl DispatchOutcome {
1452    fn complete(output: NodeOutput) -> Self {
1453        Self {
1454            output,
1455            control: NodeControl::Continue,
1456        }
1457    }
1458
1459    fn wait(output: NodeOutput, reason: Option<String>) -> Self {
1460        Self {
1461            output,
1462            control: NodeControl::Wait { reason },
1463        }
1464    }
1465
1466    fn with_control(output: NodeOutput, control: NodeControl) -> Self {
1467        Self { output, control }
1468    }
1469}
1470
1471#[derive(Clone, Debug)]
1472enum NodeControl {
1473    Continue,
1474    Wait {
1475        reason: Option<String>,
1476    },
1477    Jump(JumpControl),
1478    Respond {
1479        text: Option<String>,
1480        card_cbor: Option<Vec<u8>>,
1481        needs_user: Option<bool>,
1482    },
1483}
1484
1485#[derive(Clone, Debug)]
1486struct JumpControl {
1487    flow: String,
1488    node: Option<String>,
1489    payload: Value,
1490    hints: Value,
1491    max_redirects: Option<u32>,
1492    reason: Option<String>,
1493}
1494
1495#[derive(Clone, Debug)]
1496struct JumpTarget {
1497    flow_id: String,
1498    flow: HostFlow,
1499    node_id: NodeId,
1500}
1501
1502impl NodeOutput {
1503    fn with_meta(payload: Value, meta: Value) -> Self {
1504        Self {
1505            ok: true,
1506            payload,
1507            meta,
1508        }
1509    }
1510}
1511
1512fn component_exec_ctx(ctx: &FlowContext<'_>, node_id: &str) -> ComponentExecCtx {
1513    ComponentExecCtx {
1514        tenant: ComponentTenantCtx {
1515            tenant: ctx.tenant.to_string(),
1516            team: None,
1517            user: ctx.provider_id.map(str::to_string),
1518            trace_id: None,
1519            i18n_id: None,
1520            correlation_id: ctx.session_id.map(str::to_string),
1521            deadline_unix_ms: None,
1522            attempt: ctx.attempt,
1523            idempotency_key: ctx.session_id.map(str::to_string),
1524        },
1525        i18n_id: None,
1526        flow_id: ctx.flow_id.to_string(),
1527        node_id: Some(node_id.to_string()),
1528    }
1529}
1530
1531fn component_error(value: &Value) -> Option<(String, String)> {
1532    let obj = value.as_object()?;
1533    let ok = obj.get("ok").and_then(Value::as_bool)?;
1534    if ok {
1535        return None;
1536    }
1537    let err = obj.get("error")?.as_object()?;
1538    let code = err
1539        .get("code")
1540        .and_then(Value::as_str)
1541        .unwrap_or("component_error");
1542    let message = err
1543        .get("message")
1544        .and_then(Value::as_str)
1545        .unwrap_or("component reported error");
1546    Some((code.to_string(), message.to_string()))
1547}
1548
1549fn extract_wait_reason(payload: &Value) -> Option<String> {
1550    match payload {
1551        Value::String(s) => Some(s.clone()),
1552        Value::Object(map) => map
1553            .get("reason")
1554            .and_then(Value::as_str)
1555            .map(|value| value.to_string()),
1556        _ => None,
1557    }
1558}
1559
1560fn component_dispatch_outcome(output: NodeOutput) -> Result<DispatchOutcome> {
1561    if let Some(control) = parse_component_control(&output.payload)? {
1562        return Ok(match control {
1563            NodeControl::Jump(jump) => {
1564                let adjusted = NodeOutput::with_meta(jump.payload.clone(), jump.hints.clone());
1565                DispatchOutcome::with_control(adjusted, NodeControl::Jump(jump))
1566            }
1567            NodeControl::Respond {
1568                text,
1569                card_cbor,
1570                needs_user,
1571            } => DispatchOutcome::with_control(
1572                output,
1573                NodeControl::Respond {
1574                    text,
1575                    card_cbor,
1576                    needs_user,
1577                },
1578            ),
1579            other => DispatchOutcome::with_control(output, other),
1580        });
1581    }
1582    Ok(DispatchOutcome::complete(output))
1583}
1584
1585fn parse_component_control(payload: &Value) -> Result<Option<NodeControl>> {
1586    let Value::Object(map) = payload else {
1587        return Ok(None);
1588    };
1589    let Some(control_value) = map.get("greentic_control") else {
1590        return Ok(None);
1591    };
1592    let control = control_value
1593        .as_object()
1594        .ok_or_else(|| anyhow!("jump_failed: greentic_control must be an object"))?;
1595    let action = control
1596        .get("action")
1597        .and_then(Value::as_str)
1598        .ok_or_else(|| anyhow!("jump_failed: greentic_control.action is required"))?;
1599    let version = control
1600        .get("v")
1601        .and_then(Value::as_u64)
1602        .ok_or_else(|| anyhow!("jump_failed: greentic_control.v is required"))?;
1603    if version != 1 {
1604        bail!("jump_failed: unsupported greentic_control.v={version}");
1605    }
1606
1607    match action {
1608        "jump" => {
1609            let flow = control
1610                .get("flow")
1611                .and_then(Value::as_str)
1612                .map(str::trim)
1613                .filter(|value| !value.is_empty())
1614                .ok_or_else(|| anyhow!("jump_failed: jump flow is required"))?
1615                .to_string();
1616            let node = control
1617                .get("node")
1618                .and_then(Value::as_str)
1619                .map(str::trim)
1620                .filter(|value| !value.is_empty())
1621                .map(str::to_string);
1622            let payload = control.get("payload").cloned().unwrap_or(Value::Null);
1623            let hints = control.get("hints").cloned().unwrap_or(Value::Null);
1624            let max_redirects = control
1625                .get("max_redirects")
1626                .and_then(Value::as_u64)
1627                .and_then(|value| u32::try_from(value).ok());
1628            let reason = control
1629                .get("reason")
1630                .and_then(Value::as_str)
1631                .map(str::to_string);
1632            Ok(Some(NodeControl::Jump(JumpControl {
1633                flow,
1634                node,
1635                payload,
1636                hints,
1637                max_redirects,
1638                reason,
1639            })))
1640        }
1641        "respond" => {
1642            let text = control
1643                .get("text")
1644                .and_then(Value::as_str)
1645                .map(str::to_string);
1646            let card_cbor = control
1647                .get("card_cbor")
1648                .and_then(Value::as_array)
1649                .map(|bytes| {
1650                    bytes
1651                        .iter()
1652                        .filter_map(Value::as_u64)
1653                        .filter_map(|value| u8::try_from(value).ok())
1654                        .collect::<Vec<_>>()
1655                });
1656            let needs_user = control.get("needs_user").and_then(Value::as_bool);
1657            Ok(Some(NodeControl::Respond {
1658                text,
1659                card_cbor,
1660                needs_user,
1661            }))
1662        }
1663        _ => Ok(None),
1664    }
1665}
1666
1667fn template_context(state: &ExecutionState, prev: Value) -> Value {
1668    let entry = if state.entry.is_null() {
1669        Value::Object(JsonMap::new())
1670    } else {
1671        state.entry.clone()
1672    };
1673    let mut ctx = JsonMap::new();
1674    ctx.insert("entry".into(), entry.clone());
1675    ctx.insert("in".into(), entry); // alias for entry - used in flow templates
1676    ctx.insert("prev".into(), prev);
1677    ctx.insert("node".into(), Value::Object(state.outputs_map()));
1678    ctx.insert("state".into(), state.context());
1679    Value::Object(ctx)
1680}
1681
1682impl From<Flow> for HostFlow {
1683    fn from(value: Flow) -> Self {
1684        let mut nodes = IndexMap::new();
1685        for (id, node) in value.nodes {
1686            nodes.insert(id.clone(), HostNode::from(node));
1687        }
1688        let start = value
1689            .entrypoints
1690            .get("default")
1691            .and_then(Value::as_str)
1692            .and_then(|id| NodeId::from_str(id).ok())
1693            .or_else(|| nodes.keys().next().cloned());
1694        Self {
1695            id: value.id.as_str().to_string(),
1696            start,
1697            nodes,
1698        }
1699    }
1700}
1701
1702impl From<Node> for HostNode {
1703    fn from(node: Node) -> Self {
1704        let full_ref = node.component.id.as_str().to_string();
1705        // When the pack compiler stores "component.operation" as a single ID
1706        // without a separate operation field, split on the last dot.
1707        let is_builtin = full_ref.starts_with("component.exec")
1708            || full_ref.starts_with("flow.")
1709            || full_ref.starts_with("emit.")
1710            || full_ref.starts_with("session.")
1711            || full_ref.starts_with("provider.");
1712        let (component_ref, raw_operation) = if node.component.operation.is_some() || is_builtin {
1713            (full_ref, node.component.operation.clone())
1714        } else if let Some(dot) = full_ref.rfind('.') {
1715            let comp = full_ref[..dot].to_string();
1716            let op = full_ref[dot + 1..].to_string();
1717            (comp, Some(op))
1718        } else {
1719            (full_ref, None)
1720        };
1721        let operation_in_mapping = extract_operation_from_mapping(&node.input.mapping);
1722        let operation_is_component_exec = raw_operation.as_deref() == Some("component.exec");
1723        let operation_is_emit = raw_operation
1724            .as_deref()
1725            .map(|op| op.starts_with("emit."))
1726            .unwrap_or(false);
1727        let is_component_exec = component_ref == "component.exec" || operation_is_component_exec;
1728
1729        let kind = if is_component_exec {
1730            let target = if component_ref == "component.exec" {
1731                if let Some(op) = raw_operation
1732                    .as_deref()
1733                    .filter(|op| op.starts_with("emit."))
1734                {
1735                    op.to_string()
1736                } else {
1737                    extract_target_component(&node.input.mapping)
1738                        .unwrap_or_else(|| "component.exec".to_string())
1739                }
1740            } else {
1741                extract_target_component(&node.input.mapping)
1742                    .unwrap_or_else(|| component_ref.clone())
1743            };
1744            if target.starts_with("emit.") {
1745                NodeKind::BuiltinEmit {
1746                    kind: emit_kind_from_ref(&target),
1747                }
1748            } else {
1749                NodeKind::Exec {
1750                    target_component: target,
1751                }
1752            }
1753        } else if operation_is_emit {
1754            NodeKind::BuiltinEmit {
1755                kind: emit_kind_from_ref(raw_operation.as_deref().unwrap_or("emit.log")),
1756            }
1757        } else {
1758            match component_ref.as_str() {
1759                "flow.call" => NodeKind::FlowCall,
1760                "provider.invoke" => NodeKind::ProviderInvoke,
1761                "session.wait" => NodeKind::Wait,
1762                "state.get" => NodeKind::BuiltinStateGet,
1763                "state.set" => NodeKind::BuiltinStateSet,
1764                comp if comp.starts_with("emit.") => NodeKind::BuiltinEmit {
1765                    kind: emit_kind_from_ref(comp),
1766                },
1767                other => NodeKind::PackComponent {
1768                    component_ref: other.to_string(),
1769                },
1770            }
1771        };
1772        let component_label = match &kind {
1773            NodeKind::Exec { .. } => "component.exec".to_string(),
1774            NodeKind::PackComponent { component_ref } => component_ref.clone(),
1775            NodeKind::ProviderInvoke => "provider.invoke".to_string(),
1776            NodeKind::FlowCall => "flow.call".to_string(),
1777            NodeKind::BuiltinEmit { kind } => emit_ref_from_kind(kind),
1778            NodeKind::BuiltinStateGet => "state.get".to_string(),
1779            NodeKind::BuiltinStateSet => "state.set".to_string(),
1780            NodeKind::Wait => "session.wait".to_string(),
1781        };
1782        let operation_name = if is_component_exec && operation_is_component_exec {
1783            None
1784        } else {
1785            raw_operation.clone()
1786        };
1787        let payload_expr = match kind {
1788            NodeKind::BuiltinEmit { .. } => extract_emit_payload(&node.input.mapping),
1789            _ => node.input.mapping.clone(),
1790        };
1791        Self {
1792            kind,
1793            component: component_label,
1794            component_id: if is_component_exec {
1795                "component.exec".to_string()
1796            } else {
1797                component_ref
1798            },
1799            operation_name,
1800            operation_in_mapping,
1801            payload_expr,
1802            routing: node.routing,
1803        }
1804    }
1805}
1806
1807fn extract_target_component(payload: &Value) -> Option<String> {
1808    match payload {
1809        Value::Object(map) => map
1810            .get("component")
1811            .or_else(|| map.get("component_ref"))
1812            .and_then(Value::as_str)
1813            .map(|s| s.to_string()),
1814        _ => None,
1815    }
1816}
1817
1818fn extract_operation_from_mapping(payload: &Value) -> Option<String> {
1819    match payload {
1820        Value::Object(map) => map
1821            .get("operation")
1822            .or_else(|| map.get("op"))
1823            .and_then(Value::as_str)
1824            .map(str::trim)
1825            .filter(|value| !value.is_empty())
1826            .map(|value| value.to_string()),
1827        _ => None,
1828    }
1829}
1830
1831fn extract_emit_payload(payload: &Value) -> Value {
1832    if let Value::Object(map) = payload {
1833        if let Some(input) = map.get("input") {
1834            return input.clone();
1835        }
1836        if let Some(inner) = map.get("payload") {
1837            return inner.clone();
1838        }
1839    }
1840    payload.clone()
1841}
1842
1843fn split_operation_payload(payload: Value) -> (Value, Value) {
1844    if let Value::Object(mut map) = payload.clone()
1845        && map.contains_key("input")
1846    {
1847        let input = map.remove("input").unwrap_or(Value::Null);
1848        let config = map.remove("config").unwrap_or(Value::Null);
1849        let legacy_only = map.keys().all(|key| {
1850            matches!(
1851                key.as_str(),
1852                "operation" | "op" | "component" | "component_ref"
1853            )
1854        });
1855        if legacy_only {
1856            return (input, config);
1857        }
1858    }
1859    (payload, Value::Null)
1860}
1861
1862fn resolve_component_operation(
1863    node_id: &str,
1864    component_label: &str,
1865    payload_operation: Option<String>,
1866    operation_override: Option<&str>,
1867    operation_in_mapping: Option<&str>,
1868) -> Result<String> {
1869    if let Some(op) = operation_override
1870        .map(str::trim)
1871        .filter(|value| !value.is_empty())
1872    {
1873        return Ok(op.to_string());
1874    }
1875
1876    if let Some(op) = payload_operation
1877        .as_deref()
1878        .map(str::trim)
1879        .filter(|value| !value.is_empty())
1880    {
1881        return Ok(op.to_string());
1882    }
1883
1884    let mut message = format!(
1885        "missing operation for node `{}` (component `{}`); expected node.component.operation to be set",
1886        node_id, component_label,
1887    );
1888    if let Some(found) = operation_in_mapping {
1889        message.push_str(&format!(
1890            ". Found operation in input.mapping (`{}`) but this is not used; pack compiler must preserve node.component.operation.",
1891            found
1892        ));
1893    }
1894    bail!(message);
1895}
1896
1897fn emit_kind_from_ref(component_ref: &str) -> EmitKind {
1898    match component_ref {
1899        "emit.log" => EmitKind::Log,
1900        "emit.response" => EmitKind::Response,
1901        other => EmitKind::Other(other.to_string()),
1902    }
1903}
1904
1905fn emit_ref_from_kind(kind: &EmitKind) -> String {
1906    match kind {
1907        EmitKind::Log => "emit.log".to_string(),
1908        EmitKind::Response => "emit.response".to_string(),
1909        EmitKind::Other(other) => other.clone(),
1910    }
1911}
1912
1913/// Returns `true` when `input` looks like an Adaptive Card invocation
1914/// (contains `card_source` or `card_spec` at the top level).
1915fn is_card_invocation(input: &Value) -> bool {
1916    if let Value::Object(map) = input {
1917        return map.contains_key("card_source") || map.contains_key("card_spec");
1918    }
1919    false
1920}
1921
1922/// When the node config declares adaptive-card defaults (`default_card_asset`,
1923/// `default_card_inline`, or `default_source`) but the runtime invocation has
1924/// no `card_source`/`card_spec` yet, lift those defaults into the invocation.
1925/// This produces a schema-valid invocation envelope so the component does not
1926/// fall back to its generic "Welcome" placeholder.
1927///
1928/// Adaptive-card defaults can arrive in either of two places depending on how
1929/// the pack was compiled:
1930/// - top-level `call.config` (post `split_operation_payload`)
1931/// - nested `call.input.config` (when the node mapping kept the
1932///   `{component, config}` shape and `split_operation_payload` left it intact)
1933fn promote_card_config_to_invocation(input: &mut Value, config: &Value) {
1934    if is_card_invocation(input) {
1935        return;
1936    }
1937
1938    let cfg_map = card_defaults_source(input, config);
1939    let Some(cfg) = cfg_map else { return };
1940
1941    let default_asset = cfg
1942        .get("default_card_asset")
1943        .and_then(Value::as_str)
1944        .map(str::trim)
1945        .filter(|value| !value.is_empty())
1946        .map(str::to_string);
1947    let default_inline = cfg
1948        .get("default_card_inline")
1949        .filter(|value| value.is_object() || value.is_array())
1950        .cloned();
1951    let default_source = cfg
1952        .get("default_source")
1953        .and_then(Value::as_str)
1954        .map(str::trim)
1955        .filter(|value| !value.is_empty())
1956        .map(str::to_lowercase);
1957
1958    if default_asset.is_none() && default_inline.is_none() && default_source.is_none() {
1959        return;
1960    }
1961
1962    let card_source = default_source.unwrap_or_else(|| {
1963        if default_inline.is_some() {
1964            "inline".to_string()
1965        } else {
1966            "asset".to_string()
1967        }
1968    });
1969
1970    let mut card_spec = serde_json::Map::new();
1971    match card_source.as_str() {
1972        "asset" => {
1973            if let Some(path) = default_asset {
1974                card_spec.insert("asset_path".into(), Value::String(path));
1975            }
1976        }
1977        "inline" => {
1978            if let Some(inline) = default_inline {
1979                card_spec.insert("inline_json".into(), inline);
1980            }
1981        }
1982        _ => {}
1983    }
1984
1985    if !matches!(input, Value::Object(_)) {
1986        *input = Value::Object(serde_json::Map::new());
1987    }
1988    if let Value::Object(map) = input {
1989        map.insert("card_source".into(), Value::String(card_source));
1990        map.insert("card_spec".into(), Value::Object(card_spec));
1991    }
1992}
1993
1994/// Locate the adaptive-card defaults config object, preferring the top-level
1995/// `call.config` when present, then falling back to a nested `input.config`
1996/// (the shape produced when `split_operation_payload` leaves the mapping
1997/// intact).
1998fn card_defaults_source<'a>(
1999    input: &'a Value,
2000    config: &'a Value,
2001) -> Option<&'a serde_json::Map<String, Value>> {
2002    if let Value::Object(map) = config {
2003        return Some(map);
2004    }
2005    if let Value::Object(map) = input
2006        && let Some(Value::Object(nested)) = map.get("config")
2007    {
2008        return Some(nested);
2009    }
2010    None
2011}
2012
2013fn inject_card_locale(payload: &mut Value, entry: &Value) {
2014    if !is_card_invocation(payload) {
2015        return;
2016    }
2017    let Value::Object(map) = payload else { return };
2018    if map.contains_key("locale") {
2019        return;
2020    }
2021    let locale = entry
2022        .pointer("/input/metadata/locale")
2023        .or_else(|| entry.pointer("/metadata/locale"))
2024        .and_then(Value::as_str);
2025    if let Some(locale) = locale {
2026        map.insert("locale".into(), Value::String(locale.to_string()));
2027    }
2028}
2029
2030/// Pre-resolve `card_source: "asset"` entries by reading the referenced JSON
2031/// file from the pack's assets directory and converting to
2032/// `card_source: "inline"` with `inline_json` populated.
2033///
2034/// This handles both top-level card fields and the nested `call.payload`
2035/// structure emitted by cards2pack.
2036fn resolve_card_assets(input: &mut Value, pack: &crate::pack::PackRuntime) {
2037    resolve_card_spec_asset(input, pack);
2038
2039    // Also resolve inside `call.payload` (cards2pack duplicates the card
2040    // invocation there).
2041    if let Value::Object(map) = input
2042        && let Some(Value::Object(call)) = map.get_mut("call")
2043        && let Some(payload) = call.get_mut("payload")
2044    {
2045        resolve_card_spec_asset(payload, pack);
2046    }
2047}
2048
2049/// Resolve a single card_spec asset_path → inline_json.
2050fn resolve_card_spec_asset(value: &mut Value, pack: &crate::pack::PackRuntime) {
2051    let Value::Object(map) = value else { return };
2052
2053    let is_asset = map
2054        .get("card_source")
2055        .and_then(Value::as_str)
2056        .map(|s| s.eq_ignore_ascii_case("asset"))
2057        .unwrap_or(false);
2058    if !is_asset {
2059        return;
2060    }
2061
2062    let asset_path = map
2063        .get("card_spec")
2064        .and_then(|spec| spec.get("asset_path"))
2065        .and_then(Value::as_str)
2066        .map(str::to_string);
2067
2068    let Some(asset_path) = asset_path else { return };
2069
2070    match pack.read_asset(&asset_path) {
2071        Ok(bytes) => {
2072            let card_json: Value = match serde_json::from_slice(&bytes) {
2073                Ok(v) => v,
2074                Err(err) => {
2075                    tracing::warn!(
2076                        asset_path,
2077                        %err,
2078                        "failed to parse card asset as JSON; leaving as asset reference"
2079                    );
2080                    return;
2081                }
2082            };
2083            tracing::debug!(asset_path, "pre-resolved card asset to inline_json");
2084            map.insert("card_source".into(), Value::String("inline".into()));
2085            if let Some(Value::Object(spec)) = map.get_mut("card_spec") {
2086                spec.insert("inline_json".into(), card_json);
2087                spec.remove("asset_path");
2088            }
2089        }
2090        Err(err) => {
2091            tracing::warn!(
2092                asset_path,
2093                %err,
2094                "card asset not found in pack; leaving as asset reference"
2095            );
2096        }
2097    }
2098
2099    // Pre-resolve i18n bundle: the WASM component cannot read pack assets
2100    // directly (no host resolver registered), so inline the i18n JSON into
2101    // the invocation under `card_spec.i18n_inline`. Defense-in-depth: when
2102    // the card omits an explicit `i18n_bundle_path` we still try the
2103    // conventional `assets/i18n/` location so cards that rely on
2104    // auto-generated i18n keys (e.g. cards2pack output) keep working.
2105    let configured_bundle_path = map
2106        .get("card_spec")
2107        .and_then(|spec| spec.get("i18n_bundle_path"))
2108        .and_then(Value::as_str)
2109        .map(|s| s.trim().trim_end_matches('/').to_string())
2110        .filter(|s| !s.is_empty());
2111
2112    let bundle_path = configured_bundle_path
2113        .clone()
2114        .unwrap_or_else(|| "assets/i18n".to_string());
2115
2116    let i18n_entries = load_i18n_bundle_entries(&bundle_path, |path| pack.read_asset(path));
2117
2118    if !i18n_entries.is_empty() {
2119        let locale_keys: Vec<_> = i18n_entries.keys().cloned().collect();
2120        if let Some(Value::Object(spec)) = map.get_mut("card_spec") {
2121            spec.insert("i18n_inline".into(), Value::Object(i18n_entries));
2122            if configured_bundle_path.is_some() {
2123                tracing::info!(%bundle_path, ?locale_keys, "pre-resolved i18n bundle into card_spec.i18n_inline");
2124            } else {
2125                tracing::info!(%bundle_path, ?locale_keys, "auto-discovered i18n bundle and inlined into card_spec.i18n_inline");
2126            }
2127        }
2128    }
2129}
2130
2131fn load_i18n_bundle_entries<F>(bundle_path: &str, mut read_asset: F) -> JsonMap<String, Value>
2132where
2133    F: FnMut(&str) -> Result<Vec<u8>>,
2134{
2135    let mut i18n_entries = JsonMap::new();
2136
2137    if bundle_path.ends_with(".json") {
2138        if let Ok(bytes) = read_asset(bundle_path)
2139            && let Ok(Value::Object(entries)) = serde_json::from_slice::<Value>(&bytes)
2140        {
2141            i18n_entries.insert("en".to_string(), Value::Object(entries));
2142        }
2143        return i18n_entries;
2144    }
2145
2146    let manifest_path = format!("{bundle_path}/_manifest.json");
2147    let locale_codes: Vec<String> = read_asset(&manifest_path)
2148        .ok()
2149        .and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
2150        .and_then(|value| {
2151            let locales = value
2152                .get("locales")
2153                .and_then(Value::as_array)
2154                .cloned()
2155                .or_else(|| value.as_array().cloned());
2156            locales.map(|items| {
2157                items
2158                    .iter()
2159                    .filter_map(Value::as_str)
2160                    .map(String::from)
2161                    .collect()
2162            })
2163        })
2164        .unwrap_or_default();
2165
2166    tracing::info!(%bundle_path, ?locale_codes, "i18n manifest discovered locales");
2167
2168    for locale in &locale_codes {
2169        let candidate = format!("{bundle_path}/{locale}.json");
2170        if let Ok(bytes) = read_asset(&candidate)
2171            && let Ok(Value::Object(entries)) = serde_json::from_slice::<Value>(&bytes)
2172        {
2173            i18n_entries.insert(locale.clone(), Value::Object(entries));
2174        }
2175    }
2176    if !i18n_entries.contains_key("en") {
2177        let en_path = format!("{bundle_path}/en.json");
2178        if let Ok(bytes) = read_asset(&en_path)
2179            && let Ok(Value::Object(entries)) = serde_json::from_slice::<Value>(&bytes)
2180        {
2181            i18n_entries.insert("en".to_string(), Value::Object(entries));
2182        }
2183    }
2184
2185    i18n_entries
2186}
2187
2188/// Evaluate custom routing (conditional routes) against node output and input.
2189///
2190/// Parses `Routing::Custom(Value)` as an array of `{condition, to}` objects.
2191/// Conditions are simple equality expressions like `response.action == "about"`.
2192/// Falls back to the first route without a condition (default route).
2193///
2194/// The evaluation context includes:
2195/// - All fields from the node output payload (top-level)
2196/// - `entry` / `in` — the original flow entry (incoming message)
2197/// - `response` — synthesized from entry metadata for convenient condition checks
2198///   (e.g. `response.action` maps to `metadata.action` from the incoming envelope)
2199fn evaluate_custom_routing(
2200    raw: &Value,
2201    output: &NodeOutput,
2202    state: &ExecutionState,
2203    flow_ir: &HostFlow,
2204    node_id: &NodeId,
2205) -> (Option<NodeId>, bool) {
2206    let routes = match raw.as_array() {
2207        Some(arr) => arr,
2208        None => {
2209            tracing::warn!(
2210                flow_id = %flow_ir.id,
2211                node_id = %node_id,
2212                "custom routing is not an array; terminating"
2213            );
2214            return (None, true);
2215        }
2216    };
2217
2218    // Build a rich context for condition evaluation:
2219    // Start with output payload, then overlay entry and synthesised "response".
2220    let ctx = build_routing_context(output, state);
2221
2222    for route in routes {
2223        let condition = route.get("condition").and_then(|v| v.as_str());
2224        let to = route.get("to").and_then(|v| v.as_str());
2225
2226        if let Some(cond) = condition {
2227            if evaluate_simple_condition(cond, &ctx)
2228                && let Some(target) = to
2229                && let Ok(nid) = NodeId::new(target)
2230            {
2231                tracing::debug!(
2232                    flow_id = %flow_ir.id,
2233                    node_id = %node_id,
2234                    condition = cond,
2235                    target = target,
2236                    "conditional route matched"
2237                );
2238                return (Some(nid), false);
2239            }
2240        } else if let Some(target) = to
2241            && let Ok(nid) = NodeId::new(target)
2242        {
2243            tracing::debug!(
2244                flow_id = %flow_ir.id,
2245                node_id = %node_id,
2246                target = target,
2247                "default route taken"
2248            );
2249            return (Some(nid), false);
2250        }
2251    }
2252
2253    tracing::warn!(
2254        flow_id = %flow_ir.id,
2255        node_id = %node_id,
2256        "no conditional route matched; terminating"
2257    );
2258    (None, true)
2259}
2260
2261/// Evaluate a simple condition expression like `response.action == "about"`.
2262///
2263/// Supports dotted path lookups against a JSON value context.
2264/// Format: `<path> == "<value>"` or `<path> != "<value>"`
2265fn evaluate_simple_condition(condition: &str, ctx: &Value) -> bool {
2266    // Parse: `path == "value"` or `path != "value"`
2267    let (path, expected, negate) = if let Some(idx) = condition.find("==") {
2268        let path = condition[..idx].trim();
2269        let val = condition[idx + 2..].trim().trim_matches('"');
2270        (path, val, false)
2271    } else if let Some(idx) = condition.find("!=") {
2272        let path = condition[..idx].trim();
2273        let val = condition[idx + 2..].trim().trim_matches('"');
2274        (path, val, true)
2275    } else {
2276        return false;
2277    };
2278
2279    // Resolve dotted path against context (case-insensitive comparison)
2280    let actual = resolve_dotted_path(ctx, path);
2281    let matches = actual
2282        .as_deref()
2283        .is_some_and(|a| a.eq_ignore_ascii_case(expected));
2284    if negate { !matches } else { matches }
2285}
2286
2287/// Resolve a dotted path like `response.action` against a JSON value.
2288fn resolve_dotted_path(value: &Value, path: &str) -> Option<String> {
2289    let parts: Vec<&str> = path.split('.').collect();
2290    let mut current = value;
2291    for part in &parts {
2292        current = current.get(part)?;
2293    }
2294    match current {
2295        Value::String(s) => Some(s.clone()),
2296        Value::Bool(b) => Some(b.to_string()),
2297        Value::Number(n) => Some(n.to_string()),
2298        _ => Some(current.to_string()),
2299    }
2300}
2301
2302/// Build a context object for routing condition evaluation.
2303///
2304/// The context merges the node output with the flow entry so that conditions
2305/// can reference both component results and incoming message data.
2306///
2307/// Layout:
2308/// ```text
2309/// {
2310///   ...output.payload...,     // top-level fields from component output
2311///   "entry": <flow entry>,
2312///   "in":    <flow entry>,    // alias
2313///   "response": {             // synthesised from envelope metadata
2314///     <key>: <value>,         // e.g. "action": "about"
2315///     ...
2316///   }
2317/// }
2318/// ```
2319fn build_routing_context(output: &NodeOutput, state: &ExecutionState) -> Value {
2320    let mut ctx = match &output.payload {
2321        Value::Object(map) => map.clone(),
2322        _ => JsonMap::new(),
2323    };
2324
2325    let entry = &state.entry;
2326    ctx.insert("entry".into(), entry.clone());
2327    ctx.insert("in".into(), entry.clone());
2328
2329    // Synthesise "response" from the envelope metadata.
2330    // greentic-start demo path: entry.input.metadata.*
2331    // greentic-runner direct path: entry.metadata.*
2332    let metadata = entry
2333        .pointer("/input/metadata")
2334        .or_else(|| entry.pointer("/metadata"));
2335
2336    let mut response = JsonMap::new();
2337    if let Some(Value::Object(meta)) = metadata {
2338        for (k, v) in meta {
2339            // Flatten string values; stringify others
2340            match v {
2341                Value::String(s) => {
2342                    response.insert(k.clone(), Value::String(s.clone()));
2343                }
2344                other => {
2345                    response.insert(k.clone(), other.clone());
2346                }
2347            }
2348        }
2349    }
2350    // Also pull text from the envelope for convenience
2351    if let Some(text) = entry
2352        .pointer("/input/text")
2353        .or_else(|| entry.pointer("/text"))
2354        .filter(|t| !t.is_null())
2355    {
2356        response.insert("text".into(), text.clone());
2357    }
2358    ctx.insert("response".into(), Value::Object(response));
2359
2360    Value::Object(ctx)
2361}
2362
2363#[cfg(test)]
2364mod tests {
2365    use super::*;
2366    use crate::validate::{ValidationConfig, ValidationMode};
2367    use greentic_types::{
2368        Flow, FlowComponentRef, FlowId, FlowKind, InputMapping, Node, NodeId, OutputMapping,
2369        Routing, TelemetryHints,
2370    };
2371    use serde_json::json;
2372    use std::collections::{BTreeMap, HashMap as StdHashMap};
2373    use std::str::FromStr;
2374    use std::sync::Mutex;
2375    use tokio::runtime::Runtime;
2376
2377    fn minimal_engine() -> FlowEngine {
2378        FlowEngine {
2379            packs: Vec::new(),
2380            flows: Vec::new(),
2381            flow_sources: HashMap::new(),
2382            flow_cache: RwLock::new(HashMap::new()),
2383            default_env: "local".to_string(),
2384            validation: ValidationConfig {
2385                mode: ValidationMode::Off,
2386            },
2387            cross_pack_resolver: None,
2388        }
2389    }
2390
2391    #[test]
2392    fn templating_renders_with_partials_and_data() {
2393        let mut state = ExecutionState::new(json!({ "city": "London" }));
2394        state.nodes.insert(
2395            "forecast".to_string(),
2396            NodeOutput::new(json!({ "temp": "20C" })),
2397        );
2398
2399        // templating context includes node outputs for runner-side payload rendering.
2400        let ctx = state.context();
2401        assert_eq!(ctx["nodes"]["forecast"]["payload"]["temp"], json!("20C"));
2402    }
2403
2404    #[test]
2405    fn finalize_wraps_emitted_payloads() {
2406        let mut state = ExecutionState::new(json!({}));
2407        state.push_egress(json!({ "text": "first" }));
2408        state.push_egress(json!({ "text": "second" }));
2409        let result = state.finalize_with(Some(json!({ "text": "final" })));
2410        assert_eq!(
2411            result,
2412            json!([
2413                { "text": "first" },
2414                { "text": "second" },
2415                { "text": "final" }
2416            ])
2417        );
2418    }
2419
2420    #[test]
2421    fn finalize_flattens_final_array() {
2422        let mut state = ExecutionState::new(json!({}));
2423        state.push_egress(json!({ "text": "only" }));
2424        let result = state.finalize_with(Some(json!([
2425            { "text": "extra-1" },
2426            { "text": "extra-2" }
2427        ])));
2428        assert_eq!(
2429            result,
2430            json!([
2431                { "text": "only" },
2432                { "text": "extra-1" },
2433                { "text": "extra-2" }
2434            ])
2435        );
2436    }
2437
2438    #[test]
2439    fn inject_card_locale_uses_entry_metadata_without_overwriting_payload() {
2440        let mut payload = json!({
2441            "card_source": "inline",
2442            "card_spec": { "title": "Hello" }
2443        });
2444        inject_card_locale(
2445            &mut payload,
2446            &json!({"input": {"metadata": {"locale": "nl-NL"}}}),
2447        );
2448        assert_eq!(payload["locale"], json!("nl-NL"));
2449
2450        let mut existing = json!({
2451            "card_source": "inline",
2452            "card_spec": { "title": "Hello" },
2453            "locale": "en-GB"
2454        });
2455        inject_card_locale(&mut existing, &json!({"metadata": {"locale": "nl-NL"}}));
2456        assert_eq!(existing["locale"], json!("en-GB"));
2457    }
2458
2459    #[test]
2460    fn load_i18n_bundle_entries_reads_manifest_and_falls_back_to_en() {
2461        let assets = StdHashMap::from([
2462            (
2463                "cards/i18n/_manifest.json".to_string(),
2464                br#"{"locales":["de"]}"#.to_vec(),
2465            ),
2466            (
2467                "cards/i18n/de.json".to_string(),
2468                br#"{"title":"Hallo"}"#.to_vec(),
2469            ),
2470            (
2471                "cards/i18n/en.json".to_string(),
2472                br#"{"title":"Hello"}"#.to_vec(),
2473            ),
2474        ]);
2475
2476        let entries = load_i18n_bundle_entries("cards/i18n", |path| {
2477            assets
2478                .get(path)
2479                .cloned()
2480                .with_context(|| format!("missing asset {path}"))
2481        });
2482
2483        assert_eq!(entries["de"]["title"], json!("Hallo"));
2484        assert_eq!(entries["en"]["title"], json!("Hello"));
2485    }
2486
2487    #[test]
2488    fn load_i18n_bundle_entries_reads_single_file_bundle() {
2489        let entries = load_i18n_bundle_entries("cards/i18n.json", |path| {
2490            if path == "cards/i18n.json" {
2491                Ok(br#"{"title":"Hello"}"#.to_vec())
2492            } else {
2493                bail!("unexpected asset {path}");
2494            }
2495        });
2496
2497        assert_eq!(entries["en"]["title"], json!("Hello"));
2498    }
2499
2500    struct TestCrossPackResolver;
2501
2502    impl CrossPackResolver for TestCrossPackResolver {
2503        fn invoke(
2504            &self,
2505            provider_id: &str,
2506            provider_type: Option<&str>,
2507            op: &str,
2508            input: &[u8],
2509            tenant: &str,
2510            team: Option<&str>,
2511        ) -> Result<Value> {
2512            Ok(json!({
2513                "provider_id": provider_id,
2514                "provider_type": provider_type,
2515                "op": op,
2516                "tenant": tenant,
2517                "team": team,
2518                "input": serde_json::from_slice::<Value>(input)?,
2519            }))
2520        }
2521    }
2522
2523    #[test]
2524    fn cross_pack_resolver_returns_node_output_when_present() {
2525        let mut engine = minimal_engine();
2526        engine.set_cross_pack_resolver(Arc::new(TestCrossPackResolver));
2527
2528        let output = engine
2529            .try_invoke_cross_pack_resolver(
2530                Some("mail"),
2531                Some("messaging"),
2532                "send",
2533                br#"{"subject":"hello"}"#,
2534                "demo",
2535            )
2536            .expect("resolver invocation")
2537            .expect("resolver output");
2538
2539        assert_eq!(
2540            output.payload,
2541            json!({
2542                "provider_id": "mail",
2543                "provider_type": "messaging",
2544                "op": "send",
2545                "tenant": "demo",
2546                "team": null,
2547                "input": { "subject": "hello" },
2548            })
2549        );
2550    }
2551
2552    #[test]
2553    fn parse_component_control_ignores_plain_payload() {
2554        let payload = json!({
2555            "flow": "not-a-control-field",
2556            "node": "n1"
2557        });
2558        let control = parse_component_control(&payload).expect("parse control");
2559        assert!(control.is_none());
2560    }
2561
2562    #[test]
2563    fn parse_component_control_parses_jump_marker() {
2564        let payload = json!({
2565            "greentic_control": {
2566                "action": "jump",
2567                "v": 1,
2568                "flow": "flow.b",
2569                "node": "node-2",
2570                "payload": { "message": "hi" },
2571                "hints": { "k": "v" },
2572                "max_redirects": 2,
2573                "reason": "handoff"
2574            }
2575        });
2576        let control = parse_component_control(&payload)
2577            .expect("parse control")
2578            .expect("missing control");
2579        match control {
2580            NodeControl::Jump(jump) => {
2581                assert_eq!(jump.flow, "flow.b");
2582                assert_eq!(jump.node.as_deref(), Some("node-2"));
2583                assert_eq!(jump.payload, json!({ "message": "hi" }));
2584                assert_eq!(jump.hints, json!({ "k": "v" }));
2585                assert_eq!(jump.max_redirects, Some(2));
2586                assert_eq!(jump.reason.as_deref(), Some("handoff"));
2587            }
2588            other => panic!("expected jump control, got {other:?}"),
2589        }
2590    }
2591
2592    #[test]
2593    fn parse_component_control_rejects_invalid_marker() {
2594        let payload = json!({
2595            "greentic_control": "bad-shape"
2596        });
2597        let err = parse_component_control(&payload).expect_err("expected invalid marker error");
2598        assert!(err.to_string().contains("greentic_control"));
2599    }
2600
2601    #[test]
2602    fn missing_operation_reports_node_and_component() {
2603        let engine = minimal_engine();
2604        let rt = Runtime::new().unwrap();
2605        let retry_config = RetryConfig {
2606            max_attempts: 1,
2607            base_delay_ms: 1,
2608        };
2609        let ctx = FlowContext {
2610            tenant: "tenant",
2611            pack_id: "test-pack",
2612            flow_id: "flow",
2613            node_id: Some("missing-op"),
2614            tool: None,
2615            action: None,
2616            session_id: None,
2617            provider_id: None,
2618            retry_config,
2619            attempt: 1,
2620            observer: None,
2621            mocks: None,
2622        };
2623        let node = HostNode {
2624            kind: NodeKind::Exec {
2625                target_component: "qa.process".into(),
2626            },
2627            component: "component.exec".into(),
2628            component_id: "component.exec".into(),
2629            operation_name: None,
2630            operation_in_mapping: None,
2631            payload_expr: Value::Null,
2632            routing: Routing::End,
2633        };
2634        let _state = ExecutionState::new(Value::Null);
2635        let payload = json!({ "component": "qa.process" });
2636        let event = NodeEvent {
2637            context: &ctx,
2638            node_id: "missing-op",
2639            node: &node,
2640            payload: &payload,
2641        };
2642        let err = rt
2643            .block_on(engine.execute_component_exec(
2644                &ctx,
2645                "missing-op",
2646                &node,
2647                payload.clone(),
2648                &event,
2649                ComponentOverrides {
2650                    component: None,
2651                    operation: None,
2652                },
2653            ))
2654            .unwrap_err();
2655        let message = err.to_string();
2656        assert!(
2657            message.contains("missing operation for node `missing-op`"),
2658            "unexpected message: {message}"
2659        );
2660        assert!(
2661            message.contains("(component `component.exec`)"),
2662            "unexpected message: {message}"
2663        );
2664    }
2665
2666    #[test]
2667    fn missing_operation_mentions_mapping_hint() {
2668        let engine = minimal_engine();
2669        let rt = Runtime::new().unwrap();
2670        let retry_config = RetryConfig {
2671            max_attempts: 1,
2672            base_delay_ms: 1,
2673        };
2674        let ctx = FlowContext {
2675            tenant: "tenant",
2676            pack_id: "test-pack",
2677            flow_id: "flow",
2678            node_id: Some("missing-op-hint"),
2679            tool: None,
2680            action: None,
2681            session_id: None,
2682            provider_id: None,
2683            retry_config,
2684            attempt: 1,
2685            observer: None,
2686            mocks: None,
2687        };
2688        let node = HostNode {
2689            kind: NodeKind::Exec {
2690                target_component: "qa.process".into(),
2691            },
2692            component: "component.exec".into(),
2693            component_id: "component.exec".into(),
2694            operation_name: None,
2695            operation_in_mapping: Some("render".into()),
2696            payload_expr: Value::Null,
2697            routing: Routing::End,
2698        };
2699        let _state = ExecutionState::new(Value::Null);
2700        let payload = json!({ "component": "qa.process" });
2701        let event = NodeEvent {
2702            context: &ctx,
2703            node_id: "missing-op-hint",
2704            node: &node,
2705            payload: &payload,
2706        };
2707        let err = rt
2708            .block_on(engine.execute_component_exec(
2709                &ctx,
2710                "missing-op-hint",
2711                &node,
2712                payload.clone(),
2713                &event,
2714                ComponentOverrides {
2715                    component: None,
2716                    operation: None,
2717                },
2718            ))
2719            .unwrap_err();
2720        let message = err.to_string();
2721        assert!(
2722            message.contains("missing operation for node `missing-op-hint`"),
2723            "unexpected message: {message}"
2724        );
2725        assert!(
2726            message.contains("Found operation in input.mapping (`render`)"),
2727            "unexpected message: {message}"
2728        );
2729    }
2730
2731    struct CountingObserver {
2732        starts: Mutex<Vec<String>>,
2733        ends: Mutex<Vec<Value>>,
2734    }
2735
2736    impl CountingObserver {
2737        fn new() -> Self {
2738            Self {
2739                starts: Mutex::new(Vec::new()),
2740                ends: Mutex::new(Vec::new()),
2741            }
2742        }
2743    }
2744
2745    impl ExecutionObserver for CountingObserver {
2746        fn on_node_start(&self, event: &NodeEvent<'_>) {
2747            self.starts.lock().unwrap().push(event.node_id.to_string());
2748        }
2749
2750        fn on_node_end(&self, _event: &NodeEvent<'_>, output: &Value) {
2751            self.ends.lock().unwrap().push(output.clone());
2752        }
2753
2754        fn on_node_error(&self, _event: &NodeEvent<'_>, _error: &dyn StdError) {}
2755    }
2756
2757    #[test]
2758    fn emits_end_event_for_successful_node() {
2759        let node_id = NodeId::from_str("emit").unwrap();
2760        let node = Node {
2761            id: node_id.clone(),
2762            component: FlowComponentRef {
2763                id: "emit.log".parse().unwrap(),
2764                pack_alias: None,
2765                operation: None,
2766            },
2767            input: InputMapping {
2768                mapping: json!({ "message": "logged" }),
2769            },
2770            output: OutputMapping {
2771                mapping: Value::Null,
2772            },
2773            err_map: None,
2774            routing: Routing::End,
2775            telemetry: TelemetryHints::default(),
2776        };
2777        let mut nodes = indexmap::IndexMap::default();
2778        nodes.insert(node_id.clone(), node);
2779        let flow = Flow {
2780            schema_version: "1.0".into(),
2781            id: FlowId::from_str("emit.flow").unwrap(),
2782            kind: FlowKind::Messaging,
2783            entrypoints: BTreeMap::from([(
2784                "default".to_string(),
2785                Value::String(node_id.to_string()),
2786            )]),
2787            nodes,
2788            metadata: Default::default(),
2789        };
2790        let host_flow = HostFlow::from(flow);
2791
2792        let engine = FlowEngine {
2793            packs: Vec::new(),
2794            flows: Vec::new(),
2795            flow_sources: HashMap::new(),
2796            flow_cache: RwLock::new(HashMap::from([(
2797                FlowKey {
2798                    pack_id: "test-pack".to_string(),
2799                    flow_id: "emit.flow".to_string(),
2800                },
2801                host_flow,
2802            )])),
2803            default_env: "local".to_string(),
2804            validation: ValidationConfig {
2805                mode: ValidationMode::Off,
2806            },
2807            cross_pack_resolver: None,
2808        };
2809        let observer = CountingObserver::new();
2810        let ctx = FlowContext {
2811            tenant: "demo",
2812            pack_id: "test-pack",
2813            flow_id: "emit.flow",
2814            node_id: None,
2815            tool: None,
2816            action: None,
2817            session_id: None,
2818            provider_id: None,
2819            retry_config: RetryConfig {
2820                max_attempts: 1,
2821                base_delay_ms: 1,
2822            },
2823            attempt: 1,
2824            observer: Some(&observer),
2825            mocks: None,
2826        };
2827
2828        let rt = Runtime::new().unwrap();
2829        let result = rt.block_on(engine.execute(ctx, Value::Null)).unwrap();
2830        assert!(matches!(result.status, FlowStatus::Completed));
2831
2832        let starts = observer.starts.lock().unwrap();
2833        let ends = observer.ends.lock().unwrap();
2834        assert_eq!(starts.len(), 1);
2835        assert_eq!(ends.len(), 1);
2836        assert_eq!(ends[0], json!({ "message": "logged" }));
2837    }
2838
2839    fn host_flow_for_test(
2840        flow_id: &str,
2841        node_ids: &[&str],
2842        default_start: Option<&str>,
2843    ) -> HostFlow {
2844        let mut nodes = indexmap::IndexMap::default();
2845        for node_id in node_ids {
2846            let id = NodeId::from_str(node_id).unwrap();
2847            let node = Node {
2848                id: id.clone(),
2849                component: FlowComponentRef {
2850                    id: "emit.log".parse().unwrap(),
2851                    pack_alias: None,
2852                    operation: None,
2853                },
2854                input: InputMapping {
2855                    mapping: json!({ "message": node_id }),
2856                },
2857                output: OutputMapping {
2858                    mapping: Value::Null,
2859                },
2860                err_map: None,
2861                routing: Routing::End,
2862                telemetry: TelemetryHints::default(),
2863            };
2864            nodes.insert(id, node);
2865        }
2866        let mut entrypoints = BTreeMap::new();
2867        if let Some(start) = default_start {
2868            entrypoints.insert("default".to_string(), Value::String(start.to_string()));
2869        }
2870        HostFlow::from(Flow {
2871            schema_version: "1.0".into(),
2872            id: FlowId::from_str(flow_id).unwrap(),
2873            kind: FlowKind::Messaging,
2874            entrypoints,
2875            nodes,
2876            metadata: Default::default(),
2877        })
2878    }
2879
2880    fn jump_test_engine() -> FlowEngine {
2881        let target_flow = host_flow_for_test("flow.target", &["node-a", "node-b"], None);
2882        FlowEngine {
2883            packs: Vec::new(),
2884            flows: Vec::new(),
2885            flow_sources: HashMap::new(),
2886            flow_cache: RwLock::new(HashMap::from([(
2887                FlowKey {
2888                    pack_id: "test-pack".to_string(),
2889                    flow_id: "flow.target".to_string(),
2890                },
2891                target_flow,
2892            )])),
2893            default_env: "local".to_string(),
2894            validation: ValidationConfig {
2895                mode: ValidationMode::Off,
2896            },
2897            cross_pack_resolver: None,
2898        }
2899    }
2900
2901    fn jump_ctx<'a>(flow_id: &'a str) -> FlowContext<'a> {
2902        FlowContext {
2903            tenant: "demo",
2904            pack_id: "test-pack",
2905            flow_id,
2906            node_id: None,
2907            tool: None,
2908            action: None,
2909            session_id: None,
2910            provider_id: None,
2911            retry_config: RetryConfig {
2912                max_attempts: 1,
2913                base_delay_ms: 1,
2914            },
2915            attempt: 1,
2916            observer: None,
2917            mocks: None,
2918        }
2919    }
2920
2921    #[test]
2922    fn apply_jump_unknown_flow_errors() {
2923        let engine = minimal_engine();
2924        let mut state = ExecutionState::new(Value::Null);
2925        let rt = Runtime::new().unwrap();
2926        let err = rt
2927            .block_on(engine.apply_jump(
2928                &jump_ctx("flow.source"),
2929                &mut state,
2930                JumpControl {
2931                    flow: "flow.missing".into(),
2932                    node: None,
2933                    payload: json!({ "ok": true }),
2934                    hints: Value::Null,
2935                    max_redirects: None,
2936                    reason: None,
2937                },
2938            ))
2939            .unwrap_err();
2940        assert!(
2941            err.to_string().contains("unknown_flow"),
2942            "unexpected error: {err}"
2943        );
2944    }
2945
2946    #[test]
2947    fn apply_jump_unknown_node_errors() {
2948        let engine = jump_test_engine();
2949        let mut state = ExecutionState::new(Value::Null);
2950        let rt = Runtime::new().unwrap();
2951        let err = rt
2952            .block_on(engine.apply_jump(
2953                &jump_ctx("flow.source"),
2954                &mut state,
2955                JumpControl {
2956                    flow: "flow.target".into(),
2957                    node: Some("node-missing".into()),
2958                    payload: json!({ "ok": true }),
2959                    hints: Value::Null,
2960                    max_redirects: None,
2961                    reason: None,
2962                },
2963            ))
2964            .unwrap_err();
2965        assert!(
2966            err.to_string().contains("unknown_node"),
2967            "unexpected error: {err}"
2968        );
2969    }
2970
2971    #[test]
2972    fn apply_jump_uses_default_start_fallback() {
2973        let engine = jump_test_engine();
2974        let mut state = ExecutionState::new(Value::Null);
2975        let rt = Runtime::new().unwrap();
2976        let target = rt
2977            .block_on(engine.apply_jump(
2978                &jump_ctx("flow.source"),
2979                &mut state,
2980                JumpControl {
2981                    flow: "flow.target".into(),
2982                    node: None,
2983                    payload: json!({ "k": "v" }),
2984                    hints: Value::Null,
2985                    max_redirects: None,
2986                    reason: None,
2987                },
2988            ))
2989            .expect("jump target");
2990        assert_eq!(target.flow_id, "flow.target");
2991        assert_eq!(target.node_id.as_str(), "node-a");
2992    }
2993
2994    #[test]
2995    fn apply_jump_redirect_limit_enforced() {
2996        let engine = jump_test_engine();
2997        let mut state = ExecutionState::new(Value::Null);
2998        state.redirect_count = 3;
2999        let rt = Runtime::new().unwrap();
3000        let err = rt
3001            .block_on(engine.apply_jump(
3002                &jump_ctx("flow.source"),
3003                &mut state,
3004                JumpControl {
3005                    flow: "flow.target".into(),
3006                    node: None,
3007                    payload: json!({ "k": "v" }),
3008                    hints: Value::Null,
3009                    max_redirects: Some(3),
3010                    reason: None,
3011                },
3012            ))
3013            .unwrap_err();
3014        assert_eq!(err.to_string(), "redirect_limit");
3015    }
3016}
3017
3018use tracing::Instrument;
3019
3020pub struct FlowContext<'a> {
3021    pub tenant: &'a str,
3022    pub pack_id: &'a str,
3023    pub flow_id: &'a str,
3024    pub node_id: Option<&'a str>,
3025    pub tool: Option<&'a str>,
3026    pub action: Option<&'a str>,
3027    pub session_id: Option<&'a str>,
3028    pub provider_id: Option<&'a str>,
3029    pub retry_config: RetryConfig,
3030    pub attempt: u32,
3031    pub observer: Option<&'a dyn ExecutionObserver>,
3032    pub mocks: Option<&'a MockLayer>,
3033}
3034
3035#[derive(Copy, Clone)]
3036pub struct RetryConfig {
3037    pub max_attempts: u32,
3038    pub base_delay_ms: u64,
3039}
3040
3041fn should_retry(err: &anyhow::Error) -> bool {
3042    let lower = err.to_string().to_lowercase();
3043    lower.contains("transient")
3044        || lower.contains("unavailable")
3045        || lower.contains("internal")
3046        || lower.contains("timeout")
3047}
3048
3049impl From<FlowRetryConfig> for RetryConfig {
3050    fn from(value: FlowRetryConfig) -> Self {
3051        Self {
3052            max_attempts: value.max_attempts.max(1),
3053            base_delay_ms: value.base_delay_ms.max(50),
3054        }
3055    }
3056}