1use std::collections::HashMap;
2use std::env;
3use std::error::Error as StdError;
4use std::str::FromStr;
5use std::sync::Arc;
6use std::time::Duration;
7
8use crate::component_api::node::{ExecCtx as ComponentExecCtx, TenantCtx as ComponentTenantCtx};
9use anyhow::{Context, Result, anyhow, bail};
10use indexmap::IndexMap;
11use parking_lot::RwLock;
12use serde::{Deserialize, Serialize};
13use serde_json::{Map as JsonMap, Value, json};
14use tokio::task;
15
16use super::mocks::MockLayer;
17use super::templating::{TemplateOptions, render_template_value};
18use crate::config::{FlowRetryConfig, HostConfig};
19use crate::pack::{FlowDescriptor, PackRuntime};
20use crate::runner::invocation::{InvocationMeta, build_invocation_envelope};
21use crate::telemetry::{
22 FlowSpanAttributes, RolloutIds, annotate_span, backoff_delay_ms, set_flow_context,
23};
24#[cfg(feature = "fault-injection")]
25use crate::testing::fault_injection::{FaultContext, FaultPoint, maybe_fail};
26use crate::validate::{
27 ValidationConfig, ValidationIssue, ValidationMode, validate_component_envelope,
28 validate_tool_envelope,
29};
30use greentic_flow::SLOT_SCHEMA_METADATA_KEY;
31use greentic_types::{Flow, Node, NodeId, Routing};
32
33const SLOT_EXTRACTOR_COMPONENT_ID: &str = "ai.greentic.component-slot-extractor";
37
38pub trait CrossPackResolver: Send + Sync {
45 fn invoke(
46 &self,
47 provider_id: &str,
48 provider_type: Option<&str>,
49 op: &str,
50 input: &[u8],
51 tenant: &str,
52 team: Option<&str>,
53 ) -> Result<Value>;
54}
55
56pub struct FlowEngine {
57 packs: Vec<Arc<PackRuntime>>,
58 flows: Vec<FlowDescriptor>,
59 flow_sources: HashMap<FlowKey, usize>,
60 messaging_provider_pack_ids: std::collections::HashSet<String>,
67 flow_cache: RwLock<HashMap<FlowKey, HostFlow>>,
68 default_env: String,
69 validation: ValidationConfig,
70 cross_pack_resolver: Option<Arc<dyn CrossPackResolver>>,
71 rollout_ids: RolloutIds,
76 remote_dispatch_handler: Option<Arc<dyn crate::runner::remote_dispatch::RemoteDispatchHandler>>,
79 #[cfg(feature = "agentic-worker")]
82 dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch,
83 #[cfg(feature = "agentic-worker")]
84 agent_node_handler: Option<Arc<dyn crate::runner::agent_node::AgentNodeHandler>>,
85 #[cfg(feature = "agentic-worker")]
86 graph_node_handler: Option<Arc<dyn crate::runner::graph_node::GraphNodeHandler>>,
87 #[cfg(feature = "agentic-worker")]
92 mcp_tool_source: Option<Arc<greentic_aw_runtime::McpToolSource>>,
93}
94
95#[derive(Clone, Debug, PartialEq, Eq, Hash)]
96struct FlowKey {
97 pack_id: String,
98 flow_id: String,
99}
100
101#[derive(Clone, Debug, Serialize, Deserialize)]
102pub struct FlowSnapshot {
103 pub pack_id: String,
104 pub flow_id: String,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub next_flow: Option<String>,
107 pub next_node: String,
108 #[serde(default)]
115 pub awaiting_submit: bool,
116 pub state: ExecutionState,
117}
118
119#[derive(Clone, Debug)]
120pub struct FlowWait {
121 pub reason: Option<String>,
122 pub snapshot: FlowSnapshot,
123}
124
125#[derive(Clone, Debug)]
126pub enum FlowStatus {
127 Completed,
128 Waiting(Box<FlowWait>),
129}
130
131#[derive(Clone, Debug)]
132pub struct FlowExecution {
133 pub output: Value,
134 pub status: FlowStatus,
135}
136
137#[derive(Clone, Debug)]
138struct HostFlow {
139 id: String,
140 start: Option<NodeId>,
141 nodes: IndexMap<NodeId, HostNode>,
142 slot_schema: Option<Value>,
145 vars_init: JsonMap<String, Value>,
146}
147
148#[derive(Clone, Debug)]
149pub struct HostNode {
150 kind: NodeKind,
151 pub component: String,
153 component_id: String,
154 operation_name: Option<String>,
155 operation_in_mapping: Option<String>,
156 payload_expr: Value,
157 routing: Routing,
158 vars_out: Option<JsonMap<String, Value>>,
163}
164
165impl HostNode {
166 pub fn component_id(&self) -> &str {
167 &self.component_id
168 }
169
170 pub fn operation_name(&self) -> Option<&str> {
171 self.operation_name.as_deref()
172 }
173
174 pub fn operation_in_mapping(&self) -> Option<&str> {
175 self.operation_in_mapping.as_deref()
176 }
177}
178
179#[cfg(test)]
180impl HostNode {
181 pub(crate) fn for_test(component_id: &str, operation_name: Option<&str>) -> Self {
187 HostNode {
188 kind: NodeKind::Exec {
189 target_component: component_id.to_string(),
190 },
191 component: component_id.to_string(),
192 component_id: component_id.to_string(),
193 operation_name: operation_name.map(str::to_string),
194 operation_in_mapping: None,
195 payload_expr: Value::Null,
196 routing: Routing::End,
197 vars_out: None,
198 }
199 }
200}
201
202#[derive(Clone, Debug)]
203enum NodeKind {
204 Exec {
205 target_component: String,
206 },
207 PackComponent {
208 component_ref: String,
209 },
210 ProviderInvoke,
211 FlowCall,
212 FlowGoto,
226 BuiltinEmit {
227 kind: EmitKind,
228 },
229 BuiltinStateGet,
230 BuiltinStateSet,
231 VarSet {
235 name: String,
236 value: Value,
237 },
238 Wait,
239 DwAgent {
240 agent_id: String,
241 },
242 DwAgentGraph {
243 graph_id: String,
244 },
245 SorlaCall {
249 target: String,
250 },
251 OperalaCall {
254 target: String,
255 },
256 AgenticCall {
261 target: String,
262 },
263 TelcoXCall {
268 target: String,
269 },
270 ApprovalCall {
275 target: String,
276 },
277 Mcp {
287 server_id: String,
288 tool: String,
289 },
290}
291
292#[derive(Clone, Debug)]
293enum EmitKind {
294 Log,
295 Response,
296 Other(String),
297}
298
299struct ComponentOverrides<'a> {
300 component: Option<&'a str>,
301 operation: Option<&'a str>,
302}
303
304struct ComponentCall {
305 component_ref: String,
306 operation: String,
307 input: Value,
308 config: Value,
309 has_error_route: bool,
313}
314
315impl FlowExecution {
316 fn completed(output: Value) -> Self {
317 Self {
318 output,
319 status: FlowStatus::Completed,
320 }
321 }
322
323 fn waiting(output: Value, wait: FlowWait) -> Self {
324 Self {
325 output,
326 status: FlowStatus::Waiting(Box::new(wait)),
327 }
328 }
329}
330
331impl FlowEngine {
332 pub async fn new(packs: Vec<Arc<PackRuntime>>, config: Arc<HostConfig>) -> Result<Self> {
333 let mut flow_sources: HashMap<FlowKey, usize> = HashMap::new();
334 let mut messaging_provider_pack_ids: std::collections::HashSet<String> =
335 std::collections::HashSet::new();
336 let mut descriptors = Vec::new();
337 let mut bindings = HashMap::new();
338 for pack in &config.pack_bindings {
339 bindings.insert(pack.pack_id.clone(), pack.flows.clone());
340 }
341 let enforce_bindings = !bindings.is_empty();
342 for (idx, pack) in packs.iter().enumerate() {
343 let pack_id = pack.metadata().pack_id.clone();
344 if enforce_bindings && !bindings.contains_key(&pack_id) {
345 bail!("no gtbind entries found for pack {}", pack_id);
346 }
347 let declares_messaging_provider = pack
351 .provider_registry_optional()
352 .ok()
353 .flatten()
354 .map(|registry| {
355 registry
356 .operator_metadata()
357 .iter()
358 .any(|meta| meta.provider_type.starts_with("messaging."))
359 })
360 .unwrap_or(false);
361 if declares_messaging_provider {
362 messaging_provider_pack_ids.insert(pack_id.clone());
363 }
364 let flows = pack.list_flows().await?;
365 let allowed = bindings.get(&pack_id).map(|flows| {
366 flows
367 .iter()
368 .cloned()
369 .collect::<std::collections::HashSet<_>>()
370 });
371 let mut seen = std::collections::HashSet::new();
372 for flow in flows {
373 if let Some(ref allow) = allowed
374 && !allow.contains(&flow.id)
375 {
376 continue;
377 }
378 seen.insert(flow.id.clone());
379 tracing::info!(
380 flow_id = %flow.id,
381 flow_type = %flow.flow_type,
382 pack_id = %flow.pack_id,
383 pack_index = idx,
384 "registered flow"
385 );
386 if let Ok(flow_ir) = pack.load_flow(&flow.id) {
387 for node in flow_ir.nodes.values() {
388 config
389 .secrets_policy
390 .register_flow_secret_refs(&node.input.mapping);
391 config
392 .secrets_policy
393 .register_flow_secret_refs(&node.output.mapping);
394 }
395 }
396 flow_sources.insert(
397 FlowKey {
398 pack_id: flow.pack_id.clone(),
399 flow_id: flow.id.clone(),
400 },
401 idx,
402 );
403 descriptors.retain(|existing: &FlowDescriptor| {
404 !(existing.id == flow.id && existing.pack_id == flow.pack_id)
405 });
406 descriptors.push(flow);
407 }
408 if let Some(allow) = allowed {
409 let missing = allow.difference(&seen).cloned().collect::<Vec<_>>();
410 if !missing.is_empty() {
411 bail!(
412 "gtbind flow ids missing in pack {}: {}",
413 pack_id,
414 missing.join(", ")
415 );
416 }
417 }
418 }
419
420 let mut flow_map = HashMap::new();
421 for flow in &descriptors {
422 let pack_id = flow.pack_id.clone();
423 if let Some(&pack_idx) = flow_sources.get(&FlowKey {
424 pack_id: pack_id.clone(),
425 flow_id: flow.id.clone(),
426 }) {
427 let pack_clone = Arc::clone(&packs[pack_idx]);
428 let flow_id = flow.id.clone();
429 let task_flow_id = flow_id.clone();
430 match task::spawn_blocking(move || pack_clone.load_flow(&task_flow_id)).await {
431 Ok(Ok(loaded_flow)) => {
432 flow_map.insert(
433 FlowKey {
434 pack_id: pack_id.clone(),
435 flow_id,
436 },
437 HostFlow::from(loaded_flow),
438 );
439 }
440 Ok(Err(err)) => {
441 tracing::warn!(flow_id = %flow.id, error = %err, "failed to load flow metadata");
442 }
443 Err(err) => {
444 tracing::warn!(flow_id = %flow.id, error = %err, "join error loading flow metadata");
445 }
446 }
447 }
448 }
449
450 Ok(Self {
451 packs,
452 flows: descriptors,
453 flow_sources,
454 messaging_provider_pack_ids,
455 flow_cache: RwLock::new(flow_map),
456 default_env: env::var("GREENTIC_ENV").unwrap_or_else(|_| "local".to_string()),
457 validation: config.validation.clone(),
458 cross_pack_resolver: None,
459 rollout_ids: RolloutIds::default(),
460 remote_dispatch_handler: None,
461 #[cfg(feature = "agentic-worker")]
462 dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
463 #[cfg(feature = "agentic-worker")]
464 agent_node_handler: None,
465 #[cfg(feature = "agentic-worker")]
466 graph_node_handler: None,
467 #[cfg(feature = "agentic-worker")]
468 mcp_tool_source: crate::runner::mcp_node::source_from_env(),
469 })
470 }
471
472 pub fn with_rollout_ids(mut self, rollout_ids: RolloutIds) -> Self {
478 self.rollout_ids = rollout_ids;
479 self
480 }
481
482 pub fn rollout_ids(&self) -> &RolloutIds {
486 &self.rollout_ids
487 }
488
489 pub fn set_cross_pack_resolver(&mut self, resolver: Arc<dyn CrossPackResolver>) {
492 self.cross_pack_resolver = Some(resolver);
493 }
494
495 pub fn set_remote_dispatch_handler(
499 &mut self,
500 handler: Arc<dyn crate::runner::remote_dispatch::RemoteDispatchHandler>,
501 ) {
502 self.remote_dispatch_handler = Some(handler);
503 }
504
505 #[cfg(feature = "agentic-worker")]
508 pub fn set_agent_node_handler(
509 &mut self,
510 handler: Arc<dyn crate::runner::agent_node::AgentNodeHandler>,
511 ) {
512 self.agent_node_handler = Some(handler);
513 }
514
515 #[cfg(feature = "agentic-worker")]
521 pub fn set_graph_node_handler(
522 &mut self,
523 handler: Arc<dyn crate::runner::graph_node::GraphNodeHandler>,
524 ) {
525 self.graph_node_handler = Some(handler);
526 }
527
528 #[cfg(feature = "agentic-worker")]
541 pub fn set_dw_agent_dispatch(&mut self, mode: crate::runner::agent_node::DwAgentDispatch) {
542 self.dw_agent_dispatch = mode;
543 }
544
545 async fn get_or_load_flow(&self, pack_id: &str, flow_id: &str) -> Result<HostFlow> {
546 let key = FlowKey {
547 pack_id: pack_id.to_string(),
548 flow_id: flow_id.to_string(),
549 };
550 if let Some(flow) = self.flow_cache.read().get(&key).cloned() {
551 return Ok(flow);
552 }
553
554 let pack_idx = *self
555 .flow_sources
556 .get(&key)
557 .with_context(|| format!("flow {pack_id}:{flow_id} not registered"))?;
558 let pack = Arc::clone(&self.packs[pack_idx]);
559 let flow_id_owned = flow_id.to_string();
560 let task_flow_id = flow_id_owned.clone();
561 let flow = task::spawn_blocking(move || pack.load_flow(&task_flow_id))
562 .await
563 .context("failed to join flow metadata task")??;
564 let host_flow = HostFlow::from(flow);
565 self.flow_cache.write().insert(
566 FlowKey {
567 pack_id: pack_id.to_string(),
568 flow_id: flow_id_owned.clone(),
569 },
570 host_flow.clone(),
571 );
572 Ok(host_flow)
573 }
574
575 fn flow_execute_span(&self, ctx: &FlowContext<'_>) -> tracing::Span {
582 let span = tracing::info_span!(
583 "flow.execute",
584 tenant = tracing::field::Empty,
585 flow_id = tracing::field::Empty,
586 node_id = tracing::field::Empty,
587 tool = tracing::field::Empty,
588 action = tracing::field::Empty
589 );
590 annotate_span(
591 &span,
592 &FlowSpanAttributes {
593 tenant: ctx.tenant,
594 flow_id: ctx.flow_id,
595 node_id: ctx.node_id,
596 tool: ctx.tool,
597 action: ctx.action,
598 },
599 );
600 set_flow_context(
601 &span,
602 &self.default_env,
603 ctx.tenant,
604 ctx.flow_id,
605 ctx.node_id,
606 ctx.provider_id,
607 ctx.session_id,
608 ctx.pack_id,
609 &self.rollout_ids,
610 );
611 span
612 }
613
614 pub async fn execute(&self, ctx: FlowContext<'_>, input: Value) -> Result<FlowExecution> {
615 self.execute_with_entry(ctx, input, None).await
616 }
617
618 pub async fn execute_from(
635 &self,
636 ctx: FlowContext<'_>,
637 input: Value,
638 entry_node: &str,
639 ) -> Result<FlowExecution> {
640 self.execute_with_entry(ctx, input, Some(entry_node.to_string()))
641 .await
642 }
643
644 async fn execute_with_entry(
645 &self,
646 ctx: FlowContext<'_>,
647 input: Value,
648 entry_node: Option<String>,
649 ) -> Result<FlowExecution> {
650 if let Some(node) = entry_node.as_deref() {
656 let flow_ir = self.get_or_load_flow(ctx.pack_id, ctx.flow_id).await?;
657 let node_id = NodeId::from_str(node)
658 .with_context(|| format!("invalid entry node id `{node}`"))?;
659 if !flow_ir.nodes.contains_key(&node_id) {
660 bail!("flow {} has no node `{}`", ctx.flow_id, node);
661 }
662 }
663 let span = self.flow_execute_span(&ctx);
664 let retry_config = ctx.retry_config;
665 let original_input = input;
666 let mut ctx = ctx;
667 let metric_tenant = ctx.tenant.to_string();
668 let metric_flow_id = ctx.flow_id.to_string();
669 let started = std::time::Instant::now();
670 let result = async move {
671 let mut attempt = 0u32;
672 loop {
673 attempt += 1;
674 ctx.attempt = attempt;
675 #[cfg(feature = "fault-injection")]
676 {
677 let fault_ctx = FaultContext {
678 pack_id: ctx.pack_id,
679 flow_id: ctx.flow_id,
680 node_id: ctx.node_id,
681 attempt: ctx.attempt,
682 };
683 maybe_fail(FaultPoint::Timeout, fault_ctx)
684 .map_err(|err| anyhow!(err.to_string()))?;
685 }
686 match self
687 .execute_once(&ctx, original_input.clone(), entry_node.clone())
688 .await
689 {
690 Ok(value) => return Ok(value),
691 Err(err) => {
692 if attempt >= retry_config.max_attempts || !should_retry(&err) {
693 if ctx.session_id.is_some() {
698 return Ok(FlowExecution::completed(json!({
699 "metadata": {
700 "error_kind": "flow_execution_failed",
701 "error_message": err.to_string(),
702 "flow_id": ctx.flow_id,
703 }
704 })));
705 }
706 return Err(err);
707 }
708 let delay = backoff_delay_ms(retry_config.base_delay_ms, attempt - 1);
709 tracing::warn!(
710 tenant = ctx.tenant,
711 flow_id = ctx.flow_id,
712 attempt,
713 max_attempts = retry_config.max_attempts,
714 delay_ms = delay,
715 error = %err,
716 "transient flow execution failure, backing off"
717 );
718 tokio::time::sleep(Duration::from_millis(delay)).await;
719 }
720 }
721 }
722 }
723 .instrument(span)
724 .await;
725 let status = if result.is_ok() { "ok" } else { "err" };
726 let duration_ms = started.elapsed().as_secs_f64() * 1000.0;
727 crate::metrics::record_flow_execution(&metric_tenant, &metric_flow_id, status, duration_ms);
728 result
729 }
730
731 pub async fn resume(
732 &self,
733 ctx: FlowContext<'_>,
734 snapshot: FlowSnapshot,
735 input: Value,
736 ) -> Result<FlowExecution> {
737 if snapshot.pack_id != ctx.pack_id {
738 bail!(
739 "snapshot pack {} does not match requested {}",
740 snapshot.pack_id,
741 ctx.pack_id
742 );
743 }
744 let resume_flow = snapshot
745 .next_flow
746 .clone()
747 .unwrap_or_else(|| snapshot.flow_id.clone());
748 let flow_ir = self.get_or_load_flow(ctx.pack_id, &resume_flow).await?;
749 let pending_card_answers = pending_from_snapshot(&snapshot, &input);
752 let mut state = snapshot.state;
753 state.replace_input(input.clone());
763 state.pending_card_answers = pending_card_answers;
767 state.entry = input;
768 let span = self.flow_execute_span(&ctx);
769 self.drive_flow(&ctx, flow_ir, state, Some(snapshot.next_node), resume_flow)
770 .instrument(span)
771 .await
772 }
773
774 async fn execute_once(
775 &self,
776 ctx: &FlowContext<'_>,
777 input: Value,
778 entry_node: Option<String>,
779 ) -> Result<FlowExecution> {
780 let flow_ir = self.get_or_load_flow(ctx.pack_id, ctx.flow_id).await?;
781 let mut state = ExecutionState::new(input);
782 for (name, default) in flow_ir.vars_init.iter() {
783 state
784 .vars
785 .entry(name.clone())
786 .or_insert_with(|| default.clone());
787 }
788 self.drive_flow(ctx, flow_ir, state, entry_node, ctx.flow_id.to_string())
789 .await
790 }
791
792 async fn drive_flow(
793 &self,
794 ctx: &FlowContext<'_>,
795 mut flow_ir: HostFlow,
796 mut state: ExecutionState,
797 resume_from: Option<String>,
798 mut current_flow_id: String,
799 ) -> Result<FlowExecution> {
800 let mut current = match resume_from {
801 Some(node) => NodeId::from_str(&node)
802 .with_context(|| format!("invalid resume node id `{node}`"))?,
803 None => flow_ir
804 .start
805 .clone()
806 .or_else(|| flow_ir.nodes.keys().next().cloned())
807 .with_context(|| format!("flow {} has no start node", flow_ir.id))?,
808 };
809
810 loop {
811 let step_ctx = FlowContext {
812 tenant: ctx.tenant,
813 pack_id: ctx.pack_id,
814 flow_id: current_flow_id.as_str(),
815 node_id: ctx.node_id,
816 tool: ctx.tool,
817 action: ctx.action,
818 session_id: ctx.session_id,
819 provider_id: ctx.provider_id,
820 reply_scope: ctx.reply_scope,
821 retry_config: ctx.retry_config,
822 attempt: ctx.attempt,
823 observer: ctx.observer,
824 mocks: ctx.mocks,
825 };
826 let node = flow_ir
827 .nodes
828 .get(¤t)
829 .with_context(|| format!("node {} not found", current.as_str()))?;
830
831 let payload_template = node.payload_expr.clone();
832 let prev = state
833 .last_output
834 .as_ref()
835 .cloned()
836 .unwrap_or_else(|| Value::Object(JsonMap::new()));
837 let ctx_value = template_context(&state, prev);
838 #[cfg(feature = "fault-injection")]
839 {
840 let fault_ctx = FaultContext {
841 pack_id: ctx.pack_id,
842 flow_id: ctx.flow_id,
843 node_id: Some(current.as_str()),
844 attempt: ctx.attempt,
845 };
846 maybe_fail(FaultPoint::TemplateRender, fault_ctx)
847 .map_err(|err| anyhow!(err.to_string()))?;
848 }
849 let mut payload =
850 render_template_value(&payload_template, &ctx_value, TemplateOptions::default())
851 .context("failed to render node input template")?;
852 let node_id = current.clone();
853
854 if let NodeKind::Exec { target_component } = &node.kind
859 && target_component == SLOT_EXTRACTOR_COMPONENT_ID
860 && let Some(schema) = flow_ir.slot_schema.as_ref()
861 && let Some(map) = payload.as_object_mut()
862 {
863 let input = map.entry("input").or_insert(Value::Null);
864 inject_slot_definitions(input, schema, step_ctx.flow_id, node_id.as_str());
865 }
866
867 let observed_payload = payload.clone();
868 let event = NodeEvent {
869 context: &step_ctx,
870 node_id: node_id.as_str(),
871 node,
872 payload: &observed_payload,
873 };
874 if let Some(observer) = step_ctx.observer {
875 observer.on_node_start(&event);
876 }
877 let dispatch = self
878 .dispatch_node(
879 &step_ctx,
880 node_id.as_str(),
881 node,
882 &mut state,
883 payload,
884 &event,
885 )
886 .await;
887 let DispatchOutcome {
888 mut output,
889 control,
890 } = match dispatch {
891 Ok(outcome) => outcome,
892 Err(err) => {
893 if let Some(observer) = step_ctx.observer {
894 observer.on_node_error(&event, err.as_ref());
895 }
896 return Err(err);
900 }
901 };
902
903 let owned_the_submit =
904 attach_pending_card_answers(&mut state, node_id.as_str(), node, &mut output);
905 state.nodes.insert(node_id.clone().into(), output.clone());
906 state.last_output = Some(output.payload.clone());
907 if let Some(bindings) = node.vars_out.as_ref() {
911 let ctx = template_context(&state, output.payload.clone());
912 for (var_name, template) in bindings.iter() {
913 let rendered = render_template_value(
914 template,
915 &ctx,
916 TemplateOptions {
917 allow_pointer: true,
918 },
919 )
920 .with_context(|| format!("failed to render vars_out binding `{var_name}`"))?;
921 state.vars.insert(var_name.clone(), rendered);
922 }
923 }
924 if let Some(observer) = step_ctx.observer {
925 observer.on_node_end(&event, &output.payload);
926 }
927
928 match control {
929 NodeControl::Continue => {
930 enum NextDecision {
931 Next(NodeId),
932 End,
933 Wait,
934 }
935 let decision = match &node.routing {
936 Routing::Next { node_id } => NextDecision::Next(node_id.clone()),
937 Routing::End | Routing::Reply => NextDecision::End,
938 Routing::Branch { default, .. } => match default {
939 Some(target) => NextDecision::Next(target.clone()),
940 None => NextDecision::End,
941 },
942 Routing::Custom(raw) => {
943 match evaluate_custom_routing(raw, &output, &state, &flow_ir, &node_id)
944 {
945 CustomRoutingDecision::Next(nid) => NextDecision::Next(nid),
946 CustomRoutingDecision::End => NextDecision::End,
947 CustomRoutingDecision::Wait => NextDecision::Wait,
948 }
949 }
950 };
951
952 if owned_the_submit {
961 consume_routing_action(&mut state.entry);
962 }
963
964 match decision {
965 NextDecision::Next(n) => current = n,
966 NextDecision::End => {
967 let nodes_snapshot = state.nodes.clone();
968 let final_output = state.finalize_with(Some(output.payload.clone()));
969 return Ok(FlowExecution::completed(lift_first_node_error_from_nodes(
970 final_output,
971 &nodes_snapshot,
972 )));
973 }
974 NextDecision::Wait => {
975 let mut snapshot_state = state.clone();
980 snapshot_state.clear_egress();
981 let snapshot = FlowSnapshot {
982 pack_id: step_ctx.pack_id.to_string(),
983 flow_id: step_ctx.flow_id.to_string(),
984 next_flow: (current_flow_id != step_ctx.flow_id)
985 .then_some(current_flow_id.clone()),
986 next_node: node_id.as_str().to_string(),
987 awaiting_submit: true,
988 state: snapshot_state,
989 };
990 let output_value = state.finalize_with(Some(output.payload.clone()));
991 return Ok(FlowExecution::waiting(
992 output_value,
993 FlowWait {
994 reason: Some(format!(
995 "awaiting user submit at node `{}`",
996 node_id.as_str()
997 )),
998 snapshot,
999 },
1000 ));
1001 }
1002 }
1003 }
1004 NodeControl::Wait { reason } => {
1005 let next: Option<NodeId> = match &node.routing {
1006 Routing::Next { node_id } => Some(node_id.clone()),
1007 Routing::End | Routing::Reply => None,
1008 Routing::Branch { default, .. } => default.clone(),
1009 Routing::Custom(raw) => {
1010 match evaluate_custom_routing(raw, &output, &state, &flow_ir, &node_id)
1011 {
1012 CustomRoutingDecision::Next(nid) => Some(nid),
1013 CustomRoutingDecision::End | CustomRoutingDecision::Wait => None,
1018 }
1019 }
1020 };
1021 let resume_target = next.ok_or_else(|| {
1022 anyhow!(
1023 "session.wait node {} requires a non-empty route",
1024 current.as_str()
1025 )
1026 })?;
1027 let mut snapshot_state = state.clone();
1028 snapshot_state.clear_egress();
1029 let snapshot = FlowSnapshot {
1030 pack_id: step_ctx.pack_id.to_string(),
1031 flow_id: step_ctx.flow_id.to_string(),
1032 next_flow: (current_flow_id != step_ctx.flow_id)
1033 .then_some(current_flow_id.clone()),
1034 next_node: resume_target.as_str().to_string(),
1035 awaiting_submit: false,
1036 state: snapshot_state,
1037 };
1038 let output_value = state.clone().finalize_with(None);
1039 return Ok(FlowExecution::waiting(
1040 output_value,
1041 FlowWait { reason, snapshot },
1042 ));
1043 }
1044 NodeControl::Jump(jump) => {
1045 let jump_target = self.apply_jump(&step_ctx, &mut state, jump).await?;
1046 flow_ir = jump_target.flow;
1047 current_flow_id = jump_target.flow_id;
1048 current = jump_target.node_id;
1049 }
1050 NodeControl::Respond {
1051 text,
1052 card_cbor,
1053 needs_user,
1054 } => {
1055 let response = json!({
1056 "text": text,
1057 "card_cbor": card_cbor,
1058 "needs_user": needs_user,
1059 });
1060 state.push_egress(response);
1061 let nodes_snapshot = state.nodes.clone();
1062 let final_output = state.finalize_with(None);
1063 return Ok(FlowExecution::completed(lift_first_node_error_from_nodes(
1064 final_output,
1065 &nodes_snapshot,
1066 )));
1067 }
1068 }
1069 }
1070 }
1071
1072 async fn dispatch_node(
1073 &self,
1074 ctx: &FlowContext<'_>,
1075 node_id: &str,
1076 node: &HostNode,
1077 state: &mut ExecutionState,
1078 mut payload: Value,
1079 event: &NodeEvent<'_>,
1080 ) -> Result<DispatchOutcome> {
1081 inject_card_locale(&mut payload, &state.entry);
1082 inject_card_route(&mut payload, &state.entry, node);
1083 match &node.kind {
1084 NodeKind::Exec { target_component } => self
1085 .execute_component_exec(
1086 ctx,
1087 node_id,
1088 node,
1089 payload,
1090 event,
1091 ComponentOverrides {
1092 component: Some(target_component.as_str()),
1093 operation: node.operation_name.as_deref(),
1094 },
1095 )
1096 .await
1097 .and_then(component_dispatch_outcome),
1098 NodeKind::PackComponent { component_ref } => self
1099 .execute_component_call(ctx, node_id, node, payload, component_ref.as_str(), event)
1100 .await
1101 .and_then(component_dispatch_outcome),
1102 NodeKind::FlowCall => self
1103 .execute_flow_call(ctx, payload)
1104 .await
1105 .map(DispatchOutcome::complete),
1106 NodeKind::FlowGoto => execute_flow_goto(payload),
1107 NodeKind::ProviderInvoke => self
1108 .execute_provider_invoke(ctx, node_id, state, payload, event)
1109 .await
1110 .map(DispatchOutcome::complete),
1111 NodeKind::BuiltinEmit { kind } => {
1112 match kind {
1113 EmitKind::Log | EmitKind::Response => {}
1114 EmitKind::Other(component) => {
1115 tracing::debug!(%component, "handling emit.* as builtin");
1116 }
1117 }
1118 state.push_egress(payload.clone());
1119 Ok(DispatchOutcome::complete(NodeOutput::new(payload)))
1120 }
1121 NodeKind::BuiltinStateGet => self
1122 .execute_state_get(ctx, payload)
1123 .await
1124 .map(DispatchOutcome::complete),
1125 NodeKind::BuiltinStateSet => self
1126 .execute_state_set(ctx, payload)
1127 .await
1128 .map(DispatchOutcome::complete),
1129 NodeKind::VarSet { name, value } => {
1130 if name.trim().is_empty() {
1131 tracing::warn!(
1132 node_id = %node_id,
1133 "var_set node has an empty variable name; skipping write"
1134 );
1135 return Ok(DispatchOutcome::complete(NodeOutput::new(
1136 serde_json::json!({ "ok": true }),
1137 )));
1138 }
1139 let prev = state.last_output.clone().unwrap_or(Value::Null);
1140 let ctx_val = template_context(state, prev);
1141 let rendered = render_template_value(
1142 value,
1143 &ctx_val,
1144 TemplateOptions {
1145 allow_pointer: true,
1146 },
1147 )
1148 .context("failed to render var_set value")?;
1149 state.vars.insert(name.clone(), rendered);
1150 Ok(DispatchOutcome::complete(NodeOutput::new(
1151 serde_json::json!({ "ok": true }),
1152 )))
1153 }
1154 NodeKind::Wait => {
1155 let reason = extract_wait_reason(&payload);
1156 Ok(DispatchOutcome::wait(NodeOutput::new(payload), reason))
1157 }
1158 NodeKind::DwAgent { agent_id } => {
1159 #[cfg(feature = "agentic-worker")]
1160 match self.dw_agent_dispatch {
1161 crate::runner::agent_node::DwAgentDispatch::Nats => {
1162 let remote_payload = serde_json::json!({ "await": true, "input": payload });
1167 self.execute_remote_dispatch(ctx, "agentic", agent_id, remote_payload)
1168 .await
1169 }
1170 crate::runner::agent_node::DwAgentDispatch::InProcess => self
1171 .execute_dw_agent(ctx, agent_id, payload)
1172 .await
1173 .map(DispatchOutcome::complete),
1174 }
1175 #[cfg(not(feature = "agentic-worker"))]
1176 self.execute_dw_agent(ctx, agent_id, payload)
1177 .await
1178 .map(DispatchOutcome::complete)
1179 }
1180 NodeKind::DwAgentGraph { graph_id } => self
1181 .execute_dw_agent_graph(ctx, graph_id, payload)
1182 .await
1183 .map(DispatchOutcome::complete),
1184 NodeKind::SorlaCall { target } => self.execute_sorla_call(ctx, target, payload).await,
1185 NodeKind::OperalaCall { target } => {
1186 self.execute_operala_call(ctx, target, payload).await
1187 }
1188 NodeKind::AgenticCall { target } => {
1189 self.execute_agentic_call(ctx, target, payload).await
1190 }
1191 NodeKind::TelcoXCall { target } => {
1192 self.execute_telco_x_call(ctx, target, payload).await
1193 }
1194 NodeKind::ApprovalCall { target } => {
1195 self.execute_approval_call(ctx, target, payload).await
1196 }
1197 NodeKind::Mcp { server_id, tool } => self
1198 .execute_mcp(ctx, server_id, tool, payload)
1199 .await
1200 .map(DispatchOutcome::complete),
1201 }
1202 }
1203
1204 #[cfg(feature = "agentic-worker")]
1205 async fn execute_dw_agent(
1206 &self,
1207 ctx: &FlowContext<'_>,
1208 agent_id: &str,
1209 payload: Value,
1210 ) -> Result<NodeOutput> {
1211 let handler = self
1212 .agent_node_handler
1213 .as_ref()
1214 .context("DwAgent node dispatched but no AgentNodeHandler configured on FlowEngine")?;
1215 let session_id = ctx.session_id.unwrap_or("");
1216 let result = handler
1217 .execute(
1218 ctx.tenant,
1219 &self.default_env,
1220 agent_id,
1221 session_id,
1222 &payload,
1223 )
1224 .await?;
1225 Ok(NodeOutput::new(result))
1226 }
1227
1228 #[cfg(not(feature = "agentic-worker"))]
1229 async fn execute_dw_agent(
1230 &self,
1231 _ctx: &FlowContext<'_>,
1232 agent_id: &str,
1233 _payload: Value,
1234 ) -> Result<NodeOutput> {
1235 anyhow::bail!(
1236 "DwAgent node '{agent_id}' cannot run: this build was compiled without the \
1237 `agentic-worker` feature. Rebuild with --features agentic-worker."
1238 )
1239 }
1240
1241 async fn execute_sorla_call(
1262 &self,
1263 ctx: &FlowContext<'_>,
1264 target: &str,
1265 payload: Value,
1266 ) -> Result<DispatchOutcome> {
1267 self.execute_remote_dispatch(ctx, "sorla", target, payload)
1268 .await
1269 }
1270
1271 async fn execute_operala_call(
1274 &self,
1275 ctx: &FlowContext<'_>,
1276 target: &str,
1277 payload: Value,
1278 ) -> Result<DispatchOutcome> {
1279 self.execute_remote_dispatch(ctx, "operala", target, payload)
1280 .await
1281 }
1282
1283 async fn execute_agentic_call(
1288 &self,
1289 ctx: &FlowContext<'_>,
1290 target: &str,
1291 payload: Value,
1292 ) -> Result<DispatchOutcome> {
1293 self.execute_remote_dispatch(ctx, "agentic", target, payload)
1294 .await
1295 }
1296
1297 async fn execute_telco_x_call(
1301 &self,
1302 ctx: &FlowContext<'_>,
1303 target: &str,
1304 payload: Value,
1305 ) -> Result<DispatchOutcome> {
1306 self.execute_remote_dispatch(ctx, "telco-x", target, payload)
1307 .await
1308 }
1309
1310 async fn execute_approval_call(
1316 &self,
1317 ctx: &FlowContext<'_>,
1318 target: &str,
1319 payload: Value,
1320 ) -> Result<DispatchOutcome> {
1321 let input = payload.get("input").cloned().unwrap_or(Value::Null);
1322 if !approval_requires_human(&input) {
1323 let output = NodeOutput::new(serde_json::json!({
1327 "ok": true,
1328 "output": { "decision": "approved", "auto": true },
1329 "error": serde_json::Value::Null,
1330 }));
1331 return Ok(DispatchOutcome::complete(output));
1332 }
1333 self.execute_remote_dispatch(ctx, "approval", target, payload)
1334 .await
1335 }
1336
1337 #[cfg(feature = "agentic-worker")]
1355 async fn execute_mcp(
1356 &self,
1357 ctx: &FlowContext<'_>,
1358 server_id: &str,
1359 tool: &str,
1360 payload: Value,
1361 ) -> Result<NodeOutput> {
1362 let payload_server = crate::runner::mcp_node::str_field(&payload, "server");
1366 let payload_tool = crate::runner::mcp_node::str_field(&payload, "tool");
1367 let server_id = payload_server.as_deref().unwrap_or(server_id);
1368 let tool = payload_tool.as_deref().unwrap_or(tool);
1369
1370 let arguments = payload
1372 .get("arguments")
1373 .cloned()
1374 .unwrap_or_else(|| Value::Object(JsonMap::new()));
1375
1376 let pack = self
1380 .packs
1381 .iter()
1382 .find(|pack| pack.metadata().pack_id == ctx.pack_id);
1383 let pack_routes = pack.and_then(|pack| pack.mcp_routes());
1384
1385 let auth_team = pack_routes
1391 .and_then(|routes| routes.get(server_id))
1392 .and_then(|route| route.auth_team.as_deref());
1393
1394 let result = crate::runner::mcp_node::invoke_with_secrets(
1400 self.mcp_tool_source.as_ref(),
1401 pack_routes,
1402 pack.map(|p| p.secrets()),
1403 ctx.tenant,
1404 &self.default_env,
1405 auth_team,
1406 server_id,
1407 tool,
1408 &arguments,
1409 )
1410 .await;
1411
1412 let bound = match payload.get("output").and_then(Value::as_str) {
1416 Some(key) if !key.is_empty() => json!({ key: result.clone() }),
1417 _ => result.clone(),
1418 };
1419 Ok(mcp_output(bound, &result))
1420 }
1421
1422 #[cfg(not(feature = "agentic-worker"))]
1426 async fn execute_mcp(
1427 &self,
1428 _ctx: &FlowContext<'_>,
1429 server_id: &str,
1430 tool: &str,
1431 _payload: Value,
1432 ) -> Result<NodeOutput> {
1433 Ok(NodeOutput::new(json!({
1434 "error": format!(
1435 "mcp node '{server_id}/{tool}' requires the agentic-worker feature (MCP runtime not compiled in)"
1436 )
1437 })))
1438 }
1439
1440 async fn execute_remote_dispatch(
1458 &self,
1459 ctx: &FlowContext<'_>,
1460 runtime: &str,
1461 target: &str,
1462 payload: Value,
1463 ) -> Result<DispatchOutcome> {
1464 let handler = self.remote_dispatch_handler.as_ref().with_context(|| {
1465 format!("{runtime}.call node dispatched but no RemoteDispatchHandler configured")
1466 })?;
1467
1468 let await_mode = payload
1469 .get("await")
1470 .and_then(Value::as_bool)
1471 .unwrap_or(true);
1472 let operation = payload
1473 .get("operation")
1474 .and_then(Value::as_str)
1475 .unwrap_or_default()
1476 .to_string();
1477 let deadline_ms = payload.get("deadline_ms").and_then(Value::as_u64);
1478 let inner_input = payload.get("input").cloned().unwrap_or(Value::Null);
1479
1480 let raw_hint = ctx.session_id.unwrap_or_default();
1490 let bare_hint = raw_hint.split("::").next().unwrap_or_default();
1491 let mut correlation_id =
1501 format!("{}::pack={}::flow={}", bare_hint, ctx.pack_id, ctx.flow_id);
1502 if let Some(scope) = ctx.reply_scope {
1503 if let Some(thread) = scope.thread.as_deref().filter(|value| !value.is_empty()) {
1504 correlation_id.push_str("::thread=");
1505 correlation_id.push_str(thread);
1506 }
1507 if let Some(reply_to) = scope.reply_to.as_deref().filter(|value| !value.is_empty()) {
1508 correlation_id.push_str("::reply=");
1509 correlation_id.push_str(reply_to);
1510 }
1511 }
1512 let mode = if await_mode {
1513 greentic_types::DispatchMode::Await
1514 } else {
1515 greentic_types::DispatchMode::FireAndForget
1516 };
1517
1518 let action = handler
1519 .dispatch(crate::runner::remote_dispatch::RemoteDispatch {
1520 tenant: ctx.tenant.to_string(),
1521 env: self.default_env.clone(),
1522 runtime: runtime.to_string(),
1523 target: target.to_string(),
1524 operation,
1525 mode,
1526 correlation_id: correlation_id.clone(),
1527 input: inner_input,
1528 deadline_ms,
1529 })
1530 .await?;
1531
1532 match action {
1533 crate::runner::remote_dispatch::RemoteDispatchAction::AwaitingResponse {
1534 correlation_id,
1535 } => {
1536 let reason = format!("await-runtime:{correlation_id}");
1537 let output = NodeOutput::new(serde_json::json!({
1538 "pending": true,
1539 "correlation_id": correlation_id,
1540 }));
1541 Ok(DispatchOutcome::wait(output, Some(reason)))
1542 }
1543 crate::runner::remote_dispatch::RemoteDispatchAction::Dispatched => {
1544 let output = NodeOutput::new(serde_json::json!({
1545 "dispatched": true,
1546 "correlation_id": correlation_id,
1547 }));
1548 Ok(DispatchOutcome::complete(output))
1549 }
1550 }
1551 }
1552
1553 #[cfg(feature = "agentic-worker")]
1560 async fn execute_dw_agent_graph(
1561 &self,
1562 ctx: &FlowContext<'_>,
1563 graph_id: &str,
1564 payload: Value,
1565 ) -> Result<NodeOutput> {
1566 let handler = self.graph_node_handler.as_ref().context(
1567 "DwAgentGraph node dispatched but no GraphNodeHandler configured on FlowEngine",
1568 )?;
1569 let session_id = ctx.session_id.unwrap_or("");
1570 let result = handler
1571 .execute(
1572 ctx.tenant,
1573 &self.default_env,
1574 graph_id,
1575 session_id,
1576 &payload,
1577 )
1578 .await?;
1579 Ok(NodeOutput::new(result))
1580 }
1581
1582 #[cfg(not(feature = "agentic-worker"))]
1583 async fn execute_dw_agent_graph(
1584 &self,
1585 _ctx: &FlowContext<'_>,
1586 graph_id: &str,
1587 _payload: Value,
1588 ) -> Result<NodeOutput> {
1589 anyhow::bail!(
1590 "DwAgentGraph node '{graph_id}' cannot run: this build was compiled without the \
1591 `agentic-worker` feature. Rebuild with --features agentic-worker."
1592 )
1593 }
1594
1595 async fn execute_state_get(&self, ctx: &FlowContext<'_>, payload: Value) -> Result<NodeOutput> {
1596 let key = Self::extract_state_key_helper(&payload)?;
1597 let pack = self.pack_for_flow(ctx)?;
1598 let store = pack
1599 .state_store_handle()
1600 .context("state store is not configured for this runtime")?;
1601 let tenant_ctx = self.state_tenant_ctx(ctx)?;
1602 let state_key = greentic_state::StateKey::new(&key);
1603 let value = store
1604 .get_json(
1605 &tenant_ctx,
1606 crate::storage::state::STATE_PREFIX,
1607 &state_key,
1608 None,
1609 )
1610 .with_context(|| format!("state.get failed for key `{key}`"))?;
1611 let payload = serde_json::json!({
1612 "key": key,
1613 "value": value,
1614 "found": value.is_some(),
1615 });
1616 Ok(NodeOutput::new(payload))
1617 }
1618
1619 async fn execute_state_set(&self, ctx: &FlowContext<'_>, payload: Value) -> Result<NodeOutput> {
1620 let key = Self::extract_state_key_helper(&payload)?;
1621 let value = payload.get("value").cloned().unwrap_or(Value::Null);
1622 let pack = self.pack_for_flow(ctx)?;
1623 let store = pack
1624 .state_store_handle()
1625 .context("state store is not configured for this runtime")?;
1626 let tenant_ctx = self.state_tenant_ctx(ctx)?;
1627 let state_key = greentic_state::StateKey::new(&key);
1628 store
1629 .set_json(
1630 &tenant_ctx,
1631 crate::storage::state::STATE_PREFIX,
1632 &state_key,
1633 None,
1634 &value,
1635 None,
1636 )
1637 .with_context(|| format!("state.set failed for key `{key}`"))?;
1638 let payload = serde_json::json!({ "key": key, "value": value });
1639 Ok(NodeOutput::new(payload))
1640 }
1641
1642 fn pack_for_flow(&self, ctx: &FlowContext<'_>) -> Result<&Arc<PackRuntime>> {
1643 let key = FlowKey {
1644 pack_id: ctx.pack_id.to_string(),
1645 flow_id: ctx.flow_id.to_string(),
1646 };
1647 let idx = self.flow_sources.get(&key).with_context(|| {
1648 format!("flow {} (pack {}) not registered", ctx.flow_id, ctx.pack_id)
1649 })?;
1650 Ok(&self.packs[*idx])
1651 }
1652
1653 fn extract_state_key_helper(payload: &Value) -> Result<String> {
1654 payload
1655 .get("key")
1656 .and_then(Value::as_str)
1657 .map(String::from)
1658 .filter(|k| !k.is_empty())
1659 .context("state node payload missing required `key` (non-empty string)")
1660 }
1661
1662 fn state_tenant_ctx(&self, ctx: &FlowContext<'_>) -> Result<greentic_types::TenantCtx> {
1663 let env = greentic_types::EnvId::from_str(&self.default_env)
1664 .with_context(|| format!("invalid env id `{}`", self.default_env))?;
1665 let tenant = greentic_types::TenantId::from_str(ctx.tenant)
1666 .with_context(|| format!("invalid tenant id `{}`", ctx.tenant))?;
1667 Ok(greentic_types::TenantCtx::new(env, tenant))
1668 }
1669
1670 async fn apply_jump(
1671 &self,
1672 ctx: &FlowContext<'_>,
1673 state: &mut ExecutionState,
1674 jump: JumpControl,
1675 ) -> Result<JumpTarget> {
1676 let target_flow = jump.flow.trim();
1677 if target_flow.is_empty() {
1678 bail!("missing_flow");
1679 }
1680
1681 let flow = self
1682 .get_or_load_flow(ctx.pack_id, target_flow)
1683 .await
1684 .with_context(|| format!("unknown_flow:{target_flow}"))?;
1685
1686 let target_node = if let Some(node) = jump.node.as_deref() {
1687 let parsed = NodeId::from_str(node).with_context(|| format!("unknown_node:{node}"))?;
1688 if !flow.nodes.contains_key(&parsed) {
1689 bail!("unknown_node:{node}");
1690 }
1691 parsed
1692 } else {
1693 flow.start
1694 .clone()
1695 .or_else(|| flow.nodes.keys().next().cloned())
1696 .ok_or_else(|| anyhow!("jump_failed: flow {target_flow} has no start node"))?
1697 };
1698
1699 let max_redirects = jump.max_redirects.unwrap_or(3);
1700 if state.redirect_count() >= max_redirects {
1701 bail!("redirect_limit");
1702 }
1703 state.increment_redirect_count();
1704 state.replace_input(jump.payload.clone());
1705 state.last_output = Some(jump.payload);
1706 tracing::info!(
1707 flow_id = %ctx.flow_id,
1708 target_flow = %target_flow,
1709 target_node = %target_node.as_str(),
1710 reason = ?jump.reason,
1711 redirects = state.redirect_count(),
1712 "flow.jump.applied"
1713 );
1714
1715 Ok(JumpTarget {
1716 flow_id: target_flow.to_string(),
1717 flow,
1718 node_id: target_node,
1719 })
1720 }
1721
1722 async fn execute_flow_call(&self, ctx: &FlowContext<'_>, payload: Value) -> Result<NodeOutput> {
1723 #[derive(Deserialize)]
1724 struct FlowCallPayload {
1725 #[serde(alias = "flow")]
1726 flow_id: String,
1727 #[serde(default)]
1728 input: Value,
1729 }
1730
1731 let call: FlowCallPayload =
1732 serde_json::from_value(payload).context("invalid payload for flow.call node")?;
1733 if call.flow_id.trim().is_empty() {
1734 bail!("flow.call requires a non-empty flow_id");
1735 }
1736
1737 let sub_input = if call.input.is_null() {
1738 Value::Null
1739 } else {
1740 call.input
1741 };
1742
1743 let flow_id_owned = call.flow_id;
1744 let action = "flow.call";
1745 let sub_ctx = FlowContext {
1746 tenant: ctx.tenant,
1747 pack_id: ctx.pack_id,
1748 flow_id: flow_id_owned.as_str(),
1749 node_id: None,
1750 tool: ctx.tool,
1751 action: Some(action),
1752 session_id: ctx.session_id,
1753 provider_id: ctx.provider_id,
1754 reply_scope: ctx.reply_scope,
1755 retry_config: ctx.retry_config,
1756 attempt: ctx.attempt,
1757 observer: ctx.observer,
1758 mocks: ctx.mocks,
1759 };
1760
1761 let execution = Box::pin(self.execute(sub_ctx, sub_input))
1762 .await
1763 .with_context(|| format!("flow.call failed for {}", flow_id_owned))?;
1764 match execution.status {
1765 FlowStatus::Completed => Ok(NodeOutput::new(execution.output)),
1766 FlowStatus::Waiting(wait) => bail!(
1767 "flow.call cannot pause (flow {} waiting {:?})",
1768 flow_id_owned,
1769 wait.reason
1770 ),
1771 }
1772 }
1773}
1774
1775fn execute_flow_goto(payload: Value) -> Result<DispatchOutcome> {
1794 #[derive(Deserialize)]
1795 struct FlowGotoPayload {
1796 #[serde(alias = "flow")]
1797 flow_id: String,
1798 #[serde(default)]
1799 node: Option<String>,
1800 #[serde(default)]
1801 input: Value,
1802 #[serde(default)]
1803 max_redirects: Option<u32>,
1804 #[serde(default)]
1805 reason: Option<String>,
1806 }
1807
1808 let goto: FlowGotoPayload =
1809 serde_json::from_value(payload).context("invalid payload for flow.goto node")?;
1810 let flow = goto.flow_id.trim().to_string();
1811 if flow.is_empty() {
1812 bail!("flow.goto requires a non-empty flow_id");
1813 }
1814 let node = goto
1815 .node
1816 .map(|n| n.trim().to_string())
1817 .filter(|n| !n.is_empty());
1818
1819 let jump = JumpControl {
1820 flow,
1821 node,
1822 payload: goto.input,
1823 hints: Value::Null,
1827 max_redirects: goto.max_redirects,
1828 reason: goto.reason.or_else(|| Some("flow.goto node".to_string())),
1829 };
1830 let output = NodeOutput::with_meta(jump.payload.clone(), jump.hints.clone());
1831 Ok(DispatchOutcome::with_control(
1832 output,
1833 NodeControl::Jump(jump),
1834 ))
1835}
1836
1837impl FlowEngine {
1838 async fn execute_component_exec(
1839 &self,
1840 ctx: &FlowContext<'_>,
1841 node_id: &str,
1842 node: &HostNode,
1843 payload: Value,
1844 event: &NodeEvent<'_>,
1845 overrides: ComponentOverrides<'_>,
1846 ) -> Result<NodeOutput> {
1847 #[derive(Deserialize)]
1848 struct ComponentPayload {
1849 #[serde(default, alias = "component_ref", alias = "component")]
1850 component: Option<String>,
1851 #[serde(alias = "op")]
1852 operation: Option<String>,
1853 #[serde(default)]
1854 input: Value,
1855 #[serde(default)]
1856 config: Value,
1857 }
1858
1859 let payload: ComponentPayload =
1860 serde_json::from_value(payload).context("invalid payload for component.exec")?;
1861 let component_ref = overrides
1862 .component
1863 .map(str::to_string)
1864 .or_else(|| payload.component.filter(|v| !v.trim().is_empty()))
1865 .with_context(|| "component.exec requires a component_ref")?;
1866 let operation = resolve_component_operation(
1867 node_id,
1868 node.component_id.as_str(),
1869 payload.operation,
1870 overrides.operation,
1871 node.operation_in_mapping.as_deref(),
1872 )?;
1873
1874 let call = ComponentCall {
1875 component_ref,
1876 operation,
1877 input: payload.input,
1878 config: payload.config,
1879 has_error_route: node_has_error_route(&node.routing),
1880 };
1881
1882 self.invoke_component_call(ctx, node_id, call, event).await
1883 }
1884
1885 async fn execute_component_call(
1886 &self,
1887 ctx: &FlowContext<'_>,
1888 node_id: &str,
1889 node: &HostNode,
1890 payload: Value,
1891 component_ref: &str,
1892 event: &NodeEvent<'_>,
1893 ) -> Result<NodeOutput> {
1894 let payload_operation = extract_operation_from_mapping(&payload);
1895 let (input, config) = split_operation_payload(payload);
1896 let operation = resolve_component_operation(
1897 node_id,
1898 node.component_id.as_str(),
1899 payload_operation,
1900 node.operation_name.as_deref(),
1901 node.operation_in_mapping.as_deref(),
1902 )?;
1903 let call = ComponentCall {
1904 component_ref: component_ref.to_string(),
1905 operation,
1906 input,
1907 config,
1908 has_error_route: node_has_error_route(&node.routing),
1909 };
1910 self.invoke_component_call(ctx, node_id, call, event).await
1911 }
1912
1913 async fn invoke_component_call(
1914 &self,
1915 ctx: &FlowContext<'_>,
1916 node_id: &str,
1917 mut call: ComponentCall,
1918 event: &NodeEvent<'_>,
1919 ) -> Result<NodeOutput> {
1920 self.validate_component(ctx, event, &call)?;
1921 let key = FlowKey {
1922 pack_id: ctx.pack_id.to_string(),
1923 flow_id: ctx.flow_id.to_string(),
1924 };
1925 let pack_idx = *self.flow_sources.get(&key).with_context(|| {
1926 format!("flow {} (pack {}) not registered", ctx.flow_id, ctx.pack_id)
1927 })?;
1928 let pack = Arc::clone(&self.packs[pack_idx]);
1929
1930 promote_card_config_to_invocation(&mut call.input, &call.config);
1937
1938 resolve_card_assets(&mut call.input, &pack);
1942
1943 let is_card = is_card_invocation(&call.input);
1951
1952 let input_json = if is_card {
1953 serde_json::to_string(&call.input)?
1954 } else {
1955 let meta = InvocationMeta {
1957 env: &self.default_env,
1958 tenant: ctx.tenant,
1959 flow_id: ctx.flow_id,
1960 node_id: Some(node_id),
1961 provider_id: ctx.provider_id,
1962 session_id: ctx.session_id,
1963 attempt: ctx.attempt,
1964 };
1965 let invocation_envelope =
1966 build_invocation_envelope(meta, call.operation.as_str(), call.input)
1967 .context("build invocation envelope for component call")?;
1968 serde_json::to_string(&invocation_envelope)?
1969 };
1970 let config_json = if call.config.is_null() {
1971 None
1972 } else {
1973 Some(serde_json::to_string(&call.config)?)
1974 };
1975
1976 let exec_ctx = component_exec_ctx(ctx, node_id);
1977 #[cfg(feature = "fault-injection")]
1978 {
1979 let fault_ctx = FaultContext {
1980 pack_id: ctx.pack_id,
1981 flow_id: ctx.flow_id,
1982 node_id: Some(node_id),
1983 attempt: ctx.attempt,
1984 };
1985 maybe_fail(FaultPoint::BeforeComponentCall, fault_ctx)
1986 .map_err(|err| anyhow!(err.to_string()))?;
1987 }
1988 let value = pack
1989 .invoke_component(
1990 call.component_ref.as_str(),
1991 exec_ctx,
1992 call.operation.as_str(),
1993 config_json,
1994 input_json,
1995 )
1996 .await?;
1997 #[cfg(feature = "fault-injection")]
1998 {
1999 let fault_ctx = FaultContext {
2000 pack_id: ctx.pack_id,
2001 flow_id: ctx.flow_id,
2002 node_id: Some(node_id),
2003 attempt: ctx.attempt,
2004 };
2005 maybe_fail(FaultPoint::AfterComponentCall, fault_ctx)
2006 .map_err(|err| anyhow!(err.to_string()))?;
2007 }
2008
2009 if let Some((code, message)) = component_error(&value) {
2010 if call.has_error_route {
2015 return Ok(NodeOutput::errored(value));
2016 }
2017 bail!(
2018 "component {} failed: {}: {}",
2019 call.component_ref,
2020 code,
2021 message
2022 );
2023 }
2024 if let Some((code, message)) = mcp_tool_error(&value) {
2030 bail!(
2031 "component {} returned tool error: {}: {}",
2032 call.component_ref,
2033 code,
2034 message
2035 );
2036 }
2037 let meta = outcome_meta(&value);
2038 Ok(NodeOutput::with_meta(value, meta))
2039 }
2040
2041 async fn execute_provider_invoke(
2042 &self,
2043 ctx: &FlowContext<'_>,
2044 node_id: &str,
2045 state: &ExecutionState,
2046 payload: Value,
2047 event: &NodeEvent<'_>,
2048 ) -> Result<NodeOutput> {
2049 #[derive(Deserialize)]
2050 struct ProviderPayload {
2051 #[serde(default)]
2052 provider_id: Option<String>,
2053 #[serde(default)]
2054 provider_type: Option<String>,
2055 #[serde(default, alias = "operation")]
2056 op: Option<String>,
2057 #[serde(default)]
2058 input: Value,
2059 #[serde(default)]
2060 in_map: Value,
2061 #[serde(default)]
2062 out_map: Value,
2063 #[serde(default)]
2064 err_map: Value,
2065 }
2066
2067 let payload: ProviderPayload =
2068 serde_json::from_value(payload).context("invalid payload for provider.invoke")?;
2069 let op = payload
2070 .op
2071 .as_deref()
2072 .filter(|v| !v.trim().is_empty())
2073 .with_context(|| "provider.invoke requires an op")?
2074 .to_string();
2075
2076 let prev = state
2077 .last_output
2078 .as_ref()
2079 .cloned()
2080 .unwrap_or_else(|| Value::Object(JsonMap::new()));
2081 let base_ctx = template_context(state, prev);
2082
2083 let input_value = if !payload.in_map.is_null() {
2084 let mut ctx_value = base_ctx.clone();
2085 if let Value::Object(ref mut map) = ctx_value {
2086 map.insert("input".into(), payload.input.clone());
2087 map.insert("result".into(), payload.input.clone());
2088 }
2089 render_template_value(
2090 &payload.in_map,
2091 &ctx_value,
2092 TemplateOptions {
2093 allow_pointer: true,
2094 },
2095 )
2096 .context("failed to render provider.invoke in_map")?
2097 } else if !payload.input.is_null() {
2098 payload.input
2099 } else {
2100 Value::Null
2101 };
2102 let input_json = serde_json::to_vec(&input_value)?;
2103
2104 self.validate_tool(
2105 ctx,
2106 event,
2107 payload.provider_id.as_deref(),
2108 payload.provider_type.as_deref(),
2109 &op,
2110 &input_value,
2111 )?;
2112
2113 let key = FlowKey {
2114 pack_id: ctx.pack_id.to_string(),
2115 flow_id: ctx.flow_id.to_string(),
2116 };
2117 let pack_idx = *self.flow_sources.get(&key).with_context(|| {
2118 format!("flow {} (pack {}) not registered", ctx.flow_id, ctx.pack_id)
2119 })?;
2120 let pack = Arc::clone(&self.packs[pack_idx]);
2121 let binding = pack.resolve_provider(
2122 payload.provider_id.as_deref(),
2123 payload.provider_type.as_deref(),
2124 );
2125
2126 if binding.is_err()
2128 && let Some(output) = self.try_invoke_cross_pack_resolver(
2129 payload.provider_id.as_deref(),
2130 payload.provider_type.as_deref(),
2131 &op,
2132 &input_json,
2133 ctx.tenant,
2134 )?
2135 {
2136 return Ok(output);
2137 }
2138
2139 let binding = binding?;
2140 let exec_ctx = component_exec_ctx(ctx, node_id);
2141 #[cfg(feature = "fault-injection")]
2142 {
2143 let fault_ctx = FaultContext {
2144 pack_id: ctx.pack_id,
2145 flow_id: ctx.flow_id,
2146 node_id: Some(node_id),
2147 attempt: ctx.attempt,
2148 };
2149 maybe_fail(FaultPoint::BeforeToolCall, fault_ctx)
2150 .map_err(|err| anyhow!(err.to_string()))?;
2151 }
2152 let provider_metric_id = payload
2153 .provider_id
2154 .as_deref()
2155 .or(payload.provider_type.as_deref())
2156 .unwrap_or("unknown");
2157 let invoke_started = std::time::Instant::now();
2158 let invoke_result = pack
2159 .invoke_provider(&binding, exec_ctx, &op, input_json)
2160 .await;
2161 let invoke_duration_ms = invoke_started.elapsed().as_secs_f64() * 1000.0;
2162 crate::metrics::record_provider_invocation(
2163 ctx.tenant,
2164 provider_metric_id,
2165 &op,
2166 if invoke_result.is_ok() { "ok" } else { "err" },
2167 invoke_duration_ms,
2168 );
2169 let result = invoke_result?;
2170 #[cfg(feature = "fault-injection")]
2171 {
2172 let fault_ctx = FaultContext {
2173 pack_id: ctx.pack_id,
2174 flow_id: ctx.flow_id,
2175 node_id: Some(node_id),
2176 attempt: ctx.attempt,
2177 };
2178 maybe_fail(FaultPoint::AfterToolCall, fault_ctx)
2179 .map_err(|err| anyhow!(err.to_string()))?;
2180 }
2181
2182 let output = if payload.out_map.is_null() {
2183 result
2184 } else {
2185 let mut ctx_value = base_ctx;
2186 if let Value::Object(ref mut map) = ctx_value {
2187 map.insert("input".into(), result.clone());
2188 map.insert("result".into(), result.clone());
2189 }
2190 render_template_value(
2191 &payload.out_map,
2192 &ctx_value,
2193 TemplateOptions {
2194 allow_pointer: true,
2195 },
2196 )
2197 .context("failed to render provider.invoke out_map")?
2198 };
2199 let _ = payload.err_map;
2200 Ok(NodeOutput::new(output))
2201 }
2202
2203 fn try_invoke_cross_pack_resolver(
2204 &self,
2205 provider_id: Option<&str>,
2206 provider_type: Option<&str>,
2207 op: &str,
2208 input_json: &[u8],
2209 tenant: &str,
2210 ) -> Result<Option<NodeOutput>> {
2211 eprintln!(
2212 "[DEBUG] provider.invoke: pack-local failed, has_resolver={}",
2213 self.cross_pack_resolver.is_some()
2214 );
2215 let Some(resolver) = self.cross_pack_resolver.as_ref() else {
2216 return Ok(None);
2217 };
2218 let provider_id = provider_id.unwrap_or("unknown");
2219 tracing::info!(
2220 provider_id,
2221 op = %op,
2222 "provider.invoke: pack-local resolution failed, trying cross-pack resolver"
2223 );
2224 let result_value =
2225 resolver.invoke(provider_id, provider_type, op, input_json, tenant, None)?;
2226 Ok(Some(NodeOutput::new(result_value)))
2227 }
2228
2229 fn validate_component(
2230 &self,
2231 ctx: &FlowContext<'_>,
2232 event: &NodeEvent<'_>,
2233 call: &ComponentCall,
2234 ) -> Result<()> {
2235 if self.validation.mode == ValidationMode::Off {
2236 return Ok(());
2237 }
2238 let mut metadata = JsonMap::new();
2239 metadata.insert("tenant_id".to_string(), json!(ctx.tenant));
2240 if let Some(id) = ctx.session_id {
2241 metadata.insert("session".to_string(), json!({ "id": id }));
2242 }
2243 let envelope = json!({
2244 "component_id": call.component_ref,
2245 "operation": call.operation,
2246 "input": call.input,
2247 "config": call.config,
2248 "metadata": Value::Object(metadata),
2249 });
2250 let issues = validate_component_envelope(&envelope);
2251 self.report_validation(ctx, event, "component", issues)
2252 }
2253
2254 fn validate_tool(
2255 &self,
2256 ctx: &FlowContext<'_>,
2257 event: &NodeEvent<'_>,
2258 provider_id: Option<&str>,
2259 provider_type: Option<&str>,
2260 operation: &str,
2261 input: &Value,
2262 ) -> Result<()> {
2263 if self.validation.mode == ValidationMode::Off {
2264 return Ok(());
2265 }
2266 let tool_id = provider_id.or(provider_type).unwrap_or("provider.invoke");
2267 let mut metadata = JsonMap::new();
2268 metadata.insert("tenant_id".to_string(), json!(ctx.tenant));
2269 if let Some(id) = ctx.session_id {
2270 metadata.insert("session".to_string(), json!({ "id": id }));
2271 }
2272 let envelope = json!({
2273 "tool_id": tool_id,
2274 "operation": operation,
2275 "input": input,
2276 "metadata": Value::Object(metadata),
2277 });
2278 let issues = validate_tool_envelope(&envelope);
2279 self.report_validation(ctx, event, "tool", issues)
2280 }
2281
2282 fn report_validation(
2283 &self,
2284 ctx: &FlowContext<'_>,
2285 event: &NodeEvent<'_>,
2286 kind: &str,
2287 issues: Vec<ValidationIssue>,
2288 ) -> Result<()> {
2289 if issues.is_empty() {
2290 return Ok(());
2291 }
2292 if let Some(observer) = ctx.observer {
2293 observer.on_validation(event, &issues);
2294 }
2295 match self.validation.mode {
2296 ValidationMode::Warn => {
2297 tracing::warn!(
2298 tenant = ctx.tenant,
2299 flow_id = ctx.flow_id,
2300 node_id = event.node_id,
2301 kind,
2302 issues = ?issues,
2303 "invocation envelope validation issues"
2304 );
2305 Ok(())
2306 }
2307 ValidationMode::Error => {
2308 tracing::error!(
2309 tenant = ctx.tenant,
2310 flow_id = ctx.flow_id,
2311 node_id = event.node_id,
2312 kind,
2313 issues = ?issues,
2314 "invocation envelope validation failed"
2315 );
2316 bail!("invocation_validation_failed");
2317 }
2318 ValidationMode::Off => Ok(()),
2319 }
2320 }
2321
2322 pub fn flows(&self) -> &[FlowDescriptor] {
2323 &self.flows
2324 }
2325
2326 pub async fn flow_node_ids(&self, pack_id: &str, flow_id: &str) -> Vec<String> {
2334 match self.get_or_load_flow(pack_id, flow_id).await {
2335 Ok(flow) => flow
2336 .nodes
2337 .keys()
2338 .map(|id| id.as_str().to_string())
2339 .collect(),
2340 Err(error) => {
2341 tracing::debug!(
2342 pack_id,
2343 flow_id,
2344 error = %error,
2345 "flow not loadable while listing node ids; treating as no nodes"
2346 );
2347 Vec::new()
2348 }
2349 }
2350 }
2351
2352 pub fn flow_by_key(&self, pack_id: &str, flow_id: &str) -> Option<&FlowDescriptor> {
2353 self.flows
2354 .iter()
2355 .find(|descriptor| descriptor.pack_id == pack_id && descriptor.id == flow_id)
2356 }
2357
2358 pub fn flow_by_type(&self, flow_type: &str) -> Option<&FlowDescriptor> {
2359 let mut matches = self
2360 .flows
2361 .iter()
2362 .filter(|descriptor| descriptor.flow_type == flow_type);
2363 let first = matches.next()?;
2364 if matches.next().is_some() {
2365 return None;
2366 }
2367 Some(first)
2368 }
2369
2370 pub fn entry_flow_by_type(&self, flow_type: &str) -> Option<&FlowDescriptor> {
2388 let mut matches = self.flows.iter().filter(|descriptor| {
2389 descriptor.flow_type == flow_type
2390 && descriptor.entry
2391 && !self
2392 .messaging_provider_pack_ids
2393 .contains(&descriptor.pack_id)
2394 });
2395 let first = matches.next()?;
2396 if matches.next().is_some() {
2397 return None;
2398 }
2399 Some(first)
2400 }
2401
2402 pub fn flow_by_id(&self, flow_id: &str) -> Option<&FlowDescriptor> {
2403 let mut matches = self
2404 .flows
2405 .iter()
2406 .filter(|descriptor| descriptor.id == flow_id);
2407 let first = matches.next()?;
2408 if matches.next().is_some() {
2409 return None;
2410 }
2411 Some(first)
2412 }
2413}
2414
2415pub trait ExecutionObserver: Send + Sync {
2416 fn on_node_start(&self, event: &NodeEvent<'_>);
2417 fn on_node_end(&self, event: &NodeEvent<'_>, output: &Value);
2418 fn on_node_error(&self, event: &NodeEvent<'_>, error: &dyn StdError);
2419 fn on_validation(&self, _event: &NodeEvent<'_>, _issues: &[ValidationIssue]) {}
2420}
2421
2422pub struct NodeEvent<'a> {
2423 pub context: &'a FlowContext<'a>,
2424 pub node_id: &'a str,
2425 pub node: &'a HostNode,
2426 pub payload: &'a Value,
2427}
2428
2429#[cfg(conversational_dw_agent_port)]
2445const MAX_PARK_TURNS: u32 = 100;
2446
2447#[derive(Clone, Debug, Serialize, Deserialize)]
2454struct PendingCardAnswers {
2455 node_id: String,
2456 answers: JsonMap<String, Value>,
2457}
2458
2459#[derive(Clone, Debug, Serialize, Deserialize)]
2460pub struct ExecutionState {
2461 #[serde(default)]
2462 entry: Value,
2463 #[serde(default)]
2464 input: Value,
2465 #[serde(default)]
2466 nodes: HashMap<String, NodeOutput>,
2467 #[serde(default)]
2468 egress: Vec<Value>,
2469 #[serde(default, skip_serializing_if = "Option::is_none")]
2470 last_output: Option<Value>,
2471 #[serde(default)]
2472 redirect_count: u32,
2473 #[serde(default)]
2474 vars: JsonMap<String, Value>,
2475 #[serde(default)]
2480 park_turns: HashMap<String, u32>,
2481 #[serde(default)]
2485 pending_agent_await: HashMap<String, ()>,
2486 #[serde(default)]
2491 pending_approval_await: HashMap<String, ()>,
2492 #[serde(default, skip_serializing_if = "Option::is_none")]
2496 pending_card_answers: Option<PendingCardAnswers>,
2497}
2498
2499impl ExecutionState {
2500 fn new(input: Value) -> Self {
2501 Self {
2502 entry: input.clone(),
2503 input,
2504 nodes: HashMap::new(),
2505 egress: Vec::new(),
2506 last_output: None,
2507 redirect_count: 0,
2508 vars: JsonMap::new(),
2509 park_turns: HashMap::new(),
2510 pending_agent_await: HashMap::new(),
2511 pending_approval_await: HashMap::new(),
2512 pending_card_answers: None,
2513 }
2514 }
2515
2516 #[allow(dead_code)]
2520 fn ensure_entry(&mut self) {
2521 if self.entry.is_null() {
2522 self.entry = self.input.clone();
2523 }
2524 }
2525
2526 fn context(&self) -> Value {
2527 let mut nodes = JsonMap::new();
2528 for (id, output) in &self.nodes {
2529 nodes.insert(
2530 id.clone(),
2531 json!({
2532 "ok": output.ok,
2533 "payload": output.payload.clone(),
2534 "meta": output.meta.clone(),
2535 }),
2536 );
2537 }
2538 json!({
2539 "entry": self.entry.clone(),
2540 "input": self.input.clone(),
2541 "nodes": nodes,
2542 "redirect_count": self.redirect_count,
2543 })
2544 }
2545
2546 fn outputs_map(&self) -> JsonMap<String, Value> {
2547 let mut outputs = JsonMap::new();
2548 for (id, output) in &self.nodes {
2549 outputs.insert(id.clone(), node_output_view(&output.payload));
2550 }
2551 outputs
2552 }
2553 fn push_egress(&mut self, payload: Value) {
2554 self.egress.push(payload);
2555 }
2556
2557 fn replace_input(&mut self, input: Value) {
2558 self.input = input;
2559 }
2560
2561 fn clear_egress(&mut self) {
2562 self.egress.clear();
2563 }
2564
2565 fn redirect_count(&self) -> u32 {
2566 self.redirect_count
2567 }
2568
2569 fn increment_redirect_count(&mut self) {
2570 self.redirect_count = self.redirect_count.saturating_add(1);
2571 }
2572
2573 fn finalize_with(mut self, final_payload: Option<Value>) -> Value {
2574 if self.egress.is_empty() {
2575 return final_payload.unwrap_or(Value::Null);
2576 }
2577 let mut emitted = std::mem::take(&mut self.egress);
2578 if let Some(value) = final_payload {
2579 match value {
2580 Value::Null => {}
2581 Value::Array(items) => emitted.extend(items),
2582 other if emitted.last() == Some(&other) => {}
2590 other => emitted.push(other),
2591 }
2592 }
2593 Value::Array(emitted)
2594 }
2595}
2596
2597#[derive(Clone, Debug, Serialize, Deserialize)]
2598struct NodeOutput {
2599 ok: bool,
2600 payload: Value,
2601 meta: Value,
2602}
2603
2604impl NodeOutput {
2605 fn new(payload: Value) -> Self {
2606 Self {
2607 ok: true,
2608 payload,
2609 meta: Value::Null,
2610 }
2611 }
2612
2613 #[allow(dead_code)]
2617 fn with_error(node_id: &str, err: &(dyn std::error::Error + 'static)) -> Self {
2618 Self {
2619 ok: false,
2620 payload: Value::Null,
2621 meta: json!({
2622 "error": {
2623 "kind": "flow_node_failed",
2624 "message": err.to_string(),
2625 "node_id": node_id,
2626 }
2627 }),
2628 }
2629 }
2630}
2631
2632struct DispatchOutcome {
2633 output: NodeOutput,
2634 control: NodeControl,
2635}
2636
2637impl DispatchOutcome {
2638 fn complete(output: NodeOutput) -> Self {
2639 Self {
2640 output,
2641 control: NodeControl::Continue,
2642 }
2643 }
2644
2645 fn wait(output: NodeOutput, reason: Option<String>) -> Self {
2646 Self {
2647 output,
2648 control: NodeControl::Wait { reason },
2649 }
2650 }
2651
2652 fn with_control(output: NodeOutput, control: NodeControl) -> Self {
2653 Self { output, control }
2654 }
2655}
2656
2657#[derive(Clone, Debug)]
2658enum NodeControl {
2659 Continue,
2660 Wait {
2661 reason: Option<String>,
2662 },
2663 Jump(JumpControl),
2664 Respond {
2665 text: Option<String>,
2666 card_cbor: Option<Vec<u8>>,
2667 needs_user: Option<bool>,
2668 },
2669}
2670
2671#[derive(Clone, Debug)]
2672struct JumpControl {
2673 flow: String,
2674 node: Option<String>,
2675 payload: Value,
2676 hints: Value,
2677 max_redirects: Option<u32>,
2678 reason: Option<String>,
2679}
2680
2681#[derive(Clone, Debug)]
2682struct JumpTarget {
2683 flow_id: String,
2684 flow: HostFlow,
2685 node_id: NodeId,
2686}
2687
2688impl NodeOutput {
2689 fn with_meta(payload: Value, meta: Value) -> Self {
2690 Self {
2691 ok: true,
2692 payload,
2693 meta,
2694 }
2695 }
2696
2697 fn errored(payload: Value) -> Self {
2701 Self {
2702 ok: false,
2703 payload,
2704 meta: Value::Null,
2705 }
2706 }
2707}
2708
2709fn component_exec_ctx(ctx: &FlowContext<'_>, node_id: &str) -> ComponentExecCtx {
2710 ComponentExecCtx {
2711 tenant: ComponentTenantCtx {
2712 tenant: ctx.tenant.to_string(),
2713 team: None,
2714 user: ctx.provider_id.map(str::to_string),
2715 trace_id: None,
2716 i18n_id: None,
2717 correlation_id: ctx.session_id.map(str::to_string),
2718 deadline_unix_ms: None,
2719 attempt: ctx.attempt,
2720 idempotency_key: ctx.session_id.map(str::to_string),
2721 },
2722 i18n_id: None,
2723 flow_id: ctx.flow_id.to_string(),
2724 node_id: Some(node_id.to_string()),
2725 }
2726}
2727
2728fn to_node_output(value: &Value) -> greentic_types::node_io::NodeOutput {
2740 use greentic_types::node_io::{ErrorKind, NodeError, NodeOutput as NioOutput};
2741
2742 if let Value::Object(map) = value {
2743 let native_errors = map.contains_key("errors") && !map.contains_key("ok");
2746 let native_data = map.contains_key("data") && !map.contains_key("ok") && map.len() == 1;
2747 if (native_errors || native_data)
2748 && let Ok(parsed) = serde_json::from_value::<NioOutput>(value.clone())
2749 {
2750 return parsed;
2751 }
2752 if let Some((code, message)) = component_error(value) {
2754 return NioOutput::failed(vec![NodeError {
2755 code,
2756 message,
2757 kind: ErrorKind::Internal,
2758 retryable: false,
2759 source: None,
2760 details: Value::Null,
2761 }]);
2762 }
2763 }
2764 NioOutput::ok(value.clone())
2766}
2767
2768fn node_output_view(payload: &Value) -> Value {
2773 let nio = to_node_output(payload);
2774 let data = nio.data().cloned().unwrap_or(Value::Null);
2775 let errors = serde_json::to_value(nio.errors()).unwrap_or_else(|_| Value::Array(Vec::new()));
2776 match payload {
2777 Value::Object(map) => {
2778 let mut view = map.clone();
2779 view.insert("data".to_string(), data);
2780 view.insert("errors".to_string(), errors);
2781 Value::Object(view)
2782 }
2783 other => other.clone(),
2784 }
2785}
2786
2787enum DeclaredAnswerFields {
2799 Absent,
2802 Malformed,
2806 Declared(Vec<String>),
2808}
2809
2810fn declared_answer_fields(payload_expr: &Value) -> DeclaredAnswerFields {
2838 let Some(raw) = payload_expr.get("answer_fields") else {
2839 return DeclaredAnswerFields::Absent;
2840 };
2841 let Some(arr) = raw.as_array() else {
2842 return DeclaredAnswerFields::Malformed;
2843 };
2844 match arr
2845 .iter()
2846 .map(|entry| entry.as_str().map(str::to_string))
2847 .collect::<Option<Vec<String>>>()
2848 {
2849 Some(list) => DeclaredAnswerFields::Declared(list),
2850 None => DeclaredAnswerFields::Malformed,
2851 }
2852}
2853
2854fn consume_routing_action(entry: &mut Value) {
2876 for pointer in ["/input/metadata", "/metadata"] {
2877 if let Some(Value::Object(meta)) = entry.pointer_mut(pointer) {
2878 meta.remove("action");
2879 }
2880 }
2881}
2882
2883fn attach_pending_card_answers(
2886 state: &mut ExecutionState,
2887 node_id: &str,
2888 node: &HostNode,
2889 output: &mut NodeOutput,
2890) -> bool {
2891 let is_target = state
2892 .pending_card_answers
2893 .as_ref()
2894 .is_some_and(|pending| pending.node_id == node_id);
2895 if !is_target {
2896 return false;
2897 }
2898 let Some(pending) = state.pending_card_answers.take() else {
2899 return false;
2900 };
2901 let Some(map) = output.payload.as_object_mut() else {
2905 tracing::warn!(
2906 node_id = %node_id,
2907 "node output payload is not an object; submitted answers not attached"
2908 );
2909 return true;
2910 };
2911 if map.contains_key("answers") {
2912 tracing::warn!(
2913 node_id = %node_id,
2914 "node output already carries `answers`; overwriting with the submitted fields"
2915 );
2916 }
2917 let answers = match declared_answer_fields(&node.payload_expr) {
2918 DeclaredAnswerFields::Declared(allow_list) => {
2919 let allowed: std::collections::HashSet<&str> =
2920 allow_list.iter().map(String::as_str).collect();
2921 pending
2922 .answers
2923 .into_iter()
2924 .filter(|(key, _)| allowed.contains(key.as_str()))
2925 .collect()
2926 }
2927 DeclaredAnswerFields::Malformed => {
2928 tracing::warn!(
2934 node_id = %node_id,
2935 "answer_fields present but malformed; falling back to unfiltered answers"
2936 );
2937 pending.answers
2938 }
2939 DeclaredAnswerFields::Absent => pending.answers,
2940 };
2941 map.insert("answers".to_string(), Value::Object(answers));
2942 true
2943}
2944
2945fn pending_from_snapshot(snapshot: &FlowSnapshot, input: &Value) -> Option<PendingCardAnswers> {
2953 if !snapshot.awaiting_submit {
2954 return None;
2955 }
2956 Some(PendingCardAnswers {
2957 node_id: snapshot.next_node.clone(),
2958 answers: submitted_fields(input),
2959 })
2960}
2961
2962fn outcome_meta(output: &Value) -> Value {
2963 match output.get("outcome").and_then(Value::as_str) {
2964 Some(outcome) => json!({ "outcome": outcome }),
2965 None => Value::Null,
2966 }
2967}
2968
2969fn component_error(value: &Value) -> Option<(String, String)> {
2970 let obj = value.as_object()?;
2971 let ok = obj.get("ok").and_then(Value::as_bool)?;
2972 if ok {
2973 return None;
2974 }
2975 let err = obj.get("error")?.as_object()?;
2976 let code = err
2977 .get("code")
2978 .and_then(Value::as_str)
2979 .unwrap_or("component_error");
2980 let message = err
2981 .get("message")
2982 .and_then(Value::as_str)
2983 .unwrap_or("component reported error");
2984 Some((code.to_string(), message.to_string()))
2985}
2986
2987fn mcp_tool_error(value: &Value) -> Option<(String, String)> {
2992 let obj = value.as_object()?;
2993 if obj.contains_key("result") {
2995 return None;
2996 }
2997 let err = obj.get("error")?.as_object()?;
2998 let code = err
2999 .get("code")
3000 .and_then(Value::as_str)
3001 .unwrap_or("tool_error");
3002 let raw_message = err
3003 .get("message")
3004 .and_then(Value::as_str)
3005 .unwrap_or("tool returned an error");
3006 let status = err.get("status").and_then(Value::as_u64);
3007 let message = match status {
3008 Some(s) => format!("{raw_message} (status {s})"),
3009 None => raw_message.to_string(),
3010 };
3011 Some((code.to_string(), message))
3012}
3013
3014fn extract_wait_reason(payload: &Value) -> Option<String> {
3015 match payload {
3016 Value::String(s) => Some(s.clone()),
3017 Value::Object(map) => map
3018 .get("reason")
3019 .and_then(Value::as_str)
3020 .map(|value| value.to_string()),
3021 _ => None,
3022 }
3023}
3024
3025fn component_dispatch_outcome(output: NodeOutput) -> Result<DispatchOutcome> {
3026 if let Some(control) = parse_component_control(&output.payload)? {
3027 return Ok(match control {
3028 NodeControl::Jump(jump) => {
3029 let adjusted = NodeOutput::with_meta(jump.payload.clone(), jump.hints.clone());
3030 DispatchOutcome::with_control(adjusted, NodeControl::Jump(jump))
3031 }
3032 NodeControl::Respond {
3033 text,
3034 card_cbor,
3035 needs_user,
3036 } => DispatchOutcome::with_control(
3037 output,
3038 NodeControl::Respond {
3039 text,
3040 card_cbor,
3041 needs_user,
3042 },
3043 ),
3044 other => DispatchOutcome::with_control(output, other),
3045 });
3046 }
3047 Ok(DispatchOutcome::complete(output))
3048}
3049
3050fn parse_component_control(payload: &Value) -> Result<Option<NodeControl>> {
3051 let Value::Object(map) = payload else {
3052 return Ok(None);
3053 };
3054 let Some(control_value) = map.get("greentic_control") else {
3055 return Ok(None);
3056 };
3057 let control = control_value
3058 .as_object()
3059 .ok_or_else(|| anyhow!("jump_failed: greentic_control must be an object"))?;
3060 let action = control
3061 .get("action")
3062 .and_then(Value::as_str)
3063 .ok_or_else(|| anyhow!("jump_failed: greentic_control.action is required"))?;
3064 let version = control
3065 .get("v")
3066 .and_then(Value::as_u64)
3067 .ok_or_else(|| anyhow!("jump_failed: greentic_control.v is required"))?;
3068 if version != 1 {
3069 bail!("jump_failed: unsupported greentic_control.v={version}");
3070 }
3071
3072 match action {
3073 "jump" => {
3074 let flow = control
3075 .get("flow")
3076 .and_then(Value::as_str)
3077 .map(str::trim)
3078 .filter(|value| !value.is_empty())
3079 .ok_or_else(|| anyhow!("jump_failed: jump flow is required"))?
3080 .to_string();
3081 let node = control
3082 .get("node")
3083 .and_then(Value::as_str)
3084 .map(str::trim)
3085 .filter(|value| !value.is_empty())
3086 .map(str::to_string);
3087 let payload = control.get("payload").cloned().unwrap_or(Value::Null);
3088 let hints = control.get("hints").cloned().unwrap_or(Value::Null);
3089 let max_redirects = control
3090 .get("max_redirects")
3091 .and_then(Value::as_u64)
3092 .and_then(|value| u32::try_from(value).ok());
3093 let reason = control
3094 .get("reason")
3095 .and_then(Value::as_str)
3096 .map(str::to_string);
3097 Ok(Some(NodeControl::Jump(JumpControl {
3098 flow,
3099 node,
3100 payload,
3101 hints,
3102 max_redirects,
3103 reason,
3104 })))
3105 }
3106 "respond" => {
3107 let text = control
3108 .get("text")
3109 .and_then(Value::as_str)
3110 .map(str::to_string);
3111 let card_cbor = control
3112 .get("card_cbor")
3113 .and_then(Value::as_array)
3114 .map(|bytes| {
3115 bytes
3116 .iter()
3117 .filter_map(Value::as_u64)
3118 .filter_map(|value| u8::try_from(value).ok())
3119 .collect::<Vec<_>>()
3120 });
3121 let needs_user = control.get("needs_user").and_then(Value::as_bool);
3122 Ok(Some(NodeControl::Respond {
3123 text,
3124 card_cbor,
3125 needs_user,
3126 }))
3127 }
3128 _ => Ok(None),
3129 }
3130}
3131
3132fn alias_input_to_entry(mut entry: Value) -> Value {
3143 if let Value::Object(map) = &mut entry
3144 && !map.contains_key("input")
3145 {
3146 let base = Value::Object(map.clone());
3147 map.insert("input".into(), base);
3148 }
3149 entry
3150}
3151
3152fn template_context(state: &ExecutionState, prev: Value) -> Value {
3153 let entry = if state.entry.is_null() {
3154 Value::Object(JsonMap::new())
3155 } else {
3156 alias_input_to_entry(state.entry.clone())
3157 };
3158 let mut ctx = JsonMap::new();
3159 ctx.insert("entry".into(), entry.clone());
3160 ctx.insert("in".into(), entry); ctx.insert("prev".into(), prev);
3162 ctx.insert("node".into(), Value::Object(state.outputs_map()));
3163 ctx.insert("state".into(), state.context());
3164 ctx.insert("vars".into(), Value::Object(state.vars.clone()));
3165 Value::Object(ctx)
3166}
3167
3168impl From<Flow> for HostFlow {
3169 fn from(value: Flow) -> Self {
3170 let mut nodes = IndexMap::new();
3171 for (id, node) in value.nodes {
3172 nodes.insert(id.clone(), HostNode::from(node));
3173 }
3174 let start = value
3175 .entrypoints
3176 .get("default")
3177 .and_then(Value::as_str)
3178 .and_then(|id| NodeId::from_str(id).ok())
3179 .or_else(|| nodes.keys().next().cloned());
3180 let slot_schema = value
3184 .metadata
3185 .extra
3186 .get(SLOT_SCHEMA_METADATA_KEY)
3187 .filter(|v| !v.is_null())
3188 .cloned();
3189 let vars_init = value
3190 .metadata
3191 .extra
3192 .get("vars_init")
3193 .and_then(|v| v.as_object())
3194 .map(|decls| {
3195 decls
3196 .iter()
3197 .filter_map(|(name, decl)| {
3198 decl.get("default").map(|d| (name.clone(), d.clone()))
3199 })
3200 .collect::<JsonMap<String, Value>>()
3201 })
3202 .unwrap_or_default();
3203 Self {
3204 id: value.id.as_str().to_string(),
3205 start,
3206 nodes,
3207 slot_schema,
3208 vars_init,
3209 }
3210 }
3211}
3212
3213impl From<Node> for HostNode {
3214 fn from(node: Node) -> Self {
3215 let full_ref = node.component.id.as_str().to_string();
3216 let operation_in_mapping = extract_operation_from_mapping(&node.input.mapping);
3217 let is_builtin = full_ref.starts_with("component.exec")
3226 || full_ref.starts_with("flow.")
3227 || full_ref.starts_with("emit.")
3228 || full_ref.starts_with("session.")
3229 || full_ref.starts_with("provider.")
3230 || full_ref.starts_with("dw.")
3231 || full_ref.starts_with("sorla.")
3232 || full_ref.starts_with("operala.")
3233 || full_ref.starts_with("agentic.")
3234 || full_ref.starts_with("var.")
3235 || full_ref.starts_with("mcp:");
3238 let (component_ref, raw_operation) =
3239 if node.component.operation.is_some() || is_builtin || operation_in_mapping.is_some() {
3240 (full_ref, node.component.operation.clone())
3241 } else if let Some(dot) = full_ref.rfind('.') {
3242 let comp = full_ref[..dot].to_string();
3243 let op = full_ref[dot + 1..].to_string();
3244 (comp, Some(op))
3245 } else {
3246 (full_ref, None)
3247 };
3248 let operation_is_component_exec = raw_operation.as_deref() == Some("component.exec");
3249 let operation_is_emit = raw_operation
3250 .as_deref()
3251 .map(|op| op.starts_with("emit."))
3252 .unwrap_or(false);
3253 let is_component_exec = component_ref == "component.exec" || operation_is_component_exec;
3254
3255 let kind = if is_component_exec {
3256 let target = if component_ref == "component.exec" {
3257 if let Some(op) = raw_operation
3258 .as_deref()
3259 .filter(|op| op.starts_with("emit."))
3260 {
3261 op.to_string()
3262 } else {
3263 extract_target_component(&node.input.mapping)
3264 .unwrap_or_else(|| "component.exec".to_string())
3265 }
3266 } else {
3267 extract_target_component(&node.input.mapping)
3268 .unwrap_or_else(|| component_ref.clone())
3269 };
3270 if target.starts_with("emit.") {
3271 NodeKind::BuiltinEmit {
3272 kind: emit_kind_from_ref(&target),
3273 }
3274 } else {
3275 NodeKind::Exec {
3276 target_component: target,
3277 }
3278 }
3279 } else if operation_is_emit {
3280 NodeKind::BuiltinEmit {
3281 kind: emit_kind_from_ref(raw_operation.as_deref().unwrap_or("emit.log")),
3282 }
3283 } else {
3284 match component_ref.as_str() {
3285 "flow.call" => NodeKind::FlowCall,
3286 "flow.goto" => NodeKind::FlowGoto,
3287 "provider.invoke" => NodeKind::ProviderInvoke,
3288 "session.wait" => NodeKind::Wait,
3289 "state.get" => NodeKind::BuiltinStateGet,
3290 "state.set" => NodeKind::BuiltinStateSet,
3291 "var.set" => {
3292 let name = node
3293 .input
3294 .mapping
3295 .get("name")
3296 .and_then(Value::as_str)
3297 .unwrap_or("")
3298 .to_string();
3299 let value = node
3300 .input
3301 .mapping
3302 .get("value")
3303 .cloned()
3304 .unwrap_or(Value::Null);
3305 NodeKind::VarSet { name, value }
3306 }
3307 "dw.agent" => NodeKind::DwAgent {
3308 agent_id: raw_operation.clone().unwrap_or_default(),
3309 },
3310 "dw.agent_graph" => NodeKind::DwAgentGraph {
3311 graph_id: raw_operation.clone().unwrap_or_default(),
3312 },
3313 "sorla.call" => NodeKind::SorlaCall {
3314 target: raw_operation.clone().unwrap_or_default(),
3315 },
3316 "operala.call" => NodeKind::OperalaCall {
3317 target: raw_operation.clone().unwrap_or_default(),
3318 },
3319 "agentic.call" => NodeKind::AgenticCall {
3320 target: raw_operation.clone().unwrap_or_default(),
3321 },
3322 "telco-x.call" => NodeKind::TelcoXCall {
3323 target: raw_operation.clone().unwrap_or_default(),
3324 },
3325 "approval.call" => NodeKind::ApprovalCall {
3326 target: raw_operation.clone().unwrap_or_default(),
3327 },
3328 comp if comp.starts_with("emit.") => NodeKind::BuiltinEmit {
3329 kind: emit_kind_from_ref(comp),
3330 },
3331 "mcp" => mcp_node_kind(&node.input.mapping, raw_operation.as_deref()),
3340 comp if comp.starts_with("mcp:") => mcp_node_kind(&node.input.mapping, Some(comp)),
3346 other => NodeKind::PackComponent {
3347 component_ref: other.to_string(),
3348 },
3349 }
3350 };
3351 let component_label = match &kind {
3352 NodeKind::Exec { .. } => "component.exec".to_string(),
3353 NodeKind::PackComponent { component_ref } => component_ref.clone(),
3354 NodeKind::ProviderInvoke => "provider.invoke".to_string(),
3355 NodeKind::FlowCall => "flow.call".to_string(),
3356 NodeKind::FlowGoto => "flow.goto".to_string(),
3357 NodeKind::BuiltinEmit { kind } => emit_ref_from_kind(kind),
3358 NodeKind::BuiltinStateGet => "state.get".to_string(),
3359 NodeKind::BuiltinStateSet => "state.set".to_string(),
3360 NodeKind::VarSet { .. } => "var.set".to_string(),
3361 NodeKind::Wait => "session.wait".to_string(),
3362 NodeKind::DwAgent { .. } => "dw.agent".to_string(),
3363 NodeKind::DwAgentGraph { .. } => "dw.agent_graph".to_string(),
3364 NodeKind::SorlaCall { .. } => "sorla.call".to_string(),
3365 NodeKind::OperalaCall { .. } => "operala.call".to_string(),
3366 NodeKind::AgenticCall { .. } => "agentic.call".to_string(),
3367 NodeKind::TelcoXCall { .. } => "telco-x.call".to_string(),
3368 NodeKind::ApprovalCall { .. } => "approval.call".to_string(),
3369 NodeKind::Mcp { server_id, tool } => format!("mcp:{server_id}/{tool}"),
3370 };
3371 let operation_name = if is_component_exec && operation_is_component_exec {
3372 None
3373 } else {
3374 raw_operation.clone()
3375 };
3376 let vars_out = node
3380 .input
3381 .mapping
3382 .get("vars_out")
3383 .and_then(Value::as_object)
3384 .cloned();
3385 let payload_expr = match kind {
3386 NodeKind::BuiltinEmit { .. } => extract_emit_payload(&node.input.mapping),
3387 NodeKind::VarSet { .. } => Value::Null,
3390 _ => {
3391 let mut mapping = node.input.mapping.clone();
3395 if let Some(obj) = mapping.as_object_mut() {
3396 obj.remove("vars_out");
3397 }
3398 mapping
3399 }
3400 };
3401 Self {
3402 kind,
3403 component: component_label,
3404 component_id: if is_component_exec {
3405 "component.exec".to_string()
3406 } else {
3407 component_ref
3408 },
3409 operation_name,
3410 operation_in_mapping,
3411 payload_expr,
3412 routing: node.routing,
3413 vars_out,
3414 }
3415 }
3416}
3417
3418fn mcp_node_kind(payload: &Value, legacy_ref: Option<&str>) -> NodeKind {
3431 if let Some((server_id, tool)) = crate::runner::mcp_node::server_tool_from_payload(payload) {
3432 return NodeKind::Mcp { server_id, tool };
3433 }
3434 if let Some((server_id, tool)) = legacy_ref.and_then(parse_legacy_mcp_ref) {
3435 return NodeKind::Mcp { server_id, tool };
3436 }
3437 NodeKind::PackComponent {
3438 component_ref: "mcp".to_string(),
3439 }
3440}
3441
3442fn parse_legacy_mcp_ref(reference: &str) -> Option<(String, String)> {
3446 let rest = reference.strip_prefix("mcp:").unwrap_or(reference);
3447 let (server, tool) = rest.split_once('/')?;
3448 if server.is_empty() || tool.is_empty() {
3449 return None;
3450 }
3451 Some((server.to_string(), tool.to_string()))
3452}
3453
3454fn extract_target_component(payload: &Value) -> Option<String> {
3455 match payload {
3456 Value::Object(map) => map
3457 .get("component")
3458 .or_else(|| map.get("component_ref"))
3459 .and_then(Value::as_str)
3460 .map(|s| s.to_string()),
3461 _ => None,
3462 }
3463}
3464
3465fn extract_operation_from_mapping(payload: &Value) -> Option<String> {
3466 match payload {
3467 Value::Object(map) => map
3468 .get("operation")
3469 .or_else(|| map.get("op"))
3470 .and_then(Value::as_str)
3471 .map(str::trim)
3472 .filter(|value| !value.is_empty())
3473 .map(|value| value.to_string()),
3474 _ => None,
3475 }
3476}
3477
3478fn extract_emit_payload(payload: &Value) -> Value {
3479 if let Value::Object(map) = payload {
3480 if let Some(input) = map.get("input") {
3481 return input.clone();
3482 }
3483 if let Some(inner) = map.get("payload") {
3484 return inner.clone();
3485 }
3486 }
3487 payload.clone()
3488}
3489
3490fn split_operation_payload(payload: Value) -> (Value, Value) {
3491 if let Value::Object(mut map) = payload.clone()
3492 && map.contains_key("input")
3493 {
3494 let input = map.remove("input").unwrap_or(Value::Null);
3495 let config = map.remove("config").unwrap_or(Value::Null);
3496 let legacy_only = map.keys().all(|key| {
3497 matches!(
3498 key.as_str(),
3499 "operation" | "op" | "component" | "component_ref"
3500 )
3501 });
3502 if legacy_only {
3503 return (input, config);
3504 }
3505 }
3506 (payload, Value::Null)
3507}
3508
3509fn resolve_component_operation(
3510 node_id: &str,
3511 component_label: &str,
3512 payload_operation: Option<String>,
3513 operation_override: Option<&str>,
3514 operation_in_mapping: Option<&str>,
3515) -> Result<String> {
3516 if let Some(op) = operation_override
3517 .map(str::trim)
3518 .filter(|value| !value.is_empty())
3519 {
3520 return Ok(op.to_string());
3521 }
3522
3523 if let Some(op) = payload_operation
3524 .as_deref()
3525 .map(str::trim)
3526 .filter(|value| !value.is_empty())
3527 {
3528 return Ok(op.to_string());
3529 }
3530
3531 let mut message = format!(
3532 "missing operation for node `{}` (component `{}`); expected node.component.operation to be set",
3533 node_id, component_label,
3534 );
3535 if let Some(found) = operation_in_mapping {
3536 message.push_str(&format!(
3537 ". Found operation in input.mapping (`{}`) but this is not used; pack compiler must preserve node.component.operation.",
3538 found
3539 ));
3540 }
3541 bail!(message);
3542}
3543
3544fn emit_kind_from_ref(component_ref: &str) -> EmitKind {
3545 match component_ref {
3546 "emit.log" => EmitKind::Log,
3547 "emit.response" => EmitKind::Response,
3548 other => EmitKind::Other(other.to_string()),
3549 }
3550}
3551
3552fn emit_ref_from_kind(kind: &EmitKind) -> String {
3553 match kind {
3554 EmitKind::Log => "emit.log".to_string(),
3555 EmitKind::Response => "emit.response".to_string(),
3556 EmitKind::Other(other) => other.clone(),
3557 }
3558}
3559
3560fn is_card_invocation(input: &Value) -> bool {
3563 if let Value::Object(map) = input {
3564 return map.contains_key("card_source") || map.contains_key("card_spec");
3565 }
3566 false
3567}
3568
3569fn promote_card_config_to_invocation(input: &mut Value, config: &Value) {
3581 if is_card_invocation(input) {
3582 return;
3583 }
3584
3585 let cfg_map = card_defaults_source(input, config);
3586 let Some(cfg) = cfg_map else { return };
3587
3588 let default_asset = cfg
3589 .get("default_card_asset")
3590 .and_then(Value::as_str)
3591 .map(str::trim)
3592 .filter(|value| !value.is_empty())
3593 .map(str::to_string);
3594 let default_inline = cfg
3595 .get("default_card_inline")
3596 .filter(|value| value.is_object() || value.is_array())
3597 .cloned();
3598 let default_source = cfg
3599 .get("default_source")
3600 .and_then(Value::as_str)
3601 .map(str::trim)
3602 .filter(|value| !value.is_empty())
3603 .map(str::to_lowercase);
3604
3605 if default_asset.is_none() && default_inline.is_none() && default_source.is_none() {
3606 return;
3607 }
3608
3609 let card_source = default_source.unwrap_or_else(|| {
3610 if default_inline.is_some() {
3611 "inline".to_string()
3612 } else {
3613 "asset".to_string()
3614 }
3615 });
3616
3617 let mut card_spec = serde_json::Map::new();
3618 match card_source.as_str() {
3619 "asset" => {
3620 if let Some(path) = default_asset {
3621 card_spec.insert("asset_path".into(), Value::String(path));
3622 }
3623 }
3624 "inline" => {
3625 if let Some(inline) = default_inline {
3626 card_spec.insert("inline_json".into(), inline);
3627 }
3628 }
3629 _ => {}
3630 }
3631
3632 if !matches!(input, Value::Object(_)) {
3633 *input = Value::Object(serde_json::Map::new());
3634 }
3635 if let Value::Object(map) = input {
3636 map.insert("card_source".into(), Value::String(card_source));
3637 map.insert("card_spec".into(), Value::Object(card_spec));
3638 }
3639}
3640
3641fn card_defaults_source<'a>(
3646 input: &'a Value,
3647 config: &'a Value,
3648) -> Option<&'a serde_json::Map<String, Value>> {
3649 if let Value::Object(map) = config {
3650 return Some(map);
3651 }
3652 if let Value::Object(map) = input
3653 && let Some(Value::Object(nested)) = map.get("config")
3654 {
3655 return Some(nested);
3656 }
3657 None
3658}
3659
3660fn inject_card_locale(payload: &mut Value, entry: &Value) {
3661 if !is_card_invocation(payload) {
3662 return;
3663 }
3664 let Value::Object(map) = payload else { return };
3665 if map.contains_key("locale") {
3666 return;
3667 }
3668 let locale = entry
3669 .pointer("/input/metadata/locale")
3670 .or_else(|| entry.pointer("/metadata/locale"))
3671 .and_then(Value::as_str);
3672 if let Some(locale) = locale {
3673 map.insert("locale".into(), Value::String(locale.to_string()));
3674 }
3675}
3676
3677fn inject_card_route(payload: &mut Value, entry: &Value, node: &HostNode) {
3692 let is_adaptive_card =
3693 node.component_id().contains("adaptive-card") || node.component.contains("adaptive-card");
3694 if !is_adaptive_card || is_card_invocation(payload) {
3695 return;
3696 }
3697 let route = entry
3698 .pointer("/input/metadata/routeToCardId")
3699 .or_else(|| entry.pointer("/metadata/routeToCardId"))
3700 .or_else(|| entry.pointer("/input/metadata/toCardId"))
3701 .or_else(|| entry.pointer("/metadata/toCardId"))
3702 .or_else(|| entry.pointer("/input/metadata/nextCardId"))
3703 .or_else(|| entry.pointer("/metadata/nextCardId"))
3704 .and_then(Value::as_str)
3705 .map(str::trim)
3706 .filter(|value| !value.is_empty());
3707 let Some(route) = route else {
3708 return;
3709 };
3710
3711 if !matches!(payload, Value::Object(_)) {
3712 *payload = Value::Object(serde_json::Map::new());
3713 }
3714 if let Value::Object(map) = payload {
3715 let mut card_spec = serde_json::Map::new();
3716 card_spec.insert(
3717 "asset_path".into(),
3718 Value::String(format!("assets/cards/{route}.json")),
3719 );
3720 map.insert("card_source".into(), Value::String("asset".into()));
3721 map.insert("card_spec".into(), Value::Object(card_spec));
3722 tracing::debug!(route_to_card = %route, "inject_card_route: routed card asset selected");
3723 }
3724}
3725
3726fn inject_slot_definitions(input: &mut Value, slot_schema: &Value, flow_id: &str, node_id: &str) {
3732 if input.is_null() {
3733 *input = Value::Object(serde_json::Map::new());
3734 }
3735 let Some(map) = input.as_object_mut() else {
3736 tracing::warn!(
3737 flow_id,
3738 node_id,
3739 "slot-extractor input is not an object; cannot inject slot_definitions"
3740 );
3741 return;
3742 };
3743 if map.contains_key("slot_definitions") {
3744 return;
3745 }
3746 let slot_count = slot_schema.as_array().map_or(0, Vec::len);
3747 tracing::debug!(
3748 flow_id,
3749 slot_count,
3750 "injecting flow-level slot_schema as slot_definitions into slot-extractor input"
3751 );
3752 map.insert("slot_definitions".to_string(), slot_schema.clone());
3753}
3754
3755fn resolve_card_assets(input: &mut Value, pack: &crate::pack::PackRuntime) {
3762 resolve_card_spec_asset(input, pack);
3763
3764 if let Value::Object(map) = input
3767 && let Some(Value::Object(call)) = map.get_mut("call")
3768 && let Some(payload) = call.get_mut("payload")
3769 {
3770 resolve_card_spec_asset(payload, pack);
3771 }
3772}
3773
3774fn resolve_card_spec_asset(value: &mut Value, pack: &crate::pack::PackRuntime) {
3776 let Value::Object(map) = value else { return };
3777
3778 let is_asset = map
3779 .get("card_source")
3780 .and_then(Value::as_str)
3781 .map(|s| s.eq_ignore_ascii_case("asset"))
3782 .unwrap_or(false);
3783 if !is_asset {
3784 return;
3785 }
3786
3787 let asset_path = map
3788 .get("card_spec")
3789 .and_then(|spec| spec.get("asset_path"))
3790 .and_then(Value::as_str)
3791 .map(str::to_string);
3792
3793 let Some(asset_path) = asset_path else { return };
3794
3795 match pack.read_asset(&asset_path) {
3796 Ok(bytes) => {
3797 let card_json: Value = match serde_json::from_slice(&bytes) {
3798 Ok(v) => v,
3799 Err(err) => {
3800 tracing::warn!(
3801 asset_path,
3802 %err,
3803 "failed to parse card asset as JSON; leaving as asset reference"
3804 );
3805 return;
3806 }
3807 };
3808 tracing::debug!(asset_path, "pre-resolved card asset to inline_json");
3809 map.insert("card_source".into(), Value::String("inline".into()));
3810 if let Some(Value::Object(spec)) = map.get_mut("card_spec") {
3811 spec.insert("inline_json".into(), card_json);
3812 spec.remove("asset_path");
3813 }
3814 }
3815 Err(err) => {
3816 tracing::warn!(
3817 asset_path,
3818 %err,
3819 "card asset not found in pack; leaving as asset reference"
3820 );
3821 }
3822 }
3823
3824 let configured_bundle_path = map
3831 .get("card_spec")
3832 .and_then(|spec| spec.get("i18n_bundle_path"))
3833 .and_then(Value::as_str)
3834 .map(|s| s.trim().trim_end_matches('/').to_string())
3835 .filter(|s| !s.is_empty());
3836
3837 let bundle_path = configured_bundle_path
3838 .clone()
3839 .unwrap_or_else(|| "assets/i18n".to_string());
3840
3841 let i18n_entries = load_i18n_bundle_entries(&bundle_path, |path| pack.read_asset(path));
3842
3843 if !i18n_entries.is_empty() {
3844 let locale_keys: Vec<_> = i18n_entries.keys().cloned().collect();
3845 if let Some(Value::Object(spec)) = map.get_mut("card_spec") {
3846 spec.insert("i18n_inline".into(), Value::Object(i18n_entries));
3847 if configured_bundle_path.is_some() {
3848 tracing::info!(%bundle_path, ?locale_keys, "pre-resolved i18n bundle into card_spec.i18n_inline");
3849 } else {
3850 tracing::info!(%bundle_path, ?locale_keys, "auto-discovered i18n bundle and inlined into card_spec.i18n_inline");
3851 }
3852 }
3853 }
3854}
3855
3856fn load_i18n_bundle_entries<F>(bundle_path: &str, mut read_asset: F) -> JsonMap<String, Value>
3857where
3858 F: FnMut(&str) -> Result<Vec<u8>>,
3859{
3860 let mut i18n_entries = JsonMap::new();
3861
3862 if bundle_path.ends_with(".json") {
3863 if let Ok(bytes) = read_asset(bundle_path)
3864 && let Ok(Value::Object(entries)) = serde_json::from_slice::<Value>(&bytes)
3865 {
3866 i18n_entries.insert("en".to_string(), Value::Object(entries));
3867 }
3868 return i18n_entries;
3869 }
3870
3871 let manifest_path = format!("{bundle_path}/_manifest.json");
3872 let locale_codes: Vec<String> = read_asset(&manifest_path)
3873 .ok()
3874 .and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
3875 .and_then(|value| {
3876 let locales = value
3877 .get("locales")
3878 .and_then(Value::as_array)
3879 .cloned()
3880 .or_else(|| value.as_array().cloned());
3881 locales.map(|items| {
3882 items
3883 .iter()
3884 .filter_map(Value::as_str)
3885 .map(String::from)
3886 .collect()
3887 })
3888 })
3889 .unwrap_or_default();
3890
3891 tracing::info!(%bundle_path, ?locale_codes, "i18n manifest discovered locales");
3892
3893 for locale in &locale_codes {
3894 let candidate = format!("{bundle_path}/{locale}.json");
3895 if let Ok(bytes) = read_asset(&candidate)
3896 && let Ok(Value::Object(entries)) = serde_json::from_slice::<Value>(&bytes)
3897 {
3898 i18n_entries.insert(locale.clone(), Value::Object(entries));
3899 }
3900 }
3901 if !i18n_entries.contains_key("en") {
3902 let en_path = format!("{bundle_path}/en.json");
3903 if let Ok(bytes) = read_asset(&en_path)
3904 && let Ok(Value::Object(entries)) = serde_json::from_slice::<Value>(&bytes)
3905 {
3906 i18n_entries.insert("en".to_string(), Value::Object(entries));
3907 }
3908 }
3909
3910 i18n_entries
3911}
3912
3913#[derive(Debug)]
3922pub(crate) enum CustomRoutingDecision {
3923 Next(NodeId),
3924 End,
3925 Wait,
3926}
3927
3928fn evaluate_custom_routing(
3941 raw: &Value,
3942 output: &NodeOutput,
3943 state: &ExecutionState,
3944 flow_ir: &HostFlow,
3945 node_id: &NodeId,
3946) -> CustomRoutingDecision {
3947 let routes = match raw.as_array() {
3948 Some(arr) => arr,
3949 None => {
3950 tracing::warn!(
3951 flow_id = %flow_ir.id,
3952 node_id = %node_id,
3953 "custom routing is not an array; terminating"
3954 );
3955 return CustomRoutingDecision::End;
3956 }
3957 };
3958
3959 let ctx = build_routing_context(
3966 output,
3967 state,
3968 default_success_event(routes),
3969 default_error_event(routes),
3970 );
3971
3972 let mut has_condition = false;
3973 for route in routes {
3974 let condition = route.get("condition").and_then(|v| v.as_str());
3975 let to = route.get("to").and_then(|v| v.as_str());
3976
3977 if let Some(cond) = condition {
3978 has_condition = true;
3979 if evaluate_simple_condition(cond, &ctx)
3980 && let Some(target) = to
3981 && let Ok(nid) = NodeId::new(target)
3982 {
3983 tracing::debug!(
3984 flow_id = %flow_ir.id,
3985 node_id = %node_id,
3986 condition = cond,
3987 target = target,
3988 "conditional route matched"
3989 );
3990 return CustomRoutingDecision::Next(nid);
3991 }
3992 } else if let Some(target) = to
3993 && let Ok(nid) = NodeId::new(target)
3994 {
3995 tracing::debug!(
3996 flow_id = %flow_ir.id,
3997 node_id = %node_id,
3998 target = target,
3999 "default route taken"
4000 );
4001 return CustomRoutingDecision::Next(nid);
4002 }
4003 }
4004
4005 if has_condition {
4012 tracing::debug!(
4013 flow_id = %flow_ir.id,
4014 node_id = %node_id,
4015 "no conditional route matched; pausing run at current node for resume"
4016 );
4017 CustomRoutingDecision::Wait
4018 } else {
4019 tracing::warn!(
4020 flow_id = %flow_ir.id,
4021 node_id = %node_id,
4022 "no route matched and no conditions present; terminating"
4023 );
4024 CustomRoutingDecision::End
4025 }
4026}
4027
4028fn evaluate_simple_condition(condition: &str, ctx: &Value) -> bool {
4039 if let Some((path, expected)) = split_condition(condition, "==") {
4040 return string_eq(ctx, path, expected, false);
4041 }
4042 if let Some((path, expected)) = split_condition(condition, "!=") {
4043 return string_eq(ctx, path, expected, true);
4044 }
4045 if let Some((path, expected)) = split_condition(condition, ">=") {
4046 return numeric_cmp(ctx, path, expected, |a, b| a >= b);
4047 }
4048 if let Some((path, expected)) = split_condition(condition, "<=") {
4049 return numeric_cmp(ctx, path, expected, |a, b| a <= b);
4050 }
4051 if let Some((path, expected)) = split_condition(condition, ">") {
4052 return numeric_cmp(ctx, path, expected, |a, b| a > b);
4053 }
4054 if let Some((path, expected)) = split_condition(condition, "<") {
4055 return numeric_cmp(ctx, path, expected, |a, b| a < b);
4056 }
4057 if let Some((path, expected)) = split_condition(condition, " contains ") {
4058 let needle = expected.to_lowercase();
4059 return resolve_dotted_path(ctx, path)
4060 .is_some_and(|actual| actual.to_lowercase().contains(&needle));
4061 }
4062 false
4063}
4064
4065fn split_condition<'a>(condition: &'a str, op: &str) -> Option<(&'a str, &'a str)> {
4069 let idx = condition.find(op)?;
4070 let path = condition[..idx].trim();
4071 let value = condition[idx + op.len()..].trim().trim_matches('"');
4072 Some((path, value))
4073}
4074
4075fn string_eq(ctx: &Value, path: &str, expected: &str, negate: bool) -> bool {
4078 let matches = resolve_dotted_path(ctx, path)
4079 .as_deref()
4080 .is_some_and(|a| a.eq_ignore_ascii_case(expected));
4081 if negate { !matches } else { matches }
4082}
4083
4084fn numeric_cmp(ctx: &Value, path: &str, expected: &str, cmp: impl Fn(f64, f64) -> bool) -> bool {
4087 let Some(actual) = resolve_dotted_path(ctx, path).and_then(|a| a.trim().parse::<f64>().ok())
4088 else {
4089 return false;
4090 };
4091 let Ok(rhs) = expected.parse::<f64>() else {
4092 return false;
4093 };
4094 cmp(actual, rhs)
4095}
4096
4097fn resolve_dotted_path(value: &Value, path: &str) -> Option<String> {
4099 let parts: Vec<&str> = path.split('.').collect();
4100 let mut current = value;
4101 for part in &parts {
4102 current = current.get(part)?;
4103 }
4104 match current {
4105 Value::String(s) => Some(s.clone()),
4106 Value::Bool(b) => Some(b.to_string()),
4107 Value::Number(n) => Some(n.to_string()),
4108 _ => Some(current.to_string()),
4109 }
4110}
4111
4112const SUCCESS_EVENT_PORTS: [&str; 3] = ["on_success", "on_complete", "on_submit"];
4134
4135const ERROR_EVENT_PORTS: [&str; 3] = ["on_error", "on_cancel", "on_timeout"];
4140
4141fn node_has_error_route(routing: &Routing) -> bool {
4148 let Routing::Custom(raw) = routing else {
4149 return false;
4150 };
4151 let Some(routes) = raw.as_array() else {
4152 return false;
4153 };
4154 routes.iter().any(|route| {
4155 let by_event = route
4156 .get("event")
4157 .and_then(Value::as_str)
4158 .is_some_and(|e| ERROR_EVENT_PORTS.contains(&e));
4159 let by_condition = route
4160 .get("condition")
4161 .and_then(Value::as_str)
4162 .is_some_and(|c| ERROR_EVENT_PORTS.iter().any(|port| c.contains(port)));
4163 by_event || by_condition
4164 })
4165}
4166
4167fn default_success_event(routes: &[Value]) -> &'static str {
4177 default_event(routes, &SUCCESS_EVENT_PORTS, "on_success")
4178}
4179
4180fn default_error_event(routes: &[Value]) -> &'static str {
4185 default_event(routes, &ERROR_EVENT_PORTS, "on_error")
4186}
4187
4188fn default_event(routes: &[Value], ports: &[&'static str], fallback: &'static str) -> &'static str {
4191 let referenced: Vec<&str> = routes
4192 .iter()
4193 .filter_map(|route| route.get("condition").and_then(Value::as_str))
4194 .filter_map(condition_event_eq)
4195 .collect();
4196 ports
4197 .iter()
4198 .copied()
4199 .find(|port| referenced.contains(port))
4200 .unwrap_or(fallback)
4201}
4202
4203fn condition_event_eq(condition: &str) -> Option<&str> {
4206 let idx = condition.find("==")?;
4207 if condition[..idx].trim() != "event" {
4208 return None;
4209 }
4210 Some(condition[idx + 2..].trim().trim_matches('"'))
4211}
4212
4213fn resolve_entry_metadata(entry: &Value) -> Option<&Value> {
4220 entry
4221 .pointer("/input/metadata")
4222 .or_else(|| entry.pointer("/metadata"))
4223}
4224
4225fn submitted_fields(entry: &Value) -> JsonMap<String, Value> {
4251 let mut fields = JsonMap::new();
4252 if let Some(Value::Object(meta)) = resolve_entry_metadata(entry) {
4253 for (key, value) in meta {
4254 if key == "action" {
4255 continue;
4256 }
4257 fields.insert(key.clone(), value.clone());
4258 }
4259 }
4260 let envelope = entry
4261 .get("input")
4262 .filter(|v| v.is_object())
4263 .unwrap_or(entry);
4264 if let Some(map) = envelope.as_object() {
4265 for (key, value) in map {
4266 if key == "metadata" || key == "text" {
4267 continue;
4268 }
4269 fields.insert(key.clone(), value.clone());
4270 }
4271 }
4272 fields
4273}
4274
4275fn build_routing_context(
4302 output: &NodeOutput,
4303 state: &ExecutionState,
4304 success_event: &str,
4305 error_event: &str,
4306) -> Value {
4307 let mut ctx = match &output.payload {
4308 Value::Object(map) => map.clone(),
4309 _ => JsonMap::new(),
4310 };
4311
4312 let entry = alias_input_to_entry(state.entry.clone());
4317 ctx.insert("entry".into(), entry.clone());
4318 ctx.insert("in".into(), entry.clone());
4319
4320 let metadata = entry
4324 .pointer("/input/metadata")
4325 .or_else(|| entry.pointer("/metadata"));
4326
4327 let mut response = JsonMap::new();
4328 if let Some(Value::Object(meta)) = metadata {
4329 for (k, v) in meta {
4330 match v {
4332 Value::String(s) => {
4333 response.insert(k.clone(), Value::String(s.clone()));
4334 }
4335 other => {
4336 response.insert(k.clone(), other.clone());
4337 }
4338 }
4339 }
4340 }
4341 if let Some(text) = entry
4343 .pointer("/input/text")
4344 .or_else(|| entry.pointer("/text"))
4345 .filter(|t| !t.is_null())
4346 {
4347 response.insert("text".into(), text.clone());
4348 }
4349 ctx.insert("response".into(), Value::Object(response));
4350
4351 let event = output
4360 .meta
4361 .get("outcome")
4362 .and_then(Value::as_str)
4363 .map(str::to_string)
4364 .unwrap_or_else(|| {
4365 if output.ok {
4366 success_event
4367 } else {
4368 error_event
4369 }
4370 .to_string()
4371 });
4372 ctx.insert("event".into(), Value::String(event));
4373
4374 Value::Object(ctx)
4375}
4376
4377fn approval_requires_human(input: &Value) -> bool {
4385 let mode = input
4386 .get("mode")
4387 .and_then(Value::as_str)
4388 .unwrap_or("always");
4389 match mode {
4390 "above_risk" => {
4391 let risk = input.get("risk").and_then(Value::as_f64).unwrap_or(0.0);
4392 let threshold = input
4393 .get("risk_threshold")
4394 .and_then(Value::as_f64)
4395 .unwrap_or(1.0);
4396 risk >= threshold
4397 }
4398 "above_confidence" => {
4399 let confidence = input
4400 .get("confidence")
4401 .and_then(Value::as_f64)
4402 .unwrap_or(0.0);
4403 let threshold = input
4404 .get("confidence_threshold")
4405 .and_then(Value::as_f64)
4406 .unwrap_or(1.0);
4407 confidence < threshold
4408 }
4409 _ => true,
4411 }
4412}
4413
4414#[cfg(test)]
4415mod approval_gate_tests {
4416 use super::approval_requires_human;
4417 use serde_json::json;
4418
4419 #[test]
4420 fn above_risk_auto_approves_below_threshold() {
4421 let input = json!({ "risk": 0.5, "mode": "above_risk", "risk_threshold": 0.7 });
4422 assert!(!approval_requires_human(&input));
4423 }
4424
4425 #[test]
4426 fn above_risk_requires_human_at_or_above_threshold() {
4427 let input = json!({ "risk": 0.9, "mode": "above_risk", "risk_threshold": 0.7 });
4428 assert!(approval_requires_human(&input));
4429 }
4430
4431 #[test]
4432 fn above_confidence_requires_human_when_low_confidence() {
4433 let input =
4434 json!({ "confidence": 0.4, "mode": "above_confidence", "confidence_threshold": 0.8 });
4435 assert!(approval_requires_human(&input));
4436 }
4437
4438 #[test]
4439 fn always_and_missing_gate_require_human() {
4440 assert!(approval_requires_human(&json!({ "mode": "always" })));
4441 assert!(approval_requires_human(&json!({})));
4442 }
4443}
4444
4445#[cfg(test)]
4446mod tests {
4447 use super::*;
4448
4449 fn native_op_key_for(kind: &NodeKind) -> Option<&'static str> {
4467 match kind {
4468 NodeKind::Exec { .. } | NodeKind::PackComponent { .. } => None,
4470 NodeKind::BuiltinEmit { .. } | NodeKind::Mcp { .. } => None,
4472
4473 NodeKind::ProviderInvoke => Some("provider.invoke"),
4474 NodeKind::FlowCall => Some("flow.call"),
4475 NodeKind::FlowGoto => Some("flow.goto"),
4476 NodeKind::BuiltinStateGet => Some("state.get"),
4477 NodeKind::BuiltinStateSet => Some("state.set"),
4478 NodeKind::VarSet { .. } => Some("var.set"),
4479 NodeKind::Wait => Some("session.wait"),
4480 NodeKind::DwAgent { .. } => Some("dw.agent"),
4481 NodeKind::DwAgentGraph { .. } => Some("dw.agent_graph"),
4482 NodeKind::SorlaCall { .. } => Some("sorla.call"),
4483 NodeKind::OperalaCall { .. } => Some("operala.call"),
4484 NodeKind::AgenticCall { .. } => Some("agentic.call"),
4485 NodeKind::TelcoXCall { .. } => Some("telco-x.call"),
4486 NodeKind::ApprovalCall { .. } => Some("approval.call"),
4487 }
4488 }
4489
4490 #[test]
4491 fn every_engine_dispatched_op_key_is_native_to_the_loader() {
4492 for key in [
4496 "provider.invoke",
4497 "flow.call",
4498 "flow.goto",
4499 "state.get",
4500 "state.set",
4501 "var.set",
4502 "session.wait",
4503 "dw.agent",
4504 "dw.agent_graph",
4505 "sorla.call",
4506 "operala.call",
4507 "agentic.call",
4508 "telco-x.call",
4509 "approval.call",
4510 ] {
4511 assert!(
4512 crate::runner::flow_adapter::is_native_op_key(key),
4513 "the engine dispatches `{key}`, but the loader does not treat it \
4514 as native — it will be wrapped as a generic component and the \
4515 engine arm becomes unreachable"
4516 );
4517 }
4518 assert!(crate::runner::flow_adapter::is_native_op_key(
4520 "emit.response"
4521 ));
4522 assert!(crate::runner::flow_adapter::is_native_op_key(
4523 "mcp:srv/tool"
4524 ));
4525 assert!(!crate::runner::flow_adapter::is_native_op_key("mcp.exec"));
4527
4528 assert_eq!(native_op_key_for(&NodeKind::FlowGoto), Some("flow.goto"));
4530 }
4531 use crate::validate::{ValidationConfig, ValidationMode};
4532 use greentic_types::{
4533 Flow, FlowComponentRef, FlowId, FlowKind, FlowMetadata, InputMapping, Node, NodeId,
4534 OutputMapping, Routing, TelemetryHints,
4535 };
4536 use serde_json::json;
4537 use std::collections::{BTreeMap, HashMap as StdHashMap};
4538 use std::str::FromStr;
4539 use std::sync::Mutex;
4540 use tokio::runtime::Runtime;
4541
4542 fn minimal_engine() -> FlowEngine {
4543 FlowEngine {
4544 packs: Vec::new(),
4545 flows: Vec::new(),
4546 flow_sources: HashMap::new(),
4547 messaging_provider_pack_ids: std::collections::HashSet::new(),
4548 flow_cache: RwLock::new(HashMap::new()),
4549 default_env: "local".to_string(),
4550 validation: ValidationConfig {
4551 mode: ValidationMode::Off,
4552 },
4553 cross_pack_resolver: None,
4554 rollout_ids: RolloutIds::default(),
4555 remote_dispatch_handler: None,
4556 #[cfg(feature = "agentic-worker")]
4557 dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
4558 #[cfg(feature = "agentic-worker")]
4559 agent_node_handler: None,
4560 #[cfg(feature = "agentic-worker")]
4561 graph_node_handler: None,
4562 #[cfg(feature = "agentic-worker")]
4563 mcp_tool_source: None,
4564 }
4565 }
4566
4567 fn flow_desc(id: &str, pack_id: &str, flow_type: &str, entry: bool) -> FlowDescriptor {
4568 FlowDescriptor {
4569 id: id.into(),
4570 flow_type: flow_type.into(),
4571 pack_id: pack_id.into(),
4572 profile: pack_id.into(),
4573 version: "0.0.0".into(),
4574 description: None,
4575 entry,
4576 }
4577 }
4578
4579 #[test]
4580 fn entry_flow_by_type_disambiguates_entrypoint_from_internal_helpers() {
4581 let mut engine = minimal_engine();
4586 engine.flows = vec![
4587 flow_desc("default", "weatherapi-pack", "messaging", true),
4588 flow_desc("flow_", "weatherapi-pack", "messaging", false),
4589 flow_desc("flow_error", "weatherapi-pack", "messaging", false),
4590 flow_desc("flow_get_weather", "weatherapi-pack", "messaging", false),
4591 ];
4592
4593 assert!(
4595 engine.flow_by_type("messaging").is_none(),
4596 "multiple messaging flows must be ambiguous for the plain lookup"
4597 );
4598 let resolved = engine
4600 .entry_flow_by_type("messaging")
4601 .expect("single entry flow must resolve");
4602 assert_eq!(resolved.id, "default");
4603 assert_eq!(resolved.pack_id, "weatherapi-pack");
4604 }
4605
4606 #[test]
4607 fn entry_flow_by_type_still_ambiguous_across_two_entrypoints() {
4608 let mut engine = minimal_engine();
4611 engine.flows = vec![
4612 flow_desc("default", "pack.a", "messaging", true),
4613 flow_desc("default", "pack.b", "messaging", true),
4614 flow_desc("helper", "pack.a", "messaging", false),
4615 ];
4616 assert!(engine.entry_flow_by_type("messaging").is_none());
4617 }
4618
4619 #[test]
4620 fn entry_flow_by_type_excludes_messaging_provider_pack_flows() {
4621 let mut engine = minimal_engine();
4627 engine.flows = vec![
4628 flow_desc("main", "hr-onboarding-pack", "messaging", true),
4629 flow_desc("main", "messaging-teams", "messaging", true),
4630 ];
4631 engine
4634 .messaging_provider_pack_ids
4635 .insert("messaging-teams".to_string());
4636
4637 assert!(engine.flow_by_type("messaging").is_none());
4639 let resolved = engine
4641 .entry_flow_by_type("messaging")
4642 .expect("app entry flow must resolve past the provider flow");
4643 assert_eq!(resolved.id, "main");
4644 assert_eq!(resolved.pack_id, "hr-onboarding-pack");
4645 }
4646
4647 #[test]
4648 fn entry_flow_by_type_matches_plain_lookup_for_single_flow() {
4649 let mut engine = minimal_engine();
4652 engine.flows = vec![flow_desc("only", "pack.a", "messaging", true)];
4653 assert_eq!(
4654 engine.flow_by_type("messaging").map(|f| f.id.as_str()),
4655 Some("only")
4656 );
4657 assert_eq!(
4658 engine
4659 .entry_flow_by_type("messaging")
4660 .map(|f| f.id.as_str()),
4661 Some("only")
4662 );
4663 }
4664
4665 #[test]
4666 fn to_node_output_legacy_success_becomes_data() {
4667 let out = to_node_output(&json!({ "ok": true, "temp": "20C" }));
4669 assert!(out.is_ok(), "legacy ok:true must classify as Data");
4670 let data = out.data().expect("data present");
4671 assert_eq!(data.get("temp").and_then(Value::as_str), Some("20C"));
4672 }
4673
4674 #[test]
4675 fn to_node_output_legacy_error_becomes_errors() {
4676 let out = to_node_output(
4678 &json!({ "ok": false, "error": { "code": "E_BAD", "message": "boom" } }),
4679 );
4680 assert!(!out.is_ok(), "legacy ok:false must classify as Errors");
4681 let errs = out.errors();
4682 assert_eq!(errs.len(), 1);
4683 assert_eq!(errs[0].code, "E_BAD");
4684 assert_eq!(errs[0].message, "boom");
4685 }
4686
4687 #[test]
4688 fn to_node_output_native_data_envelope_roundtrips() {
4689 let out = to_node_output(&json!({ "data": { "x": 1 } }));
4691 assert!(out.is_ok());
4692 assert_eq!(
4693 out.data().and_then(|d| d.get("x")).and_then(Value::as_i64),
4694 Some(1)
4695 );
4696 }
4697
4698 #[test]
4699 fn to_node_output_native_errors_envelope_roundtrips() {
4700 let out = to_node_output(&json!({
4702 "errors": [ { "code": "C", "message": "m", "kind": "validation",
4703 "retryable": false, "details": {} } ]
4704 }));
4705 assert!(!out.is_ok());
4706 assert_eq!(out.errors()[0].code, "C");
4707 assert_eq!(
4708 out.errors()[0].kind,
4709 greentic_types::node_io::ErrorKind::Validation
4710 );
4711 }
4712
4713 #[test]
4714 fn to_node_output_bare_object_becomes_data() {
4715 let out = to_node_output(&json!({ "foo": 1 }));
4717 assert!(out.is_ok());
4718 assert_eq!(
4719 out.data()
4720 .and_then(|d| d.get("foo"))
4721 .and_then(Value::as_i64),
4722 Some(1)
4723 );
4724 }
4725
4726 #[test]
4727 fn templating_renders_with_partials_and_data() {
4728 let mut state = ExecutionState::new(json!({ "city": "London" }));
4729 state.nodes.insert(
4730 "forecast".to_string(),
4731 NodeOutput::new(json!({ "temp": "20C" })),
4732 );
4733
4734 let ctx = state.context();
4736 assert_eq!(ctx["nodes"]["forecast"]["payload"]["temp"], json!("20C"));
4737 }
4738
4739 #[test]
4740 fn outputs_map_exposes_node_io_data_and_errors_alongside_flat() {
4741 let mut state = ExecutionState::new(json!({}));
4742 state.nodes.insert(
4743 "forecast".to_string(),
4744 NodeOutput::new(json!({ "temp": "20C" })),
4745 );
4746 let outs = state.outputs_map();
4747 assert_eq!(outs["forecast"]["temp"], json!("20C"));
4749 assert_eq!(outs["forecast"]["data"]["temp"], json!("20C"));
4751 assert_eq!(outs["forecast"]["errors"], json!([]));
4753 }
4754
4755 #[test]
4756 fn finalize_wraps_emitted_payloads() {
4757 let mut state = ExecutionState::new(json!({}));
4758 state.push_egress(json!({ "text": "first" }));
4759 state.push_egress(json!({ "text": "second" }));
4760 let result = state.finalize_with(Some(json!({ "text": "final" })));
4761 assert_eq!(
4762 result,
4763 json!([
4764 { "text": "first" },
4765 { "text": "second" },
4766 { "text": "final" }
4767 ])
4768 );
4769 }
4770
4771 #[test]
4772 fn finalize_does_not_double_terminal_emit_response() {
4773 let card = json!({ "renderedCard": { "type": "AdaptiveCard" } });
4778 let mut state = ExecutionState::new(json!({}));
4779 state.push_egress(card.clone());
4780 let result = state.finalize_with(Some(card.clone()));
4781 assert_eq!(result, json!([card]));
4782 }
4783
4784 #[test]
4785 fn finalize_still_appends_distinct_terminal_output() {
4786 let mut state = ExecutionState::new(json!({}));
4789 state.push_egress(json!({ "text": "emitted" }));
4790 let result = state.finalize_with(Some(json!({ "text": "final" })));
4791 assert_eq!(result, json!([{ "text": "emitted" }, { "text": "final" }]));
4792 }
4793
4794 #[test]
4795 fn alias_input_to_entry_exposes_input_for_bare_message() {
4796 let msg = json!({ "text": "hi", "metadata": { "operation": "get_weather" } });
4800 let aliased = alias_input_to_entry(msg);
4801 assert_eq!(
4802 aliased.pointer("/metadata/operation"),
4803 Some(&json!("get_weather"))
4804 );
4805 assert_eq!(
4806 aliased.pointer("/input/metadata/operation"),
4807 Some(&json!("get_weather"))
4808 );
4809 }
4810
4811 #[test]
4812 fn alias_input_to_entry_preserves_explicit_input_wrapper() {
4813 let wrapped = json!({ "input": { "metadata": { "operation": "x" } } });
4815 assert_eq!(alias_input_to_entry(wrapped.clone()), wrapped);
4816 }
4817
4818 #[test]
4819 fn alias_input_to_entry_ignores_non_objects() {
4820 assert_eq!(alias_input_to_entry(json!("hi")), json!("hi"));
4821 assert_eq!(alias_input_to_entry(json!(null)), json!(null));
4822 }
4823
4824 #[test]
4825 fn finalize_flattens_final_array() {
4826 let mut state = ExecutionState::new(json!({}));
4827 state.push_egress(json!({ "text": "only" }));
4828 let result = state.finalize_with(Some(json!([
4829 { "text": "extra-1" },
4830 { "text": "extra-2" }
4831 ])));
4832 assert_eq!(
4833 result,
4834 json!([
4835 { "text": "only" },
4836 { "text": "extra-1" },
4837 { "text": "extra-2" }
4838 ])
4839 );
4840 }
4841
4842 #[test]
4843 fn inject_card_locale_uses_entry_metadata_without_overwriting_payload() {
4844 let mut payload = json!({
4845 "card_source": "inline",
4846 "card_spec": { "title": "Hello" }
4847 });
4848 inject_card_locale(
4849 &mut payload,
4850 &json!({"input": {"metadata": {"locale": "nl-NL"}}}),
4851 );
4852 assert_eq!(payload["locale"], json!("nl-NL"));
4853
4854 let mut existing = json!({
4855 "card_source": "inline",
4856 "card_spec": { "title": "Hello" },
4857 "locale": "en-GB"
4858 });
4859 inject_card_locale(&mut existing, &json!({"metadata": {"locale": "nl-NL"}}));
4860 assert_eq!(existing["locale"], json!("en-GB"));
4861 }
4862
4863 #[test]
4864 fn load_i18n_bundle_entries_reads_manifest_and_falls_back_to_en() {
4865 let assets = StdHashMap::from([
4866 (
4867 "cards/i18n/_manifest.json".to_string(),
4868 br#"{"locales":["de"]}"#.to_vec(),
4869 ),
4870 (
4871 "cards/i18n/de.json".to_string(),
4872 br#"{"title":"Hallo"}"#.to_vec(),
4873 ),
4874 (
4875 "cards/i18n/en.json".to_string(),
4876 br#"{"title":"Hello"}"#.to_vec(),
4877 ),
4878 ]);
4879
4880 let entries = load_i18n_bundle_entries("cards/i18n", |path| {
4881 assets
4882 .get(path)
4883 .cloned()
4884 .with_context(|| format!("missing asset {path}"))
4885 });
4886
4887 assert_eq!(entries["de"]["title"], json!("Hallo"));
4888 assert_eq!(entries["en"]["title"], json!("Hello"));
4889 }
4890
4891 #[test]
4892 fn load_i18n_bundle_entries_reads_single_file_bundle() {
4893 let entries = load_i18n_bundle_entries("cards/i18n.json", |path| {
4894 if path == "cards/i18n.json" {
4895 Ok(br#"{"title":"Hello"}"#.to_vec())
4896 } else {
4897 bail!("unexpected asset {path}");
4898 }
4899 });
4900
4901 assert_eq!(entries["en"]["title"], json!("Hello"));
4902 }
4903
4904 struct TestCrossPackResolver;
4905
4906 impl CrossPackResolver for TestCrossPackResolver {
4907 fn invoke(
4908 &self,
4909 provider_id: &str,
4910 provider_type: Option<&str>,
4911 op: &str,
4912 input: &[u8],
4913 tenant: &str,
4914 team: Option<&str>,
4915 ) -> Result<Value> {
4916 Ok(json!({
4917 "provider_id": provider_id,
4918 "provider_type": provider_type,
4919 "op": op,
4920 "tenant": tenant,
4921 "team": team,
4922 "input": serde_json::from_slice::<Value>(input)?,
4923 }))
4924 }
4925 }
4926
4927 #[test]
4928 fn cross_pack_resolver_returns_node_output_when_present() {
4929 let mut engine = minimal_engine();
4930 engine.set_cross_pack_resolver(Arc::new(TestCrossPackResolver));
4931
4932 let output = engine
4933 .try_invoke_cross_pack_resolver(
4934 Some("mail"),
4935 Some("messaging"),
4936 "send",
4937 br#"{"subject":"hello"}"#,
4938 "demo",
4939 )
4940 .expect("resolver invocation")
4941 .expect("resolver output");
4942
4943 assert_eq!(
4944 output.payload,
4945 json!({
4946 "provider_id": "mail",
4947 "provider_type": "messaging",
4948 "op": "send",
4949 "tenant": "demo",
4950 "team": null,
4951 "input": { "subject": "hello" },
4952 })
4953 );
4954 }
4955
4956 #[test]
4957 fn flow_goto_builds_a_jump_to_the_named_flow() {
4958 let outcome = execute_flow_goto(json!({
4959 "flow_id": "support",
4960 "node": "ask_order",
4961 "input": { "order": "A-1" },
4962 }))
4963 .expect("goto builds");
4964
4965 let NodeControl::Jump(jump) = outcome.control else {
4966 panic!("flow.goto must produce a Jump, got {:?}", outcome.control);
4967 };
4968 assert_eq!(jump.flow, "support");
4969 assert_eq!(jump.node.as_deref(), Some("ask_order"));
4970 assert_eq!(jump.payload, json!({ "order": "A-1" }));
4971 assert_eq!(outcome.output.payload, json!({ "order": "A-1" }));
4974 }
4975
4976 #[test]
4979 fn flow_goto_accepts_the_flow_alias_and_defaults_the_entry_node() {
4980 let outcome = execute_flow_goto(json!({ "flow": "support" })).expect("goto builds");
4981 let NodeControl::Jump(jump) = outcome.control else {
4982 panic!("expected a Jump");
4983 };
4984 assert_eq!(jump.flow, "support");
4985 assert!(
4986 jump.node.is_none(),
4987 "no node means `apply_jump` uses the target flow's start"
4988 );
4989 assert_eq!(jump.payload, Value::Null);
4990 }
4991
4992 #[test]
4995 fn flow_goto_refuses_an_empty_flow_id() {
4996 for payload in [json!({ "flow_id": "" }), json!({ "flow_id": " " })] {
4997 let err = execute_flow_goto(payload)
4998 .err()
4999 .expect("empty target must not build");
5000 assert!(
5001 err.to_string().contains("flow_id"),
5002 "error must name the field: {err}"
5003 );
5004 }
5005 let err = execute_flow_goto(json!({ "input": {} }))
5006 .err()
5007 .expect("missing target");
5008 assert!(err.to_string().contains("flow.goto"), "got: {err}");
5009 }
5010
5011 #[test]
5015 fn flow_goto_treats_a_blank_entry_node_as_absent() {
5016 let outcome =
5017 execute_flow_goto(json!({ "flow_id": "support", "node": " " })).expect("goto builds");
5018 let NodeControl::Jump(jump) = outcome.control else {
5019 panic!("expected a Jump");
5020 };
5021 assert!(jump.node.is_none());
5022 }
5023
5024 #[test]
5027 fn flow_goto_forwards_the_redirect_ceiling_and_reason() {
5028 let outcome = execute_flow_goto(json!({
5029 "flow_id": "support",
5030 "max_redirects": 1,
5031 "reason": "menu choice",
5032 }))
5033 .expect("goto builds");
5034 let NodeControl::Jump(jump) = outcome.control else {
5035 panic!("expected a Jump");
5036 };
5037 assert_eq!(jump.max_redirects, Some(1));
5038 assert_eq!(jump.reason.as_deref(), Some("menu choice"));
5039 }
5040
5041 #[test]
5044 fn flow_goto_names_itself_as_the_reason_by_default() {
5045 let outcome = execute_flow_goto(json!({ "flow_id": "support" })).expect("goto builds");
5046 let NodeControl::Jump(jump) = outcome.control else {
5047 panic!("expected a Jump");
5048 };
5049 assert_eq!(jump.reason.as_deref(), Some("flow.goto node"));
5050 }
5051
5052 #[test]
5053 fn parse_component_control_ignores_plain_payload() {
5054 let payload = json!({
5055 "flow": "not-a-control-field",
5056 "node": "n1"
5057 });
5058 let control = parse_component_control(&payload).expect("parse control");
5059 assert!(control.is_none());
5060 }
5061
5062 #[test]
5063 fn parse_component_control_parses_jump_marker() {
5064 let payload = json!({
5065 "greentic_control": {
5066 "action": "jump",
5067 "v": 1,
5068 "flow": "flow.b",
5069 "node": "node-2",
5070 "payload": { "message": "hi" },
5071 "hints": { "k": "v" },
5072 "max_redirects": 2,
5073 "reason": "handoff"
5074 }
5075 });
5076 let control = parse_component_control(&payload)
5077 .expect("parse control")
5078 .expect("missing control");
5079 match control {
5080 NodeControl::Jump(jump) => {
5081 assert_eq!(jump.flow, "flow.b");
5082 assert_eq!(jump.node.as_deref(), Some("node-2"));
5083 assert_eq!(jump.payload, json!({ "message": "hi" }));
5084 assert_eq!(jump.hints, json!({ "k": "v" }));
5085 assert_eq!(jump.max_redirects, Some(2));
5086 assert_eq!(jump.reason.as_deref(), Some("handoff"));
5087 }
5088 other => panic!("expected jump control, got {other:?}"),
5089 }
5090 }
5091
5092 #[test]
5093 fn parse_component_control_rejects_invalid_marker() {
5094 let payload = json!({
5095 "greentic_control": "bad-shape"
5096 });
5097 let err = parse_component_control(&payload).expect_err("expected invalid marker error");
5098 assert!(err.to_string().contains("greentic_control"));
5099 }
5100
5101 #[test]
5102 fn missing_operation_reports_node_and_component() {
5103 let engine = minimal_engine();
5104 let rt = Runtime::new().unwrap();
5105 let retry_config = RetryConfig {
5106 max_attempts: 1,
5107 base_delay_ms: 1,
5108 };
5109 let ctx = FlowContext {
5110 tenant: "tenant",
5111 pack_id: "test-pack",
5112 flow_id: "flow",
5113 node_id: Some("missing-op"),
5114 tool: None,
5115 action: None,
5116 session_id: None,
5117 provider_id: None,
5118 reply_scope: None,
5119 retry_config,
5120 attempt: 1,
5121 observer: None,
5122 mocks: None,
5123 };
5124 let node = HostNode {
5125 kind: NodeKind::Exec {
5126 target_component: "qa.process".into(),
5127 },
5128 component: "component.exec".into(),
5129 component_id: "component.exec".into(),
5130 operation_name: None,
5131 operation_in_mapping: None,
5132 payload_expr: Value::Null,
5133 routing: Routing::End,
5134 vars_out: None,
5135 };
5136 let _state = ExecutionState::new(Value::Null);
5137 let payload = json!({ "component": "qa.process" });
5138 let event = NodeEvent {
5139 context: &ctx,
5140 node_id: "missing-op",
5141 node: &node,
5142 payload: &payload,
5143 };
5144 let err = rt
5145 .block_on(engine.execute_component_exec(
5146 &ctx,
5147 "missing-op",
5148 &node,
5149 payload.clone(),
5150 &event,
5151 ComponentOverrides {
5152 component: None,
5153 operation: None,
5154 },
5155 ))
5156 .unwrap_err();
5157 let message = err.to_string();
5158 assert!(
5159 message.contains("missing operation for node `missing-op`"),
5160 "unexpected message: {message}"
5161 );
5162 assert!(
5163 message.contains("(component `component.exec`)"),
5164 "unexpected message: {message}"
5165 );
5166 }
5167
5168 #[test]
5169 fn missing_operation_mentions_mapping_hint() {
5170 let engine = minimal_engine();
5171 let rt = Runtime::new().unwrap();
5172 let retry_config = RetryConfig {
5173 max_attempts: 1,
5174 base_delay_ms: 1,
5175 };
5176 let ctx = FlowContext {
5177 tenant: "tenant",
5178 pack_id: "test-pack",
5179 flow_id: "flow",
5180 node_id: Some("missing-op-hint"),
5181 tool: None,
5182 action: None,
5183 session_id: None,
5184 provider_id: None,
5185 reply_scope: None,
5186 retry_config,
5187 attempt: 1,
5188 observer: None,
5189 mocks: None,
5190 };
5191 let node = HostNode {
5192 kind: NodeKind::Exec {
5193 target_component: "qa.process".into(),
5194 },
5195 component: "component.exec".into(),
5196 component_id: "component.exec".into(),
5197 operation_name: None,
5198 operation_in_mapping: Some("render".into()),
5199 payload_expr: Value::Null,
5200 routing: Routing::End,
5201 vars_out: None,
5202 };
5203 let _state = ExecutionState::new(Value::Null);
5204 let payload = json!({ "component": "qa.process" });
5205 let event = NodeEvent {
5206 context: &ctx,
5207 node_id: "missing-op-hint",
5208 node: &node,
5209 payload: &payload,
5210 };
5211 let err = rt
5212 .block_on(engine.execute_component_exec(
5213 &ctx,
5214 "missing-op-hint",
5215 &node,
5216 payload.clone(),
5217 &event,
5218 ComponentOverrides {
5219 component: None,
5220 operation: None,
5221 },
5222 ))
5223 .unwrap_err();
5224 let message = err.to_string();
5225 assert!(
5226 message.contains("missing operation for node `missing-op-hint`"),
5227 "unexpected message: {message}"
5228 );
5229 assert!(
5230 message.contains("Found operation in input.mapping (`render`)"),
5231 "unexpected message: {message}"
5232 );
5233 }
5234
5235 struct CountingObserver {
5236 starts: Mutex<Vec<String>>,
5237 ends: Mutex<Vec<Value>>,
5238 }
5239
5240 impl CountingObserver {
5241 fn new() -> Self {
5242 Self {
5243 starts: Mutex::new(Vec::new()),
5244 ends: Mutex::new(Vec::new()),
5245 }
5246 }
5247 }
5248
5249 impl ExecutionObserver for CountingObserver {
5250 fn on_node_start(&self, event: &NodeEvent<'_>) {
5251 self.starts.lock().unwrap().push(event.node_id.to_string());
5252 }
5253
5254 fn on_node_end(&self, _event: &NodeEvent<'_>, output: &Value) {
5255 self.ends.lock().unwrap().push(output.clone());
5256 }
5257
5258 fn on_node_error(&self, _event: &NodeEvent<'_>, _error: &dyn StdError) {}
5259 }
5260
5261 #[test]
5262 fn emits_end_event_for_successful_node() {
5263 let node_id = NodeId::from_str("emit").unwrap();
5264 let node = Node {
5265 id: node_id.clone(),
5266 component: FlowComponentRef {
5267 id: "emit.log".parse().unwrap(),
5268 pack_alias: None,
5269 operation: None,
5270 },
5271 input: InputMapping {
5272 mapping: json!({ "message": "logged" }),
5273 },
5274 output: OutputMapping {
5275 mapping: Value::Null,
5276 },
5277 err_map: None,
5278 routing: Routing::End,
5279 telemetry: TelemetryHints::default(),
5280 conversational: false,
5281 };
5282 let mut nodes = indexmap::IndexMap::default();
5283 nodes.insert(node_id.clone(), node);
5284 let flow = Flow {
5285 schema_version: "1.0".into(),
5286 id: FlowId::from_str("emit.flow").unwrap(),
5287 kind: FlowKind::Messaging,
5288 entrypoints: BTreeMap::from([(
5289 "default".to_string(),
5290 Value::String(node_id.to_string()),
5291 )]),
5292 nodes,
5293 metadata: Default::default(),
5294 };
5295 let host_flow = HostFlow::from(flow);
5296
5297 let engine = FlowEngine {
5298 packs: Vec::new(),
5299 flows: Vec::new(),
5300 flow_sources: HashMap::new(),
5301 messaging_provider_pack_ids: std::collections::HashSet::new(),
5302 flow_cache: RwLock::new(HashMap::from([(
5303 FlowKey {
5304 pack_id: "test-pack".to_string(),
5305 flow_id: "emit.flow".to_string(),
5306 },
5307 host_flow,
5308 )])),
5309 default_env: "local".to_string(),
5310 validation: ValidationConfig {
5311 mode: ValidationMode::Off,
5312 },
5313 cross_pack_resolver: None,
5314 rollout_ids: RolloutIds::default(),
5315 remote_dispatch_handler: None,
5316 #[cfg(feature = "agentic-worker")]
5317 dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
5318 #[cfg(feature = "agentic-worker")]
5319 agent_node_handler: None,
5320 #[cfg(feature = "agentic-worker")]
5321 graph_node_handler: None,
5322 #[cfg(feature = "agentic-worker")]
5323 mcp_tool_source: None,
5324 };
5325 let observer = CountingObserver::new();
5326 let ctx = FlowContext {
5327 tenant: "demo",
5328 pack_id: "test-pack",
5329 flow_id: "emit.flow",
5330 node_id: None,
5331 tool: None,
5332 action: None,
5333 session_id: None,
5334 provider_id: None,
5335 reply_scope: None,
5336 retry_config: RetryConfig {
5337 max_attempts: 1,
5338 base_delay_ms: 1,
5339 },
5340 attempt: 1,
5341 observer: Some(&observer),
5342 mocks: None,
5343 };
5344
5345 let rt = Runtime::new().unwrap();
5346 let result = rt.block_on(engine.execute(ctx, Value::Null)).unwrap();
5347 assert!(matches!(result.status, FlowStatus::Completed));
5348
5349 let starts = observer.starts.lock().unwrap();
5350 let ends = observer.ends.lock().unwrap();
5351 assert_eq!(starts.len(), 1);
5352 assert_eq!(ends.len(), 1);
5353 assert_eq!(ends[0], json!({ "message": "logged" }));
5354 }
5355
5356 #[test]
5357 fn dotted_component_id_with_mapping_operation_is_not_split() {
5358 let node = Node {
5364 id: NodeId::from_str("render").unwrap(),
5365 component: FlowComponentRef {
5366 id: "ai.greentic.component-templates".parse().unwrap(),
5367 pack_alias: None,
5368 operation: None,
5369 },
5370 input: InputMapping {
5371 mapping: json!({ "operation": "handle_message", "input": "hi" }),
5372 },
5373 output: OutputMapping {
5374 mapping: Value::Null,
5375 },
5376 err_map: None,
5377 routing: Routing::End,
5378 telemetry: TelemetryHints::default(),
5379 conversational: false,
5380 };
5381 let host = HostNode::from(node);
5382 assert!(
5383 matches!(&host.kind, NodeKind::PackComponent { component_ref } if component_ref == "ai.greentic.component-templates"),
5384 "dotted component id must stay intact, got kind {:?}",
5385 host.kind
5386 );
5387 assert_eq!(host.component, "ai.greentic.component-templates");
5388 assert_eq!(host.operation_in_mapping(), Some("handle_message"));
5389 }
5390
5391 #[test]
5392 fn packed_component_operation_id_still_splits_without_mapping_operation() {
5393 let node = Node {
5397 id: NodeId::from_str("render").unwrap(),
5398 component: FlowComponentRef {
5399 id: "templating.handlebars".parse().unwrap(),
5400 pack_alias: None,
5401 operation: None,
5402 },
5403 input: InputMapping {
5404 mapping: json!({ "text": "hello" }),
5405 },
5406 output: OutputMapping {
5407 mapping: Value::Null,
5408 },
5409 err_map: None,
5410 routing: Routing::End,
5411 telemetry: TelemetryHints::default(),
5412 conversational: false,
5413 };
5414 let host = HostNode::from(node);
5415 assert!(
5416 matches!(&host.kind, NodeKind::PackComponent { component_ref } if component_ref == "templating"),
5417 "packed <component>.<operation> id must split, got kind {:?}",
5418 host.kind
5419 );
5420 assert_eq!(host.operation_name(), Some("handlebars"));
5421 }
5422
5423 #[cfg(feature = "agentic-worker")]
5424 #[test]
5425 fn dw_agent_node_routes_to_handler_and_returns_reply() {
5426 use crate::runner::agent_node::{AgentNodeHandler, RuntimeAgentNodeHandler};
5427 use greentic_aw_runtime::cost::MockTokenMeter;
5428 use greentic_aw_runtime::llm::LlmResponse;
5429 use greentic_aw_runtime::mock::{
5430 MockAgentStateStore, MockConfigProvider, MockLlmBackend, MockTelemetry, NoopToolLedger,
5431 };
5432 use greentic_aw_runtime::{
5433 AgentConfig, AgentLimits, AgentRuntime, LlmProviderRef, TenantContext,
5434 };
5435
5436 let llm = Arc::new(MockLlmBackend::new(vec![Ok(LlmResponse {
5438 content: Some("pong".into()),
5439 tool_calls: vec![],
5440 tokens_in: 1,
5441 tokens_out: 1,
5442 })]));
5443 let store = Arc::new(MockAgentStateStore::new());
5444 let telemetry = Arc::new(MockTelemetry::new());
5445
5446 let config_provider = MockConfigProvider::new();
5451 let tenant = TenantContext::new("demo", "local");
5452 config_provider.insert(
5453 &tenant,
5454 "greeter",
5455 AgentConfig {
5456 agent_id: "greeter".into(),
5457 system_prompt: "sys".into(),
5458 tools: vec![],
5459 guardrails: vec![],
5460 llm: LlmProviderRef {
5461 provider: "mock".into(),
5462 model: "m".into(),
5463 credential_ref: None,
5464 },
5465 limits: AgentLimits::default(),
5466 memory: None,
5467 knowledge: None,
5468 },
5469 );
5470 let config_provider = Arc::new(config_provider);
5471 let token_meter = Arc::new(MockTokenMeter::new(0));
5472 let ledger = Arc::new(NoopToolLedger);
5473 let ext_runtime = Arc::new(crate::runner::agent_node::test_extension_runtime());
5474 let runtime = Arc::new(AgentRuntime::new(
5475 config_provider,
5476 store,
5477 ext_runtime,
5478 llm,
5479 telemetry,
5480 token_meter,
5481 ledger,
5482 None,
5483 ));
5484 let handler: Arc<dyn AgentNodeHandler> =
5485 Arc::new(RuntimeAgentNodeHandler::new(runtime, None, None));
5486
5487 let node_id = NodeId::from_str("agent").unwrap();
5489 let node = Node {
5490 id: node_id.clone(),
5491 component: FlowComponentRef {
5492 id: "dw.agent".parse().unwrap(),
5493 pack_alias: None,
5494 operation: Some("greeter".to_string()),
5495 },
5496 input: InputMapping {
5497 mapping: json!({ "user_text": "ping" }),
5498 },
5499 output: OutputMapping {
5500 mapping: Value::Null,
5501 },
5502 err_map: None,
5503 routing: Routing::End,
5504 telemetry: TelemetryHints::default(),
5505 conversational: false,
5506 };
5507 let mut nodes = indexmap::IndexMap::default();
5508 nodes.insert(node_id.clone(), node);
5509 let flow = Flow {
5510 schema_version: "1.0".into(),
5511 id: FlowId::from_str("dw.flow").unwrap(),
5512 kind: FlowKind::Messaging,
5513 entrypoints: BTreeMap::from([(
5514 "default".to_string(),
5515 Value::String(node_id.to_string()),
5516 )]),
5517 nodes,
5518 metadata: Default::default(),
5519 };
5520 let host_flow = HostFlow::from(flow);
5521
5522 let engine = FlowEngine {
5523 packs: Vec::new(),
5524 flows: Vec::new(),
5525 flow_sources: HashMap::new(),
5526 messaging_provider_pack_ids: std::collections::HashSet::new(),
5527 flow_cache: RwLock::new(HashMap::from([(
5528 FlowKey {
5529 pack_id: "test-pack".to_string(),
5530 flow_id: "dw.flow".to_string(),
5531 },
5532 host_flow,
5533 )])),
5534 default_env: "local".to_string(),
5535 validation: ValidationConfig {
5536 mode: ValidationMode::Off,
5537 },
5538 cross_pack_resolver: None,
5539 rollout_ids: RolloutIds::default(),
5540 remote_dispatch_handler: None,
5541 #[cfg(feature = "agentic-worker")]
5542 dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
5543 #[cfg(feature = "agentic-worker")]
5544 agent_node_handler: Some(handler),
5545 #[cfg(feature = "agentic-worker")]
5546 graph_node_handler: None,
5547 #[cfg(feature = "agentic-worker")]
5548 mcp_tool_source: None,
5549 };
5550 let ctx = FlowContext {
5551 tenant: "demo",
5552 pack_id: "test-pack",
5553 flow_id: "dw.flow",
5554 node_id: None,
5555 tool: None,
5556 action: None,
5557 session_id: Some("sess-1"),
5558 provider_id: None,
5559 reply_scope: None,
5560 retry_config: RetryConfig {
5561 max_attempts: 1,
5562 base_delay_ms: 1,
5563 },
5564 attempt: 1,
5565 observer: None,
5566 mocks: None,
5567 };
5568
5569 let rt = Runtime::new().unwrap();
5570 let result = rt
5571 .block_on(engine.execute(ctx, json!({ "user_text": "ping" })))
5572 .unwrap();
5573 assert!(matches!(result.status, FlowStatus::Completed));
5574
5575 let output_str = serde_json::to_string(&result.output).unwrap();
5578 assert!(
5579 output_str.contains("pong"),
5580 "expected agent reply in flow output, got: {output_str}"
5581 );
5582 }
5583
5584 #[cfg(feature = "agentic-worker")]
5590 #[test]
5591 fn dw_agent_graph_node_routes_to_handler_and_returns_reply() {
5592 use std::sync::Mutex;
5593
5594 use crate::runner::graph_node::GraphNodeHandler;
5595
5596 struct RecordingGraphHandler {
5598 seen: Mutex<Option<(String, String, String, String)>>,
5599 }
5600
5601 #[async_trait::async_trait]
5602 impl GraphNodeHandler for RecordingGraphHandler {
5603 async fn execute(
5604 &self,
5605 tenant_id: &str,
5606 env_id: &str,
5607 graph_id: &str,
5608 session_id: &str,
5609 _flow_input: &Value,
5610 ) -> Result<Value> {
5611 *self.seen.lock().unwrap() = Some((
5612 tenant_id.to_string(),
5613 env_id.to_string(),
5614 graph_id.to_string(),
5615 session_id.to_string(),
5616 ));
5617 Ok(json!({
5618 "reply": "graph-pong",
5619 "trail": [],
5620 "terminated_by": "respond",
5621 }))
5622 }
5623 }
5624
5625 let handler = Arc::new(RecordingGraphHandler {
5626 seen: Mutex::new(None),
5627 });
5628 let handler_dyn: Arc<dyn GraphNodeHandler> = handler.clone();
5629
5630 let node_id = NodeId::from_str("graph").unwrap();
5632 let node = Node {
5633 id: node_id.clone(),
5634 component: FlowComponentRef {
5635 id: "dw.agent_graph".parse().unwrap(),
5636 pack_alias: None,
5637 operation: Some("triage".to_string()),
5638 },
5639 input: InputMapping {
5640 mapping: json!({ "user_text": "ping" }),
5641 },
5642 output: OutputMapping {
5643 mapping: Value::Null,
5644 },
5645 err_map: None,
5646 routing: Routing::End,
5647 telemetry: TelemetryHints::default(),
5648 conversational: false,
5649 };
5650 let mut nodes = indexmap::IndexMap::default();
5651 nodes.insert(node_id.clone(), node);
5652 let flow = Flow {
5653 schema_version: "1.0".into(),
5654 id: FlowId::from_str("dwg.flow").unwrap(),
5655 kind: FlowKind::Messaging,
5656 entrypoints: BTreeMap::from([(
5657 "default".to_string(),
5658 Value::String(node_id.to_string()),
5659 )]),
5660 nodes,
5661 metadata: Default::default(),
5662 };
5663 let host_flow = HostFlow::from(flow);
5664
5665 let engine = FlowEngine {
5666 packs: Vec::new(),
5667 flows: Vec::new(),
5668 flow_sources: HashMap::new(),
5669 messaging_provider_pack_ids: std::collections::HashSet::new(),
5670 flow_cache: RwLock::new(HashMap::from([(
5671 FlowKey {
5672 pack_id: "test-pack".to_string(),
5673 flow_id: "dwg.flow".to_string(),
5674 },
5675 host_flow,
5676 )])),
5677 default_env: "local".to_string(),
5678 validation: ValidationConfig {
5679 mode: ValidationMode::Off,
5680 },
5681 cross_pack_resolver: None,
5682 rollout_ids: RolloutIds::default(),
5683 remote_dispatch_handler: None,
5684 #[cfg(feature = "agentic-worker")]
5685 dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
5686 #[cfg(feature = "agentic-worker")]
5687 agent_node_handler: None,
5688 #[cfg(feature = "agentic-worker")]
5689 graph_node_handler: Some(handler_dyn),
5690 #[cfg(feature = "agentic-worker")]
5691 mcp_tool_source: None,
5692 };
5693 let ctx = FlowContext {
5694 tenant: "demo",
5695 pack_id: "test-pack",
5696 flow_id: "dwg.flow",
5697 node_id: None,
5698 tool: None,
5699 action: None,
5700 session_id: Some("sess-1"),
5701 provider_id: None,
5702 reply_scope: None,
5703 retry_config: RetryConfig {
5704 max_attempts: 1,
5705 base_delay_ms: 1,
5706 },
5707 attempt: 1,
5708 observer: None,
5709 mocks: None,
5710 };
5711
5712 let rt = Runtime::new().unwrap();
5713 let result = rt
5714 .block_on(engine.execute(ctx, json!({ "user_text": "ping" })))
5715 .unwrap();
5716 assert!(matches!(result.status, FlowStatus::Completed));
5717
5718 let seen = handler.seen.lock().unwrap().clone();
5721 assert_eq!(
5722 seen,
5723 Some((
5724 "demo".to_string(),
5725 "local".to_string(),
5726 "triage".to_string(),
5727 "sess-1".to_string(),
5728 )),
5729 "dw.agent_graph dispatch must mirror dw.agent's tenant/env/graph_id/session derivation"
5730 );
5731
5732 let output_str = serde_json::to_string(&result.output).unwrap();
5733 assert!(
5734 output_str.contains("graph-pong"),
5735 "expected graph reply in flow output, got: {output_str}"
5736 );
5737 }
5738
5739 #[cfg(feature = "agentic-worker")]
5745 #[test]
5746 fn dw_agent_nats_mode_dispatches_remote() {
5747 use std::sync::Mutex;
5748
5749 use crate::runner::agent_node::DwAgentDispatch;
5750 use crate::runner::remote_dispatch::{
5751 RemoteDispatch, RemoteDispatchAction, RemoteDispatchHandler,
5752 };
5753
5754 struct RecordingDispatcher {
5757 seen: Mutex<Option<RemoteDispatch>>,
5758 }
5759
5760 #[async_trait::async_trait]
5761 impl RemoteDispatchHandler for RecordingDispatcher {
5762 async fn dispatch(
5763 &self,
5764 request: RemoteDispatch,
5765 ) -> anyhow::Result<RemoteDispatchAction> {
5766 let corr = request.correlation_id.clone();
5767 *self.seen.lock().unwrap() = Some(request);
5768 Ok(RemoteDispatchAction::AwaitingResponse {
5769 correlation_id: corr,
5770 })
5771 }
5772 }
5773
5774 let dispatcher = Arc::new(RecordingDispatcher {
5775 seen: Mutex::new(None),
5776 });
5777
5778 let resume_id = NodeId::from_str("after-agent").unwrap();
5783 let node_id = NodeId::from_str("agent-nats").unwrap();
5784 let agent_node = Node {
5785 id: node_id.clone(),
5786 component: FlowComponentRef {
5787 id: "dw.agent".parse().unwrap(),
5788 pack_alias: None,
5789 operation: Some("greeter".to_string()),
5790 },
5791 input: InputMapping {
5792 mapping: json!({ "user_text": "hi" }),
5793 },
5794 output: OutputMapping {
5795 mapping: Value::Null,
5796 },
5797 err_map: None,
5798 routing: Routing::Next {
5799 node_id: resume_id.clone(),
5800 },
5801 telemetry: TelemetryHints::default(),
5802 conversational: false,
5803 };
5804 let resume_node = Node {
5805 id: resume_id.clone(),
5806 component: FlowComponentRef {
5807 id: "emit.log".parse().unwrap(),
5808 pack_alias: None,
5809 operation: None,
5810 },
5811 input: InputMapping {
5812 mapping: json!({ "message": "done" }),
5813 },
5814 output: OutputMapping {
5815 mapping: Value::Null,
5816 },
5817 err_map: None,
5818 routing: Routing::End,
5819 telemetry: TelemetryHints::default(),
5820 conversational: false,
5821 };
5822 let mut nodes = indexmap::IndexMap::default();
5823 nodes.insert(node_id.clone(), agent_node);
5824 nodes.insert(resume_id.clone(), resume_node);
5825 let flow = Flow {
5826 schema_version: "1.0".into(),
5827 id: FlowId::from_str("nats-agent.flow").unwrap(),
5828 kind: FlowKind::Messaging,
5829 entrypoints: BTreeMap::from([(
5830 "default".to_string(),
5831 Value::String(node_id.to_string()),
5832 )]),
5833 nodes,
5834 metadata: Default::default(),
5835 };
5836 let host_flow = HostFlow::from(flow);
5837
5838 let engine = FlowEngine {
5839 packs: Vec::new(),
5840 flows: Vec::new(),
5841 flow_sources: HashMap::new(),
5842 messaging_provider_pack_ids: std::collections::HashSet::new(),
5843 flow_cache: RwLock::new(HashMap::from([(
5844 FlowKey {
5845 pack_id: "test-pack".to_string(),
5846 flow_id: "nats-agent.flow".to_string(),
5847 },
5848 host_flow,
5849 )])),
5850 default_env: "local".to_string(),
5851 validation: ValidationConfig {
5852 mode: ValidationMode::Off,
5853 },
5854 cross_pack_resolver: None,
5855 rollout_ids: RolloutIds::default(),
5856 remote_dispatch_handler: Some(dispatcher.clone() as Arc<dyn crate::runner::remote_dispatch::RemoteDispatchHandler>),
5857 #[cfg(feature = "agentic-worker")]
5858 dw_agent_dispatch: DwAgentDispatch::Nats,
5859 #[cfg(feature = "agentic-worker")]
5860 agent_node_handler: None,
5862 #[cfg(feature = "agentic-worker")]
5863 graph_node_handler: None,
5864 #[cfg(feature = "agentic-worker")]
5865 mcp_tool_source: None,
5866 };
5867
5868 let ctx = FlowContext {
5869 tenant: "demo",
5870 pack_id: "test-pack",
5871 flow_id: "nats-agent.flow",
5872 node_id: None,
5873 tool: None,
5874 action: None,
5875 session_id: Some("sess-nats"),
5876 provider_id: None,
5877 reply_scope: None,
5878 retry_config: RetryConfig {
5879 max_attempts: 1,
5880 base_delay_ms: 1,
5881 },
5882 attempt: 1,
5883 observer: None,
5884 mocks: None,
5885 };
5886
5887 let rt = Runtime::new().unwrap();
5888 let result = rt
5889 .block_on(engine.execute(ctx, json!({ "user_text": "hi" })))
5890 .unwrap();
5891
5892 assert!(
5894 matches!(result.status, FlowStatus::Waiting(_)),
5895 "expected Waiting outcome from dw.agent Nats mode, got: {:?}",
5896 result.status
5897 );
5898
5899 let seen = dispatcher.seen.lock().unwrap();
5902 let dispatch = seen.as_ref().expect("dispatcher was not called");
5903 assert_eq!(
5904 dispatch.runtime, "agentic",
5905 "runtime name must be 'agentic'"
5906 );
5907 assert_eq!(dispatch.target, "greeter", "target must be the agent_id");
5908 assert_eq!(
5909 dispatch.input,
5910 json!({ "user_text": "hi" }),
5911 "node payload must be forwarded as dispatch input"
5912 );
5913 }
5914
5915 fn host_flow_for_test(
5916 flow_id: &str,
5917 node_ids: &[&str],
5918 default_start: Option<&str>,
5919 ) -> HostFlow {
5920 let mut nodes = indexmap::IndexMap::default();
5921 for node_id in node_ids {
5922 let id = NodeId::from_str(node_id).unwrap();
5923 let node = Node {
5924 id: id.clone(),
5925 component: FlowComponentRef {
5926 id: "emit.log".parse().unwrap(),
5927 pack_alias: None,
5928 operation: None,
5929 },
5930 input: InputMapping {
5931 mapping: json!({ "message": node_id }),
5932 },
5933 output: OutputMapping {
5934 mapping: Value::Null,
5935 },
5936 err_map: None,
5937 routing: Routing::End,
5938 telemetry: TelemetryHints::default(),
5939 conversational: false,
5940 };
5941 nodes.insert(id, node);
5942 }
5943 let mut entrypoints = BTreeMap::new();
5944 if let Some(start) = default_start {
5945 entrypoints.insert("default".to_string(), Value::String(start.to_string()));
5946 }
5947 HostFlow::from(Flow {
5948 schema_version: "1.0".into(),
5949 id: FlowId::from_str(flow_id).unwrap(),
5950 kind: FlowKind::Messaging,
5951 entrypoints,
5952 nodes,
5953 metadata: Default::default(),
5954 })
5955 }
5956
5957 fn jump_test_engine() -> FlowEngine {
5958 let target_flow = host_flow_for_test("flow.target", &["node-a", "node-b"], None);
5959 FlowEngine {
5960 packs: Vec::new(),
5961 flows: Vec::new(),
5962 flow_sources: HashMap::new(),
5963 messaging_provider_pack_ids: std::collections::HashSet::new(),
5964 flow_cache: RwLock::new(HashMap::from([(
5965 FlowKey {
5966 pack_id: "test-pack".to_string(),
5967 flow_id: "flow.target".to_string(),
5968 },
5969 target_flow,
5970 )])),
5971 default_env: "local".to_string(),
5972 validation: ValidationConfig {
5973 mode: ValidationMode::Off,
5974 },
5975 cross_pack_resolver: None,
5976 rollout_ids: RolloutIds::default(),
5977 remote_dispatch_handler: None,
5978 #[cfg(feature = "agentic-worker")]
5979 dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
5980 #[cfg(feature = "agentic-worker")]
5981 agent_node_handler: None,
5982 #[cfg(feature = "agentic-worker")]
5983 graph_node_handler: None,
5984 #[cfg(feature = "agentic-worker")]
5985 mcp_tool_source: None,
5986 }
5987 }
5988
5989 fn jump_ctx<'a>(flow_id: &'a str) -> FlowContext<'a> {
5990 FlowContext {
5991 tenant: "demo",
5992 pack_id: "test-pack",
5993 flow_id,
5994 node_id: None,
5995 tool: None,
5996 action: None,
5997 session_id: None,
5998 provider_id: None,
5999 reply_scope: None,
6000 retry_config: RetryConfig {
6001 max_attempts: 1,
6002 base_delay_ms: 1,
6003 },
6004 attempt: 1,
6005 observer: None,
6006 mocks: None,
6007 }
6008 }
6009
6010 #[test]
6011 fn with_rollout_ids_binds_revision_identity() {
6012 let engine = minimal_engine().with_rollout_ids(RolloutIds {
6013 customer_id: Some("cust-acme".into()),
6014 deployment_id: Some("01JTKS".into()),
6015 bundle_id: Some("customer.support".into()),
6016 revision_id: Some("01JTKR".into()),
6017 });
6018 assert_eq!(engine.rollout_ids.revision_id.as_deref(), Some("01JTKR"));
6019 assert_eq!(engine.rollout_ids.deployment_id.as_deref(), Some("01JTKS"));
6020 assert!(minimal_engine().rollout_ids.is_empty());
6022 }
6023
6024 #[test]
6033 fn a_flow_goto_node_produces_a_jump_the_engine_applies() {
6034 let outcome = execute_flow_goto(json!({
6035 "flow_id": "flow.target",
6036 "input": { "order": "A-1" },
6037 }))
6038 .expect("goto builds");
6039 let NodeControl::Jump(jump) = outcome.control else {
6040 panic!("expected a Jump");
6041 };
6042
6043 let engine = jump_test_engine();
6044 let mut state = ExecutionState::new(Value::Null);
6045 let rt = Runtime::new().unwrap();
6046 let target = rt
6047 .block_on(engine.apply_jump(&jump_ctx("flow.source"), &mut state, jump))
6048 .expect("the engine must accept the jump a flow.goto node builds");
6049
6050 assert_eq!(target.flow_id, "flow.target");
6051 assert_eq!(
6052 target.node_id.as_str(),
6053 "node-a",
6054 "no explicit node means the target flow's first node"
6055 );
6056 assert_eq!(
6057 state.redirect_count(),
6058 1,
6059 "the loop guard must have counted"
6060 );
6061 }
6062
6063 #[test]
6066 fn a_flow_goto_node_can_name_the_entry_node() {
6067 let outcome = execute_flow_goto(json!({ "flow_id": "flow.target", "node": "node-b" }))
6068 .expect("goto builds");
6069 let NodeControl::Jump(jump) = outcome.control else {
6070 panic!("expected a Jump");
6071 };
6072
6073 let engine = jump_test_engine();
6074 let mut state = ExecutionState::new(Value::Null);
6075 let rt = Runtime::new().unwrap();
6076 let target = rt
6077 .block_on(engine.apply_jump(&jump_ctx("flow.source"), &mut state, jump))
6078 .expect("jump applies");
6079 assert_eq!(target.node_id.as_str(), "node-b");
6080 }
6081
6082 #[test]
6083 fn apply_jump_unknown_flow_errors() {
6084 let engine = minimal_engine();
6085 let mut state = ExecutionState::new(Value::Null);
6086 let rt = Runtime::new().unwrap();
6087 let err = rt
6088 .block_on(engine.apply_jump(
6089 &jump_ctx("flow.source"),
6090 &mut state,
6091 JumpControl {
6092 flow: "flow.missing".into(),
6093 node: None,
6094 payload: json!({ "ok": true }),
6095 hints: Value::Null,
6096 max_redirects: None,
6097 reason: None,
6098 },
6099 ))
6100 .unwrap_err();
6101 assert!(
6102 err.to_string().contains("unknown_flow"),
6103 "unexpected error: {err}"
6104 );
6105 }
6106
6107 #[test]
6108 fn apply_jump_unknown_node_errors() {
6109 let engine = jump_test_engine();
6110 let mut state = ExecutionState::new(Value::Null);
6111 let rt = Runtime::new().unwrap();
6112 let err = rt
6113 .block_on(engine.apply_jump(
6114 &jump_ctx("flow.source"),
6115 &mut state,
6116 JumpControl {
6117 flow: "flow.target".into(),
6118 node: Some("node-missing".into()),
6119 payload: json!({ "ok": true }),
6120 hints: Value::Null,
6121 max_redirects: None,
6122 reason: None,
6123 },
6124 ))
6125 .unwrap_err();
6126 assert!(
6127 err.to_string().contains("unknown_node"),
6128 "unexpected error: {err}"
6129 );
6130 }
6131
6132 #[test]
6133 fn apply_jump_uses_default_start_fallback() {
6134 let engine = jump_test_engine();
6135 let mut state = ExecutionState::new(Value::Null);
6136 let rt = Runtime::new().unwrap();
6137 let target = rt
6138 .block_on(engine.apply_jump(
6139 &jump_ctx("flow.source"),
6140 &mut state,
6141 JumpControl {
6142 flow: "flow.target".into(),
6143 node: None,
6144 payload: json!({ "k": "v" }),
6145 hints: Value::Null,
6146 max_redirects: None,
6147 reason: None,
6148 },
6149 ))
6150 .expect("jump target");
6151 assert_eq!(target.flow_id, "flow.target");
6152 assert_eq!(target.node_id.as_str(), "node-a");
6153 }
6154
6155 #[test]
6156 fn apply_jump_redirect_limit_enforced() {
6157 let engine = jump_test_engine();
6158 let mut state = ExecutionState::new(Value::Null);
6159 state.redirect_count = 3;
6160 let rt = Runtime::new().unwrap();
6161 let err = rt
6162 .block_on(engine.apply_jump(
6163 &jump_ctx("flow.source"),
6164 &mut state,
6165 JumpControl {
6166 flow: "flow.target".into(),
6167 node: None,
6168 payload: json!({ "k": "v" }),
6169 hints: Value::Null,
6170 max_redirects: Some(3),
6171 reason: None,
6172 },
6173 ))
6174 .unwrap_err();
6175 assert_eq!(err.to_string(), "redirect_limit");
6176 }
6177
6178 #[test]
6185 fn evaluate_custom_routing_waits_when_conditional_falls_through() {
6186 let raw_routing = json!([
6187 { "condition": "response.action == \"go\"", "to": "next" },
6188 { "out": true }
6189 ]);
6190 let flow_ir = HostFlow {
6191 id: "flow.test".to_string(),
6192 start: None,
6193 nodes: IndexMap::new(),
6194 slot_schema: None,
6195 vars_init: JsonMap::new(),
6196 };
6197 let current_node = NodeId::from_str("current").unwrap();
6198 let output = NodeOutput::new(Value::Null);
6199
6200 let mut state_empty = ExecutionState::new(json!({ "metadata": { "action": "" } }));
6202 state_empty.entry = json!({ "metadata": { "action": "" } });
6203 let decision_empty =
6204 evaluate_custom_routing(&raw_routing, &output, &state_empty, &flow_ir, ¤t_node);
6205 assert!(
6206 matches!(decision_empty, CustomRoutingDecision::Wait),
6207 "expected Wait on conditional fall-through, got {decision_empty:?}"
6208 );
6209
6210 let mut state_go = ExecutionState::new(json!({ "metadata": { "action": "go" } }));
6212 state_go.entry = json!({ "metadata": { "action": "go" } });
6213 let decision_go =
6214 evaluate_custom_routing(&raw_routing, &output, &state_go, &flow_ir, ¤t_node);
6215 match decision_go {
6216 CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "next"),
6217 other => panic!("expected Next(\"next\"), got {other:?}"),
6218 }
6219 }
6220
6221 #[test]
6222 fn node_output_with_error_marks_ok_false_and_stashes_in_meta() {
6223 let err: Box<dyn std::error::Error + 'static> =
6224 Box::<dyn std::error::Error + 'static>::from("weatherapi returned 401 Unauthorized");
6225 let out = NodeOutput::with_error("call_weather", err.as_ref());
6226 assert!(!out.ok);
6227 assert_eq!(out.payload, Value::Null);
6228 assert_eq!(out.meta["error"]["kind"], "flow_node_failed");
6229 assert_eq!(out.meta["error"]["node_id"], "call_weather");
6230 assert_eq!(
6231 out.meta["error"]["message"],
6232 "weatherapi returned 401 Unauthorized"
6233 );
6234 }
6235
6236 #[test]
6244 fn a_failed_mcp_call_marks_the_node_not_ok() {
6245 let result = json!({ "error": "MCP is not configured on this runner" });
6246 let bound = json!({ "quote_result_data": result.clone() });
6247
6248 let out = mcp_output(bound.clone(), &result);
6249
6250 assert!(!out.ok, "a failed MCP call must not report ok");
6251 assert_eq!(
6252 out.meta.pointer("/error/message").and_then(Value::as_str),
6253 Some("MCP is not configured on this runner"),
6254 "the message must reach meta.error where the lift reads it, got {:?}",
6255 out.meta
6256 );
6257 assert_eq!(
6258 out.payload, bound,
6259 "the bound payload must be untouched — routing and any flow \
6260 reading the bound value must behave exactly as before"
6261 );
6262 }
6263
6264 #[test]
6266 fn a_successful_mcp_call_stays_ok() {
6267 let result = json!({ "annual_premium": 1234 });
6268 let bound = json!({ "quote_result_data": result.clone() });
6269
6270 let out = mcp_output(bound.clone(), &result);
6271
6272 assert!(out.ok, "a successful MCP call must report ok");
6273 assert_eq!(out.payload, bound);
6274 assert!(
6275 out.meta.get("error").is_none(),
6276 "a successful call must not stash an error, got {:?}",
6277 out.meta
6278 );
6279 }
6280
6281 #[test]
6282 fn lift_first_node_error_promotes_node_meta_to_output_metadata() {
6283 let mut nodes: HashMap<String, NodeOutput> = HashMap::new();
6288 let err: Box<dyn std::error::Error + 'static> =
6289 Box::<dyn std::error::Error + 'static>::from("weatherapi returned 401 Unauthorized");
6290 nodes.insert(
6291 "call_weather".to_string(),
6292 NodeOutput::with_error("call_weather", err.as_ref()),
6293 );
6294 nodes.insert(
6295 "render_current_card".to_string(),
6296 NodeOutput::new(json!({ "text": "message" })),
6297 );
6298
6299 let final_output = json!({ "text": "message" });
6300 let enriched = lift_first_node_error_from_nodes(final_output, &nodes);
6301 assert_eq!(
6302 enriched["metadata"]["error_kind"], "flow_node_failed",
6303 "first failing node's kind must be lifted"
6304 );
6305 assert_eq!(
6306 enriched["metadata"]["error_message"],
6307 "weatherapi returned 401 Unauthorized"
6308 );
6309 assert_eq!(enriched["metadata"]["node_id"], "call_weather");
6310 assert_eq!(enriched["text"], "message");
6313 }
6314
6315 #[test]
6316 fn lift_first_node_error_is_noop_when_all_nodes_ok() {
6317 let mut nodes: HashMap<String, NodeOutput> = HashMap::new();
6318 nodes.insert(
6319 "ok_node".to_string(),
6320 NodeOutput::new(json!({ "text": "all good" })),
6321 );
6322 let output = json!({ "text": "all good" });
6323 let lifted = lift_first_node_error_from_nodes(output.clone(), &nodes);
6324 assert_eq!(lifted, output);
6325 }
6326
6327 #[tokio::test]
6328 async fn execute_user_facing_flow_failure_returns_completed_with_error_envelope() {
6329 let flow_id_str = "broken.flow";
6334 let pack_id_str = "test-pack";
6335 let host_flow = host_flow_for_test(flow_id_str, &["only-node"], Some("does-not-exist"));
6336 let engine = FlowEngine {
6337 packs: Vec::new(),
6338 flows: Vec::new(),
6339 flow_sources: HashMap::new(),
6340 messaging_provider_pack_ids: std::collections::HashSet::new(),
6341 flow_cache: RwLock::new(HashMap::from([(
6342 FlowKey {
6343 pack_id: pack_id_str.to_string(),
6344 flow_id: flow_id_str.to_string(),
6345 },
6346 host_flow,
6347 )])),
6348 default_env: "local".to_string(),
6349 validation: ValidationConfig {
6350 mode: ValidationMode::Off,
6351 },
6352 cross_pack_resolver: None,
6353 rollout_ids: RolloutIds::default(),
6354 remote_dispatch_handler: None,
6355 #[cfg(feature = "agentic-worker")]
6356 dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
6357 #[cfg(feature = "agentic-worker")]
6358 agent_node_handler: None,
6359 #[cfg(feature = "agentic-worker")]
6360 graph_node_handler: None,
6361 #[cfg(feature = "agentic-worker")]
6362 mcp_tool_source: None,
6363 };
6364 let ctx = FlowContext {
6365 tenant: "demo",
6366 pack_id: pack_id_str,
6367 flow_id: flow_id_str,
6368 node_id: None,
6369 tool: None,
6370 action: None,
6371 session_id: Some("conv-1"),
6372 provider_id: None,
6373 reply_scope: None,
6374 retry_config: RetryConfig {
6375 max_attempts: 1,
6376 base_delay_ms: 1,
6377 },
6378 attempt: 1,
6379 observer: None,
6380 mocks: None,
6381 };
6382 let result = engine
6383 .execute(ctx, Value::Null)
6384 .await
6385 .expect("must not propagate Err");
6386 assert!(matches!(result.status, FlowStatus::Completed));
6387 assert_eq!(
6388 result.output["metadata"]["error_kind"],
6389 "flow_execution_failed"
6390 );
6391 let msg = result.output["metadata"]["error_message"]
6392 .as_str()
6393 .unwrap_or("");
6394 assert!(!msg.is_empty(), "error_message must be populated");
6395 assert_eq!(result.output["metadata"]["flow_id"], "broken.flow");
6396 }
6397
6398 #[test]
6399 fn mcp_tool_error_recognises_generator_error_shape() {
6400 let value = json!({
6403 "error": {
6404 "code": "tool_error",
6405 "message": "API request returned status 401",
6406 "status": 401
6407 }
6408 });
6409 let (code, message) = mcp_tool_error(&value).expect("must detect MCP error shape");
6410 assert_eq!(code, "tool_error");
6411 assert!(message.contains("API request returned status 401"));
6412 assert!(message.contains("(status 401)"));
6413 }
6414
6415 #[test]
6416 fn mcp_tool_error_skips_success_responses() {
6417 let value = json!({ "result": { "current": { "temp_c": 19.0 } } });
6419 assert!(mcp_tool_error(&value).is_none());
6420 }
6421
6422 #[test]
6423 fn mcp_tool_error_skips_non_object_and_unrelated_shapes() {
6424 assert!(mcp_tool_error(&Value::Null).is_none());
6425 assert!(mcp_tool_error(&json!({"unrelated": true})).is_none());
6426 assert!(mcp_tool_error(&json!({"error": "oops"})).is_none());
6428 }
6429
6430 #[tokio::test]
6431 async fn execute_non_user_facing_flow_failure_still_propagates() {
6432 let flow_id_str = "broken.flow";
6435 let pack_id_str = "test-pack";
6436 let host_flow = host_flow_for_test(flow_id_str, &["only-node"], Some("does-not-exist"));
6437 let engine = FlowEngine {
6438 packs: Vec::new(),
6439 flows: Vec::new(),
6440 flow_sources: HashMap::new(),
6441 messaging_provider_pack_ids: std::collections::HashSet::new(),
6442 flow_cache: RwLock::new(HashMap::from([(
6443 FlowKey {
6444 pack_id: pack_id_str.to_string(),
6445 flow_id: flow_id_str.to_string(),
6446 },
6447 host_flow,
6448 )])),
6449 default_env: "local".to_string(),
6450 validation: ValidationConfig {
6451 mode: ValidationMode::Off,
6452 },
6453 cross_pack_resolver: None,
6454 rollout_ids: RolloutIds::default(),
6455 remote_dispatch_handler: None,
6456 #[cfg(feature = "agentic-worker")]
6457 dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
6458 #[cfg(feature = "agentic-worker")]
6459 agent_node_handler: None,
6460 #[cfg(feature = "agentic-worker")]
6461 graph_node_handler: None,
6462 #[cfg(feature = "agentic-worker")]
6463 mcp_tool_source: None,
6464 };
6465 let ctx = FlowContext {
6466 tenant: "demo",
6467 pack_id: pack_id_str,
6468 flow_id: flow_id_str,
6469 node_id: None,
6470 tool: None,
6471 action: None,
6472 session_id: None,
6473 provider_id: None,
6474 reply_scope: None,
6475 retry_config: RetryConfig {
6476 max_attempts: 1,
6477 base_delay_ms: 1,
6478 },
6479 attempt: 1,
6480 observer: None,
6481 mocks: None,
6482 };
6483 let result = engine.execute(ctx, Value::Null).await;
6484 assert!(result.is_err(), "non-user-facing flow must propagate Err");
6485 }
6486
6487 #[test]
6490 fn host_flow_extracts_slot_schema_from_metadata_extra() {
6491 use greentic_types::FlowMetadata;
6492 use std::collections::BTreeSet;
6493
6494 let schema = json!([
6495 {"name": "counterparty", "slot_type": "string", "required": true},
6496 {"name": "due_date", "slot_type": "date", "required": true}
6497 ]);
6498 let flow = Flow {
6499 schema_version: "flow-v1".into(),
6500 id: FlowId::from_str("test.flow").unwrap(),
6501 kind: FlowKind::Messaging,
6502 entrypoints: BTreeMap::new(),
6503 nodes: IndexMap::default(),
6504 metadata: FlowMetadata {
6505 title: None,
6506 description: None,
6507 tags: BTreeSet::new(),
6508 extra: json!({(SLOT_SCHEMA_METADATA_KEY): schema}),
6509 },
6510 };
6511 let host = HostFlow::from(flow);
6512 assert_eq!(
6513 host.slot_schema.as_ref(),
6514 Some(&schema),
6515 "HostFlow must extract slot_schema from metadata.extra"
6516 );
6517 }
6518
6519 #[test]
6520 fn host_flow_slot_schema_is_none_when_absent() {
6521 let flow = Flow {
6522 schema_version: "flow-v1".into(),
6523 id: FlowId::from_str("test.flow").unwrap(),
6524 kind: FlowKind::Messaging,
6525 entrypoints: BTreeMap::new(),
6526 nodes: IndexMap::default(),
6527 metadata: Default::default(),
6528 };
6529 let host = HostFlow::from(flow);
6530 assert!(
6531 host.slot_schema.is_none(),
6532 "HostFlow.slot_schema must be None when metadata.extra has no greentic.slot_schema"
6533 );
6534 }
6535
6536 #[test]
6537 fn inject_slot_definitions_adds_to_object_input() {
6538 let schema = json!([
6539 {"name": "city", "slot_type": "string"}
6540 ]);
6541 let mut input = json!({"utterance": "hello"});
6542 inject_slot_definitions(&mut input, &schema, "f", "n");
6543 assert_eq!(
6544 input,
6545 json!({"utterance": "hello", "slot_definitions": schema}),
6546 "slot_definitions must be injected into existing object"
6547 );
6548 }
6549
6550 #[test]
6551 fn inject_slot_definitions_wraps_null_input() {
6552 let schema = json!([{"name": "x", "slot_type": "string"}]);
6553 let mut input = Value::Null;
6554 inject_slot_definitions(&mut input, &schema, "f", "n");
6555 assert_eq!(
6556 input,
6557 json!({"slot_definitions": schema}),
6558 "null input must become an object with slot_definitions"
6559 );
6560 }
6561
6562 #[test]
6563 fn inject_slot_definitions_preserves_explicit_inline() {
6564 let flow_schema = json!([{"name": "city", "slot_type": "string"}]);
6565 let inline_defs = json!([{"name": "country", "slot_type": "string"}]);
6566 let mut input = json!({
6567 "utterance": "hello",
6568 "slot_definitions": inline_defs
6569 });
6570 inject_slot_definitions(&mut input, &flow_schema, "f", "n");
6571 assert_eq!(
6572 input["slot_definitions"], inline_defs,
6573 "explicit inline slot_definitions must not be overwritten"
6574 );
6575 }
6576
6577 #[test]
6578 fn inject_slot_definitions_skips_non_object_input() {
6579 let schema = json!([{"name": "x", "slot_type": "string"}]);
6580 let mut input = json!("a string");
6581 inject_slot_definitions(&mut input, &schema, "f", "n");
6582 assert_eq!(
6583 input,
6584 json!("a string"),
6585 "non-object input must be left unchanged"
6586 );
6587 }
6588
6589 fn make_flow_doc_for_test(
6590 id: &str,
6591 node_name: &str,
6592 component: &str,
6593 slot_schema: Option<Value>,
6594 ) -> greentic_flow::model::FlowDoc {
6595 use greentic_flow::model::{FlowDoc, NodeDoc};
6596
6597 let mut nodes = IndexMap::new();
6598 nodes.insert(
6599 node_name.to_string(),
6600 NodeDoc {
6601 raw: {
6602 let mut m = IndexMap::new();
6603 m.insert(
6604 "component.exec".to_string(),
6605 json!({ "component": component }),
6606 );
6607 m
6608 },
6609 routing: json!([{ "out": true }]),
6610 ..Default::default()
6611 },
6612 );
6613
6614 FlowDoc {
6615 id: id.into(),
6616 title: None,
6617 description: None,
6618 flow_type: "messaging".into(),
6619 start: Some(node_name.into()),
6620 parameters: json!({}),
6621 tags: Vec::new(),
6622 schema_version: None,
6623 entrypoints: IndexMap::new(),
6624 meta: None,
6625 slot_schema,
6626 nodes,
6627 }
6628 }
6629
6630 #[test]
6636 fn compile_flow_round_trips_slot_schema_into_host_flow() {
6637 let slot_defs = json!([
6638 { "name": "counterparty", "slot_type": "string", "required": true,
6639 "pattern": ".+" },
6640 { "name": "due_date", "slot_type": "date", "required": true,
6641 "pattern": "\\d{4}-\\d{2}-\\d{2}" }
6642 ]);
6643 let doc = make_flow_doc_for_test(
6644 "slot-test",
6645 "extractor",
6646 "slot-extractor",
6647 Some(slot_defs.clone()),
6648 );
6649
6650 let flow = greentic_flow::compile_flow(doc).expect("compile_flow must succeed");
6651 assert_eq!(
6652 flow.metadata.extra.get(SLOT_SCHEMA_METADATA_KEY),
6653 Some(&slot_defs),
6654 "compile_flow must forward slot_schema into metadata.extra"
6655 );
6656
6657 let host = HostFlow::from(flow);
6658 assert_eq!(
6659 host.slot_schema.as_ref(),
6660 Some(&slot_defs),
6661 "HostFlow.slot_schema must survive the compile_flow -> HostFlow round-trip"
6662 );
6663 }
6664
6665 #[test]
6669 fn compile_flow_without_slot_schema_leaves_host_flow_none() {
6670 let doc = make_flow_doc_for_test("no-slots", "echo", "echo", None);
6671
6672 let flow = greentic_flow::compile_flow(doc).expect("compile_flow must succeed");
6673 assert!(
6674 flow.metadata.extra.get(SLOT_SCHEMA_METADATA_KEY).is_none(),
6675 "metadata.extra must not contain greentic.slot_schema when FlowDoc.slot_schema is None"
6676 );
6677
6678 let host = HostFlow::from(flow);
6679 assert!(
6680 host.slot_schema.is_none(),
6681 "HostFlow.slot_schema must be None when FlowDoc has no slot_schema"
6682 );
6683 }
6684
6685 #[test]
6686 fn multi_edge_node_routes_on_injected_event() {
6687 let raw_routing = json!([
6688 { "condition": "event == \"on_success\"", "to": "next" },
6689 { "condition": "event == \"on_error\"", "to": "err" }
6690 ]);
6691 let flow_ir = HostFlow {
6692 id: "flow.test".to_string(),
6693 start: None,
6694 nodes: IndexMap::new(),
6695 slot_schema: None,
6696 vars_init: JsonMap::new(),
6697 };
6698 let current = NodeId::from_str("current").unwrap();
6699 let state = ExecutionState::new(json!({}));
6700
6701 let ok_out = NodeOutput::new(json!({ "x": 1 }));
6703 match evaluate_custom_routing(&raw_routing, &ok_out, &state, &flow_ir, ¤t) {
6704 CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "next"),
6705 other => panic!("expected Next(\"next\"), got {other:?}"),
6706 }
6707
6708 let routed = NodeOutput::with_meta(json!({}), json!({ "outcome": "on_error" }));
6710 match evaluate_custom_routing(&raw_routing, &routed, &state, &flow_ir, ¤t) {
6711 CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "err"),
6712 other => panic!("expected Next(\"err\"), got {other:?}"),
6713 }
6714 }
6715
6716 #[test]
6720 fn errored_output_routes_to_on_error_branch() {
6721 let raw_routing = json!([
6722 { "condition": "event == \"on_success\"", "to": "ok_node" },
6723 { "condition": "event == \"on_error\"", "to": "err_node" }
6724 ]);
6725 let flow_ir = HostFlow {
6726 id: "flow.test".to_string(),
6727 start: None,
6728 nodes: IndexMap::new(),
6729 slot_schema: None,
6730 vars_init: JsonMap::new(),
6731 };
6732 let current = NodeId::from_str("current").unwrap();
6733 let state = ExecutionState::new(json!({}));
6734
6735 let errored =
6736 NodeOutput::errored(json!({ "ok": false, "error": { "code": "E", "message": "m" } }));
6737 match evaluate_custom_routing(&raw_routing, &errored, &state, &flow_ir, ¤t) {
6738 CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "err_node"),
6739 other => panic!("expected on_error route, got {other:?}"),
6740 }
6741 }
6742
6743 #[test]
6744 fn node_has_error_route_detects_error_family_ports() {
6745 let with_err = Routing::Custom(json!([
6746 { "condition": "event == \"on_success\"", "to": "n" },
6747 { "condition": "event == \"on_error\"", "to": "e" }
6748 ]));
6749 assert!(
6750 node_has_error_route(&with_err),
6751 "on_error route must be detected"
6752 );
6753
6754 let only_success = Routing::Custom(json!([
6755 { "condition": "event == \"on_success\"", "to": "n" }
6756 ]));
6757 assert!(
6758 !node_has_error_route(&only_success),
6759 "a success-only Custom routing has no error branch"
6760 );
6761
6762 let plain = Routing::Next {
6763 node_id: NodeId::from_str("n").unwrap(),
6764 };
6765 assert!(
6766 !node_has_error_route(&plain),
6767 "Routing::Next has no error branch"
6768 );
6769 }
6770
6771 #[test]
6780 fn success_default_matches_available_outcome_port() {
6781 let flow_ir = HostFlow {
6782 id: "flow.test".to_string(),
6783 start: None,
6784 nodes: IndexMap::new(),
6785 slot_schema: None,
6786 vars_init: JsonMap::new(),
6787 };
6788 let current = NodeId::from_str("current").unwrap();
6789 let state = ExecutionState::new(json!({}));
6790 let ok_out = NodeOutput::new(json!({ "answer": "hi" }));
6792
6793 let on_complete_routing = json!([
6795 { "condition": "event == \"on_complete\"", "to": "next" },
6796 { "condition": "event == \"on_cancel\"", "to": "cancelled" }
6797 ]);
6798 match evaluate_custom_routing(&on_complete_routing, &ok_out, &state, &flow_ir, ¤t) {
6799 CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "next"),
6800 other => panic!("expected Next(\"next\") via on_complete default, got {other:?}"),
6801 }
6802
6803 let on_submit_routing = json!([
6805 { "condition": "event == \"on_submit\"", "to": "saved" },
6806 { "condition": "event == \"on_cancel\"", "to": "cancelled" }
6807 ]);
6808 match evaluate_custom_routing(&on_submit_routing, &ok_out, &state, &flow_ir, ¤t) {
6809 CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "saved"),
6810 other => panic!("expected Next(\"saved\") via on_submit default, got {other:?}"),
6811 }
6812
6813 let on_success_routing = json!([
6816 { "condition": "event == \"on_success\"", "to": "ok" },
6817 { "condition": "event == \"on_error\"", "to": "err" }
6818 ]);
6819 match evaluate_custom_routing(&on_success_routing, &ok_out, &state, &flow_ir, ¤t) {
6820 CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "ok"),
6821 other => panic!("expected Next(\"ok\") via on_success default, got {other:?}"),
6822 }
6823 }
6824
6825 #[test]
6831 fn condition_evaluator_supports_comparisons_and_contains() {
6832 let ctx = json!({
6833 "register": { "q_age": 18 },
6834 "submit": { "status": "ok" },
6835 "msg": { "text": "Hello World" }
6836 });
6837
6838 assert!(evaluate_simple_condition("register.q_age >= 18", &ctx));
6840 assert!(!evaluate_simple_condition("register.q_age > 18", &ctx));
6841 assert!(evaluate_simple_condition("register.q_age <= 18", &ctx));
6842 assert!(!evaluate_simple_condition("register.q_age < 18", &ctx));
6843
6844 assert!(evaluate_simple_condition(
6846 "msg.text contains \"world\"",
6847 &ctx
6848 ));
6849 assert!(!evaluate_simple_condition(
6850 "msg.text contains \"bye\"",
6851 &ctx
6852 ));
6853
6854 assert!(evaluate_simple_condition("submit.status == \"ok\"", &ctx));
6856 assert!(!evaluate_simple_condition("submit.status != \"ok\"", &ctx));
6857 assert!(!evaluate_simple_condition("submit.status >= 1", &ctx));
6859 }
6860
6861 #[test]
6867 fn failure_default_matches_available_outcome_port() {
6868 let flow_ir = HostFlow {
6869 id: "flow.test".to_string(),
6870 start: None,
6871 nodes: IndexMap::new(),
6872 slot_schema: None,
6873 vars_init: JsonMap::new(),
6874 };
6875 let current = NodeId::from_str("current").unwrap();
6876 let state = ExecutionState::new(json!({}));
6877 let err_out = NodeOutput {
6879 ok: false,
6880 payload: json!({}),
6881 meta: Value::Null,
6882 };
6883
6884 let on_cancel_routing = json!([
6886 { "condition": "event == \"on_complete\"", "to": "next" },
6887 { "condition": "event == \"on_cancel\"", "to": "cancelled" }
6888 ]);
6889 match evaluate_custom_routing(&on_cancel_routing, &err_out, &state, &flow_ir, ¤t) {
6890 CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "cancelled"),
6891 other => panic!("expected Next(\"cancelled\") via on_cancel default, got {other:?}"),
6892 }
6893
6894 let on_error_routing = json!([
6896 { "condition": "event == \"on_success\"", "to": "ok" },
6897 { "condition": "event == \"on_error\"", "to": "err" }
6898 ]);
6899 match evaluate_custom_routing(&on_error_routing, &err_out, &state, &flow_ir, ¤t) {
6900 CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "err"),
6901 other => panic!("expected Next(\"err\") via on_error default, got {other:?}"),
6902 }
6903
6904 let on_timeout_routing = json!([
6906 { "condition": "event == \"on_success\"", "to": "ok" },
6907 { "condition": "event == \"on_timeout\"", "to": "timed_out" }
6908 ]);
6909 match evaluate_custom_routing(&on_timeout_routing, &err_out, &state, &flow_ir, ¤t) {
6910 CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "timed_out"),
6911 other => panic!("expected Next(\"timed_out\") via on_timeout default, got {other:?}"),
6912 }
6913 }
6914
6915 #[test]
6916 fn outcome_meta_surfaces_component_emitted_outcome() {
6917 assert_eq!(
6920 outcome_meta(&json!({ "ok": true, "outcome": "on_complete" })),
6921 json!({ "outcome": "on_complete" })
6922 );
6923 assert_eq!(
6925 outcome_meta(&json!({ "ok": true, "body": {} })),
6926 Value::Null
6927 );
6928 }
6929
6930 #[cfg(feature = "agentic-worker")]
6960 #[tokio::test]
6961 #[ignore = "requires live NATS; run with --ignored after `nats-server -js`"]
6962 async fn dw_agent_scale_to_zero_nats_e2e() {
6963 use crate::runner::agent_node::DwAgentDispatch;
6964 use crate::runner::dispatch_listener::{SessionResumer, run_response_listener};
6965 use crate::runner::remote_dispatch::NatsDispatcher;
6966 use futures::StreamExt as _;
6967 use greentic_types::{
6968 RuntimeDispatchResponse, TenantCtx as DispatchTenantCtx, request_topic, response_topic,
6969 };
6970 use tokio::sync::Notify;
6971
6972 let nats_url = match std::env::var("GREENTIC_EVENTS_NATS_URL") {
6973 Ok(url) => url,
6974 Err(_) => {
6975 eprintln!(
6976 "skipping dw_agent_scale_to_zero_nats_e2e: GREENTIC_EVENTS_NATS_URL not set"
6977 );
6978 return;
6979 }
6980 };
6981
6982 let resume_id = NodeId::from_str("after-agent").unwrap();
6986 let agent_node_id = NodeId::from_str("agent-e2e").unwrap();
6987 let agent_node = Node {
6988 id: agent_node_id.clone(),
6989 component: FlowComponentRef {
6990 id: "dw.agent".parse().unwrap(),
6991 pack_alias: None,
6992 operation: Some("greeter".to_string()),
6993 },
6994 input: InputMapping {
6995 mapping: json!({ "user_text": "ping" }),
6996 },
6997 output: OutputMapping {
6998 mapping: Value::Null,
6999 },
7000 err_map: None,
7001 routing: Routing::Next {
7002 node_id: resume_id.clone(),
7003 },
7004 telemetry: TelemetryHints::default(),
7005 conversational: false,
7006 };
7007 let resume_node = Node {
7008 id: resume_id.clone(),
7009 component: FlowComponentRef {
7010 id: "emit.log".parse().unwrap(),
7011 pack_alias: None,
7012 operation: None,
7013 },
7014 input: InputMapping {
7015 mapping: json!({ "message": "resumed" }),
7016 },
7017 output: OutputMapping {
7018 mapping: Value::Null,
7019 },
7020 err_map: None,
7021 routing: Routing::End,
7022 telemetry: TelemetryHints::default(),
7023 conversational: false,
7024 };
7025 let mut nodes = indexmap::IndexMap::default();
7026 nodes.insert(agent_node_id.clone(), agent_node);
7027 nodes.insert(resume_id.clone(), resume_node);
7028 let flow = greentic_types::Flow {
7029 schema_version: "1.0".into(),
7030 id: greentic_types::FlowId::from_str("e2e-agent.flow").unwrap(),
7031 kind: greentic_types::FlowKind::Messaging,
7032 entrypoints: BTreeMap::from([(
7033 "default".to_string(),
7034 Value::String(agent_node_id.to_string()),
7035 )]),
7036 nodes,
7037 metadata: Default::default(),
7038 };
7039 let host_flow = HostFlow::from(flow);
7040
7041 let dispatcher_client = async_nats::connect(&nats_url)
7043 .await
7044 .expect("NATS: dispatcher client");
7045 let bridge_client = async_nats::connect(&nats_url)
7046 .await
7047 .expect("NATS: fake bridge client");
7048 let listener_client = async_nats::connect(&nats_url)
7049 .await
7050 .expect("NATS: response listener client");
7051
7052 let agentic_request_subject = request_topic("agentic");
7054 let agentic_response_subject = response_topic("agentic");
7055 let mut req_sub = bridge_client
7056 .subscribe(agentic_request_subject.clone())
7057 .await
7058 .expect("fake bridge: subscribe to agentic request subject");
7059 let bridge_reply_client = bridge_client.clone();
7060 let reply_subject = agentic_response_subject.clone();
7061 tokio::spawn(async move {
7062 while let Some(msg) = req_sub.next().await {
7063 let headers = msg.headers.as_ref();
7064 let get_hdr = |name: &str| {
7065 headers
7066 .and_then(|h| h.get(name))
7067 .map(|v| v.as_str().to_owned())
7068 .unwrap_or_default()
7069 };
7070 let correlation_id = get_hdr("Greentic-Correlation-Id");
7071 let tenant = get_hdr("Greentic-Tenant");
7072 let env = get_hdr("Greentic-Env");
7073
7074 let response_payload = RuntimeDispatchResponse {
7075 ok: true,
7076 output: json!({
7077 "reply": "pong",
7078 "trail": [],
7079 "terminated_by": "final_reply"
7080 }),
7081 events: vec![],
7082 error: None,
7083 };
7084 let body =
7085 serde_json::to_vec(&response_payload).expect("serialize fake bridge response");
7086
7087 let mut resp_headers = async_nats::HeaderMap::new();
7088 resp_headers.insert("Greentic-Correlation-Id", correlation_id.as_str());
7089 resp_headers.insert("Greentic-Tenant", tenant.as_str());
7090 resp_headers.insert("Greentic-Env", env.as_str());
7091
7092 bridge_reply_client
7093 .publish_with_headers(reply_subject.clone(), resp_headers, body.into())
7094 .await
7095 .expect("fake bridge: publish response");
7096 }
7097 });
7098
7099 struct RecordingResumer {
7101 calls: std::sync::Mutex<Vec<(String, Value)>>,
7102 notify: Notify,
7103 }
7104
7105 impl RecordingResumer {
7106 fn new() -> Self {
7107 Self {
7108 calls: std::sync::Mutex::new(vec![]),
7109 notify: Notify::new(),
7110 }
7111 }
7112 }
7113
7114 #[async_trait::async_trait]
7115 impl SessionResumer for RecordingResumer {
7116 async fn resume(
7117 &self,
7118 _tenant: DispatchTenantCtx,
7119 correlation_id: &str,
7120 output: Value,
7121 ) -> anyhow::Result<()> {
7122 self.calls
7123 .lock()
7124 .unwrap()
7125 .push((correlation_id.to_string(), output));
7126 self.notify.notify_one();
7127 Ok(())
7128 }
7129 }
7130
7131 let resumer = Arc::new(RecordingResumer::new());
7132 let resumer_for_listener = resumer.clone();
7133 tokio::spawn(async move {
7134 run_response_listener(listener_client, "agentic".to_owned(), resumer_for_listener)
7135 .await
7136 .expect("response listener exited unexpectedly");
7137 });
7138
7139 tokio::time::sleep(tokio::time::Duration::from_millis(150)).await;
7141
7142 let nats_engine_dispatcher = Arc::new(NatsDispatcher::new(dispatcher_client));
7144 let engine = FlowEngine {
7145 packs: Vec::new(),
7146 flows: Vec::new(),
7147 flow_sources: StdHashMap::new(),
7148 messaging_provider_pack_ids: std::collections::HashSet::new(),
7149 rollout_ids: RolloutIds::default(),
7150 flow_cache: RwLock::new(StdHashMap::from([(
7151 FlowKey {
7152 pack_id: "e2e-pack".to_string(),
7153 flow_id: "e2e-agent.flow".to_string(),
7154 },
7155 host_flow,
7156 )])),
7157 default_env: "local".to_string(),
7158 validation: crate::validate::ValidationConfig {
7159 mode: crate::validate::ValidationMode::Off,
7160 },
7161 cross_pack_resolver: None,
7162 remote_dispatch_handler: Some(
7163 nats_engine_dispatcher
7164 as Arc<dyn crate::runner::remote_dispatch::RemoteDispatchHandler>,
7165 ),
7166 dw_agent_dispatch: DwAgentDispatch::Nats,
7167 agent_node_handler: None,
7168 graph_node_handler: None,
7169 mcp_tool_source: None,
7170 };
7171
7172 let ctx = FlowContext {
7173 tenant: "demo",
7174 pack_id: "e2e-pack",
7175 flow_id: "e2e-agent.flow",
7176 node_id: None,
7177 tool: None,
7178 action: None,
7179 session_id: Some("e2e-sess-1"),
7180 provider_id: None,
7181 reply_scope: None,
7182 retry_config: RetryConfig {
7183 max_attempts: 1,
7184 base_delay_ms: 1,
7185 },
7186 attempt: 1,
7187 observer: None,
7188 mocks: None,
7189 };
7190
7191 let result = engine
7193 .execute(ctx, json!({ "user_text": "ping" }))
7194 .await
7195 .expect("engine.execute succeeded");
7196
7197 assert!(
7198 matches!(result.status, FlowStatus::Waiting(_)),
7199 "expected FlowStatus::Waiting from dw.agent Nats path, got: {:?}",
7200 result.status
7201 );
7202 eprintln!("dw.agent: flow paused (Waiting) — dispatch published to NATS");
7203
7204 let wait = tokio::time::timeout(
7206 tokio::time::Duration::from_secs(5),
7207 resumer.notify.notified(),
7208 )
7209 .await;
7210
7211 assert!(
7212 wait.is_ok(),
7213 "timed out waiting for fake bridge reply — is NATS running? ({nats_url})"
7214 );
7215
7216 let calls = resumer.calls.lock().unwrap();
7218 assert_eq!(
7219 calls.len(),
7220 1,
7221 "resumer should have been called exactly once"
7222 );
7223 let (ref _corr, ref output) = calls[0];
7224 assert_eq!(
7225 output["output"]["reply"],
7226 json!("pong"),
7227 "resumed reply must match the aw-serve canned reply"
7228 );
7229 eprintln!(
7230 "PASSED: dw.agent scale-to-zero NATS e2e — reply={:?}",
7231 output["output"]["reply"]
7232 );
7233 }
7234
7235 #[test]
7236 fn execution_state_vars_survive_serde_round_trip() {
7237 let mut st = ExecutionState::new(json!({}));
7239 st.vars.insert("counter".into(), json!(3));
7240 st.vars.insert("region".into(), json!("us-east-1"));
7241
7242 let encoded = serde_json::to_string(&st).expect("serialize");
7243 let decoded: ExecutionState = serde_json::from_str(&encoded).expect("deserialize");
7244
7245 assert_eq!(decoded.vars.get("counter"), Some(&json!(3)));
7246 assert_eq!(decoded.vars.get("region"), Some(&json!("us-east-1")));
7247 }
7248
7249 #[test]
7250 fn execution_state_vars_default_empty_for_old_snapshots() {
7251 let legacy = r#"{"entry":{},"input":{},"nodes":{},"egress":[],"redirect_count":0}"#;
7253 let decoded: ExecutionState = serde_json::from_str(legacy).expect("legacy loads");
7254 assert!(decoded.vars.is_empty());
7255 }
7256
7257 #[test]
7258 fn template_context_exposes_vars_namespace_typed() {
7259 let mut st = ExecutionState::new(serde_json::json!({}));
7260 st.vars.insert("count".into(), serde_json::json!(5));
7261 st.vars.insert("name".into(), serde_json::json!("aws"));
7262
7263 let ctx = template_context(&st, serde_json::Value::Null);
7264 let rendered_num = render_template_value(
7266 &serde_json::json!("{{vars.count}}"),
7267 &ctx,
7268 TemplateOptions::default(),
7269 )
7270 .expect("render num");
7271 assert_eq!(rendered_num, serde_json::json!(5));
7272
7273 let rendered_str = render_template_value(
7274 &serde_json::json!("prefix-{{vars.name}}"),
7275 &ctx,
7276 TemplateOptions::default(),
7277 )
7278 .expect("render str");
7279 assert_eq!(rendered_str, serde_json::json!("prefix-aws"));
7280 }
7281
7282 #[test]
7283 fn vars_namespace_does_not_shadow_existing_namespaces() {
7284 let st = ExecutionState::new(serde_json::json!({"user": {"id": 7}}));
7285 let ctx = template_context(&st, serde_json::Value::Null);
7286 let obj = ctx.as_object().expect("ctx object");
7287 for key in ["entry", "in", "prev", "node", "state", "vars"] {
7288 assert!(obj.contains_key(key), "context must expose `{key}`");
7289 }
7290 }
7291
7292 fn flow_with_extra(extra: serde_json::Value) -> Flow {
7298 Flow {
7299 schema_version: "1.0".into(),
7300 id: FlowId::from_str("test.flow").unwrap(),
7301 kind: FlowKind::Messaging,
7302 entrypoints: BTreeMap::new(),
7303 nodes: indexmap::IndexMap::default(),
7304 metadata: FlowMetadata {
7305 title: None,
7306 description: None,
7307 tags: Default::default(),
7308 extra,
7309 },
7310 }
7311 }
7312
7313 #[test]
7314 fn from_flow_extracts_vars_init() {
7315 let flow = flow_with_extra(serde_json::json!({
7316 "vars_init": {
7317 "region": { "type": "string", "default": "us-east-1" },
7318 "counter": { "type": "number", "default": 0 }
7319 }
7320 }));
7321 let host: HostFlow = HostFlow::from(flow);
7322 assert_eq!(
7323 host.vars_init.get("region"),
7324 Some(&serde_json::json!("us-east-1"))
7325 );
7326 assert_eq!(host.vars_init.get("counter"), Some(&serde_json::json!(0)));
7327 }
7328
7329 #[test]
7330 fn from_flow_vars_init_absent() {
7331 let flow = flow_with_extra(serde_json::json!({}));
7332 let host: HostFlow = HostFlow::from(flow);
7333 assert!(host.vars_init.is_empty());
7334 }
7335
7336 #[test]
7337 fn execute_once_seeds_declared_vars() {
7338 let node_id = NodeId::from_str("n1").unwrap();
7343 let node = Node {
7344 id: node_id.clone(),
7345 component: FlowComponentRef {
7346 id: "emit.log".parse().unwrap(),
7347 pack_alias: None,
7348 operation: None,
7349 },
7350 input: InputMapping {
7351 mapping: json!({ "message": "{{vars.region}}" }),
7352 },
7353 output: OutputMapping {
7354 mapping: Value::Null,
7355 },
7356 err_map: None,
7357 routing: Routing::End,
7358 telemetry: TelemetryHints::default(),
7359 conversational: false,
7360 };
7361 let mut nodes = indexmap::IndexMap::default();
7362 nodes.insert(node_id.clone(), node);
7363 let flow = Flow {
7364 schema_version: "1.0".into(),
7365 id: FlowId::from_str("vars.flow").unwrap(),
7366 kind: FlowKind::Messaging,
7367 entrypoints: BTreeMap::from([(
7368 "default".to_string(),
7369 Value::String(node_id.to_string()),
7370 )]),
7371 nodes,
7372 metadata: FlowMetadata {
7373 title: None,
7374 description: None,
7375 tags: Default::default(),
7376 extra: json!({
7377 "vars_init": {
7378 "region": { "type": "string", "default": "us-east-1" }
7379 }
7380 }),
7381 },
7382 };
7383 let host_flow = HostFlow::from(flow);
7384
7385 let engine = FlowEngine {
7386 packs: Vec::new(),
7387 flows: Vec::new(),
7388 flow_sources: HashMap::new(),
7389 messaging_provider_pack_ids: std::collections::HashSet::new(),
7390 rollout_ids: RolloutIds::default(),
7391 flow_cache: RwLock::new(HashMap::from([(
7392 FlowKey {
7393 pack_id: "test-pack".to_string(),
7394 flow_id: "vars.flow".to_string(),
7395 },
7396 host_flow,
7397 )])),
7398 default_env: "local".to_string(),
7399 validation: ValidationConfig {
7400 mode: ValidationMode::Off,
7401 },
7402 cross_pack_resolver: None,
7403 remote_dispatch_handler: None,
7404 #[cfg(feature = "agentic-worker")]
7405 dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
7406 #[cfg(feature = "agentic-worker")]
7407 agent_node_handler: None,
7408 #[cfg(feature = "agentic-worker")]
7409 graph_node_handler: None,
7410 #[cfg(feature = "agentic-worker")]
7411 mcp_tool_source: None,
7412 };
7413
7414 let observer = CountingObserver::new();
7415 let ctx = FlowContext {
7416 tenant: "demo",
7417 pack_id: "test-pack",
7418 flow_id: "vars.flow",
7419 node_id: None,
7420 tool: None,
7421 action: None,
7422 session_id: None,
7423 provider_id: None,
7424 reply_scope: None,
7425 retry_config: RetryConfig {
7426 max_attempts: 1,
7427 base_delay_ms: 1,
7428 },
7429 attempt: 1,
7430 observer: Some(&observer),
7431 mocks: None,
7432 };
7433
7434 let rt = Runtime::new().unwrap();
7435 let result = rt.block_on(engine.execute(ctx, Value::Null)).unwrap();
7436 assert!(matches!(result.status, FlowStatus::Completed));
7437
7438 let ends = observer.ends.lock().unwrap();
7439 assert_eq!(ends.len(), 1);
7440 assert_eq!(
7441 ends[0].get("message").and_then(Value::as_str),
7442 Some("us-east-1"),
7443 "vars.region must be seeded to its default and rendered in the node payload"
7444 );
7445 }
7446
7447 fn var_set_flow(
7456 var_set_input: Value,
7457 emit_input: Value,
7458 vars_init_extra: Option<Value>,
7459 ) -> Flow {
7460 let set_id = NodeId::from_str("set1").unwrap();
7461 let emit_id = NodeId::from_str("emit1").unwrap();
7462
7463 let set_node = Node {
7464 id: set_id.clone(),
7465 component: FlowComponentRef {
7466 id: "var.set".parse().unwrap(),
7467 pack_alias: None,
7468 operation: None,
7469 },
7470 input: InputMapping {
7471 mapping: var_set_input,
7472 },
7473 output: OutputMapping {
7474 mapping: Value::Null,
7475 },
7476 err_map: None,
7477 routing: Routing::Next {
7478 node_id: emit_id.clone(),
7479 },
7480 telemetry: TelemetryHints::default(),
7481 conversational: false,
7482 };
7483
7484 let emit_node = Node {
7485 id: emit_id.clone(),
7486 component: FlowComponentRef {
7487 id: "emit.log".parse().unwrap(),
7488 pack_alias: None,
7489 operation: None,
7490 },
7491 input: InputMapping {
7492 mapping: emit_input,
7493 },
7494 output: OutputMapping {
7495 mapping: Value::Null,
7496 },
7497 err_map: None,
7498 routing: Routing::End,
7499 telemetry: TelemetryHints::default(),
7500 conversational: false,
7501 };
7502
7503 let mut nodes = indexmap::IndexMap::default();
7504 nodes.insert(set_id.clone(), set_node);
7505 nodes.insert(emit_id.clone(), emit_node);
7506
7507 let extra = vars_init_extra.unwrap_or(serde_json::json!({}));
7508
7509 Flow {
7510 schema_version: "1.0".into(),
7511 id: FlowId::from_str("var.set.flow").unwrap(),
7512 kind: FlowKind::Messaging,
7513 entrypoints: BTreeMap::from([(
7514 "default".to_string(),
7515 Value::String(set_id.to_string()),
7516 )]),
7517 nodes,
7518 metadata: FlowMetadata {
7519 title: None,
7520 description: None,
7521 tags: Default::default(),
7522 extra,
7523 },
7524 }
7525 }
7526
7527 fn run_var_set_flow(flow: Flow) -> (FlowStatus, Vec<Value>) {
7528 let host_flow = HostFlow::from(flow);
7529 let engine = FlowEngine {
7530 packs: Vec::new(),
7531 flows: Vec::new(),
7532 flow_sources: StdHashMap::new(),
7533 messaging_provider_pack_ids: std::collections::HashSet::new(),
7534 rollout_ids: RolloutIds::default(),
7535 flow_cache: RwLock::new(StdHashMap::from([(
7536 FlowKey {
7537 pack_id: "test-pack".to_string(),
7538 flow_id: "var.set.flow".to_string(),
7539 },
7540 host_flow,
7541 )])),
7542 default_env: "local".to_string(),
7543 validation: ValidationConfig {
7544 mode: ValidationMode::Off,
7545 },
7546 cross_pack_resolver: None,
7547 remote_dispatch_handler: None,
7548 #[cfg(feature = "agentic-worker")]
7549 dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
7550 #[cfg(feature = "agentic-worker")]
7551 agent_node_handler: None,
7552 #[cfg(feature = "agentic-worker")]
7553 graph_node_handler: None,
7554 #[cfg(feature = "agentic-worker")]
7555 mcp_tool_source: None,
7556 };
7557 let observer = CountingObserver::new();
7558 let ctx = FlowContext {
7559 tenant: "demo",
7560 pack_id: "test-pack",
7561 flow_id: "var.set.flow",
7562 node_id: None,
7563 tool: None,
7564 action: None,
7565 session_id: None,
7566 provider_id: None,
7567 reply_scope: None,
7568 retry_config: RetryConfig {
7569 max_attempts: 1,
7570 base_delay_ms: 1,
7571 },
7572 attempt: 1,
7573 observer: Some(&observer),
7574 mocks: None,
7575 };
7576 let rt = Runtime::new().unwrap();
7577 let result = rt.block_on(engine.execute(ctx, Value::Null)).unwrap();
7578 let ends = observer.ends.lock().unwrap().clone();
7579 (result.status, ends)
7580 }
7581
7582 #[test]
7583 fn var_set_node_writes_literal_value_into_vars() {
7584 let flow = var_set_flow(
7588 json!({ "name": "greeting", "value": "hi" }),
7589 json!({ "message": "{{vars.greeting}}" }),
7590 None,
7591 );
7592 let (status, ends) = run_var_set_flow(flow);
7593
7594 assert!(
7595 matches!(status, FlowStatus::Completed),
7596 "flow must complete"
7597 );
7598 assert_eq!(ends.len(), 2, "both nodes must fire");
7599 assert_eq!(ends[0].get("ok"), Some(&json!(true)), "var_set output ok");
7601 assert_eq!(
7603 ends[1].get("message").and_then(Value::as_str),
7604 Some("hi"),
7605 "vars.greeting must be written and renderable in the next node"
7606 );
7607 }
7608
7609 #[test]
7610 fn var_set_node_writes_templated_value_with_type_preserved() {
7611 let flow = var_set_flow(
7616 json!({ "name": "copy", "value": "{{vars.counter}}" }),
7617 json!({ "message": "{{vars.copy}}" }),
7618 Some(json!({
7619 "vars_init": {
7620 "counter": { "type": "number", "default": 1 }
7621 }
7622 })),
7623 );
7624 let (status, ends) = run_var_set_flow(flow);
7625
7626 assert!(
7627 matches!(status, FlowStatus::Completed),
7628 "flow must complete"
7629 );
7630 assert_eq!(ends.len(), 2, "both nodes must fire");
7631 assert_eq!(
7633 ends[1].get("message"),
7634 Some(&json!(1)),
7635 "vars.copy must preserve the JSON number type from vars.counter"
7636 );
7637 }
7638
7639 #[test]
7640 fn var_set_empty_name_is_skipped_not_written() {
7641 let engine = minimal_engine();
7644 let rt = Runtime::new().unwrap();
7645 let retry_config = RetryConfig {
7646 max_attempts: 1,
7647 base_delay_ms: 1,
7648 };
7649 let ctx = FlowContext {
7650 tenant: "demo",
7651 pack_id: "test-pack",
7652 flow_id: "var.set.flow",
7653 node_id: Some("set1"),
7654 tool: None,
7655 action: None,
7656 session_id: None,
7657 provider_id: None,
7658 reply_scope: None,
7659 retry_config,
7660 attempt: 1,
7661 observer: None,
7662 mocks: None,
7663 };
7664 let node = HostNode {
7665 kind: NodeKind::VarSet {
7666 name: "".to_string(),
7667 value: json!("garbage"),
7668 },
7669 component: "var.set".into(),
7670 component_id: "var.set".into(),
7671 operation_name: None,
7672 operation_in_mapping: None,
7673 payload_expr: Value::Null,
7674 routing: Routing::End,
7675 vars_out: None,
7676 };
7677 let mut state = ExecutionState::new(Value::Null);
7678 let payload = Value::Null;
7679 let event = NodeEvent {
7680 context: &ctx,
7681 node_id: "set1",
7682 node: &node,
7683 payload: &payload,
7684 };
7685
7686 let outcome = rt
7687 .block_on(engine.dispatch_node(
7688 &ctx,
7689 "set1",
7690 &node,
7691 &mut state,
7692 payload.clone(),
7693 &event,
7694 ))
7695 .expect("dispatch_node must not error on empty var name");
7696
7697 assert_eq!(
7699 outcome.output.payload,
7700 json!({ "ok": true }),
7701 "dispatch must return ok:true even when name is empty"
7702 );
7703 assert!(
7705 state.vars.get("").is_none(),
7706 "empty var name must not create a \"\" key in state.vars"
7707 );
7708 }
7709
7710 #[test]
7711 fn var_set_node_has_empty_payload_expr() {
7712 let node_id = NodeId::from_str("set1").unwrap();
7716 let node = Node {
7717 id: node_id.clone(),
7718 component: FlowComponentRef {
7719 id: "var.set".parse().unwrap(),
7720 pack_alias: None,
7721 operation: None,
7722 },
7723 input: InputMapping {
7724 mapping: json!({ "name": "greeting", "value": "hi" }),
7725 },
7726 output: OutputMapping {
7727 mapping: Value::Null,
7728 },
7729 err_map: None,
7730 routing: Routing::End,
7731 telemetry: TelemetryHints::default(),
7732 conversational: false,
7733 };
7734
7735 let host_node = HostNode::from(node);
7736
7737 assert_eq!(
7739 host_node.payload_expr,
7740 Value::Null,
7741 "var.set node must have Null payload_expr after lowering"
7742 );
7743 match &host_node.kind {
7745 NodeKind::VarSet { name, value } => {
7746 assert_eq!(name.as_str(), "greeting", "name must be preserved in kind");
7747 assert_eq!(value, &json!("hi"), "value must be preserved in kind");
7748 }
7749 other => panic!("expected NodeKind::VarSet, got {other:?}"),
7750 }
7751 }
7752
7753 fn vars_out_flow(emit1_input: Value, emit2_input: Value) -> Flow {
7762 let emit1_id = NodeId::from_str("emit1").unwrap();
7763 let emit2_id = NodeId::from_str("emit2").unwrap();
7764
7765 let emit1_node = Node {
7766 id: emit1_id.clone(),
7767 component: FlowComponentRef {
7768 id: "emit.log".parse().unwrap(),
7769 pack_alias: None,
7770 operation: None,
7771 },
7772 input: InputMapping {
7773 mapping: emit1_input,
7774 },
7775 output: OutputMapping {
7776 mapping: Value::Null,
7777 },
7778 err_map: None,
7779 routing: Routing::Next {
7780 node_id: emit2_id.clone(),
7781 },
7782 telemetry: TelemetryHints::default(),
7783 conversational: false,
7784 };
7785
7786 let emit2_node = Node {
7787 id: emit2_id.clone(),
7788 component: FlowComponentRef {
7789 id: "emit.log".parse().unwrap(),
7790 pack_alias: None,
7791 operation: None,
7792 },
7793 input: InputMapping {
7794 mapping: emit2_input,
7795 },
7796 output: OutputMapping {
7797 mapping: Value::Null,
7798 },
7799 err_map: None,
7800 routing: Routing::End,
7801 telemetry: TelemetryHints::default(),
7802 conversational: false,
7803 };
7804
7805 let mut nodes = indexmap::IndexMap::default();
7806 nodes.insert(emit1_id.clone(), emit1_node);
7807 nodes.insert(emit2_id.clone(), emit2_node);
7808
7809 Flow {
7812 schema_version: "1.0".into(),
7813 id: FlowId::from_str("var.set.flow").unwrap(),
7814 kind: FlowKind::Messaging,
7815 entrypoints: BTreeMap::from([(
7816 "default".to_string(),
7817 Value::String(emit1_id.to_string()),
7818 )]),
7819 nodes,
7820 metadata: FlowMetadata {
7821 title: None,
7822 description: None,
7823 tags: Default::default(),
7824 extra: serde_json::json!({}),
7825 },
7826 }
7827 }
7828
7829 #[test]
7830 fn vars_out_binds_node_output_into_vars() {
7831 let flow = vars_out_flow(
7837 json!({
7838 "message": "hello",
7839 "vars_out": { "lastReply": "{{prev.message}}" }
7840 }),
7841 json!({ "message": "{{vars.lastReply}}" }),
7842 );
7843 let (status, ends) = run_var_set_flow(flow);
7844
7845 assert!(
7846 matches!(status, FlowStatus::Completed),
7847 "flow must complete"
7848 );
7849 assert_eq!(ends.len(), 2, "both nodes must fire");
7850 assert_eq!(
7852 ends[1].get("message").and_then(Value::as_str),
7853 Some("hello"),
7854 "vars_out binding from node 1 must be readable in node 2"
7855 );
7856 }
7857
7858 fn vars_survive_flow() -> Flow {
7862 let set_id = NodeId::from_str("set1").unwrap();
7863 let wait_id = NodeId::from_str("wait1").unwrap();
7864 let emit_id = NodeId::from_str("emit1").unwrap();
7865
7866 let set_node = Node {
7867 id: set_id.clone(),
7868 component: FlowComponentRef {
7869 id: "var.set".parse().unwrap(),
7870 pack_alias: None,
7871 operation: None,
7872 },
7873 input: InputMapping {
7874 mapping: json!({ "name": "greeting", "value": "hello" }),
7875 },
7876 output: OutputMapping {
7877 mapping: Value::Null,
7878 },
7879 err_map: None,
7880 routing: Routing::Next {
7881 node_id: wait_id.clone(),
7882 },
7883 telemetry: TelemetryHints::default(),
7884 conversational: false,
7885 };
7886
7887 let wait_node = Node {
7888 id: wait_id.clone(),
7889 component: FlowComponentRef {
7890 id: "session.wait".parse().unwrap(),
7891 pack_alias: None,
7892 operation: None,
7893 },
7894 input: InputMapping {
7895 mapping: Value::Null,
7896 },
7897 output: OutputMapping {
7898 mapping: Value::Null,
7899 },
7900 err_map: None,
7901 routing: Routing::Next {
7902 node_id: emit_id.clone(),
7903 },
7904 telemetry: TelemetryHints::default(),
7905 conversational: false,
7906 };
7907
7908 let emit_node = Node {
7909 id: emit_id.clone(),
7910 component: FlowComponentRef {
7911 id: "emit.log".parse().unwrap(),
7912 pack_alias: None,
7913 operation: None,
7914 },
7915 input: InputMapping {
7916 mapping: json!({
7917 "greeting": "{{vars.greeting}}",
7918 "counter": "{{vars.counter}}"
7919 }),
7920 },
7921 output: OutputMapping {
7922 mapping: Value::Null,
7923 },
7924 err_map: None,
7925 routing: Routing::End,
7926 telemetry: TelemetryHints::default(),
7927 conversational: false,
7928 };
7929
7930 let mut nodes = indexmap::IndexMap::default();
7931 nodes.insert(set_id.clone(), set_node);
7932 nodes.insert(wait_id.clone(), wait_node);
7933 nodes.insert(emit_id.clone(), emit_node);
7934
7935 Flow {
7936 schema_version: "1.0".into(),
7937 id: FlowId::from_str("vars.survive.flow").unwrap(),
7938 kind: FlowKind::Messaging,
7939 entrypoints: BTreeMap::from([(
7940 "default".to_string(),
7941 Value::String(set_id.to_string()),
7942 )]),
7943 nodes,
7944 metadata: FlowMetadata {
7945 title: None,
7946 description: None,
7947 tags: Default::default(),
7948 extra: json!({
7949 "vars_init": {
7950 "counter": { "type": "number", "default": 1 }
7951 }
7952 }),
7953 },
7954 }
7955 }
7956
7957 #[test]
7958 fn vars_survive_park_and_resume_end_to_end() {
7959 let flow = vars_survive_flow();
7962 let host_flow = HostFlow::from(flow);
7963 let flow_id = "vars.survive.flow";
7964 let pack_id = "test-pack";
7965 let engine = FlowEngine {
7966 packs: Vec::new(),
7967 flows: Vec::new(),
7968 flow_sources: StdHashMap::new(),
7969 messaging_provider_pack_ids: std::collections::HashSet::new(),
7970 rollout_ids: RolloutIds::default(),
7971 flow_cache: RwLock::new(StdHashMap::from([(
7972 FlowKey {
7973 pack_id: pack_id.to_string(),
7974 flow_id: flow_id.to_string(),
7975 },
7976 host_flow,
7977 )])),
7978 default_env: "local".to_string(),
7979 validation: ValidationConfig {
7980 mode: ValidationMode::Off,
7981 },
7982 cross_pack_resolver: None,
7983 remote_dispatch_handler: None,
7984 #[cfg(feature = "agentic-worker")]
7985 dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
7986 #[cfg(feature = "agentic-worker")]
7987 agent_node_handler: None,
7988 #[cfg(feature = "agentic-worker")]
7989 graph_node_handler: None,
7990 #[cfg(feature = "agentic-worker")]
7991 mcp_tool_source: None,
7992 };
7993 let rt = Runtime::new().unwrap();
7994
7995 let ctx1 = FlowContext {
7997 tenant: "demo",
7998 pack_id,
7999 flow_id,
8000 node_id: None,
8001 tool: None,
8002 action: None,
8003 session_id: None,
8004 provider_id: None,
8005 reply_scope: None,
8006 retry_config: RetryConfig {
8007 max_attempts: 1,
8008 base_delay_ms: 1,
8009 },
8010 attempt: 1,
8011 observer: None,
8012 mocks: None,
8013 };
8014 let result1 = rt.block_on(engine.execute(ctx1, Value::Null)).unwrap();
8015 let snapshot = match result1.status {
8016 FlowStatus::Waiting(w) => w.snapshot,
8017 other => panic!("expected Waiting after session.wait, got {other:?}"),
8018 };
8019
8020 assert_eq!(
8022 snapshot.state.vars.get("greeting"),
8023 Some(&json!("hello")),
8024 "greeting var must be in snapshot"
8025 );
8026 assert_eq!(
8027 snapshot.state.vars.get("counter"),
8028 Some(&json!(1)),
8029 "counter var (from vars_init) must be in snapshot"
8030 );
8031
8032 let observer2 = CountingObserver::new();
8034 let ctx2 = FlowContext {
8035 tenant: "demo",
8036 pack_id,
8037 flow_id,
8038 node_id: None,
8039 tool: None,
8040 action: None,
8041 session_id: None,
8042 provider_id: None,
8043 reply_scope: None,
8044 retry_config: RetryConfig {
8045 max_attempts: 1,
8046 base_delay_ms: 1,
8047 },
8048 attempt: 1,
8049 observer: Some(&observer2),
8050 mocks: None,
8051 };
8052 let result2 = rt
8053 .block_on(engine.resume(ctx2, snapshot, Value::Null))
8054 .unwrap();
8055 assert!(
8056 matches!(result2.status, FlowStatus::Completed),
8057 "flow must complete after resume"
8058 );
8059 let ends2 = observer2.ends.lock().unwrap().clone();
8060 assert_eq!(ends2.len(), 1, "only emit.log fires after resume");
8061 assert_eq!(
8062 ends2[0].get("greeting").and_then(Value::as_str),
8063 Some("hello"),
8064 "vars.greeting must survive the park/resume"
8065 );
8066 assert_eq!(
8067 ends2[0].get("counter"),
8068 Some(&json!(1)),
8069 "vars.counter (vars_init) must survive the park/resume"
8070 );
8071 }
8072
8073 #[cfg(conversational_dw_agent_port)]
8093 mod conversational_dw_agent {
8094 use super::*;
8095
8096 #[cfg(feature = "agentic-worker")]
8097 struct StubAgentHandler {
8098 payload: serde_json::Value,
8099 }
8100 #[cfg(feature = "agentic-worker")]
8101 #[async_trait::async_trait]
8102 impl crate::runner::agent_node::AgentNodeHandler for StubAgentHandler {
8103 async fn execute(
8104 &self,
8105 _tenant_id: &str,
8106 _env_id: &str,
8107 _agent_id: &str,
8108 _session_id: &str,
8109 _flow_input: &serde_json::Value,
8110 _conversational: bool,
8111 ) -> anyhow::Result<serde_json::Value> {
8112 Ok(self.payload.clone())
8113 }
8114 }
8115
8116 #[cfg(feature = "agentic-worker")]
8119 fn conversational_dw_flow(conversational: bool) -> HostFlow {
8120 let mut nodes = IndexMap::new();
8121 let agent_id = NodeId::from_str("agent").unwrap();
8122 let thanks_id = NodeId::from_str("thanks").unwrap();
8123 nodes.insert(
8124 agent_id.clone(),
8125 HostNode {
8126 kind: NodeKind::DwAgent {
8127 agent_id: "a".to_string(),
8128 conversational,
8129 },
8130 component: "dw.agent".to_string(),
8131 component_id: "dw.agent".to_string(),
8132 operation_name: Some("a".to_string()),
8133 operation_in_mapping: None,
8134 payload_expr: json!({ "user_text": "hi" }),
8135 routing: Routing::Next {
8136 node_id: thanks_id.clone(),
8137 },
8138 vars_out: None,
8139 },
8140 );
8141 nodes.insert(
8142 thanks_id.clone(),
8143 HostNode {
8144 kind: NodeKind::BuiltinEmit {
8145 kind: EmitKind::Response,
8146 },
8147 component: "emit.response".to_string(),
8148 component_id: "emit.response".to_string(),
8149 operation_name: None,
8150 operation_in_mapping: None,
8151 payload_expr: json!({ "text": "thanks" }),
8152 routing: Routing::End,
8153 vars_out: None,
8154 },
8155 );
8156 HostFlow {
8157 slot_schema: None,
8158 id: "conv.flow".to_string(),
8159 start: Some(agent_id),
8160 nodes,
8161 vars_init: JsonMap::new(),
8162 required_vars: Vec::new(),
8163 }
8164 }
8165
8166 #[cfg(feature = "agentic-worker")]
8169 fn conv_engine(flow: HostFlow, payload: serde_json::Value) -> FlowEngine {
8170 FlowEngine {
8171 rollout_ids: RolloutIds::default(),
8172 packs: Vec::new(),
8173 flows: Vec::new(),
8174 flow_sources: StdHashMap::new(),
8175 flow_cache: RwLock::new(StdHashMap::from([(
8176 FlowKey {
8177 pack_id: "test-pack".to_string(),
8178 flow_id: "conv.flow".to_string(),
8179 },
8180 flow,
8181 )])),
8182 default_env: "local".to_string(),
8183 validation: ValidationConfig {
8184 mode: ValidationMode::Off,
8185 },
8186 cross_pack_resolver: None,
8187 remote_dispatch_handler: None,
8188 dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
8189 agent_node_handler: Some(std::sync::Arc::new(StubAgentHandler { payload })),
8190 graph_node_handler: None,
8191 mcp_tool_source: None,
8192 }
8193 }
8194
8195 #[cfg(feature = "agentic-worker")]
8196 fn conv_ctx<'a>() -> FlowContext<'a> {
8197 FlowContext {
8198 tenant: "demo",
8199 pack_id: "test-pack",
8200 flow_id: "conv.flow",
8201 node_id: None,
8202 tool: None,
8203 action: None,
8204 session_id: Some("sess-conv"),
8205 provider_id: None,
8206 reply_scope: None,
8207 retry_config: RetryConfig {
8208 max_attempts: 1,
8209 base_delay_ms: 1,
8210 },
8211 attempt: 1,
8212 observer: None,
8213 mocks: None,
8214 }
8215 }
8216
8217 #[cfg(feature = "agentic-worker")]
8218 #[test]
8219 fn conversational_dw_agent_parks_and_loops_on_normal_reply() {
8220 let engine = conv_engine(
8221 conversational_dw_flow(true),
8222 json!({ "reply": "hello there", "trail": [], "terminated_by": "final_reply" }),
8223 );
8224 let rt = Runtime::new().unwrap();
8225 let result = rt
8226 .block_on(engine.execute(conv_ctx(), Value::Null))
8227 .unwrap();
8228 let snapshot = match result.status {
8229 FlowStatus::Waiting(w) => w.snapshot,
8230 other => panic!("expected Waiting (park-loop), got {other:?}"),
8231 };
8232 assert_eq!(
8233 snapshot.next_node, "agent",
8234 "must re-enter the dw.agent node itself"
8235 );
8236 assert!(
8238 serde_json::to_string(&result.output)
8239 .unwrap()
8240 .contains("hello there"),
8241 "the agent reply must be rendered before parking: {:?}",
8242 result.output
8243 );
8244 }
8245
8246 #[cfg(feature = "agentic-worker")]
8247 #[test]
8248 fn conversational_dw_agent_advances_on_conversation_ended() {
8249 let engine = conv_engine(
8250 conversational_dw_flow(true),
8251 json!({ "reply": "bye", "trail": [], "terminated_by": "conversation_ended" }),
8252 );
8253 let rt = Runtime::new().unwrap();
8254 let result = rt
8255 .block_on(engine.execute(conv_ctx(), Value::Null))
8256 .unwrap();
8257 assert!(
8258 matches!(result.status, FlowStatus::Completed),
8259 "conversation_ended must advance to the successor and complete, got {:?}",
8260 result.status
8261 );
8262 }
8263
8264 #[cfg(feature = "agentic-worker")]
8265 #[test]
8266 fn non_conversational_dw_agent_never_loops() {
8267 for tb in ["final_reply", "conversation_ended"] {
8270 let engine = conv_engine(
8271 conversational_dw_flow(false),
8272 json!({ "reply": "x", "trail": [], "terminated_by": tb }),
8273 );
8274 let rt = Runtime::new().unwrap();
8275 let result = rt
8276 .block_on(engine.execute(conv_ctx(), Value::Null))
8277 .unwrap();
8278 assert!(
8279 matches!(result.status, FlowStatus::Completed),
8280 "non-conversational must complete (route onward) for terminated_by={tb}, got {:?}",
8281 result.status
8282 );
8283 }
8284 }
8285
8286 #[cfg(feature = "agentic-worker")]
8291 #[test]
8292 fn conversational_dw_agent_force_advances_after_park_loop_cap() {
8293 let engine = conv_engine(
8294 conversational_dw_flow(true),
8295 json!({ "reply": "still thinking", "trail": [], "terminated_by": "final_reply" }),
8296 );
8297 let rt = Runtime::new().unwrap();
8298
8299 let result = rt
8300 .block_on(engine.execute(conv_ctx(), Value::Null))
8301 .unwrap();
8302 let mut snapshot = match result.status {
8303 FlowStatus::Waiting(w) => w.snapshot,
8304 other => panic!("expected Waiting after turn 1, got {other:?}"),
8305 };
8306
8307 for turn in 2..MAX_PARK_TURNS {
8309 let result = rt
8310 .block_on(engine.resume(conv_ctx(), snapshot, json!({ "text": "still here" })))
8311 .unwrap();
8312 snapshot = match result.status {
8313 FlowStatus::Waiting(w) => w.snapshot,
8314 other => panic!("expected Waiting at turn {turn}, got {other:?}"),
8315 };
8316 }
8317
8318 let result = rt
8320 .block_on(engine.resume(conv_ctx(), snapshot, json!({ "text": "still here" })))
8321 .unwrap();
8322 assert!(
8323 matches!(result.status, FlowStatus::Completed),
8324 "park-loop cap must force-advance to the successor at turn {MAX_PARK_TURNS}, got {:?}",
8325 result.status
8326 );
8327 }
8328
8329 #[cfg(feature = "agentic-worker")]
8347 struct ScriptedNatsDispatcher {
8348 calls: Mutex<Vec<crate::runner::remote_dispatch::RemoteDispatch>>,
8349 }
8350
8351 #[cfg(feature = "agentic-worker")]
8352 #[async_trait::async_trait]
8353 impl crate::runner::remote_dispatch::RemoteDispatchHandler for ScriptedNatsDispatcher {
8354 async fn dispatch(
8355 &self,
8356 request: crate::runner::remote_dispatch::RemoteDispatch,
8357 ) -> anyhow::Result<crate::runner::remote_dispatch::RemoteDispatchAction> {
8358 let correlation_id = request.correlation_id.clone();
8359 self.calls.lock().unwrap().push(request);
8360 Ok(
8361 crate::runner::remote_dispatch::RemoteDispatchAction::AwaitingResponse {
8362 correlation_id,
8363 },
8364 )
8365 }
8366 }
8367
8368 #[cfg(feature = "agentic-worker")]
8372 fn nats_conv_engine(
8373 flow: HostFlow,
8374 dispatcher: std::sync::Arc<dyn crate::runner::remote_dispatch::RemoteDispatchHandler>,
8375 ) -> FlowEngine {
8376 FlowEngine {
8377 rollout_ids: RolloutIds::default(),
8378 packs: Vec::new(),
8379 flows: Vec::new(),
8380 flow_sources: StdHashMap::new(),
8381 flow_cache: RwLock::new(StdHashMap::from([(
8382 FlowKey {
8383 pack_id: "test-pack".to_string(),
8384 flow_id: "conv.flow".to_string(),
8385 },
8386 flow,
8387 )])),
8388 default_env: "local".to_string(),
8389 validation: ValidationConfig {
8390 mode: ValidationMode::Off,
8391 },
8392 cross_pack_resolver: None,
8393 remote_dispatch_handler: Some(dispatcher),
8394 dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::Nats,
8395 agent_node_handler: None,
8396 graph_node_handler: None,
8397 mcp_tool_source: None,
8398 }
8399 }
8400
8401 #[cfg(feature = "agentic-worker")]
8405 fn agent_response_envelope(reply: &str, terminated_by: &str) -> Value {
8406 json!({
8407 "ok": true,
8408 "output": { "reply": reply, "trail": [], "terminated_by": terminated_by },
8409 "events": [],
8410 "error": Value::Null,
8411 })
8412 }
8413
8414 #[cfg(feature = "agentic-worker")]
8419 #[test]
8420 fn conversational_dw_agent_nats_turn1_parks_via_await_here() {
8421 let dispatcher = Arc::new(ScriptedNatsDispatcher {
8422 calls: Mutex::new(vec![]),
8423 });
8424 let engine = nats_conv_engine(conversational_dw_flow(true), dispatcher.clone());
8425 let rt = Runtime::new().unwrap();
8426
8427 let result = rt
8428 .block_on(engine.execute(conv_ctx(), Value::Null))
8429 .unwrap();
8430 let snapshot = match result.status {
8431 FlowStatus::Waiting(w) => w.snapshot,
8432 other => panic!("expected Waiting after fresh dispatch, got {other:?}"),
8433 };
8434 assert_eq!(
8435 snapshot.next_node, "agent",
8436 "AwaitHere must resume at self, not the routing successor"
8437 );
8438 assert_eq!(
8439 dispatcher.calls.lock().unwrap().len(),
8440 1,
8441 "a fresh user turn must dispatch to NATS exactly once"
8442 );
8443 assert_eq!(
8444 result.output,
8445 Value::Null,
8446 "no reply is known yet on the initial dispatch — the async response hasn't arrived"
8447 );
8448 }
8449
8450 #[cfg(feature = "agentic-worker")]
8457 #[test]
8458 fn conversational_dw_agent_nats_park_loop_full_turn_cycle() {
8459 let dispatcher = Arc::new(ScriptedNatsDispatcher {
8460 calls: Mutex::new(vec![]),
8461 });
8462 let engine = nats_conv_engine(conversational_dw_flow(true), dispatcher.clone());
8463 let rt = Runtime::new().unwrap();
8464
8465 let result = rt
8467 .block_on(engine.execute(conv_ctx(), Value::Null))
8468 .unwrap();
8469 let snapshot = match result.status {
8470 FlowStatus::Waiting(w) => w.snapshot,
8471 other => {
8472 panic!("expected Waiting (AwaitHere) after turn 1 dispatch, got {other:?}")
8473 }
8474 };
8475 assert_eq!(snapshot.next_node, "agent");
8476 assert_eq!(dispatcher.calls.lock().unwrap().len(), 1);
8477
8478 let result = rt
8481 .block_on(engine.resume(
8482 conv_ctx(),
8483 snapshot,
8484 agent_response_envelope("hello there", "final_reply"),
8485 ))
8486 .unwrap();
8487 let snapshot = match result.status {
8488 FlowStatus::Waiting(w) => w.snapshot,
8489 other => {
8490 panic!("expected Waiting (LoopHere) after not-ended response, got {other:?}")
8491 }
8492 };
8493 assert_eq!(
8494 snapshot.next_node, "agent",
8495 "LoopHere also re-enters the node itself"
8496 );
8497 assert!(
8498 serde_json::to_string(&result.output)
8499 .unwrap()
8500 .contains("hello there"),
8501 "the agent's reply must be surfaced once the response resume lands: {:?}",
8502 result.output
8503 );
8504 assert_eq!(
8505 dispatcher.calls.lock().unwrap().len(),
8506 1,
8507 "the response landing must not itself trigger another NATS dispatch"
8508 );
8509
8510 let result = rt
8512 .block_on(engine.resume(conv_ctx(), snapshot, json!({ "text": "user says more" })))
8513 .unwrap();
8514 let snapshot = match result.status {
8515 FlowStatus::Waiting(w) => w.snapshot,
8516 other => {
8517 panic!("expected Waiting (AwaitHere) after turn 2 dispatch, got {other:?}")
8518 }
8519 };
8520 assert_eq!(snapshot.next_node, "agent");
8521 assert_eq!(
8522 dispatcher.calls.lock().unwrap().len(),
8523 2,
8524 "a second fresh user turn must dispatch to NATS again"
8525 );
8526
8527 let result = rt
8530 .block_on(engine.resume(
8531 conv_ctx(),
8532 snapshot,
8533 agent_response_envelope("bye", "conversation_ended"),
8534 ))
8535 .unwrap();
8536 assert!(
8537 matches!(result.status, FlowStatus::Completed),
8538 "conversation_ended response must advance to the successor and complete, got {:?}",
8539 result.status
8540 );
8541 assert_eq!(
8542 dispatcher.calls.lock().unwrap().len(),
8543 2,
8544 "conversation end must not trigger another NATS dispatch"
8545 );
8546 }
8547
8548 #[cfg(feature = "agentic-worker")]
8554 #[test]
8555 fn conversational_dw_agent_nats_force_advances_after_park_loop_cap() {
8556 let dispatcher = Arc::new(ScriptedNatsDispatcher {
8557 calls: Mutex::new(vec![]),
8558 });
8559 let engine = nats_conv_engine(conversational_dw_flow(true), dispatcher.clone());
8560 let rt = Runtime::new().unwrap();
8561
8562 let result = rt
8566 .block_on(engine.execute(conv_ctx(), Value::Null))
8567 .unwrap();
8568 let mut snapshot = match result.status {
8569 FlowStatus::Waiting(w) => w.snapshot,
8570 other => panic!("expected Waiting after turn 1 dispatch, got {other:?}"),
8571 };
8572
8573 for turn in 1..MAX_PARK_TURNS {
8577 let result = rt
8578 .block_on(engine.resume(
8579 conv_ctx(),
8580 snapshot,
8581 agent_response_envelope("still thinking", "final_reply"),
8582 ))
8583 .unwrap();
8584 snapshot = match result.status {
8585 FlowStatus::Waiting(w) => w.snapshot,
8586 other => {
8587 panic!("expected Waiting (LoopHere) at response #{turn}, got {other:?}")
8588 }
8589 };
8590 let result = rt
8591 .block_on(engine.resume(conv_ctx(), snapshot, json!({ "text": "still here" })))
8592 .unwrap();
8593 snapshot = match result.status {
8594 FlowStatus::Waiting(w) => w.snapshot,
8595 other => {
8596 panic!(
8597 "expected Waiting (AwaitHere) after user turn #{turn}, got {other:?}"
8598 )
8599 }
8600 };
8601 }
8602
8603 let result = rt
8606 .block_on(engine.resume(
8607 conv_ctx(),
8608 snapshot,
8609 agent_response_envelope("still thinking", "final_reply"),
8610 ))
8611 .unwrap();
8612 assert!(
8613 matches!(result.status, FlowStatus::Completed),
8614 "park-loop cap must force-advance to the successor at response {MAX_PARK_TURNS}, got {:?}",
8615 result.status
8616 );
8617 assert_eq!(
8618 dispatcher.calls.lock().unwrap().len(),
8619 1 + (MAX_PARK_TURNS as usize - 1),
8620 "exactly one NATS dispatch per user turn across the whole park-loop"
8621 );
8622 }
8623
8624 #[cfg(feature = "agentic-worker")]
8637 #[test]
8638 fn conversational_dw_agent_nats_and_inprocess_transcripts_match_for_same_script() {
8639 let rt = Runtime::new().unwrap();
8640
8641 let inproc_handler = Arc::new(ScriptedAgentHandler {
8643 script: Mutex::new(std::collections::VecDeque::from(vec![
8644 json!({ "reply": "hello there", "trail": [], "terminated_by": "final_reply" }),
8645 json!({ "reply": "bye", "trail": [], "terminated_by": "conversation_ended" }),
8646 ])),
8647 });
8648 let inproc_engine = conv_engine_scripted(conversational_dw_flow(true), inproc_handler);
8649 let r1 = rt
8650 .block_on(inproc_engine.execute(conv_ctx(), Value::Null))
8651 .unwrap();
8652 let inproc_snapshot = match r1.status {
8653 FlowStatus::Waiting(ref w) => w.snapshot.clone(),
8654 ref other => panic!("in-process turn 1: expected Waiting, got {other:?}"),
8655 };
8656 let r2 = rt
8657 .block_on(inproc_engine.resume(
8658 conv_ctx(),
8659 inproc_snapshot,
8660 json!({ "text": "more" }),
8661 ))
8662 .unwrap();
8663
8664 let dispatcher = Arc::new(ScriptedNatsDispatcher {
8666 calls: Mutex::new(vec![]),
8667 });
8668 let nats_engine = nats_conv_engine(conversational_dw_flow(true), dispatcher);
8669 let n1 = rt
8670 .block_on(nats_engine.execute(conv_ctx(), Value::Null))
8671 .unwrap();
8672 let n1_snapshot = match n1.status {
8673 FlowStatus::Waiting(w) => w.snapshot,
8674 other => panic!("nats turn 1 dispatch: expected Waiting, got {other:?}"),
8675 };
8676 let n1r = rt
8677 .block_on(nats_engine.resume(
8678 conv_ctx(),
8679 n1_snapshot,
8680 agent_response_envelope("hello there", "final_reply"),
8681 ))
8682 .unwrap();
8683 let n1r_snapshot = match n1r.status {
8684 FlowStatus::Waiting(ref w) => w.snapshot.clone(),
8685 ref other => panic!("nats turn 1 response resume: expected Waiting, got {other:?}"),
8686 };
8687 let n2 = rt
8688 .block_on(nats_engine.resume(conv_ctx(), n1r_snapshot, json!({ "text": "more" })))
8689 .unwrap();
8690 let n2_snapshot = match n2.status {
8691 FlowStatus::Waiting(w) => w.snapshot,
8692 other => panic!("nats turn 2 dispatch: expected Waiting, got {other:?}"),
8693 };
8694 let n2r = rt
8695 .block_on(nats_engine.resume(
8696 conv_ctx(),
8697 n2_snapshot,
8698 agent_response_envelope("bye", "conversation_ended"),
8699 ))
8700 .unwrap();
8701
8702 assert!(matches!(r1.status, FlowStatus::Waiting(_)));
8704 assert!(
8705 matches!(n1r.status, FlowStatus::Waiting(_)),
8706 "nats turn 1's user-visible status must also be Waiting"
8707 );
8708 assert!(matches!(r2.status, FlowStatus::Completed));
8709 assert!(
8710 matches!(n2r.status, FlowStatus::Completed),
8711 "nats turn 2 must also complete, matching the in-process transcript"
8712 );
8713
8714 assert!(
8716 serde_json::to_string(&r1.output)
8717 .unwrap()
8718 .contains("hello there"),
8719 "in-process turn 1 must surface the reply: {:?}",
8720 r1.output
8721 );
8722 assert!(
8723 serde_json::to_string(&n1r.output)
8724 .unwrap()
8725 .contains("hello there"),
8726 "nats turn 1 must surface the identical reply once the response resume lands: {:?}",
8727 n1r.output
8728 );
8729 }
8730
8731 #[cfg(feature = "agentic-worker")]
8739 fn agent_error_envelope(message: &str, code: Option<&str>) -> Value {
8740 json!({
8741 "ok": false,
8742 "output": Value::Null,
8743 "events": [],
8744 "error": { "code": code, "message": message },
8745 })
8746 }
8747
8748 #[cfg(feature = "agentic-worker")]
8757 #[test]
8758 fn conversational_dw_agent_nats_interleaved_user_message_is_not_misread_as_response() {
8759 let dispatcher = Arc::new(ScriptedNatsDispatcher {
8760 calls: Mutex::new(vec![]),
8761 });
8762 let engine = nats_conv_engine(conversational_dw_flow(true), dispatcher.clone());
8763 let rt = Runtime::new().unwrap();
8764
8765 let result = rt
8767 .block_on(engine.execute(conv_ctx(), Value::Null))
8768 .unwrap();
8769 let snapshot = match result.status {
8770 FlowStatus::Waiting(w) => w.snapshot,
8771 other => panic!("expected Waiting after turn 1 dispatch, got {other:?}"),
8772 };
8773 assert!(
8774 snapshot.state.pending_agent_await.contains_key("agent"),
8775 "turn 1 dispatch must mark the pending await"
8776 );
8777 assert_eq!(dispatcher.calls.lock().unwrap().len(), 1);
8778
8779 let result = rt
8782 .block_on(engine.resume(
8783 conv_ctx(),
8784 snapshot,
8785 json!({ "text": "are you still there?" }),
8786 ))
8787 .unwrap();
8788 let snapshot = match result.status {
8789 FlowStatus::Waiting(w) => w.snapshot,
8790 other => panic!(
8791 "a stray user message must re-dispatch as a fresh turn (Waiting/AwaitHere), got {other:?}"
8792 ),
8793 };
8794 assert_eq!(
8795 snapshot.next_node, "agent",
8796 "the fresh re-dispatch still awaits at self"
8797 );
8798 assert_eq!(
8799 dispatcher.calls.lock().unwrap().len(),
8800 2,
8801 "the stray user message must trigger its OWN fresh NATS dispatch, not be swallowed"
8802 );
8803 assert!(
8804 snapshot.state.pending_agent_await.contains_key("agent"),
8805 "the marker must still be set for the real response to land against"
8806 );
8807 assert_eq!(
8808 result.output,
8809 Value::Null,
8810 "no reply is surfaced — this was not a misread null agent turn"
8811 );
8812 assert!(
8813 !snapshot.state.park_turns.contains_key("agent"),
8814 "a stray user message must not touch the park-loop cap"
8815 );
8816 }
8817
8818 #[cfg(feature = "agentic-worker")]
8827 #[test]
8828 fn conversational_dw_agent_nats_error_envelope_surfaces_and_reparks_without_cap_bump() {
8829 let dispatcher = Arc::new(ScriptedNatsDispatcher {
8830 calls: Mutex::new(vec![]),
8831 });
8832 let engine = nats_conv_engine(conversational_dw_flow(true), dispatcher.clone());
8833 let rt = Runtime::new().unwrap();
8834
8835 let result = rt
8837 .block_on(engine.execute(conv_ctx(), Value::Null))
8838 .unwrap();
8839 let snapshot = match result.status {
8840 FlowStatus::Waiting(w) => w.snapshot,
8841 other => panic!("expected Waiting after turn 1 dispatch, got {other:?}"),
8842 };
8843
8844 let result = rt
8846 .block_on(engine.resume(conv_ctx(), snapshot, agent_error_envelope("boom", None)))
8847 .unwrap();
8848 let snapshot = match result.status {
8849 FlowStatus::Waiting(w) => w.snapshot,
8850 other => panic!("an error envelope must re-park (Waiting/LoopHere), got {other:?}"),
8851 };
8852 assert_eq!(
8853 snapshot.next_node, "agent",
8854 "LoopHere re-enters the node itself"
8855 );
8856 assert!(
8857 serde_json::to_string(&result.output)
8858 .unwrap()
8859 .contains("boom"),
8860 "the error message must be surfaced as the reply: {:?}",
8861 result.output
8862 );
8863 assert!(
8864 !snapshot.state.park_turns.contains_key("agent"),
8865 "an error response must NOT bump the park-loop cap counter"
8866 );
8867
8868 let result = rt
8870 .block_on(engine.resume(conv_ctx(), snapshot, json!({ "text": "hello?" })))
8871 .unwrap();
8872 let snapshot = match result.status {
8873 FlowStatus::Waiting(w) => w.snapshot,
8874 other => panic!("expected Waiting (AwaitHere) after user turn, got {other:?}"),
8875 };
8876 assert_eq!(dispatcher.calls.lock().unwrap().len(), 2);
8877
8878 let result = rt
8882 .block_on(engine.resume(
8883 conv_ctx(),
8884 snapshot,
8885 agent_error_envelope("timeout waiting for agent response", Some("timeout")),
8886 ))
8887 .unwrap();
8888 let snapshot = match result.status {
8889 FlowStatus::Waiting(w) => w.snapshot,
8890 other => {
8891 panic!("a timeout envelope must also re-park (Waiting/LoopHere), got {other:?}")
8892 }
8893 };
8894 assert!(
8895 serde_json::to_string(&result.output)
8896 .unwrap()
8897 .contains("timeout waiting for agent response"),
8898 "the timeout message must be surfaced as the reply: {:?}",
8899 result.output
8900 );
8901 assert!(
8902 !snapshot.state.park_turns.contains_key("agent"),
8903 "two error/timeout responses in a row (with an intervening user turn) must still \
8904 not have bumped the park-loop cap counter"
8905 );
8906 }
8907
8908 #[cfg(feature = "agentic-worker")]
8913 struct ScriptedAgentHandler {
8914 script: Mutex<std::collections::VecDeque<serde_json::Value>>,
8915 }
8916 #[cfg(feature = "agentic-worker")]
8917 #[async_trait::async_trait]
8918 impl crate::runner::agent_node::AgentNodeHandler for ScriptedAgentHandler {
8919 async fn execute(
8920 &self,
8921 _tenant_id: &str,
8922 _env_id: &str,
8923 _agent_id: &str,
8924 _session_id: &str,
8925 _flow_input: &serde_json::Value,
8926 _conversational: bool,
8927 ) -> anyhow::Result<serde_json::Value> {
8928 Ok(self
8929 .script
8930 .lock()
8931 .unwrap()
8932 .pop_front()
8933 .expect("ScriptedAgentHandler: script exhausted"))
8934 }
8935 }
8936
8937 #[cfg(feature = "agentic-worker")]
8941 fn conv_engine_scripted(
8942 flow: HostFlow,
8943 handler: std::sync::Arc<ScriptedAgentHandler>,
8944 ) -> FlowEngine {
8945 FlowEngine {
8946 rollout_ids: RolloutIds::default(),
8947 packs: Vec::new(),
8948 flows: Vec::new(),
8949 flow_sources: StdHashMap::new(),
8950 flow_cache: RwLock::new(StdHashMap::from([(
8951 FlowKey {
8952 pack_id: "test-pack".to_string(),
8953 flow_id: "conv.flow".to_string(),
8954 },
8955 flow,
8956 )])),
8957 default_env: "local".to_string(),
8958 validation: ValidationConfig {
8959 mode: ValidationMode::Off,
8960 },
8961 cross_pack_resolver: None,
8962 remote_dispatch_handler: None,
8963 dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
8964 agent_node_handler: Some(handler),
8965 graph_node_handler: None,
8966 mcp_tool_source: None,
8967 }
8968 }
8969 }
8970
8971 #[test]
8972 fn submitted_fields_reads_root_inputs_on_the_demo_path() {
8973 let entry = json!({
8976 "amount": "500",
8977 "reason": "looks good",
8978 "metadata": { "action": "approve" }
8979 });
8980 let fields = submitted_fields(&entry);
8981 assert_eq!(fields.get("amount"), Some(&json!("500")));
8982 assert_eq!(fields.get("reason"), Some(&json!("looks good")));
8983 assert!(
8984 !fields.contains_key("metadata"),
8985 "the envelope key is not a field"
8986 );
8987 assert!(
8988 !fields.contains_key("action"),
8989 "the route discriminator is not a field"
8990 );
8991 }
8992
8993 #[test]
8994 fn submitted_fields_reads_metadata_on_the_wrapped_path() {
8995 let entry = json!({
8997 "input": {
8998 "metadata": { "action": "approve", "email": "a@b.c" },
8999 "text": "hi"
9000 }
9001 });
9002 let fields = submitted_fields(&entry);
9003 assert_eq!(fields.get("email"), Some(&json!("a@b.c")));
9004 assert!(!fields.contains_key("action"));
9005 assert!(!fields.contains_key("text"), "envelope text is not a field");
9006 assert!(
9007 !fields.contains_key("input"),
9008 "the envelope root must be resolved, or `input` becomes one giant field"
9009 );
9010 }
9011
9012 #[test]
9013 fn submitted_fields_lets_the_envelope_root_win_a_collision() {
9014 let entry = json!({
9015 "email": "root@x",
9016 "metadata": { "action": "go", "email": "meta@x" }
9017 });
9018 assert_eq!(
9019 submitted_fields(&entry).get("email"),
9020 Some(&json!("root@x"))
9021 );
9022 }
9023
9024 #[test]
9025 fn submitted_fields_counts_a_root_input_named_action() {
9026 let entry = json!({ "action": "typed", "metadata": { "action": "approve" } });
9029 assert_eq!(
9030 submitted_fields(&entry).get("action"),
9031 Some(&json!("typed"))
9032 );
9033 }
9034
9035 #[test]
9036 fn submitted_fields_is_empty_for_a_button_with_no_inputs() {
9037 let entry = json!({ "metadata": { "action": "approve" } });
9038 assert!(submitted_fields(&entry).is_empty());
9039 }
9040
9041 fn plain_card_node() -> HostNode {
9045 HostNode {
9046 kind: NodeKind::Exec {
9047 target_component: "card".to_string(),
9048 },
9049 component: "component.exec".to_string(),
9050 component_id: "component.exec".to_string(),
9051 operation_name: None,
9052 operation_in_mapping: None,
9053 payload_expr: Value::Null,
9054 routing: Routing::End,
9055 vars_out: None,
9056 }
9057 }
9058
9059 #[test]
9060 fn an_old_snapshot_without_the_flag_deserializes_as_not_awaiting_submit() {
9061 let raw = json!({
9064 "pack_id": "p",
9065 "flow_id": "f",
9066 "next_node": "card",
9067 "state": {}
9068 });
9069 let snap: FlowSnapshot = serde_json::from_value(raw).expect("legacy snapshot decodes");
9070 assert!(!snap.awaiting_submit);
9071 }
9072
9073 #[test]
9081 fn submitted_answers_survive_the_resume_redispatch_and_reach_a_later_node() {
9082 let mut state = ExecutionState::new(json!({}));
9083 state.nodes.insert(
9086 "card".to_string(),
9087 NodeOutput::new(json!({ "event": "rendered" })),
9088 );
9089 state.pending_card_answers = Some(PendingCardAnswers {
9090 node_id: "card".to_string(),
9091 answers: submitted_fields(&json!({
9092 "email": "a@b.c",
9093 "metadata": { "action": "submit" }
9094 })),
9095 });
9096
9097 let mut fresh = NodeOutput::new(json!({ "event": "rendered" }));
9100 attach_pending_card_answers(&mut state, "card", &plain_card_node(), &mut fresh);
9101 state.nodes.insert("card".to_string(), fresh);
9102
9103 let ctx = template_context(&state, Value::Null);
9105 let rendered = render_template_value(
9106 &json!("{{node.card.answers.email}}"),
9107 &ctx,
9108 TemplateOptions::default(),
9109 )
9110 .expect("render");
9111 assert_eq!(rendered, json!("a@b.c"));
9112 }
9113
9114 #[test]
9115 fn the_card_render_context_carries_the_answers_too() {
9116 let mut state = ExecutionState::new(json!({}));
9119 state.pending_card_answers = Some(PendingCardAnswers {
9120 node_id: "card".to_string(),
9121 answers: submitted_fields(&json!({ "email": "a@b.c" })),
9122 });
9123 let mut output = NodeOutput::new(json!({ "event": "rendered" }));
9124 attach_pending_card_answers(&mut state, "card", &plain_card_node(), &mut output);
9125 state.nodes.insert("card".to_string(), output);
9126
9127 let ctx = state.context();
9128 assert_eq!(
9129 ctx["nodes"]["card"]["payload"]["answers"]["email"],
9130 json!("a@b.c")
9131 );
9132 }
9133
9134 #[test]
9135 fn answers_are_absent_before_any_submit() {
9136 let mut state = ExecutionState::new(json!({}));
9139 let mut output = NodeOutput::new(json!({ "event": "rendered" }));
9140 attach_pending_card_answers(&mut state, "card", &plain_card_node(), &mut output);
9141 assert!(output.payload.get("answers").is_none());
9142 }
9143
9144 #[test]
9145 fn a_button_with_no_inputs_yields_an_empty_answers_object() {
9146 let mut state = ExecutionState::new(json!({}));
9147 state.pending_card_answers = Some(PendingCardAnswers {
9148 node_id: "card".to_string(),
9149 answers: submitted_fields(&json!({ "metadata": { "action": "approve" } })),
9150 });
9151 let mut output = NodeOutput::new(json!({ "event": "rendered" }));
9152 attach_pending_card_answers(&mut state, "card", &plain_card_node(), &mut output);
9153 assert_eq!(output.payload["answers"], json!({}));
9154 }
9155
9156 #[test]
9157 fn the_pending_answers_are_consumed_exactly_once() {
9158 let mut state = ExecutionState::new(json!({}));
9161 state.pending_card_answers = Some(PendingCardAnswers {
9162 node_id: "card".to_string(),
9163 answers: submitted_fields(&json!({ "email": "a@b.c" })),
9164 });
9165 let mut first = NodeOutput::new(json!({ "event": "rendered" }));
9166 attach_pending_card_answers(&mut state, "card", &plain_card_node(), &mut first);
9167 assert!(first.payload.get("answers").is_some());
9168
9169 let mut second = NodeOutput::new(json!({ "event": "rendered" }));
9170 attach_pending_card_answers(&mut state, "card", &plain_card_node(), &mut second);
9171 assert!(
9172 second.payload.get("answers").is_none(),
9173 "consumed, not read"
9174 );
9175 }
9176
9177 #[test]
9178 fn answers_attach_even_when_the_redispatch_failed() {
9179 let mut state = ExecutionState::new(json!({}));
9183 state.pending_card_answers = Some(PendingCardAnswers {
9184 node_id: "card".to_string(),
9185 answers: submitted_fields(&json!({ "email": "a@b.c" })),
9186 });
9187 let mut output = NodeOutput::new(json!({ "ok": false, "error": { "code": "boom" } }));
9188 attach_pending_card_answers(&mut state, "card", &plain_card_node(), &mut output);
9189 assert_eq!(output.payload["answers"]["email"], json!("a@b.c"));
9190 }
9191
9192 #[test]
9193 fn answers_are_not_attached_to_a_different_node() {
9194 let mut state = ExecutionState::new(json!({}));
9195 state.pending_card_answers = Some(PendingCardAnswers {
9196 node_id: "card".to_string(),
9197 answers: submitted_fields(&json!({ "email": "a@b.c" })),
9198 });
9199 let mut other = NodeOutput::new(json!({ "event": "rendered" }));
9200 attach_pending_card_answers(&mut state, "next_step", &plain_card_node(), &mut other);
9201 assert!(other.payload.get("answers").is_none());
9202 assert!(
9203 state.pending_card_answers.is_some(),
9204 "still pending for its own node"
9205 );
9206 }
9207
9208 #[test]
9221 fn answers_intersect_the_declared_allow_list_on_a_wrapped_start_envelope() {
9222 let entry = json!({
9223 "input": {
9224 "id": "msg-1",
9225 "tenant": "acme",
9226 "channel": "webchat",
9227 "session_id": "sess-1",
9228 "from": "user-1",
9229 "to": "bot-1",
9230 "correlation_id": "corr-1",
9231 "attachments": [],
9232 "metadata": {
9233 "action": "submit",
9234 "nextCardId": "confirm",
9235 "route": "default",
9236 "env": "prod",
9237 "team": "support",
9238 "locale": "en-US",
9239 "autoStart": true,
9240 "url": "https://api.example.com",
9241 "model": "gpt-4",
9242 "provider": "openai",
9243 "api_key_secret": "secrets://tenant/acme/openai_key",
9244 "full_name": "Ada Lovelace",
9245 "email": "ada@example.com"
9246 },
9247 "text": "submitted"
9248 }
9249 });
9250
9251 let mut state = ExecutionState::new(json!({}));
9252 state.pending_card_answers = Some(PendingCardAnswers {
9253 node_id: "card".to_string(),
9254 answers: submitted_fields(&entry),
9255 });
9256
9257 let node = HostNode {
9258 payload_expr: json!({ "answer_fields": ["full_name", "email"] }),
9259 ..plain_card_node()
9260 };
9261 let mut output = NodeOutput::new(json!({ "event": "rendered" }));
9262 attach_pending_card_answers(&mut state, "card", &node, &mut output);
9263
9264 let answers = output.payload["answers"]
9265 .as_object()
9266 .expect("answers must be an object");
9267 assert_eq!(
9268 answers,
9269 &serde_json::Map::from_iter([
9270 ("full_name".to_string(), json!("Ada Lovelace")),
9271 ("email".to_string(), json!("ada@example.com")),
9272 ]),
9273 "answers must contain exactly the two declared card inputs, and \
9274 nothing from transport identity, routing, or pack config: {answers:?}"
9275 );
9276 }
9277
9278 #[test]
9279 fn answer_fields_absent_keeps_todays_permissive_behaviour() {
9280 let entry = json!({
9284 "input": {
9285 "tenant": "acme",
9286 "metadata": { "action": "submit", "email": "a@b.c" },
9287 "text": "hi"
9288 }
9289 });
9290 let mut state = ExecutionState::new(json!({}));
9291 state.pending_card_answers = Some(PendingCardAnswers {
9292 node_id: "card".to_string(),
9293 answers: submitted_fields(&entry),
9294 });
9295 let mut output = NodeOutput::new(json!({ "event": "rendered" }));
9296 attach_pending_card_answers(&mut state, "card", &plain_card_node(), &mut output);
9297
9298 assert_eq!(output.payload["answers"]["tenant"], json!("acme"));
9299 assert_eq!(output.payload["answers"]["email"], json!("a@b.c"));
9300 }
9301
9302 #[test]
9303 fn answer_fields_declared_empty_yields_no_answers() {
9304 let mut state = ExecutionState::new(json!({}));
9308 state.pending_card_answers = Some(PendingCardAnswers {
9309 node_id: "card".to_string(),
9310 answers: submitted_fields(&json!({
9311 "email": "a@b.c",
9312 "metadata": { "action": "approve" }
9313 })),
9314 });
9315 let node = HostNode {
9316 payload_expr: json!({ "answer_fields": [] }),
9317 ..plain_card_node()
9318 };
9319 let mut output = NodeOutput::new(json!({ "event": "rendered" }));
9320 attach_pending_card_answers(&mut state, "card", &node, &mut output);
9321
9322 assert_eq!(output.payload["answers"], json!({}));
9323 }
9324
9325 #[test]
9339 fn absent_and_malformed_answer_fields_both_stay_permissive_but_declared_filters() {
9340 let entry = json!({ "email": "a@b.c", "phone": "555-1234" });
9341
9342 let mut absent_state = ExecutionState::new(json!({}));
9344 absent_state.pending_card_answers = Some(PendingCardAnswers {
9345 node_id: "card".to_string(),
9346 answers: submitted_fields(&entry),
9347 });
9348 let mut absent_output = NodeOutput::new(json!({ "event": "rendered" }));
9349 attach_pending_card_answers(
9350 &mut absent_state,
9351 "card",
9352 &plain_card_node(),
9353 &mut absent_output,
9354 );
9355 assert_eq!(
9356 absent_output.payload["answers"],
9357 json!({ "email": "a@b.c", "phone": "555-1234" }),
9358 "absent must stay fully permissive"
9359 );
9360
9361 let mut malformed_state = ExecutionState::new(json!({}));
9364 malformed_state.pending_card_answers = Some(PendingCardAnswers {
9365 node_id: "card".to_string(),
9366 answers: submitted_fields(&entry),
9367 });
9368 let malformed_node = HostNode {
9369 payload_expr: json!({ "answer_fields": "{{unresolved.template}}" }),
9370 ..plain_card_node()
9371 };
9372 let mut malformed_output = NodeOutput::new(json!({ "event": "rendered" }));
9373 attach_pending_card_answers(
9374 &mut malformed_state,
9375 "card",
9376 &malformed_node,
9377 &mut malformed_output,
9378 );
9379 assert_eq!(
9380 malformed_output.payload["answers"],
9381 json!({ "email": "a@b.c", "phone": "555-1234" }),
9382 "malformed must ALSO stay fully permissive, same as absent"
9383 );
9384
9385 let mut declared_state = ExecutionState::new(json!({}));
9387 declared_state.pending_card_answers = Some(PendingCardAnswers {
9388 node_id: "card".to_string(),
9389 answers: submitted_fields(&entry),
9390 });
9391 let declared_node = HostNode {
9392 payload_expr: json!({ "answer_fields": ["email"] }),
9393 ..plain_card_node()
9394 };
9395 let mut declared_output = NodeOutput::new(json!({ "event": "rendered" }));
9396 attach_pending_card_answers(
9397 &mut declared_state,
9398 "card",
9399 &declared_node,
9400 &mut declared_output,
9401 );
9402 assert_eq!(
9403 declared_output.payload["answers"],
9404 json!({ "email": "a@b.c" }),
9405 "declared must filter down to exactly the allow-listed keys"
9406 );
9407 }
9408
9409 #[test]
9410 fn pending_from_snapshot_names_next_node_when_awaiting_submit() {
9411 let snapshot = FlowSnapshot {
9412 pack_id: "p".to_string(),
9413 flow_id: "f".to_string(),
9414 next_flow: None,
9415 next_node: "card".to_string(),
9416 awaiting_submit: true,
9417 state: ExecutionState::new(json!({})),
9418 };
9419 let input = json!({ "email": "a@b.c", "metadata": { "action": "submit" } });
9420 let pending = pending_from_snapshot(&snapshot, &input).expect("should park answers");
9421 assert_eq!(pending.node_id, "card");
9422 assert_eq!(pending.answers.get("email"), Some(&json!("a@b.c")));
9423 }
9424
9425 #[test]
9426 fn pending_from_snapshot_is_none_when_not_awaiting_submit() {
9427 let snapshot = FlowSnapshot {
9428 pack_id: "p".to_string(),
9429 flow_id: "f".to_string(),
9430 next_flow: None,
9431 next_node: "successor".to_string(),
9432 awaiting_submit: false,
9433 state: ExecutionState::new(json!({})),
9434 };
9435 let input = json!({ "email": "a@b.c" });
9436 assert!(pending_from_snapshot(&snapshot, &input).is_none());
9437 }
9438
9439 fn two_card_chain_flow() -> Flow {
9450 let card_node = |id: &str, to: Option<&str>| Node {
9451 id: NodeId::from_str(id).unwrap(),
9452 component: FlowComponentRef {
9453 id: "emit.log".parse().unwrap(),
9454 pack_alias: None,
9455 operation: None,
9456 },
9457 input: InputMapping {
9458 mapping: json!({ "card": id }),
9459 },
9460 output: OutputMapping {
9461 mapping: Value::Null,
9462 },
9463 err_map: None,
9464 routing: match to {
9465 Some(target) => Routing::Custom(json!([
9466 { "condition": "response.action == \"submit\"", "to": target }
9467 ])),
9468 None => Routing::End,
9469 },
9470 telemetry: TelemetryHints::default(),
9471 conversational: false,
9472 };
9473
9474 let mut nodes = indexmap::IndexMap::default();
9475 for (id, to) in [
9476 ("card1", Some("card2")),
9477 ("card2", Some("card3")),
9478 ("card3", None),
9479 ] {
9480 nodes.insert(NodeId::from_str(id).unwrap(), card_node(id, to));
9481 }
9482
9483 Flow {
9484 schema_version: "1.0".into(),
9485 id: FlowId::from_str("two.card.flow").unwrap(),
9486 kind: FlowKind::Messaging,
9487 entrypoints: BTreeMap::from([(
9488 "default".to_string(),
9489 Value::String("card1".to_string()),
9490 )]),
9491 nodes,
9492 metadata: FlowMetadata {
9493 title: None,
9494 description: None,
9495 tags: Default::default(),
9496 extra: json!({}),
9497 },
9498 }
9499 }
9500
9501 #[test]
9512 fn one_submit_advances_exactly_one_card() {
9513 let host_flow = HostFlow::from(two_card_chain_flow());
9514 let (flow_id, pack_id) = ("two.card.flow", "test-pack");
9515 let engine = FlowEngine {
9516 rollout_ids: RolloutIds::default(),
9517 packs: Vec::new(),
9518 flows: Vec::new(),
9519 flow_sources: StdHashMap::new(),
9520 messaging_provider_pack_ids: std::collections::HashSet::new(),
9521 flow_cache: RwLock::new(StdHashMap::from([(
9522 FlowKey {
9523 pack_id: pack_id.to_string(),
9524 flow_id: flow_id.to_string(),
9525 },
9526 host_flow,
9527 )])),
9528 default_env: "local".to_string(),
9529 validation: ValidationConfig {
9530 mode: ValidationMode::Off,
9531 },
9532 cross_pack_resolver: None,
9533 remote_dispatch_handler: None,
9534 #[cfg(feature = "agentic-worker")]
9535 dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
9536 #[cfg(feature = "agentic-worker")]
9537 agent_node_handler: None,
9538 #[cfg(feature = "agentic-worker")]
9539 graph_node_handler: None,
9540 #[cfg(feature = "agentic-worker")]
9541 mcp_tool_source: None,
9542 };
9543 let rt = Runtime::new().unwrap();
9544 let ctx = || FlowContext {
9545 tenant: "demo",
9546 pack_id,
9547 flow_id,
9548 node_id: None,
9549 tool: None,
9550 action: None,
9551 session_id: None,
9552 provider_id: None,
9553 reply_scope: None,
9554 retry_config: RetryConfig {
9555 max_attempts: 1,
9556 base_delay_ms: 1,
9557 },
9558 attempt: 1,
9559 observer: None,
9560 mocks: None,
9561 };
9562
9563 let first = rt.block_on(engine.execute(ctx(), Value::Null)).unwrap();
9565 let snapshot = match first.status {
9566 FlowStatus::Waiting(w) => w.snapshot,
9567 other => panic!("expected Waiting at card1, got {other:?}"),
9568 };
9569 assert_eq!(snapshot.next_node, "card1");
9570
9571 let submit = json!({ "input": { "metadata": { "action": "submit" } } });
9573 let second = rt.block_on(engine.resume(ctx(), snapshot, submit)).unwrap();
9574 match second.status {
9575 FlowStatus::Waiting(w) => assert_eq!(
9576 w.snapshot.next_node, "card2",
9577 "one submit must advance exactly one card and park at the next"
9578 ),
9579 FlowStatus::Completed => panic!(
9580 "the run reached the terminal node: the consumed action re-fired \
9581 at card2 and skipped it"
9582 ),
9583 }
9584 }
9585
9586 fn card_answers_flow() -> Flow {
9587 let card_id = NodeId::from_str("card").unwrap();
9588 let next_id = NodeId::from_str("next").unwrap();
9589
9590 let card_node = Node {
9591 id: card_id.clone(),
9592 component: FlowComponentRef {
9593 id: "emit.log".parse().unwrap(),
9594 pack_alias: None,
9595 operation: None,
9596 },
9597 input: InputMapping {
9598 mapping: json!({ "event": "rendered" }),
9599 },
9600 output: OutputMapping {
9601 mapping: Value::Null,
9602 },
9603 err_map: None,
9604 routing: Routing::Custom(json!([
9605 { "condition": "response.action == \"submit\"", "to": next_id.to_string() }
9606 ])),
9607 telemetry: TelemetryHints::default(),
9608 conversational: false,
9609 };
9610
9611 let next_node = Node {
9612 id: next_id.clone(),
9613 component: FlowComponentRef {
9614 id: "emit.response".parse().unwrap(),
9615 pack_alias: None,
9616 operation: None,
9617 },
9618 input: InputMapping {
9619 mapping: json!({ "text": "{{node.card.answers.email}}" }),
9620 },
9621 output: OutputMapping {
9622 mapping: Value::Null,
9623 },
9624 err_map: None,
9625 routing: Routing::End,
9626 telemetry: TelemetryHints::default(),
9627 conversational: false,
9628 };
9629
9630 let mut nodes = indexmap::IndexMap::default();
9631 nodes.insert(card_id.clone(), card_node);
9632 nodes.insert(next_id.clone(), next_node);
9633
9634 Flow {
9635 schema_version: "1.0".into(),
9636 id: FlowId::from_str("card.answers.flow").unwrap(),
9637 kind: FlowKind::Messaging,
9638 entrypoints: BTreeMap::from([(
9639 "default".to_string(),
9640 Value::String(card_id.to_string()),
9641 )]),
9642 nodes,
9643 metadata: FlowMetadata {
9644 title: None,
9645 description: None,
9646 tags: Default::default(),
9647 extra: json!({}),
9648 },
9649 }
9650 }
9651
9652 #[test]
9663 fn card_answers_survive_a_real_park_and_resume_and_reach_a_later_node() {
9664 let flow = card_answers_flow();
9665 let host_flow = HostFlow::from(flow);
9666 let flow_id = "card.answers.flow";
9667 let pack_id = "test-pack";
9668 let engine = FlowEngine {
9669 rollout_ids: RolloutIds::default(),
9670 packs: Vec::new(),
9671 flows: Vec::new(),
9672 flow_sources: StdHashMap::new(),
9673 messaging_provider_pack_ids: std::collections::HashSet::new(),
9674 flow_cache: RwLock::new(StdHashMap::from([(
9675 FlowKey {
9676 pack_id: pack_id.to_string(),
9677 flow_id: flow_id.to_string(),
9678 },
9679 host_flow,
9680 )])),
9681 default_env: "local".to_string(),
9682 validation: ValidationConfig {
9683 mode: ValidationMode::Off,
9684 },
9685 cross_pack_resolver: None,
9686 remote_dispatch_handler: None,
9687 #[cfg(feature = "agentic-worker")]
9688 dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
9689 #[cfg(feature = "agentic-worker")]
9690 agent_node_handler: None,
9691 #[cfg(feature = "agentic-worker")]
9692 graph_node_handler: None,
9693 #[cfg(feature = "agentic-worker")]
9694 mcp_tool_source: None,
9695 };
9696 let rt = Runtime::new().unwrap();
9697
9698 let ctx1 = FlowContext {
9699 tenant: "demo",
9700 pack_id,
9701 flow_id,
9702 node_id: None,
9703 tool: None,
9704 action: None,
9705 session_id: None,
9706 provider_id: None,
9707 reply_scope: None,
9708 retry_config: RetryConfig {
9709 max_attempts: 1,
9710 base_delay_ms: 1,
9711 },
9712 attempt: 1,
9713 observer: None,
9714 mocks: None,
9715 };
9716 let result1 = rt.block_on(engine.execute(ctx1, Value::Null)).unwrap();
9719 let snapshot = match result1.status {
9720 FlowStatus::Waiting(w) => w.snapshot,
9721 other => panic!("expected Waiting at the card node, got {other:?}"),
9722 };
9723 assert!(
9724 snapshot.awaiting_submit,
9725 "the card's conditional fall-through must set awaiting_submit"
9726 );
9727 assert_eq!(snapshot.next_node, "card");
9728
9729 let ctx2 = FlowContext {
9734 tenant: "demo",
9735 pack_id,
9736 flow_id,
9737 node_id: None,
9738 tool: None,
9739 action: None,
9740 session_id: None,
9741 provider_id: None,
9742 reply_scope: None,
9743 retry_config: RetryConfig {
9744 max_attempts: 1,
9745 base_delay_ms: 1,
9746 },
9747 attempt: 1,
9748 observer: None,
9749 mocks: None,
9750 };
9751 let input = json!({ "email": "a@b.c", "metadata": { "action": "submit" } });
9752 let result2 = rt.block_on(engine.resume(ctx2, snapshot, input)).unwrap();
9753 assert!(
9754 matches!(result2.status, FlowStatus::Completed),
9755 "the submit must route past the card to `next`"
9756 );
9757 let rendered = serde_json::to_string(&result2.output).expect("encode output");
9762 assert!(
9763 rendered.contains("a@b.c"),
9764 "`next`, a LATER node, must read the answers through \
9765 {{node.card.answers.email}}; got {rendered}"
9766 );
9767 }
9768}
9769
9770use tracing::Instrument;
9771
9772pub struct FlowContext<'a> {
9773 pub tenant: &'a str,
9774 pub pack_id: &'a str,
9775 pub flow_id: &'a str,
9776 pub node_id: Option<&'a str>,
9777 pub tool: Option<&'a str>,
9778 pub action: Option<&'a str>,
9779 pub session_id: Option<&'a str>,
9780 pub provider_id: Option<&'a str>,
9781 pub reply_scope: Option<&'a greentic_types::ReplyScope>,
9789 pub retry_config: RetryConfig,
9790 pub attempt: u32,
9791 pub observer: Option<&'a dyn ExecutionObserver>,
9792 pub mocks: Option<&'a MockLayer>,
9793}
9794
9795#[derive(Copy, Clone)]
9796pub struct RetryConfig {
9797 pub max_attempts: u32,
9798 pub base_delay_ms: u64,
9799}
9800
9801fn mcp_output(bound: Value, result: &Value) -> NodeOutput {
9830 let Some(message) = result.get("error") else {
9831 return NodeOutput::new(bound);
9832 };
9833 let message = message
9834 .as_str()
9835 .map(str::to_string)
9836 .unwrap_or_else(|| message.to_string());
9837 NodeOutput {
9838 ok: false,
9839 payload: bound,
9840 meta: json!({
9841 "error": {
9842 "kind": "mcp_call_failed",
9843 "message": message,
9844 }
9845 }),
9846 }
9847}
9848
9849fn lift_first_node_error_from_nodes(output: Value, nodes: &HashMap<String, NodeOutput>) -> Value {
9850 let Some((node_id, failed)) = nodes.iter().find(|(_, out)| !out.ok) else {
9851 return output;
9852 };
9853 let err_meta = failed.meta.get("error");
9854 let message = err_meta
9855 .and_then(|e| e.get("message"))
9856 .and_then(|v| v.as_str())
9857 .unwrap_or("flow node failed");
9858 let kind = err_meta
9859 .and_then(|e| e.get("kind"))
9860 .and_then(|v| v.as_str())
9861 .unwrap_or("flow_node_failed");
9862
9863 let mut output = match output {
9864 Value::Object(map) => map,
9865 Value::Null => JsonMap::new(),
9866 other => {
9867 let mut wrap = JsonMap::new();
9868 wrap.insert("payload".to_string(), other);
9869 wrap
9870 }
9871 };
9872 let metadata_entry = output
9873 .entry("metadata".to_string())
9874 .or_insert_with(|| Value::Object(JsonMap::new()));
9875 let metadata_map = match metadata_entry {
9876 Value::Object(map) => map,
9877 _ => {
9878 *metadata_entry = Value::Object(JsonMap::new());
9879 metadata_entry.as_object_mut().unwrap()
9880 }
9881 };
9882 metadata_map
9883 .entry("error_kind".to_string())
9884 .or_insert(Value::String(kind.to_string()));
9885 metadata_map
9886 .entry("error_message".to_string())
9887 .or_insert(Value::String(message.to_string()));
9888 metadata_map
9889 .entry("node_id".to_string())
9890 .or_insert(Value::String(node_id.clone()));
9891 Value::Object(output)
9892}
9893
9894fn should_retry(err: &anyhow::Error) -> bool {
9895 let lower = err.to_string().to_lowercase();
9896 lower.contains("transient")
9897 || lower.contains("unavailable")
9898 || lower.contains("internal")
9899 || lower.contains("timeout")
9900}
9901
9902impl From<FlowRetryConfig> for RetryConfig {
9903 fn from(value: FlowRetryConfig) -> Self {
9904 Self {
9905 max_attempts: value.max_attempts.max(1),
9906 base_delay_ms: value.base_delay_ms.max(50),
9907 }
9908 }
9909}