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 async move {
350 let mut attempt = 0u32;
351 loop {
352 attempt += 1;
353 ctx.attempt = attempt;
354 #[cfg(feature = "fault-injection")]
355 {
356 let fault_ctx = FaultContext {
357 pack_id: ctx.pack_id,
358 flow_id: ctx.flow_id,
359 node_id: ctx.node_id,
360 attempt: ctx.attempt,
361 };
362 maybe_fail(FaultPoint::Timeout, fault_ctx)
363 .map_err(|err| anyhow!(err.to_string()))?;
364 }
365 match self.execute_once(&ctx, original_input.clone()).await {
366 Ok(value) => return Ok(value),
367 Err(err) => {
368 if attempt >= retry_config.max_attempts || !should_retry(&err) {
369 return Err(err);
370 }
371 let delay = backoff_delay_ms(retry_config.base_delay_ms, attempt - 1);
372 tracing::warn!(
373 tenant = ctx.tenant,
374 flow_id = ctx.flow_id,
375 attempt,
376 max_attempts = retry_config.max_attempts,
377 delay_ms = delay,
378 error = %err,
379 "transient flow execution failure, backing off"
380 );
381 tokio::time::sleep(Duration::from_millis(delay)).await;
382 }
383 }
384 }
385 }
386 .instrument(span)
387 .await
388 }
389
390 pub async fn resume(
391 &self,
392 ctx: FlowContext<'_>,
393 snapshot: FlowSnapshot,
394 input: Value,
395 ) -> Result<FlowExecution> {
396 if snapshot.pack_id != ctx.pack_id {
397 bail!(
398 "snapshot pack {} does not match requested {}",
399 snapshot.pack_id,
400 ctx.pack_id
401 );
402 }
403 let resume_flow = snapshot
404 .next_flow
405 .clone()
406 .unwrap_or_else(|| snapshot.flow_id.clone());
407 let flow_ir = self.get_or_load_flow(ctx.pack_id, &resume_flow).await?;
408 let mut state = snapshot.state;
409 state.replace_input(input);
410 state.ensure_entry();
411 self.drive_flow(&ctx, flow_ir, state, Some(snapshot.next_node), resume_flow)
412 .await
413 }
414
415 async fn execute_once(&self, ctx: &FlowContext<'_>, input: Value) -> Result<FlowExecution> {
416 let flow_ir = self.get_or_load_flow(ctx.pack_id, ctx.flow_id).await?;
417 let state = ExecutionState::new(input);
418 self.drive_flow(ctx, flow_ir, state, None, ctx.flow_id.to_string())
419 .await
420 }
421
422 async fn drive_flow(
423 &self,
424 ctx: &FlowContext<'_>,
425 mut flow_ir: HostFlow,
426 mut state: ExecutionState,
427 resume_from: Option<String>,
428 mut current_flow_id: String,
429 ) -> Result<FlowExecution> {
430 let mut current = match resume_from {
431 Some(node) => NodeId::from_str(&node)
432 .with_context(|| format!("invalid resume node id `{node}`"))?,
433 None => flow_ir
434 .start
435 .clone()
436 .or_else(|| flow_ir.nodes.keys().next().cloned())
437 .with_context(|| format!("flow {} has no start node", flow_ir.id))?,
438 };
439
440 loop {
441 let step_ctx = FlowContext {
442 tenant: ctx.tenant,
443 pack_id: ctx.pack_id,
444 flow_id: current_flow_id.as_str(),
445 node_id: ctx.node_id,
446 tool: ctx.tool,
447 action: ctx.action,
448 session_id: ctx.session_id,
449 provider_id: ctx.provider_id,
450 retry_config: ctx.retry_config,
451 attempt: ctx.attempt,
452 observer: ctx.observer,
453 mocks: ctx.mocks,
454 };
455 let node = flow_ir
456 .nodes
457 .get(¤t)
458 .with_context(|| format!("node {} not found", current.as_str()))?;
459
460 let payload_template = node.payload_expr.clone();
461 let prev = state
462 .last_output
463 .as_ref()
464 .cloned()
465 .unwrap_or_else(|| Value::Object(JsonMap::new()));
466 let ctx_value = template_context(&state, prev);
467 #[cfg(feature = "fault-injection")]
468 {
469 let fault_ctx = FaultContext {
470 pack_id: ctx.pack_id,
471 flow_id: ctx.flow_id,
472 node_id: Some(current.as_str()),
473 attempt: ctx.attempt,
474 };
475 maybe_fail(FaultPoint::TemplateRender, fault_ctx)
476 .map_err(|err| anyhow!(err.to_string()))?;
477 }
478 let payload =
479 render_template_value(&payload_template, &ctx_value, TemplateOptions::default())
480 .context("failed to render node input template")?;
481 let observed_payload = payload.clone();
482 let node_id = current.clone();
483 let event = NodeEvent {
484 context: &step_ctx,
485 node_id: node_id.as_str(),
486 node,
487 payload: &observed_payload,
488 };
489 if let Some(observer) = step_ctx.observer {
490 observer.on_node_start(&event);
491 }
492 let dispatch = self
493 .dispatch_node(
494 &step_ctx,
495 node_id.as_str(),
496 node,
497 &mut state,
498 payload,
499 &event,
500 )
501 .await;
502 let DispatchOutcome { output, control } = match dispatch {
503 Ok(outcome) => outcome,
504 Err(err) => {
505 if let Some(observer) = step_ctx.observer {
506 observer.on_node_error(&event, err.as_ref());
507 }
508 return Err(err);
509 }
510 };
511
512 state.nodes.insert(node_id.clone().into(), output.clone());
513 state.last_output = Some(output.payload.clone());
514 if let Some(observer) = step_ctx.observer {
515 observer.on_node_end(&event, &output.payload);
516 }
517
518 match control {
519 NodeControl::Continue => {
520 let (next, should_exit) = match &node.routing {
521 Routing::Next { node_id } => (Some(node_id.clone()), false),
522 Routing::End | Routing::Reply => (None, true),
523 Routing::Branch { default, .. } => (default.clone(), default.is_none()),
524 Routing::Custom(raw) => {
525 evaluate_custom_routing(raw, &output, &state, &flow_ir, &node_id)
526 }
527 };
528
529 if should_exit {
530 return Ok(FlowExecution::completed(
531 state.finalize_with(Some(output.payload.clone())),
532 ));
533 }
534
535 match next {
536 Some(n) => current = n,
537 None => {
538 return Ok(FlowExecution::completed(
539 state.finalize_with(Some(output.payload.clone())),
540 ));
541 }
542 }
543 }
544 NodeControl::Wait { reason } => {
545 let (next, _) = match &node.routing {
546 Routing::Next { node_id } => (Some(node_id.clone()), false),
547 Routing::End | Routing::Reply => (None, true),
548 Routing::Branch { default, .. } => (default.clone(), default.is_none()),
549 Routing::Custom(raw) => {
550 evaluate_custom_routing(raw, &output, &state, &flow_ir, &node_id)
551 }
552 };
553 let resume_target = next.ok_or_else(|| {
554 anyhow!(
555 "session.wait node {} requires a non-empty route",
556 current.as_str()
557 )
558 })?;
559 let mut snapshot_state = state.clone();
560 snapshot_state.clear_egress();
561 let snapshot = FlowSnapshot {
562 pack_id: step_ctx.pack_id.to_string(),
563 flow_id: step_ctx.flow_id.to_string(),
564 next_flow: (current_flow_id != step_ctx.flow_id)
565 .then_some(current_flow_id.clone()),
566 next_node: resume_target.as_str().to_string(),
567 state: snapshot_state,
568 };
569 let output_value = state.clone().finalize_with(None);
570 return Ok(FlowExecution::waiting(
571 output_value,
572 FlowWait { reason, snapshot },
573 ));
574 }
575 NodeControl::Jump(jump) => {
576 let jump_target = self.apply_jump(&step_ctx, &mut state, jump).await?;
577 flow_ir = jump_target.flow;
578 current_flow_id = jump_target.flow_id;
579 current = jump_target.node_id;
580 }
581 NodeControl::Respond {
582 text,
583 card_cbor,
584 needs_user,
585 } => {
586 let response = json!({
587 "text": text,
588 "card_cbor": card_cbor,
589 "needs_user": needs_user,
590 });
591 state.push_egress(response);
592 return Ok(FlowExecution::completed(state.finalize_with(None)));
593 }
594 }
595 }
596 }
597
598 async fn dispatch_node(
599 &self,
600 ctx: &FlowContext<'_>,
601 node_id: &str,
602 node: &HostNode,
603 state: &mut ExecutionState,
604 mut payload: Value,
605 event: &NodeEvent<'_>,
606 ) -> Result<DispatchOutcome> {
607 inject_card_locale(&mut payload, &state.entry);
608 match &node.kind {
609 NodeKind::Exec { target_component } => self
610 .execute_component_exec(
611 ctx,
612 node_id,
613 node,
614 payload,
615 event,
616 ComponentOverrides {
617 component: Some(target_component.as_str()),
618 operation: node.operation_name.as_deref(),
619 },
620 )
621 .await
622 .and_then(component_dispatch_outcome),
623 NodeKind::PackComponent { component_ref } => self
624 .execute_component_call(ctx, node_id, node, payload, component_ref.as_str(), event)
625 .await
626 .and_then(component_dispatch_outcome),
627 NodeKind::FlowCall => self
628 .execute_flow_call(ctx, payload)
629 .await
630 .map(DispatchOutcome::complete),
631 NodeKind::ProviderInvoke => self
632 .execute_provider_invoke(ctx, node_id, state, payload, event)
633 .await
634 .map(DispatchOutcome::complete),
635 NodeKind::BuiltinEmit { kind } => {
636 match kind {
637 EmitKind::Log | EmitKind::Response => {}
638 EmitKind::Other(component) => {
639 tracing::debug!(%component, "handling emit.* as builtin");
640 }
641 }
642 state.push_egress(payload.clone());
643 Ok(DispatchOutcome::complete(NodeOutput::new(payload)))
644 }
645 NodeKind::BuiltinStateGet => self
646 .execute_state_get(ctx, payload)
647 .await
648 .map(DispatchOutcome::complete),
649 NodeKind::BuiltinStateSet => self
650 .execute_state_set(ctx, payload)
651 .await
652 .map(DispatchOutcome::complete),
653 NodeKind::Wait => {
654 let reason = extract_wait_reason(&payload);
655 Ok(DispatchOutcome::wait(NodeOutput::new(payload), reason))
656 }
657 }
658 }
659
660 async fn execute_state_get(&self, ctx: &FlowContext<'_>, payload: Value) -> Result<NodeOutput> {
661 let key = Self::extract_state_key_helper(&payload)?;
662 let pack = self.pack_for_flow(ctx)?;
663 let store = pack
664 .state_store_handle()
665 .context("state store is not configured for this runtime")?;
666 let tenant_ctx = self.state_tenant_ctx(ctx)?;
667 let state_key = greentic_state::StateKey::new(&key);
668 let value = store
669 .get_json(
670 &tenant_ctx,
671 crate::storage::state::STATE_PREFIX,
672 &state_key,
673 None,
674 )
675 .with_context(|| format!("state.get failed for key `{key}`"))?;
676 let payload = serde_json::json!({
677 "key": key,
678 "value": value,
679 "found": value.is_some(),
680 });
681 Ok(NodeOutput::new(payload))
682 }
683
684 async fn execute_state_set(&self, ctx: &FlowContext<'_>, payload: Value) -> Result<NodeOutput> {
685 let key = Self::extract_state_key_helper(&payload)?;
686 let value = payload.get("value").cloned().unwrap_or(Value::Null);
687 let pack = self.pack_for_flow(ctx)?;
688 let store = pack
689 .state_store_handle()
690 .context("state store is not configured for this runtime")?;
691 let tenant_ctx = self.state_tenant_ctx(ctx)?;
692 let state_key = greentic_state::StateKey::new(&key);
693 store
694 .set_json(
695 &tenant_ctx,
696 crate::storage::state::STATE_PREFIX,
697 &state_key,
698 None,
699 &value,
700 None,
701 )
702 .with_context(|| format!("state.set failed for key `{key}`"))?;
703 let payload = serde_json::json!({ "key": key, "value": value });
704 Ok(NodeOutput::new(payload))
705 }
706
707 fn pack_for_flow(&self, ctx: &FlowContext<'_>) -> Result<&Arc<PackRuntime>> {
708 let key = FlowKey {
709 pack_id: ctx.pack_id.to_string(),
710 flow_id: ctx.flow_id.to_string(),
711 };
712 let idx = self.flow_sources.get(&key).with_context(|| {
713 format!("flow {} (pack {}) not registered", ctx.flow_id, ctx.pack_id)
714 })?;
715 Ok(&self.packs[*idx])
716 }
717
718 fn extract_state_key_helper(payload: &Value) -> Result<String> {
719 payload
720 .get("key")
721 .and_then(Value::as_str)
722 .map(String::from)
723 .filter(|k| !k.is_empty())
724 .context("state node payload missing required `key` (non-empty string)")
725 }
726
727 fn state_tenant_ctx(&self, ctx: &FlowContext<'_>) -> Result<greentic_types::TenantCtx> {
728 let env = greentic_types::EnvId::from_str(&self.default_env)
729 .with_context(|| format!("invalid env id `{}`", self.default_env))?;
730 let tenant = greentic_types::TenantId::from_str(ctx.tenant)
731 .with_context(|| format!("invalid tenant id `{}`", ctx.tenant))?;
732 Ok(greentic_types::TenantCtx::new(env, tenant))
733 }
734
735 async fn apply_jump(
736 &self,
737 ctx: &FlowContext<'_>,
738 state: &mut ExecutionState,
739 jump: JumpControl,
740 ) -> Result<JumpTarget> {
741 let target_flow = jump.flow.trim();
742 if target_flow.is_empty() {
743 bail!("missing_flow");
744 }
745
746 let flow = self
747 .get_or_load_flow(ctx.pack_id, target_flow)
748 .await
749 .with_context(|| format!("unknown_flow:{target_flow}"))?;
750
751 let target_node = if let Some(node) = jump.node.as_deref() {
752 let parsed = NodeId::from_str(node).with_context(|| format!("unknown_node:{node}"))?;
753 if !flow.nodes.contains_key(&parsed) {
754 bail!("unknown_node:{node}");
755 }
756 parsed
757 } else {
758 flow.start
759 .clone()
760 .or_else(|| flow.nodes.keys().next().cloned())
761 .ok_or_else(|| anyhow!("jump_failed: flow {target_flow} has no start node"))?
762 };
763
764 let max_redirects = jump.max_redirects.unwrap_or(3);
765 if state.redirect_count() >= max_redirects {
766 bail!("redirect_limit");
767 }
768 state.increment_redirect_count();
769 state.replace_input(jump.payload.clone());
770 state.last_output = Some(jump.payload);
771 tracing::info!(
772 flow_id = %ctx.flow_id,
773 target_flow = %target_flow,
774 target_node = %target_node.as_str(),
775 reason = ?jump.reason,
776 redirects = state.redirect_count(),
777 "flow.jump.applied"
778 );
779
780 Ok(JumpTarget {
781 flow_id: target_flow.to_string(),
782 flow,
783 node_id: target_node,
784 })
785 }
786
787 async fn execute_flow_call(&self, ctx: &FlowContext<'_>, payload: Value) -> Result<NodeOutput> {
788 #[derive(Deserialize)]
789 struct FlowCallPayload {
790 #[serde(alias = "flow")]
791 flow_id: String,
792 #[serde(default)]
793 input: Value,
794 }
795
796 let call: FlowCallPayload =
797 serde_json::from_value(payload).context("invalid payload for flow.call node")?;
798 if call.flow_id.trim().is_empty() {
799 bail!("flow.call requires a non-empty flow_id");
800 }
801
802 let sub_input = if call.input.is_null() {
803 Value::Null
804 } else {
805 call.input
806 };
807
808 let flow_id_owned = call.flow_id;
809 let action = "flow.call";
810 let sub_ctx = FlowContext {
811 tenant: ctx.tenant,
812 pack_id: ctx.pack_id,
813 flow_id: flow_id_owned.as_str(),
814 node_id: None,
815 tool: ctx.tool,
816 action: Some(action),
817 session_id: ctx.session_id,
818 provider_id: ctx.provider_id,
819 retry_config: ctx.retry_config,
820 attempt: ctx.attempt,
821 observer: ctx.observer,
822 mocks: ctx.mocks,
823 };
824
825 let execution = Box::pin(self.execute(sub_ctx, sub_input))
826 .await
827 .with_context(|| format!("flow.call failed for {}", flow_id_owned))?;
828 match execution.status {
829 FlowStatus::Completed => Ok(NodeOutput::new(execution.output)),
830 FlowStatus::Waiting(wait) => bail!(
831 "flow.call cannot pause (flow {} waiting {:?})",
832 flow_id_owned,
833 wait.reason
834 ),
835 }
836 }
837
838 async fn execute_component_exec(
839 &self,
840 ctx: &FlowContext<'_>,
841 node_id: &str,
842 node: &HostNode,
843 payload: Value,
844 event: &NodeEvent<'_>,
845 overrides: ComponentOverrides<'_>,
846 ) -> Result<NodeOutput> {
847 #[derive(Deserialize)]
848 struct ComponentPayload {
849 #[serde(default, alias = "component_ref", alias = "component")]
850 component: Option<String>,
851 #[serde(alias = "op")]
852 operation: Option<String>,
853 #[serde(default)]
854 input: Value,
855 #[serde(default)]
856 config: Value,
857 }
858
859 let payload: ComponentPayload =
860 serde_json::from_value(payload).context("invalid payload for component.exec")?;
861 let component_ref = overrides
862 .component
863 .map(str::to_string)
864 .or_else(|| payload.component.filter(|v| !v.trim().is_empty()))
865 .with_context(|| "component.exec requires a component_ref")?;
866 let operation = resolve_component_operation(
867 node_id,
868 node.component_id.as_str(),
869 payload.operation,
870 overrides.operation,
871 node.operation_in_mapping.as_deref(),
872 )?;
873 let call = ComponentCall {
874 component_ref,
875 operation,
876 input: payload.input,
877 config: payload.config,
878 };
879
880 self.invoke_component_call(ctx, node_id, call, event).await
881 }
882
883 async fn execute_component_call(
884 &self,
885 ctx: &FlowContext<'_>,
886 node_id: &str,
887 node: &HostNode,
888 payload: Value,
889 component_ref: &str,
890 event: &NodeEvent<'_>,
891 ) -> Result<NodeOutput> {
892 let payload_operation = extract_operation_from_mapping(&payload);
893 let (input, config) = split_operation_payload(payload);
894 let operation = resolve_component_operation(
895 node_id,
896 node.component_id.as_str(),
897 payload_operation,
898 node.operation_name.as_deref(),
899 node.operation_in_mapping.as_deref(),
900 )?;
901 let call = ComponentCall {
902 component_ref: component_ref.to_string(),
903 operation,
904 input,
905 config,
906 };
907 self.invoke_component_call(ctx, node_id, call, event).await
908 }
909
910 async fn invoke_component_call(
911 &self,
912 ctx: &FlowContext<'_>,
913 node_id: &str,
914 mut call: ComponentCall,
915 event: &NodeEvent<'_>,
916 ) -> Result<NodeOutput> {
917 self.validate_component(ctx, event, &call)?;
918 let key = FlowKey {
919 pack_id: ctx.pack_id.to_string(),
920 flow_id: ctx.flow_id.to_string(),
921 };
922 let pack_idx = *self.flow_sources.get(&key).with_context(|| {
923 format!("flow {} (pack {}) not registered", ctx.flow_id, ctx.pack_id)
924 })?;
925 let pack = Arc::clone(&self.packs[pack_idx]);
926
927 promote_card_config_to_invocation(&mut call.input, &call.config);
934
935 resolve_card_assets(&mut call.input, &pack);
939
940 let is_card = is_card_invocation(&call.input);
948
949 let input_json = if is_card {
950 serde_json::to_string(&call.input)?
951 } else {
952 let meta = InvocationMeta {
954 env: &self.default_env,
955 tenant: ctx.tenant,
956 flow_id: ctx.flow_id,
957 node_id: Some(node_id),
958 provider_id: ctx.provider_id,
959 session_id: ctx.session_id,
960 attempt: ctx.attempt,
961 };
962 let invocation_envelope =
963 build_invocation_envelope(meta, call.operation.as_str(), call.input)
964 .context("build invocation envelope for component call")?;
965 serde_json::to_string(&invocation_envelope)?
966 };
967 let config_json = if call.config.is_null() {
968 None
969 } else {
970 Some(serde_json::to_string(&call.config)?)
971 };
972
973 let exec_ctx = component_exec_ctx(ctx, node_id);
974 #[cfg(feature = "fault-injection")]
975 {
976 let fault_ctx = FaultContext {
977 pack_id: ctx.pack_id,
978 flow_id: ctx.flow_id,
979 node_id: Some(node_id),
980 attempt: ctx.attempt,
981 };
982 maybe_fail(FaultPoint::BeforeComponentCall, fault_ctx)
983 .map_err(|err| anyhow!(err.to_string()))?;
984 }
985 let value = pack
986 .invoke_component(
987 call.component_ref.as_str(),
988 exec_ctx,
989 call.operation.as_str(),
990 config_json,
991 input_json,
992 )
993 .await?;
994 #[cfg(feature = "fault-injection")]
995 {
996 let fault_ctx = FaultContext {
997 pack_id: ctx.pack_id,
998 flow_id: ctx.flow_id,
999 node_id: Some(node_id),
1000 attempt: ctx.attempt,
1001 };
1002 maybe_fail(FaultPoint::AfterComponentCall, fault_ctx)
1003 .map_err(|err| anyhow!(err.to_string()))?;
1004 }
1005
1006 if let Some((code, message)) = component_error(&value) {
1007 bail!(
1008 "component {} failed: {}: {}",
1009 call.component_ref,
1010 code,
1011 message
1012 );
1013 }
1014 Ok(NodeOutput::new(value))
1015 }
1016
1017 async fn execute_provider_invoke(
1018 &self,
1019 ctx: &FlowContext<'_>,
1020 node_id: &str,
1021 state: &ExecutionState,
1022 payload: Value,
1023 event: &NodeEvent<'_>,
1024 ) -> Result<NodeOutput> {
1025 #[derive(Deserialize)]
1026 struct ProviderPayload {
1027 #[serde(default)]
1028 provider_id: Option<String>,
1029 #[serde(default)]
1030 provider_type: Option<String>,
1031 #[serde(default, alias = "operation")]
1032 op: Option<String>,
1033 #[serde(default)]
1034 input: Value,
1035 #[serde(default)]
1036 in_map: Value,
1037 #[serde(default)]
1038 out_map: Value,
1039 #[serde(default)]
1040 err_map: Value,
1041 }
1042
1043 let payload: ProviderPayload =
1044 serde_json::from_value(payload).context("invalid payload for provider.invoke")?;
1045 let op = payload
1046 .op
1047 .as_deref()
1048 .filter(|v| !v.trim().is_empty())
1049 .with_context(|| "provider.invoke requires an op")?
1050 .to_string();
1051
1052 let prev = state
1053 .last_output
1054 .as_ref()
1055 .cloned()
1056 .unwrap_or_else(|| Value::Object(JsonMap::new()));
1057 let base_ctx = template_context(state, prev);
1058
1059 let input_value = if !payload.in_map.is_null() {
1060 let mut ctx_value = base_ctx.clone();
1061 if let Value::Object(ref mut map) = ctx_value {
1062 map.insert("input".into(), payload.input.clone());
1063 map.insert("result".into(), payload.input.clone());
1064 }
1065 render_template_value(
1066 &payload.in_map,
1067 &ctx_value,
1068 TemplateOptions {
1069 allow_pointer: true,
1070 },
1071 )
1072 .context("failed to render provider.invoke in_map")?
1073 } else if !payload.input.is_null() {
1074 payload.input
1075 } else {
1076 Value::Null
1077 };
1078 let input_json = serde_json::to_vec(&input_value)?;
1079
1080 self.validate_tool(
1081 ctx,
1082 event,
1083 payload.provider_id.as_deref(),
1084 payload.provider_type.as_deref(),
1085 &op,
1086 &input_value,
1087 )?;
1088
1089 let key = FlowKey {
1090 pack_id: ctx.pack_id.to_string(),
1091 flow_id: ctx.flow_id.to_string(),
1092 };
1093 let pack_idx = *self.flow_sources.get(&key).with_context(|| {
1094 format!("flow {} (pack {}) not registered", ctx.flow_id, ctx.pack_id)
1095 })?;
1096 let pack = Arc::clone(&self.packs[pack_idx]);
1097 let binding = pack.resolve_provider(
1098 payload.provider_id.as_deref(),
1099 payload.provider_type.as_deref(),
1100 );
1101
1102 if binding.is_err()
1104 && let Some(output) = self.try_invoke_cross_pack_resolver(
1105 payload.provider_id.as_deref(),
1106 payload.provider_type.as_deref(),
1107 &op,
1108 &input_json,
1109 ctx.tenant,
1110 )?
1111 {
1112 return Ok(output);
1113 }
1114
1115 let binding = binding?;
1116 let exec_ctx = component_exec_ctx(ctx, node_id);
1117 #[cfg(feature = "fault-injection")]
1118 {
1119 let fault_ctx = FaultContext {
1120 pack_id: ctx.pack_id,
1121 flow_id: ctx.flow_id,
1122 node_id: Some(node_id),
1123 attempt: ctx.attempt,
1124 };
1125 maybe_fail(FaultPoint::BeforeToolCall, fault_ctx)
1126 .map_err(|err| anyhow!(err.to_string()))?;
1127 }
1128 let result = pack
1129 .invoke_provider(&binding, exec_ctx, &op, input_json)
1130 .await?;
1131 #[cfg(feature = "fault-injection")]
1132 {
1133 let fault_ctx = FaultContext {
1134 pack_id: ctx.pack_id,
1135 flow_id: ctx.flow_id,
1136 node_id: Some(node_id),
1137 attempt: ctx.attempt,
1138 };
1139 maybe_fail(FaultPoint::AfterToolCall, fault_ctx)
1140 .map_err(|err| anyhow!(err.to_string()))?;
1141 }
1142
1143 let output = if payload.out_map.is_null() {
1144 result
1145 } else {
1146 let mut ctx_value = base_ctx;
1147 if let Value::Object(ref mut map) = ctx_value {
1148 map.insert("input".into(), result.clone());
1149 map.insert("result".into(), result.clone());
1150 }
1151 render_template_value(
1152 &payload.out_map,
1153 &ctx_value,
1154 TemplateOptions {
1155 allow_pointer: true,
1156 },
1157 )
1158 .context("failed to render provider.invoke out_map")?
1159 };
1160 let _ = payload.err_map;
1161 Ok(NodeOutput::new(output))
1162 }
1163
1164 fn try_invoke_cross_pack_resolver(
1165 &self,
1166 provider_id: Option<&str>,
1167 provider_type: Option<&str>,
1168 op: &str,
1169 input_json: &[u8],
1170 tenant: &str,
1171 ) -> Result<Option<NodeOutput>> {
1172 eprintln!(
1173 "[DEBUG] provider.invoke: pack-local failed, has_resolver={}",
1174 self.cross_pack_resolver.is_some()
1175 );
1176 let Some(resolver) = self.cross_pack_resolver.as_ref() else {
1177 return Ok(None);
1178 };
1179 let provider_id = provider_id.unwrap_or("unknown");
1180 tracing::info!(
1181 provider_id,
1182 op = %op,
1183 "provider.invoke: pack-local resolution failed, trying cross-pack resolver"
1184 );
1185 let result_value =
1186 resolver.invoke(provider_id, provider_type, op, input_json, tenant, None)?;
1187 Ok(Some(NodeOutput::new(result_value)))
1188 }
1189
1190 fn validate_component(
1191 &self,
1192 ctx: &FlowContext<'_>,
1193 event: &NodeEvent<'_>,
1194 call: &ComponentCall,
1195 ) -> Result<()> {
1196 if self.validation.mode == ValidationMode::Off {
1197 return Ok(());
1198 }
1199 let mut metadata = JsonMap::new();
1200 metadata.insert("tenant_id".to_string(), json!(ctx.tenant));
1201 if let Some(id) = ctx.session_id {
1202 metadata.insert("session".to_string(), json!({ "id": id }));
1203 }
1204 let envelope = json!({
1205 "component_id": call.component_ref,
1206 "operation": call.operation,
1207 "input": call.input,
1208 "config": call.config,
1209 "metadata": Value::Object(metadata),
1210 });
1211 let issues = validate_component_envelope(&envelope);
1212 self.report_validation(ctx, event, "component", issues)
1213 }
1214
1215 fn validate_tool(
1216 &self,
1217 ctx: &FlowContext<'_>,
1218 event: &NodeEvent<'_>,
1219 provider_id: Option<&str>,
1220 provider_type: Option<&str>,
1221 operation: &str,
1222 input: &Value,
1223 ) -> Result<()> {
1224 if self.validation.mode == ValidationMode::Off {
1225 return Ok(());
1226 }
1227 let tool_id = provider_id.or(provider_type).unwrap_or("provider.invoke");
1228 let mut metadata = JsonMap::new();
1229 metadata.insert("tenant_id".to_string(), json!(ctx.tenant));
1230 if let Some(id) = ctx.session_id {
1231 metadata.insert("session".to_string(), json!({ "id": id }));
1232 }
1233 let envelope = json!({
1234 "tool_id": tool_id,
1235 "operation": operation,
1236 "input": input,
1237 "metadata": Value::Object(metadata),
1238 });
1239 let issues = validate_tool_envelope(&envelope);
1240 self.report_validation(ctx, event, "tool", issues)
1241 }
1242
1243 fn report_validation(
1244 &self,
1245 ctx: &FlowContext<'_>,
1246 event: &NodeEvent<'_>,
1247 kind: &str,
1248 issues: Vec<ValidationIssue>,
1249 ) -> Result<()> {
1250 if issues.is_empty() {
1251 return Ok(());
1252 }
1253 if let Some(observer) = ctx.observer {
1254 observer.on_validation(event, &issues);
1255 }
1256 match self.validation.mode {
1257 ValidationMode::Warn => {
1258 tracing::warn!(
1259 tenant = ctx.tenant,
1260 flow_id = ctx.flow_id,
1261 node_id = event.node_id,
1262 kind,
1263 issues = ?issues,
1264 "invocation envelope validation issues"
1265 );
1266 Ok(())
1267 }
1268 ValidationMode::Error => {
1269 tracing::error!(
1270 tenant = ctx.tenant,
1271 flow_id = ctx.flow_id,
1272 node_id = event.node_id,
1273 kind,
1274 issues = ?issues,
1275 "invocation envelope validation failed"
1276 );
1277 bail!("invocation_validation_failed");
1278 }
1279 ValidationMode::Off => Ok(()),
1280 }
1281 }
1282
1283 pub fn flows(&self) -> &[FlowDescriptor] {
1284 &self.flows
1285 }
1286
1287 pub fn flow_by_key(&self, pack_id: &str, flow_id: &str) -> Option<&FlowDescriptor> {
1288 self.flows
1289 .iter()
1290 .find(|descriptor| descriptor.pack_id == pack_id && descriptor.id == flow_id)
1291 }
1292
1293 pub fn flow_by_type(&self, flow_type: &str) -> Option<&FlowDescriptor> {
1294 let mut matches = self
1295 .flows
1296 .iter()
1297 .filter(|descriptor| descriptor.flow_type == flow_type);
1298 let first = matches.next()?;
1299 if matches.next().is_some() {
1300 return None;
1301 }
1302 Some(first)
1303 }
1304
1305 pub fn flow_by_id(&self, flow_id: &str) -> Option<&FlowDescriptor> {
1306 let mut matches = self
1307 .flows
1308 .iter()
1309 .filter(|descriptor| descriptor.id == flow_id);
1310 let first = matches.next()?;
1311 if matches.next().is_some() {
1312 return None;
1313 }
1314 Some(first)
1315 }
1316}
1317
1318pub trait ExecutionObserver: Send + Sync {
1319 fn on_node_start(&self, event: &NodeEvent<'_>);
1320 fn on_node_end(&self, event: &NodeEvent<'_>, output: &Value);
1321 fn on_node_error(&self, event: &NodeEvent<'_>, error: &dyn StdError);
1322 fn on_validation(&self, _event: &NodeEvent<'_>, _issues: &[ValidationIssue]) {}
1323}
1324
1325pub struct NodeEvent<'a> {
1326 pub context: &'a FlowContext<'a>,
1327 pub node_id: &'a str,
1328 pub node: &'a HostNode,
1329 pub payload: &'a Value,
1330}
1331
1332#[derive(Clone, Debug, Serialize, Deserialize)]
1333pub struct ExecutionState {
1334 #[serde(default)]
1335 entry: Value,
1336 #[serde(default)]
1337 input: Value,
1338 #[serde(default)]
1339 nodes: HashMap<String, NodeOutput>,
1340 #[serde(default)]
1341 egress: Vec<Value>,
1342 #[serde(default, skip_serializing_if = "Option::is_none")]
1343 last_output: Option<Value>,
1344 #[serde(default)]
1345 redirect_count: u32,
1346}
1347
1348impl ExecutionState {
1349 fn new(input: Value) -> Self {
1350 Self {
1351 entry: input.clone(),
1352 input,
1353 nodes: HashMap::new(),
1354 egress: Vec::new(),
1355 last_output: None,
1356 redirect_count: 0,
1357 }
1358 }
1359
1360 fn ensure_entry(&mut self) {
1361 if self.entry.is_null() {
1362 self.entry = self.input.clone();
1363 }
1364 }
1365
1366 fn context(&self) -> Value {
1367 let mut nodes = JsonMap::new();
1368 for (id, output) in &self.nodes {
1369 nodes.insert(
1370 id.clone(),
1371 json!({
1372 "ok": output.ok,
1373 "payload": output.payload.clone(),
1374 "meta": output.meta.clone(),
1375 }),
1376 );
1377 }
1378 json!({
1379 "entry": self.entry.clone(),
1380 "input": self.input.clone(),
1381 "nodes": nodes,
1382 "redirect_count": self.redirect_count,
1383 })
1384 }
1385
1386 fn outputs_map(&self) -> JsonMap<String, Value> {
1387 let mut outputs = JsonMap::new();
1388 for (id, output) in &self.nodes {
1389 outputs.insert(id.clone(), output.payload.clone());
1390 }
1391 outputs
1392 }
1393 fn push_egress(&mut self, payload: Value) {
1394 self.egress.push(payload);
1395 }
1396
1397 fn replace_input(&mut self, input: Value) {
1398 self.input = input;
1399 }
1400
1401 fn clear_egress(&mut self) {
1402 self.egress.clear();
1403 }
1404
1405 fn redirect_count(&self) -> u32 {
1406 self.redirect_count
1407 }
1408
1409 fn increment_redirect_count(&mut self) {
1410 self.redirect_count = self.redirect_count.saturating_add(1);
1411 }
1412
1413 fn finalize_with(mut self, final_payload: Option<Value>) -> Value {
1414 if self.egress.is_empty() {
1415 return final_payload.unwrap_or(Value::Null);
1416 }
1417 let mut emitted = std::mem::take(&mut self.egress);
1418 if let Some(value) = final_payload {
1419 match value {
1420 Value::Null => {}
1421 Value::Array(items) => emitted.extend(items),
1422 other => emitted.push(other),
1423 }
1424 }
1425 Value::Array(emitted)
1426 }
1427}
1428
1429#[derive(Clone, Debug, Serialize, Deserialize)]
1430struct NodeOutput {
1431 ok: bool,
1432 payload: Value,
1433 meta: Value,
1434}
1435
1436impl NodeOutput {
1437 fn new(payload: Value) -> Self {
1438 Self {
1439 ok: true,
1440 payload,
1441 meta: Value::Null,
1442 }
1443 }
1444}
1445
1446struct DispatchOutcome {
1447 output: NodeOutput,
1448 control: NodeControl,
1449}
1450
1451impl DispatchOutcome {
1452 fn complete(output: NodeOutput) -> Self {
1453 Self {
1454 output,
1455 control: NodeControl::Continue,
1456 }
1457 }
1458
1459 fn wait(output: NodeOutput, reason: Option<String>) -> Self {
1460 Self {
1461 output,
1462 control: NodeControl::Wait { reason },
1463 }
1464 }
1465
1466 fn with_control(output: NodeOutput, control: NodeControl) -> Self {
1467 Self { output, control }
1468 }
1469}
1470
1471#[derive(Clone, Debug)]
1472enum NodeControl {
1473 Continue,
1474 Wait {
1475 reason: Option<String>,
1476 },
1477 Jump(JumpControl),
1478 Respond {
1479 text: Option<String>,
1480 card_cbor: Option<Vec<u8>>,
1481 needs_user: Option<bool>,
1482 },
1483}
1484
1485#[derive(Clone, Debug)]
1486struct JumpControl {
1487 flow: String,
1488 node: Option<String>,
1489 payload: Value,
1490 hints: Value,
1491 max_redirects: Option<u32>,
1492 reason: Option<String>,
1493}
1494
1495#[derive(Clone, Debug)]
1496struct JumpTarget {
1497 flow_id: String,
1498 flow: HostFlow,
1499 node_id: NodeId,
1500}
1501
1502impl NodeOutput {
1503 fn with_meta(payload: Value, meta: Value) -> Self {
1504 Self {
1505 ok: true,
1506 payload,
1507 meta,
1508 }
1509 }
1510}
1511
1512fn component_exec_ctx(ctx: &FlowContext<'_>, node_id: &str) -> ComponentExecCtx {
1513 ComponentExecCtx {
1514 tenant: ComponentTenantCtx {
1515 tenant: ctx.tenant.to_string(),
1516 team: None,
1517 user: ctx.provider_id.map(str::to_string),
1518 trace_id: None,
1519 i18n_id: None,
1520 correlation_id: ctx.session_id.map(str::to_string),
1521 deadline_unix_ms: None,
1522 attempt: ctx.attempt,
1523 idempotency_key: ctx.session_id.map(str::to_string),
1524 },
1525 i18n_id: None,
1526 flow_id: ctx.flow_id.to_string(),
1527 node_id: Some(node_id.to_string()),
1528 }
1529}
1530
1531fn component_error(value: &Value) -> Option<(String, String)> {
1532 let obj = value.as_object()?;
1533 let ok = obj.get("ok").and_then(Value::as_bool)?;
1534 if ok {
1535 return None;
1536 }
1537 let err = obj.get("error")?.as_object()?;
1538 let code = err
1539 .get("code")
1540 .and_then(Value::as_str)
1541 .unwrap_or("component_error");
1542 let message = err
1543 .get("message")
1544 .and_then(Value::as_str)
1545 .unwrap_or("component reported error");
1546 Some((code.to_string(), message.to_string()))
1547}
1548
1549fn extract_wait_reason(payload: &Value) -> Option<String> {
1550 match payload {
1551 Value::String(s) => Some(s.clone()),
1552 Value::Object(map) => map
1553 .get("reason")
1554 .and_then(Value::as_str)
1555 .map(|value| value.to_string()),
1556 _ => None,
1557 }
1558}
1559
1560fn component_dispatch_outcome(output: NodeOutput) -> Result<DispatchOutcome> {
1561 if let Some(control) = parse_component_control(&output.payload)? {
1562 return Ok(match control {
1563 NodeControl::Jump(jump) => {
1564 let adjusted = NodeOutput::with_meta(jump.payload.clone(), jump.hints.clone());
1565 DispatchOutcome::with_control(adjusted, NodeControl::Jump(jump))
1566 }
1567 NodeControl::Respond {
1568 text,
1569 card_cbor,
1570 needs_user,
1571 } => DispatchOutcome::with_control(
1572 output,
1573 NodeControl::Respond {
1574 text,
1575 card_cbor,
1576 needs_user,
1577 },
1578 ),
1579 other => DispatchOutcome::with_control(output, other),
1580 });
1581 }
1582 Ok(DispatchOutcome::complete(output))
1583}
1584
1585fn parse_component_control(payload: &Value) -> Result<Option<NodeControl>> {
1586 let Value::Object(map) = payload else {
1587 return Ok(None);
1588 };
1589 let Some(control_value) = map.get("greentic_control") else {
1590 return Ok(None);
1591 };
1592 let control = control_value
1593 .as_object()
1594 .ok_or_else(|| anyhow!("jump_failed: greentic_control must be an object"))?;
1595 let action = control
1596 .get("action")
1597 .and_then(Value::as_str)
1598 .ok_or_else(|| anyhow!("jump_failed: greentic_control.action is required"))?;
1599 let version = control
1600 .get("v")
1601 .and_then(Value::as_u64)
1602 .ok_or_else(|| anyhow!("jump_failed: greentic_control.v is required"))?;
1603 if version != 1 {
1604 bail!("jump_failed: unsupported greentic_control.v={version}");
1605 }
1606
1607 match action {
1608 "jump" => {
1609 let flow = control
1610 .get("flow")
1611 .and_then(Value::as_str)
1612 .map(str::trim)
1613 .filter(|value| !value.is_empty())
1614 .ok_or_else(|| anyhow!("jump_failed: jump flow is required"))?
1615 .to_string();
1616 let node = control
1617 .get("node")
1618 .and_then(Value::as_str)
1619 .map(str::trim)
1620 .filter(|value| !value.is_empty())
1621 .map(str::to_string);
1622 let payload = control.get("payload").cloned().unwrap_or(Value::Null);
1623 let hints = control.get("hints").cloned().unwrap_or(Value::Null);
1624 let max_redirects = control
1625 .get("max_redirects")
1626 .and_then(Value::as_u64)
1627 .and_then(|value| u32::try_from(value).ok());
1628 let reason = control
1629 .get("reason")
1630 .and_then(Value::as_str)
1631 .map(str::to_string);
1632 Ok(Some(NodeControl::Jump(JumpControl {
1633 flow,
1634 node,
1635 payload,
1636 hints,
1637 max_redirects,
1638 reason,
1639 })))
1640 }
1641 "respond" => {
1642 let text = control
1643 .get("text")
1644 .and_then(Value::as_str)
1645 .map(str::to_string);
1646 let card_cbor = control
1647 .get("card_cbor")
1648 .and_then(Value::as_array)
1649 .map(|bytes| {
1650 bytes
1651 .iter()
1652 .filter_map(Value::as_u64)
1653 .filter_map(|value| u8::try_from(value).ok())
1654 .collect::<Vec<_>>()
1655 });
1656 let needs_user = control.get("needs_user").and_then(Value::as_bool);
1657 Ok(Some(NodeControl::Respond {
1658 text,
1659 card_cbor,
1660 needs_user,
1661 }))
1662 }
1663 _ => Ok(None),
1664 }
1665}
1666
1667fn template_context(state: &ExecutionState, prev: Value) -> Value {
1668 let entry = if state.entry.is_null() {
1669 Value::Object(JsonMap::new())
1670 } else {
1671 state.entry.clone()
1672 };
1673 let mut ctx = JsonMap::new();
1674 ctx.insert("entry".into(), entry.clone());
1675 ctx.insert("in".into(), entry); ctx.insert("prev".into(), prev);
1677 ctx.insert("node".into(), Value::Object(state.outputs_map()));
1678 ctx.insert("state".into(), state.context());
1679 Value::Object(ctx)
1680}
1681
1682impl From<Flow> for HostFlow {
1683 fn from(value: Flow) -> Self {
1684 let mut nodes = IndexMap::new();
1685 for (id, node) in value.nodes {
1686 nodes.insert(id.clone(), HostNode::from(node));
1687 }
1688 let start = value
1689 .entrypoints
1690 .get("default")
1691 .and_then(Value::as_str)
1692 .and_then(|id| NodeId::from_str(id).ok())
1693 .or_else(|| nodes.keys().next().cloned());
1694 Self {
1695 id: value.id.as_str().to_string(),
1696 start,
1697 nodes,
1698 }
1699 }
1700}
1701
1702impl From<Node> for HostNode {
1703 fn from(node: Node) -> Self {
1704 let full_ref = node.component.id.as_str().to_string();
1705 let is_builtin = full_ref.starts_with("component.exec")
1708 || full_ref.starts_with("flow.")
1709 || full_ref.starts_with("emit.")
1710 || full_ref.starts_with("session.")
1711 || full_ref.starts_with("provider.");
1712 let (component_ref, raw_operation) = if node.component.operation.is_some() || is_builtin {
1713 (full_ref, node.component.operation.clone())
1714 } else if let Some(dot) = full_ref.rfind('.') {
1715 let comp = full_ref[..dot].to_string();
1716 let op = full_ref[dot + 1..].to_string();
1717 (comp, Some(op))
1718 } else {
1719 (full_ref, None)
1720 };
1721 let operation_in_mapping = extract_operation_from_mapping(&node.input.mapping);
1722 let operation_is_component_exec = raw_operation.as_deref() == Some("component.exec");
1723 let operation_is_emit = raw_operation
1724 .as_deref()
1725 .map(|op| op.starts_with("emit."))
1726 .unwrap_or(false);
1727 let is_component_exec = component_ref == "component.exec" || operation_is_component_exec;
1728
1729 let kind = if is_component_exec {
1730 let target = if component_ref == "component.exec" {
1731 if let Some(op) = raw_operation
1732 .as_deref()
1733 .filter(|op| op.starts_with("emit."))
1734 {
1735 op.to_string()
1736 } else {
1737 extract_target_component(&node.input.mapping)
1738 .unwrap_or_else(|| "component.exec".to_string())
1739 }
1740 } else {
1741 extract_target_component(&node.input.mapping)
1742 .unwrap_or_else(|| component_ref.clone())
1743 };
1744 if target.starts_with("emit.") {
1745 NodeKind::BuiltinEmit {
1746 kind: emit_kind_from_ref(&target),
1747 }
1748 } else {
1749 NodeKind::Exec {
1750 target_component: target,
1751 }
1752 }
1753 } else if operation_is_emit {
1754 NodeKind::BuiltinEmit {
1755 kind: emit_kind_from_ref(raw_operation.as_deref().unwrap_or("emit.log")),
1756 }
1757 } else {
1758 match component_ref.as_str() {
1759 "flow.call" => NodeKind::FlowCall,
1760 "provider.invoke" => NodeKind::ProviderInvoke,
1761 "session.wait" => NodeKind::Wait,
1762 "state.get" => NodeKind::BuiltinStateGet,
1763 "state.set" => NodeKind::BuiltinStateSet,
1764 comp if comp.starts_with("emit.") => NodeKind::BuiltinEmit {
1765 kind: emit_kind_from_ref(comp),
1766 },
1767 other => NodeKind::PackComponent {
1768 component_ref: other.to_string(),
1769 },
1770 }
1771 };
1772 let component_label = match &kind {
1773 NodeKind::Exec { .. } => "component.exec".to_string(),
1774 NodeKind::PackComponent { component_ref } => component_ref.clone(),
1775 NodeKind::ProviderInvoke => "provider.invoke".to_string(),
1776 NodeKind::FlowCall => "flow.call".to_string(),
1777 NodeKind::BuiltinEmit { kind } => emit_ref_from_kind(kind),
1778 NodeKind::BuiltinStateGet => "state.get".to_string(),
1779 NodeKind::BuiltinStateSet => "state.set".to_string(),
1780 NodeKind::Wait => "session.wait".to_string(),
1781 };
1782 let operation_name = if is_component_exec && operation_is_component_exec {
1783 None
1784 } else {
1785 raw_operation.clone()
1786 };
1787 let payload_expr = match kind {
1788 NodeKind::BuiltinEmit { .. } => extract_emit_payload(&node.input.mapping),
1789 _ => node.input.mapping.clone(),
1790 };
1791 Self {
1792 kind,
1793 component: component_label,
1794 component_id: if is_component_exec {
1795 "component.exec".to_string()
1796 } else {
1797 component_ref
1798 },
1799 operation_name,
1800 operation_in_mapping,
1801 payload_expr,
1802 routing: node.routing,
1803 }
1804 }
1805}
1806
1807fn extract_target_component(payload: &Value) -> Option<String> {
1808 match payload {
1809 Value::Object(map) => map
1810 .get("component")
1811 .or_else(|| map.get("component_ref"))
1812 .and_then(Value::as_str)
1813 .map(|s| s.to_string()),
1814 _ => None,
1815 }
1816}
1817
1818fn extract_operation_from_mapping(payload: &Value) -> Option<String> {
1819 match payload {
1820 Value::Object(map) => map
1821 .get("operation")
1822 .or_else(|| map.get("op"))
1823 .and_then(Value::as_str)
1824 .map(str::trim)
1825 .filter(|value| !value.is_empty())
1826 .map(|value| value.to_string()),
1827 _ => None,
1828 }
1829}
1830
1831fn extract_emit_payload(payload: &Value) -> Value {
1832 if let Value::Object(map) = payload {
1833 if let Some(input) = map.get("input") {
1834 return input.clone();
1835 }
1836 if let Some(inner) = map.get("payload") {
1837 return inner.clone();
1838 }
1839 }
1840 payload.clone()
1841}
1842
1843fn split_operation_payload(payload: Value) -> (Value, Value) {
1844 if let Value::Object(mut map) = payload.clone()
1845 && map.contains_key("input")
1846 {
1847 let input = map.remove("input").unwrap_or(Value::Null);
1848 let config = map.remove("config").unwrap_or(Value::Null);
1849 let legacy_only = map.keys().all(|key| {
1850 matches!(
1851 key.as_str(),
1852 "operation" | "op" | "component" | "component_ref"
1853 )
1854 });
1855 if legacy_only {
1856 return (input, config);
1857 }
1858 }
1859 (payload, Value::Null)
1860}
1861
1862fn resolve_component_operation(
1863 node_id: &str,
1864 component_label: &str,
1865 payload_operation: Option<String>,
1866 operation_override: Option<&str>,
1867 operation_in_mapping: Option<&str>,
1868) -> Result<String> {
1869 if let Some(op) = operation_override
1870 .map(str::trim)
1871 .filter(|value| !value.is_empty())
1872 {
1873 return Ok(op.to_string());
1874 }
1875
1876 if let Some(op) = payload_operation
1877 .as_deref()
1878 .map(str::trim)
1879 .filter(|value| !value.is_empty())
1880 {
1881 return Ok(op.to_string());
1882 }
1883
1884 let mut message = format!(
1885 "missing operation for node `{}` (component `{}`); expected node.component.operation to be set",
1886 node_id, component_label,
1887 );
1888 if let Some(found) = operation_in_mapping {
1889 message.push_str(&format!(
1890 ". Found operation in input.mapping (`{}`) but this is not used; pack compiler must preserve node.component.operation.",
1891 found
1892 ));
1893 }
1894 bail!(message);
1895}
1896
1897fn emit_kind_from_ref(component_ref: &str) -> EmitKind {
1898 match component_ref {
1899 "emit.log" => EmitKind::Log,
1900 "emit.response" => EmitKind::Response,
1901 other => EmitKind::Other(other.to_string()),
1902 }
1903}
1904
1905fn emit_ref_from_kind(kind: &EmitKind) -> String {
1906 match kind {
1907 EmitKind::Log => "emit.log".to_string(),
1908 EmitKind::Response => "emit.response".to_string(),
1909 EmitKind::Other(other) => other.clone(),
1910 }
1911}
1912
1913fn is_card_invocation(input: &Value) -> bool {
1916 if let Value::Object(map) = input {
1917 return map.contains_key("card_source") || map.contains_key("card_spec");
1918 }
1919 false
1920}
1921
1922fn promote_card_config_to_invocation(input: &mut Value, config: &Value) {
1934 if is_card_invocation(input) {
1935 return;
1936 }
1937
1938 let cfg_map = card_defaults_source(input, config);
1939 let Some(cfg) = cfg_map else { return };
1940
1941 let default_asset = cfg
1942 .get("default_card_asset")
1943 .and_then(Value::as_str)
1944 .map(str::trim)
1945 .filter(|value| !value.is_empty())
1946 .map(str::to_string);
1947 let default_inline = cfg
1948 .get("default_card_inline")
1949 .filter(|value| value.is_object() || value.is_array())
1950 .cloned();
1951 let default_source = cfg
1952 .get("default_source")
1953 .and_then(Value::as_str)
1954 .map(str::trim)
1955 .filter(|value| !value.is_empty())
1956 .map(str::to_lowercase);
1957
1958 if default_asset.is_none() && default_inline.is_none() && default_source.is_none() {
1959 return;
1960 }
1961
1962 let card_source = default_source.unwrap_or_else(|| {
1963 if default_inline.is_some() {
1964 "inline".to_string()
1965 } else {
1966 "asset".to_string()
1967 }
1968 });
1969
1970 let mut card_spec = serde_json::Map::new();
1971 match card_source.as_str() {
1972 "asset" => {
1973 if let Some(path) = default_asset {
1974 card_spec.insert("asset_path".into(), Value::String(path));
1975 }
1976 }
1977 "inline" => {
1978 if let Some(inline) = default_inline {
1979 card_spec.insert("inline_json".into(), inline);
1980 }
1981 }
1982 _ => {}
1983 }
1984
1985 if !matches!(input, Value::Object(_)) {
1986 *input = Value::Object(serde_json::Map::new());
1987 }
1988 if let Value::Object(map) = input {
1989 map.insert("card_source".into(), Value::String(card_source));
1990 map.insert("card_spec".into(), Value::Object(card_spec));
1991 }
1992}
1993
1994fn card_defaults_source<'a>(
1999 input: &'a Value,
2000 config: &'a Value,
2001) -> Option<&'a serde_json::Map<String, Value>> {
2002 if let Value::Object(map) = config {
2003 return Some(map);
2004 }
2005 if let Value::Object(map) = input
2006 && let Some(Value::Object(nested)) = map.get("config")
2007 {
2008 return Some(nested);
2009 }
2010 None
2011}
2012
2013fn inject_card_locale(payload: &mut Value, entry: &Value) {
2014 if !is_card_invocation(payload) {
2015 return;
2016 }
2017 let Value::Object(map) = payload else { return };
2018 if map.contains_key("locale") {
2019 return;
2020 }
2021 let locale = entry
2022 .pointer("/input/metadata/locale")
2023 .or_else(|| entry.pointer("/metadata/locale"))
2024 .and_then(Value::as_str);
2025 if let Some(locale) = locale {
2026 map.insert("locale".into(), Value::String(locale.to_string()));
2027 }
2028}
2029
2030fn resolve_card_assets(input: &mut Value, pack: &crate::pack::PackRuntime) {
2037 resolve_card_spec_asset(input, pack);
2038
2039 if let Value::Object(map) = input
2042 && let Some(Value::Object(call)) = map.get_mut("call")
2043 && let Some(payload) = call.get_mut("payload")
2044 {
2045 resolve_card_spec_asset(payload, pack);
2046 }
2047}
2048
2049fn resolve_card_spec_asset(value: &mut Value, pack: &crate::pack::PackRuntime) {
2051 let Value::Object(map) = value else { return };
2052
2053 let is_asset = map
2054 .get("card_source")
2055 .and_then(Value::as_str)
2056 .map(|s| s.eq_ignore_ascii_case("asset"))
2057 .unwrap_or(false);
2058 if !is_asset {
2059 return;
2060 }
2061
2062 let asset_path = map
2063 .get("card_spec")
2064 .and_then(|spec| spec.get("asset_path"))
2065 .and_then(Value::as_str)
2066 .map(str::to_string);
2067
2068 let Some(asset_path) = asset_path else { return };
2069
2070 match pack.read_asset(&asset_path) {
2071 Ok(bytes) => {
2072 let card_json: Value = match serde_json::from_slice(&bytes) {
2073 Ok(v) => v,
2074 Err(err) => {
2075 tracing::warn!(
2076 asset_path,
2077 %err,
2078 "failed to parse card asset as JSON; leaving as asset reference"
2079 );
2080 return;
2081 }
2082 };
2083 tracing::debug!(asset_path, "pre-resolved card asset to inline_json");
2084 map.insert("card_source".into(), Value::String("inline".into()));
2085 if let Some(Value::Object(spec)) = map.get_mut("card_spec") {
2086 spec.insert("inline_json".into(), card_json);
2087 spec.remove("asset_path");
2088 }
2089 }
2090 Err(err) => {
2091 tracing::warn!(
2092 asset_path,
2093 %err,
2094 "card asset not found in pack; leaving as asset reference"
2095 );
2096 }
2097 }
2098
2099 let configured_bundle_path = map
2106 .get("card_spec")
2107 .and_then(|spec| spec.get("i18n_bundle_path"))
2108 .and_then(Value::as_str)
2109 .map(|s| s.trim().trim_end_matches('/').to_string())
2110 .filter(|s| !s.is_empty());
2111
2112 let bundle_path = configured_bundle_path
2113 .clone()
2114 .unwrap_or_else(|| "assets/i18n".to_string());
2115
2116 let i18n_entries = load_i18n_bundle_entries(&bundle_path, |path| pack.read_asset(path));
2117
2118 if !i18n_entries.is_empty() {
2119 let locale_keys: Vec<_> = i18n_entries.keys().cloned().collect();
2120 if let Some(Value::Object(spec)) = map.get_mut("card_spec") {
2121 spec.insert("i18n_inline".into(), Value::Object(i18n_entries));
2122 if configured_bundle_path.is_some() {
2123 tracing::info!(%bundle_path, ?locale_keys, "pre-resolved i18n bundle into card_spec.i18n_inline");
2124 } else {
2125 tracing::info!(%bundle_path, ?locale_keys, "auto-discovered i18n bundle and inlined into card_spec.i18n_inline");
2126 }
2127 }
2128 }
2129}
2130
2131fn load_i18n_bundle_entries<F>(bundle_path: &str, mut read_asset: F) -> JsonMap<String, Value>
2132where
2133 F: FnMut(&str) -> Result<Vec<u8>>,
2134{
2135 let mut i18n_entries = JsonMap::new();
2136
2137 if bundle_path.ends_with(".json") {
2138 if let Ok(bytes) = read_asset(bundle_path)
2139 && let Ok(Value::Object(entries)) = serde_json::from_slice::<Value>(&bytes)
2140 {
2141 i18n_entries.insert("en".to_string(), Value::Object(entries));
2142 }
2143 return i18n_entries;
2144 }
2145
2146 let manifest_path = format!("{bundle_path}/_manifest.json");
2147 let locale_codes: Vec<String> = read_asset(&manifest_path)
2148 .ok()
2149 .and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
2150 .and_then(|value| {
2151 let locales = value
2152 .get("locales")
2153 .and_then(Value::as_array)
2154 .cloned()
2155 .or_else(|| value.as_array().cloned());
2156 locales.map(|items| {
2157 items
2158 .iter()
2159 .filter_map(Value::as_str)
2160 .map(String::from)
2161 .collect()
2162 })
2163 })
2164 .unwrap_or_default();
2165
2166 tracing::info!(%bundle_path, ?locale_codes, "i18n manifest discovered locales");
2167
2168 for locale in &locale_codes {
2169 let candidate = format!("{bundle_path}/{locale}.json");
2170 if let Ok(bytes) = read_asset(&candidate)
2171 && let Ok(Value::Object(entries)) = serde_json::from_slice::<Value>(&bytes)
2172 {
2173 i18n_entries.insert(locale.clone(), Value::Object(entries));
2174 }
2175 }
2176 if !i18n_entries.contains_key("en") {
2177 let en_path = format!("{bundle_path}/en.json");
2178 if let Ok(bytes) = read_asset(&en_path)
2179 && let Ok(Value::Object(entries)) = serde_json::from_slice::<Value>(&bytes)
2180 {
2181 i18n_entries.insert("en".to_string(), Value::Object(entries));
2182 }
2183 }
2184
2185 i18n_entries
2186}
2187
2188fn evaluate_custom_routing(
2200 raw: &Value,
2201 output: &NodeOutput,
2202 state: &ExecutionState,
2203 flow_ir: &HostFlow,
2204 node_id: &NodeId,
2205) -> (Option<NodeId>, bool) {
2206 let routes = match raw.as_array() {
2207 Some(arr) => arr,
2208 None => {
2209 tracing::warn!(
2210 flow_id = %flow_ir.id,
2211 node_id = %node_id,
2212 "custom routing is not an array; terminating"
2213 );
2214 return (None, true);
2215 }
2216 };
2217
2218 let ctx = build_routing_context(output, state);
2221
2222 for route in routes {
2223 let condition = route.get("condition").and_then(|v| v.as_str());
2224 let to = route.get("to").and_then(|v| v.as_str());
2225
2226 if let Some(cond) = condition {
2227 if evaluate_simple_condition(cond, &ctx)
2228 && let Some(target) = to
2229 && let Ok(nid) = NodeId::new(target)
2230 {
2231 tracing::debug!(
2232 flow_id = %flow_ir.id,
2233 node_id = %node_id,
2234 condition = cond,
2235 target = target,
2236 "conditional route matched"
2237 );
2238 return (Some(nid), false);
2239 }
2240 } else if let Some(target) = to
2241 && let Ok(nid) = NodeId::new(target)
2242 {
2243 tracing::debug!(
2244 flow_id = %flow_ir.id,
2245 node_id = %node_id,
2246 target = target,
2247 "default route taken"
2248 );
2249 return (Some(nid), false);
2250 }
2251 }
2252
2253 tracing::warn!(
2254 flow_id = %flow_ir.id,
2255 node_id = %node_id,
2256 "no conditional route matched; terminating"
2257 );
2258 (None, true)
2259}
2260
2261fn evaluate_simple_condition(condition: &str, ctx: &Value) -> bool {
2266 let (path, expected, negate) = if let Some(idx) = condition.find("==") {
2268 let path = condition[..idx].trim();
2269 let val = condition[idx + 2..].trim().trim_matches('"');
2270 (path, val, false)
2271 } else if let Some(idx) = condition.find("!=") {
2272 let path = condition[..idx].trim();
2273 let val = condition[idx + 2..].trim().trim_matches('"');
2274 (path, val, true)
2275 } else {
2276 return false;
2277 };
2278
2279 let actual = resolve_dotted_path(ctx, path);
2281 let matches = actual
2282 .as_deref()
2283 .is_some_and(|a| a.eq_ignore_ascii_case(expected));
2284 if negate { !matches } else { matches }
2285}
2286
2287fn resolve_dotted_path(value: &Value, path: &str) -> Option<String> {
2289 let parts: Vec<&str> = path.split('.').collect();
2290 let mut current = value;
2291 for part in &parts {
2292 current = current.get(part)?;
2293 }
2294 match current {
2295 Value::String(s) => Some(s.clone()),
2296 Value::Bool(b) => Some(b.to_string()),
2297 Value::Number(n) => Some(n.to_string()),
2298 _ => Some(current.to_string()),
2299 }
2300}
2301
2302fn build_routing_context(output: &NodeOutput, state: &ExecutionState) -> Value {
2320 let mut ctx = match &output.payload {
2321 Value::Object(map) => map.clone(),
2322 _ => JsonMap::new(),
2323 };
2324
2325 let entry = &state.entry;
2326 ctx.insert("entry".into(), entry.clone());
2327 ctx.insert("in".into(), entry.clone());
2328
2329 let metadata = entry
2333 .pointer("/input/metadata")
2334 .or_else(|| entry.pointer("/metadata"));
2335
2336 let mut response = JsonMap::new();
2337 if let Some(Value::Object(meta)) = metadata {
2338 for (k, v) in meta {
2339 match v {
2341 Value::String(s) => {
2342 response.insert(k.clone(), Value::String(s.clone()));
2343 }
2344 other => {
2345 response.insert(k.clone(), other.clone());
2346 }
2347 }
2348 }
2349 }
2350 if let Some(text) = entry
2352 .pointer("/input/text")
2353 .or_else(|| entry.pointer("/text"))
2354 .filter(|t| !t.is_null())
2355 {
2356 response.insert("text".into(), text.clone());
2357 }
2358 ctx.insert("response".into(), Value::Object(response));
2359
2360 Value::Object(ctx)
2361}
2362
2363#[cfg(test)]
2364mod tests {
2365 use super::*;
2366 use crate::validate::{ValidationConfig, ValidationMode};
2367 use greentic_types::{
2368 Flow, FlowComponentRef, FlowId, FlowKind, InputMapping, Node, NodeId, OutputMapping,
2369 Routing, TelemetryHints,
2370 };
2371 use serde_json::json;
2372 use std::collections::{BTreeMap, HashMap as StdHashMap};
2373 use std::str::FromStr;
2374 use std::sync::Mutex;
2375 use tokio::runtime::Runtime;
2376
2377 fn minimal_engine() -> FlowEngine {
2378 FlowEngine {
2379 packs: Vec::new(),
2380 flows: Vec::new(),
2381 flow_sources: HashMap::new(),
2382 flow_cache: RwLock::new(HashMap::new()),
2383 default_env: "local".to_string(),
2384 validation: ValidationConfig {
2385 mode: ValidationMode::Off,
2386 },
2387 cross_pack_resolver: None,
2388 }
2389 }
2390
2391 #[test]
2392 fn templating_renders_with_partials_and_data() {
2393 let mut state = ExecutionState::new(json!({ "city": "London" }));
2394 state.nodes.insert(
2395 "forecast".to_string(),
2396 NodeOutput::new(json!({ "temp": "20C" })),
2397 );
2398
2399 let ctx = state.context();
2401 assert_eq!(ctx["nodes"]["forecast"]["payload"]["temp"], json!("20C"));
2402 }
2403
2404 #[test]
2405 fn finalize_wraps_emitted_payloads() {
2406 let mut state = ExecutionState::new(json!({}));
2407 state.push_egress(json!({ "text": "first" }));
2408 state.push_egress(json!({ "text": "second" }));
2409 let result = state.finalize_with(Some(json!({ "text": "final" })));
2410 assert_eq!(
2411 result,
2412 json!([
2413 { "text": "first" },
2414 { "text": "second" },
2415 { "text": "final" }
2416 ])
2417 );
2418 }
2419
2420 #[test]
2421 fn finalize_flattens_final_array() {
2422 let mut state = ExecutionState::new(json!({}));
2423 state.push_egress(json!({ "text": "only" }));
2424 let result = state.finalize_with(Some(json!([
2425 { "text": "extra-1" },
2426 { "text": "extra-2" }
2427 ])));
2428 assert_eq!(
2429 result,
2430 json!([
2431 { "text": "only" },
2432 { "text": "extra-1" },
2433 { "text": "extra-2" }
2434 ])
2435 );
2436 }
2437
2438 #[test]
2439 fn inject_card_locale_uses_entry_metadata_without_overwriting_payload() {
2440 let mut payload = json!({
2441 "card_source": "inline",
2442 "card_spec": { "title": "Hello" }
2443 });
2444 inject_card_locale(
2445 &mut payload,
2446 &json!({"input": {"metadata": {"locale": "nl-NL"}}}),
2447 );
2448 assert_eq!(payload["locale"], json!("nl-NL"));
2449
2450 let mut existing = json!({
2451 "card_source": "inline",
2452 "card_spec": { "title": "Hello" },
2453 "locale": "en-GB"
2454 });
2455 inject_card_locale(&mut existing, &json!({"metadata": {"locale": "nl-NL"}}));
2456 assert_eq!(existing["locale"], json!("en-GB"));
2457 }
2458
2459 #[test]
2460 fn load_i18n_bundle_entries_reads_manifest_and_falls_back_to_en() {
2461 let assets = StdHashMap::from([
2462 (
2463 "cards/i18n/_manifest.json".to_string(),
2464 br#"{"locales":["de"]}"#.to_vec(),
2465 ),
2466 (
2467 "cards/i18n/de.json".to_string(),
2468 br#"{"title":"Hallo"}"#.to_vec(),
2469 ),
2470 (
2471 "cards/i18n/en.json".to_string(),
2472 br#"{"title":"Hello"}"#.to_vec(),
2473 ),
2474 ]);
2475
2476 let entries = load_i18n_bundle_entries("cards/i18n", |path| {
2477 assets
2478 .get(path)
2479 .cloned()
2480 .with_context(|| format!("missing asset {path}"))
2481 });
2482
2483 assert_eq!(entries["de"]["title"], json!("Hallo"));
2484 assert_eq!(entries["en"]["title"], json!("Hello"));
2485 }
2486
2487 #[test]
2488 fn load_i18n_bundle_entries_reads_single_file_bundle() {
2489 let entries = load_i18n_bundle_entries("cards/i18n.json", |path| {
2490 if path == "cards/i18n.json" {
2491 Ok(br#"{"title":"Hello"}"#.to_vec())
2492 } else {
2493 bail!("unexpected asset {path}");
2494 }
2495 });
2496
2497 assert_eq!(entries["en"]["title"], json!("Hello"));
2498 }
2499
2500 struct TestCrossPackResolver;
2501
2502 impl CrossPackResolver for TestCrossPackResolver {
2503 fn invoke(
2504 &self,
2505 provider_id: &str,
2506 provider_type: Option<&str>,
2507 op: &str,
2508 input: &[u8],
2509 tenant: &str,
2510 team: Option<&str>,
2511 ) -> Result<Value> {
2512 Ok(json!({
2513 "provider_id": provider_id,
2514 "provider_type": provider_type,
2515 "op": op,
2516 "tenant": tenant,
2517 "team": team,
2518 "input": serde_json::from_slice::<Value>(input)?,
2519 }))
2520 }
2521 }
2522
2523 #[test]
2524 fn cross_pack_resolver_returns_node_output_when_present() {
2525 let mut engine = minimal_engine();
2526 engine.set_cross_pack_resolver(Arc::new(TestCrossPackResolver));
2527
2528 let output = engine
2529 .try_invoke_cross_pack_resolver(
2530 Some("mail"),
2531 Some("messaging"),
2532 "send",
2533 br#"{"subject":"hello"}"#,
2534 "demo",
2535 )
2536 .expect("resolver invocation")
2537 .expect("resolver output");
2538
2539 assert_eq!(
2540 output.payload,
2541 json!({
2542 "provider_id": "mail",
2543 "provider_type": "messaging",
2544 "op": "send",
2545 "tenant": "demo",
2546 "team": null,
2547 "input": { "subject": "hello" },
2548 })
2549 );
2550 }
2551
2552 #[test]
2553 fn parse_component_control_ignores_plain_payload() {
2554 let payload = json!({
2555 "flow": "not-a-control-field",
2556 "node": "n1"
2557 });
2558 let control = parse_component_control(&payload).expect("parse control");
2559 assert!(control.is_none());
2560 }
2561
2562 #[test]
2563 fn parse_component_control_parses_jump_marker() {
2564 let payload = json!({
2565 "greentic_control": {
2566 "action": "jump",
2567 "v": 1,
2568 "flow": "flow.b",
2569 "node": "node-2",
2570 "payload": { "message": "hi" },
2571 "hints": { "k": "v" },
2572 "max_redirects": 2,
2573 "reason": "handoff"
2574 }
2575 });
2576 let control = parse_component_control(&payload)
2577 .expect("parse control")
2578 .expect("missing control");
2579 match control {
2580 NodeControl::Jump(jump) => {
2581 assert_eq!(jump.flow, "flow.b");
2582 assert_eq!(jump.node.as_deref(), Some("node-2"));
2583 assert_eq!(jump.payload, json!({ "message": "hi" }));
2584 assert_eq!(jump.hints, json!({ "k": "v" }));
2585 assert_eq!(jump.max_redirects, Some(2));
2586 assert_eq!(jump.reason.as_deref(), Some("handoff"));
2587 }
2588 other => panic!("expected jump control, got {other:?}"),
2589 }
2590 }
2591
2592 #[test]
2593 fn parse_component_control_rejects_invalid_marker() {
2594 let payload = json!({
2595 "greentic_control": "bad-shape"
2596 });
2597 let err = parse_component_control(&payload).expect_err("expected invalid marker error");
2598 assert!(err.to_string().contains("greentic_control"));
2599 }
2600
2601 #[test]
2602 fn missing_operation_reports_node_and_component() {
2603 let engine = minimal_engine();
2604 let rt = Runtime::new().unwrap();
2605 let retry_config = RetryConfig {
2606 max_attempts: 1,
2607 base_delay_ms: 1,
2608 };
2609 let ctx = FlowContext {
2610 tenant: "tenant",
2611 pack_id: "test-pack",
2612 flow_id: "flow",
2613 node_id: Some("missing-op"),
2614 tool: None,
2615 action: None,
2616 session_id: None,
2617 provider_id: None,
2618 retry_config,
2619 attempt: 1,
2620 observer: None,
2621 mocks: None,
2622 };
2623 let node = HostNode {
2624 kind: NodeKind::Exec {
2625 target_component: "qa.process".into(),
2626 },
2627 component: "component.exec".into(),
2628 component_id: "component.exec".into(),
2629 operation_name: None,
2630 operation_in_mapping: None,
2631 payload_expr: Value::Null,
2632 routing: Routing::End,
2633 };
2634 let _state = ExecutionState::new(Value::Null);
2635 let payload = json!({ "component": "qa.process" });
2636 let event = NodeEvent {
2637 context: &ctx,
2638 node_id: "missing-op",
2639 node: &node,
2640 payload: &payload,
2641 };
2642 let err = rt
2643 .block_on(engine.execute_component_exec(
2644 &ctx,
2645 "missing-op",
2646 &node,
2647 payload.clone(),
2648 &event,
2649 ComponentOverrides {
2650 component: None,
2651 operation: None,
2652 },
2653 ))
2654 .unwrap_err();
2655 let message = err.to_string();
2656 assert!(
2657 message.contains("missing operation for node `missing-op`"),
2658 "unexpected message: {message}"
2659 );
2660 assert!(
2661 message.contains("(component `component.exec`)"),
2662 "unexpected message: {message}"
2663 );
2664 }
2665
2666 #[test]
2667 fn missing_operation_mentions_mapping_hint() {
2668 let engine = minimal_engine();
2669 let rt = Runtime::new().unwrap();
2670 let retry_config = RetryConfig {
2671 max_attempts: 1,
2672 base_delay_ms: 1,
2673 };
2674 let ctx = FlowContext {
2675 tenant: "tenant",
2676 pack_id: "test-pack",
2677 flow_id: "flow",
2678 node_id: Some("missing-op-hint"),
2679 tool: None,
2680 action: None,
2681 session_id: None,
2682 provider_id: None,
2683 retry_config,
2684 attempt: 1,
2685 observer: None,
2686 mocks: None,
2687 };
2688 let node = HostNode {
2689 kind: NodeKind::Exec {
2690 target_component: "qa.process".into(),
2691 },
2692 component: "component.exec".into(),
2693 component_id: "component.exec".into(),
2694 operation_name: None,
2695 operation_in_mapping: Some("render".into()),
2696 payload_expr: Value::Null,
2697 routing: Routing::End,
2698 };
2699 let _state = ExecutionState::new(Value::Null);
2700 let payload = json!({ "component": "qa.process" });
2701 let event = NodeEvent {
2702 context: &ctx,
2703 node_id: "missing-op-hint",
2704 node: &node,
2705 payload: &payload,
2706 };
2707 let err = rt
2708 .block_on(engine.execute_component_exec(
2709 &ctx,
2710 "missing-op-hint",
2711 &node,
2712 payload.clone(),
2713 &event,
2714 ComponentOverrides {
2715 component: None,
2716 operation: None,
2717 },
2718 ))
2719 .unwrap_err();
2720 let message = err.to_string();
2721 assert!(
2722 message.contains("missing operation for node `missing-op-hint`"),
2723 "unexpected message: {message}"
2724 );
2725 assert!(
2726 message.contains("Found operation in input.mapping (`render`)"),
2727 "unexpected message: {message}"
2728 );
2729 }
2730
2731 struct CountingObserver {
2732 starts: Mutex<Vec<String>>,
2733 ends: Mutex<Vec<Value>>,
2734 }
2735
2736 impl CountingObserver {
2737 fn new() -> Self {
2738 Self {
2739 starts: Mutex::new(Vec::new()),
2740 ends: Mutex::new(Vec::new()),
2741 }
2742 }
2743 }
2744
2745 impl ExecutionObserver for CountingObserver {
2746 fn on_node_start(&self, event: &NodeEvent<'_>) {
2747 self.starts.lock().unwrap().push(event.node_id.to_string());
2748 }
2749
2750 fn on_node_end(&self, _event: &NodeEvent<'_>, output: &Value) {
2751 self.ends.lock().unwrap().push(output.clone());
2752 }
2753
2754 fn on_node_error(&self, _event: &NodeEvent<'_>, _error: &dyn StdError) {}
2755 }
2756
2757 #[test]
2758 fn emits_end_event_for_successful_node() {
2759 let node_id = NodeId::from_str("emit").unwrap();
2760 let node = Node {
2761 id: node_id.clone(),
2762 component: FlowComponentRef {
2763 id: "emit.log".parse().unwrap(),
2764 pack_alias: None,
2765 operation: None,
2766 },
2767 input: InputMapping {
2768 mapping: json!({ "message": "logged" }),
2769 },
2770 output: OutputMapping {
2771 mapping: Value::Null,
2772 },
2773 err_map: None,
2774 routing: Routing::End,
2775 telemetry: TelemetryHints::default(),
2776 };
2777 let mut nodes = indexmap::IndexMap::default();
2778 nodes.insert(node_id.clone(), node);
2779 let flow = Flow {
2780 schema_version: "1.0".into(),
2781 id: FlowId::from_str("emit.flow").unwrap(),
2782 kind: FlowKind::Messaging,
2783 entrypoints: BTreeMap::from([(
2784 "default".to_string(),
2785 Value::String(node_id.to_string()),
2786 )]),
2787 nodes,
2788 metadata: Default::default(),
2789 };
2790 let host_flow = HostFlow::from(flow);
2791
2792 let engine = FlowEngine {
2793 packs: Vec::new(),
2794 flows: Vec::new(),
2795 flow_sources: HashMap::new(),
2796 flow_cache: RwLock::new(HashMap::from([(
2797 FlowKey {
2798 pack_id: "test-pack".to_string(),
2799 flow_id: "emit.flow".to_string(),
2800 },
2801 host_flow,
2802 )])),
2803 default_env: "local".to_string(),
2804 validation: ValidationConfig {
2805 mode: ValidationMode::Off,
2806 },
2807 cross_pack_resolver: None,
2808 };
2809 let observer = CountingObserver::new();
2810 let ctx = FlowContext {
2811 tenant: "demo",
2812 pack_id: "test-pack",
2813 flow_id: "emit.flow",
2814 node_id: None,
2815 tool: None,
2816 action: None,
2817 session_id: None,
2818 provider_id: None,
2819 retry_config: RetryConfig {
2820 max_attempts: 1,
2821 base_delay_ms: 1,
2822 },
2823 attempt: 1,
2824 observer: Some(&observer),
2825 mocks: None,
2826 };
2827
2828 let rt = Runtime::new().unwrap();
2829 let result = rt.block_on(engine.execute(ctx, Value::Null)).unwrap();
2830 assert!(matches!(result.status, FlowStatus::Completed));
2831
2832 let starts = observer.starts.lock().unwrap();
2833 let ends = observer.ends.lock().unwrap();
2834 assert_eq!(starts.len(), 1);
2835 assert_eq!(ends.len(), 1);
2836 assert_eq!(ends[0], json!({ "message": "logged" }));
2837 }
2838
2839 fn host_flow_for_test(
2840 flow_id: &str,
2841 node_ids: &[&str],
2842 default_start: Option<&str>,
2843 ) -> HostFlow {
2844 let mut nodes = indexmap::IndexMap::default();
2845 for node_id in node_ids {
2846 let id = NodeId::from_str(node_id).unwrap();
2847 let node = Node {
2848 id: id.clone(),
2849 component: FlowComponentRef {
2850 id: "emit.log".parse().unwrap(),
2851 pack_alias: None,
2852 operation: None,
2853 },
2854 input: InputMapping {
2855 mapping: json!({ "message": node_id }),
2856 },
2857 output: OutputMapping {
2858 mapping: Value::Null,
2859 },
2860 err_map: None,
2861 routing: Routing::End,
2862 telemetry: TelemetryHints::default(),
2863 };
2864 nodes.insert(id, node);
2865 }
2866 let mut entrypoints = BTreeMap::new();
2867 if let Some(start) = default_start {
2868 entrypoints.insert("default".to_string(), Value::String(start.to_string()));
2869 }
2870 HostFlow::from(Flow {
2871 schema_version: "1.0".into(),
2872 id: FlowId::from_str(flow_id).unwrap(),
2873 kind: FlowKind::Messaging,
2874 entrypoints,
2875 nodes,
2876 metadata: Default::default(),
2877 })
2878 }
2879
2880 fn jump_test_engine() -> FlowEngine {
2881 let target_flow = host_flow_for_test("flow.target", &["node-a", "node-b"], None);
2882 FlowEngine {
2883 packs: Vec::new(),
2884 flows: Vec::new(),
2885 flow_sources: HashMap::new(),
2886 flow_cache: RwLock::new(HashMap::from([(
2887 FlowKey {
2888 pack_id: "test-pack".to_string(),
2889 flow_id: "flow.target".to_string(),
2890 },
2891 target_flow,
2892 )])),
2893 default_env: "local".to_string(),
2894 validation: ValidationConfig {
2895 mode: ValidationMode::Off,
2896 },
2897 cross_pack_resolver: None,
2898 }
2899 }
2900
2901 fn jump_ctx<'a>(flow_id: &'a str) -> FlowContext<'a> {
2902 FlowContext {
2903 tenant: "demo",
2904 pack_id: "test-pack",
2905 flow_id,
2906 node_id: None,
2907 tool: None,
2908 action: None,
2909 session_id: None,
2910 provider_id: None,
2911 retry_config: RetryConfig {
2912 max_attempts: 1,
2913 base_delay_ms: 1,
2914 },
2915 attempt: 1,
2916 observer: None,
2917 mocks: None,
2918 }
2919 }
2920
2921 #[test]
2922 fn apply_jump_unknown_flow_errors() {
2923 let engine = minimal_engine();
2924 let mut state = ExecutionState::new(Value::Null);
2925 let rt = Runtime::new().unwrap();
2926 let err = rt
2927 .block_on(engine.apply_jump(
2928 &jump_ctx("flow.source"),
2929 &mut state,
2930 JumpControl {
2931 flow: "flow.missing".into(),
2932 node: None,
2933 payload: json!({ "ok": true }),
2934 hints: Value::Null,
2935 max_redirects: None,
2936 reason: None,
2937 },
2938 ))
2939 .unwrap_err();
2940 assert!(
2941 err.to_string().contains("unknown_flow"),
2942 "unexpected error: {err}"
2943 );
2944 }
2945
2946 #[test]
2947 fn apply_jump_unknown_node_errors() {
2948 let engine = jump_test_engine();
2949 let mut state = ExecutionState::new(Value::Null);
2950 let rt = Runtime::new().unwrap();
2951 let err = rt
2952 .block_on(engine.apply_jump(
2953 &jump_ctx("flow.source"),
2954 &mut state,
2955 JumpControl {
2956 flow: "flow.target".into(),
2957 node: Some("node-missing".into()),
2958 payload: json!({ "ok": true }),
2959 hints: Value::Null,
2960 max_redirects: None,
2961 reason: None,
2962 },
2963 ))
2964 .unwrap_err();
2965 assert!(
2966 err.to_string().contains("unknown_node"),
2967 "unexpected error: {err}"
2968 );
2969 }
2970
2971 #[test]
2972 fn apply_jump_uses_default_start_fallback() {
2973 let engine = jump_test_engine();
2974 let mut state = ExecutionState::new(Value::Null);
2975 let rt = Runtime::new().unwrap();
2976 let target = rt
2977 .block_on(engine.apply_jump(
2978 &jump_ctx("flow.source"),
2979 &mut state,
2980 JumpControl {
2981 flow: "flow.target".into(),
2982 node: None,
2983 payload: json!({ "k": "v" }),
2984 hints: Value::Null,
2985 max_redirects: None,
2986 reason: None,
2987 },
2988 ))
2989 .expect("jump target");
2990 assert_eq!(target.flow_id, "flow.target");
2991 assert_eq!(target.node_id.as_str(), "node-a");
2992 }
2993
2994 #[test]
2995 fn apply_jump_redirect_limit_enforced() {
2996 let engine = jump_test_engine();
2997 let mut state = ExecutionState::new(Value::Null);
2998 state.redirect_count = 3;
2999 let rt = Runtime::new().unwrap();
3000 let err = rt
3001 .block_on(engine.apply_jump(
3002 &jump_ctx("flow.source"),
3003 &mut state,
3004 JumpControl {
3005 flow: "flow.target".into(),
3006 node: None,
3007 payload: json!({ "k": "v" }),
3008 hints: Value::Null,
3009 max_redirects: Some(3),
3010 reason: None,
3011 },
3012 ))
3013 .unwrap_err();
3014 assert_eq!(err.to_string(), "redirect_limit");
3015 }
3016}
3017
3018use tracing::Instrument;
3019
3020pub struct FlowContext<'a> {
3021 pub tenant: &'a str,
3022 pub pack_id: &'a str,
3023 pub flow_id: &'a str,
3024 pub node_id: Option<&'a str>,
3025 pub tool: Option<&'a str>,
3026 pub action: Option<&'a str>,
3027 pub session_id: Option<&'a str>,
3028 pub provider_id: Option<&'a str>,
3029 pub retry_config: RetryConfig,
3030 pub attempt: u32,
3031 pub observer: Option<&'a dyn ExecutionObserver>,
3032 pub mocks: Option<&'a MockLayer>,
3033}
3034
3035#[derive(Copy, Clone)]
3036pub struct RetryConfig {
3037 pub max_attempts: u32,
3038 pub base_delay_ms: u64,
3039}
3040
3041fn should_retry(err: &anyhow::Error) -> bool {
3042 let lower = err.to_string().to_lowercase();
3043 lower.contains("transient")
3044 || lower.contains("unavailable")
3045 || lower.contains("internal")
3046 || lower.contains("timeout")
3047}
3048
3049impl From<FlowRetryConfig> for RetryConfig {
3050 fn from(value: FlowRetryConfig) -> Self {
3051 Self {
3052 max_attempts: value.max_attempts.max(1),
3053 base_delay_ms: value.base_delay_ms.max(50),
3054 }
3055 }
3056}