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