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        let metric_tenant = ctx.tenant.to_string();
350        let metric_flow_id = ctx.flow_id.to_string();
351        let started = std::time::Instant::now();
352        let result = async move {
353            let mut attempt = 0u32;
354            loop {
355                attempt += 1;
356                ctx.attempt = attempt;
357                #[cfg(feature = "fault-injection")]
358                {
359                    let fault_ctx = FaultContext {
360                        pack_id: ctx.pack_id,
361                        flow_id: ctx.flow_id,
362                        node_id: ctx.node_id,
363                        attempt: ctx.attempt,
364                    };
365                    maybe_fail(FaultPoint::Timeout, fault_ctx)
366                        .map_err(|err| anyhow!(err.to_string()))?;
367                }
368                match self.execute_once(&ctx, original_input.clone()).await {
369                    Ok(value) => return Ok(value),
370                    Err(err) => {
371                        if attempt >= retry_config.max_attempts || !should_retry(&err) {
372                            // User-facing session flows surface the terminal
373                            // error as a metadata-only Ok envelope so the
374                            // messaging provider renders it instead of leaking
375                            // raw engine text to the chat.
376                            if ctx.session_id.is_some() {
377                                return Ok(FlowExecution::completed(json!({
378                                    "metadata": {
379                                        "error_kind": "flow_execution_failed",
380                                        "error_message": err.to_string(),
381                                        "flow_id": ctx.flow_id,
382                                    }
383                                })));
384                            }
385                            return Err(err);
386                        }
387                        let delay = backoff_delay_ms(retry_config.base_delay_ms, attempt - 1);
388                        tracing::warn!(
389                            tenant = ctx.tenant,
390                            flow_id = ctx.flow_id,
391                            attempt,
392                            max_attempts = retry_config.max_attempts,
393                            delay_ms = delay,
394                            error = %err,
395                            "transient flow execution failure, backing off"
396                        );
397                        tokio::time::sleep(Duration::from_millis(delay)).await;
398                    }
399                }
400            }
401        }
402        .instrument(span)
403        .await;
404        let status = if result.is_ok() { "ok" } else { "err" };
405        let duration_ms = started.elapsed().as_secs_f64() * 1000.0;
406        crate::metrics::record_flow_execution(&metric_tenant, &metric_flow_id, status, duration_ms);
407        result
408    }
409
410    pub async fn resume(
411        &self,
412        ctx: FlowContext<'_>,
413        snapshot: FlowSnapshot,
414        input: Value,
415    ) -> Result<FlowExecution> {
416        if snapshot.pack_id != ctx.pack_id {
417            bail!(
418                "snapshot pack {} does not match requested {}",
419                snapshot.pack_id,
420                ctx.pack_id
421            );
422        }
423        let resume_flow = snapshot
424            .next_flow
425            .clone()
426            .unwrap_or_else(|| snapshot.flow_id.clone());
427        let flow_ir = self.get_or_load_flow(ctx.pack_id, &resume_flow).await?;
428        let mut state = snapshot.state;
429        // Replace BOTH `input` AND `entry` with the new activity. The
430        // routing context (built by `build_routing_context`) reads
431        // `entry.input.metadata.*` for the synthesised `response.*` fields
432        // that conditional routes test against — keeping the snapshot's
433        // stale entry would make `response.action` perpetually empty and
434        // every condition fail, looping the user back to the wait point
435        // forever. `replace_input` only touches `state.input`, so we have
436        // to refresh `entry` ourselves; `ensure_entry` is a no-op once
437        // entry is non-null.
438        state.replace_input(input.clone());
439        state.entry = input;
440        self.drive_flow(&ctx, flow_ir, state, Some(snapshot.next_node), resume_flow)
441            .await
442    }
443
444    async fn execute_once(&self, ctx: &FlowContext<'_>, input: Value) -> Result<FlowExecution> {
445        let flow_ir = self.get_or_load_flow(ctx.pack_id, ctx.flow_id).await?;
446        let state = ExecutionState::new(input);
447        self.drive_flow(ctx, flow_ir, state, None, ctx.flow_id.to_string())
448            .await
449    }
450
451    async fn drive_flow(
452        &self,
453        ctx: &FlowContext<'_>,
454        mut flow_ir: HostFlow,
455        mut state: ExecutionState,
456        resume_from: Option<String>,
457        mut current_flow_id: String,
458    ) -> Result<FlowExecution> {
459        let mut current = match resume_from {
460            Some(node) => NodeId::from_str(&node)
461                .with_context(|| format!("invalid resume node id `{node}`"))?,
462            None => flow_ir
463                .start
464                .clone()
465                .or_else(|| flow_ir.nodes.keys().next().cloned())
466                .with_context(|| format!("flow {} has no start node", flow_ir.id))?,
467        };
468
469        loop {
470            let step_ctx = FlowContext {
471                tenant: ctx.tenant,
472                pack_id: ctx.pack_id,
473                flow_id: current_flow_id.as_str(),
474                node_id: ctx.node_id,
475                tool: ctx.tool,
476                action: ctx.action,
477                session_id: ctx.session_id,
478                provider_id: ctx.provider_id,
479                retry_config: ctx.retry_config,
480                attempt: ctx.attempt,
481                observer: ctx.observer,
482                mocks: ctx.mocks,
483            };
484            let node = flow_ir
485                .nodes
486                .get(&current)
487                .with_context(|| format!("node {} not found", current.as_str()))?;
488
489            let payload_template = node.payload_expr.clone();
490            let prev = state
491                .last_output
492                .as_ref()
493                .cloned()
494                .unwrap_or_else(|| Value::Object(JsonMap::new()));
495            let ctx_value = template_context(&state, prev);
496            #[cfg(feature = "fault-injection")]
497            {
498                let fault_ctx = FaultContext {
499                    pack_id: ctx.pack_id,
500                    flow_id: ctx.flow_id,
501                    node_id: Some(current.as_str()),
502                    attempt: ctx.attempt,
503                };
504                maybe_fail(FaultPoint::TemplateRender, fault_ctx)
505                    .map_err(|err| anyhow!(err.to_string()))?;
506            }
507            let payload =
508                render_template_value(&payload_template, &ctx_value, TemplateOptions::default())
509                    .context("failed to render node input template")?;
510            let observed_payload = payload.clone();
511            let node_id = current.clone();
512            let event = NodeEvent {
513                context: &step_ctx,
514                node_id: node_id.as_str(),
515                node,
516                payload: &observed_payload,
517            };
518            if let Some(observer) = step_ctx.observer {
519                observer.on_node_start(&event);
520            }
521            let dispatch = self
522                .dispatch_node(
523                    &step_ctx,
524                    node_id.as_str(),
525                    node,
526                    &mut state,
527                    payload,
528                    &event,
529                )
530                .await;
531            let DispatchOutcome { output, control } = match dispatch {
532                Ok(outcome) => outcome,
533                Err(err) => {
534                    if let Some(observer) = step_ctx.observer {
535                        observer.on_node_error(&event, err.as_ref());
536                    }
537                    // Propagate so `execute()`'s retry loop can retry transient
538                    // failures, then convert to a metadata-only Ok envelope at
539                    // the top level once retries are exhausted (session flows).
540                    return Err(err);
541                }
542            };
543
544            state.nodes.insert(node_id.clone().into(), output.clone());
545            state.last_output = Some(output.payload.clone());
546            if let Some(observer) = step_ctx.observer {
547                observer.on_node_end(&event, &output.payload);
548            }
549
550            match control {
551                NodeControl::Continue => {
552                    enum NextDecision {
553                        Next(NodeId),
554                        End,
555                        Wait,
556                    }
557                    let decision = match &node.routing {
558                        Routing::Next { node_id } => NextDecision::Next(node_id.clone()),
559                        Routing::End | Routing::Reply => NextDecision::End,
560                        Routing::Branch { default, .. } => match default {
561                            Some(target) => NextDecision::Next(target.clone()),
562                            None => NextDecision::End,
563                        },
564                        Routing::Custom(raw) => {
565                            match evaluate_custom_routing(raw, &output, &state, &flow_ir, &node_id)
566                            {
567                                CustomRoutingDecision::Next(nid) => NextDecision::Next(nid),
568                                CustomRoutingDecision::End => NextDecision::End,
569                                CustomRoutingDecision::Wait => NextDecision::Wait,
570                            }
571                        }
572                    };
573
574                    match decision {
575                        NextDecision::Next(n) => current = n,
576                        NextDecision::End => {
577                            let nodes_snapshot = state.nodes.clone();
578                            let final_output = state.finalize_with(Some(output.payload.clone()));
579                            return Ok(FlowExecution::completed(lift_first_node_error_from_nodes(
580                                final_output,
581                                &nodes_snapshot,
582                            )));
583                        }
584                        NextDecision::Wait => {
585                            // Conditional routing fell through. Pause at the
586                            // current node so the next inbound activity
587                            // resumes here and re-evaluates this node's
588                            // routing with the user's new submit payload.
589                            let mut snapshot_state = state.clone();
590                            snapshot_state.clear_egress();
591                            let snapshot = FlowSnapshot {
592                                pack_id: step_ctx.pack_id.to_string(),
593                                flow_id: step_ctx.flow_id.to_string(),
594                                next_flow: (current_flow_id != step_ctx.flow_id)
595                                    .then_some(current_flow_id.clone()),
596                                next_node: node_id.as_str().to_string(),
597                                state: snapshot_state,
598                            };
599                            let output_value = state.finalize_with(Some(output.payload.clone()));
600                            return Ok(FlowExecution::waiting(
601                                output_value,
602                                FlowWait {
603                                    reason: Some(format!(
604                                        "awaiting user submit at node `{}`",
605                                        node_id.as_str()
606                                    )),
607                                    snapshot,
608                                },
609                            ));
610                        }
611                    }
612                }
613                NodeControl::Wait { reason } => {
614                    let next: Option<NodeId> = match &node.routing {
615                        Routing::Next { node_id } => Some(node_id.clone()),
616                        Routing::End | Routing::Reply => None,
617                        Routing::Branch { default, .. } => default.clone(),
618                        Routing::Custom(raw) => {
619                            match evaluate_custom_routing(raw, &output, &state, &flow_ir, &node_id)
620                            {
621                                CustomRoutingDecision::Next(nid) => Some(nid),
622                                // session.wait operator must have an
623                                // explicit forward target — both End and
624                                // Wait decisions collapse to "no next" and
625                                // surface the same error below.
626                                CustomRoutingDecision::End | CustomRoutingDecision::Wait => None,
627                            }
628                        }
629                    };
630                    let resume_target = next.ok_or_else(|| {
631                        anyhow!(
632                            "session.wait node {} requires a non-empty route",
633                            current.as_str()
634                        )
635                    })?;
636                    let mut snapshot_state = state.clone();
637                    snapshot_state.clear_egress();
638                    let snapshot = FlowSnapshot {
639                        pack_id: step_ctx.pack_id.to_string(),
640                        flow_id: step_ctx.flow_id.to_string(),
641                        next_flow: (current_flow_id != step_ctx.flow_id)
642                            .then_some(current_flow_id.clone()),
643                        next_node: resume_target.as_str().to_string(),
644                        state: snapshot_state,
645                    };
646                    let output_value = state.clone().finalize_with(None);
647                    return Ok(FlowExecution::waiting(
648                        output_value,
649                        FlowWait { reason, snapshot },
650                    ));
651                }
652                NodeControl::Jump(jump) => {
653                    let jump_target = self.apply_jump(&step_ctx, &mut state, jump).await?;
654                    flow_ir = jump_target.flow;
655                    current_flow_id = jump_target.flow_id;
656                    current = jump_target.node_id;
657                }
658                NodeControl::Respond {
659                    text,
660                    card_cbor,
661                    needs_user,
662                } => {
663                    let response = json!({
664                        "text": text,
665                        "card_cbor": card_cbor,
666                        "needs_user": needs_user,
667                    });
668                    state.push_egress(response);
669                    let nodes_snapshot = state.nodes.clone();
670                    let final_output = state.finalize_with(None);
671                    return Ok(FlowExecution::completed(lift_first_node_error_from_nodes(
672                        final_output,
673                        &nodes_snapshot,
674                    )));
675                }
676            }
677        }
678    }
679
680    async fn dispatch_node(
681        &self,
682        ctx: &FlowContext<'_>,
683        node_id: &str,
684        node: &HostNode,
685        state: &mut ExecutionState,
686        mut payload: Value,
687        event: &NodeEvent<'_>,
688    ) -> Result<DispatchOutcome> {
689        inject_card_locale(&mut payload, &state.entry);
690        match &node.kind {
691            NodeKind::Exec { target_component } => self
692                .execute_component_exec(
693                    ctx,
694                    node_id,
695                    node,
696                    payload,
697                    event,
698                    ComponentOverrides {
699                        component: Some(target_component.as_str()),
700                        operation: node.operation_name.as_deref(),
701                    },
702                )
703                .await
704                .and_then(component_dispatch_outcome),
705            NodeKind::PackComponent { component_ref } => self
706                .execute_component_call(ctx, node_id, node, payload, component_ref.as_str(), event)
707                .await
708                .and_then(component_dispatch_outcome),
709            NodeKind::FlowCall => self
710                .execute_flow_call(ctx, payload)
711                .await
712                .map(DispatchOutcome::complete),
713            NodeKind::ProviderInvoke => self
714                .execute_provider_invoke(ctx, node_id, state, payload, event)
715                .await
716                .map(DispatchOutcome::complete),
717            NodeKind::BuiltinEmit { kind } => {
718                match kind {
719                    EmitKind::Log | EmitKind::Response => {}
720                    EmitKind::Other(component) => {
721                        tracing::debug!(%component, "handling emit.* as builtin");
722                    }
723                }
724                state.push_egress(payload.clone());
725                Ok(DispatchOutcome::complete(NodeOutput::new(payload)))
726            }
727            NodeKind::BuiltinStateGet => self
728                .execute_state_get(ctx, payload)
729                .await
730                .map(DispatchOutcome::complete),
731            NodeKind::BuiltinStateSet => self
732                .execute_state_set(ctx, payload)
733                .await
734                .map(DispatchOutcome::complete),
735            NodeKind::Wait => {
736                let reason = extract_wait_reason(&payload);
737                Ok(DispatchOutcome::wait(NodeOutput::new(payload), reason))
738            }
739        }
740    }
741
742    async fn execute_state_get(&self, ctx: &FlowContext<'_>, payload: Value) -> Result<NodeOutput> {
743        let key = Self::extract_state_key_helper(&payload)?;
744        let pack = self.pack_for_flow(ctx)?;
745        let store = pack
746            .state_store_handle()
747            .context("state store is not configured for this runtime")?;
748        let tenant_ctx = self.state_tenant_ctx(ctx)?;
749        let state_key = greentic_state::StateKey::new(&key);
750        let value = store
751            .get_json(
752                &tenant_ctx,
753                crate::storage::state::STATE_PREFIX,
754                &state_key,
755                None,
756            )
757            .with_context(|| format!("state.get failed for key `{key}`"))?;
758        let payload = serde_json::json!({
759            "key": key,
760            "value": value,
761            "found": value.is_some(),
762        });
763        Ok(NodeOutput::new(payload))
764    }
765
766    async fn execute_state_set(&self, ctx: &FlowContext<'_>, payload: Value) -> Result<NodeOutput> {
767        let key = Self::extract_state_key_helper(&payload)?;
768        let value = payload.get("value").cloned().unwrap_or(Value::Null);
769        let pack = self.pack_for_flow(ctx)?;
770        let store = pack
771            .state_store_handle()
772            .context("state store is not configured for this runtime")?;
773        let tenant_ctx = self.state_tenant_ctx(ctx)?;
774        let state_key = greentic_state::StateKey::new(&key);
775        store
776            .set_json(
777                &tenant_ctx,
778                crate::storage::state::STATE_PREFIX,
779                &state_key,
780                None,
781                &value,
782                None,
783            )
784            .with_context(|| format!("state.set failed for key `{key}`"))?;
785        let payload = serde_json::json!({ "key": key, "value": value });
786        Ok(NodeOutput::new(payload))
787    }
788
789    fn pack_for_flow(&self, ctx: &FlowContext<'_>) -> Result<&Arc<PackRuntime>> {
790        let key = FlowKey {
791            pack_id: ctx.pack_id.to_string(),
792            flow_id: ctx.flow_id.to_string(),
793        };
794        let idx = self.flow_sources.get(&key).with_context(|| {
795            format!("flow {} (pack {}) not registered", ctx.flow_id, ctx.pack_id)
796        })?;
797        Ok(&self.packs[*idx])
798    }
799
800    fn extract_state_key_helper(payload: &Value) -> Result<String> {
801        payload
802            .get("key")
803            .and_then(Value::as_str)
804            .map(String::from)
805            .filter(|k| !k.is_empty())
806            .context("state node payload missing required `key` (non-empty string)")
807    }
808
809    fn state_tenant_ctx(&self, ctx: &FlowContext<'_>) -> Result<greentic_types::TenantCtx> {
810        let env = greentic_types::EnvId::from_str(&self.default_env)
811            .with_context(|| format!("invalid env id `{}`", self.default_env))?;
812        let tenant = greentic_types::TenantId::from_str(ctx.tenant)
813            .with_context(|| format!("invalid tenant id `{}`", ctx.tenant))?;
814        Ok(greentic_types::TenantCtx::new(env, tenant))
815    }
816
817    async fn apply_jump(
818        &self,
819        ctx: &FlowContext<'_>,
820        state: &mut ExecutionState,
821        jump: JumpControl,
822    ) -> Result<JumpTarget> {
823        let target_flow = jump.flow.trim();
824        if target_flow.is_empty() {
825            bail!("missing_flow");
826        }
827
828        let flow = self
829            .get_or_load_flow(ctx.pack_id, target_flow)
830            .await
831            .with_context(|| format!("unknown_flow:{target_flow}"))?;
832
833        let target_node = if let Some(node) = jump.node.as_deref() {
834            let parsed = NodeId::from_str(node).with_context(|| format!("unknown_node:{node}"))?;
835            if !flow.nodes.contains_key(&parsed) {
836                bail!("unknown_node:{node}");
837            }
838            parsed
839        } else {
840            flow.start
841                .clone()
842                .or_else(|| flow.nodes.keys().next().cloned())
843                .ok_or_else(|| anyhow!("jump_failed: flow {target_flow} has no start node"))?
844        };
845
846        let max_redirects = jump.max_redirects.unwrap_or(3);
847        if state.redirect_count() >= max_redirects {
848            bail!("redirect_limit");
849        }
850        state.increment_redirect_count();
851        state.replace_input(jump.payload.clone());
852        state.last_output = Some(jump.payload);
853        tracing::info!(
854            flow_id = %ctx.flow_id,
855            target_flow = %target_flow,
856            target_node = %target_node.as_str(),
857            reason = ?jump.reason,
858            redirects = state.redirect_count(),
859            "flow.jump.applied"
860        );
861
862        Ok(JumpTarget {
863            flow_id: target_flow.to_string(),
864            flow,
865            node_id: target_node,
866        })
867    }
868
869    async fn execute_flow_call(&self, ctx: &FlowContext<'_>, payload: Value) -> Result<NodeOutput> {
870        #[derive(Deserialize)]
871        struct FlowCallPayload {
872            #[serde(alias = "flow")]
873            flow_id: String,
874            #[serde(default)]
875            input: Value,
876        }
877
878        let call: FlowCallPayload =
879            serde_json::from_value(payload).context("invalid payload for flow.call node")?;
880        if call.flow_id.trim().is_empty() {
881            bail!("flow.call requires a non-empty flow_id");
882        }
883
884        let sub_input = if call.input.is_null() {
885            Value::Null
886        } else {
887            call.input
888        };
889
890        let flow_id_owned = call.flow_id;
891        let action = "flow.call";
892        let sub_ctx = FlowContext {
893            tenant: ctx.tenant,
894            pack_id: ctx.pack_id,
895            flow_id: flow_id_owned.as_str(),
896            node_id: None,
897            tool: ctx.tool,
898            action: Some(action),
899            session_id: ctx.session_id,
900            provider_id: ctx.provider_id,
901            retry_config: ctx.retry_config,
902            attempt: ctx.attempt,
903            observer: ctx.observer,
904            mocks: ctx.mocks,
905        };
906
907        let execution = Box::pin(self.execute(sub_ctx, sub_input))
908            .await
909            .with_context(|| format!("flow.call failed for {}", flow_id_owned))?;
910        match execution.status {
911            FlowStatus::Completed => Ok(NodeOutput::new(execution.output)),
912            FlowStatus::Waiting(wait) => bail!(
913                "flow.call cannot pause (flow {} waiting {:?})",
914                flow_id_owned,
915                wait.reason
916            ),
917        }
918    }
919
920    async fn execute_component_exec(
921        &self,
922        ctx: &FlowContext<'_>,
923        node_id: &str,
924        node: &HostNode,
925        payload: Value,
926        event: &NodeEvent<'_>,
927        overrides: ComponentOverrides<'_>,
928    ) -> Result<NodeOutput> {
929        #[derive(Deserialize)]
930        struct ComponentPayload {
931            #[serde(default, alias = "component_ref", alias = "component")]
932            component: Option<String>,
933            #[serde(alias = "op")]
934            operation: Option<String>,
935            #[serde(default)]
936            input: Value,
937            #[serde(default)]
938            config: Value,
939        }
940
941        let payload: ComponentPayload =
942            serde_json::from_value(payload).context("invalid payload for component.exec")?;
943        let component_ref = overrides
944            .component
945            .map(str::to_string)
946            .or_else(|| payload.component.filter(|v| !v.trim().is_empty()))
947            .with_context(|| "component.exec requires a component_ref")?;
948        let operation = resolve_component_operation(
949            node_id,
950            node.component_id.as_str(),
951            payload.operation,
952            overrides.operation,
953            node.operation_in_mapping.as_deref(),
954        )?;
955        let call = ComponentCall {
956            component_ref,
957            operation,
958            input: payload.input,
959            config: payload.config,
960        };
961
962        self.invoke_component_call(ctx, node_id, call, event).await
963    }
964
965    async fn execute_component_call(
966        &self,
967        ctx: &FlowContext<'_>,
968        node_id: &str,
969        node: &HostNode,
970        payload: Value,
971        component_ref: &str,
972        event: &NodeEvent<'_>,
973    ) -> Result<NodeOutput> {
974        let payload_operation = extract_operation_from_mapping(&payload);
975        let (input, config) = split_operation_payload(payload);
976        let operation = resolve_component_operation(
977            node_id,
978            node.component_id.as_str(),
979            payload_operation,
980            node.operation_name.as_deref(),
981            node.operation_in_mapping.as_deref(),
982        )?;
983        let call = ComponentCall {
984            component_ref: component_ref.to_string(),
985            operation,
986            input,
987            config,
988        };
989        self.invoke_component_call(ctx, node_id, call, event).await
990    }
991
992    async fn invoke_component_call(
993        &self,
994        ctx: &FlowContext<'_>,
995        node_id: &str,
996        mut call: ComponentCall,
997        event: &NodeEvent<'_>,
998    ) -> Result<NodeOutput> {
999        self.validate_component(ctx, event, &call)?;
1000        let key = FlowKey {
1001            pack_id: ctx.pack_id.to_string(),
1002            flow_id: ctx.flow_id.to_string(),
1003        };
1004        let pack_idx = *self.flow_sources.get(&key).with_context(|| {
1005            format!("flow {} (pack {}) not registered", ctx.flow_id, ctx.pack_id)
1006        })?;
1007        let pack = Arc::clone(&self.packs[pack_idx]);
1008
1009        // Promote adaptive-card defaults from node config (default_card_asset /
1010        // default_card_inline / default_source) into the invocation, so the
1011        // component receives a valid `card_spec` field even when the user input
1012        // is empty (e.g. webchat ConversationStart with no text). Without this,
1013        // schema validation in the component reports AC_INVOCATION_MISSING_FIELD
1014        // and the renderer falls back to a generic "Welcome" placeholder.
1015        promote_card_config_to_invocation(&mut call.input, &call.config);
1016
1017        // Pre-resolve card asset paths: read JSON files from the pack's assets
1018        // directory and inject as inline_json so the component doesn't need
1019        // WASI filesystem access.
1020        resolve_card_assets(&mut call.input, &pack);
1021
1022        // When the input is a card-like invocation (has card_source/card_spec),
1023        // pass it directly to the component instead of wrapping in an
1024        // InvocationEnvelope.  The envelope serialises the payload field as a
1025        // byte array which the component cannot decode back, and the
1026        // InvocationPayload::parse heuristic strips domain fields when a
1027        // `payload` key is present (e.g.  the card's Handlebars template
1028        // context `payload: {}`).
1029        let is_card = is_card_invocation(&call.input);
1030
1031        let input_json = if is_card {
1032            serde_json::to_string(&call.input)?
1033        } else {
1034            // Runtime owns ctx; flows must not embed ctx, even if they provide envelopes.
1035            let meta = InvocationMeta {
1036                env: &self.default_env,
1037                tenant: ctx.tenant,
1038                flow_id: ctx.flow_id,
1039                node_id: Some(node_id),
1040                provider_id: ctx.provider_id,
1041                session_id: ctx.session_id,
1042                attempt: ctx.attempt,
1043            };
1044            let invocation_envelope =
1045                build_invocation_envelope(meta, call.operation.as_str(), call.input)
1046                    .context("build invocation envelope for component call")?;
1047            serde_json::to_string(&invocation_envelope)?
1048        };
1049        let config_json = if call.config.is_null() {
1050            None
1051        } else {
1052            Some(serde_json::to_string(&call.config)?)
1053        };
1054
1055        let exec_ctx = component_exec_ctx(ctx, node_id);
1056        #[cfg(feature = "fault-injection")]
1057        {
1058            let fault_ctx = FaultContext {
1059                pack_id: ctx.pack_id,
1060                flow_id: ctx.flow_id,
1061                node_id: Some(node_id),
1062                attempt: ctx.attempt,
1063            };
1064            maybe_fail(FaultPoint::BeforeComponentCall, fault_ctx)
1065                .map_err(|err| anyhow!(err.to_string()))?;
1066        }
1067        let value = pack
1068            .invoke_component(
1069                call.component_ref.as_str(),
1070                exec_ctx,
1071                call.operation.as_str(),
1072                config_json,
1073                input_json,
1074            )
1075            .await?;
1076        #[cfg(feature = "fault-injection")]
1077        {
1078            let fault_ctx = FaultContext {
1079                pack_id: ctx.pack_id,
1080                flow_id: ctx.flow_id,
1081                node_id: Some(node_id),
1082                attempt: ctx.attempt,
1083            };
1084            maybe_fail(FaultPoint::AfterComponentCall, fault_ctx)
1085                .map_err(|err| anyhow!(err.to_string()))?;
1086        }
1087
1088        if let Some((code, message)) = component_error(&value) {
1089            bail!(
1090                "component {} failed: {}: {}",
1091                call.component_ref,
1092                code,
1093                message
1094            );
1095        }
1096        // MCP-shaped tool errors (greentic-mcp-generator's tool_error_with_status)
1097        // come back as a top-level `{ "error": { "code", "message", "status" } }`
1098        // value with the WIT envelope still ok=true (because the wasm guest
1099        // returned normally). Treat them the same as a component_error so the
1100        // engine error-envelope lift path surfaces the failure to the user.
1101        if let Some((code, message)) = mcp_tool_error(&value) {
1102            bail!(
1103                "component {} returned tool error: {}: {}",
1104                call.component_ref,
1105                code,
1106                message
1107            );
1108        }
1109        Ok(NodeOutput::new(value))
1110    }
1111
1112    async fn execute_provider_invoke(
1113        &self,
1114        ctx: &FlowContext<'_>,
1115        node_id: &str,
1116        state: &ExecutionState,
1117        payload: Value,
1118        event: &NodeEvent<'_>,
1119    ) -> Result<NodeOutput> {
1120        #[derive(Deserialize)]
1121        struct ProviderPayload {
1122            #[serde(default)]
1123            provider_id: Option<String>,
1124            #[serde(default)]
1125            provider_type: Option<String>,
1126            #[serde(default, alias = "operation")]
1127            op: Option<String>,
1128            #[serde(default)]
1129            input: Value,
1130            #[serde(default)]
1131            in_map: Value,
1132            #[serde(default)]
1133            out_map: Value,
1134            #[serde(default)]
1135            err_map: Value,
1136        }
1137
1138        let payload: ProviderPayload =
1139            serde_json::from_value(payload).context("invalid payload for provider.invoke")?;
1140        let op = payload
1141            .op
1142            .as_deref()
1143            .filter(|v| !v.trim().is_empty())
1144            .with_context(|| "provider.invoke requires an op")?
1145            .to_string();
1146
1147        let prev = state
1148            .last_output
1149            .as_ref()
1150            .cloned()
1151            .unwrap_or_else(|| Value::Object(JsonMap::new()));
1152        let base_ctx = template_context(state, prev);
1153
1154        let input_value = if !payload.in_map.is_null() {
1155            let mut ctx_value = base_ctx.clone();
1156            if let Value::Object(ref mut map) = ctx_value {
1157                map.insert("input".into(), payload.input.clone());
1158                map.insert("result".into(), payload.input.clone());
1159            }
1160            render_template_value(
1161                &payload.in_map,
1162                &ctx_value,
1163                TemplateOptions {
1164                    allow_pointer: true,
1165                },
1166            )
1167            .context("failed to render provider.invoke in_map")?
1168        } else if !payload.input.is_null() {
1169            payload.input
1170        } else {
1171            Value::Null
1172        };
1173        let input_json = serde_json::to_vec(&input_value)?;
1174
1175        self.validate_tool(
1176            ctx,
1177            event,
1178            payload.provider_id.as_deref(),
1179            payload.provider_type.as_deref(),
1180            &op,
1181            &input_value,
1182        )?;
1183
1184        let key = FlowKey {
1185            pack_id: ctx.pack_id.to_string(),
1186            flow_id: ctx.flow_id.to_string(),
1187        };
1188        let pack_idx = *self.flow_sources.get(&key).with_context(|| {
1189            format!("flow {} (pack {}) not registered", ctx.flow_id, ctx.pack_id)
1190        })?;
1191        let pack = Arc::clone(&self.packs[pack_idx]);
1192        let binding = pack.resolve_provider(
1193            payload.provider_id.as_deref(),
1194            payload.provider_type.as_deref(),
1195        );
1196
1197        // If pack-local resolution fails, try the cross-pack resolver (capability registry).
1198        if binding.is_err()
1199            && let Some(output) = self.try_invoke_cross_pack_resolver(
1200                payload.provider_id.as_deref(),
1201                payload.provider_type.as_deref(),
1202                &op,
1203                &input_json,
1204                ctx.tenant,
1205            )?
1206        {
1207            return Ok(output);
1208        }
1209
1210        let binding = binding?;
1211        let exec_ctx = component_exec_ctx(ctx, node_id);
1212        #[cfg(feature = "fault-injection")]
1213        {
1214            let fault_ctx = FaultContext {
1215                pack_id: ctx.pack_id,
1216                flow_id: ctx.flow_id,
1217                node_id: Some(node_id),
1218                attempt: ctx.attempt,
1219            };
1220            maybe_fail(FaultPoint::BeforeToolCall, fault_ctx)
1221                .map_err(|err| anyhow!(err.to_string()))?;
1222        }
1223        let provider_metric_id = payload
1224            .provider_id
1225            .as_deref()
1226            .or(payload.provider_type.as_deref())
1227            .unwrap_or("unknown");
1228        let invoke_started = std::time::Instant::now();
1229        let invoke_result = pack
1230            .invoke_provider(&binding, exec_ctx, &op, input_json)
1231            .await;
1232        let invoke_duration_ms = invoke_started.elapsed().as_secs_f64() * 1000.0;
1233        crate::metrics::record_provider_invocation(
1234            ctx.tenant,
1235            provider_metric_id,
1236            &op,
1237            if invoke_result.is_ok() { "ok" } else { "err" },
1238            invoke_duration_ms,
1239        );
1240        let result = invoke_result?;
1241        #[cfg(feature = "fault-injection")]
1242        {
1243            let fault_ctx = FaultContext {
1244                pack_id: ctx.pack_id,
1245                flow_id: ctx.flow_id,
1246                node_id: Some(node_id),
1247                attempt: ctx.attempt,
1248            };
1249            maybe_fail(FaultPoint::AfterToolCall, fault_ctx)
1250                .map_err(|err| anyhow!(err.to_string()))?;
1251        }
1252
1253        let output = if payload.out_map.is_null() {
1254            result
1255        } else {
1256            let mut ctx_value = base_ctx;
1257            if let Value::Object(ref mut map) = ctx_value {
1258                map.insert("input".into(), result.clone());
1259                map.insert("result".into(), result.clone());
1260            }
1261            render_template_value(
1262                &payload.out_map,
1263                &ctx_value,
1264                TemplateOptions {
1265                    allow_pointer: true,
1266                },
1267            )
1268            .context("failed to render provider.invoke out_map")?
1269        };
1270        let _ = payload.err_map;
1271        Ok(NodeOutput::new(output))
1272    }
1273
1274    fn try_invoke_cross_pack_resolver(
1275        &self,
1276        provider_id: Option<&str>,
1277        provider_type: Option<&str>,
1278        op: &str,
1279        input_json: &[u8],
1280        tenant: &str,
1281    ) -> Result<Option<NodeOutput>> {
1282        eprintln!(
1283            "[DEBUG] provider.invoke: pack-local failed, has_resolver={}",
1284            self.cross_pack_resolver.is_some()
1285        );
1286        let Some(resolver) = self.cross_pack_resolver.as_ref() else {
1287            return Ok(None);
1288        };
1289        let provider_id = provider_id.unwrap_or("unknown");
1290        tracing::info!(
1291            provider_id,
1292            op = %op,
1293            "provider.invoke: pack-local resolution failed, trying cross-pack resolver"
1294        );
1295        let result_value =
1296            resolver.invoke(provider_id, provider_type, op, input_json, tenant, None)?;
1297        Ok(Some(NodeOutput::new(result_value)))
1298    }
1299
1300    fn validate_component(
1301        &self,
1302        ctx: &FlowContext<'_>,
1303        event: &NodeEvent<'_>,
1304        call: &ComponentCall,
1305    ) -> Result<()> {
1306        if self.validation.mode == ValidationMode::Off {
1307            return Ok(());
1308        }
1309        let mut metadata = JsonMap::new();
1310        metadata.insert("tenant_id".to_string(), json!(ctx.tenant));
1311        if let Some(id) = ctx.session_id {
1312            metadata.insert("session".to_string(), json!({ "id": id }));
1313        }
1314        let envelope = json!({
1315            "component_id": call.component_ref,
1316            "operation": call.operation,
1317            "input": call.input,
1318            "config": call.config,
1319            "metadata": Value::Object(metadata),
1320        });
1321        let issues = validate_component_envelope(&envelope);
1322        self.report_validation(ctx, event, "component", issues)
1323    }
1324
1325    fn validate_tool(
1326        &self,
1327        ctx: &FlowContext<'_>,
1328        event: &NodeEvent<'_>,
1329        provider_id: Option<&str>,
1330        provider_type: Option<&str>,
1331        operation: &str,
1332        input: &Value,
1333    ) -> Result<()> {
1334        if self.validation.mode == ValidationMode::Off {
1335            return Ok(());
1336        }
1337        let tool_id = provider_id.or(provider_type).unwrap_or("provider.invoke");
1338        let mut metadata = JsonMap::new();
1339        metadata.insert("tenant_id".to_string(), json!(ctx.tenant));
1340        if let Some(id) = ctx.session_id {
1341            metadata.insert("session".to_string(), json!({ "id": id }));
1342        }
1343        let envelope = json!({
1344            "tool_id": tool_id,
1345            "operation": operation,
1346            "input": input,
1347            "metadata": Value::Object(metadata),
1348        });
1349        let issues = validate_tool_envelope(&envelope);
1350        self.report_validation(ctx, event, "tool", issues)
1351    }
1352
1353    fn report_validation(
1354        &self,
1355        ctx: &FlowContext<'_>,
1356        event: &NodeEvent<'_>,
1357        kind: &str,
1358        issues: Vec<ValidationIssue>,
1359    ) -> Result<()> {
1360        if issues.is_empty() {
1361            return Ok(());
1362        }
1363        if let Some(observer) = ctx.observer {
1364            observer.on_validation(event, &issues);
1365        }
1366        match self.validation.mode {
1367            ValidationMode::Warn => {
1368                tracing::warn!(
1369                    tenant = ctx.tenant,
1370                    flow_id = ctx.flow_id,
1371                    node_id = event.node_id,
1372                    kind,
1373                    issues = ?issues,
1374                    "invocation envelope validation issues"
1375                );
1376                Ok(())
1377            }
1378            ValidationMode::Error => {
1379                tracing::error!(
1380                    tenant = ctx.tenant,
1381                    flow_id = ctx.flow_id,
1382                    node_id = event.node_id,
1383                    kind,
1384                    issues = ?issues,
1385                    "invocation envelope validation failed"
1386                );
1387                bail!("invocation_validation_failed");
1388            }
1389            ValidationMode::Off => Ok(()),
1390        }
1391    }
1392
1393    pub fn flows(&self) -> &[FlowDescriptor] {
1394        &self.flows
1395    }
1396
1397    pub fn flow_by_key(&self, pack_id: &str, flow_id: &str) -> Option<&FlowDescriptor> {
1398        self.flows
1399            .iter()
1400            .find(|descriptor| descriptor.pack_id == pack_id && descriptor.id == flow_id)
1401    }
1402
1403    pub fn flow_by_type(&self, flow_type: &str) -> Option<&FlowDescriptor> {
1404        let mut matches = self
1405            .flows
1406            .iter()
1407            .filter(|descriptor| descriptor.flow_type == flow_type);
1408        let first = matches.next()?;
1409        if matches.next().is_some() {
1410            return None;
1411        }
1412        Some(first)
1413    }
1414
1415    pub fn flow_by_id(&self, flow_id: &str) -> Option<&FlowDescriptor> {
1416        let mut matches = self
1417            .flows
1418            .iter()
1419            .filter(|descriptor| descriptor.id == flow_id);
1420        let first = matches.next()?;
1421        if matches.next().is_some() {
1422            return None;
1423        }
1424        Some(first)
1425    }
1426}
1427
1428pub trait ExecutionObserver: Send + Sync {
1429    fn on_node_start(&self, event: &NodeEvent<'_>);
1430    fn on_node_end(&self, event: &NodeEvent<'_>, output: &Value);
1431    fn on_node_error(&self, event: &NodeEvent<'_>, error: &dyn StdError);
1432    fn on_validation(&self, _event: &NodeEvent<'_>, _issues: &[ValidationIssue]) {}
1433}
1434
1435pub struct NodeEvent<'a> {
1436    pub context: &'a FlowContext<'a>,
1437    pub node_id: &'a str,
1438    pub node: &'a HostNode,
1439    pub payload: &'a Value,
1440}
1441
1442#[derive(Clone, Debug, Serialize, Deserialize)]
1443pub struct ExecutionState {
1444    #[serde(default)]
1445    entry: Value,
1446    #[serde(default)]
1447    input: Value,
1448    #[serde(default)]
1449    nodes: HashMap<String, NodeOutput>,
1450    #[serde(default)]
1451    egress: Vec<Value>,
1452    #[serde(default, skip_serializing_if = "Option::is_none")]
1453    last_output: Option<Value>,
1454    #[serde(default)]
1455    redirect_count: u32,
1456}
1457
1458impl ExecutionState {
1459    fn new(input: Value) -> Self {
1460        Self {
1461            entry: input.clone(),
1462            input,
1463            nodes: HashMap::new(),
1464            egress: Vec::new(),
1465            last_output: None,
1466            redirect_count: 0,
1467        }
1468    }
1469
1470    /// Refresh `entry` from `input` if the snapshot was loaded without an
1471    /// entry value. Kept for backwards compatibility with snapshots
1472    /// persisted before the entry-refresh fix in `FlowEngine::resume`.
1473    #[allow(dead_code)]
1474    fn ensure_entry(&mut self) {
1475        if self.entry.is_null() {
1476            self.entry = self.input.clone();
1477        }
1478    }
1479
1480    fn context(&self) -> Value {
1481        let mut nodes = JsonMap::new();
1482        for (id, output) in &self.nodes {
1483            nodes.insert(
1484                id.clone(),
1485                json!({
1486                    "ok": output.ok,
1487                    "payload": output.payload.clone(),
1488                    "meta": output.meta.clone(),
1489                }),
1490            );
1491        }
1492        json!({
1493            "entry": self.entry.clone(),
1494            "input": self.input.clone(),
1495            "nodes": nodes,
1496            "redirect_count": self.redirect_count,
1497        })
1498    }
1499
1500    fn outputs_map(&self) -> JsonMap<String, Value> {
1501        let mut outputs = JsonMap::new();
1502        for (id, output) in &self.nodes {
1503            outputs.insert(id.clone(), output.payload.clone());
1504        }
1505        outputs
1506    }
1507    fn push_egress(&mut self, payload: Value) {
1508        self.egress.push(payload);
1509    }
1510
1511    fn replace_input(&mut self, input: Value) {
1512        self.input = input;
1513    }
1514
1515    fn clear_egress(&mut self) {
1516        self.egress.clear();
1517    }
1518
1519    fn redirect_count(&self) -> u32 {
1520        self.redirect_count
1521    }
1522
1523    fn increment_redirect_count(&mut self) {
1524        self.redirect_count = self.redirect_count.saturating_add(1);
1525    }
1526
1527    fn finalize_with(mut self, final_payload: Option<Value>) -> Value {
1528        if self.egress.is_empty() {
1529            return final_payload.unwrap_or(Value::Null);
1530        }
1531        let mut emitted = std::mem::take(&mut self.egress);
1532        if let Some(value) = final_payload {
1533            match value {
1534                Value::Null => {}
1535                Value::Array(items) => emitted.extend(items),
1536                other => emitted.push(other),
1537            }
1538        }
1539        Value::Array(emitted)
1540    }
1541}
1542
1543#[derive(Clone, Debug, Serialize, Deserialize)]
1544struct NodeOutput {
1545    ok: bool,
1546    payload: Value,
1547    meta: Value,
1548}
1549
1550impl NodeOutput {
1551    fn new(payload: Value) -> Self {
1552        Self {
1553            ok: true,
1554            payload,
1555            meta: Value::Null,
1556        }
1557    }
1558
1559    /// `ok=false` output stashing error context in `meta.error`. Currently
1560    /// only used by `lift_first_node_error_from_nodes` tests — kept around so
1561    /// drive_flow can resume populating it once we have a hook for it.
1562    #[allow(dead_code)]
1563    fn with_error(node_id: &str, err: &(dyn std::error::Error + 'static)) -> Self {
1564        Self {
1565            ok: false,
1566            payload: Value::Null,
1567            meta: json!({
1568                "error": {
1569                    "kind": "flow_node_failed",
1570                    "message": err.to_string(),
1571                    "node_id": node_id,
1572                }
1573            }),
1574        }
1575    }
1576}
1577
1578struct DispatchOutcome {
1579    output: NodeOutput,
1580    control: NodeControl,
1581}
1582
1583impl DispatchOutcome {
1584    fn complete(output: NodeOutput) -> Self {
1585        Self {
1586            output,
1587            control: NodeControl::Continue,
1588        }
1589    }
1590
1591    fn wait(output: NodeOutput, reason: Option<String>) -> Self {
1592        Self {
1593            output,
1594            control: NodeControl::Wait { reason },
1595        }
1596    }
1597
1598    fn with_control(output: NodeOutput, control: NodeControl) -> Self {
1599        Self { output, control }
1600    }
1601}
1602
1603#[derive(Clone, Debug)]
1604enum NodeControl {
1605    Continue,
1606    Wait {
1607        reason: Option<String>,
1608    },
1609    Jump(JumpControl),
1610    Respond {
1611        text: Option<String>,
1612        card_cbor: Option<Vec<u8>>,
1613        needs_user: Option<bool>,
1614    },
1615}
1616
1617#[derive(Clone, Debug)]
1618struct JumpControl {
1619    flow: String,
1620    node: Option<String>,
1621    payload: Value,
1622    hints: Value,
1623    max_redirects: Option<u32>,
1624    reason: Option<String>,
1625}
1626
1627#[derive(Clone, Debug)]
1628struct JumpTarget {
1629    flow_id: String,
1630    flow: HostFlow,
1631    node_id: NodeId,
1632}
1633
1634impl NodeOutput {
1635    fn with_meta(payload: Value, meta: Value) -> Self {
1636        Self {
1637            ok: true,
1638            payload,
1639            meta,
1640        }
1641    }
1642}
1643
1644fn component_exec_ctx(ctx: &FlowContext<'_>, node_id: &str) -> ComponentExecCtx {
1645    ComponentExecCtx {
1646        tenant: ComponentTenantCtx {
1647            tenant: ctx.tenant.to_string(),
1648            team: None,
1649            user: ctx.provider_id.map(str::to_string),
1650            trace_id: None,
1651            i18n_id: None,
1652            correlation_id: ctx.session_id.map(str::to_string),
1653            deadline_unix_ms: None,
1654            attempt: ctx.attempt,
1655            idempotency_key: ctx.session_id.map(str::to_string),
1656        },
1657        i18n_id: None,
1658        flow_id: ctx.flow_id.to_string(),
1659        node_id: Some(node_id.to_string()),
1660    }
1661}
1662
1663fn component_error(value: &Value) -> Option<(String, String)> {
1664    let obj = value.as_object()?;
1665    let ok = obj.get("ok").and_then(Value::as_bool)?;
1666    if ok {
1667        return None;
1668    }
1669    let err = obj.get("error")?.as_object()?;
1670    let code = err
1671        .get("code")
1672        .and_then(Value::as_str)
1673        .unwrap_or("component_error");
1674    let message = err
1675        .get("message")
1676        .and_then(Value::as_str)
1677        .unwrap_or("component reported error");
1678    Some((code.to_string(), message.to_string()))
1679}
1680
1681/// MCP tool-error wire shape from greentic-mcp-generator's `tool_error_with_status`:
1682/// `{ "error": { "code", "message", "status" } }`. The component returned ok=true at
1683/// the WIT level (the HTTP failure was caught and serialized), so the regular
1684/// component_error path doesn't catch it.
1685fn mcp_tool_error(value: &Value) -> Option<(String, String)> {
1686    let obj = value.as_object()?;
1687    // Must be the error shape: no `result` field, just `error`.
1688    if obj.contains_key("result") {
1689        return None;
1690    }
1691    let err = obj.get("error")?.as_object()?;
1692    let code = err
1693        .get("code")
1694        .and_then(Value::as_str)
1695        .unwrap_or("tool_error");
1696    let raw_message = err
1697        .get("message")
1698        .and_then(Value::as_str)
1699        .unwrap_or("tool returned an error");
1700    let status = err.get("status").and_then(Value::as_u64);
1701    let message = match status {
1702        Some(s) => format!("{raw_message} (status {s})"),
1703        None => raw_message.to_string(),
1704    };
1705    Some((code.to_string(), message))
1706}
1707
1708fn extract_wait_reason(payload: &Value) -> Option<String> {
1709    match payload {
1710        Value::String(s) => Some(s.clone()),
1711        Value::Object(map) => map
1712            .get("reason")
1713            .and_then(Value::as_str)
1714            .map(|value| value.to_string()),
1715        _ => None,
1716    }
1717}
1718
1719fn component_dispatch_outcome(output: NodeOutput) -> Result<DispatchOutcome> {
1720    if let Some(control) = parse_component_control(&output.payload)? {
1721        return Ok(match control {
1722            NodeControl::Jump(jump) => {
1723                let adjusted = NodeOutput::with_meta(jump.payload.clone(), jump.hints.clone());
1724                DispatchOutcome::with_control(adjusted, NodeControl::Jump(jump))
1725            }
1726            NodeControl::Respond {
1727                text,
1728                card_cbor,
1729                needs_user,
1730            } => DispatchOutcome::with_control(
1731                output,
1732                NodeControl::Respond {
1733                    text,
1734                    card_cbor,
1735                    needs_user,
1736                },
1737            ),
1738            other => DispatchOutcome::with_control(output, other),
1739        });
1740    }
1741    Ok(DispatchOutcome::complete(output))
1742}
1743
1744fn parse_component_control(payload: &Value) -> Result<Option<NodeControl>> {
1745    let Value::Object(map) = payload else {
1746        return Ok(None);
1747    };
1748    let Some(control_value) = map.get("greentic_control") else {
1749        return Ok(None);
1750    };
1751    let control = control_value
1752        .as_object()
1753        .ok_or_else(|| anyhow!("jump_failed: greentic_control must be an object"))?;
1754    let action = control
1755        .get("action")
1756        .and_then(Value::as_str)
1757        .ok_or_else(|| anyhow!("jump_failed: greentic_control.action is required"))?;
1758    let version = control
1759        .get("v")
1760        .and_then(Value::as_u64)
1761        .ok_or_else(|| anyhow!("jump_failed: greentic_control.v is required"))?;
1762    if version != 1 {
1763        bail!("jump_failed: unsupported greentic_control.v={version}");
1764    }
1765
1766    match action {
1767        "jump" => {
1768            let flow = control
1769                .get("flow")
1770                .and_then(Value::as_str)
1771                .map(str::trim)
1772                .filter(|value| !value.is_empty())
1773                .ok_or_else(|| anyhow!("jump_failed: jump flow is required"))?
1774                .to_string();
1775            let node = control
1776                .get("node")
1777                .and_then(Value::as_str)
1778                .map(str::trim)
1779                .filter(|value| !value.is_empty())
1780                .map(str::to_string);
1781            let payload = control.get("payload").cloned().unwrap_or(Value::Null);
1782            let hints = control.get("hints").cloned().unwrap_or(Value::Null);
1783            let max_redirects = control
1784                .get("max_redirects")
1785                .and_then(Value::as_u64)
1786                .and_then(|value| u32::try_from(value).ok());
1787            let reason = control
1788                .get("reason")
1789                .and_then(Value::as_str)
1790                .map(str::to_string);
1791            Ok(Some(NodeControl::Jump(JumpControl {
1792                flow,
1793                node,
1794                payload,
1795                hints,
1796                max_redirects,
1797                reason,
1798            })))
1799        }
1800        "respond" => {
1801            let text = control
1802                .get("text")
1803                .and_then(Value::as_str)
1804                .map(str::to_string);
1805            let card_cbor = control
1806                .get("card_cbor")
1807                .and_then(Value::as_array)
1808                .map(|bytes| {
1809                    bytes
1810                        .iter()
1811                        .filter_map(Value::as_u64)
1812                        .filter_map(|value| u8::try_from(value).ok())
1813                        .collect::<Vec<_>>()
1814                });
1815            let needs_user = control.get("needs_user").and_then(Value::as_bool);
1816            Ok(Some(NodeControl::Respond {
1817                text,
1818                card_cbor,
1819                needs_user,
1820            }))
1821        }
1822        _ => Ok(None),
1823    }
1824}
1825
1826fn template_context(state: &ExecutionState, prev: Value) -> Value {
1827    let entry = if state.entry.is_null() {
1828        Value::Object(JsonMap::new())
1829    } else {
1830        state.entry.clone()
1831    };
1832    let mut ctx = JsonMap::new();
1833    ctx.insert("entry".into(), entry.clone());
1834    ctx.insert("in".into(), entry); // alias for entry - used in flow templates
1835    ctx.insert("prev".into(), prev);
1836    ctx.insert("node".into(), Value::Object(state.outputs_map()));
1837    ctx.insert("state".into(), state.context());
1838    Value::Object(ctx)
1839}
1840
1841impl From<Flow> for HostFlow {
1842    fn from(value: Flow) -> Self {
1843        let mut nodes = IndexMap::new();
1844        for (id, node) in value.nodes {
1845            nodes.insert(id.clone(), HostNode::from(node));
1846        }
1847        let start = value
1848            .entrypoints
1849            .get("default")
1850            .and_then(Value::as_str)
1851            .and_then(|id| NodeId::from_str(id).ok())
1852            .or_else(|| nodes.keys().next().cloned());
1853        Self {
1854            id: value.id.as_str().to_string(),
1855            start,
1856            nodes,
1857        }
1858    }
1859}
1860
1861impl From<Node> for HostNode {
1862    fn from(node: Node) -> Self {
1863        let full_ref = node.component.id.as_str().to_string();
1864        // When the pack compiler stores "component.operation" as a single ID
1865        // without a separate operation field, split on the last dot.
1866        let is_builtin = full_ref.starts_with("component.exec")
1867            || full_ref.starts_with("flow.")
1868            || full_ref.starts_with("emit.")
1869            || full_ref.starts_with("session.")
1870            || full_ref.starts_with("provider.");
1871        let (component_ref, raw_operation) = if node.component.operation.is_some() || is_builtin {
1872            (full_ref, node.component.operation.clone())
1873        } else if let Some(dot) = full_ref.rfind('.') {
1874            let comp = full_ref[..dot].to_string();
1875            let op = full_ref[dot + 1..].to_string();
1876            (comp, Some(op))
1877        } else {
1878            (full_ref, None)
1879        };
1880        let operation_in_mapping = extract_operation_from_mapping(&node.input.mapping);
1881        let operation_is_component_exec = raw_operation.as_deref() == Some("component.exec");
1882        let operation_is_emit = raw_operation
1883            .as_deref()
1884            .map(|op| op.starts_with("emit."))
1885            .unwrap_or(false);
1886        let is_component_exec = component_ref == "component.exec" || operation_is_component_exec;
1887
1888        let kind = if is_component_exec {
1889            let target = if component_ref == "component.exec" {
1890                if let Some(op) = raw_operation
1891                    .as_deref()
1892                    .filter(|op| op.starts_with("emit."))
1893                {
1894                    op.to_string()
1895                } else {
1896                    extract_target_component(&node.input.mapping)
1897                        .unwrap_or_else(|| "component.exec".to_string())
1898                }
1899            } else {
1900                extract_target_component(&node.input.mapping)
1901                    .unwrap_or_else(|| component_ref.clone())
1902            };
1903            if target.starts_with("emit.") {
1904                NodeKind::BuiltinEmit {
1905                    kind: emit_kind_from_ref(&target),
1906                }
1907            } else {
1908                NodeKind::Exec {
1909                    target_component: target,
1910                }
1911            }
1912        } else if operation_is_emit {
1913            NodeKind::BuiltinEmit {
1914                kind: emit_kind_from_ref(raw_operation.as_deref().unwrap_or("emit.log")),
1915            }
1916        } else {
1917            match component_ref.as_str() {
1918                "flow.call" => NodeKind::FlowCall,
1919                "provider.invoke" => NodeKind::ProviderInvoke,
1920                "session.wait" => NodeKind::Wait,
1921                "state.get" => NodeKind::BuiltinStateGet,
1922                "state.set" => NodeKind::BuiltinStateSet,
1923                comp if comp.starts_with("emit.") => NodeKind::BuiltinEmit {
1924                    kind: emit_kind_from_ref(comp),
1925                },
1926                other => NodeKind::PackComponent {
1927                    component_ref: other.to_string(),
1928                },
1929            }
1930        };
1931        let component_label = match &kind {
1932            NodeKind::Exec { .. } => "component.exec".to_string(),
1933            NodeKind::PackComponent { component_ref } => component_ref.clone(),
1934            NodeKind::ProviderInvoke => "provider.invoke".to_string(),
1935            NodeKind::FlowCall => "flow.call".to_string(),
1936            NodeKind::BuiltinEmit { kind } => emit_ref_from_kind(kind),
1937            NodeKind::BuiltinStateGet => "state.get".to_string(),
1938            NodeKind::BuiltinStateSet => "state.set".to_string(),
1939            NodeKind::Wait => "session.wait".to_string(),
1940        };
1941        let operation_name = if is_component_exec && operation_is_component_exec {
1942            None
1943        } else {
1944            raw_operation.clone()
1945        };
1946        let payload_expr = match kind {
1947            NodeKind::BuiltinEmit { .. } => extract_emit_payload(&node.input.mapping),
1948            _ => node.input.mapping.clone(),
1949        };
1950        Self {
1951            kind,
1952            component: component_label,
1953            component_id: if is_component_exec {
1954                "component.exec".to_string()
1955            } else {
1956                component_ref
1957            },
1958            operation_name,
1959            operation_in_mapping,
1960            payload_expr,
1961            routing: node.routing,
1962        }
1963    }
1964}
1965
1966fn extract_target_component(payload: &Value) -> Option<String> {
1967    match payload {
1968        Value::Object(map) => map
1969            .get("component")
1970            .or_else(|| map.get("component_ref"))
1971            .and_then(Value::as_str)
1972            .map(|s| s.to_string()),
1973        _ => None,
1974    }
1975}
1976
1977fn extract_operation_from_mapping(payload: &Value) -> Option<String> {
1978    match payload {
1979        Value::Object(map) => map
1980            .get("operation")
1981            .or_else(|| map.get("op"))
1982            .and_then(Value::as_str)
1983            .map(str::trim)
1984            .filter(|value| !value.is_empty())
1985            .map(|value| value.to_string()),
1986        _ => None,
1987    }
1988}
1989
1990fn extract_emit_payload(payload: &Value) -> Value {
1991    if let Value::Object(map) = payload {
1992        if let Some(input) = map.get("input") {
1993            return input.clone();
1994        }
1995        if let Some(inner) = map.get("payload") {
1996            return inner.clone();
1997        }
1998    }
1999    payload.clone()
2000}
2001
2002fn split_operation_payload(payload: Value) -> (Value, Value) {
2003    if let Value::Object(mut map) = payload.clone()
2004        && map.contains_key("input")
2005    {
2006        let input = map.remove("input").unwrap_or(Value::Null);
2007        let config = map.remove("config").unwrap_or(Value::Null);
2008        let legacy_only = map.keys().all(|key| {
2009            matches!(
2010                key.as_str(),
2011                "operation" | "op" | "component" | "component_ref"
2012            )
2013        });
2014        if legacy_only {
2015            return (input, config);
2016        }
2017    }
2018    (payload, Value::Null)
2019}
2020
2021fn resolve_component_operation(
2022    node_id: &str,
2023    component_label: &str,
2024    payload_operation: Option<String>,
2025    operation_override: Option<&str>,
2026    operation_in_mapping: Option<&str>,
2027) -> Result<String> {
2028    if let Some(op) = operation_override
2029        .map(str::trim)
2030        .filter(|value| !value.is_empty())
2031    {
2032        return Ok(op.to_string());
2033    }
2034
2035    if let Some(op) = payload_operation
2036        .as_deref()
2037        .map(str::trim)
2038        .filter(|value| !value.is_empty())
2039    {
2040        return Ok(op.to_string());
2041    }
2042
2043    let mut message = format!(
2044        "missing operation for node `{}` (component `{}`); expected node.component.operation to be set",
2045        node_id, component_label,
2046    );
2047    if let Some(found) = operation_in_mapping {
2048        message.push_str(&format!(
2049            ". Found operation in input.mapping (`{}`) but this is not used; pack compiler must preserve node.component.operation.",
2050            found
2051        ));
2052    }
2053    bail!(message);
2054}
2055
2056fn emit_kind_from_ref(component_ref: &str) -> EmitKind {
2057    match component_ref {
2058        "emit.log" => EmitKind::Log,
2059        "emit.response" => EmitKind::Response,
2060        other => EmitKind::Other(other.to_string()),
2061    }
2062}
2063
2064fn emit_ref_from_kind(kind: &EmitKind) -> String {
2065    match kind {
2066        EmitKind::Log => "emit.log".to_string(),
2067        EmitKind::Response => "emit.response".to_string(),
2068        EmitKind::Other(other) => other.clone(),
2069    }
2070}
2071
2072/// Returns `true` when `input` looks like an Adaptive Card invocation
2073/// (contains `card_source` or `card_spec` at the top level).
2074fn is_card_invocation(input: &Value) -> bool {
2075    if let Value::Object(map) = input {
2076        return map.contains_key("card_source") || map.contains_key("card_spec");
2077    }
2078    false
2079}
2080
2081/// When the node config declares adaptive-card defaults (`default_card_asset`,
2082/// `default_card_inline`, or `default_source`) but the runtime invocation has
2083/// no `card_source`/`card_spec` yet, lift those defaults into the invocation.
2084/// This produces a schema-valid invocation envelope so the component does not
2085/// fall back to its generic "Welcome" placeholder.
2086///
2087/// Adaptive-card defaults can arrive in either of two places depending on how
2088/// the pack was compiled:
2089/// - top-level `call.config` (post `split_operation_payload`)
2090/// - nested `call.input.config` (when the node mapping kept the
2091///   `{component, config}` shape and `split_operation_payload` left it intact)
2092fn promote_card_config_to_invocation(input: &mut Value, config: &Value) {
2093    if is_card_invocation(input) {
2094        return;
2095    }
2096
2097    let cfg_map = card_defaults_source(input, config);
2098    let Some(cfg) = cfg_map else { return };
2099
2100    let default_asset = cfg
2101        .get("default_card_asset")
2102        .and_then(Value::as_str)
2103        .map(str::trim)
2104        .filter(|value| !value.is_empty())
2105        .map(str::to_string);
2106    let default_inline = cfg
2107        .get("default_card_inline")
2108        .filter(|value| value.is_object() || value.is_array())
2109        .cloned();
2110    let default_source = cfg
2111        .get("default_source")
2112        .and_then(Value::as_str)
2113        .map(str::trim)
2114        .filter(|value| !value.is_empty())
2115        .map(str::to_lowercase);
2116
2117    if default_asset.is_none() && default_inline.is_none() && default_source.is_none() {
2118        return;
2119    }
2120
2121    let card_source = default_source.unwrap_or_else(|| {
2122        if default_inline.is_some() {
2123            "inline".to_string()
2124        } else {
2125            "asset".to_string()
2126        }
2127    });
2128
2129    let mut card_spec = serde_json::Map::new();
2130    match card_source.as_str() {
2131        "asset" => {
2132            if let Some(path) = default_asset {
2133                card_spec.insert("asset_path".into(), Value::String(path));
2134            }
2135        }
2136        "inline" => {
2137            if let Some(inline) = default_inline {
2138                card_spec.insert("inline_json".into(), inline);
2139            }
2140        }
2141        _ => {}
2142    }
2143
2144    if !matches!(input, Value::Object(_)) {
2145        *input = Value::Object(serde_json::Map::new());
2146    }
2147    if let Value::Object(map) = input {
2148        map.insert("card_source".into(), Value::String(card_source));
2149        map.insert("card_spec".into(), Value::Object(card_spec));
2150    }
2151}
2152
2153/// Locate the adaptive-card defaults config object, preferring the top-level
2154/// `call.config` when present, then falling back to a nested `input.config`
2155/// (the shape produced when `split_operation_payload` leaves the mapping
2156/// intact).
2157fn card_defaults_source<'a>(
2158    input: &'a Value,
2159    config: &'a Value,
2160) -> Option<&'a serde_json::Map<String, Value>> {
2161    if let Value::Object(map) = config {
2162        return Some(map);
2163    }
2164    if let Value::Object(map) = input
2165        && let Some(Value::Object(nested)) = map.get("config")
2166    {
2167        return Some(nested);
2168    }
2169    None
2170}
2171
2172fn inject_card_locale(payload: &mut Value, entry: &Value) {
2173    if !is_card_invocation(payload) {
2174        return;
2175    }
2176    let Value::Object(map) = payload else { return };
2177    if map.contains_key("locale") {
2178        return;
2179    }
2180    let locale = entry
2181        .pointer("/input/metadata/locale")
2182        .or_else(|| entry.pointer("/metadata/locale"))
2183        .and_then(Value::as_str);
2184    if let Some(locale) = locale {
2185        map.insert("locale".into(), Value::String(locale.to_string()));
2186    }
2187}
2188
2189/// Pre-resolve `card_source: "asset"` entries by reading the referenced JSON
2190/// file from the pack's assets directory and converting to
2191/// `card_source: "inline"` with `inline_json` populated.
2192///
2193/// This handles both top-level card fields and the nested `call.payload`
2194/// structure emitted by cards2pack.
2195fn resolve_card_assets(input: &mut Value, pack: &crate::pack::PackRuntime) {
2196    resolve_card_spec_asset(input, pack);
2197
2198    // Also resolve inside `call.payload` (cards2pack duplicates the card
2199    // invocation there).
2200    if let Value::Object(map) = input
2201        && let Some(Value::Object(call)) = map.get_mut("call")
2202        && let Some(payload) = call.get_mut("payload")
2203    {
2204        resolve_card_spec_asset(payload, pack);
2205    }
2206}
2207
2208/// Resolve a single card_spec asset_path → inline_json.
2209fn resolve_card_spec_asset(value: &mut Value, pack: &crate::pack::PackRuntime) {
2210    let Value::Object(map) = value else { return };
2211
2212    let is_asset = map
2213        .get("card_source")
2214        .and_then(Value::as_str)
2215        .map(|s| s.eq_ignore_ascii_case("asset"))
2216        .unwrap_or(false);
2217    if !is_asset {
2218        return;
2219    }
2220
2221    let asset_path = map
2222        .get("card_spec")
2223        .and_then(|spec| spec.get("asset_path"))
2224        .and_then(Value::as_str)
2225        .map(str::to_string);
2226
2227    let Some(asset_path) = asset_path else { return };
2228
2229    match pack.read_asset(&asset_path) {
2230        Ok(bytes) => {
2231            let card_json: Value = match serde_json::from_slice(&bytes) {
2232                Ok(v) => v,
2233                Err(err) => {
2234                    tracing::warn!(
2235                        asset_path,
2236                        %err,
2237                        "failed to parse card asset as JSON; leaving as asset reference"
2238                    );
2239                    return;
2240                }
2241            };
2242            tracing::debug!(asset_path, "pre-resolved card asset to inline_json");
2243            map.insert("card_source".into(), Value::String("inline".into()));
2244            if let Some(Value::Object(spec)) = map.get_mut("card_spec") {
2245                spec.insert("inline_json".into(), card_json);
2246                spec.remove("asset_path");
2247            }
2248        }
2249        Err(err) => {
2250            tracing::warn!(
2251                asset_path,
2252                %err,
2253                "card asset not found in pack; leaving as asset reference"
2254            );
2255        }
2256    }
2257
2258    // Pre-resolve i18n bundle: the WASM component cannot read pack assets
2259    // directly (no host resolver registered), so inline the i18n JSON into
2260    // the invocation under `card_spec.i18n_inline`. Defense-in-depth: when
2261    // the card omits an explicit `i18n_bundle_path` we still try the
2262    // conventional `assets/i18n/` location so cards that rely on
2263    // auto-generated i18n keys (e.g. cards2pack output) keep working.
2264    let configured_bundle_path = map
2265        .get("card_spec")
2266        .and_then(|spec| spec.get("i18n_bundle_path"))
2267        .and_then(Value::as_str)
2268        .map(|s| s.trim().trim_end_matches('/').to_string())
2269        .filter(|s| !s.is_empty());
2270
2271    let bundle_path = configured_bundle_path
2272        .clone()
2273        .unwrap_or_else(|| "assets/i18n".to_string());
2274
2275    let i18n_entries = load_i18n_bundle_entries(&bundle_path, |path| pack.read_asset(path));
2276
2277    if !i18n_entries.is_empty() {
2278        let locale_keys: Vec<_> = i18n_entries.keys().cloned().collect();
2279        if let Some(Value::Object(spec)) = map.get_mut("card_spec") {
2280            spec.insert("i18n_inline".into(), Value::Object(i18n_entries));
2281            if configured_bundle_path.is_some() {
2282                tracing::info!(%bundle_path, ?locale_keys, "pre-resolved i18n bundle into card_spec.i18n_inline");
2283            } else {
2284                tracing::info!(%bundle_path, ?locale_keys, "auto-discovered i18n bundle and inlined into card_spec.i18n_inline");
2285            }
2286        }
2287    }
2288}
2289
2290fn load_i18n_bundle_entries<F>(bundle_path: &str, mut read_asset: F) -> JsonMap<String, Value>
2291where
2292    F: FnMut(&str) -> Result<Vec<u8>>,
2293{
2294    let mut i18n_entries = JsonMap::new();
2295
2296    if bundle_path.ends_with(".json") {
2297        if let Ok(bytes) = read_asset(bundle_path)
2298            && let Ok(Value::Object(entries)) = serde_json::from_slice::<Value>(&bytes)
2299        {
2300            i18n_entries.insert("en".to_string(), Value::Object(entries));
2301        }
2302        return i18n_entries;
2303    }
2304
2305    let manifest_path = format!("{bundle_path}/_manifest.json");
2306    let locale_codes: Vec<String> = read_asset(&manifest_path)
2307        .ok()
2308        .and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
2309        .and_then(|value| {
2310            let locales = value
2311                .get("locales")
2312                .and_then(Value::as_array)
2313                .cloned()
2314                .or_else(|| value.as_array().cloned());
2315            locales.map(|items| {
2316                items
2317                    .iter()
2318                    .filter_map(Value::as_str)
2319                    .map(String::from)
2320                    .collect()
2321            })
2322        })
2323        .unwrap_or_default();
2324
2325    tracing::info!(%bundle_path, ?locale_codes, "i18n manifest discovered locales");
2326
2327    for locale in &locale_codes {
2328        let candidate = format!("{bundle_path}/{locale}.json");
2329        if let Ok(bytes) = read_asset(&candidate)
2330            && let Ok(Value::Object(entries)) = serde_json::from_slice::<Value>(&bytes)
2331        {
2332            i18n_entries.insert(locale.clone(), Value::Object(entries));
2333        }
2334    }
2335    if !i18n_entries.contains_key("en") {
2336        let en_path = format!("{bundle_path}/en.json");
2337        if let Ok(bytes) = read_asset(&en_path)
2338            && let Ok(Value::Object(entries)) = serde_json::from_slice::<Value>(&bytes)
2339        {
2340            i18n_entries.insert("en".to_string(), Value::Object(entries));
2341        }
2342    }
2343
2344    i18n_entries
2345}
2346
2347/// Outcome of `evaluate_custom_routing` for a node's `Routing::Custom` array.
2348///
2349/// `Next` advances the flow to the named target. `End` terminates the run.
2350/// `Wait` pauses the run at the current node so the next inbound activity
2351/// resumes here and re-evaluates the routing with the new context — this is
2352/// what allows messaging flows (welcome → ... → confirm) to behave like a
2353/// live conversation instead of restarting at the entry point on every
2354/// click.
2355#[derive(Debug)]
2356pub(crate) enum CustomRoutingDecision {
2357    Next(NodeId),
2358    End,
2359    Wait,
2360}
2361
2362/// Evaluate a node's `Routing::Custom` array against the current execution
2363/// context.
2364///
2365/// Parses `Routing::Custom(Value)` as an array of `{condition, to}` objects.
2366/// Conditions are simple equality expressions like `response.action == "about"`.
2367/// Falls back to the first route without a condition (default route).
2368///
2369/// The evaluation context includes:
2370/// - All fields from the node output payload (top-level)
2371/// - `entry` / `in` — the original flow entry (incoming message)
2372/// - `response` — synthesized from entry metadata for convenient condition checks
2373///   (e.g. `response.action` maps to `metadata.action` from the incoming envelope)
2374fn evaluate_custom_routing(
2375    raw: &Value,
2376    output: &NodeOutput,
2377    state: &ExecutionState,
2378    flow_ir: &HostFlow,
2379    node_id: &NodeId,
2380) -> CustomRoutingDecision {
2381    let routes = match raw.as_array() {
2382        Some(arr) => arr,
2383        None => {
2384            tracing::warn!(
2385                flow_id = %flow_ir.id,
2386                node_id = %node_id,
2387                "custom routing is not an array; terminating"
2388            );
2389            return CustomRoutingDecision::End;
2390        }
2391    };
2392
2393    // Build a rich context for condition evaluation:
2394    // Start with output payload, then overlay entry and synthesised "response".
2395    let ctx = build_routing_context(output, state);
2396
2397    let mut has_condition = false;
2398    for route in routes {
2399        let condition = route.get("condition").and_then(|v| v.as_str());
2400        let to = route.get("to").and_then(|v| v.as_str());
2401
2402        if let Some(cond) = condition {
2403            has_condition = true;
2404            if evaluate_simple_condition(cond, &ctx)
2405                && let Some(target) = to
2406                && let Ok(nid) = NodeId::new(target)
2407            {
2408                tracing::debug!(
2409                    flow_id = %flow_ir.id,
2410                    node_id = %node_id,
2411                    condition = cond,
2412                    target = target,
2413                    "conditional route matched"
2414                );
2415                return CustomRoutingDecision::Next(nid);
2416            }
2417        } else if let Some(target) = to
2418            && let Ok(nid) = NodeId::new(target)
2419        {
2420            tracing::debug!(
2421                flow_id = %flow_ir.id,
2422                node_id = %node_id,
2423                target = target,
2424                "default route taken"
2425            );
2426            return CustomRoutingDecision::Next(nid);
2427        }
2428    }
2429
2430    // Fall-through. When the routing array contained at least one
2431    // conditional entry, treat the unmatched fall-through as a pause: the
2432    // user's next submission should be re-evaluated against this same
2433    // node's routing rather than restarting the flow from the entry point.
2434    // Routing arrays with no conditions at all (pure unconditional `out`
2435    // terminators) remain true ends.
2436    if has_condition {
2437        tracing::debug!(
2438            flow_id = %flow_ir.id,
2439            node_id = %node_id,
2440            "no conditional route matched; pausing run at current node for resume"
2441        );
2442        CustomRoutingDecision::Wait
2443    } else {
2444        tracing::warn!(
2445            flow_id = %flow_ir.id,
2446            node_id = %node_id,
2447            "no route matched and no conditions present; terminating"
2448        );
2449        CustomRoutingDecision::End
2450    }
2451}
2452
2453/// Evaluate a simple condition expression like `response.action == "about"`.
2454///
2455/// Supports dotted path lookups against a JSON value context.
2456/// Format: `<path> == "<value>"` or `<path> != "<value>"`
2457fn evaluate_simple_condition(condition: &str, ctx: &Value) -> bool {
2458    // Parse: `path == "value"` or `path != "value"`
2459    let (path, expected, negate) = if let Some(idx) = condition.find("==") {
2460        let path = condition[..idx].trim();
2461        let val = condition[idx + 2..].trim().trim_matches('"');
2462        (path, val, false)
2463    } else if let Some(idx) = condition.find("!=") {
2464        let path = condition[..idx].trim();
2465        let val = condition[idx + 2..].trim().trim_matches('"');
2466        (path, val, true)
2467    } else {
2468        return false;
2469    };
2470
2471    // Resolve dotted path against context (case-insensitive comparison)
2472    let actual = resolve_dotted_path(ctx, path);
2473    let matches = actual
2474        .as_deref()
2475        .is_some_and(|a| a.eq_ignore_ascii_case(expected));
2476    if negate { !matches } else { matches }
2477}
2478
2479/// Resolve a dotted path like `response.action` against a JSON value.
2480fn resolve_dotted_path(value: &Value, path: &str) -> Option<String> {
2481    let parts: Vec<&str> = path.split('.').collect();
2482    let mut current = value;
2483    for part in &parts {
2484        current = current.get(part)?;
2485    }
2486    match current {
2487        Value::String(s) => Some(s.clone()),
2488        Value::Bool(b) => Some(b.to_string()),
2489        Value::Number(n) => Some(n.to_string()),
2490        _ => Some(current.to_string()),
2491    }
2492}
2493
2494/// Build a context object for routing condition evaluation.
2495///
2496/// The context merges the node output with the flow entry so that conditions
2497/// can reference both component results and incoming message data.
2498///
2499/// Layout:
2500/// ```text
2501/// {
2502///   ...output.payload...,     // top-level fields from component output
2503///   "entry": <flow entry>,
2504///   "in":    <flow entry>,    // alias
2505///   "response": {             // synthesised from envelope metadata
2506///     <key>: <value>,         // e.g. "action": "about"
2507///     ...
2508///   }
2509/// }
2510/// ```
2511fn build_routing_context(output: &NodeOutput, state: &ExecutionState) -> Value {
2512    let mut ctx = match &output.payload {
2513        Value::Object(map) => map.clone(),
2514        _ => JsonMap::new(),
2515    };
2516
2517    let entry = &state.entry;
2518    ctx.insert("entry".into(), entry.clone());
2519    ctx.insert("in".into(), entry.clone());
2520
2521    // Synthesise "response" from the envelope metadata.
2522    // greentic-start demo path: entry.input.metadata.*
2523    // greentic-runner direct path: entry.metadata.*
2524    let metadata = entry
2525        .pointer("/input/metadata")
2526        .or_else(|| entry.pointer("/metadata"));
2527
2528    let mut response = JsonMap::new();
2529    if let Some(Value::Object(meta)) = metadata {
2530        for (k, v) in meta {
2531            // Flatten string values; stringify others
2532            match v {
2533                Value::String(s) => {
2534                    response.insert(k.clone(), Value::String(s.clone()));
2535                }
2536                other => {
2537                    response.insert(k.clone(), other.clone());
2538                }
2539            }
2540        }
2541    }
2542    // Also pull text from the envelope for convenience
2543    if let Some(text) = entry
2544        .pointer("/input/text")
2545        .or_else(|| entry.pointer("/text"))
2546        .filter(|t| !t.is_null())
2547    {
2548        response.insert("text".into(), text.clone());
2549    }
2550    ctx.insert("response".into(), Value::Object(response));
2551
2552    Value::Object(ctx)
2553}
2554
2555#[cfg(test)]
2556mod tests {
2557    use super::*;
2558    use crate::validate::{ValidationConfig, ValidationMode};
2559    use greentic_types::{
2560        Flow, FlowComponentRef, FlowId, FlowKind, InputMapping, Node, NodeId, OutputMapping,
2561        Routing, TelemetryHints,
2562    };
2563    use serde_json::json;
2564    use std::collections::{BTreeMap, HashMap as StdHashMap};
2565    use std::str::FromStr;
2566    use std::sync::Mutex;
2567    use tokio::runtime::Runtime;
2568
2569    fn minimal_engine() -> FlowEngine {
2570        FlowEngine {
2571            packs: Vec::new(),
2572            flows: Vec::new(),
2573            flow_sources: HashMap::new(),
2574            flow_cache: RwLock::new(HashMap::new()),
2575            default_env: "local".to_string(),
2576            validation: ValidationConfig {
2577                mode: ValidationMode::Off,
2578            },
2579            cross_pack_resolver: None,
2580        }
2581    }
2582
2583    #[test]
2584    fn templating_renders_with_partials_and_data() {
2585        let mut state = ExecutionState::new(json!({ "city": "London" }));
2586        state.nodes.insert(
2587            "forecast".to_string(),
2588            NodeOutput::new(json!({ "temp": "20C" })),
2589        );
2590
2591        // templating context includes node outputs for runner-side payload rendering.
2592        let ctx = state.context();
2593        assert_eq!(ctx["nodes"]["forecast"]["payload"]["temp"], json!("20C"));
2594    }
2595
2596    #[test]
2597    fn finalize_wraps_emitted_payloads() {
2598        let mut state = ExecutionState::new(json!({}));
2599        state.push_egress(json!({ "text": "first" }));
2600        state.push_egress(json!({ "text": "second" }));
2601        let result = state.finalize_with(Some(json!({ "text": "final" })));
2602        assert_eq!(
2603            result,
2604            json!([
2605                { "text": "first" },
2606                { "text": "second" },
2607                { "text": "final" }
2608            ])
2609        );
2610    }
2611
2612    #[test]
2613    fn finalize_flattens_final_array() {
2614        let mut state = ExecutionState::new(json!({}));
2615        state.push_egress(json!({ "text": "only" }));
2616        let result = state.finalize_with(Some(json!([
2617            { "text": "extra-1" },
2618            { "text": "extra-2" }
2619        ])));
2620        assert_eq!(
2621            result,
2622            json!([
2623                { "text": "only" },
2624                { "text": "extra-1" },
2625                { "text": "extra-2" }
2626            ])
2627        );
2628    }
2629
2630    #[test]
2631    fn inject_card_locale_uses_entry_metadata_without_overwriting_payload() {
2632        let mut payload = json!({
2633            "card_source": "inline",
2634            "card_spec": { "title": "Hello" }
2635        });
2636        inject_card_locale(
2637            &mut payload,
2638            &json!({"input": {"metadata": {"locale": "nl-NL"}}}),
2639        );
2640        assert_eq!(payload["locale"], json!("nl-NL"));
2641
2642        let mut existing = json!({
2643            "card_source": "inline",
2644            "card_spec": { "title": "Hello" },
2645            "locale": "en-GB"
2646        });
2647        inject_card_locale(&mut existing, &json!({"metadata": {"locale": "nl-NL"}}));
2648        assert_eq!(existing["locale"], json!("en-GB"));
2649    }
2650
2651    #[test]
2652    fn load_i18n_bundle_entries_reads_manifest_and_falls_back_to_en() {
2653        let assets = StdHashMap::from([
2654            (
2655                "cards/i18n/_manifest.json".to_string(),
2656                br#"{"locales":["de"]}"#.to_vec(),
2657            ),
2658            (
2659                "cards/i18n/de.json".to_string(),
2660                br#"{"title":"Hallo"}"#.to_vec(),
2661            ),
2662            (
2663                "cards/i18n/en.json".to_string(),
2664                br#"{"title":"Hello"}"#.to_vec(),
2665            ),
2666        ]);
2667
2668        let entries = load_i18n_bundle_entries("cards/i18n", |path| {
2669            assets
2670                .get(path)
2671                .cloned()
2672                .with_context(|| format!("missing asset {path}"))
2673        });
2674
2675        assert_eq!(entries["de"]["title"], json!("Hallo"));
2676        assert_eq!(entries["en"]["title"], json!("Hello"));
2677    }
2678
2679    #[test]
2680    fn load_i18n_bundle_entries_reads_single_file_bundle() {
2681        let entries = load_i18n_bundle_entries("cards/i18n.json", |path| {
2682            if path == "cards/i18n.json" {
2683                Ok(br#"{"title":"Hello"}"#.to_vec())
2684            } else {
2685                bail!("unexpected asset {path}");
2686            }
2687        });
2688
2689        assert_eq!(entries["en"]["title"], json!("Hello"));
2690    }
2691
2692    struct TestCrossPackResolver;
2693
2694    impl CrossPackResolver for TestCrossPackResolver {
2695        fn invoke(
2696            &self,
2697            provider_id: &str,
2698            provider_type: Option<&str>,
2699            op: &str,
2700            input: &[u8],
2701            tenant: &str,
2702            team: Option<&str>,
2703        ) -> Result<Value> {
2704            Ok(json!({
2705                "provider_id": provider_id,
2706                "provider_type": provider_type,
2707                "op": op,
2708                "tenant": tenant,
2709                "team": team,
2710                "input": serde_json::from_slice::<Value>(input)?,
2711            }))
2712        }
2713    }
2714
2715    #[test]
2716    fn cross_pack_resolver_returns_node_output_when_present() {
2717        let mut engine = minimal_engine();
2718        engine.set_cross_pack_resolver(Arc::new(TestCrossPackResolver));
2719
2720        let output = engine
2721            .try_invoke_cross_pack_resolver(
2722                Some("mail"),
2723                Some("messaging"),
2724                "send",
2725                br#"{"subject":"hello"}"#,
2726                "demo",
2727            )
2728            .expect("resolver invocation")
2729            .expect("resolver output");
2730
2731        assert_eq!(
2732            output.payload,
2733            json!({
2734                "provider_id": "mail",
2735                "provider_type": "messaging",
2736                "op": "send",
2737                "tenant": "demo",
2738                "team": null,
2739                "input": { "subject": "hello" },
2740            })
2741        );
2742    }
2743
2744    #[test]
2745    fn parse_component_control_ignores_plain_payload() {
2746        let payload = json!({
2747            "flow": "not-a-control-field",
2748            "node": "n1"
2749        });
2750        let control = parse_component_control(&payload).expect("parse control");
2751        assert!(control.is_none());
2752    }
2753
2754    #[test]
2755    fn parse_component_control_parses_jump_marker() {
2756        let payload = json!({
2757            "greentic_control": {
2758                "action": "jump",
2759                "v": 1,
2760                "flow": "flow.b",
2761                "node": "node-2",
2762                "payload": { "message": "hi" },
2763                "hints": { "k": "v" },
2764                "max_redirects": 2,
2765                "reason": "handoff"
2766            }
2767        });
2768        let control = parse_component_control(&payload)
2769            .expect("parse control")
2770            .expect("missing control");
2771        match control {
2772            NodeControl::Jump(jump) => {
2773                assert_eq!(jump.flow, "flow.b");
2774                assert_eq!(jump.node.as_deref(), Some("node-2"));
2775                assert_eq!(jump.payload, json!({ "message": "hi" }));
2776                assert_eq!(jump.hints, json!({ "k": "v" }));
2777                assert_eq!(jump.max_redirects, Some(2));
2778                assert_eq!(jump.reason.as_deref(), Some("handoff"));
2779            }
2780            other => panic!("expected jump control, got {other:?}"),
2781        }
2782    }
2783
2784    #[test]
2785    fn parse_component_control_rejects_invalid_marker() {
2786        let payload = json!({
2787            "greentic_control": "bad-shape"
2788        });
2789        let err = parse_component_control(&payload).expect_err("expected invalid marker error");
2790        assert!(err.to_string().contains("greentic_control"));
2791    }
2792
2793    #[test]
2794    fn missing_operation_reports_node_and_component() {
2795        let engine = minimal_engine();
2796        let rt = Runtime::new().unwrap();
2797        let retry_config = RetryConfig {
2798            max_attempts: 1,
2799            base_delay_ms: 1,
2800        };
2801        let ctx = FlowContext {
2802            tenant: "tenant",
2803            pack_id: "test-pack",
2804            flow_id: "flow",
2805            node_id: Some("missing-op"),
2806            tool: None,
2807            action: None,
2808            session_id: None,
2809            provider_id: None,
2810            retry_config,
2811            attempt: 1,
2812            observer: None,
2813            mocks: None,
2814        };
2815        let node = HostNode {
2816            kind: NodeKind::Exec {
2817                target_component: "qa.process".into(),
2818            },
2819            component: "component.exec".into(),
2820            component_id: "component.exec".into(),
2821            operation_name: None,
2822            operation_in_mapping: None,
2823            payload_expr: Value::Null,
2824            routing: Routing::End,
2825        };
2826        let _state = ExecutionState::new(Value::Null);
2827        let payload = json!({ "component": "qa.process" });
2828        let event = NodeEvent {
2829            context: &ctx,
2830            node_id: "missing-op",
2831            node: &node,
2832            payload: &payload,
2833        };
2834        let err = rt
2835            .block_on(engine.execute_component_exec(
2836                &ctx,
2837                "missing-op",
2838                &node,
2839                payload.clone(),
2840                &event,
2841                ComponentOverrides {
2842                    component: None,
2843                    operation: None,
2844                },
2845            ))
2846            .unwrap_err();
2847        let message = err.to_string();
2848        assert!(
2849            message.contains("missing operation for node `missing-op`"),
2850            "unexpected message: {message}"
2851        );
2852        assert!(
2853            message.contains("(component `component.exec`)"),
2854            "unexpected message: {message}"
2855        );
2856    }
2857
2858    #[test]
2859    fn missing_operation_mentions_mapping_hint() {
2860        let engine = minimal_engine();
2861        let rt = Runtime::new().unwrap();
2862        let retry_config = RetryConfig {
2863            max_attempts: 1,
2864            base_delay_ms: 1,
2865        };
2866        let ctx = FlowContext {
2867            tenant: "tenant",
2868            pack_id: "test-pack",
2869            flow_id: "flow",
2870            node_id: Some("missing-op-hint"),
2871            tool: None,
2872            action: None,
2873            session_id: None,
2874            provider_id: None,
2875            retry_config,
2876            attempt: 1,
2877            observer: None,
2878            mocks: None,
2879        };
2880        let node = HostNode {
2881            kind: NodeKind::Exec {
2882                target_component: "qa.process".into(),
2883            },
2884            component: "component.exec".into(),
2885            component_id: "component.exec".into(),
2886            operation_name: None,
2887            operation_in_mapping: Some("render".into()),
2888            payload_expr: Value::Null,
2889            routing: Routing::End,
2890        };
2891        let _state = ExecutionState::new(Value::Null);
2892        let payload = json!({ "component": "qa.process" });
2893        let event = NodeEvent {
2894            context: &ctx,
2895            node_id: "missing-op-hint",
2896            node: &node,
2897            payload: &payload,
2898        };
2899        let err = rt
2900            .block_on(engine.execute_component_exec(
2901                &ctx,
2902                "missing-op-hint",
2903                &node,
2904                payload.clone(),
2905                &event,
2906                ComponentOverrides {
2907                    component: None,
2908                    operation: None,
2909                },
2910            ))
2911            .unwrap_err();
2912        let message = err.to_string();
2913        assert!(
2914            message.contains("missing operation for node `missing-op-hint`"),
2915            "unexpected message: {message}"
2916        );
2917        assert!(
2918            message.contains("Found operation in input.mapping (`render`)"),
2919            "unexpected message: {message}"
2920        );
2921    }
2922
2923    struct CountingObserver {
2924        starts: Mutex<Vec<String>>,
2925        ends: Mutex<Vec<Value>>,
2926    }
2927
2928    impl CountingObserver {
2929        fn new() -> Self {
2930            Self {
2931                starts: Mutex::new(Vec::new()),
2932                ends: Mutex::new(Vec::new()),
2933            }
2934        }
2935    }
2936
2937    impl ExecutionObserver for CountingObserver {
2938        fn on_node_start(&self, event: &NodeEvent<'_>) {
2939            self.starts.lock().unwrap().push(event.node_id.to_string());
2940        }
2941
2942        fn on_node_end(&self, _event: &NodeEvent<'_>, output: &Value) {
2943            self.ends.lock().unwrap().push(output.clone());
2944        }
2945
2946        fn on_node_error(&self, _event: &NodeEvent<'_>, _error: &dyn StdError) {}
2947    }
2948
2949    #[test]
2950    fn emits_end_event_for_successful_node() {
2951        let node_id = NodeId::from_str("emit").unwrap();
2952        let node = Node {
2953            id: node_id.clone(),
2954            component: FlowComponentRef {
2955                id: "emit.log".parse().unwrap(),
2956                pack_alias: None,
2957                operation: None,
2958            },
2959            input: InputMapping {
2960                mapping: json!({ "message": "logged" }),
2961            },
2962            output: OutputMapping {
2963                mapping: Value::Null,
2964            },
2965            err_map: None,
2966            routing: Routing::End,
2967            telemetry: TelemetryHints::default(),
2968        };
2969        let mut nodes = indexmap::IndexMap::default();
2970        nodes.insert(node_id.clone(), node);
2971        let flow = Flow {
2972            schema_version: "1.0".into(),
2973            id: FlowId::from_str("emit.flow").unwrap(),
2974            kind: FlowKind::Messaging,
2975            entrypoints: BTreeMap::from([(
2976                "default".to_string(),
2977                Value::String(node_id.to_string()),
2978            )]),
2979            nodes,
2980            metadata: Default::default(),
2981        };
2982        let host_flow = HostFlow::from(flow);
2983
2984        let engine = FlowEngine {
2985            packs: Vec::new(),
2986            flows: Vec::new(),
2987            flow_sources: HashMap::new(),
2988            flow_cache: RwLock::new(HashMap::from([(
2989                FlowKey {
2990                    pack_id: "test-pack".to_string(),
2991                    flow_id: "emit.flow".to_string(),
2992                },
2993                host_flow,
2994            )])),
2995            default_env: "local".to_string(),
2996            validation: ValidationConfig {
2997                mode: ValidationMode::Off,
2998            },
2999            cross_pack_resolver: None,
3000        };
3001        let observer = CountingObserver::new();
3002        let ctx = FlowContext {
3003            tenant: "demo",
3004            pack_id: "test-pack",
3005            flow_id: "emit.flow",
3006            node_id: None,
3007            tool: None,
3008            action: None,
3009            session_id: None,
3010            provider_id: None,
3011            retry_config: RetryConfig {
3012                max_attempts: 1,
3013                base_delay_ms: 1,
3014            },
3015            attempt: 1,
3016            observer: Some(&observer),
3017            mocks: None,
3018        };
3019
3020        let rt = Runtime::new().unwrap();
3021        let result = rt.block_on(engine.execute(ctx, Value::Null)).unwrap();
3022        assert!(matches!(result.status, FlowStatus::Completed));
3023
3024        let starts = observer.starts.lock().unwrap();
3025        let ends = observer.ends.lock().unwrap();
3026        assert_eq!(starts.len(), 1);
3027        assert_eq!(ends.len(), 1);
3028        assert_eq!(ends[0], json!({ "message": "logged" }));
3029    }
3030
3031    fn host_flow_for_test(
3032        flow_id: &str,
3033        node_ids: &[&str],
3034        default_start: Option<&str>,
3035    ) -> HostFlow {
3036        let mut nodes = indexmap::IndexMap::default();
3037        for node_id in node_ids {
3038            let id = NodeId::from_str(node_id).unwrap();
3039            let node = Node {
3040                id: id.clone(),
3041                component: FlowComponentRef {
3042                    id: "emit.log".parse().unwrap(),
3043                    pack_alias: None,
3044                    operation: None,
3045                },
3046                input: InputMapping {
3047                    mapping: json!({ "message": node_id }),
3048                },
3049                output: OutputMapping {
3050                    mapping: Value::Null,
3051                },
3052                err_map: None,
3053                routing: Routing::End,
3054                telemetry: TelemetryHints::default(),
3055            };
3056            nodes.insert(id, node);
3057        }
3058        let mut entrypoints = BTreeMap::new();
3059        if let Some(start) = default_start {
3060            entrypoints.insert("default".to_string(), Value::String(start.to_string()));
3061        }
3062        HostFlow::from(Flow {
3063            schema_version: "1.0".into(),
3064            id: FlowId::from_str(flow_id).unwrap(),
3065            kind: FlowKind::Messaging,
3066            entrypoints,
3067            nodes,
3068            metadata: Default::default(),
3069        })
3070    }
3071
3072    fn jump_test_engine() -> FlowEngine {
3073        let target_flow = host_flow_for_test("flow.target", &["node-a", "node-b"], None);
3074        FlowEngine {
3075            packs: Vec::new(),
3076            flows: Vec::new(),
3077            flow_sources: HashMap::new(),
3078            flow_cache: RwLock::new(HashMap::from([(
3079                FlowKey {
3080                    pack_id: "test-pack".to_string(),
3081                    flow_id: "flow.target".to_string(),
3082                },
3083                target_flow,
3084            )])),
3085            default_env: "local".to_string(),
3086            validation: ValidationConfig {
3087                mode: ValidationMode::Off,
3088            },
3089            cross_pack_resolver: None,
3090        }
3091    }
3092
3093    fn jump_ctx<'a>(flow_id: &'a str) -> FlowContext<'a> {
3094        FlowContext {
3095            tenant: "demo",
3096            pack_id: "test-pack",
3097            flow_id,
3098            node_id: None,
3099            tool: None,
3100            action: None,
3101            session_id: None,
3102            provider_id: None,
3103            retry_config: RetryConfig {
3104                max_attempts: 1,
3105                base_delay_ms: 1,
3106            },
3107            attempt: 1,
3108            observer: None,
3109            mocks: None,
3110        }
3111    }
3112
3113    #[test]
3114    fn apply_jump_unknown_flow_errors() {
3115        let engine = minimal_engine();
3116        let mut state = ExecutionState::new(Value::Null);
3117        let rt = Runtime::new().unwrap();
3118        let err = rt
3119            .block_on(engine.apply_jump(
3120                &jump_ctx("flow.source"),
3121                &mut state,
3122                JumpControl {
3123                    flow: "flow.missing".into(),
3124                    node: None,
3125                    payload: json!({ "ok": true }),
3126                    hints: Value::Null,
3127                    max_redirects: None,
3128                    reason: None,
3129                },
3130            ))
3131            .unwrap_err();
3132        assert!(
3133            err.to_string().contains("unknown_flow"),
3134            "unexpected error: {err}"
3135        );
3136    }
3137
3138    #[test]
3139    fn apply_jump_unknown_node_errors() {
3140        let engine = jump_test_engine();
3141        let mut state = ExecutionState::new(Value::Null);
3142        let rt = Runtime::new().unwrap();
3143        let err = rt
3144            .block_on(engine.apply_jump(
3145                &jump_ctx("flow.source"),
3146                &mut state,
3147                JumpControl {
3148                    flow: "flow.target".into(),
3149                    node: Some("node-missing".into()),
3150                    payload: json!({ "ok": true }),
3151                    hints: Value::Null,
3152                    max_redirects: None,
3153                    reason: None,
3154                },
3155            ))
3156            .unwrap_err();
3157        assert!(
3158            err.to_string().contains("unknown_node"),
3159            "unexpected error: {err}"
3160        );
3161    }
3162
3163    #[test]
3164    fn apply_jump_uses_default_start_fallback() {
3165        let engine = jump_test_engine();
3166        let mut state = ExecutionState::new(Value::Null);
3167        let rt = Runtime::new().unwrap();
3168        let target = rt
3169            .block_on(engine.apply_jump(
3170                &jump_ctx("flow.source"),
3171                &mut state,
3172                JumpControl {
3173                    flow: "flow.target".into(),
3174                    node: None,
3175                    payload: json!({ "k": "v" }),
3176                    hints: Value::Null,
3177                    max_redirects: None,
3178                    reason: None,
3179                },
3180            ))
3181            .expect("jump target");
3182        assert_eq!(target.flow_id, "flow.target");
3183        assert_eq!(target.node_id.as_str(), "node-a");
3184    }
3185
3186    #[test]
3187    fn apply_jump_redirect_limit_enforced() {
3188        let engine = jump_test_engine();
3189        let mut state = ExecutionState::new(Value::Null);
3190        state.redirect_count = 3;
3191        let rt = Runtime::new().unwrap();
3192        let err = rt
3193            .block_on(engine.apply_jump(
3194                &jump_ctx("flow.source"),
3195                &mut state,
3196                JumpControl {
3197                    flow: "flow.target".into(),
3198                    node: None,
3199                    payload: json!({ "k": "v" }),
3200                    hints: Value::Null,
3201                    max_redirects: Some(3),
3202                    reason: None,
3203                },
3204            ))
3205            .unwrap_err();
3206        assert_eq!(err.to_string(), "redirect_limit");
3207    }
3208
3209    /// Regression: a `Routing::Custom` array containing at least one
3210    /// conditional entry must pause (return `Wait`) when no condition
3211    /// matches, instead of terminating. Concrete bug it guards against:
3212    /// every card click used to terminate the flow because the entry-card's
3213    /// routing array didn't enumerate every downstream action, so users got
3214    /// looped back to the entry on every interaction.
3215    #[test]
3216    fn evaluate_custom_routing_waits_when_conditional_falls_through() {
3217        let raw_routing = json!([
3218            { "condition": "response.action == \"go\"", "to": "next" },
3219            { "out": true }
3220        ]);
3221        let flow_ir = HostFlow {
3222            id: "flow.test".to_string(),
3223            start: None,
3224            nodes: IndexMap::new(),
3225        };
3226        let current_node = NodeId::from_str("current").unwrap();
3227        let output = NodeOutput::new(Value::Null);
3228
3229        // First case: empty action -> conditional does not match, must wait.
3230        let mut state_empty = ExecutionState::new(json!({ "metadata": { "action": "" } }));
3231        state_empty.entry = json!({ "metadata": { "action": "" } });
3232        let decision_empty =
3233            evaluate_custom_routing(&raw_routing, &output, &state_empty, &flow_ir, &current_node);
3234        assert!(
3235            matches!(decision_empty, CustomRoutingDecision::Wait),
3236            "expected Wait on conditional fall-through, got {decision_empty:?}"
3237        );
3238
3239        // Second case: action == "go" -> conditional matches, must advance.
3240        let mut state_go = ExecutionState::new(json!({ "metadata": { "action": "go" } }));
3241        state_go.entry = json!({ "metadata": { "action": "go" } });
3242        let decision_go =
3243            evaluate_custom_routing(&raw_routing, &output, &state_go, &flow_ir, &current_node);
3244        match decision_go {
3245            CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "next"),
3246            other => panic!("expected Next(\"next\"), got {other:?}"),
3247        }
3248    }
3249
3250    #[test]
3251    fn node_output_with_error_marks_ok_false_and_stashes_in_meta() {
3252        let err: Box<dyn std::error::Error + 'static> =
3253            Box::<dyn std::error::Error + 'static>::from("weatherapi returned 401 Unauthorized");
3254        let out = NodeOutput::with_error("call_weather", err.as_ref());
3255        assert!(!out.ok);
3256        assert_eq!(out.payload, Value::Null);
3257        assert_eq!(out.meta["error"]["kind"], "flow_node_failed");
3258        assert_eq!(out.meta["error"]["node_id"], "call_weather");
3259        assert_eq!(
3260            out.meta["error"]["message"],
3261            "weatherapi returned 401 Unauthorized"
3262        );
3263    }
3264
3265    #[test]
3266    fn lift_first_node_error_promotes_node_meta_to_output_metadata() {
3267        // Two nodes ran; the first failed, the second produced a default-
3268        // looking output (flow author wrote no error routing). The executor
3269        // must lift the first failure into output.metadata so the messaging
3270        // provider renders the error card without any flow-author changes.
3271        let mut nodes: HashMap<String, NodeOutput> = HashMap::new();
3272        let err: Box<dyn std::error::Error + 'static> =
3273            Box::<dyn std::error::Error + 'static>::from("weatherapi returned 401 Unauthorized");
3274        nodes.insert(
3275            "call_weather".to_string(),
3276            NodeOutput::with_error("call_weather", err.as_ref()),
3277        );
3278        nodes.insert(
3279            "render_current_card".to_string(),
3280            NodeOutput::new(json!({ "text": "message" })),
3281        );
3282
3283        let final_output = json!({ "text": "message" });
3284        let enriched = lift_first_node_error_from_nodes(final_output, &nodes);
3285        assert_eq!(
3286            enriched["metadata"]["error_kind"], "flow_node_failed",
3287            "first failing node's kind must be lifted"
3288        );
3289        assert_eq!(
3290            enriched["metadata"]["error_message"],
3291            "weatherapi returned 401 Unauthorized"
3292        );
3293        assert_eq!(enriched["metadata"]["node_id"], "call_weather");
3294        // Preserves the original payload bits so downstream renderers still
3295        // see what the flow produced.
3296        assert_eq!(enriched["text"], "message");
3297    }
3298
3299    #[test]
3300    fn lift_first_node_error_is_noop_when_all_nodes_ok() {
3301        let mut nodes: HashMap<String, NodeOutput> = HashMap::new();
3302        nodes.insert(
3303            "ok_node".to_string(),
3304            NodeOutput::new(json!({ "text": "all good" })),
3305        );
3306        let output = json!({ "text": "all good" });
3307        let lifted = lift_first_node_error_from_nodes(output.clone(), &nodes);
3308        assert_eq!(lifted, output);
3309    }
3310
3311    #[tokio::test]
3312    async fn execute_user_facing_flow_failure_returns_completed_with_error_envelope() {
3313        // Flow whose start node is missing — drive_flow will return Err on
3314        // node lookup. With session_id present, execute() must convert that
3315        // to a Completed FlowExecution carrying error_kind/error_message in
3316        // output.metadata so the chat user sees the error card.
3317        let flow_id_str = "broken.flow";
3318        let pack_id_str = "test-pack";
3319        let host_flow = host_flow_for_test(flow_id_str, &["only-node"], Some("does-not-exist"));
3320        let engine = FlowEngine {
3321            packs: Vec::new(),
3322            flows: Vec::new(),
3323            flow_sources: HashMap::new(),
3324            flow_cache: RwLock::new(HashMap::from([(
3325                FlowKey {
3326                    pack_id: pack_id_str.to_string(),
3327                    flow_id: flow_id_str.to_string(),
3328                },
3329                host_flow,
3330            )])),
3331            default_env: "local".to_string(),
3332            validation: ValidationConfig {
3333                mode: ValidationMode::Off,
3334            },
3335            cross_pack_resolver: None,
3336        };
3337        let ctx = FlowContext {
3338            tenant: "demo",
3339            pack_id: pack_id_str,
3340            flow_id: flow_id_str,
3341            node_id: None,
3342            tool: None,
3343            action: None,
3344            session_id: Some("conv-1"),
3345            provider_id: None,
3346            retry_config: RetryConfig {
3347                max_attempts: 1,
3348                base_delay_ms: 1,
3349            },
3350            attempt: 1,
3351            observer: None,
3352            mocks: None,
3353        };
3354        let result = engine
3355            .execute(ctx, Value::Null)
3356            .await
3357            .expect("must not propagate Err");
3358        assert!(matches!(result.status, FlowStatus::Completed));
3359        assert_eq!(
3360            result.output["metadata"]["error_kind"],
3361            "flow_execution_failed"
3362        );
3363        let msg = result.output["metadata"]["error_message"]
3364            .as_str()
3365            .unwrap_or("");
3366        assert!(!msg.is_empty(), "error_message must be populated");
3367        assert_eq!(result.output["metadata"]["flow_id"], "broken.flow");
3368    }
3369
3370    #[test]
3371    fn mcp_tool_error_recognises_generator_error_shape() {
3372        // greentic-mcp-generator's tool_error_with_status emits this exact
3373        // shape when the upstream HTTP call to weatherapi.com returns 401.
3374        let value = json!({
3375            "error": {
3376                "code": "tool_error",
3377                "message": "API request returned status 401",
3378                "status": 401
3379            }
3380        });
3381        let (code, message) = mcp_tool_error(&value).expect("must detect MCP error shape");
3382        assert_eq!(code, "tool_error");
3383        assert!(message.contains("API request returned status 401"));
3384        assert!(message.contains("(status 401)"));
3385    }
3386
3387    #[test]
3388    fn mcp_tool_error_skips_success_responses() {
3389        // A success response uses `result`, not `error`.
3390        let value = json!({ "result": { "current": { "temp_c": 19.0 } } });
3391        assert!(mcp_tool_error(&value).is_none());
3392    }
3393
3394    #[test]
3395    fn mcp_tool_error_skips_non_object_and_unrelated_shapes() {
3396        assert!(mcp_tool_error(&Value::Null).is_none());
3397        assert!(mcp_tool_error(&json!({"unrelated": true})).is_none());
3398        // `error` must be an object; a string isn't enough.
3399        assert!(mcp_tool_error(&json!({"error": "oops"})).is_none());
3400    }
3401
3402    #[tokio::test]
3403    async fn execute_non_user_facing_flow_failure_still_propagates() {
3404        // No session_id => internal job. Errors still propagate as Err so
3405        // operator alerting / metrics pipelines stay intact.
3406        let flow_id_str = "broken.flow";
3407        let pack_id_str = "test-pack";
3408        let host_flow = host_flow_for_test(flow_id_str, &["only-node"], Some("does-not-exist"));
3409        let engine = FlowEngine {
3410            packs: Vec::new(),
3411            flows: Vec::new(),
3412            flow_sources: HashMap::new(),
3413            flow_cache: RwLock::new(HashMap::from([(
3414                FlowKey {
3415                    pack_id: pack_id_str.to_string(),
3416                    flow_id: flow_id_str.to_string(),
3417                },
3418                host_flow,
3419            )])),
3420            default_env: "local".to_string(),
3421            validation: ValidationConfig {
3422                mode: ValidationMode::Off,
3423            },
3424            cross_pack_resolver: None,
3425        };
3426        let ctx = FlowContext {
3427            tenant: "demo",
3428            pack_id: pack_id_str,
3429            flow_id: flow_id_str,
3430            node_id: None,
3431            tool: None,
3432            action: None,
3433            session_id: None,
3434            provider_id: None,
3435            retry_config: RetryConfig {
3436                max_attempts: 1,
3437                base_delay_ms: 1,
3438            },
3439            attempt: 1,
3440            observer: None,
3441            mocks: None,
3442        };
3443        let result = engine.execute(ctx, Value::Null).await;
3444        assert!(result.is_err(), "non-user-facing flow must propagate Err");
3445    }
3446}
3447
3448use tracing::Instrument;
3449
3450pub struct FlowContext<'a> {
3451    pub tenant: &'a str,
3452    pub pack_id: &'a str,
3453    pub flow_id: &'a str,
3454    pub node_id: Option<&'a str>,
3455    pub tool: Option<&'a str>,
3456    pub action: Option<&'a str>,
3457    pub session_id: Option<&'a str>,
3458    pub provider_id: Option<&'a str>,
3459    pub retry_config: RetryConfig,
3460    pub attempt: u32,
3461    pub observer: Option<&'a dyn ExecutionObserver>,
3462    pub mocks: Option<&'a MockLayer>,
3463}
3464
3465#[derive(Copy, Clone)]
3466pub struct RetryConfig {
3467    pub max_attempts: u32,
3468    pub base_delay_ms: u64,
3469}
3470
3471/// Look across all node outputs, find the first one that finished with
3472/// `ok=false`, and lift its `meta.error` fields into
3473/// `output.metadata.error_kind` / `.error_message` / `.node_id`. Returns the
3474/// (possibly enriched) output unchanged otherwise.
3475///
3476/// This is how the executor "shows" an unhandled flow-node failure to the
3477/// caller without the flow author having to add error routing: the chat-side
3478/// provider (messaging-providers `extract_error_envelope`) picks the lifted
3479/// fields off `output.metadata` and renders a styled error card.
3480///
3481/// Takes a borrow of the node-output map rather than the whole
3482/// `ExecutionState` because the callers have already consumed `state` via
3483/// `state.finalize_with(...)`; we capture a cheap clone of `state.nodes` up
3484/// front and pass it in here.
3485fn lift_first_node_error_from_nodes(output: Value, nodes: &HashMap<String, NodeOutput>) -> Value {
3486    let Some((node_id, failed)) = nodes.iter().find(|(_, out)| !out.ok) else {
3487        return output;
3488    };
3489    let err_meta = failed.meta.get("error");
3490    let message = err_meta
3491        .and_then(|e| e.get("message"))
3492        .and_then(|v| v.as_str())
3493        .unwrap_or("flow node failed");
3494    let kind = err_meta
3495        .and_then(|e| e.get("kind"))
3496        .and_then(|v| v.as_str())
3497        .unwrap_or("flow_node_failed");
3498
3499    let mut output = match output {
3500        Value::Object(map) => map,
3501        Value::Null => JsonMap::new(),
3502        other => {
3503            let mut wrap = JsonMap::new();
3504            wrap.insert("payload".to_string(), other);
3505            wrap
3506        }
3507    };
3508    let metadata_entry = output
3509        .entry("metadata".to_string())
3510        .or_insert_with(|| Value::Object(JsonMap::new()));
3511    let metadata_map = match metadata_entry {
3512        Value::Object(map) => map,
3513        _ => {
3514            *metadata_entry = Value::Object(JsonMap::new());
3515            metadata_entry.as_object_mut().unwrap()
3516        }
3517    };
3518    metadata_map
3519        .entry("error_kind".to_string())
3520        .or_insert(Value::String(kind.to_string()));
3521    metadata_map
3522        .entry("error_message".to_string())
3523        .or_insert(Value::String(message.to_string()));
3524    metadata_map
3525        .entry("node_id".to_string())
3526        .or_insert(Value::String(node_id.clone()));
3527    Value::Object(output)
3528}
3529
3530fn should_retry(err: &anyhow::Error) -> bool {
3531    let lower = err.to_string().to_lowercase();
3532    lower.contains("transient")
3533        || lower.contains("unavailable")
3534        || lower.contains("internal")
3535        || lower.contains("timeout")
3536}
3537
3538impl From<FlowRetryConfig> for RetryConfig {
3539    fn from(value: FlowRetryConfig) -> Self {
3540        Self {
3541            max_attempts: value.max_attempts.max(1),
3542            base_delay_ms: value.base_delay_ms.max(50),
3543        }
3544    }
3545}