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