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 merge_submitted_fields_into_entry(mut entry: Value, fields: JsonMap<String, Value>) -> Value {
3184 if let Value::Object(map) = &mut entry {
3185 for (key, value) in fields {
3186 map.entry(key).or_insert(value);
3187 }
3188 }
3189 entry
3190}
3191
3192fn template_context(state: &ExecutionState, prev: Value) -> Value {
3193 let entry = if state.entry.is_null() {
3194 Value::Object(JsonMap::new())
3195 } else {
3196 let fields = submitted_fields(&state.entry);
3200 merge_submitted_fields_into_entry(alias_input_to_entry(state.entry.clone()), fields)
3201 };
3202 let mut ctx = JsonMap::new();
3203 ctx.insert("entry".into(), entry.clone());
3204 ctx.insert("in".into(), entry); ctx.insert("prev".into(), prev);
3206 ctx.insert("node".into(), Value::Object(state.outputs_map()));
3207 ctx.insert("state".into(), state.context());
3208 ctx.insert("vars".into(), Value::Object(state.vars.clone()));
3209 Value::Object(ctx)
3210}
3211
3212impl From<Flow> for HostFlow {
3213 fn from(value: Flow) -> Self {
3214 let mut nodes = IndexMap::new();
3215 for (id, node) in value.nodes {
3216 nodes.insert(id.clone(), HostNode::from(node));
3217 }
3218 let start = value
3219 .entrypoints
3220 .get("default")
3221 .and_then(Value::as_str)
3222 .and_then(|id| NodeId::from_str(id).ok())
3223 .or_else(|| nodes.keys().next().cloned());
3224 let slot_schema = value
3228 .metadata
3229 .extra
3230 .get(SLOT_SCHEMA_METADATA_KEY)
3231 .filter(|v| !v.is_null())
3232 .cloned();
3233 let vars_init = value
3234 .metadata
3235 .extra
3236 .get("vars_init")
3237 .and_then(|v| v.as_object())
3238 .map(|decls| {
3239 decls
3240 .iter()
3241 .filter_map(|(name, decl)| {
3242 decl.get("default").map(|d| (name.clone(), d.clone()))
3243 })
3244 .collect::<JsonMap<String, Value>>()
3245 })
3246 .unwrap_or_default();
3247 Self {
3248 id: value.id.as_str().to_string(),
3249 start,
3250 nodes,
3251 slot_schema,
3252 vars_init,
3253 }
3254 }
3255}
3256
3257impl From<Node> for HostNode {
3258 fn from(node: Node) -> Self {
3259 let full_ref = node.component.id.as_str().to_string();
3260 let operation_in_mapping = extract_operation_from_mapping(&node.input.mapping);
3261 let is_builtin = full_ref.starts_with("component.exec")
3270 || full_ref.starts_with("flow.")
3271 || full_ref.starts_with("emit.")
3272 || full_ref.starts_with("session.")
3273 || full_ref.starts_with("provider.")
3274 || full_ref.starts_with("dw.")
3275 || full_ref.starts_with("sorla.")
3276 || full_ref.starts_with("operala.")
3277 || full_ref.starts_with("agentic.")
3278 || full_ref.starts_with("var.")
3279 || full_ref.starts_with("mcp:");
3282 let (component_ref, raw_operation) =
3283 if node.component.operation.is_some() || is_builtin || operation_in_mapping.is_some() {
3284 (full_ref, node.component.operation.clone())
3285 } else if let Some(dot) = full_ref.rfind('.') {
3286 let comp = full_ref[..dot].to_string();
3287 let op = full_ref[dot + 1..].to_string();
3288 (comp, Some(op))
3289 } else {
3290 (full_ref, None)
3291 };
3292 let operation_is_component_exec = raw_operation.as_deref() == Some("component.exec");
3293 let operation_is_emit = raw_operation
3294 .as_deref()
3295 .map(|op| op.starts_with("emit."))
3296 .unwrap_or(false);
3297 let is_component_exec = component_ref == "component.exec" || operation_is_component_exec;
3298
3299 let kind = if is_component_exec {
3300 let target = if component_ref == "component.exec" {
3301 if let Some(op) = raw_operation
3302 .as_deref()
3303 .filter(|op| op.starts_with("emit."))
3304 {
3305 op.to_string()
3306 } else {
3307 extract_target_component(&node.input.mapping)
3308 .unwrap_or_else(|| "component.exec".to_string())
3309 }
3310 } else {
3311 extract_target_component(&node.input.mapping)
3312 .unwrap_or_else(|| component_ref.clone())
3313 };
3314 if target.starts_with("emit.") {
3315 NodeKind::BuiltinEmit {
3316 kind: emit_kind_from_ref(&target),
3317 }
3318 } else {
3319 NodeKind::Exec {
3320 target_component: target,
3321 }
3322 }
3323 } else if operation_is_emit {
3324 NodeKind::BuiltinEmit {
3325 kind: emit_kind_from_ref(raw_operation.as_deref().unwrap_or("emit.log")),
3326 }
3327 } else {
3328 match component_ref.as_str() {
3329 "flow.call" => NodeKind::FlowCall,
3330 "flow.goto" => NodeKind::FlowGoto,
3331 "provider.invoke" => NodeKind::ProviderInvoke,
3332 "session.wait" => NodeKind::Wait,
3333 "state.get" => NodeKind::BuiltinStateGet,
3334 "state.set" => NodeKind::BuiltinStateSet,
3335 "var.set" => {
3336 let name = node
3337 .input
3338 .mapping
3339 .get("name")
3340 .and_then(Value::as_str)
3341 .unwrap_or("")
3342 .to_string();
3343 let value = node
3344 .input
3345 .mapping
3346 .get("value")
3347 .cloned()
3348 .unwrap_or(Value::Null);
3349 NodeKind::VarSet { name, value }
3350 }
3351 "dw.agent" => NodeKind::DwAgent {
3352 agent_id: raw_operation.clone().unwrap_or_default(),
3353 },
3354 "dw.agent_graph" => NodeKind::DwAgentGraph {
3355 graph_id: raw_operation.clone().unwrap_or_default(),
3356 },
3357 "sorla.call" => NodeKind::SorlaCall {
3358 target: raw_operation.clone().unwrap_or_default(),
3359 },
3360 "operala.call" => NodeKind::OperalaCall {
3361 target: raw_operation.clone().unwrap_or_default(),
3362 },
3363 "agentic.call" => NodeKind::AgenticCall {
3364 target: raw_operation.clone().unwrap_or_default(),
3365 },
3366 "telco-x.call" => NodeKind::TelcoXCall {
3367 target: raw_operation.clone().unwrap_or_default(),
3368 },
3369 "approval.call" => NodeKind::ApprovalCall {
3370 target: raw_operation.clone().unwrap_or_default(),
3371 },
3372 comp if comp.starts_with("emit.") => NodeKind::BuiltinEmit {
3373 kind: emit_kind_from_ref(comp),
3374 },
3375 "mcp" => mcp_node_kind(&node.input.mapping, raw_operation.as_deref()),
3384 comp if comp.starts_with("mcp:") => mcp_node_kind(&node.input.mapping, Some(comp)),
3390 other => NodeKind::PackComponent {
3391 component_ref: other.to_string(),
3392 },
3393 }
3394 };
3395 let component_label = match &kind {
3396 NodeKind::Exec { .. } => "component.exec".to_string(),
3397 NodeKind::PackComponent { component_ref } => component_ref.clone(),
3398 NodeKind::ProviderInvoke => "provider.invoke".to_string(),
3399 NodeKind::FlowCall => "flow.call".to_string(),
3400 NodeKind::FlowGoto => "flow.goto".to_string(),
3401 NodeKind::BuiltinEmit { kind } => emit_ref_from_kind(kind),
3402 NodeKind::BuiltinStateGet => "state.get".to_string(),
3403 NodeKind::BuiltinStateSet => "state.set".to_string(),
3404 NodeKind::VarSet { .. } => "var.set".to_string(),
3405 NodeKind::Wait => "session.wait".to_string(),
3406 NodeKind::DwAgent { .. } => "dw.agent".to_string(),
3407 NodeKind::DwAgentGraph { .. } => "dw.agent_graph".to_string(),
3408 NodeKind::SorlaCall { .. } => "sorla.call".to_string(),
3409 NodeKind::OperalaCall { .. } => "operala.call".to_string(),
3410 NodeKind::AgenticCall { .. } => "agentic.call".to_string(),
3411 NodeKind::TelcoXCall { .. } => "telco-x.call".to_string(),
3412 NodeKind::ApprovalCall { .. } => "approval.call".to_string(),
3413 NodeKind::Mcp { server_id, tool } => format!("mcp:{server_id}/{tool}"),
3414 };
3415 let operation_name = if is_component_exec && operation_is_component_exec {
3416 None
3417 } else {
3418 raw_operation.clone()
3419 };
3420 let vars_out = node
3424 .input
3425 .mapping
3426 .get("vars_out")
3427 .and_then(Value::as_object)
3428 .cloned();
3429 let payload_expr = match kind {
3430 NodeKind::BuiltinEmit { .. } => extract_emit_payload(&node.input.mapping),
3431 NodeKind::VarSet { .. } => Value::Null,
3434 _ => {
3435 let mut mapping = node.input.mapping.clone();
3439 if let Some(obj) = mapping.as_object_mut() {
3440 obj.remove("vars_out");
3441 }
3442 mapping
3443 }
3444 };
3445 Self {
3446 kind,
3447 component: component_label,
3448 component_id: if is_component_exec {
3449 "component.exec".to_string()
3450 } else {
3451 component_ref
3452 },
3453 operation_name,
3454 operation_in_mapping,
3455 payload_expr,
3456 routing: node.routing,
3457 vars_out,
3458 }
3459 }
3460}
3461
3462fn mcp_node_kind(payload: &Value, legacy_ref: Option<&str>) -> NodeKind {
3475 if let Some((server_id, tool)) = crate::runner::mcp_node::server_tool_from_payload(payload) {
3476 return NodeKind::Mcp { server_id, tool };
3477 }
3478 if let Some((server_id, tool)) = legacy_ref.and_then(parse_legacy_mcp_ref) {
3479 return NodeKind::Mcp { server_id, tool };
3480 }
3481 NodeKind::PackComponent {
3482 component_ref: "mcp".to_string(),
3483 }
3484}
3485
3486fn parse_legacy_mcp_ref(reference: &str) -> Option<(String, String)> {
3490 let rest = reference.strip_prefix("mcp:").unwrap_or(reference);
3491 let (server, tool) = rest.split_once('/')?;
3492 if server.is_empty() || tool.is_empty() {
3493 return None;
3494 }
3495 Some((server.to_string(), tool.to_string()))
3496}
3497
3498fn extract_target_component(payload: &Value) -> Option<String> {
3499 match payload {
3500 Value::Object(map) => map
3501 .get("component")
3502 .or_else(|| map.get("component_ref"))
3503 .and_then(Value::as_str)
3504 .map(|s| s.to_string()),
3505 _ => None,
3506 }
3507}
3508
3509fn extract_operation_from_mapping(payload: &Value) -> Option<String> {
3510 match payload {
3511 Value::Object(map) => map
3512 .get("operation")
3513 .or_else(|| map.get("op"))
3514 .and_then(Value::as_str)
3515 .map(str::trim)
3516 .filter(|value| !value.is_empty())
3517 .map(|value| value.to_string()),
3518 _ => None,
3519 }
3520}
3521
3522fn extract_emit_payload(payload: &Value) -> Value {
3523 if let Value::Object(map) = payload {
3524 if let Some(input) = map.get("input") {
3525 return input.clone();
3526 }
3527 if let Some(inner) = map.get("payload") {
3528 return inner.clone();
3529 }
3530 }
3531 payload.clone()
3532}
3533
3534fn split_operation_payload(payload: Value) -> (Value, Value) {
3535 if let Value::Object(mut map) = payload.clone()
3536 && map.contains_key("input")
3537 {
3538 let input = map.remove("input").unwrap_or(Value::Null);
3539 let config = map.remove("config").unwrap_or(Value::Null);
3540 let legacy_only = map.keys().all(|key| {
3541 matches!(
3542 key.as_str(),
3543 "operation" | "op" | "component" | "component_ref"
3544 )
3545 });
3546 if legacy_only {
3547 return (input, config);
3548 }
3549 }
3550 (payload, Value::Null)
3551}
3552
3553fn resolve_component_operation(
3554 node_id: &str,
3555 component_label: &str,
3556 payload_operation: Option<String>,
3557 operation_override: Option<&str>,
3558 operation_in_mapping: Option<&str>,
3559) -> Result<String> {
3560 if let Some(op) = operation_override
3561 .map(str::trim)
3562 .filter(|value| !value.is_empty())
3563 {
3564 return Ok(op.to_string());
3565 }
3566
3567 if let Some(op) = payload_operation
3568 .as_deref()
3569 .map(str::trim)
3570 .filter(|value| !value.is_empty())
3571 {
3572 return Ok(op.to_string());
3573 }
3574
3575 let mut message = format!(
3576 "missing operation for node `{}` (component `{}`); expected node.component.operation to be set",
3577 node_id, component_label,
3578 );
3579 if let Some(found) = operation_in_mapping {
3580 message.push_str(&format!(
3581 ". Found operation in input.mapping (`{}`) but this is not used; pack compiler must preserve node.component.operation.",
3582 found
3583 ));
3584 }
3585 bail!(message);
3586}
3587
3588fn emit_kind_from_ref(component_ref: &str) -> EmitKind {
3589 match component_ref {
3590 "emit.log" => EmitKind::Log,
3591 "emit.response" => EmitKind::Response,
3592 other => EmitKind::Other(other.to_string()),
3593 }
3594}
3595
3596fn emit_ref_from_kind(kind: &EmitKind) -> String {
3597 match kind {
3598 EmitKind::Log => "emit.log".to_string(),
3599 EmitKind::Response => "emit.response".to_string(),
3600 EmitKind::Other(other) => other.clone(),
3601 }
3602}
3603
3604fn is_card_invocation(input: &Value) -> bool {
3607 if let Value::Object(map) = input {
3608 return map.contains_key("card_source") || map.contains_key("card_spec");
3609 }
3610 false
3611}
3612
3613fn promote_card_config_to_invocation(input: &mut Value, config: &Value) {
3625 if is_card_invocation(input) {
3626 return;
3627 }
3628
3629 let cfg_map = card_defaults_source(input, config);
3630 let Some(cfg) = cfg_map else { return };
3631
3632 let default_asset = cfg
3633 .get("default_card_asset")
3634 .and_then(Value::as_str)
3635 .map(str::trim)
3636 .filter(|value| !value.is_empty())
3637 .map(str::to_string);
3638 let default_inline = cfg
3639 .get("default_card_inline")
3640 .filter(|value| value.is_object() || value.is_array())
3641 .cloned();
3642 let default_source = cfg
3643 .get("default_source")
3644 .and_then(Value::as_str)
3645 .map(str::trim)
3646 .filter(|value| !value.is_empty())
3647 .map(str::to_lowercase);
3648
3649 if default_asset.is_none() && default_inline.is_none() && default_source.is_none() {
3650 return;
3651 }
3652
3653 let card_source = default_source.unwrap_or_else(|| {
3654 if default_inline.is_some() {
3655 "inline".to_string()
3656 } else {
3657 "asset".to_string()
3658 }
3659 });
3660
3661 let mut card_spec = serde_json::Map::new();
3662 match card_source.as_str() {
3663 "asset" => {
3664 if let Some(path) = default_asset {
3665 card_spec.insert("asset_path".into(), Value::String(path));
3666 }
3667 }
3668 "inline" => {
3669 if let Some(inline) = default_inline {
3670 card_spec.insert("inline_json".into(), inline);
3671 }
3672 }
3673 _ => {}
3674 }
3675
3676 if !matches!(input, Value::Object(_)) {
3677 *input = Value::Object(serde_json::Map::new());
3678 }
3679 if let Value::Object(map) = input {
3680 map.insert("card_source".into(), Value::String(card_source));
3681 map.insert("card_spec".into(), Value::Object(card_spec));
3682 }
3683}
3684
3685fn card_defaults_source<'a>(
3690 input: &'a Value,
3691 config: &'a Value,
3692) -> Option<&'a serde_json::Map<String, Value>> {
3693 if let Value::Object(map) = config {
3694 return Some(map);
3695 }
3696 if let Value::Object(map) = input
3697 && let Some(Value::Object(nested)) = map.get("config")
3698 {
3699 return Some(nested);
3700 }
3701 None
3702}
3703
3704fn inject_card_locale(payload: &mut Value, entry: &Value) {
3705 if !is_card_invocation(payload) {
3706 return;
3707 }
3708 let Value::Object(map) = payload else { return };
3709 if map.contains_key("locale") {
3710 return;
3711 }
3712 let locale = entry
3713 .pointer("/input/metadata/locale")
3714 .or_else(|| entry.pointer("/metadata/locale"))
3715 .and_then(Value::as_str);
3716 if let Some(locale) = locale {
3717 map.insert("locale".into(), Value::String(locale.to_string()));
3718 }
3719}
3720
3721fn inject_card_route(payload: &mut Value, entry: &Value, node: &HostNode) {
3736 let is_adaptive_card =
3737 node.component_id().contains("adaptive-card") || node.component.contains("adaptive-card");
3738 if !is_adaptive_card || is_card_invocation(payload) {
3739 return;
3740 }
3741 let route = entry
3742 .pointer("/input/metadata/routeToCardId")
3743 .or_else(|| entry.pointer("/metadata/routeToCardId"))
3744 .or_else(|| entry.pointer("/input/metadata/toCardId"))
3745 .or_else(|| entry.pointer("/metadata/toCardId"))
3746 .or_else(|| entry.pointer("/input/metadata/nextCardId"))
3747 .or_else(|| entry.pointer("/metadata/nextCardId"))
3748 .and_then(Value::as_str)
3749 .map(str::trim)
3750 .filter(|value| !value.is_empty());
3751 let Some(route) = route else {
3752 return;
3753 };
3754
3755 if !matches!(payload, Value::Object(_)) {
3756 *payload = Value::Object(serde_json::Map::new());
3757 }
3758 if let Value::Object(map) = payload {
3759 let mut card_spec = serde_json::Map::new();
3760 card_spec.insert(
3761 "asset_path".into(),
3762 Value::String(format!("assets/cards/{route}.json")),
3763 );
3764 map.insert("card_source".into(), Value::String("asset".into()));
3765 map.insert("card_spec".into(), Value::Object(card_spec));
3766 tracing::debug!(route_to_card = %route, "inject_card_route: routed card asset selected");
3767 }
3768}
3769
3770fn inject_slot_definitions(input: &mut Value, slot_schema: &Value, flow_id: &str, node_id: &str) {
3776 if input.is_null() {
3777 *input = Value::Object(serde_json::Map::new());
3778 }
3779 let Some(map) = input.as_object_mut() else {
3780 tracing::warn!(
3781 flow_id,
3782 node_id,
3783 "slot-extractor input is not an object; cannot inject slot_definitions"
3784 );
3785 return;
3786 };
3787 if map.contains_key("slot_definitions") {
3788 return;
3789 }
3790 let slot_count = slot_schema.as_array().map_or(0, Vec::len);
3791 tracing::debug!(
3792 flow_id,
3793 slot_count,
3794 "injecting flow-level slot_schema as slot_definitions into slot-extractor input"
3795 );
3796 map.insert("slot_definitions".to_string(), slot_schema.clone());
3797}
3798
3799fn resolve_card_assets(input: &mut Value, pack: &crate::pack::PackRuntime) {
3806 resolve_card_spec_asset(input, pack);
3807
3808 if let Value::Object(map) = input
3811 && let Some(Value::Object(call)) = map.get_mut("call")
3812 && let Some(payload) = call.get_mut("payload")
3813 {
3814 resolve_card_spec_asset(payload, pack);
3815 }
3816}
3817
3818fn resolve_card_spec_asset(value: &mut Value, pack: &crate::pack::PackRuntime) {
3820 let Value::Object(map) = value else { return };
3821
3822 let is_asset = map
3823 .get("card_source")
3824 .and_then(Value::as_str)
3825 .map(|s| s.eq_ignore_ascii_case("asset"))
3826 .unwrap_or(false);
3827 if !is_asset {
3828 return;
3829 }
3830
3831 let asset_path = map
3832 .get("card_spec")
3833 .and_then(|spec| spec.get("asset_path"))
3834 .and_then(Value::as_str)
3835 .map(str::to_string);
3836
3837 let Some(asset_path) = asset_path else { return };
3838
3839 match pack.read_asset(&asset_path) {
3840 Ok(bytes) => {
3841 let card_json: Value = match serde_json::from_slice(&bytes) {
3842 Ok(v) => v,
3843 Err(err) => {
3844 tracing::warn!(
3845 asset_path,
3846 %err,
3847 "failed to parse card asset as JSON; leaving as asset reference"
3848 );
3849 return;
3850 }
3851 };
3852 tracing::debug!(asset_path, "pre-resolved card asset to inline_json");
3853 map.insert("card_source".into(), Value::String("inline".into()));
3854 if let Some(Value::Object(spec)) = map.get_mut("card_spec") {
3855 spec.insert("inline_json".into(), card_json);
3856 spec.remove("asset_path");
3857 }
3858 }
3859 Err(err) => {
3860 tracing::warn!(
3861 asset_path,
3862 %err,
3863 "card asset not found in pack; leaving as asset reference"
3864 );
3865 }
3866 }
3867
3868 let configured_bundle_path = map
3875 .get("card_spec")
3876 .and_then(|spec| spec.get("i18n_bundle_path"))
3877 .and_then(Value::as_str)
3878 .map(|s| s.trim().trim_end_matches('/').to_string())
3879 .filter(|s| !s.is_empty());
3880
3881 let bundle_path = configured_bundle_path
3882 .clone()
3883 .unwrap_or_else(|| "assets/i18n".to_string());
3884
3885 let i18n_entries = load_i18n_bundle_entries(&bundle_path, |path| pack.read_asset(path));
3886
3887 if !i18n_entries.is_empty() {
3888 let locale_keys: Vec<_> = i18n_entries.keys().cloned().collect();
3889 if let Some(Value::Object(spec)) = map.get_mut("card_spec") {
3890 spec.insert("i18n_inline".into(), Value::Object(i18n_entries));
3891 if configured_bundle_path.is_some() {
3892 tracing::info!(%bundle_path, ?locale_keys, "pre-resolved i18n bundle into card_spec.i18n_inline");
3893 } else {
3894 tracing::info!(%bundle_path, ?locale_keys, "auto-discovered i18n bundle and inlined into card_spec.i18n_inline");
3895 }
3896 }
3897 }
3898}
3899
3900fn load_i18n_bundle_entries<F>(bundle_path: &str, mut read_asset: F) -> JsonMap<String, Value>
3901where
3902 F: FnMut(&str) -> Result<Vec<u8>>,
3903{
3904 let mut i18n_entries = JsonMap::new();
3905
3906 if bundle_path.ends_with(".json") {
3907 if let Ok(bytes) = read_asset(bundle_path)
3908 && let Ok(Value::Object(entries)) = serde_json::from_slice::<Value>(&bytes)
3909 {
3910 i18n_entries.insert("en".to_string(), Value::Object(entries));
3911 }
3912 return i18n_entries;
3913 }
3914
3915 let manifest_path = format!("{bundle_path}/_manifest.json");
3916 let locale_codes: Vec<String> = read_asset(&manifest_path)
3917 .ok()
3918 .and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
3919 .and_then(|value| {
3920 let locales = value
3921 .get("locales")
3922 .and_then(Value::as_array)
3923 .cloned()
3924 .or_else(|| value.as_array().cloned());
3925 locales.map(|items| {
3926 items
3927 .iter()
3928 .filter_map(Value::as_str)
3929 .map(String::from)
3930 .collect()
3931 })
3932 })
3933 .unwrap_or_default();
3934
3935 tracing::info!(%bundle_path, ?locale_codes, "i18n manifest discovered locales");
3936
3937 for locale in &locale_codes {
3938 let candidate = format!("{bundle_path}/{locale}.json");
3939 if let Ok(bytes) = read_asset(&candidate)
3940 && let Ok(Value::Object(entries)) = serde_json::from_slice::<Value>(&bytes)
3941 {
3942 i18n_entries.insert(locale.clone(), Value::Object(entries));
3943 }
3944 }
3945 if !i18n_entries.contains_key("en") {
3946 let en_path = format!("{bundle_path}/en.json");
3947 if let Ok(bytes) = read_asset(&en_path)
3948 && let Ok(Value::Object(entries)) = serde_json::from_slice::<Value>(&bytes)
3949 {
3950 i18n_entries.insert("en".to_string(), Value::Object(entries));
3951 }
3952 }
3953
3954 i18n_entries
3955}
3956
3957#[derive(Debug)]
3966pub(crate) enum CustomRoutingDecision {
3967 Next(NodeId),
3968 End,
3969 Wait,
3970}
3971
3972fn evaluate_custom_routing(
3985 raw: &Value,
3986 output: &NodeOutput,
3987 state: &ExecutionState,
3988 flow_ir: &HostFlow,
3989 node_id: &NodeId,
3990) -> CustomRoutingDecision {
3991 let routes = match raw.as_array() {
3992 Some(arr) => arr,
3993 None => {
3994 tracing::warn!(
3995 flow_id = %flow_ir.id,
3996 node_id = %node_id,
3997 "custom routing is not an array; terminating"
3998 );
3999 return CustomRoutingDecision::End;
4000 }
4001 };
4002
4003 let ctx = build_routing_context(
4010 output,
4011 state,
4012 default_success_event(routes),
4013 default_error_event(routes),
4014 );
4015
4016 let mut has_condition = false;
4017 for route in routes {
4018 let condition = route.get("condition").and_then(|v| v.as_str());
4019 let to = route.get("to").and_then(|v| v.as_str());
4020
4021 if let Some(cond) = condition {
4022 has_condition = true;
4023 if evaluate_simple_condition(cond, &ctx)
4024 && let Some(target) = to
4025 && let Ok(nid) = NodeId::new(target)
4026 {
4027 tracing::debug!(
4028 flow_id = %flow_ir.id,
4029 node_id = %node_id,
4030 condition = cond,
4031 target = target,
4032 "conditional route matched"
4033 );
4034 return CustomRoutingDecision::Next(nid);
4035 }
4036 } else if let Some(target) = to
4037 && let Ok(nid) = NodeId::new(target)
4038 {
4039 tracing::debug!(
4040 flow_id = %flow_ir.id,
4041 node_id = %node_id,
4042 target = target,
4043 "default route taken"
4044 );
4045 return CustomRoutingDecision::Next(nid);
4046 }
4047 }
4048
4049 if has_condition {
4056 tracing::debug!(
4057 flow_id = %flow_ir.id,
4058 node_id = %node_id,
4059 "no conditional route matched; pausing run at current node for resume"
4060 );
4061 CustomRoutingDecision::Wait
4062 } else {
4063 tracing::warn!(
4064 flow_id = %flow_ir.id,
4065 node_id = %node_id,
4066 "no route matched and no conditions present; terminating"
4067 );
4068 CustomRoutingDecision::End
4069 }
4070}
4071
4072fn evaluate_simple_condition(condition: &str, ctx: &Value) -> bool {
4083 if let Some((path, expected)) = split_condition(condition, "==") {
4084 return string_eq(ctx, path, expected, false);
4085 }
4086 if let Some((path, expected)) = split_condition(condition, "!=") {
4087 return string_eq(ctx, path, expected, true);
4088 }
4089 if let Some((path, expected)) = split_condition(condition, ">=") {
4090 return numeric_cmp(ctx, path, expected, |a, b| a >= b);
4091 }
4092 if let Some((path, expected)) = split_condition(condition, "<=") {
4093 return numeric_cmp(ctx, path, expected, |a, b| a <= b);
4094 }
4095 if let Some((path, expected)) = split_condition(condition, ">") {
4096 return numeric_cmp(ctx, path, expected, |a, b| a > b);
4097 }
4098 if let Some((path, expected)) = split_condition(condition, "<") {
4099 return numeric_cmp(ctx, path, expected, |a, b| a < b);
4100 }
4101 if let Some((path, expected)) = split_condition(condition, " contains ") {
4102 let needle = expected.to_lowercase();
4103 return resolve_dotted_path(ctx, path)
4104 .is_some_and(|actual| actual.to_lowercase().contains(&needle));
4105 }
4106 false
4107}
4108
4109fn split_condition<'a>(condition: &'a str, op: &str) -> Option<(&'a str, &'a str)> {
4113 let idx = condition.find(op)?;
4114 let path = condition[..idx].trim();
4115 let value = condition[idx + op.len()..].trim().trim_matches('"');
4116 Some((path, value))
4117}
4118
4119fn string_eq(ctx: &Value, path: &str, expected: &str, negate: bool) -> bool {
4122 let matches = resolve_dotted_path(ctx, path)
4123 .as_deref()
4124 .is_some_and(|a| a.eq_ignore_ascii_case(expected));
4125 if negate { !matches } else { matches }
4126}
4127
4128fn numeric_cmp(ctx: &Value, path: &str, expected: &str, cmp: impl Fn(f64, f64) -> bool) -> bool {
4131 let Some(actual) = resolve_dotted_path(ctx, path).and_then(|a| a.trim().parse::<f64>().ok())
4132 else {
4133 return false;
4134 };
4135 let Ok(rhs) = expected.parse::<f64>() else {
4136 return false;
4137 };
4138 cmp(actual, rhs)
4139}
4140
4141fn resolve_dotted_path(value: &Value, path: &str) -> Option<String> {
4143 let parts: Vec<&str> = path.split('.').collect();
4144 let mut current = value;
4145 for part in &parts {
4146 current = current.get(part)?;
4147 }
4148 match current {
4149 Value::String(s) => Some(s.clone()),
4150 Value::Bool(b) => Some(b.to_string()),
4151 Value::Number(n) => Some(n.to_string()),
4152 _ => Some(current.to_string()),
4153 }
4154}
4155
4156const SUCCESS_EVENT_PORTS: [&str; 3] = ["on_success", "on_complete", "on_submit"];
4178
4179const ERROR_EVENT_PORTS: [&str; 3] = ["on_error", "on_cancel", "on_timeout"];
4184
4185fn node_has_error_route(routing: &Routing) -> bool {
4192 let Routing::Custom(raw) = routing else {
4193 return false;
4194 };
4195 let Some(routes) = raw.as_array() else {
4196 return false;
4197 };
4198 routes.iter().any(|route| {
4199 let by_event = route
4200 .get("event")
4201 .and_then(Value::as_str)
4202 .is_some_and(|e| ERROR_EVENT_PORTS.contains(&e));
4203 let by_condition = route
4204 .get("condition")
4205 .and_then(Value::as_str)
4206 .is_some_and(|c| ERROR_EVENT_PORTS.iter().any(|port| c.contains(port)));
4207 by_event || by_condition
4208 })
4209}
4210
4211fn default_success_event(routes: &[Value]) -> &'static str {
4221 default_event(routes, &SUCCESS_EVENT_PORTS, "on_success")
4222}
4223
4224fn default_error_event(routes: &[Value]) -> &'static str {
4229 default_event(routes, &ERROR_EVENT_PORTS, "on_error")
4230}
4231
4232fn default_event(routes: &[Value], ports: &[&'static str], fallback: &'static str) -> &'static str {
4235 let referenced: Vec<&str> = routes
4236 .iter()
4237 .filter_map(|route| route.get("condition").and_then(Value::as_str))
4238 .filter_map(condition_event_eq)
4239 .collect();
4240 ports
4241 .iter()
4242 .copied()
4243 .find(|port| referenced.contains(port))
4244 .unwrap_or(fallback)
4245}
4246
4247fn condition_event_eq(condition: &str) -> Option<&str> {
4250 let idx = condition.find("==")?;
4251 if condition[..idx].trim() != "event" {
4252 return None;
4253 }
4254 Some(condition[idx + 2..].trim().trim_matches('"'))
4255}
4256
4257fn resolve_entry_metadata(entry: &Value) -> Option<&Value> {
4264 entry
4265 .pointer("/input/metadata")
4266 .or_else(|| entry.pointer("/metadata"))
4267}
4268
4269fn submitted_fields(entry: &Value) -> JsonMap<String, Value> {
4295 let mut fields = JsonMap::new();
4296 if let Some(Value::Object(meta)) = resolve_entry_metadata(entry) {
4297 for (key, value) in meta {
4298 if key == "action" {
4299 continue;
4300 }
4301 fields.insert(key.clone(), value.clone());
4302 }
4303 }
4304 let envelope = entry
4305 .get("input")
4306 .filter(|v| v.is_object())
4307 .unwrap_or(entry);
4308 if let Some(map) = envelope.as_object() {
4309 for (key, value) in map {
4310 if key == "metadata" || key == "text" {
4311 continue;
4312 }
4313 fields.insert(key.clone(), value.clone());
4314 }
4315 }
4316 fields
4317}
4318
4319fn build_routing_context(
4346 output: &NodeOutput,
4347 state: &ExecutionState,
4348 success_event: &str,
4349 error_event: &str,
4350) -> Value {
4351 let mut ctx = match &output.payload {
4352 Value::Object(map) => map.clone(),
4353 _ => JsonMap::new(),
4354 };
4355
4356 let entry = alias_input_to_entry(state.entry.clone());
4361 ctx.insert("entry".into(), entry.clone());
4362 ctx.insert("in".into(), entry.clone());
4363
4364 let metadata = entry
4368 .pointer("/input/metadata")
4369 .or_else(|| entry.pointer("/metadata"));
4370
4371 let mut response = JsonMap::new();
4372 if let Some(Value::Object(meta)) = metadata {
4373 for (k, v) in meta {
4374 match v {
4376 Value::String(s) => {
4377 response.insert(k.clone(), Value::String(s.clone()));
4378 }
4379 other => {
4380 response.insert(k.clone(), other.clone());
4381 }
4382 }
4383 }
4384 }
4385 if let Some(text) = entry
4387 .pointer("/input/text")
4388 .or_else(|| entry.pointer("/text"))
4389 .filter(|t| !t.is_null())
4390 {
4391 response.insert("text".into(), text.clone());
4392 }
4393 ctx.insert("response".into(), Value::Object(response));
4394
4395 let event = output
4404 .meta
4405 .get("outcome")
4406 .and_then(Value::as_str)
4407 .map(str::to_string)
4408 .unwrap_or_else(|| {
4409 if output.ok {
4410 success_event
4411 } else {
4412 error_event
4413 }
4414 .to_string()
4415 });
4416 ctx.insert("event".into(), Value::String(event));
4417
4418 Value::Object(ctx)
4419}
4420
4421fn approval_requires_human(input: &Value) -> bool {
4429 let mode = input
4430 .get("mode")
4431 .and_then(Value::as_str)
4432 .unwrap_or("always");
4433 match mode {
4434 "above_risk" => {
4435 let risk = input.get("risk").and_then(Value::as_f64).unwrap_or(0.0);
4436 let threshold = input
4437 .get("risk_threshold")
4438 .and_then(Value::as_f64)
4439 .unwrap_or(1.0);
4440 risk >= threshold
4441 }
4442 "above_confidence" => {
4443 let confidence = input
4444 .get("confidence")
4445 .and_then(Value::as_f64)
4446 .unwrap_or(0.0);
4447 let threshold = input
4448 .get("confidence_threshold")
4449 .and_then(Value::as_f64)
4450 .unwrap_or(1.0);
4451 confidence < threshold
4452 }
4453 _ => true,
4455 }
4456}
4457
4458#[cfg(test)]
4459mod approval_gate_tests {
4460 use super::approval_requires_human;
4461 use serde_json::json;
4462
4463 #[test]
4464 fn above_risk_auto_approves_below_threshold() {
4465 let input = json!({ "risk": 0.5, "mode": "above_risk", "risk_threshold": 0.7 });
4466 assert!(!approval_requires_human(&input));
4467 }
4468
4469 #[test]
4470 fn above_risk_requires_human_at_or_above_threshold() {
4471 let input = json!({ "risk": 0.9, "mode": "above_risk", "risk_threshold": 0.7 });
4472 assert!(approval_requires_human(&input));
4473 }
4474
4475 #[test]
4476 fn above_confidence_requires_human_when_low_confidence() {
4477 let input =
4478 json!({ "confidence": 0.4, "mode": "above_confidence", "confidence_threshold": 0.8 });
4479 assert!(approval_requires_human(&input));
4480 }
4481
4482 #[test]
4483 fn always_and_missing_gate_require_human() {
4484 assert!(approval_requires_human(&json!({ "mode": "always" })));
4485 assert!(approval_requires_human(&json!({})));
4486 }
4487}
4488
4489#[cfg(test)]
4490mod tests {
4491 use super::*;
4492
4493 fn native_op_key_for(kind: &NodeKind) -> Option<&'static str> {
4511 match kind {
4512 NodeKind::Exec { .. } | NodeKind::PackComponent { .. } => None,
4514 NodeKind::BuiltinEmit { .. } | NodeKind::Mcp { .. } => None,
4516
4517 NodeKind::ProviderInvoke => Some("provider.invoke"),
4518 NodeKind::FlowCall => Some("flow.call"),
4519 NodeKind::FlowGoto => Some("flow.goto"),
4520 NodeKind::BuiltinStateGet => Some("state.get"),
4521 NodeKind::BuiltinStateSet => Some("state.set"),
4522 NodeKind::VarSet { .. } => Some("var.set"),
4523 NodeKind::Wait => Some("session.wait"),
4524 NodeKind::DwAgent { .. } => Some("dw.agent"),
4525 NodeKind::DwAgentGraph { .. } => Some("dw.agent_graph"),
4526 NodeKind::SorlaCall { .. } => Some("sorla.call"),
4527 NodeKind::OperalaCall { .. } => Some("operala.call"),
4528 NodeKind::AgenticCall { .. } => Some("agentic.call"),
4529 NodeKind::TelcoXCall { .. } => Some("telco-x.call"),
4530 NodeKind::ApprovalCall { .. } => Some("approval.call"),
4531 }
4532 }
4533
4534 #[test]
4535 fn every_engine_dispatched_op_key_is_native_to_the_loader() {
4536 for key in [
4540 "provider.invoke",
4541 "flow.call",
4542 "flow.goto",
4543 "state.get",
4544 "state.set",
4545 "var.set",
4546 "session.wait",
4547 "dw.agent",
4548 "dw.agent_graph",
4549 "sorla.call",
4550 "operala.call",
4551 "agentic.call",
4552 "telco-x.call",
4553 "approval.call",
4554 ] {
4555 assert!(
4556 crate::runner::flow_adapter::is_native_op_key(key),
4557 "the engine dispatches `{key}`, but the loader does not treat it \
4558 as native — it will be wrapped as a generic component and the \
4559 engine arm becomes unreachable"
4560 );
4561 }
4562 assert!(crate::runner::flow_adapter::is_native_op_key(
4564 "emit.response"
4565 ));
4566 assert!(crate::runner::flow_adapter::is_native_op_key(
4567 "mcp:srv/tool"
4568 ));
4569 assert!(!crate::runner::flow_adapter::is_native_op_key("mcp.exec"));
4571
4572 assert_eq!(native_op_key_for(&NodeKind::FlowGoto), Some("flow.goto"));
4574 }
4575 use crate::validate::{ValidationConfig, ValidationMode};
4576 use greentic_types::{
4577 Flow, FlowComponentRef, FlowId, FlowKind, FlowMetadata, InputMapping, Node, NodeId,
4578 OutputMapping, Routing, TelemetryHints,
4579 };
4580 use serde_json::json;
4581 use std::collections::{BTreeMap, HashMap as StdHashMap};
4582 use std::str::FromStr;
4583 use std::sync::Mutex;
4584 use tokio::runtime::Runtime;
4585
4586 fn minimal_engine() -> FlowEngine {
4587 FlowEngine {
4588 packs: Vec::new(),
4589 flows: Vec::new(),
4590 flow_sources: HashMap::new(),
4591 messaging_provider_pack_ids: std::collections::HashSet::new(),
4592 flow_cache: RwLock::new(HashMap::new()),
4593 default_env: "local".to_string(),
4594 validation: ValidationConfig {
4595 mode: ValidationMode::Off,
4596 },
4597 cross_pack_resolver: None,
4598 rollout_ids: RolloutIds::default(),
4599 remote_dispatch_handler: None,
4600 #[cfg(feature = "agentic-worker")]
4601 dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
4602 #[cfg(feature = "agentic-worker")]
4603 agent_node_handler: None,
4604 #[cfg(feature = "agentic-worker")]
4605 graph_node_handler: None,
4606 #[cfg(feature = "agentic-worker")]
4607 mcp_tool_source: None,
4608 }
4609 }
4610
4611 fn flow_desc(id: &str, pack_id: &str, flow_type: &str, entry: bool) -> FlowDescriptor {
4612 FlowDescriptor {
4613 id: id.into(),
4614 flow_type: flow_type.into(),
4615 pack_id: pack_id.into(),
4616 profile: pack_id.into(),
4617 version: "0.0.0".into(),
4618 description: None,
4619 entry,
4620 }
4621 }
4622
4623 #[test]
4624 fn entry_flow_by_type_disambiguates_entrypoint_from_internal_helpers() {
4625 let mut engine = minimal_engine();
4630 engine.flows = vec![
4631 flow_desc("default", "weatherapi-pack", "messaging", true),
4632 flow_desc("flow_", "weatherapi-pack", "messaging", false),
4633 flow_desc("flow_error", "weatherapi-pack", "messaging", false),
4634 flow_desc("flow_get_weather", "weatherapi-pack", "messaging", false),
4635 ];
4636
4637 assert!(
4639 engine.flow_by_type("messaging").is_none(),
4640 "multiple messaging flows must be ambiguous for the plain lookup"
4641 );
4642 let resolved = engine
4644 .entry_flow_by_type("messaging")
4645 .expect("single entry flow must resolve");
4646 assert_eq!(resolved.id, "default");
4647 assert_eq!(resolved.pack_id, "weatherapi-pack");
4648 }
4649
4650 #[test]
4651 fn entry_flow_by_type_still_ambiguous_across_two_entrypoints() {
4652 let mut engine = minimal_engine();
4655 engine.flows = vec![
4656 flow_desc("default", "pack.a", "messaging", true),
4657 flow_desc("default", "pack.b", "messaging", true),
4658 flow_desc("helper", "pack.a", "messaging", false),
4659 ];
4660 assert!(engine.entry_flow_by_type("messaging").is_none());
4661 }
4662
4663 #[test]
4664 fn entry_flow_by_type_excludes_messaging_provider_pack_flows() {
4665 let mut engine = minimal_engine();
4671 engine.flows = vec![
4672 flow_desc("main", "hr-onboarding-pack", "messaging", true),
4673 flow_desc("main", "messaging-teams", "messaging", true),
4674 ];
4675 engine
4678 .messaging_provider_pack_ids
4679 .insert("messaging-teams".to_string());
4680
4681 assert!(engine.flow_by_type("messaging").is_none());
4683 let resolved = engine
4685 .entry_flow_by_type("messaging")
4686 .expect("app entry flow must resolve past the provider flow");
4687 assert_eq!(resolved.id, "main");
4688 assert_eq!(resolved.pack_id, "hr-onboarding-pack");
4689 }
4690
4691 #[test]
4692 fn entry_flow_by_type_matches_plain_lookup_for_single_flow() {
4693 let mut engine = minimal_engine();
4696 engine.flows = vec![flow_desc("only", "pack.a", "messaging", true)];
4697 assert_eq!(
4698 engine.flow_by_type("messaging").map(|f| f.id.as_str()),
4699 Some("only")
4700 );
4701 assert_eq!(
4702 engine
4703 .entry_flow_by_type("messaging")
4704 .map(|f| f.id.as_str()),
4705 Some("only")
4706 );
4707 }
4708
4709 #[test]
4710 fn to_node_output_legacy_success_becomes_data() {
4711 let out = to_node_output(&json!({ "ok": true, "temp": "20C" }));
4713 assert!(out.is_ok(), "legacy ok:true must classify as Data");
4714 let data = out.data().expect("data present");
4715 assert_eq!(data.get("temp").and_then(Value::as_str), Some("20C"));
4716 }
4717
4718 #[test]
4719 fn to_node_output_legacy_error_becomes_errors() {
4720 let out = to_node_output(
4722 &json!({ "ok": false, "error": { "code": "E_BAD", "message": "boom" } }),
4723 );
4724 assert!(!out.is_ok(), "legacy ok:false must classify as Errors");
4725 let errs = out.errors();
4726 assert_eq!(errs.len(), 1);
4727 assert_eq!(errs[0].code, "E_BAD");
4728 assert_eq!(errs[0].message, "boom");
4729 }
4730
4731 #[test]
4732 fn to_node_output_native_data_envelope_roundtrips() {
4733 let out = to_node_output(&json!({ "data": { "x": 1 } }));
4735 assert!(out.is_ok());
4736 assert_eq!(
4737 out.data().and_then(|d| d.get("x")).and_then(Value::as_i64),
4738 Some(1)
4739 );
4740 }
4741
4742 #[test]
4743 fn to_node_output_native_errors_envelope_roundtrips() {
4744 let out = to_node_output(&json!({
4746 "errors": [ { "code": "C", "message": "m", "kind": "validation",
4747 "retryable": false, "details": {} } ]
4748 }));
4749 assert!(!out.is_ok());
4750 assert_eq!(out.errors()[0].code, "C");
4751 assert_eq!(
4752 out.errors()[0].kind,
4753 greentic_types::node_io::ErrorKind::Validation
4754 );
4755 }
4756
4757 #[test]
4758 fn to_node_output_bare_object_becomes_data() {
4759 let out = to_node_output(&json!({ "foo": 1 }));
4761 assert!(out.is_ok());
4762 assert_eq!(
4763 out.data()
4764 .and_then(|d| d.get("foo"))
4765 .and_then(Value::as_i64),
4766 Some(1)
4767 );
4768 }
4769
4770 #[test]
4771 fn templating_renders_with_partials_and_data() {
4772 let mut state = ExecutionState::new(json!({ "city": "London" }));
4773 state.nodes.insert(
4774 "forecast".to_string(),
4775 NodeOutput::new(json!({ "temp": "20C" })),
4776 );
4777
4778 let ctx = state.context();
4780 assert_eq!(ctx["nodes"]["forecast"]["payload"]["temp"], json!("20C"));
4781 }
4782
4783 #[test]
4784 fn outputs_map_exposes_node_io_data_and_errors_alongside_flat() {
4785 let mut state = ExecutionState::new(json!({}));
4786 state.nodes.insert(
4787 "forecast".to_string(),
4788 NodeOutput::new(json!({ "temp": "20C" })),
4789 );
4790 let outs = state.outputs_map();
4791 assert_eq!(outs["forecast"]["temp"], json!("20C"));
4793 assert_eq!(outs["forecast"]["data"]["temp"], json!("20C"));
4795 assert_eq!(outs["forecast"]["errors"], json!([]));
4797 }
4798
4799 #[test]
4800 fn finalize_wraps_emitted_payloads() {
4801 let mut state = ExecutionState::new(json!({}));
4802 state.push_egress(json!({ "text": "first" }));
4803 state.push_egress(json!({ "text": "second" }));
4804 let result = state.finalize_with(Some(json!({ "text": "final" })));
4805 assert_eq!(
4806 result,
4807 json!([
4808 { "text": "first" },
4809 { "text": "second" },
4810 { "text": "final" }
4811 ])
4812 );
4813 }
4814
4815 #[test]
4816 fn finalize_does_not_double_terminal_emit_response() {
4817 let card = json!({ "renderedCard": { "type": "AdaptiveCard" } });
4822 let mut state = ExecutionState::new(json!({}));
4823 state.push_egress(card.clone());
4824 let result = state.finalize_with(Some(card.clone()));
4825 assert_eq!(result, json!([card]));
4826 }
4827
4828 #[test]
4829 fn finalize_still_appends_distinct_terminal_output() {
4830 let mut state = ExecutionState::new(json!({}));
4833 state.push_egress(json!({ "text": "emitted" }));
4834 let result = state.finalize_with(Some(json!({ "text": "final" })));
4835 assert_eq!(result, json!([{ "text": "emitted" }, { "text": "final" }]));
4836 }
4837
4838 #[test]
4839 fn alias_input_to_entry_exposes_input_for_bare_message() {
4840 let msg = json!({ "text": "hi", "metadata": { "operation": "get_weather" } });
4844 let aliased = alias_input_to_entry(msg);
4845 assert_eq!(
4846 aliased.pointer("/metadata/operation"),
4847 Some(&json!("get_weather"))
4848 );
4849 assert_eq!(
4850 aliased.pointer("/input/metadata/operation"),
4851 Some(&json!("get_weather"))
4852 );
4853 }
4854
4855 #[test]
4856 fn alias_input_to_entry_preserves_explicit_input_wrapper() {
4857 let wrapped = json!({ "input": { "metadata": { "operation": "x" } } });
4859 assert_eq!(alias_input_to_entry(wrapped.clone()), wrapped);
4860 }
4861
4862 #[test]
4863 fn alias_input_to_entry_ignores_non_objects() {
4864 assert_eq!(alias_input_to_entry(json!("hi")), json!("hi"));
4865 assert_eq!(alias_input_to_entry(json!(null)), json!(null));
4866 }
4867
4868 fn render_entry(entry: Value, expr: &str) -> Value {
4870 let st = ExecutionState::new(entry);
4871 let ctx = template_context(&st, Value::Null);
4872 render_template_value(&json!(expr), &ctx, TemplateOptions::default())
4873 .expect("template renders")
4874 }
4875
4876 #[test]
4877 fn entry_root_exposes_submitted_fields_on_the_wrapped_path() {
4878 let entry = json!({
4882 "input": {
4883 "metadata": { "action": "submit", "company_size": "11-50" },
4884 "text": "hi"
4885 },
4886 "tenant": "acme",
4887 "correlation_id": "c-1"
4888 });
4889
4890 assert_eq!(
4891 render_entry(entry.clone(), "{{entry.company_size}}"),
4892 json!("11-50")
4893 );
4894 assert_eq!(
4896 render_entry(entry.clone(), "{{in.company_size}}"),
4897 json!("11-50")
4898 );
4899 assert_eq!(
4901 render_entry(entry.clone(), "{{entry.input.metadata.company_size}}"),
4902 json!("11-50")
4903 );
4904 assert_eq!(
4905 render_entry(entry.clone(), "{{in.input.metadata.company_size}}"),
4906 json!("11-50")
4907 );
4908 assert_eq!(
4910 render_entry(entry.clone(), "{{entry.tenant}}"),
4911 json!("acme")
4912 );
4913 assert_eq!(
4914 render_entry(entry.clone(), "{{entry.correlation_id}}"),
4915 json!("c-1")
4916 );
4917 assert_eq!(
4919 render_entry(entry.clone(), "{{entry.input.text}}"),
4920 json!("hi")
4921 );
4922 assert_eq!(
4923 render_entry(entry, "{{entry.input.metadata.action}}"),
4924 json!("submit")
4925 );
4926 }
4927
4928 #[test]
4929 fn entry_root_exposes_submitted_fields_on_the_demo_path() {
4930 let entry = json!({
4934 "company_size": "11-50",
4935 "metadata": { "action": "submit", "email": "a@b.c" },
4936 "text": "hi"
4937 });
4938
4939 assert_eq!(
4940 render_entry(entry.clone(), "{{entry.company_size}}"),
4941 json!("11-50")
4942 );
4943 assert_eq!(
4944 render_entry(entry.clone(), "{{entry.email}}"),
4945 json!("a@b.c")
4946 );
4947 assert_eq!(
4949 render_entry(entry.clone(), "{{entry.metadata.email}}"),
4950 json!("a@b.c")
4951 );
4952 assert_eq!(render_entry(entry, "{{entry.text}}"), json!("hi"));
4954 }
4955
4956 #[test]
4957 fn entry_merge_never_clobbers_an_existing_root_key() {
4958 let entry = json!({
4961 "input": {
4962 "metadata": { "tenant": "evil", "company_size": "11-50" }
4963 },
4964 "tenant": "acme"
4965 });
4966
4967 assert_eq!(
4968 render_entry(entry.clone(), "{{entry.tenant}}"),
4969 json!("acme")
4970 );
4971 assert_eq!(
4972 render_entry(entry.clone(), "{{entry.company_size}}"),
4973 json!("11-50")
4974 );
4975 assert_eq!(
4977 render_entry(entry, "{{entry.input.metadata.company_size}}"),
4978 json!("11-50")
4979 );
4980 }
4981
4982 #[test]
4983 fn entry_and_in_stay_identical_after_the_merge() {
4984 let entry = json!({
4985 "input": { "metadata": { "action": "submit", "email": "a@b.c" } },
4986 "tenant": "acme"
4987 });
4988 let st = ExecutionState::new(entry);
4989 let ctx = template_context(&st, Value::Null);
4990 assert_eq!(
4991 ctx.pointer("/entry"),
4992 ctx.pointer("/in"),
4993 "`in` is an alias of `entry` and must not drift from it"
4994 );
4995 }
4996
4997 #[test]
4998 fn entry_merge_leaves_a_non_object_entry_untouched() {
4999 assert_eq!(
5002 merge_submitted_fields_into_entry(json!("hi"), JsonMap::new()),
5003 json!("hi")
5004 );
5005 let mut fields = JsonMap::new();
5006 fields.insert("x".into(), json!(1));
5007 assert_eq!(
5008 merge_submitted_fields_into_entry(json!(null), fields),
5009 json!(null)
5010 );
5011 }
5012
5013 #[test]
5014 fn finalize_flattens_final_array() {
5015 let mut state = ExecutionState::new(json!({}));
5016 state.push_egress(json!({ "text": "only" }));
5017 let result = state.finalize_with(Some(json!([
5018 { "text": "extra-1" },
5019 { "text": "extra-2" }
5020 ])));
5021 assert_eq!(
5022 result,
5023 json!([
5024 { "text": "only" },
5025 { "text": "extra-1" },
5026 { "text": "extra-2" }
5027 ])
5028 );
5029 }
5030
5031 #[test]
5032 fn inject_card_locale_uses_entry_metadata_without_overwriting_payload() {
5033 let mut payload = json!({
5034 "card_source": "inline",
5035 "card_spec": { "title": "Hello" }
5036 });
5037 inject_card_locale(
5038 &mut payload,
5039 &json!({"input": {"metadata": {"locale": "nl-NL"}}}),
5040 );
5041 assert_eq!(payload["locale"], json!("nl-NL"));
5042
5043 let mut existing = json!({
5044 "card_source": "inline",
5045 "card_spec": { "title": "Hello" },
5046 "locale": "en-GB"
5047 });
5048 inject_card_locale(&mut existing, &json!({"metadata": {"locale": "nl-NL"}}));
5049 assert_eq!(existing["locale"], json!("en-GB"));
5050 }
5051
5052 #[test]
5053 fn load_i18n_bundle_entries_reads_manifest_and_falls_back_to_en() {
5054 let assets = StdHashMap::from([
5055 (
5056 "cards/i18n/_manifest.json".to_string(),
5057 br#"{"locales":["de"]}"#.to_vec(),
5058 ),
5059 (
5060 "cards/i18n/de.json".to_string(),
5061 br#"{"title":"Hallo"}"#.to_vec(),
5062 ),
5063 (
5064 "cards/i18n/en.json".to_string(),
5065 br#"{"title":"Hello"}"#.to_vec(),
5066 ),
5067 ]);
5068
5069 let entries = load_i18n_bundle_entries("cards/i18n", |path| {
5070 assets
5071 .get(path)
5072 .cloned()
5073 .with_context(|| format!("missing asset {path}"))
5074 });
5075
5076 assert_eq!(entries["de"]["title"], json!("Hallo"));
5077 assert_eq!(entries["en"]["title"], json!("Hello"));
5078 }
5079
5080 #[test]
5081 fn load_i18n_bundle_entries_reads_single_file_bundle() {
5082 let entries = load_i18n_bundle_entries("cards/i18n.json", |path| {
5083 if path == "cards/i18n.json" {
5084 Ok(br#"{"title":"Hello"}"#.to_vec())
5085 } else {
5086 bail!("unexpected asset {path}");
5087 }
5088 });
5089
5090 assert_eq!(entries["en"]["title"], json!("Hello"));
5091 }
5092
5093 struct TestCrossPackResolver;
5094
5095 impl CrossPackResolver for TestCrossPackResolver {
5096 fn invoke(
5097 &self,
5098 provider_id: &str,
5099 provider_type: Option<&str>,
5100 op: &str,
5101 input: &[u8],
5102 tenant: &str,
5103 team: Option<&str>,
5104 ) -> Result<Value> {
5105 Ok(json!({
5106 "provider_id": provider_id,
5107 "provider_type": provider_type,
5108 "op": op,
5109 "tenant": tenant,
5110 "team": team,
5111 "input": serde_json::from_slice::<Value>(input)?,
5112 }))
5113 }
5114 }
5115
5116 #[test]
5117 fn cross_pack_resolver_returns_node_output_when_present() {
5118 let mut engine = minimal_engine();
5119 engine.set_cross_pack_resolver(Arc::new(TestCrossPackResolver));
5120
5121 let output = engine
5122 .try_invoke_cross_pack_resolver(
5123 Some("mail"),
5124 Some("messaging"),
5125 "send",
5126 br#"{"subject":"hello"}"#,
5127 "demo",
5128 )
5129 .expect("resolver invocation")
5130 .expect("resolver output");
5131
5132 assert_eq!(
5133 output.payload,
5134 json!({
5135 "provider_id": "mail",
5136 "provider_type": "messaging",
5137 "op": "send",
5138 "tenant": "demo",
5139 "team": null,
5140 "input": { "subject": "hello" },
5141 })
5142 );
5143 }
5144
5145 #[test]
5146 fn flow_goto_builds_a_jump_to_the_named_flow() {
5147 let outcome = execute_flow_goto(json!({
5148 "flow_id": "support",
5149 "node": "ask_order",
5150 "input": { "order": "A-1" },
5151 }))
5152 .expect("goto builds");
5153
5154 let NodeControl::Jump(jump) = outcome.control else {
5155 panic!("flow.goto must produce a Jump, got {:?}", outcome.control);
5156 };
5157 assert_eq!(jump.flow, "support");
5158 assert_eq!(jump.node.as_deref(), Some("ask_order"));
5159 assert_eq!(jump.payload, json!({ "order": "A-1" }));
5160 assert_eq!(outcome.output.payload, json!({ "order": "A-1" }));
5163 }
5164
5165 #[test]
5168 fn flow_goto_accepts_the_flow_alias_and_defaults_the_entry_node() {
5169 let outcome = execute_flow_goto(json!({ "flow": "support" })).expect("goto builds");
5170 let NodeControl::Jump(jump) = outcome.control else {
5171 panic!("expected a Jump");
5172 };
5173 assert_eq!(jump.flow, "support");
5174 assert!(
5175 jump.node.is_none(),
5176 "no node means `apply_jump` uses the target flow's start"
5177 );
5178 assert_eq!(jump.payload, Value::Null);
5179 }
5180
5181 #[test]
5184 fn flow_goto_refuses_an_empty_flow_id() {
5185 for payload in [json!({ "flow_id": "" }), json!({ "flow_id": " " })] {
5186 let err = execute_flow_goto(payload)
5187 .err()
5188 .expect("empty target must not build");
5189 assert!(
5190 err.to_string().contains("flow_id"),
5191 "error must name the field: {err}"
5192 );
5193 }
5194 let err = execute_flow_goto(json!({ "input": {} }))
5195 .err()
5196 .expect("missing target");
5197 assert!(err.to_string().contains("flow.goto"), "got: {err}");
5198 }
5199
5200 #[test]
5204 fn flow_goto_treats_a_blank_entry_node_as_absent() {
5205 let outcome =
5206 execute_flow_goto(json!({ "flow_id": "support", "node": " " })).expect("goto builds");
5207 let NodeControl::Jump(jump) = outcome.control else {
5208 panic!("expected a Jump");
5209 };
5210 assert!(jump.node.is_none());
5211 }
5212
5213 #[test]
5216 fn flow_goto_forwards_the_redirect_ceiling_and_reason() {
5217 let outcome = execute_flow_goto(json!({
5218 "flow_id": "support",
5219 "max_redirects": 1,
5220 "reason": "menu choice",
5221 }))
5222 .expect("goto builds");
5223 let NodeControl::Jump(jump) = outcome.control else {
5224 panic!("expected a Jump");
5225 };
5226 assert_eq!(jump.max_redirects, Some(1));
5227 assert_eq!(jump.reason.as_deref(), Some("menu choice"));
5228 }
5229
5230 #[test]
5233 fn flow_goto_names_itself_as_the_reason_by_default() {
5234 let outcome = execute_flow_goto(json!({ "flow_id": "support" })).expect("goto builds");
5235 let NodeControl::Jump(jump) = outcome.control else {
5236 panic!("expected a Jump");
5237 };
5238 assert_eq!(jump.reason.as_deref(), Some("flow.goto node"));
5239 }
5240
5241 #[test]
5242 fn parse_component_control_ignores_plain_payload() {
5243 let payload = json!({
5244 "flow": "not-a-control-field",
5245 "node": "n1"
5246 });
5247 let control = parse_component_control(&payload).expect("parse control");
5248 assert!(control.is_none());
5249 }
5250
5251 #[test]
5252 fn parse_component_control_parses_jump_marker() {
5253 let payload = json!({
5254 "greentic_control": {
5255 "action": "jump",
5256 "v": 1,
5257 "flow": "flow.b",
5258 "node": "node-2",
5259 "payload": { "message": "hi" },
5260 "hints": { "k": "v" },
5261 "max_redirects": 2,
5262 "reason": "handoff"
5263 }
5264 });
5265 let control = parse_component_control(&payload)
5266 .expect("parse control")
5267 .expect("missing control");
5268 match control {
5269 NodeControl::Jump(jump) => {
5270 assert_eq!(jump.flow, "flow.b");
5271 assert_eq!(jump.node.as_deref(), Some("node-2"));
5272 assert_eq!(jump.payload, json!({ "message": "hi" }));
5273 assert_eq!(jump.hints, json!({ "k": "v" }));
5274 assert_eq!(jump.max_redirects, Some(2));
5275 assert_eq!(jump.reason.as_deref(), Some("handoff"));
5276 }
5277 other => panic!("expected jump control, got {other:?}"),
5278 }
5279 }
5280
5281 #[test]
5282 fn parse_component_control_rejects_invalid_marker() {
5283 let payload = json!({
5284 "greentic_control": "bad-shape"
5285 });
5286 let err = parse_component_control(&payload).expect_err("expected invalid marker error");
5287 assert!(err.to_string().contains("greentic_control"));
5288 }
5289
5290 #[test]
5291 fn missing_operation_reports_node_and_component() {
5292 let engine = minimal_engine();
5293 let rt = Runtime::new().unwrap();
5294 let retry_config = RetryConfig {
5295 max_attempts: 1,
5296 base_delay_ms: 1,
5297 };
5298 let ctx = FlowContext {
5299 tenant: "tenant",
5300 pack_id: "test-pack",
5301 flow_id: "flow",
5302 node_id: Some("missing-op"),
5303 tool: None,
5304 action: None,
5305 session_id: None,
5306 provider_id: None,
5307 reply_scope: None,
5308 retry_config,
5309 attempt: 1,
5310 observer: None,
5311 mocks: None,
5312 };
5313 let node = HostNode {
5314 kind: NodeKind::Exec {
5315 target_component: "qa.process".into(),
5316 },
5317 component: "component.exec".into(),
5318 component_id: "component.exec".into(),
5319 operation_name: None,
5320 operation_in_mapping: None,
5321 payload_expr: Value::Null,
5322 routing: Routing::End,
5323 vars_out: None,
5324 };
5325 let _state = ExecutionState::new(Value::Null);
5326 let payload = json!({ "component": "qa.process" });
5327 let event = NodeEvent {
5328 context: &ctx,
5329 node_id: "missing-op",
5330 node: &node,
5331 payload: &payload,
5332 };
5333 let err = rt
5334 .block_on(engine.execute_component_exec(
5335 &ctx,
5336 "missing-op",
5337 &node,
5338 payload.clone(),
5339 &event,
5340 ComponentOverrides {
5341 component: None,
5342 operation: None,
5343 },
5344 ))
5345 .unwrap_err();
5346 let message = err.to_string();
5347 assert!(
5348 message.contains("missing operation for node `missing-op`"),
5349 "unexpected message: {message}"
5350 );
5351 assert!(
5352 message.contains("(component `component.exec`)"),
5353 "unexpected message: {message}"
5354 );
5355 }
5356
5357 #[test]
5358 fn missing_operation_mentions_mapping_hint() {
5359 let engine = minimal_engine();
5360 let rt = Runtime::new().unwrap();
5361 let retry_config = RetryConfig {
5362 max_attempts: 1,
5363 base_delay_ms: 1,
5364 };
5365 let ctx = FlowContext {
5366 tenant: "tenant",
5367 pack_id: "test-pack",
5368 flow_id: "flow",
5369 node_id: Some("missing-op-hint"),
5370 tool: None,
5371 action: None,
5372 session_id: None,
5373 provider_id: None,
5374 reply_scope: None,
5375 retry_config,
5376 attempt: 1,
5377 observer: None,
5378 mocks: None,
5379 };
5380 let node = HostNode {
5381 kind: NodeKind::Exec {
5382 target_component: "qa.process".into(),
5383 },
5384 component: "component.exec".into(),
5385 component_id: "component.exec".into(),
5386 operation_name: None,
5387 operation_in_mapping: Some("render".into()),
5388 payload_expr: Value::Null,
5389 routing: Routing::End,
5390 vars_out: None,
5391 };
5392 let _state = ExecutionState::new(Value::Null);
5393 let payload = json!({ "component": "qa.process" });
5394 let event = NodeEvent {
5395 context: &ctx,
5396 node_id: "missing-op-hint",
5397 node: &node,
5398 payload: &payload,
5399 };
5400 let err = rt
5401 .block_on(engine.execute_component_exec(
5402 &ctx,
5403 "missing-op-hint",
5404 &node,
5405 payload.clone(),
5406 &event,
5407 ComponentOverrides {
5408 component: None,
5409 operation: None,
5410 },
5411 ))
5412 .unwrap_err();
5413 let message = err.to_string();
5414 assert!(
5415 message.contains("missing operation for node `missing-op-hint`"),
5416 "unexpected message: {message}"
5417 );
5418 assert!(
5419 message.contains("Found operation in input.mapping (`render`)"),
5420 "unexpected message: {message}"
5421 );
5422 }
5423
5424 struct CountingObserver {
5425 starts: Mutex<Vec<String>>,
5426 ends: Mutex<Vec<Value>>,
5427 }
5428
5429 impl CountingObserver {
5430 fn new() -> Self {
5431 Self {
5432 starts: Mutex::new(Vec::new()),
5433 ends: Mutex::new(Vec::new()),
5434 }
5435 }
5436 }
5437
5438 impl ExecutionObserver for CountingObserver {
5439 fn on_node_start(&self, event: &NodeEvent<'_>) {
5440 self.starts.lock().unwrap().push(event.node_id.to_string());
5441 }
5442
5443 fn on_node_end(&self, _event: &NodeEvent<'_>, output: &Value) {
5444 self.ends.lock().unwrap().push(output.clone());
5445 }
5446
5447 fn on_node_error(&self, _event: &NodeEvent<'_>, _error: &dyn StdError) {}
5448 }
5449
5450 #[test]
5451 fn emits_end_event_for_successful_node() {
5452 let node_id = NodeId::from_str("emit").unwrap();
5453 let node = Node {
5454 id: node_id.clone(),
5455 component: FlowComponentRef {
5456 id: "emit.log".parse().unwrap(),
5457 pack_alias: None,
5458 operation: None,
5459 },
5460 input: InputMapping {
5461 mapping: json!({ "message": "logged" }),
5462 },
5463 output: OutputMapping {
5464 mapping: Value::Null,
5465 },
5466 err_map: None,
5467 routing: Routing::End,
5468 telemetry: TelemetryHints::default(),
5469 conversational: false,
5470 };
5471 let mut nodes = indexmap::IndexMap::default();
5472 nodes.insert(node_id.clone(), node);
5473 let flow = Flow {
5474 schema_version: "1.0".into(),
5475 id: FlowId::from_str("emit.flow").unwrap(),
5476 kind: FlowKind::Messaging,
5477 entrypoints: BTreeMap::from([(
5478 "default".to_string(),
5479 Value::String(node_id.to_string()),
5480 )]),
5481 nodes,
5482 metadata: Default::default(),
5483 };
5484 let host_flow = HostFlow::from(flow);
5485
5486 let engine = FlowEngine {
5487 packs: Vec::new(),
5488 flows: Vec::new(),
5489 flow_sources: HashMap::new(),
5490 messaging_provider_pack_ids: std::collections::HashSet::new(),
5491 flow_cache: RwLock::new(HashMap::from([(
5492 FlowKey {
5493 pack_id: "test-pack".to_string(),
5494 flow_id: "emit.flow".to_string(),
5495 },
5496 host_flow,
5497 )])),
5498 default_env: "local".to_string(),
5499 validation: ValidationConfig {
5500 mode: ValidationMode::Off,
5501 },
5502 cross_pack_resolver: None,
5503 rollout_ids: RolloutIds::default(),
5504 remote_dispatch_handler: None,
5505 #[cfg(feature = "agentic-worker")]
5506 dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
5507 #[cfg(feature = "agentic-worker")]
5508 agent_node_handler: None,
5509 #[cfg(feature = "agentic-worker")]
5510 graph_node_handler: None,
5511 #[cfg(feature = "agentic-worker")]
5512 mcp_tool_source: None,
5513 };
5514 let observer = CountingObserver::new();
5515 let ctx = FlowContext {
5516 tenant: "demo",
5517 pack_id: "test-pack",
5518 flow_id: "emit.flow",
5519 node_id: None,
5520 tool: None,
5521 action: None,
5522 session_id: None,
5523 provider_id: None,
5524 reply_scope: None,
5525 retry_config: RetryConfig {
5526 max_attempts: 1,
5527 base_delay_ms: 1,
5528 },
5529 attempt: 1,
5530 observer: Some(&observer),
5531 mocks: None,
5532 };
5533
5534 let rt = Runtime::new().unwrap();
5535 let result = rt.block_on(engine.execute(ctx, Value::Null)).unwrap();
5536 assert!(matches!(result.status, FlowStatus::Completed));
5537
5538 let starts = observer.starts.lock().unwrap();
5539 let ends = observer.ends.lock().unwrap();
5540 assert_eq!(starts.len(), 1);
5541 assert_eq!(ends.len(), 1);
5542 assert_eq!(ends[0], json!({ "message": "logged" }));
5543 }
5544
5545 #[test]
5546 fn dotted_component_id_with_mapping_operation_is_not_split() {
5547 let node = Node {
5553 id: NodeId::from_str("render").unwrap(),
5554 component: FlowComponentRef {
5555 id: "ai.greentic.component-templates".parse().unwrap(),
5556 pack_alias: None,
5557 operation: None,
5558 },
5559 input: InputMapping {
5560 mapping: json!({ "operation": "handle_message", "input": "hi" }),
5561 },
5562 output: OutputMapping {
5563 mapping: Value::Null,
5564 },
5565 err_map: None,
5566 routing: Routing::End,
5567 telemetry: TelemetryHints::default(),
5568 conversational: false,
5569 };
5570 let host = HostNode::from(node);
5571 assert!(
5572 matches!(&host.kind, NodeKind::PackComponent { component_ref } if component_ref == "ai.greentic.component-templates"),
5573 "dotted component id must stay intact, got kind {:?}",
5574 host.kind
5575 );
5576 assert_eq!(host.component, "ai.greentic.component-templates");
5577 assert_eq!(host.operation_in_mapping(), Some("handle_message"));
5578 }
5579
5580 #[test]
5581 fn packed_component_operation_id_still_splits_without_mapping_operation() {
5582 let node = Node {
5586 id: NodeId::from_str("render").unwrap(),
5587 component: FlowComponentRef {
5588 id: "templating.handlebars".parse().unwrap(),
5589 pack_alias: None,
5590 operation: None,
5591 },
5592 input: InputMapping {
5593 mapping: json!({ "text": "hello" }),
5594 },
5595 output: OutputMapping {
5596 mapping: Value::Null,
5597 },
5598 err_map: None,
5599 routing: Routing::End,
5600 telemetry: TelemetryHints::default(),
5601 conversational: false,
5602 };
5603 let host = HostNode::from(node);
5604 assert!(
5605 matches!(&host.kind, NodeKind::PackComponent { component_ref } if component_ref == "templating"),
5606 "packed <component>.<operation> id must split, got kind {:?}",
5607 host.kind
5608 );
5609 assert_eq!(host.operation_name(), Some("handlebars"));
5610 }
5611
5612 #[cfg(feature = "agentic-worker")]
5613 #[test]
5614 fn dw_agent_node_routes_to_handler_and_returns_reply() {
5615 use crate::runner::agent_node::{AgentNodeHandler, RuntimeAgentNodeHandler};
5616 use greentic_aw_runtime::cost::MockTokenMeter;
5617 use greentic_aw_runtime::llm::LlmResponse;
5618 use greentic_aw_runtime::mock::{
5619 MockAgentStateStore, MockConfigProvider, MockLlmBackend, MockTelemetry, NoopToolLedger,
5620 };
5621 use greentic_aw_runtime::{
5622 AgentConfig, AgentLimits, AgentRuntime, LlmProviderRef, TenantContext,
5623 };
5624
5625 let llm = Arc::new(MockLlmBackend::new(vec![Ok(LlmResponse {
5627 content: Some("pong".into()),
5628 tool_calls: vec![],
5629 tokens_in: 1,
5630 tokens_out: 1,
5631 })]));
5632 let store = Arc::new(MockAgentStateStore::new());
5633 let telemetry = Arc::new(MockTelemetry::new());
5634
5635 let config_provider = MockConfigProvider::new();
5640 let tenant = TenantContext::new("demo", "local");
5641 config_provider.insert(
5642 &tenant,
5643 "greeter",
5644 AgentConfig {
5645 agent_id: "greeter".into(),
5646 system_prompt: "sys".into(),
5647 tools: vec![],
5648 guardrails: vec![],
5649 llm: LlmProviderRef {
5650 provider: "mock".into(),
5651 model: "m".into(),
5652 credential_ref: None,
5653 },
5654 limits: AgentLimits::default(),
5655 memory: None,
5656 knowledge: None,
5657 },
5658 );
5659 let config_provider = Arc::new(config_provider);
5660 let token_meter = Arc::new(MockTokenMeter::new(0));
5661 let ledger = Arc::new(NoopToolLedger);
5662 let ext_runtime = Arc::new(crate::runner::agent_node::test_extension_runtime());
5663 let runtime = Arc::new(AgentRuntime::new(
5664 config_provider,
5665 store,
5666 ext_runtime,
5667 llm,
5668 telemetry,
5669 token_meter,
5670 ledger,
5671 None,
5672 ));
5673 let handler: Arc<dyn AgentNodeHandler> =
5674 Arc::new(RuntimeAgentNodeHandler::new(runtime, None, None));
5675
5676 let node_id = NodeId::from_str("agent").unwrap();
5678 let node = Node {
5679 id: node_id.clone(),
5680 component: FlowComponentRef {
5681 id: "dw.agent".parse().unwrap(),
5682 pack_alias: None,
5683 operation: Some("greeter".to_string()),
5684 },
5685 input: InputMapping {
5686 mapping: json!({ "user_text": "ping" }),
5687 },
5688 output: OutputMapping {
5689 mapping: Value::Null,
5690 },
5691 err_map: None,
5692 routing: Routing::End,
5693 telemetry: TelemetryHints::default(),
5694 conversational: false,
5695 };
5696 let mut nodes = indexmap::IndexMap::default();
5697 nodes.insert(node_id.clone(), node);
5698 let flow = Flow {
5699 schema_version: "1.0".into(),
5700 id: FlowId::from_str("dw.flow").unwrap(),
5701 kind: FlowKind::Messaging,
5702 entrypoints: BTreeMap::from([(
5703 "default".to_string(),
5704 Value::String(node_id.to_string()),
5705 )]),
5706 nodes,
5707 metadata: Default::default(),
5708 };
5709 let host_flow = HostFlow::from(flow);
5710
5711 let engine = FlowEngine {
5712 packs: Vec::new(),
5713 flows: Vec::new(),
5714 flow_sources: HashMap::new(),
5715 messaging_provider_pack_ids: std::collections::HashSet::new(),
5716 flow_cache: RwLock::new(HashMap::from([(
5717 FlowKey {
5718 pack_id: "test-pack".to_string(),
5719 flow_id: "dw.flow".to_string(),
5720 },
5721 host_flow,
5722 )])),
5723 default_env: "local".to_string(),
5724 validation: ValidationConfig {
5725 mode: ValidationMode::Off,
5726 },
5727 cross_pack_resolver: None,
5728 rollout_ids: RolloutIds::default(),
5729 remote_dispatch_handler: None,
5730 #[cfg(feature = "agentic-worker")]
5731 dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
5732 #[cfg(feature = "agentic-worker")]
5733 agent_node_handler: Some(handler),
5734 #[cfg(feature = "agentic-worker")]
5735 graph_node_handler: None,
5736 #[cfg(feature = "agentic-worker")]
5737 mcp_tool_source: None,
5738 };
5739 let ctx = FlowContext {
5740 tenant: "demo",
5741 pack_id: "test-pack",
5742 flow_id: "dw.flow",
5743 node_id: None,
5744 tool: None,
5745 action: None,
5746 session_id: Some("sess-1"),
5747 provider_id: None,
5748 reply_scope: None,
5749 retry_config: RetryConfig {
5750 max_attempts: 1,
5751 base_delay_ms: 1,
5752 },
5753 attempt: 1,
5754 observer: None,
5755 mocks: None,
5756 };
5757
5758 let rt = Runtime::new().unwrap();
5759 let result = rt
5760 .block_on(engine.execute(ctx, json!({ "user_text": "ping" })))
5761 .unwrap();
5762 assert!(matches!(result.status, FlowStatus::Completed));
5763
5764 let output_str = serde_json::to_string(&result.output).unwrap();
5767 assert!(
5768 output_str.contains("pong"),
5769 "expected agent reply in flow output, got: {output_str}"
5770 );
5771 }
5772
5773 #[cfg(feature = "agentic-worker")]
5779 #[test]
5780 fn dw_agent_graph_node_routes_to_handler_and_returns_reply() {
5781 use std::sync::Mutex;
5782
5783 use crate::runner::graph_node::GraphNodeHandler;
5784
5785 struct RecordingGraphHandler {
5787 seen: Mutex<Option<(String, String, String, String)>>,
5788 }
5789
5790 #[async_trait::async_trait]
5791 impl GraphNodeHandler for RecordingGraphHandler {
5792 async fn execute(
5793 &self,
5794 tenant_id: &str,
5795 env_id: &str,
5796 graph_id: &str,
5797 session_id: &str,
5798 _flow_input: &Value,
5799 ) -> Result<Value> {
5800 *self.seen.lock().unwrap() = Some((
5801 tenant_id.to_string(),
5802 env_id.to_string(),
5803 graph_id.to_string(),
5804 session_id.to_string(),
5805 ));
5806 Ok(json!({
5807 "reply": "graph-pong",
5808 "trail": [],
5809 "terminated_by": "respond",
5810 }))
5811 }
5812 }
5813
5814 let handler = Arc::new(RecordingGraphHandler {
5815 seen: Mutex::new(None),
5816 });
5817 let handler_dyn: Arc<dyn GraphNodeHandler> = handler.clone();
5818
5819 let node_id = NodeId::from_str("graph").unwrap();
5821 let node = Node {
5822 id: node_id.clone(),
5823 component: FlowComponentRef {
5824 id: "dw.agent_graph".parse().unwrap(),
5825 pack_alias: None,
5826 operation: Some("triage".to_string()),
5827 },
5828 input: InputMapping {
5829 mapping: json!({ "user_text": "ping" }),
5830 },
5831 output: OutputMapping {
5832 mapping: Value::Null,
5833 },
5834 err_map: None,
5835 routing: Routing::End,
5836 telemetry: TelemetryHints::default(),
5837 conversational: false,
5838 };
5839 let mut nodes = indexmap::IndexMap::default();
5840 nodes.insert(node_id.clone(), node);
5841 let flow = Flow {
5842 schema_version: "1.0".into(),
5843 id: FlowId::from_str("dwg.flow").unwrap(),
5844 kind: FlowKind::Messaging,
5845 entrypoints: BTreeMap::from([(
5846 "default".to_string(),
5847 Value::String(node_id.to_string()),
5848 )]),
5849 nodes,
5850 metadata: Default::default(),
5851 };
5852 let host_flow = HostFlow::from(flow);
5853
5854 let engine = FlowEngine {
5855 packs: Vec::new(),
5856 flows: Vec::new(),
5857 flow_sources: HashMap::new(),
5858 messaging_provider_pack_ids: std::collections::HashSet::new(),
5859 flow_cache: RwLock::new(HashMap::from([(
5860 FlowKey {
5861 pack_id: "test-pack".to_string(),
5862 flow_id: "dwg.flow".to_string(),
5863 },
5864 host_flow,
5865 )])),
5866 default_env: "local".to_string(),
5867 validation: ValidationConfig {
5868 mode: ValidationMode::Off,
5869 },
5870 cross_pack_resolver: None,
5871 rollout_ids: RolloutIds::default(),
5872 remote_dispatch_handler: None,
5873 #[cfg(feature = "agentic-worker")]
5874 dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
5875 #[cfg(feature = "agentic-worker")]
5876 agent_node_handler: None,
5877 #[cfg(feature = "agentic-worker")]
5878 graph_node_handler: Some(handler_dyn),
5879 #[cfg(feature = "agentic-worker")]
5880 mcp_tool_source: None,
5881 };
5882 let ctx = FlowContext {
5883 tenant: "demo",
5884 pack_id: "test-pack",
5885 flow_id: "dwg.flow",
5886 node_id: None,
5887 tool: None,
5888 action: None,
5889 session_id: Some("sess-1"),
5890 provider_id: None,
5891 reply_scope: None,
5892 retry_config: RetryConfig {
5893 max_attempts: 1,
5894 base_delay_ms: 1,
5895 },
5896 attempt: 1,
5897 observer: None,
5898 mocks: None,
5899 };
5900
5901 let rt = Runtime::new().unwrap();
5902 let result = rt
5903 .block_on(engine.execute(ctx, json!({ "user_text": "ping" })))
5904 .unwrap();
5905 assert!(matches!(result.status, FlowStatus::Completed));
5906
5907 let seen = handler.seen.lock().unwrap().clone();
5910 assert_eq!(
5911 seen,
5912 Some((
5913 "demo".to_string(),
5914 "local".to_string(),
5915 "triage".to_string(),
5916 "sess-1".to_string(),
5917 )),
5918 "dw.agent_graph dispatch must mirror dw.agent's tenant/env/graph_id/session derivation"
5919 );
5920
5921 let output_str = serde_json::to_string(&result.output).unwrap();
5922 assert!(
5923 output_str.contains("graph-pong"),
5924 "expected graph reply in flow output, got: {output_str}"
5925 );
5926 }
5927
5928 #[cfg(feature = "agentic-worker")]
5934 #[test]
5935 fn dw_agent_nats_mode_dispatches_remote() {
5936 use std::sync::Mutex;
5937
5938 use crate::runner::agent_node::DwAgentDispatch;
5939 use crate::runner::remote_dispatch::{
5940 RemoteDispatch, RemoteDispatchAction, RemoteDispatchHandler,
5941 };
5942
5943 struct RecordingDispatcher {
5946 seen: Mutex<Option<RemoteDispatch>>,
5947 }
5948
5949 #[async_trait::async_trait]
5950 impl RemoteDispatchHandler for RecordingDispatcher {
5951 async fn dispatch(
5952 &self,
5953 request: RemoteDispatch,
5954 ) -> anyhow::Result<RemoteDispatchAction> {
5955 let corr = request.correlation_id.clone();
5956 *self.seen.lock().unwrap() = Some(request);
5957 Ok(RemoteDispatchAction::AwaitingResponse {
5958 correlation_id: corr,
5959 })
5960 }
5961 }
5962
5963 let dispatcher = Arc::new(RecordingDispatcher {
5964 seen: Mutex::new(None),
5965 });
5966
5967 let resume_id = NodeId::from_str("after-agent").unwrap();
5972 let node_id = NodeId::from_str("agent-nats").unwrap();
5973 let agent_node = Node {
5974 id: node_id.clone(),
5975 component: FlowComponentRef {
5976 id: "dw.agent".parse().unwrap(),
5977 pack_alias: None,
5978 operation: Some("greeter".to_string()),
5979 },
5980 input: InputMapping {
5981 mapping: json!({ "user_text": "hi" }),
5982 },
5983 output: OutputMapping {
5984 mapping: Value::Null,
5985 },
5986 err_map: None,
5987 routing: Routing::Next {
5988 node_id: resume_id.clone(),
5989 },
5990 telemetry: TelemetryHints::default(),
5991 conversational: false,
5992 };
5993 let resume_node = Node {
5994 id: resume_id.clone(),
5995 component: FlowComponentRef {
5996 id: "emit.log".parse().unwrap(),
5997 pack_alias: None,
5998 operation: None,
5999 },
6000 input: InputMapping {
6001 mapping: json!({ "message": "done" }),
6002 },
6003 output: OutputMapping {
6004 mapping: Value::Null,
6005 },
6006 err_map: None,
6007 routing: Routing::End,
6008 telemetry: TelemetryHints::default(),
6009 conversational: false,
6010 };
6011 let mut nodes = indexmap::IndexMap::default();
6012 nodes.insert(node_id.clone(), agent_node);
6013 nodes.insert(resume_id.clone(), resume_node);
6014 let flow = Flow {
6015 schema_version: "1.0".into(),
6016 id: FlowId::from_str("nats-agent.flow").unwrap(),
6017 kind: FlowKind::Messaging,
6018 entrypoints: BTreeMap::from([(
6019 "default".to_string(),
6020 Value::String(node_id.to_string()),
6021 )]),
6022 nodes,
6023 metadata: Default::default(),
6024 };
6025 let host_flow = HostFlow::from(flow);
6026
6027 let engine = FlowEngine {
6028 packs: Vec::new(),
6029 flows: Vec::new(),
6030 flow_sources: HashMap::new(),
6031 messaging_provider_pack_ids: std::collections::HashSet::new(),
6032 flow_cache: RwLock::new(HashMap::from([(
6033 FlowKey {
6034 pack_id: "test-pack".to_string(),
6035 flow_id: "nats-agent.flow".to_string(),
6036 },
6037 host_flow,
6038 )])),
6039 default_env: "local".to_string(),
6040 validation: ValidationConfig {
6041 mode: ValidationMode::Off,
6042 },
6043 cross_pack_resolver: None,
6044 rollout_ids: RolloutIds::default(),
6045 remote_dispatch_handler: Some(dispatcher.clone() as Arc<dyn crate::runner::remote_dispatch::RemoteDispatchHandler>),
6046 #[cfg(feature = "agentic-worker")]
6047 dw_agent_dispatch: DwAgentDispatch::Nats,
6048 #[cfg(feature = "agentic-worker")]
6049 agent_node_handler: None,
6051 #[cfg(feature = "agentic-worker")]
6052 graph_node_handler: None,
6053 #[cfg(feature = "agentic-worker")]
6054 mcp_tool_source: None,
6055 };
6056
6057 let ctx = FlowContext {
6058 tenant: "demo",
6059 pack_id: "test-pack",
6060 flow_id: "nats-agent.flow",
6061 node_id: None,
6062 tool: None,
6063 action: None,
6064 session_id: Some("sess-nats"),
6065 provider_id: None,
6066 reply_scope: None,
6067 retry_config: RetryConfig {
6068 max_attempts: 1,
6069 base_delay_ms: 1,
6070 },
6071 attempt: 1,
6072 observer: None,
6073 mocks: None,
6074 };
6075
6076 let rt = Runtime::new().unwrap();
6077 let result = rt
6078 .block_on(engine.execute(ctx, json!({ "user_text": "hi" })))
6079 .unwrap();
6080
6081 assert!(
6083 matches!(result.status, FlowStatus::Waiting(_)),
6084 "expected Waiting outcome from dw.agent Nats mode, got: {:?}",
6085 result.status
6086 );
6087
6088 let seen = dispatcher.seen.lock().unwrap();
6091 let dispatch = seen.as_ref().expect("dispatcher was not called");
6092 assert_eq!(
6093 dispatch.runtime, "agentic",
6094 "runtime name must be 'agentic'"
6095 );
6096 assert_eq!(dispatch.target, "greeter", "target must be the agent_id");
6097 assert_eq!(
6098 dispatch.input,
6099 json!({ "user_text": "hi" }),
6100 "node payload must be forwarded as dispatch input"
6101 );
6102 }
6103
6104 fn host_flow_for_test(
6105 flow_id: &str,
6106 node_ids: &[&str],
6107 default_start: Option<&str>,
6108 ) -> HostFlow {
6109 let mut nodes = indexmap::IndexMap::default();
6110 for node_id in node_ids {
6111 let id = NodeId::from_str(node_id).unwrap();
6112 let node = Node {
6113 id: id.clone(),
6114 component: FlowComponentRef {
6115 id: "emit.log".parse().unwrap(),
6116 pack_alias: None,
6117 operation: None,
6118 },
6119 input: InputMapping {
6120 mapping: json!({ "message": node_id }),
6121 },
6122 output: OutputMapping {
6123 mapping: Value::Null,
6124 },
6125 err_map: None,
6126 routing: Routing::End,
6127 telemetry: TelemetryHints::default(),
6128 conversational: false,
6129 };
6130 nodes.insert(id, node);
6131 }
6132 let mut entrypoints = BTreeMap::new();
6133 if let Some(start) = default_start {
6134 entrypoints.insert("default".to_string(), Value::String(start.to_string()));
6135 }
6136 HostFlow::from(Flow {
6137 schema_version: "1.0".into(),
6138 id: FlowId::from_str(flow_id).unwrap(),
6139 kind: FlowKind::Messaging,
6140 entrypoints,
6141 nodes,
6142 metadata: Default::default(),
6143 })
6144 }
6145
6146 fn jump_test_engine() -> FlowEngine {
6147 let target_flow = host_flow_for_test("flow.target", &["node-a", "node-b"], None);
6148 FlowEngine {
6149 packs: Vec::new(),
6150 flows: Vec::new(),
6151 flow_sources: HashMap::new(),
6152 messaging_provider_pack_ids: std::collections::HashSet::new(),
6153 flow_cache: RwLock::new(HashMap::from([(
6154 FlowKey {
6155 pack_id: "test-pack".to_string(),
6156 flow_id: "flow.target".to_string(),
6157 },
6158 target_flow,
6159 )])),
6160 default_env: "local".to_string(),
6161 validation: ValidationConfig {
6162 mode: ValidationMode::Off,
6163 },
6164 cross_pack_resolver: None,
6165 rollout_ids: RolloutIds::default(),
6166 remote_dispatch_handler: None,
6167 #[cfg(feature = "agentic-worker")]
6168 dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
6169 #[cfg(feature = "agentic-worker")]
6170 agent_node_handler: None,
6171 #[cfg(feature = "agentic-worker")]
6172 graph_node_handler: None,
6173 #[cfg(feature = "agentic-worker")]
6174 mcp_tool_source: None,
6175 }
6176 }
6177
6178 fn jump_ctx<'a>(flow_id: &'a str) -> FlowContext<'a> {
6179 FlowContext {
6180 tenant: "demo",
6181 pack_id: "test-pack",
6182 flow_id,
6183 node_id: None,
6184 tool: None,
6185 action: None,
6186 session_id: None,
6187 provider_id: None,
6188 reply_scope: None,
6189 retry_config: RetryConfig {
6190 max_attempts: 1,
6191 base_delay_ms: 1,
6192 },
6193 attempt: 1,
6194 observer: None,
6195 mocks: None,
6196 }
6197 }
6198
6199 #[test]
6200 fn with_rollout_ids_binds_revision_identity() {
6201 let engine = minimal_engine().with_rollout_ids(RolloutIds {
6202 customer_id: Some("cust-acme".into()),
6203 deployment_id: Some("01JTKS".into()),
6204 bundle_id: Some("customer.support".into()),
6205 revision_id: Some("01JTKR".into()),
6206 });
6207 assert_eq!(engine.rollout_ids.revision_id.as_deref(), Some("01JTKR"));
6208 assert_eq!(engine.rollout_ids.deployment_id.as_deref(), Some("01JTKS"));
6209 assert!(minimal_engine().rollout_ids.is_empty());
6211 }
6212
6213 #[test]
6222 fn a_flow_goto_node_produces_a_jump_the_engine_applies() {
6223 let outcome = execute_flow_goto(json!({
6224 "flow_id": "flow.target",
6225 "input": { "order": "A-1" },
6226 }))
6227 .expect("goto builds");
6228 let NodeControl::Jump(jump) = outcome.control else {
6229 panic!("expected a Jump");
6230 };
6231
6232 let engine = jump_test_engine();
6233 let mut state = ExecutionState::new(Value::Null);
6234 let rt = Runtime::new().unwrap();
6235 let target = rt
6236 .block_on(engine.apply_jump(&jump_ctx("flow.source"), &mut state, jump))
6237 .expect("the engine must accept the jump a flow.goto node builds");
6238
6239 assert_eq!(target.flow_id, "flow.target");
6240 assert_eq!(
6241 target.node_id.as_str(),
6242 "node-a",
6243 "no explicit node means the target flow's first node"
6244 );
6245 assert_eq!(
6246 state.redirect_count(),
6247 1,
6248 "the loop guard must have counted"
6249 );
6250 }
6251
6252 #[test]
6255 fn a_flow_goto_node_can_name_the_entry_node() {
6256 let outcome = execute_flow_goto(json!({ "flow_id": "flow.target", "node": "node-b" }))
6257 .expect("goto builds");
6258 let NodeControl::Jump(jump) = outcome.control else {
6259 panic!("expected a Jump");
6260 };
6261
6262 let engine = jump_test_engine();
6263 let mut state = ExecutionState::new(Value::Null);
6264 let rt = Runtime::new().unwrap();
6265 let target = rt
6266 .block_on(engine.apply_jump(&jump_ctx("flow.source"), &mut state, jump))
6267 .expect("jump applies");
6268 assert_eq!(target.node_id.as_str(), "node-b");
6269 }
6270
6271 #[test]
6272 fn apply_jump_unknown_flow_errors() {
6273 let engine = minimal_engine();
6274 let mut state = ExecutionState::new(Value::Null);
6275 let rt = Runtime::new().unwrap();
6276 let err = rt
6277 .block_on(engine.apply_jump(
6278 &jump_ctx("flow.source"),
6279 &mut state,
6280 JumpControl {
6281 flow: "flow.missing".into(),
6282 node: None,
6283 payload: json!({ "ok": true }),
6284 hints: Value::Null,
6285 max_redirects: None,
6286 reason: None,
6287 },
6288 ))
6289 .unwrap_err();
6290 assert!(
6291 err.to_string().contains("unknown_flow"),
6292 "unexpected error: {err}"
6293 );
6294 }
6295
6296 #[test]
6297 fn apply_jump_unknown_node_errors() {
6298 let engine = jump_test_engine();
6299 let mut state = ExecutionState::new(Value::Null);
6300 let rt = Runtime::new().unwrap();
6301 let err = rt
6302 .block_on(engine.apply_jump(
6303 &jump_ctx("flow.source"),
6304 &mut state,
6305 JumpControl {
6306 flow: "flow.target".into(),
6307 node: Some("node-missing".into()),
6308 payload: json!({ "ok": true }),
6309 hints: Value::Null,
6310 max_redirects: None,
6311 reason: None,
6312 },
6313 ))
6314 .unwrap_err();
6315 assert!(
6316 err.to_string().contains("unknown_node"),
6317 "unexpected error: {err}"
6318 );
6319 }
6320
6321 #[test]
6322 fn apply_jump_uses_default_start_fallback() {
6323 let engine = jump_test_engine();
6324 let mut state = ExecutionState::new(Value::Null);
6325 let rt = Runtime::new().unwrap();
6326 let target = rt
6327 .block_on(engine.apply_jump(
6328 &jump_ctx("flow.source"),
6329 &mut state,
6330 JumpControl {
6331 flow: "flow.target".into(),
6332 node: None,
6333 payload: json!({ "k": "v" }),
6334 hints: Value::Null,
6335 max_redirects: None,
6336 reason: None,
6337 },
6338 ))
6339 .expect("jump target");
6340 assert_eq!(target.flow_id, "flow.target");
6341 assert_eq!(target.node_id.as_str(), "node-a");
6342 }
6343
6344 #[test]
6345 fn apply_jump_redirect_limit_enforced() {
6346 let engine = jump_test_engine();
6347 let mut state = ExecutionState::new(Value::Null);
6348 state.redirect_count = 3;
6349 let rt = Runtime::new().unwrap();
6350 let err = rt
6351 .block_on(engine.apply_jump(
6352 &jump_ctx("flow.source"),
6353 &mut state,
6354 JumpControl {
6355 flow: "flow.target".into(),
6356 node: None,
6357 payload: json!({ "k": "v" }),
6358 hints: Value::Null,
6359 max_redirects: Some(3),
6360 reason: None,
6361 },
6362 ))
6363 .unwrap_err();
6364 assert_eq!(err.to_string(), "redirect_limit");
6365 }
6366
6367 #[test]
6374 fn evaluate_custom_routing_waits_when_conditional_falls_through() {
6375 let raw_routing = json!([
6376 { "condition": "response.action == \"go\"", "to": "next" },
6377 { "out": true }
6378 ]);
6379 let flow_ir = HostFlow {
6380 id: "flow.test".to_string(),
6381 start: None,
6382 nodes: IndexMap::new(),
6383 slot_schema: None,
6384 vars_init: JsonMap::new(),
6385 };
6386 let current_node = NodeId::from_str("current").unwrap();
6387 let output = NodeOutput::new(Value::Null);
6388
6389 let mut state_empty = ExecutionState::new(json!({ "metadata": { "action": "" } }));
6391 state_empty.entry = json!({ "metadata": { "action": "" } });
6392 let decision_empty =
6393 evaluate_custom_routing(&raw_routing, &output, &state_empty, &flow_ir, ¤t_node);
6394 assert!(
6395 matches!(decision_empty, CustomRoutingDecision::Wait),
6396 "expected Wait on conditional fall-through, got {decision_empty:?}"
6397 );
6398
6399 let mut state_go = ExecutionState::new(json!({ "metadata": { "action": "go" } }));
6401 state_go.entry = json!({ "metadata": { "action": "go" } });
6402 let decision_go =
6403 evaluate_custom_routing(&raw_routing, &output, &state_go, &flow_ir, ¤t_node);
6404 match decision_go {
6405 CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "next"),
6406 other => panic!("expected Next(\"next\"), got {other:?}"),
6407 }
6408 }
6409
6410 #[test]
6411 fn node_output_with_error_marks_ok_false_and_stashes_in_meta() {
6412 let err: Box<dyn std::error::Error + 'static> =
6413 Box::<dyn std::error::Error + 'static>::from("weatherapi returned 401 Unauthorized");
6414 let out = NodeOutput::with_error("call_weather", err.as_ref());
6415 assert!(!out.ok);
6416 assert_eq!(out.payload, Value::Null);
6417 assert_eq!(out.meta["error"]["kind"], "flow_node_failed");
6418 assert_eq!(out.meta["error"]["node_id"], "call_weather");
6419 assert_eq!(
6420 out.meta["error"]["message"],
6421 "weatherapi returned 401 Unauthorized"
6422 );
6423 }
6424
6425 #[test]
6433 fn a_failed_mcp_call_marks_the_node_not_ok() {
6434 let result = json!({ "error": "MCP is not configured on this runner" });
6435 let bound = json!({ "quote_result_data": result.clone() });
6436
6437 let out = mcp_output(bound.clone(), &result);
6438
6439 assert!(!out.ok, "a failed MCP call must not report ok");
6440 assert_eq!(
6441 out.meta.pointer("/error/message").and_then(Value::as_str),
6442 Some("MCP is not configured on this runner"),
6443 "the message must reach meta.error where the lift reads it, got {:?}",
6444 out.meta
6445 );
6446 assert_eq!(
6447 out.payload, bound,
6448 "the bound payload must be untouched — routing and any flow \
6449 reading the bound value must behave exactly as before"
6450 );
6451 }
6452
6453 #[test]
6455 fn a_successful_mcp_call_stays_ok() {
6456 let result = json!({ "annual_premium": 1234 });
6457 let bound = json!({ "quote_result_data": result.clone() });
6458
6459 let out = mcp_output(bound.clone(), &result);
6460
6461 assert!(out.ok, "a successful MCP call must report ok");
6462 assert_eq!(out.payload, bound);
6463 assert!(
6464 out.meta.get("error").is_none(),
6465 "a successful call must not stash an error, got {:?}",
6466 out.meta
6467 );
6468 }
6469
6470 #[test]
6471 fn lift_first_node_error_promotes_node_meta_to_output_metadata() {
6472 let mut nodes: HashMap<String, NodeOutput> = HashMap::new();
6477 let err: Box<dyn std::error::Error + 'static> =
6478 Box::<dyn std::error::Error + 'static>::from("weatherapi returned 401 Unauthorized");
6479 nodes.insert(
6480 "call_weather".to_string(),
6481 NodeOutput::with_error("call_weather", err.as_ref()),
6482 );
6483 nodes.insert(
6484 "render_current_card".to_string(),
6485 NodeOutput::new(json!({ "text": "message" })),
6486 );
6487
6488 let final_output = json!({ "text": "message" });
6489 let enriched = lift_first_node_error_from_nodes(final_output, &nodes);
6490 assert_eq!(
6491 enriched["metadata"]["error_kind"], "flow_node_failed",
6492 "first failing node's kind must be lifted"
6493 );
6494 assert_eq!(
6495 enriched["metadata"]["error_message"],
6496 "weatherapi returned 401 Unauthorized"
6497 );
6498 assert_eq!(enriched["metadata"]["node_id"], "call_weather");
6499 assert_eq!(enriched["text"], "message");
6502 }
6503
6504 #[test]
6505 fn lift_first_node_error_is_noop_when_all_nodes_ok() {
6506 let mut nodes: HashMap<String, NodeOutput> = HashMap::new();
6507 nodes.insert(
6508 "ok_node".to_string(),
6509 NodeOutput::new(json!({ "text": "all good" })),
6510 );
6511 let output = json!({ "text": "all good" });
6512 let lifted = lift_first_node_error_from_nodes(output.clone(), &nodes);
6513 assert_eq!(lifted, output);
6514 }
6515
6516 #[tokio::test]
6517 async fn execute_user_facing_flow_failure_returns_completed_with_error_envelope() {
6518 let flow_id_str = "broken.flow";
6523 let pack_id_str = "test-pack";
6524 let host_flow = host_flow_for_test(flow_id_str, &["only-node"], Some("does-not-exist"));
6525 let engine = FlowEngine {
6526 packs: Vec::new(),
6527 flows: Vec::new(),
6528 flow_sources: HashMap::new(),
6529 messaging_provider_pack_ids: std::collections::HashSet::new(),
6530 flow_cache: RwLock::new(HashMap::from([(
6531 FlowKey {
6532 pack_id: pack_id_str.to_string(),
6533 flow_id: flow_id_str.to_string(),
6534 },
6535 host_flow,
6536 )])),
6537 default_env: "local".to_string(),
6538 validation: ValidationConfig {
6539 mode: ValidationMode::Off,
6540 },
6541 cross_pack_resolver: None,
6542 rollout_ids: RolloutIds::default(),
6543 remote_dispatch_handler: None,
6544 #[cfg(feature = "agentic-worker")]
6545 dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
6546 #[cfg(feature = "agentic-worker")]
6547 agent_node_handler: None,
6548 #[cfg(feature = "agentic-worker")]
6549 graph_node_handler: None,
6550 #[cfg(feature = "agentic-worker")]
6551 mcp_tool_source: None,
6552 };
6553 let ctx = FlowContext {
6554 tenant: "demo",
6555 pack_id: pack_id_str,
6556 flow_id: flow_id_str,
6557 node_id: None,
6558 tool: None,
6559 action: None,
6560 session_id: Some("conv-1"),
6561 provider_id: None,
6562 reply_scope: None,
6563 retry_config: RetryConfig {
6564 max_attempts: 1,
6565 base_delay_ms: 1,
6566 },
6567 attempt: 1,
6568 observer: None,
6569 mocks: None,
6570 };
6571 let result = engine
6572 .execute(ctx, Value::Null)
6573 .await
6574 .expect("must not propagate Err");
6575 assert!(matches!(result.status, FlowStatus::Completed));
6576 assert_eq!(
6577 result.output["metadata"]["error_kind"],
6578 "flow_execution_failed"
6579 );
6580 let msg = result.output["metadata"]["error_message"]
6581 .as_str()
6582 .unwrap_or("");
6583 assert!(!msg.is_empty(), "error_message must be populated");
6584 assert_eq!(result.output["metadata"]["flow_id"], "broken.flow");
6585 }
6586
6587 #[test]
6588 fn mcp_tool_error_recognises_generator_error_shape() {
6589 let value = json!({
6592 "error": {
6593 "code": "tool_error",
6594 "message": "API request returned status 401",
6595 "status": 401
6596 }
6597 });
6598 let (code, message) = mcp_tool_error(&value).expect("must detect MCP error shape");
6599 assert_eq!(code, "tool_error");
6600 assert!(message.contains("API request returned status 401"));
6601 assert!(message.contains("(status 401)"));
6602 }
6603
6604 #[test]
6605 fn mcp_tool_error_skips_success_responses() {
6606 let value = json!({ "result": { "current": { "temp_c": 19.0 } } });
6608 assert!(mcp_tool_error(&value).is_none());
6609 }
6610
6611 #[test]
6612 fn mcp_tool_error_skips_non_object_and_unrelated_shapes() {
6613 assert!(mcp_tool_error(&Value::Null).is_none());
6614 assert!(mcp_tool_error(&json!({"unrelated": true})).is_none());
6615 assert!(mcp_tool_error(&json!({"error": "oops"})).is_none());
6617 }
6618
6619 #[tokio::test]
6620 async fn execute_non_user_facing_flow_failure_still_propagates() {
6621 let flow_id_str = "broken.flow";
6624 let pack_id_str = "test-pack";
6625 let host_flow = host_flow_for_test(flow_id_str, &["only-node"], Some("does-not-exist"));
6626 let engine = FlowEngine {
6627 packs: Vec::new(),
6628 flows: Vec::new(),
6629 flow_sources: HashMap::new(),
6630 messaging_provider_pack_ids: std::collections::HashSet::new(),
6631 flow_cache: RwLock::new(HashMap::from([(
6632 FlowKey {
6633 pack_id: pack_id_str.to_string(),
6634 flow_id: flow_id_str.to_string(),
6635 },
6636 host_flow,
6637 )])),
6638 default_env: "local".to_string(),
6639 validation: ValidationConfig {
6640 mode: ValidationMode::Off,
6641 },
6642 cross_pack_resolver: None,
6643 rollout_ids: RolloutIds::default(),
6644 remote_dispatch_handler: None,
6645 #[cfg(feature = "agentic-worker")]
6646 dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
6647 #[cfg(feature = "agentic-worker")]
6648 agent_node_handler: None,
6649 #[cfg(feature = "agentic-worker")]
6650 graph_node_handler: None,
6651 #[cfg(feature = "agentic-worker")]
6652 mcp_tool_source: None,
6653 };
6654 let ctx = FlowContext {
6655 tenant: "demo",
6656 pack_id: pack_id_str,
6657 flow_id: flow_id_str,
6658 node_id: None,
6659 tool: None,
6660 action: None,
6661 session_id: None,
6662 provider_id: None,
6663 reply_scope: None,
6664 retry_config: RetryConfig {
6665 max_attempts: 1,
6666 base_delay_ms: 1,
6667 },
6668 attempt: 1,
6669 observer: None,
6670 mocks: None,
6671 };
6672 let result = engine.execute(ctx, Value::Null).await;
6673 assert!(result.is_err(), "non-user-facing flow must propagate Err");
6674 }
6675
6676 #[test]
6679 fn host_flow_extracts_slot_schema_from_metadata_extra() {
6680 use greentic_types::FlowMetadata;
6681 use std::collections::BTreeSet;
6682
6683 let schema = json!([
6684 {"name": "counterparty", "slot_type": "string", "required": true},
6685 {"name": "due_date", "slot_type": "date", "required": true}
6686 ]);
6687 let flow = Flow {
6688 schema_version: "flow-v1".into(),
6689 id: FlowId::from_str("test.flow").unwrap(),
6690 kind: FlowKind::Messaging,
6691 entrypoints: BTreeMap::new(),
6692 nodes: IndexMap::default(),
6693 metadata: FlowMetadata {
6694 title: None,
6695 description: None,
6696 tags: BTreeSet::new(),
6697 extra: json!({(SLOT_SCHEMA_METADATA_KEY): schema}),
6698 },
6699 };
6700 let host = HostFlow::from(flow);
6701 assert_eq!(
6702 host.slot_schema.as_ref(),
6703 Some(&schema),
6704 "HostFlow must extract slot_schema from metadata.extra"
6705 );
6706 }
6707
6708 #[test]
6709 fn host_flow_slot_schema_is_none_when_absent() {
6710 let flow = Flow {
6711 schema_version: "flow-v1".into(),
6712 id: FlowId::from_str("test.flow").unwrap(),
6713 kind: FlowKind::Messaging,
6714 entrypoints: BTreeMap::new(),
6715 nodes: IndexMap::default(),
6716 metadata: Default::default(),
6717 };
6718 let host = HostFlow::from(flow);
6719 assert!(
6720 host.slot_schema.is_none(),
6721 "HostFlow.slot_schema must be None when metadata.extra has no greentic.slot_schema"
6722 );
6723 }
6724
6725 #[test]
6726 fn inject_slot_definitions_adds_to_object_input() {
6727 let schema = json!([
6728 {"name": "city", "slot_type": "string"}
6729 ]);
6730 let mut input = json!({"utterance": "hello"});
6731 inject_slot_definitions(&mut input, &schema, "f", "n");
6732 assert_eq!(
6733 input,
6734 json!({"utterance": "hello", "slot_definitions": schema}),
6735 "slot_definitions must be injected into existing object"
6736 );
6737 }
6738
6739 #[test]
6740 fn inject_slot_definitions_wraps_null_input() {
6741 let schema = json!([{"name": "x", "slot_type": "string"}]);
6742 let mut input = Value::Null;
6743 inject_slot_definitions(&mut input, &schema, "f", "n");
6744 assert_eq!(
6745 input,
6746 json!({"slot_definitions": schema}),
6747 "null input must become an object with slot_definitions"
6748 );
6749 }
6750
6751 #[test]
6752 fn inject_slot_definitions_preserves_explicit_inline() {
6753 let flow_schema = json!([{"name": "city", "slot_type": "string"}]);
6754 let inline_defs = json!([{"name": "country", "slot_type": "string"}]);
6755 let mut input = json!({
6756 "utterance": "hello",
6757 "slot_definitions": inline_defs
6758 });
6759 inject_slot_definitions(&mut input, &flow_schema, "f", "n");
6760 assert_eq!(
6761 input["slot_definitions"], inline_defs,
6762 "explicit inline slot_definitions must not be overwritten"
6763 );
6764 }
6765
6766 #[test]
6767 fn inject_slot_definitions_skips_non_object_input() {
6768 let schema = json!([{"name": "x", "slot_type": "string"}]);
6769 let mut input = json!("a string");
6770 inject_slot_definitions(&mut input, &schema, "f", "n");
6771 assert_eq!(
6772 input,
6773 json!("a string"),
6774 "non-object input must be left unchanged"
6775 );
6776 }
6777
6778 fn make_flow_doc_for_test(
6779 id: &str,
6780 node_name: &str,
6781 component: &str,
6782 slot_schema: Option<Value>,
6783 ) -> greentic_flow::model::FlowDoc {
6784 use greentic_flow::model::{FlowDoc, NodeDoc};
6785
6786 let mut nodes = IndexMap::new();
6787 nodes.insert(
6788 node_name.to_string(),
6789 NodeDoc {
6790 raw: {
6791 let mut m = IndexMap::new();
6792 m.insert(
6793 "component.exec".to_string(),
6794 json!({ "component": component }),
6795 );
6796 m
6797 },
6798 routing: json!([{ "out": true }]),
6799 ..Default::default()
6800 },
6801 );
6802
6803 FlowDoc {
6804 id: id.into(),
6805 title: None,
6806 description: None,
6807 flow_type: "messaging".into(),
6808 start: Some(node_name.into()),
6809 parameters: json!({}),
6810 tags: Vec::new(),
6811 schema_version: None,
6812 entrypoints: IndexMap::new(),
6813 meta: None,
6814 slot_schema,
6815 nodes,
6816 }
6817 }
6818
6819 #[test]
6825 fn compile_flow_round_trips_slot_schema_into_host_flow() {
6826 let slot_defs = json!([
6827 { "name": "counterparty", "slot_type": "string", "required": true,
6828 "pattern": ".+" },
6829 { "name": "due_date", "slot_type": "date", "required": true,
6830 "pattern": "\\d{4}-\\d{2}-\\d{2}" }
6831 ]);
6832 let doc = make_flow_doc_for_test(
6833 "slot-test",
6834 "extractor",
6835 "slot-extractor",
6836 Some(slot_defs.clone()),
6837 );
6838
6839 let flow = greentic_flow::compile_flow(doc).expect("compile_flow must succeed");
6840 assert_eq!(
6841 flow.metadata.extra.get(SLOT_SCHEMA_METADATA_KEY),
6842 Some(&slot_defs),
6843 "compile_flow must forward slot_schema into metadata.extra"
6844 );
6845
6846 let host = HostFlow::from(flow);
6847 assert_eq!(
6848 host.slot_schema.as_ref(),
6849 Some(&slot_defs),
6850 "HostFlow.slot_schema must survive the compile_flow -> HostFlow round-trip"
6851 );
6852 }
6853
6854 #[test]
6858 fn compile_flow_without_slot_schema_leaves_host_flow_none() {
6859 let doc = make_flow_doc_for_test("no-slots", "echo", "echo", None);
6860
6861 let flow = greentic_flow::compile_flow(doc).expect("compile_flow must succeed");
6862 assert!(
6863 flow.metadata.extra.get(SLOT_SCHEMA_METADATA_KEY).is_none(),
6864 "metadata.extra must not contain greentic.slot_schema when FlowDoc.slot_schema is None"
6865 );
6866
6867 let host = HostFlow::from(flow);
6868 assert!(
6869 host.slot_schema.is_none(),
6870 "HostFlow.slot_schema must be None when FlowDoc has no slot_schema"
6871 );
6872 }
6873
6874 #[test]
6875 fn multi_edge_node_routes_on_injected_event() {
6876 let raw_routing = json!([
6877 { "condition": "event == \"on_success\"", "to": "next" },
6878 { "condition": "event == \"on_error\"", "to": "err" }
6879 ]);
6880 let flow_ir = HostFlow {
6881 id: "flow.test".to_string(),
6882 start: None,
6883 nodes: IndexMap::new(),
6884 slot_schema: None,
6885 vars_init: JsonMap::new(),
6886 };
6887 let current = NodeId::from_str("current").unwrap();
6888 let state = ExecutionState::new(json!({}));
6889
6890 let ok_out = NodeOutput::new(json!({ "x": 1 }));
6892 match evaluate_custom_routing(&raw_routing, &ok_out, &state, &flow_ir, ¤t) {
6893 CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "next"),
6894 other => panic!("expected Next(\"next\"), got {other:?}"),
6895 }
6896
6897 let routed = NodeOutput::with_meta(json!({}), json!({ "outcome": "on_error" }));
6899 match evaluate_custom_routing(&raw_routing, &routed, &state, &flow_ir, ¤t) {
6900 CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "err"),
6901 other => panic!("expected Next(\"err\"), got {other:?}"),
6902 }
6903 }
6904
6905 #[test]
6909 fn errored_output_routes_to_on_error_branch() {
6910 let raw_routing = json!([
6911 { "condition": "event == \"on_success\"", "to": "ok_node" },
6912 { "condition": "event == \"on_error\"", "to": "err_node" }
6913 ]);
6914 let flow_ir = HostFlow {
6915 id: "flow.test".to_string(),
6916 start: None,
6917 nodes: IndexMap::new(),
6918 slot_schema: None,
6919 vars_init: JsonMap::new(),
6920 };
6921 let current = NodeId::from_str("current").unwrap();
6922 let state = ExecutionState::new(json!({}));
6923
6924 let errored =
6925 NodeOutput::errored(json!({ "ok": false, "error": { "code": "E", "message": "m" } }));
6926 match evaluate_custom_routing(&raw_routing, &errored, &state, &flow_ir, ¤t) {
6927 CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "err_node"),
6928 other => panic!("expected on_error route, got {other:?}"),
6929 }
6930 }
6931
6932 #[test]
6933 fn node_has_error_route_detects_error_family_ports() {
6934 let with_err = Routing::Custom(json!([
6935 { "condition": "event == \"on_success\"", "to": "n" },
6936 { "condition": "event == \"on_error\"", "to": "e" }
6937 ]));
6938 assert!(
6939 node_has_error_route(&with_err),
6940 "on_error route must be detected"
6941 );
6942
6943 let only_success = Routing::Custom(json!([
6944 { "condition": "event == \"on_success\"", "to": "n" }
6945 ]));
6946 assert!(
6947 !node_has_error_route(&only_success),
6948 "a success-only Custom routing has no error branch"
6949 );
6950
6951 let plain = Routing::Next {
6952 node_id: NodeId::from_str("n").unwrap(),
6953 };
6954 assert!(
6955 !node_has_error_route(&plain),
6956 "Routing::Next has no error branch"
6957 );
6958 }
6959
6960 #[test]
6969 fn success_default_matches_available_outcome_port() {
6970 let flow_ir = HostFlow {
6971 id: "flow.test".to_string(),
6972 start: None,
6973 nodes: IndexMap::new(),
6974 slot_schema: None,
6975 vars_init: JsonMap::new(),
6976 };
6977 let current = NodeId::from_str("current").unwrap();
6978 let state = ExecutionState::new(json!({}));
6979 let ok_out = NodeOutput::new(json!({ "answer": "hi" }));
6981
6982 let on_complete_routing = json!([
6984 { "condition": "event == \"on_complete\"", "to": "next" },
6985 { "condition": "event == \"on_cancel\"", "to": "cancelled" }
6986 ]);
6987 match evaluate_custom_routing(&on_complete_routing, &ok_out, &state, &flow_ir, ¤t) {
6988 CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "next"),
6989 other => panic!("expected Next(\"next\") via on_complete default, got {other:?}"),
6990 }
6991
6992 let on_submit_routing = json!([
6994 { "condition": "event == \"on_submit\"", "to": "saved" },
6995 { "condition": "event == \"on_cancel\"", "to": "cancelled" }
6996 ]);
6997 match evaluate_custom_routing(&on_submit_routing, &ok_out, &state, &flow_ir, ¤t) {
6998 CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "saved"),
6999 other => panic!("expected Next(\"saved\") via on_submit default, got {other:?}"),
7000 }
7001
7002 let on_success_routing = json!([
7005 { "condition": "event == \"on_success\"", "to": "ok" },
7006 { "condition": "event == \"on_error\"", "to": "err" }
7007 ]);
7008 match evaluate_custom_routing(&on_success_routing, &ok_out, &state, &flow_ir, ¤t) {
7009 CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "ok"),
7010 other => panic!("expected Next(\"ok\") via on_success default, got {other:?}"),
7011 }
7012 }
7013
7014 #[test]
7020 fn condition_evaluator_supports_comparisons_and_contains() {
7021 let ctx = json!({
7022 "register": { "q_age": 18 },
7023 "submit": { "status": "ok" },
7024 "msg": { "text": "Hello World" }
7025 });
7026
7027 assert!(evaluate_simple_condition("register.q_age >= 18", &ctx));
7029 assert!(!evaluate_simple_condition("register.q_age > 18", &ctx));
7030 assert!(evaluate_simple_condition("register.q_age <= 18", &ctx));
7031 assert!(!evaluate_simple_condition("register.q_age < 18", &ctx));
7032
7033 assert!(evaluate_simple_condition(
7035 "msg.text contains \"world\"",
7036 &ctx
7037 ));
7038 assert!(!evaluate_simple_condition(
7039 "msg.text contains \"bye\"",
7040 &ctx
7041 ));
7042
7043 assert!(evaluate_simple_condition("submit.status == \"ok\"", &ctx));
7045 assert!(!evaluate_simple_condition("submit.status != \"ok\"", &ctx));
7046 assert!(!evaluate_simple_condition("submit.status >= 1", &ctx));
7048 }
7049
7050 #[test]
7056 fn failure_default_matches_available_outcome_port() {
7057 let flow_ir = HostFlow {
7058 id: "flow.test".to_string(),
7059 start: None,
7060 nodes: IndexMap::new(),
7061 slot_schema: None,
7062 vars_init: JsonMap::new(),
7063 };
7064 let current = NodeId::from_str("current").unwrap();
7065 let state = ExecutionState::new(json!({}));
7066 let err_out = NodeOutput {
7068 ok: false,
7069 payload: json!({}),
7070 meta: Value::Null,
7071 };
7072
7073 let on_cancel_routing = json!([
7075 { "condition": "event == \"on_complete\"", "to": "next" },
7076 { "condition": "event == \"on_cancel\"", "to": "cancelled" }
7077 ]);
7078 match evaluate_custom_routing(&on_cancel_routing, &err_out, &state, &flow_ir, ¤t) {
7079 CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "cancelled"),
7080 other => panic!("expected Next(\"cancelled\") via on_cancel default, got {other:?}"),
7081 }
7082
7083 let on_error_routing = json!([
7085 { "condition": "event == \"on_success\"", "to": "ok" },
7086 { "condition": "event == \"on_error\"", "to": "err" }
7087 ]);
7088 match evaluate_custom_routing(&on_error_routing, &err_out, &state, &flow_ir, ¤t) {
7089 CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "err"),
7090 other => panic!("expected Next(\"err\") via on_error default, got {other:?}"),
7091 }
7092
7093 let on_timeout_routing = json!([
7095 { "condition": "event == \"on_success\"", "to": "ok" },
7096 { "condition": "event == \"on_timeout\"", "to": "timed_out" }
7097 ]);
7098 match evaluate_custom_routing(&on_timeout_routing, &err_out, &state, &flow_ir, ¤t) {
7099 CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "timed_out"),
7100 other => panic!("expected Next(\"timed_out\") via on_timeout default, got {other:?}"),
7101 }
7102 }
7103
7104 #[test]
7105 fn outcome_meta_surfaces_component_emitted_outcome() {
7106 assert_eq!(
7109 outcome_meta(&json!({ "ok": true, "outcome": "on_complete" })),
7110 json!({ "outcome": "on_complete" })
7111 );
7112 assert_eq!(
7114 outcome_meta(&json!({ "ok": true, "body": {} })),
7115 Value::Null
7116 );
7117 }
7118
7119 #[cfg(feature = "agentic-worker")]
7149 #[tokio::test]
7150 #[ignore = "requires live NATS; run with --ignored after `nats-server -js`"]
7151 async fn dw_agent_scale_to_zero_nats_e2e() {
7152 use crate::runner::agent_node::DwAgentDispatch;
7153 use crate::runner::dispatch_listener::{SessionResumer, run_response_listener};
7154 use crate::runner::remote_dispatch::NatsDispatcher;
7155 use futures::StreamExt as _;
7156 use greentic_types::{
7157 RuntimeDispatchResponse, TenantCtx as DispatchTenantCtx, request_topic, response_topic,
7158 };
7159 use tokio::sync::Notify;
7160
7161 let nats_url = match std::env::var("GREENTIC_EVENTS_NATS_URL") {
7162 Ok(url) => url,
7163 Err(_) => {
7164 eprintln!(
7165 "skipping dw_agent_scale_to_zero_nats_e2e: GREENTIC_EVENTS_NATS_URL not set"
7166 );
7167 return;
7168 }
7169 };
7170
7171 let resume_id = NodeId::from_str("after-agent").unwrap();
7175 let agent_node_id = NodeId::from_str("agent-e2e").unwrap();
7176 let agent_node = Node {
7177 id: agent_node_id.clone(),
7178 component: FlowComponentRef {
7179 id: "dw.agent".parse().unwrap(),
7180 pack_alias: None,
7181 operation: Some("greeter".to_string()),
7182 },
7183 input: InputMapping {
7184 mapping: json!({ "user_text": "ping" }),
7185 },
7186 output: OutputMapping {
7187 mapping: Value::Null,
7188 },
7189 err_map: None,
7190 routing: Routing::Next {
7191 node_id: resume_id.clone(),
7192 },
7193 telemetry: TelemetryHints::default(),
7194 conversational: false,
7195 };
7196 let resume_node = Node {
7197 id: resume_id.clone(),
7198 component: FlowComponentRef {
7199 id: "emit.log".parse().unwrap(),
7200 pack_alias: None,
7201 operation: None,
7202 },
7203 input: InputMapping {
7204 mapping: json!({ "message": "resumed" }),
7205 },
7206 output: OutputMapping {
7207 mapping: Value::Null,
7208 },
7209 err_map: None,
7210 routing: Routing::End,
7211 telemetry: TelemetryHints::default(),
7212 conversational: false,
7213 };
7214 let mut nodes = indexmap::IndexMap::default();
7215 nodes.insert(agent_node_id.clone(), agent_node);
7216 nodes.insert(resume_id.clone(), resume_node);
7217 let flow = greentic_types::Flow {
7218 schema_version: "1.0".into(),
7219 id: greentic_types::FlowId::from_str("e2e-agent.flow").unwrap(),
7220 kind: greentic_types::FlowKind::Messaging,
7221 entrypoints: BTreeMap::from([(
7222 "default".to_string(),
7223 Value::String(agent_node_id.to_string()),
7224 )]),
7225 nodes,
7226 metadata: Default::default(),
7227 };
7228 let host_flow = HostFlow::from(flow);
7229
7230 let dispatcher_client = async_nats::connect(&nats_url)
7232 .await
7233 .expect("NATS: dispatcher client");
7234 let bridge_client = async_nats::connect(&nats_url)
7235 .await
7236 .expect("NATS: fake bridge client");
7237 let listener_client = async_nats::connect(&nats_url)
7238 .await
7239 .expect("NATS: response listener client");
7240
7241 let agentic_request_subject = request_topic("agentic");
7243 let agentic_response_subject = response_topic("agentic");
7244 let mut req_sub = bridge_client
7245 .subscribe(agentic_request_subject.clone())
7246 .await
7247 .expect("fake bridge: subscribe to agentic request subject");
7248 let bridge_reply_client = bridge_client.clone();
7249 let reply_subject = agentic_response_subject.clone();
7250 tokio::spawn(async move {
7251 while let Some(msg) = req_sub.next().await {
7252 let headers = msg.headers.as_ref();
7253 let get_hdr = |name: &str| {
7254 headers
7255 .and_then(|h| h.get(name))
7256 .map(|v| v.as_str().to_owned())
7257 .unwrap_or_default()
7258 };
7259 let correlation_id = get_hdr("Greentic-Correlation-Id");
7260 let tenant = get_hdr("Greentic-Tenant");
7261 let env = get_hdr("Greentic-Env");
7262
7263 let response_payload = RuntimeDispatchResponse {
7264 ok: true,
7265 output: json!({
7266 "reply": "pong",
7267 "trail": [],
7268 "terminated_by": "final_reply"
7269 }),
7270 events: vec![],
7271 error: None,
7272 };
7273 let body =
7274 serde_json::to_vec(&response_payload).expect("serialize fake bridge response");
7275
7276 let mut resp_headers = async_nats::HeaderMap::new();
7277 resp_headers.insert("Greentic-Correlation-Id", correlation_id.as_str());
7278 resp_headers.insert("Greentic-Tenant", tenant.as_str());
7279 resp_headers.insert("Greentic-Env", env.as_str());
7280
7281 bridge_reply_client
7282 .publish_with_headers(reply_subject.clone(), resp_headers, body.into())
7283 .await
7284 .expect("fake bridge: publish response");
7285 }
7286 });
7287
7288 struct RecordingResumer {
7290 calls: std::sync::Mutex<Vec<(String, Value)>>,
7291 notify: Notify,
7292 }
7293
7294 impl RecordingResumer {
7295 fn new() -> Self {
7296 Self {
7297 calls: std::sync::Mutex::new(vec![]),
7298 notify: Notify::new(),
7299 }
7300 }
7301 }
7302
7303 #[async_trait::async_trait]
7304 impl SessionResumer for RecordingResumer {
7305 async fn resume(
7306 &self,
7307 _tenant: DispatchTenantCtx,
7308 correlation_id: &str,
7309 output: Value,
7310 ) -> anyhow::Result<()> {
7311 self.calls
7312 .lock()
7313 .unwrap()
7314 .push((correlation_id.to_string(), output));
7315 self.notify.notify_one();
7316 Ok(())
7317 }
7318 }
7319
7320 let resumer = Arc::new(RecordingResumer::new());
7321 let resumer_for_listener = resumer.clone();
7322 tokio::spawn(async move {
7323 run_response_listener(listener_client, "agentic".to_owned(), resumer_for_listener)
7324 .await
7325 .expect("response listener exited unexpectedly");
7326 });
7327
7328 tokio::time::sleep(tokio::time::Duration::from_millis(150)).await;
7330
7331 let nats_engine_dispatcher = Arc::new(NatsDispatcher::new(dispatcher_client));
7333 let engine = FlowEngine {
7334 packs: Vec::new(),
7335 flows: Vec::new(),
7336 flow_sources: StdHashMap::new(),
7337 messaging_provider_pack_ids: std::collections::HashSet::new(),
7338 rollout_ids: RolloutIds::default(),
7339 flow_cache: RwLock::new(StdHashMap::from([(
7340 FlowKey {
7341 pack_id: "e2e-pack".to_string(),
7342 flow_id: "e2e-agent.flow".to_string(),
7343 },
7344 host_flow,
7345 )])),
7346 default_env: "local".to_string(),
7347 validation: crate::validate::ValidationConfig {
7348 mode: crate::validate::ValidationMode::Off,
7349 },
7350 cross_pack_resolver: None,
7351 remote_dispatch_handler: Some(
7352 nats_engine_dispatcher
7353 as Arc<dyn crate::runner::remote_dispatch::RemoteDispatchHandler>,
7354 ),
7355 dw_agent_dispatch: DwAgentDispatch::Nats,
7356 agent_node_handler: None,
7357 graph_node_handler: None,
7358 mcp_tool_source: None,
7359 };
7360
7361 let ctx = FlowContext {
7362 tenant: "demo",
7363 pack_id: "e2e-pack",
7364 flow_id: "e2e-agent.flow",
7365 node_id: None,
7366 tool: None,
7367 action: None,
7368 session_id: Some("e2e-sess-1"),
7369 provider_id: None,
7370 reply_scope: None,
7371 retry_config: RetryConfig {
7372 max_attempts: 1,
7373 base_delay_ms: 1,
7374 },
7375 attempt: 1,
7376 observer: None,
7377 mocks: None,
7378 };
7379
7380 let result = engine
7382 .execute(ctx, json!({ "user_text": "ping" }))
7383 .await
7384 .expect("engine.execute succeeded");
7385
7386 assert!(
7387 matches!(result.status, FlowStatus::Waiting(_)),
7388 "expected FlowStatus::Waiting from dw.agent Nats path, got: {:?}",
7389 result.status
7390 );
7391 eprintln!("dw.agent: flow paused (Waiting) — dispatch published to NATS");
7392
7393 let wait = tokio::time::timeout(
7395 tokio::time::Duration::from_secs(5),
7396 resumer.notify.notified(),
7397 )
7398 .await;
7399
7400 assert!(
7401 wait.is_ok(),
7402 "timed out waiting for fake bridge reply — is NATS running? ({nats_url})"
7403 );
7404
7405 let calls = resumer.calls.lock().unwrap();
7407 assert_eq!(
7408 calls.len(),
7409 1,
7410 "resumer should have been called exactly once"
7411 );
7412 let (ref _corr, ref output) = calls[0];
7413 assert_eq!(
7414 output["output"]["reply"],
7415 json!("pong"),
7416 "resumed reply must match the aw-serve canned reply"
7417 );
7418 eprintln!(
7419 "PASSED: dw.agent scale-to-zero NATS e2e — reply={:?}",
7420 output["output"]["reply"]
7421 );
7422 }
7423
7424 #[test]
7425 fn execution_state_vars_survive_serde_round_trip() {
7426 let mut st = ExecutionState::new(json!({}));
7428 st.vars.insert("counter".into(), json!(3));
7429 st.vars.insert("region".into(), json!("us-east-1"));
7430
7431 let encoded = serde_json::to_string(&st).expect("serialize");
7432 let decoded: ExecutionState = serde_json::from_str(&encoded).expect("deserialize");
7433
7434 assert_eq!(decoded.vars.get("counter"), Some(&json!(3)));
7435 assert_eq!(decoded.vars.get("region"), Some(&json!("us-east-1")));
7436 }
7437
7438 #[test]
7439 fn execution_state_vars_default_empty_for_old_snapshots() {
7440 let legacy = r#"{"entry":{},"input":{},"nodes":{},"egress":[],"redirect_count":0}"#;
7442 let decoded: ExecutionState = serde_json::from_str(legacy).expect("legacy loads");
7443 assert!(decoded.vars.is_empty());
7444 }
7445
7446 #[test]
7447 fn template_context_exposes_vars_namespace_typed() {
7448 let mut st = ExecutionState::new(serde_json::json!({}));
7449 st.vars.insert("count".into(), serde_json::json!(5));
7450 st.vars.insert("name".into(), serde_json::json!("aws"));
7451
7452 let ctx = template_context(&st, serde_json::Value::Null);
7453 let rendered_num = render_template_value(
7455 &serde_json::json!("{{vars.count}}"),
7456 &ctx,
7457 TemplateOptions::default(),
7458 )
7459 .expect("render num");
7460 assert_eq!(rendered_num, serde_json::json!(5));
7461
7462 let rendered_str = render_template_value(
7463 &serde_json::json!("prefix-{{vars.name}}"),
7464 &ctx,
7465 TemplateOptions::default(),
7466 )
7467 .expect("render str");
7468 assert_eq!(rendered_str, serde_json::json!("prefix-aws"));
7469 }
7470
7471 #[test]
7472 fn vars_namespace_does_not_shadow_existing_namespaces() {
7473 let st = ExecutionState::new(serde_json::json!({"user": {"id": 7}}));
7474 let ctx = template_context(&st, serde_json::Value::Null);
7475 let obj = ctx.as_object().expect("ctx object");
7476 for key in ["entry", "in", "prev", "node", "state", "vars"] {
7477 assert!(obj.contains_key(key), "context must expose `{key}`");
7478 }
7479 }
7480
7481 fn flow_with_extra(extra: serde_json::Value) -> Flow {
7487 Flow {
7488 schema_version: "1.0".into(),
7489 id: FlowId::from_str("test.flow").unwrap(),
7490 kind: FlowKind::Messaging,
7491 entrypoints: BTreeMap::new(),
7492 nodes: indexmap::IndexMap::default(),
7493 metadata: FlowMetadata {
7494 title: None,
7495 description: None,
7496 tags: Default::default(),
7497 extra,
7498 },
7499 }
7500 }
7501
7502 #[test]
7503 fn from_flow_extracts_vars_init() {
7504 let flow = flow_with_extra(serde_json::json!({
7505 "vars_init": {
7506 "region": { "type": "string", "default": "us-east-1" },
7507 "counter": { "type": "number", "default": 0 }
7508 }
7509 }));
7510 let host: HostFlow = HostFlow::from(flow);
7511 assert_eq!(
7512 host.vars_init.get("region"),
7513 Some(&serde_json::json!("us-east-1"))
7514 );
7515 assert_eq!(host.vars_init.get("counter"), Some(&serde_json::json!(0)));
7516 }
7517
7518 #[test]
7519 fn from_flow_vars_init_absent() {
7520 let flow = flow_with_extra(serde_json::json!({}));
7521 let host: HostFlow = HostFlow::from(flow);
7522 assert!(host.vars_init.is_empty());
7523 }
7524
7525 #[test]
7526 fn execute_once_seeds_declared_vars() {
7527 let node_id = NodeId::from_str("n1").unwrap();
7532 let node = Node {
7533 id: node_id.clone(),
7534 component: FlowComponentRef {
7535 id: "emit.log".parse().unwrap(),
7536 pack_alias: None,
7537 operation: None,
7538 },
7539 input: InputMapping {
7540 mapping: json!({ "message": "{{vars.region}}" }),
7541 },
7542 output: OutputMapping {
7543 mapping: Value::Null,
7544 },
7545 err_map: None,
7546 routing: Routing::End,
7547 telemetry: TelemetryHints::default(),
7548 conversational: false,
7549 };
7550 let mut nodes = indexmap::IndexMap::default();
7551 nodes.insert(node_id.clone(), node);
7552 let flow = Flow {
7553 schema_version: "1.0".into(),
7554 id: FlowId::from_str("vars.flow").unwrap(),
7555 kind: FlowKind::Messaging,
7556 entrypoints: BTreeMap::from([(
7557 "default".to_string(),
7558 Value::String(node_id.to_string()),
7559 )]),
7560 nodes,
7561 metadata: FlowMetadata {
7562 title: None,
7563 description: None,
7564 tags: Default::default(),
7565 extra: json!({
7566 "vars_init": {
7567 "region": { "type": "string", "default": "us-east-1" }
7568 }
7569 }),
7570 },
7571 };
7572 let host_flow = HostFlow::from(flow);
7573
7574 let engine = FlowEngine {
7575 packs: Vec::new(),
7576 flows: Vec::new(),
7577 flow_sources: HashMap::new(),
7578 messaging_provider_pack_ids: std::collections::HashSet::new(),
7579 rollout_ids: RolloutIds::default(),
7580 flow_cache: RwLock::new(HashMap::from([(
7581 FlowKey {
7582 pack_id: "test-pack".to_string(),
7583 flow_id: "vars.flow".to_string(),
7584 },
7585 host_flow,
7586 )])),
7587 default_env: "local".to_string(),
7588 validation: ValidationConfig {
7589 mode: ValidationMode::Off,
7590 },
7591 cross_pack_resolver: None,
7592 remote_dispatch_handler: None,
7593 #[cfg(feature = "agentic-worker")]
7594 dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
7595 #[cfg(feature = "agentic-worker")]
7596 agent_node_handler: None,
7597 #[cfg(feature = "agentic-worker")]
7598 graph_node_handler: None,
7599 #[cfg(feature = "agentic-worker")]
7600 mcp_tool_source: None,
7601 };
7602
7603 let observer = CountingObserver::new();
7604 let ctx = FlowContext {
7605 tenant: "demo",
7606 pack_id: "test-pack",
7607 flow_id: "vars.flow",
7608 node_id: None,
7609 tool: None,
7610 action: None,
7611 session_id: None,
7612 provider_id: None,
7613 reply_scope: None,
7614 retry_config: RetryConfig {
7615 max_attempts: 1,
7616 base_delay_ms: 1,
7617 },
7618 attempt: 1,
7619 observer: Some(&observer),
7620 mocks: None,
7621 };
7622
7623 let rt = Runtime::new().unwrap();
7624 let result = rt.block_on(engine.execute(ctx, Value::Null)).unwrap();
7625 assert!(matches!(result.status, FlowStatus::Completed));
7626
7627 let ends = observer.ends.lock().unwrap();
7628 assert_eq!(ends.len(), 1);
7629 assert_eq!(
7630 ends[0].get("message").and_then(Value::as_str),
7631 Some("us-east-1"),
7632 "vars.region must be seeded to its default and rendered in the node payload"
7633 );
7634 }
7635
7636 fn var_set_flow(
7645 var_set_input: Value,
7646 emit_input: Value,
7647 vars_init_extra: Option<Value>,
7648 ) -> Flow {
7649 let set_id = NodeId::from_str("set1").unwrap();
7650 let emit_id = NodeId::from_str("emit1").unwrap();
7651
7652 let set_node = Node {
7653 id: set_id.clone(),
7654 component: FlowComponentRef {
7655 id: "var.set".parse().unwrap(),
7656 pack_alias: None,
7657 operation: None,
7658 },
7659 input: InputMapping {
7660 mapping: var_set_input,
7661 },
7662 output: OutputMapping {
7663 mapping: Value::Null,
7664 },
7665 err_map: None,
7666 routing: Routing::Next {
7667 node_id: emit_id.clone(),
7668 },
7669 telemetry: TelemetryHints::default(),
7670 conversational: false,
7671 };
7672
7673 let emit_node = Node {
7674 id: emit_id.clone(),
7675 component: FlowComponentRef {
7676 id: "emit.log".parse().unwrap(),
7677 pack_alias: None,
7678 operation: None,
7679 },
7680 input: InputMapping {
7681 mapping: emit_input,
7682 },
7683 output: OutputMapping {
7684 mapping: Value::Null,
7685 },
7686 err_map: None,
7687 routing: Routing::End,
7688 telemetry: TelemetryHints::default(),
7689 conversational: false,
7690 };
7691
7692 let mut nodes = indexmap::IndexMap::default();
7693 nodes.insert(set_id.clone(), set_node);
7694 nodes.insert(emit_id.clone(), emit_node);
7695
7696 let extra = vars_init_extra.unwrap_or(serde_json::json!({}));
7697
7698 Flow {
7699 schema_version: "1.0".into(),
7700 id: FlowId::from_str("var.set.flow").unwrap(),
7701 kind: FlowKind::Messaging,
7702 entrypoints: BTreeMap::from([(
7703 "default".to_string(),
7704 Value::String(set_id.to_string()),
7705 )]),
7706 nodes,
7707 metadata: FlowMetadata {
7708 title: None,
7709 description: None,
7710 tags: Default::default(),
7711 extra,
7712 },
7713 }
7714 }
7715
7716 fn run_var_set_flow(flow: Flow) -> (FlowStatus, Vec<Value>) {
7717 let host_flow = HostFlow::from(flow);
7718 let engine = FlowEngine {
7719 packs: Vec::new(),
7720 flows: Vec::new(),
7721 flow_sources: StdHashMap::new(),
7722 messaging_provider_pack_ids: std::collections::HashSet::new(),
7723 rollout_ids: RolloutIds::default(),
7724 flow_cache: RwLock::new(StdHashMap::from([(
7725 FlowKey {
7726 pack_id: "test-pack".to_string(),
7727 flow_id: "var.set.flow".to_string(),
7728 },
7729 host_flow,
7730 )])),
7731 default_env: "local".to_string(),
7732 validation: ValidationConfig {
7733 mode: ValidationMode::Off,
7734 },
7735 cross_pack_resolver: None,
7736 remote_dispatch_handler: None,
7737 #[cfg(feature = "agentic-worker")]
7738 dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
7739 #[cfg(feature = "agentic-worker")]
7740 agent_node_handler: None,
7741 #[cfg(feature = "agentic-worker")]
7742 graph_node_handler: None,
7743 #[cfg(feature = "agentic-worker")]
7744 mcp_tool_source: None,
7745 };
7746 let observer = CountingObserver::new();
7747 let ctx = FlowContext {
7748 tenant: "demo",
7749 pack_id: "test-pack",
7750 flow_id: "var.set.flow",
7751 node_id: None,
7752 tool: None,
7753 action: None,
7754 session_id: None,
7755 provider_id: None,
7756 reply_scope: None,
7757 retry_config: RetryConfig {
7758 max_attempts: 1,
7759 base_delay_ms: 1,
7760 },
7761 attempt: 1,
7762 observer: Some(&observer),
7763 mocks: None,
7764 };
7765 let rt = Runtime::new().unwrap();
7766 let result = rt.block_on(engine.execute(ctx, Value::Null)).unwrap();
7767 let ends = observer.ends.lock().unwrap().clone();
7768 (result.status, ends)
7769 }
7770
7771 #[test]
7772 fn var_set_node_writes_literal_value_into_vars() {
7773 let flow = var_set_flow(
7777 json!({ "name": "greeting", "value": "hi" }),
7778 json!({ "message": "{{vars.greeting}}" }),
7779 None,
7780 );
7781 let (status, ends) = run_var_set_flow(flow);
7782
7783 assert!(
7784 matches!(status, FlowStatus::Completed),
7785 "flow must complete"
7786 );
7787 assert_eq!(ends.len(), 2, "both nodes must fire");
7788 assert_eq!(ends[0].get("ok"), Some(&json!(true)), "var_set output ok");
7790 assert_eq!(
7792 ends[1].get("message").and_then(Value::as_str),
7793 Some("hi"),
7794 "vars.greeting must be written and renderable in the next node"
7795 );
7796 }
7797
7798 #[test]
7799 fn var_set_node_writes_templated_value_with_type_preserved() {
7800 let flow = var_set_flow(
7805 json!({ "name": "copy", "value": "{{vars.counter}}" }),
7806 json!({ "message": "{{vars.copy}}" }),
7807 Some(json!({
7808 "vars_init": {
7809 "counter": { "type": "number", "default": 1 }
7810 }
7811 })),
7812 );
7813 let (status, ends) = run_var_set_flow(flow);
7814
7815 assert!(
7816 matches!(status, FlowStatus::Completed),
7817 "flow must complete"
7818 );
7819 assert_eq!(ends.len(), 2, "both nodes must fire");
7820 assert_eq!(
7822 ends[1].get("message"),
7823 Some(&json!(1)),
7824 "vars.copy must preserve the JSON number type from vars.counter"
7825 );
7826 }
7827
7828 #[test]
7829 fn var_set_empty_name_is_skipped_not_written() {
7830 let engine = minimal_engine();
7833 let rt = Runtime::new().unwrap();
7834 let retry_config = RetryConfig {
7835 max_attempts: 1,
7836 base_delay_ms: 1,
7837 };
7838 let ctx = FlowContext {
7839 tenant: "demo",
7840 pack_id: "test-pack",
7841 flow_id: "var.set.flow",
7842 node_id: Some("set1"),
7843 tool: None,
7844 action: None,
7845 session_id: None,
7846 provider_id: None,
7847 reply_scope: None,
7848 retry_config,
7849 attempt: 1,
7850 observer: None,
7851 mocks: None,
7852 };
7853 let node = HostNode {
7854 kind: NodeKind::VarSet {
7855 name: "".to_string(),
7856 value: json!("garbage"),
7857 },
7858 component: "var.set".into(),
7859 component_id: "var.set".into(),
7860 operation_name: None,
7861 operation_in_mapping: None,
7862 payload_expr: Value::Null,
7863 routing: Routing::End,
7864 vars_out: None,
7865 };
7866 let mut state = ExecutionState::new(Value::Null);
7867 let payload = Value::Null;
7868 let event = NodeEvent {
7869 context: &ctx,
7870 node_id: "set1",
7871 node: &node,
7872 payload: &payload,
7873 };
7874
7875 let outcome = rt
7876 .block_on(engine.dispatch_node(
7877 &ctx,
7878 "set1",
7879 &node,
7880 &mut state,
7881 payload.clone(),
7882 &event,
7883 ))
7884 .expect("dispatch_node must not error on empty var name");
7885
7886 assert_eq!(
7888 outcome.output.payload,
7889 json!({ "ok": true }),
7890 "dispatch must return ok:true even when name is empty"
7891 );
7892 assert!(
7894 state.vars.get("").is_none(),
7895 "empty var name must not create a \"\" key in state.vars"
7896 );
7897 }
7898
7899 #[test]
7900 fn var_set_node_has_empty_payload_expr() {
7901 let node_id = NodeId::from_str("set1").unwrap();
7905 let node = Node {
7906 id: node_id.clone(),
7907 component: FlowComponentRef {
7908 id: "var.set".parse().unwrap(),
7909 pack_alias: None,
7910 operation: None,
7911 },
7912 input: InputMapping {
7913 mapping: json!({ "name": "greeting", "value": "hi" }),
7914 },
7915 output: OutputMapping {
7916 mapping: Value::Null,
7917 },
7918 err_map: None,
7919 routing: Routing::End,
7920 telemetry: TelemetryHints::default(),
7921 conversational: false,
7922 };
7923
7924 let host_node = HostNode::from(node);
7925
7926 assert_eq!(
7928 host_node.payload_expr,
7929 Value::Null,
7930 "var.set node must have Null payload_expr after lowering"
7931 );
7932 match &host_node.kind {
7934 NodeKind::VarSet { name, value } => {
7935 assert_eq!(name.as_str(), "greeting", "name must be preserved in kind");
7936 assert_eq!(value, &json!("hi"), "value must be preserved in kind");
7937 }
7938 other => panic!("expected NodeKind::VarSet, got {other:?}"),
7939 }
7940 }
7941
7942 fn vars_out_flow(emit1_input: Value, emit2_input: Value) -> Flow {
7951 let emit1_id = NodeId::from_str("emit1").unwrap();
7952 let emit2_id = NodeId::from_str("emit2").unwrap();
7953
7954 let emit1_node = Node {
7955 id: emit1_id.clone(),
7956 component: FlowComponentRef {
7957 id: "emit.log".parse().unwrap(),
7958 pack_alias: None,
7959 operation: None,
7960 },
7961 input: InputMapping {
7962 mapping: emit1_input,
7963 },
7964 output: OutputMapping {
7965 mapping: Value::Null,
7966 },
7967 err_map: None,
7968 routing: Routing::Next {
7969 node_id: emit2_id.clone(),
7970 },
7971 telemetry: TelemetryHints::default(),
7972 conversational: false,
7973 };
7974
7975 let emit2_node = Node {
7976 id: emit2_id.clone(),
7977 component: FlowComponentRef {
7978 id: "emit.log".parse().unwrap(),
7979 pack_alias: None,
7980 operation: None,
7981 },
7982 input: InputMapping {
7983 mapping: emit2_input,
7984 },
7985 output: OutputMapping {
7986 mapping: Value::Null,
7987 },
7988 err_map: None,
7989 routing: Routing::End,
7990 telemetry: TelemetryHints::default(),
7991 conversational: false,
7992 };
7993
7994 let mut nodes = indexmap::IndexMap::default();
7995 nodes.insert(emit1_id.clone(), emit1_node);
7996 nodes.insert(emit2_id.clone(), emit2_node);
7997
7998 Flow {
8001 schema_version: "1.0".into(),
8002 id: FlowId::from_str("var.set.flow").unwrap(),
8003 kind: FlowKind::Messaging,
8004 entrypoints: BTreeMap::from([(
8005 "default".to_string(),
8006 Value::String(emit1_id.to_string()),
8007 )]),
8008 nodes,
8009 metadata: FlowMetadata {
8010 title: None,
8011 description: None,
8012 tags: Default::default(),
8013 extra: serde_json::json!({}),
8014 },
8015 }
8016 }
8017
8018 #[test]
8019 fn vars_out_binds_node_output_into_vars() {
8020 let flow = vars_out_flow(
8026 json!({
8027 "message": "hello",
8028 "vars_out": { "lastReply": "{{prev.message}}" }
8029 }),
8030 json!({ "message": "{{vars.lastReply}}" }),
8031 );
8032 let (status, ends) = run_var_set_flow(flow);
8033
8034 assert!(
8035 matches!(status, FlowStatus::Completed),
8036 "flow must complete"
8037 );
8038 assert_eq!(ends.len(), 2, "both nodes must fire");
8039 assert_eq!(
8041 ends[1].get("message").and_then(Value::as_str),
8042 Some("hello"),
8043 "vars_out binding from node 1 must be readable in node 2"
8044 );
8045 }
8046
8047 fn vars_survive_flow() -> Flow {
8051 let set_id = NodeId::from_str("set1").unwrap();
8052 let wait_id = NodeId::from_str("wait1").unwrap();
8053 let emit_id = NodeId::from_str("emit1").unwrap();
8054
8055 let set_node = Node {
8056 id: set_id.clone(),
8057 component: FlowComponentRef {
8058 id: "var.set".parse().unwrap(),
8059 pack_alias: None,
8060 operation: None,
8061 },
8062 input: InputMapping {
8063 mapping: json!({ "name": "greeting", "value": "hello" }),
8064 },
8065 output: OutputMapping {
8066 mapping: Value::Null,
8067 },
8068 err_map: None,
8069 routing: Routing::Next {
8070 node_id: wait_id.clone(),
8071 },
8072 telemetry: TelemetryHints::default(),
8073 conversational: false,
8074 };
8075
8076 let wait_node = Node {
8077 id: wait_id.clone(),
8078 component: FlowComponentRef {
8079 id: "session.wait".parse().unwrap(),
8080 pack_alias: None,
8081 operation: None,
8082 },
8083 input: InputMapping {
8084 mapping: Value::Null,
8085 },
8086 output: OutputMapping {
8087 mapping: Value::Null,
8088 },
8089 err_map: None,
8090 routing: Routing::Next {
8091 node_id: emit_id.clone(),
8092 },
8093 telemetry: TelemetryHints::default(),
8094 conversational: false,
8095 };
8096
8097 let emit_node = Node {
8098 id: emit_id.clone(),
8099 component: FlowComponentRef {
8100 id: "emit.log".parse().unwrap(),
8101 pack_alias: None,
8102 operation: None,
8103 },
8104 input: InputMapping {
8105 mapping: json!({
8106 "greeting": "{{vars.greeting}}",
8107 "counter": "{{vars.counter}}"
8108 }),
8109 },
8110 output: OutputMapping {
8111 mapping: Value::Null,
8112 },
8113 err_map: None,
8114 routing: Routing::End,
8115 telemetry: TelemetryHints::default(),
8116 conversational: false,
8117 };
8118
8119 let mut nodes = indexmap::IndexMap::default();
8120 nodes.insert(set_id.clone(), set_node);
8121 nodes.insert(wait_id.clone(), wait_node);
8122 nodes.insert(emit_id.clone(), emit_node);
8123
8124 Flow {
8125 schema_version: "1.0".into(),
8126 id: FlowId::from_str("vars.survive.flow").unwrap(),
8127 kind: FlowKind::Messaging,
8128 entrypoints: BTreeMap::from([(
8129 "default".to_string(),
8130 Value::String(set_id.to_string()),
8131 )]),
8132 nodes,
8133 metadata: FlowMetadata {
8134 title: None,
8135 description: None,
8136 tags: Default::default(),
8137 extra: json!({
8138 "vars_init": {
8139 "counter": { "type": "number", "default": 1 }
8140 }
8141 }),
8142 },
8143 }
8144 }
8145
8146 #[test]
8147 fn vars_survive_park_and_resume_end_to_end() {
8148 let flow = vars_survive_flow();
8151 let host_flow = HostFlow::from(flow);
8152 let flow_id = "vars.survive.flow";
8153 let pack_id = "test-pack";
8154 let engine = FlowEngine {
8155 packs: Vec::new(),
8156 flows: Vec::new(),
8157 flow_sources: StdHashMap::new(),
8158 messaging_provider_pack_ids: std::collections::HashSet::new(),
8159 rollout_ids: RolloutIds::default(),
8160 flow_cache: RwLock::new(StdHashMap::from([(
8161 FlowKey {
8162 pack_id: pack_id.to_string(),
8163 flow_id: flow_id.to_string(),
8164 },
8165 host_flow,
8166 )])),
8167 default_env: "local".to_string(),
8168 validation: ValidationConfig {
8169 mode: ValidationMode::Off,
8170 },
8171 cross_pack_resolver: None,
8172 remote_dispatch_handler: None,
8173 #[cfg(feature = "agentic-worker")]
8174 dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
8175 #[cfg(feature = "agentic-worker")]
8176 agent_node_handler: None,
8177 #[cfg(feature = "agentic-worker")]
8178 graph_node_handler: None,
8179 #[cfg(feature = "agentic-worker")]
8180 mcp_tool_source: None,
8181 };
8182 let rt = Runtime::new().unwrap();
8183
8184 let ctx1 = FlowContext {
8186 tenant: "demo",
8187 pack_id,
8188 flow_id,
8189 node_id: None,
8190 tool: None,
8191 action: None,
8192 session_id: None,
8193 provider_id: None,
8194 reply_scope: None,
8195 retry_config: RetryConfig {
8196 max_attempts: 1,
8197 base_delay_ms: 1,
8198 },
8199 attempt: 1,
8200 observer: None,
8201 mocks: None,
8202 };
8203 let result1 = rt.block_on(engine.execute(ctx1, Value::Null)).unwrap();
8204 let snapshot = match result1.status {
8205 FlowStatus::Waiting(w) => w.snapshot,
8206 other => panic!("expected Waiting after session.wait, got {other:?}"),
8207 };
8208
8209 assert_eq!(
8211 snapshot.state.vars.get("greeting"),
8212 Some(&json!("hello")),
8213 "greeting var must be in snapshot"
8214 );
8215 assert_eq!(
8216 snapshot.state.vars.get("counter"),
8217 Some(&json!(1)),
8218 "counter var (from vars_init) must be in snapshot"
8219 );
8220
8221 let observer2 = CountingObserver::new();
8223 let ctx2 = FlowContext {
8224 tenant: "demo",
8225 pack_id,
8226 flow_id,
8227 node_id: None,
8228 tool: None,
8229 action: None,
8230 session_id: None,
8231 provider_id: None,
8232 reply_scope: None,
8233 retry_config: RetryConfig {
8234 max_attempts: 1,
8235 base_delay_ms: 1,
8236 },
8237 attempt: 1,
8238 observer: Some(&observer2),
8239 mocks: None,
8240 };
8241 let result2 = rt
8242 .block_on(engine.resume(ctx2, snapshot, Value::Null))
8243 .unwrap();
8244 assert!(
8245 matches!(result2.status, FlowStatus::Completed),
8246 "flow must complete after resume"
8247 );
8248 let ends2 = observer2.ends.lock().unwrap().clone();
8249 assert_eq!(ends2.len(), 1, "only emit.log fires after resume");
8250 assert_eq!(
8251 ends2[0].get("greeting").and_then(Value::as_str),
8252 Some("hello"),
8253 "vars.greeting must survive the park/resume"
8254 );
8255 assert_eq!(
8256 ends2[0].get("counter"),
8257 Some(&json!(1)),
8258 "vars.counter (vars_init) must survive the park/resume"
8259 );
8260 }
8261
8262 #[cfg(conversational_dw_agent_port)]
8282 mod conversational_dw_agent {
8283 use super::*;
8284
8285 #[cfg(feature = "agentic-worker")]
8286 struct StubAgentHandler {
8287 payload: serde_json::Value,
8288 }
8289 #[cfg(feature = "agentic-worker")]
8290 #[async_trait::async_trait]
8291 impl crate::runner::agent_node::AgentNodeHandler for StubAgentHandler {
8292 async fn execute(
8293 &self,
8294 _tenant_id: &str,
8295 _env_id: &str,
8296 _agent_id: &str,
8297 _session_id: &str,
8298 _flow_input: &serde_json::Value,
8299 _conversational: bool,
8300 ) -> anyhow::Result<serde_json::Value> {
8301 Ok(self.payload.clone())
8302 }
8303 }
8304
8305 #[cfg(feature = "agentic-worker")]
8308 fn conversational_dw_flow(conversational: bool) -> HostFlow {
8309 let mut nodes = IndexMap::new();
8310 let agent_id = NodeId::from_str("agent").unwrap();
8311 let thanks_id = NodeId::from_str("thanks").unwrap();
8312 nodes.insert(
8313 agent_id.clone(),
8314 HostNode {
8315 kind: NodeKind::DwAgent {
8316 agent_id: "a".to_string(),
8317 conversational,
8318 },
8319 component: "dw.agent".to_string(),
8320 component_id: "dw.agent".to_string(),
8321 operation_name: Some("a".to_string()),
8322 operation_in_mapping: None,
8323 payload_expr: json!({ "user_text": "hi" }),
8324 routing: Routing::Next {
8325 node_id: thanks_id.clone(),
8326 },
8327 vars_out: None,
8328 },
8329 );
8330 nodes.insert(
8331 thanks_id.clone(),
8332 HostNode {
8333 kind: NodeKind::BuiltinEmit {
8334 kind: EmitKind::Response,
8335 },
8336 component: "emit.response".to_string(),
8337 component_id: "emit.response".to_string(),
8338 operation_name: None,
8339 operation_in_mapping: None,
8340 payload_expr: json!({ "text": "thanks" }),
8341 routing: Routing::End,
8342 vars_out: None,
8343 },
8344 );
8345 HostFlow {
8346 slot_schema: None,
8347 id: "conv.flow".to_string(),
8348 start: Some(agent_id),
8349 nodes,
8350 vars_init: JsonMap::new(),
8351 required_vars: Vec::new(),
8352 }
8353 }
8354
8355 #[cfg(feature = "agentic-worker")]
8358 fn conv_engine(flow: HostFlow, payload: serde_json::Value) -> FlowEngine {
8359 FlowEngine {
8360 rollout_ids: RolloutIds::default(),
8361 packs: Vec::new(),
8362 flows: Vec::new(),
8363 flow_sources: StdHashMap::new(),
8364 flow_cache: RwLock::new(StdHashMap::from([(
8365 FlowKey {
8366 pack_id: "test-pack".to_string(),
8367 flow_id: "conv.flow".to_string(),
8368 },
8369 flow,
8370 )])),
8371 default_env: "local".to_string(),
8372 validation: ValidationConfig {
8373 mode: ValidationMode::Off,
8374 },
8375 cross_pack_resolver: None,
8376 remote_dispatch_handler: None,
8377 dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
8378 agent_node_handler: Some(std::sync::Arc::new(StubAgentHandler { payload })),
8379 graph_node_handler: None,
8380 mcp_tool_source: None,
8381 }
8382 }
8383
8384 #[cfg(feature = "agentic-worker")]
8385 fn conv_ctx<'a>() -> FlowContext<'a> {
8386 FlowContext {
8387 tenant: "demo",
8388 pack_id: "test-pack",
8389 flow_id: "conv.flow",
8390 node_id: None,
8391 tool: None,
8392 action: None,
8393 session_id: Some("sess-conv"),
8394 provider_id: None,
8395 reply_scope: None,
8396 retry_config: RetryConfig {
8397 max_attempts: 1,
8398 base_delay_ms: 1,
8399 },
8400 attempt: 1,
8401 observer: None,
8402 mocks: None,
8403 }
8404 }
8405
8406 #[cfg(feature = "agentic-worker")]
8407 #[test]
8408 fn conversational_dw_agent_parks_and_loops_on_normal_reply() {
8409 let engine = conv_engine(
8410 conversational_dw_flow(true),
8411 json!({ "reply": "hello there", "trail": [], "terminated_by": "final_reply" }),
8412 );
8413 let rt = Runtime::new().unwrap();
8414 let result = rt
8415 .block_on(engine.execute(conv_ctx(), Value::Null))
8416 .unwrap();
8417 let snapshot = match result.status {
8418 FlowStatus::Waiting(w) => w.snapshot,
8419 other => panic!("expected Waiting (park-loop), got {other:?}"),
8420 };
8421 assert_eq!(
8422 snapshot.next_node, "agent",
8423 "must re-enter the dw.agent node itself"
8424 );
8425 assert!(
8427 serde_json::to_string(&result.output)
8428 .unwrap()
8429 .contains("hello there"),
8430 "the agent reply must be rendered before parking: {:?}",
8431 result.output
8432 );
8433 }
8434
8435 #[cfg(feature = "agentic-worker")]
8436 #[test]
8437 fn conversational_dw_agent_advances_on_conversation_ended() {
8438 let engine = conv_engine(
8439 conversational_dw_flow(true),
8440 json!({ "reply": "bye", "trail": [], "terminated_by": "conversation_ended" }),
8441 );
8442 let rt = Runtime::new().unwrap();
8443 let result = rt
8444 .block_on(engine.execute(conv_ctx(), Value::Null))
8445 .unwrap();
8446 assert!(
8447 matches!(result.status, FlowStatus::Completed),
8448 "conversation_ended must advance to the successor and complete, got {:?}",
8449 result.status
8450 );
8451 }
8452
8453 #[cfg(feature = "agentic-worker")]
8454 #[test]
8455 fn non_conversational_dw_agent_never_loops() {
8456 for tb in ["final_reply", "conversation_ended"] {
8459 let engine = conv_engine(
8460 conversational_dw_flow(false),
8461 json!({ "reply": "x", "trail": [], "terminated_by": tb }),
8462 );
8463 let rt = Runtime::new().unwrap();
8464 let result = rt
8465 .block_on(engine.execute(conv_ctx(), Value::Null))
8466 .unwrap();
8467 assert!(
8468 matches!(result.status, FlowStatus::Completed),
8469 "non-conversational must complete (route onward) for terminated_by={tb}, got {:?}",
8470 result.status
8471 );
8472 }
8473 }
8474
8475 #[cfg(feature = "agentic-worker")]
8480 #[test]
8481 fn conversational_dw_agent_force_advances_after_park_loop_cap() {
8482 let engine = conv_engine(
8483 conversational_dw_flow(true),
8484 json!({ "reply": "still thinking", "trail": [], "terminated_by": "final_reply" }),
8485 );
8486 let rt = Runtime::new().unwrap();
8487
8488 let result = rt
8489 .block_on(engine.execute(conv_ctx(), Value::Null))
8490 .unwrap();
8491 let mut snapshot = match result.status {
8492 FlowStatus::Waiting(w) => w.snapshot,
8493 other => panic!("expected Waiting after turn 1, got {other:?}"),
8494 };
8495
8496 for turn in 2..MAX_PARK_TURNS {
8498 let result = rt
8499 .block_on(engine.resume(conv_ctx(), snapshot, json!({ "text": "still here" })))
8500 .unwrap();
8501 snapshot = match result.status {
8502 FlowStatus::Waiting(w) => w.snapshot,
8503 other => panic!("expected Waiting at turn {turn}, got {other:?}"),
8504 };
8505 }
8506
8507 let result = rt
8509 .block_on(engine.resume(conv_ctx(), snapshot, json!({ "text": "still here" })))
8510 .unwrap();
8511 assert!(
8512 matches!(result.status, FlowStatus::Completed),
8513 "park-loop cap must force-advance to the successor at turn {MAX_PARK_TURNS}, got {:?}",
8514 result.status
8515 );
8516 }
8517
8518 #[cfg(feature = "agentic-worker")]
8536 struct ScriptedNatsDispatcher {
8537 calls: Mutex<Vec<crate::runner::remote_dispatch::RemoteDispatch>>,
8538 }
8539
8540 #[cfg(feature = "agentic-worker")]
8541 #[async_trait::async_trait]
8542 impl crate::runner::remote_dispatch::RemoteDispatchHandler for ScriptedNatsDispatcher {
8543 async fn dispatch(
8544 &self,
8545 request: crate::runner::remote_dispatch::RemoteDispatch,
8546 ) -> anyhow::Result<crate::runner::remote_dispatch::RemoteDispatchAction> {
8547 let correlation_id = request.correlation_id.clone();
8548 self.calls.lock().unwrap().push(request);
8549 Ok(
8550 crate::runner::remote_dispatch::RemoteDispatchAction::AwaitingResponse {
8551 correlation_id,
8552 },
8553 )
8554 }
8555 }
8556
8557 #[cfg(feature = "agentic-worker")]
8561 fn nats_conv_engine(
8562 flow: HostFlow,
8563 dispatcher: std::sync::Arc<dyn crate::runner::remote_dispatch::RemoteDispatchHandler>,
8564 ) -> FlowEngine {
8565 FlowEngine {
8566 rollout_ids: RolloutIds::default(),
8567 packs: Vec::new(),
8568 flows: Vec::new(),
8569 flow_sources: StdHashMap::new(),
8570 flow_cache: RwLock::new(StdHashMap::from([(
8571 FlowKey {
8572 pack_id: "test-pack".to_string(),
8573 flow_id: "conv.flow".to_string(),
8574 },
8575 flow,
8576 )])),
8577 default_env: "local".to_string(),
8578 validation: ValidationConfig {
8579 mode: ValidationMode::Off,
8580 },
8581 cross_pack_resolver: None,
8582 remote_dispatch_handler: Some(dispatcher),
8583 dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::Nats,
8584 agent_node_handler: None,
8585 graph_node_handler: None,
8586 mcp_tool_source: None,
8587 }
8588 }
8589
8590 #[cfg(feature = "agentic-worker")]
8594 fn agent_response_envelope(reply: &str, terminated_by: &str) -> Value {
8595 json!({
8596 "ok": true,
8597 "output": { "reply": reply, "trail": [], "terminated_by": terminated_by },
8598 "events": [],
8599 "error": Value::Null,
8600 })
8601 }
8602
8603 #[cfg(feature = "agentic-worker")]
8608 #[test]
8609 fn conversational_dw_agent_nats_turn1_parks_via_await_here() {
8610 let dispatcher = Arc::new(ScriptedNatsDispatcher {
8611 calls: Mutex::new(vec![]),
8612 });
8613 let engine = nats_conv_engine(conversational_dw_flow(true), dispatcher.clone());
8614 let rt = Runtime::new().unwrap();
8615
8616 let result = rt
8617 .block_on(engine.execute(conv_ctx(), Value::Null))
8618 .unwrap();
8619 let snapshot = match result.status {
8620 FlowStatus::Waiting(w) => w.snapshot,
8621 other => panic!("expected Waiting after fresh dispatch, got {other:?}"),
8622 };
8623 assert_eq!(
8624 snapshot.next_node, "agent",
8625 "AwaitHere must resume at self, not the routing successor"
8626 );
8627 assert_eq!(
8628 dispatcher.calls.lock().unwrap().len(),
8629 1,
8630 "a fresh user turn must dispatch to NATS exactly once"
8631 );
8632 assert_eq!(
8633 result.output,
8634 Value::Null,
8635 "no reply is known yet on the initial dispatch — the async response hasn't arrived"
8636 );
8637 }
8638
8639 #[cfg(feature = "agentic-worker")]
8646 #[test]
8647 fn conversational_dw_agent_nats_park_loop_full_turn_cycle() {
8648 let dispatcher = Arc::new(ScriptedNatsDispatcher {
8649 calls: Mutex::new(vec![]),
8650 });
8651 let engine = nats_conv_engine(conversational_dw_flow(true), dispatcher.clone());
8652 let rt = Runtime::new().unwrap();
8653
8654 let result = rt
8656 .block_on(engine.execute(conv_ctx(), Value::Null))
8657 .unwrap();
8658 let snapshot = match result.status {
8659 FlowStatus::Waiting(w) => w.snapshot,
8660 other => {
8661 panic!("expected Waiting (AwaitHere) after turn 1 dispatch, got {other:?}")
8662 }
8663 };
8664 assert_eq!(snapshot.next_node, "agent");
8665 assert_eq!(dispatcher.calls.lock().unwrap().len(), 1);
8666
8667 let result = rt
8670 .block_on(engine.resume(
8671 conv_ctx(),
8672 snapshot,
8673 agent_response_envelope("hello there", "final_reply"),
8674 ))
8675 .unwrap();
8676 let snapshot = match result.status {
8677 FlowStatus::Waiting(w) => w.snapshot,
8678 other => {
8679 panic!("expected Waiting (LoopHere) after not-ended response, got {other:?}")
8680 }
8681 };
8682 assert_eq!(
8683 snapshot.next_node, "agent",
8684 "LoopHere also re-enters the node itself"
8685 );
8686 assert!(
8687 serde_json::to_string(&result.output)
8688 .unwrap()
8689 .contains("hello there"),
8690 "the agent's reply must be surfaced once the response resume lands: {:?}",
8691 result.output
8692 );
8693 assert_eq!(
8694 dispatcher.calls.lock().unwrap().len(),
8695 1,
8696 "the response landing must not itself trigger another NATS dispatch"
8697 );
8698
8699 let result = rt
8701 .block_on(engine.resume(conv_ctx(), snapshot, json!({ "text": "user says more" })))
8702 .unwrap();
8703 let snapshot = match result.status {
8704 FlowStatus::Waiting(w) => w.snapshot,
8705 other => {
8706 panic!("expected Waiting (AwaitHere) after turn 2 dispatch, got {other:?}")
8707 }
8708 };
8709 assert_eq!(snapshot.next_node, "agent");
8710 assert_eq!(
8711 dispatcher.calls.lock().unwrap().len(),
8712 2,
8713 "a second fresh user turn must dispatch to NATS again"
8714 );
8715
8716 let result = rt
8719 .block_on(engine.resume(
8720 conv_ctx(),
8721 snapshot,
8722 agent_response_envelope("bye", "conversation_ended"),
8723 ))
8724 .unwrap();
8725 assert!(
8726 matches!(result.status, FlowStatus::Completed),
8727 "conversation_ended response must advance to the successor and complete, got {:?}",
8728 result.status
8729 );
8730 assert_eq!(
8731 dispatcher.calls.lock().unwrap().len(),
8732 2,
8733 "conversation end must not trigger another NATS dispatch"
8734 );
8735 }
8736
8737 #[cfg(feature = "agentic-worker")]
8743 #[test]
8744 fn conversational_dw_agent_nats_force_advances_after_park_loop_cap() {
8745 let dispatcher = Arc::new(ScriptedNatsDispatcher {
8746 calls: Mutex::new(vec![]),
8747 });
8748 let engine = nats_conv_engine(conversational_dw_flow(true), dispatcher.clone());
8749 let rt = Runtime::new().unwrap();
8750
8751 let result = rt
8755 .block_on(engine.execute(conv_ctx(), Value::Null))
8756 .unwrap();
8757 let mut snapshot = match result.status {
8758 FlowStatus::Waiting(w) => w.snapshot,
8759 other => panic!("expected Waiting after turn 1 dispatch, got {other:?}"),
8760 };
8761
8762 for turn in 1..MAX_PARK_TURNS {
8766 let result = rt
8767 .block_on(engine.resume(
8768 conv_ctx(),
8769 snapshot,
8770 agent_response_envelope("still thinking", "final_reply"),
8771 ))
8772 .unwrap();
8773 snapshot = match result.status {
8774 FlowStatus::Waiting(w) => w.snapshot,
8775 other => {
8776 panic!("expected Waiting (LoopHere) at response #{turn}, got {other:?}")
8777 }
8778 };
8779 let result = rt
8780 .block_on(engine.resume(conv_ctx(), snapshot, json!({ "text": "still here" })))
8781 .unwrap();
8782 snapshot = match result.status {
8783 FlowStatus::Waiting(w) => w.snapshot,
8784 other => {
8785 panic!(
8786 "expected Waiting (AwaitHere) after user turn #{turn}, got {other:?}"
8787 )
8788 }
8789 };
8790 }
8791
8792 let result = rt
8795 .block_on(engine.resume(
8796 conv_ctx(),
8797 snapshot,
8798 agent_response_envelope("still thinking", "final_reply"),
8799 ))
8800 .unwrap();
8801 assert!(
8802 matches!(result.status, FlowStatus::Completed),
8803 "park-loop cap must force-advance to the successor at response {MAX_PARK_TURNS}, got {:?}",
8804 result.status
8805 );
8806 assert_eq!(
8807 dispatcher.calls.lock().unwrap().len(),
8808 1 + (MAX_PARK_TURNS as usize - 1),
8809 "exactly one NATS dispatch per user turn across the whole park-loop"
8810 );
8811 }
8812
8813 #[cfg(feature = "agentic-worker")]
8826 #[test]
8827 fn conversational_dw_agent_nats_and_inprocess_transcripts_match_for_same_script() {
8828 let rt = Runtime::new().unwrap();
8829
8830 let inproc_handler = Arc::new(ScriptedAgentHandler {
8832 script: Mutex::new(std::collections::VecDeque::from(vec![
8833 json!({ "reply": "hello there", "trail": [], "terminated_by": "final_reply" }),
8834 json!({ "reply": "bye", "trail": [], "terminated_by": "conversation_ended" }),
8835 ])),
8836 });
8837 let inproc_engine = conv_engine_scripted(conversational_dw_flow(true), inproc_handler);
8838 let r1 = rt
8839 .block_on(inproc_engine.execute(conv_ctx(), Value::Null))
8840 .unwrap();
8841 let inproc_snapshot = match r1.status {
8842 FlowStatus::Waiting(ref w) => w.snapshot.clone(),
8843 ref other => panic!("in-process turn 1: expected Waiting, got {other:?}"),
8844 };
8845 let r2 = rt
8846 .block_on(inproc_engine.resume(
8847 conv_ctx(),
8848 inproc_snapshot,
8849 json!({ "text": "more" }),
8850 ))
8851 .unwrap();
8852
8853 let dispatcher = Arc::new(ScriptedNatsDispatcher {
8855 calls: Mutex::new(vec![]),
8856 });
8857 let nats_engine = nats_conv_engine(conversational_dw_flow(true), dispatcher);
8858 let n1 = rt
8859 .block_on(nats_engine.execute(conv_ctx(), Value::Null))
8860 .unwrap();
8861 let n1_snapshot = match n1.status {
8862 FlowStatus::Waiting(w) => w.snapshot,
8863 other => panic!("nats turn 1 dispatch: expected Waiting, got {other:?}"),
8864 };
8865 let n1r = rt
8866 .block_on(nats_engine.resume(
8867 conv_ctx(),
8868 n1_snapshot,
8869 agent_response_envelope("hello there", "final_reply"),
8870 ))
8871 .unwrap();
8872 let n1r_snapshot = match n1r.status {
8873 FlowStatus::Waiting(ref w) => w.snapshot.clone(),
8874 ref other => panic!("nats turn 1 response resume: expected Waiting, got {other:?}"),
8875 };
8876 let n2 = rt
8877 .block_on(nats_engine.resume(conv_ctx(), n1r_snapshot, json!({ "text": "more" })))
8878 .unwrap();
8879 let n2_snapshot = match n2.status {
8880 FlowStatus::Waiting(w) => w.snapshot,
8881 other => panic!("nats turn 2 dispatch: expected Waiting, got {other:?}"),
8882 };
8883 let n2r = rt
8884 .block_on(nats_engine.resume(
8885 conv_ctx(),
8886 n2_snapshot,
8887 agent_response_envelope("bye", "conversation_ended"),
8888 ))
8889 .unwrap();
8890
8891 assert!(matches!(r1.status, FlowStatus::Waiting(_)));
8893 assert!(
8894 matches!(n1r.status, FlowStatus::Waiting(_)),
8895 "nats turn 1's user-visible status must also be Waiting"
8896 );
8897 assert!(matches!(r2.status, FlowStatus::Completed));
8898 assert!(
8899 matches!(n2r.status, FlowStatus::Completed),
8900 "nats turn 2 must also complete, matching the in-process transcript"
8901 );
8902
8903 assert!(
8905 serde_json::to_string(&r1.output)
8906 .unwrap()
8907 .contains("hello there"),
8908 "in-process turn 1 must surface the reply: {:?}",
8909 r1.output
8910 );
8911 assert!(
8912 serde_json::to_string(&n1r.output)
8913 .unwrap()
8914 .contains("hello there"),
8915 "nats turn 1 must surface the identical reply once the response resume lands: {:?}",
8916 n1r.output
8917 );
8918 }
8919
8920 #[cfg(feature = "agentic-worker")]
8928 fn agent_error_envelope(message: &str, code: Option<&str>) -> Value {
8929 json!({
8930 "ok": false,
8931 "output": Value::Null,
8932 "events": [],
8933 "error": { "code": code, "message": message },
8934 })
8935 }
8936
8937 #[cfg(feature = "agentic-worker")]
8946 #[test]
8947 fn conversational_dw_agent_nats_interleaved_user_message_is_not_misread_as_response() {
8948 let dispatcher = Arc::new(ScriptedNatsDispatcher {
8949 calls: Mutex::new(vec![]),
8950 });
8951 let engine = nats_conv_engine(conversational_dw_flow(true), dispatcher.clone());
8952 let rt = Runtime::new().unwrap();
8953
8954 let result = rt
8956 .block_on(engine.execute(conv_ctx(), Value::Null))
8957 .unwrap();
8958 let snapshot = match result.status {
8959 FlowStatus::Waiting(w) => w.snapshot,
8960 other => panic!("expected Waiting after turn 1 dispatch, got {other:?}"),
8961 };
8962 assert!(
8963 snapshot.state.pending_agent_await.contains_key("agent"),
8964 "turn 1 dispatch must mark the pending await"
8965 );
8966 assert_eq!(dispatcher.calls.lock().unwrap().len(), 1);
8967
8968 let result = rt
8971 .block_on(engine.resume(
8972 conv_ctx(),
8973 snapshot,
8974 json!({ "text": "are you still there?" }),
8975 ))
8976 .unwrap();
8977 let snapshot = match result.status {
8978 FlowStatus::Waiting(w) => w.snapshot,
8979 other => panic!(
8980 "a stray user message must re-dispatch as a fresh turn (Waiting/AwaitHere), got {other:?}"
8981 ),
8982 };
8983 assert_eq!(
8984 snapshot.next_node, "agent",
8985 "the fresh re-dispatch still awaits at self"
8986 );
8987 assert_eq!(
8988 dispatcher.calls.lock().unwrap().len(),
8989 2,
8990 "the stray user message must trigger its OWN fresh NATS dispatch, not be swallowed"
8991 );
8992 assert!(
8993 snapshot.state.pending_agent_await.contains_key("agent"),
8994 "the marker must still be set for the real response to land against"
8995 );
8996 assert_eq!(
8997 result.output,
8998 Value::Null,
8999 "no reply is surfaced — this was not a misread null agent turn"
9000 );
9001 assert!(
9002 !snapshot.state.park_turns.contains_key("agent"),
9003 "a stray user message must not touch the park-loop cap"
9004 );
9005 }
9006
9007 #[cfg(feature = "agentic-worker")]
9016 #[test]
9017 fn conversational_dw_agent_nats_error_envelope_surfaces_and_reparks_without_cap_bump() {
9018 let dispatcher = Arc::new(ScriptedNatsDispatcher {
9019 calls: Mutex::new(vec![]),
9020 });
9021 let engine = nats_conv_engine(conversational_dw_flow(true), dispatcher.clone());
9022 let rt = Runtime::new().unwrap();
9023
9024 let result = rt
9026 .block_on(engine.execute(conv_ctx(), Value::Null))
9027 .unwrap();
9028 let snapshot = match result.status {
9029 FlowStatus::Waiting(w) => w.snapshot,
9030 other => panic!("expected Waiting after turn 1 dispatch, got {other:?}"),
9031 };
9032
9033 let result = rt
9035 .block_on(engine.resume(conv_ctx(), snapshot, agent_error_envelope("boom", None)))
9036 .unwrap();
9037 let snapshot = match result.status {
9038 FlowStatus::Waiting(w) => w.snapshot,
9039 other => panic!("an error envelope must re-park (Waiting/LoopHere), got {other:?}"),
9040 };
9041 assert_eq!(
9042 snapshot.next_node, "agent",
9043 "LoopHere re-enters the node itself"
9044 );
9045 assert!(
9046 serde_json::to_string(&result.output)
9047 .unwrap()
9048 .contains("boom"),
9049 "the error message must be surfaced as the reply: {:?}",
9050 result.output
9051 );
9052 assert!(
9053 !snapshot.state.park_turns.contains_key("agent"),
9054 "an error response must NOT bump the park-loop cap counter"
9055 );
9056
9057 let result = rt
9059 .block_on(engine.resume(conv_ctx(), snapshot, json!({ "text": "hello?" })))
9060 .unwrap();
9061 let snapshot = match result.status {
9062 FlowStatus::Waiting(w) => w.snapshot,
9063 other => panic!("expected Waiting (AwaitHere) after user turn, got {other:?}"),
9064 };
9065 assert_eq!(dispatcher.calls.lock().unwrap().len(), 2);
9066
9067 let result = rt
9071 .block_on(engine.resume(
9072 conv_ctx(),
9073 snapshot,
9074 agent_error_envelope("timeout waiting for agent response", Some("timeout")),
9075 ))
9076 .unwrap();
9077 let snapshot = match result.status {
9078 FlowStatus::Waiting(w) => w.snapshot,
9079 other => {
9080 panic!("a timeout envelope must also re-park (Waiting/LoopHere), got {other:?}")
9081 }
9082 };
9083 assert!(
9084 serde_json::to_string(&result.output)
9085 .unwrap()
9086 .contains("timeout waiting for agent response"),
9087 "the timeout message must be surfaced as the reply: {:?}",
9088 result.output
9089 );
9090 assert!(
9091 !snapshot.state.park_turns.contains_key("agent"),
9092 "two error/timeout responses in a row (with an intervening user turn) must still \
9093 not have bumped the park-loop cap counter"
9094 );
9095 }
9096
9097 #[cfg(feature = "agentic-worker")]
9102 struct ScriptedAgentHandler {
9103 script: Mutex<std::collections::VecDeque<serde_json::Value>>,
9104 }
9105 #[cfg(feature = "agentic-worker")]
9106 #[async_trait::async_trait]
9107 impl crate::runner::agent_node::AgentNodeHandler for ScriptedAgentHandler {
9108 async fn execute(
9109 &self,
9110 _tenant_id: &str,
9111 _env_id: &str,
9112 _agent_id: &str,
9113 _session_id: &str,
9114 _flow_input: &serde_json::Value,
9115 _conversational: bool,
9116 ) -> anyhow::Result<serde_json::Value> {
9117 Ok(self
9118 .script
9119 .lock()
9120 .unwrap()
9121 .pop_front()
9122 .expect("ScriptedAgentHandler: script exhausted"))
9123 }
9124 }
9125
9126 #[cfg(feature = "agentic-worker")]
9130 fn conv_engine_scripted(
9131 flow: HostFlow,
9132 handler: std::sync::Arc<ScriptedAgentHandler>,
9133 ) -> FlowEngine {
9134 FlowEngine {
9135 rollout_ids: RolloutIds::default(),
9136 packs: Vec::new(),
9137 flows: Vec::new(),
9138 flow_sources: StdHashMap::new(),
9139 flow_cache: RwLock::new(StdHashMap::from([(
9140 FlowKey {
9141 pack_id: "test-pack".to_string(),
9142 flow_id: "conv.flow".to_string(),
9143 },
9144 flow,
9145 )])),
9146 default_env: "local".to_string(),
9147 validation: ValidationConfig {
9148 mode: ValidationMode::Off,
9149 },
9150 cross_pack_resolver: None,
9151 remote_dispatch_handler: None,
9152 dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
9153 agent_node_handler: Some(handler),
9154 graph_node_handler: None,
9155 mcp_tool_source: None,
9156 }
9157 }
9158 }
9159
9160 #[test]
9161 fn submitted_fields_reads_root_inputs_on_the_demo_path() {
9162 let entry = json!({
9165 "amount": "500",
9166 "reason": "looks good",
9167 "metadata": { "action": "approve" }
9168 });
9169 let fields = submitted_fields(&entry);
9170 assert_eq!(fields.get("amount"), Some(&json!("500")));
9171 assert_eq!(fields.get("reason"), Some(&json!("looks good")));
9172 assert!(
9173 !fields.contains_key("metadata"),
9174 "the envelope key is not a field"
9175 );
9176 assert!(
9177 !fields.contains_key("action"),
9178 "the route discriminator is not a field"
9179 );
9180 }
9181
9182 #[test]
9183 fn submitted_fields_reads_metadata_on_the_wrapped_path() {
9184 let entry = json!({
9186 "input": {
9187 "metadata": { "action": "approve", "email": "a@b.c" },
9188 "text": "hi"
9189 }
9190 });
9191 let fields = submitted_fields(&entry);
9192 assert_eq!(fields.get("email"), Some(&json!("a@b.c")));
9193 assert!(!fields.contains_key("action"));
9194 assert!(!fields.contains_key("text"), "envelope text is not a field");
9195 assert!(
9196 !fields.contains_key("input"),
9197 "the envelope root must be resolved, or `input` becomes one giant field"
9198 );
9199 }
9200
9201 #[test]
9202 fn submitted_fields_lets_the_envelope_root_win_a_collision() {
9203 let entry = json!({
9204 "email": "root@x",
9205 "metadata": { "action": "go", "email": "meta@x" }
9206 });
9207 assert_eq!(
9208 submitted_fields(&entry).get("email"),
9209 Some(&json!("root@x"))
9210 );
9211 }
9212
9213 #[test]
9214 fn submitted_fields_counts_a_root_input_named_action() {
9215 let entry = json!({ "action": "typed", "metadata": { "action": "approve" } });
9218 assert_eq!(
9219 submitted_fields(&entry).get("action"),
9220 Some(&json!("typed"))
9221 );
9222 }
9223
9224 #[test]
9225 fn submitted_fields_is_empty_for_a_button_with_no_inputs() {
9226 let entry = json!({ "metadata": { "action": "approve" } });
9227 assert!(submitted_fields(&entry).is_empty());
9228 }
9229
9230 fn plain_card_node() -> HostNode {
9234 HostNode {
9235 kind: NodeKind::Exec {
9236 target_component: "card".to_string(),
9237 },
9238 component: "component.exec".to_string(),
9239 component_id: "component.exec".to_string(),
9240 operation_name: None,
9241 operation_in_mapping: None,
9242 payload_expr: Value::Null,
9243 routing: Routing::End,
9244 vars_out: None,
9245 }
9246 }
9247
9248 #[test]
9249 fn an_old_snapshot_without_the_flag_deserializes_as_not_awaiting_submit() {
9250 let raw = json!({
9253 "pack_id": "p",
9254 "flow_id": "f",
9255 "next_node": "card",
9256 "state": {}
9257 });
9258 let snap: FlowSnapshot = serde_json::from_value(raw).expect("legacy snapshot decodes");
9259 assert!(!snap.awaiting_submit);
9260 }
9261
9262 #[test]
9270 fn submitted_answers_survive_the_resume_redispatch_and_reach_a_later_node() {
9271 let mut state = ExecutionState::new(json!({}));
9272 state.nodes.insert(
9275 "card".to_string(),
9276 NodeOutput::new(json!({ "event": "rendered" })),
9277 );
9278 state.pending_card_answers = Some(PendingCardAnswers {
9279 node_id: "card".to_string(),
9280 answers: submitted_fields(&json!({
9281 "email": "a@b.c",
9282 "metadata": { "action": "submit" }
9283 })),
9284 });
9285
9286 let mut fresh = NodeOutput::new(json!({ "event": "rendered" }));
9289 attach_pending_card_answers(&mut state, "card", &plain_card_node(), &mut fresh);
9290 state.nodes.insert("card".to_string(), fresh);
9291
9292 let ctx = template_context(&state, Value::Null);
9294 let rendered = render_template_value(
9295 &json!("{{node.card.answers.email}}"),
9296 &ctx,
9297 TemplateOptions::default(),
9298 )
9299 .expect("render");
9300 assert_eq!(rendered, json!("a@b.c"));
9301 }
9302
9303 #[test]
9304 fn the_card_render_context_carries_the_answers_too() {
9305 let mut state = ExecutionState::new(json!({}));
9308 state.pending_card_answers = Some(PendingCardAnswers {
9309 node_id: "card".to_string(),
9310 answers: submitted_fields(&json!({ "email": "a@b.c" })),
9311 });
9312 let mut output = NodeOutput::new(json!({ "event": "rendered" }));
9313 attach_pending_card_answers(&mut state, "card", &plain_card_node(), &mut output);
9314 state.nodes.insert("card".to_string(), output);
9315
9316 let ctx = state.context();
9317 assert_eq!(
9318 ctx["nodes"]["card"]["payload"]["answers"]["email"],
9319 json!("a@b.c")
9320 );
9321 }
9322
9323 #[test]
9324 fn answers_are_absent_before_any_submit() {
9325 let mut state = ExecutionState::new(json!({}));
9328 let mut output = NodeOutput::new(json!({ "event": "rendered" }));
9329 attach_pending_card_answers(&mut state, "card", &plain_card_node(), &mut output);
9330 assert!(output.payload.get("answers").is_none());
9331 }
9332
9333 #[test]
9334 fn a_button_with_no_inputs_yields_an_empty_answers_object() {
9335 let mut state = ExecutionState::new(json!({}));
9336 state.pending_card_answers = Some(PendingCardAnswers {
9337 node_id: "card".to_string(),
9338 answers: submitted_fields(&json!({ "metadata": { "action": "approve" } })),
9339 });
9340 let mut output = NodeOutput::new(json!({ "event": "rendered" }));
9341 attach_pending_card_answers(&mut state, "card", &plain_card_node(), &mut output);
9342 assert_eq!(output.payload["answers"], json!({}));
9343 }
9344
9345 #[test]
9346 fn the_pending_answers_are_consumed_exactly_once() {
9347 let mut state = ExecutionState::new(json!({}));
9350 state.pending_card_answers = Some(PendingCardAnswers {
9351 node_id: "card".to_string(),
9352 answers: submitted_fields(&json!({ "email": "a@b.c" })),
9353 });
9354 let mut first = NodeOutput::new(json!({ "event": "rendered" }));
9355 attach_pending_card_answers(&mut state, "card", &plain_card_node(), &mut first);
9356 assert!(first.payload.get("answers").is_some());
9357
9358 let mut second = NodeOutput::new(json!({ "event": "rendered" }));
9359 attach_pending_card_answers(&mut state, "card", &plain_card_node(), &mut second);
9360 assert!(
9361 second.payload.get("answers").is_none(),
9362 "consumed, not read"
9363 );
9364 }
9365
9366 #[test]
9367 fn answers_attach_even_when_the_redispatch_failed() {
9368 let mut state = ExecutionState::new(json!({}));
9372 state.pending_card_answers = Some(PendingCardAnswers {
9373 node_id: "card".to_string(),
9374 answers: submitted_fields(&json!({ "email": "a@b.c" })),
9375 });
9376 let mut output = NodeOutput::new(json!({ "ok": false, "error": { "code": "boom" } }));
9377 attach_pending_card_answers(&mut state, "card", &plain_card_node(), &mut output);
9378 assert_eq!(output.payload["answers"]["email"], json!("a@b.c"));
9379 }
9380
9381 #[test]
9382 fn answers_are_not_attached_to_a_different_node() {
9383 let mut state = ExecutionState::new(json!({}));
9384 state.pending_card_answers = Some(PendingCardAnswers {
9385 node_id: "card".to_string(),
9386 answers: submitted_fields(&json!({ "email": "a@b.c" })),
9387 });
9388 let mut other = NodeOutput::new(json!({ "event": "rendered" }));
9389 attach_pending_card_answers(&mut state, "next_step", &plain_card_node(), &mut other);
9390 assert!(other.payload.get("answers").is_none());
9391 assert!(
9392 state.pending_card_answers.is_some(),
9393 "still pending for its own node"
9394 );
9395 }
9396
9397 #[test]
9410 fn answers_intersect_the_declared_allow_list_on_a_wrapped_start_envelope() {
9411 let entry = json!({
9412 "input": {
9413 "id": "msg-1",
9414 "tenant": "acme",
9415 "channel": "webchat",
9416 "session_id": "sess-1",
9417 "from": "user-1",
9418 "to": "bot-1",
9419 "correlation_id": "corr-1",
9420 "attachments": [],
9421 "metadata": {
9422 "action": "submit",
9423 "nextCardId": "confirm",
9424 "route": "default",
9425 "env": "prod",
9426 "team": "support",
9427 "locale": "en-US",
9428 "autoStart": true,
9429 "url": "https://api.example.com",
9430 "model": "gpt-4",
9431 "provider": "openai",
9432 "api_key_secret": "secrets://tenant/acme/openai_key",
9433 "full_name": "Ada Lovelace",
9434 "email": "ada@example.com"
9435 },
9436 "text": "submitted"
9437 }
9438 });
9439
9440 let mut state = ExecutionState::new(json!({}));
9441 state.pending_card_answers = Some(PendingCardAnswers {
9442 node_id: "card".to_string(),
9443 answers: submitted_fields(&entry),
9444 });
9445
9446 let node = HostNode {
9447 payload_expr: json!({ "answer_fields": ["full_name", "email"] }),
9448 ..plain_card_node()
9449 };
9450 let mut output = NodeOutput::new(json!({ "event": "rendered" }));
9451 attach_pending_card_answers(&mut state, "card", &node, &mut output);
9452
9453 let answers = output.payload["answers"]
9454 .as_object()
9455 .expect("answers must be an object");
9456 assert_eq!(
9457 answers,
9458 &serde_json::Map::from_iter([
9459 ("full_name".to_string(), json!("Ada Lovelace")),
9460 ("email".to_string(), json!("ada@example.com")),
9461 ]),
9462 "answers must contain exactly the two declared card inputs, and \
9463 nothing from transport identity, routing, or pack config: {answers:?}"
9464 );
9465 }
9466
9467 #[test]
9468 fn answer_fields_absent_keeps_todays_permissive_behaviour() {
9469 let entry = json!({
9473 "input": {
9474 "tenant": "acme",
9475 "metadata": { "action": "submit", "email": "a@b.c" },
9476 "text": "hi"
9477 }
9478 });
9479 let mut state = ExecutionState::new(json!({}));
9480 state.pending_card_answers = Some(PendingCardAnswers {
9481 node_id: "card".to_string(),
9482 answers: submitted_fields(&entry),
9483 });
9484 let mut output = NodeOutput::new(json!({ "event": "rendered" }));
9485 attach_pending_card_answers(&mut state, "card", &plain_card_node(), &mut output);
9486
9487 assert_eq!(output.payload["answers"]["tenant"], json!("acme"));
9488 assert_eq!(output.payload["answers"]["email"], json!("a@b.c"));
9489 }
9490
9491 #[test]
9492 fn answer_fields_declared_empty_yields_no_answers() {
9493 let mut state = ExecutionState::new(json!({}));
9497 state.pending_card_answers = Some(PendingCardAnswers {
9498 node_id: "card".to_string(),
9499 answers: submitted_fields(&json!({
9500 "email": "a@b.c",
9501 "metadata": { "action": "approve" }
9502 })),
9503 });
9504 let node = HostNode {
9505 payload_expr: json!({ "answer_fields": [] }),
9506 ..plain_card_node()
9507 };
9508 let mut output = NodeOutput::new(json!({ "event": "rendered" }));
9509 attach_pending_card_answers(&mut state, "card", &node, &mut output);
9510
9511 assert_eq!(output.payload["answers"], json!({}));
9512 }
9513
9514 #[test]
9528 fn absent_and_malformed_answer_fields_both_stay_permissive_but_declared_filters() {
9529 let entry = json!({ "email": "a@b.c", "phone": "555-1234" });
9530
9531 let mut absent_state = ExecutionState::new(json!({}));
9533 absent_state.pending_card_answers = Some(PendingCardAnswers {
9534 node_id: "card".to_string(),
9535 answers: submitted_fields(&entry),
9536 });
9537 let mut absent_output = NodeOutput::new(json!({ "event": "rendered" }));
9538 attach_pending_card_answers(
9539 &mut absent_state,
9540 "card",
9541 &plain_card_node(),
9542 &mut absent_output,
9543 );
9544 assert_eq!(
9545 absent_output.payload["answers"],
9546 json!({ "email": "a@b.c", "phone": "555-1234" }),
9547 "absent must stay fully permissive"
9548 );
9549
9550 let mut malformed_state = ExecutionState::new(json!({}));
9553 malformed_state.pending_card_answers = Some(PendingCardAnswers {
9554 node_id: "card".to_string(),
9555 answers: submitted_fields(&entry),
9556 });
9557 let malformed_node = HostNode {
9558 payload_expr: json!({ "answer_fields": "{{unresolved.template}}" }),
9559 ..plain_card_node()
9560 };
9561 let mut malformed_output = NodeOutput::new(json!({ "event": "rendered" }));
9562 attach_pending_card_answers(
9563 &mut malformed_state,
9564 "card",
9565 &malformed_node,
9566 &mut malformed_output,
9567 );
9568 assert_eq!(
9569 malformed_output.payload["answers"],
9570 json!({ "email": "a@b.c", "phone": "555-1234" }),
9571 "malformed must ALSO stay fully permissive, same as absent"
9572 );
9573
9574 let mut declared_state = ExecutionState::new(json!({}));
9576 declared_state.pending_card_answers = Some(PendingCardAnswers {
9577 node_id: "card".to_string(),
9578 answers: submitted_fields(&entry),
9579 });
9580 let declared_node = HostNode {
9581 payload_expr: json!({ "answer_fields": ["email"] }),
9582 ..plain_card_node()
9583 };
9584 let mut declared_output = NodeOutput::new(json!({ "event": "rendered" }));
9585 attach_pending_card_answers(
9586 &mut declared_state,
9587 "card",
9588 &declared_node,
9589 &mut declared_output,
9590 );
9591 assert_eq!(
9592 declared_output.payload["answers"],
9593 json!({ "email": "a@b.c" }),
9594 "declared must filter down to exactly the allow-listed keys"
9595 );
9596 }
9597
9598 #[test]
9599 fn pending_from_snapshot_names_next_node_when_awaiting_submit() {
9600 let snapshot = FlowSnapshot {
9601 pack_id: "p".to_string(),
9602 flow_id: "f".to_string(),
9603 next_flow: None,
9604 next_node: "card".to_string(),
9605 awaiting_submit: true,
9606 state: ExecutionState::new(json!({})),
9607 };
9608 let input = json!({ "email": "a@b.c", "metadata": { "action": "submit" } });
9609 let pending = pending_from_snapshot(&snapshot, &input).expect("should park answers");
9610 assert_eq!(pending.node_id, "card");
9611 assert_eq!(pending.answers.get("email"), Some(&json!("a@b.c")));
9612 }
9613
9614 #[test]
9615 fn pending_from_snapshot_is_none_when_not_awaiting_submit() {
9616 let snapshot = FlowSnapshot {
9617 pack_id: "p".to_string(),
9618 flow_id: "f".to_string(),
9619 next_flow: None,
9620 next_node: "successor".to_string(),
9621 awaiting_submit: false,
9622 state: ExecutionState::new(json!({})),
9623 };
9624 let input = json!({ "email": "a@b.c" });
9625 assert!(pending_from_snapshot(&snapshot, &input).is_none());
9626 }
9627
9628 fn two_card_chain_flow() -> Flow {
9639 let card_node = |id: &str, to: Option<&str>| Node {
9640 id: NodeId::from_str(id).unwrap(),
9641 component: FlowComponentRef {
9642 id: "emit.log".parse().unwrap(),
9643 pack_alias: None,
9644 operation: None,
9645 },
9646 input: InputMapping {
9647 mapping: json!({ "card": id }),
9648 },
9649 output: OutputMapping {
9650 mapping: Value::Null,
9651 },
9652 err_map: None,
9653 routing: match to {
9654 Some(target) => Routing::Custom(json!([
9655 { "condition": "response.action == \"submit\"", "to": target }
9656 ])),
9657 None => Routing::End,
9658 },
9659 telemetry: TelemetryHints::default(),
9660 conversational: false,
9661 };
9662
9663 let mut nodes = indexmap::IndexMap::default();
9664 for (id, to) in [
9665 ("card1", Some("card2")),
9666 ("card2", Some("card3")),
9667 ("card3", None),
9668 ] {
9669 nodes.insert(NodeId::from_str(id).unwrap(), card_node(id, to));
9670 }
9671
9672 Flow {
9673 schema_version: "1.0".into(),
9674 id: FlowId::from_str("two.card.flow").unwrap(),
9675 kind: FlowKind::Messaging,
9676 entrypoints: BTreeMap::from([(
9677 "default".to_string(),
9678 Value::String("card1".to_string()),
9679 )]),
9680 nodes,
9681 metadata: FlowMetadata {
9682 title: None,
9683 description: None,
9684 tags: Default::default(),
9685 extra: json!({}),
9686 },
9687 }
9688 }
9689
9690 #[test]
9701 fn one_submit_advances_exactly_one_card() {
9702 let host_flow = HostFlow::from(two_card_chain_flow());
9703 let (flow_id, pack_id) = ("two.card.flow", "test-pack");
9704 let engine = FlowEngine {
9705 rollout_ids: RolloutIds::default(),
9706 packs: Vec::new(),
9707 flows: Vec::new(),
9708 flow_sources: StdHashMap::new(),
9709 messaging_provider_pack_ids: std::collections::HashSet::new(),
9710 flow_cache: RwLock::new(StdHashMap::from([(
9711 FlowKey {
9712 pack_id: pack_id.to_string(),
9713 flow_id: flow_id.to_string(),
9714 },
9715 host_flow,
9716 )])),
9717 default_env: "local".to_string(),
9718 validation: ValidationConfig {
9719 mode: ValidationMode::Off,
9720 },
9721 cross_pack_resolver: None,
9722 remote_dispatch_handler: None,
9723 #[cfg(feature = "agentic-worker")]
9724 dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
9725 #[cfg(feature = "agentic-worker")]
9726 agent_node_handler: None,
9727 #[cfg(feature = "agentic-worker")]
9728 graph_node_handler: None,
9729 #[cfg(feature = "agentic-worker")]
9730 mcp_tool_source: None,
9731 };
9732 let rt = Runtime::new().unwrap();
9733 let ctx = || FlowContext {
9734 tenant: "demo",
9735 pack_id,
9736 flow_id,
9737 node_id: None,
9738 tool: None,
9739 action: None,
9740 session_id: None,
9741 provider_id: None,
9742 reply_scope: None,
9743 retry_config: RetryConfig {
9744 max_attempts: 1,
9745 base_delay_ms: 1,
9746 },
9747 attempt: 1,
9748 observer: None,
9749 mocks: None,
9750 };
9751
9752 let first = rt.block_on(engine.execute(ctx(), Value::Null)).unwrap();
9754 let snapshot = match first.status {
9755 FlowStatus::Waiting(w) => w.snapshot,
9756 other => panic!("expected Waiting at card1, got {other:?}"),
9757 };
9758 assert_eq!(snapshot.next_node, "card1");
9759
9760 let submit = json!({ "input": { "metadata": { "action": "submit" } } });
9762 let second = rt.block_on(engine.resume(ctx(), snapshot, submit)).unwrap();
9763 match second.status {
9764 FlowStatus::Waiting(w) => assert_eq!(
9765 w.snapshot.next_node, "card2",
9766 "one submit must advance exactly one card and park at the next"
9767 ),
9768 FlowStatus::Completed => panic!(
9769 "the run reached the terminal node: the consumed action re-fired \
9770 at card2 and skipped it"
9771 ),
9772 }
9773 }
9774
9775 fn card_answers_flow() -> Flow {
9776 let card_id = NodeId::from_str("card").unwrap();
9777 let next_id = NodeId::from_str("next").unwrap();
9778
9779 let card_node = Node {
9780 id: card_id.clone(),
9781 component: FlowComponentRef {
9782 id: "emit.log".parse().unwrap(),
9783 pack_alias: None,
9784 operation: None,
9785 },
9786 input: InputMapping {
9787 mapping: json!({ "event": "rendered" }),
9788 },
9789 output: OutputMapping {
9790 mapping: Value::Null,
9791 },
9792 err_map: None,
9793 routing: Routing::Custom(json!([
9794 { "condition": "response.action == \"submit\"", "to": next_id.to_string() }
9795 ])),
9796 telemetry: TelemetryHints::default(),
9797 conversational: false,
9798 };
9799
9800 let next_node = Node {
9801 id: next_id.clone(),
9802 component: FlowComponentRef {
9803 id: "emit.response".parse().unwrap(),
9804 pack_alias: None,
9805 operation: None,
9806 },
9807 input: InputMapping {
9808 mapping: json!({ "text": "{{node.card.answers.email}}" }),
9809 },
9810 output: OutputMapping {
9811 mapping: Value::Null,
9812 },
9813 err_map: None,
9814 routing: Routing::End,
9815 telemetry: TelemetryHints::default(),
9816 conversational: false,
9817 };
9818
9819 let mut nodes = indexmap::IndexMap::default();
9820 nodes.insert(card_id.clone(), card_node);
9821 nodes.insert(next_id.clone(), next_node);
9822
9823 Flow {
9824 schema_version: "1.0".into(),
9825 id: FlowId::from_str("card.answers.flow").unwrap(),
9826 kind: FlowKind::Messaging,
9827 entrypoints: BTreeMap::from([(
9828 "default".to_string(),
9829 Value::String(card_id.to_string()),
9830 )]),
9831 nodes,
9832 metadata: FlowMetadata {
9833 title: None,
9834 description: None,
9835 tags: Default::default(),
9836 extra: json!({}),
9837 },
9838 }
9839 }
9840
9841 #[test]
9852 fn card_answers_survive_a_real_park_and_resume_and_reach_a_later_node() {
9853 let flow = card_answers_flow();
9854 let host_flow = HostFlow::from(flow);
9855 let flow_id = "card.answers.flow";
9856 let pack_id = "test-pack";
9857 let engine = FlowEngine {
9858 rollout_ids: RolloutIds::default(),
9859 packs: Vec::new(),
9860 flows: Vec::new(),
9861 flow_sources: StdHashMap::new(),
9862 messaging_provider_pack_ids: std::collections::HashSet::new(),
9863 flow_cache: RwLock::new(StdHashMap::from([(
9864 FlowKey {
9865 pack_id: pack_id.to_string(),
9866 flow_id: flow_id.to_string(),
9867 },
9868 host_flow,
9869 )])),
9870 default_env: "local".to_string(),
9871 validation: ValidationConfig {
9872 mode: ValidationMode::Off,
9873 },
9874 cross_pack_resolver: None,
9875 remote_dispatch_handler: None,
9876 #[cfg(feature = "agentic-worker")]
9877 dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
9878 #[cfg(feature = "agentic-worker")]
9879 agent_node_handler: None,
9880 #[cfg(feature = "agentic-worker")]
9881 graph_node_handler: None,
9882 #[cfg(feature = "agentic-worker")]
9883 mcp_tool_source: None,
9884 };
9885 let rt = Runtime::new().unwrap();
9886
9887 let ctx1 = FlowContext {
9888 tenant: "demo",
9889 pack_id,
9890 flow_id,
9891 node_id: None,
9892 tool: None,
9893 action: None,
9894 session_id: None,
9895 provider_id: None,
9896 reply_scope: None,
9897 retry_config: RetryConfig {
9898 max_attempts: 1,
9899 base_delay_ms: 1,
9900 },
9901 attempt: 1,
9902 observer: None,
9903 mocks: None,
9904 };
9905 let result1 = rt.block_on(engine.execute(ctx1, Value::Null)).unwrap();
9908 let snapshot = match result1.status {
9909 FlowStatus::Waiting(w) => w.snapshot,
9910 other => panic!("expected Waiting at the card node, got {other:?}"),
9911 };
9912 assert!(
9913 snapshot.awaiting_submit,
9914 "the card's conditional fall-through must set awaiting_submit"
9915 );
9916 assert_eq!(snapshot.next_node, "card");
9917
9918 let ctx2 = FlowContext {
9923 tenant: "demo",
9924 pack_id,
9925 flow_id,
9926 node_id: None,
9927 tool: None,
9928 action: None,
9929 session_id: None,
9930 provider_id: None,
9931 reply_scope: None,
9932 retry_config: RetryConfig {
9933 max_attempts: 1,
9934 base_delay_ms: 1,
9935 },
9936 attempt: 1,
9937 observer: None,
9938 mocks: None,
9939 };
9940 let input = json!({ "email": "a@b.c", "metadata": { "action": "submit" } });
9941 let result2 = rt.block_on(engine.resume(ctx2, snapshot, input)).unwrap();
9942 assert!(
9943 matches!(result2.status, FlowStatus::Completed),
9944 "the submit must route past the card to `next`"
9945 );
9946 let rendered = serde_json::to_string(&result2.output).expect("encode output");
9951 assert!(
9952 rendered.contains("a@b.c"),
9953 "`next`, a LATER node, must read the answers through \
9954 {{node.card.answers.email}}; got {rendered}"
9955 );
9956 }
9957}
9958
9959use tracing::Instrument;
9960
9961pub struct FlowContext<'a> {
9962 pub tenant: &'a str,
9963 pub pack_id: &'a str,
9964 pub flow_id: &'a str,
9965 pub node_id: Option<&'a str>,
9966 pub tool: Option<&'a str>,
9967 pub action: Option<&'a str>,
9968 pub session_id: Option<&'a str>,
9969 pub provider_id: Option<&'a str>,
9970 pub reply_scope: Option<&'a greentic_types::ReplyScope>,
9978 pub retry_config: RetryConfig,
9979 pub attempt: u32,
9980 pub observer: Option<&'a dyn ExecutionObserver>,
9981 pub mocks: Option<&'a MockLayer>,
9982}
9983
9984#[derive(Copy, Clone)]
9985pub struct RetryConfig {
9986 pub max_attempts: u32,
9987 pub base_delay_ms: u64,
9988}
9989
9990fn mcp_output(bound: Value, result: &Value) -> NodeOutput {
10019 let Some(message) = result.get("error") else {
10020 return NodeOutput::new(bound);
10021 };
10022 let message = message
10023 .as_str()
10024 .map(str::to_string)
10025 .unwrap_or_else(|| message.to_string());
10026 NodeOutput {
10027 ok: false,
10028 payload: bound,
10029 meta: json!({
10030 "error": {
10031 "kind": "mcp_call_failed",
10032 "message": message,
10033 }
10034 }),
10035 }
10036}
10037
10038fn lift_first_node_error_from_nodes(output: Value, nodes: &HashMap<String, NodeOutput>) -> Value {
10039 let Some((node_id, failed)) = nodes.iter().find(|(_, out)| !out.ok) else {
10040 return output;
10041 };
10042 let err_meta = failed.meta.get("error");
10043 let message = err_meta
10044 .and_then(|e| e.get("message"))
10045 .and_then(|v| v.as_str())
10046 .unwrap_or("flow node failed");
10047 let kind = err_meta
10048 .and_then(|e| e.get("kind"))
10049 .and_then(|v| v.as_str())
10050 .unwrap_or("flow_node_failed");
10051
10052 let mut output = match output {
10053 Value::Object(map) => map,
10054 Value::Null => JsonMap::new(),
10055 other => {
10056 let mut wrap = JsonMap::new();
10057 wrap.insert("payload".to_string(), other);
10058 wrap
10059 }
10060 };
10061 let metadata_entry = output
10062 .entry("metadata".to_string())
10063 .or_insert_with(|| Value::Object(JsonMap::new()));
10064 let metadata_map = match metadata_entry {
10065 Value::Object(map) => map,
10066 _ => {
10067 *metadata_entry = Value::Object(JsonMap::new());
10068 metadata_entry.as_object_mut().unwrap()
10069 }
10070 };
10071 metadata_map
10072 .entry("error_kind".to_string())
10073 .or_insert(Value::String(kind.to_string()));
10074 metadata_map
10075 .entry("error_message".to_string())
10076 .or_insert(Value::String(message.to_string()));
10077 metadata_map
10078 .entry("node_id".to_string())
10079 .or_insert(Value::String(node_id.clone()));
10080 Value::Object(output)
10081}
10082
10083fn should_retry(err: &anyhow::Error) -> bool {
10084 let lower = err.to_string().to_lowercase();
10085 lower.contains("transient")
10086 || lower.contains("unavailable")
10087 || lower.contains("internal")
10088 || lower.contains("timeout")
10089}
10090
10091impl From<FlowRetryConfig> for RetryConfig {
10092 fn from(value: FlowRetryConfig) -> Self {
10093 Self {
10094 max_attempts: value.max_attempts.max(1),
10095 base_delay_ms: value.base_delay_ms.max(50),
10096 }
10097 }
10098}