1use std::collections::HashMap;
2use std::path::Path;
3use std::sync::Arc;
4
5use anyhow::{Context, Result, anyhow, bail};
6use serde_json::Value;
7
8use crate::activity::{Activity, WelcomeFlowHint};
9use crate::boot;
10use crate::component_api::node::{ExecCtx as ComponentExecCtx, TenantCtx as ComponentTenantCtx};
11use crate::config::{Fast2FlowRoutingConfig, HostConfig};
12use crate::engine::host::{SessionHost, StateHost};
13use crate::engine::runtime::{FlowResumeStore, IngressEnvelope};
14#[cfg(feature = "greentic-x-provider")]
15use crate::greentic_x_provider::RunnerPackFast2FlowRoutingProvider;
16use crate::http::health::HealthState;
17use crate::pack::{IdentifyOutcome, PackRuntime};
18use crate::runner::adapt_timer;
19use crate::runner::engine::FlowEngine;
20use crate::runtime::{ActivePacks, TenantRuntime};
21use crate::secrets::{DynSecretsManager, default_manager};
22use crate::storage::{
23 DynSessionStore, DynStateStore, new_session_store, new_state_store, session_host_from,
24 state_host_from,
25};
26use crate::wasi::RunnerWasiPolicy;
27use greentic_deploy_spec::ids::{BundleId, DeploymentId, RevisionId};
28#[cfg(feature = "greentic-x-provider")]
29use greentic_x_runtime::{
30 Fast2FlowDirective, Fast2FlowMessageEnvelope, Fast2FlowRouteRequest, Fast2FlowRoutingProvider,
31};
32
33#[derive(Clone, Debug)]
34pub struct TelemetryCfg {
35 pub config: greentic_telemetry::TelemetryConfig,
36 pub export: greentic_telemetry::export::ExportConfig,
37}
38
39pub struct HostBuilder {
41 configs: HashMap<String, HostConfig>,
42 telemetry: Option<TelemetryCfg>,
43 wasi_policy: RunnerWasiPolicy,
44 secrets: Option<DynSecretsManager>,
45}
46
47impl HostBuilder {
48 pub fn new() -> Self {
49 Self {
50 configs: HashMap::new(),
51 telemetry: None,
52 wasi_policy: RunnerWasiPolicy::default(),
53 secrets: None,
54 }
55 }
56
57 pub fn with_config(mut self, config: HostConfig) -> Self {
58 self.configs.insert(config.tenant.clone(), config);
59 self
60 }
61
62 pub fn with_telemetry(mut self, telemetry: TelemetryCfg) -> Self {
63 self.telemetry = Some(telemetry);
64 self
65 }
66
67 pub fn with_wasi_policy(mut self, policy: RunnerWasiPolicy) -> Self {
68 self.wasi_policy = policy;
69 self
70 }
71
72 pub fn with_secrets_manager(mut self, manager: DynSecretsManager) -> Self {
73 self.secrets = Some(manager);
74 self
75 }
76
77 pub fn build(self) -> Result<RunnerHost> {
78 if self.configs.is_empty() {
79 bail!("at least one tenant configuration is required");
80 }
81 let wasi_policy = Arc::new(self.wasi_policy);
82 let configs = self
83 .configs
84 .into_iter()
85 .map(|(tenant, cfg)| (tenant, Arc::new(cfg)))
86 .collect();
87 let session_store = new_session_store();
88 let session_host = session_host_from(Arc::clone(&session_store));
89 let state_store = new_state_store();
90 let state_host = state_host_from(Arc::clone(&state_store));
91 let secrets = match self.secrets {
92 Some(manager) => manager,
93 None => default_manager().context("failed to initialise default secrets backend")?,
94 };
95 Ok(RunnerHost {
96 configs,
97 active: Arc::new(ActivePacks::new()),
98 health: Arc::new(HealthState::new()),
99 session_store,
100 state_store,
101 session_host,
102 state_host,
103 wasi_policy,
104 secrets_manager: secrets,
105 telemetry: self.telemetry,
106 })
107 }
108}
109
110impl Default for HostBuilder {
111 fn default() -> Self {
112 Self::new()
113 }
114}
115
116pub struct RunnerHost {
118 configs: HashMap<String, Arc<HostConfig>>,
119 active: Arc<ActivePacks>,
120 health: Arc<HealthState>,
121 session_store: DynSessionStore,
122 state_store: DynStateStore,
123 session_host: Arc<dyn SessionHost>,
124 state_host: Arc<dyn StateHost>,
125 wasi_policy: Arc<RunnerWasiPolicy>,
126 secrets_manager: DynSecretsManager,
127 telemetry: Option<TelemetryCfg>,
128}
129
130#[derive(Clone)]
132pub struct TenantHandle {
133 runtime: Arc<TenantRuntime>,
134}
135
136impl RunnerHost {
137 pub async fn start(&self) -> Result<()> {
138 boot::init(&self.health, self.telemetry.as_ref())?;
139 Ok(())
140 }
141
142 pub async fn stop(&self) -> Result<()> {
143 self.active.replace(HashMap::new());
144 Ok(())
145 }
146
147 pub async fn load_pack(&self, tenant: &str, pack_path: &Path) -> Result<()> {
148 let archive_source = if is_pack_archive(pack_path) {
149 Some(pack_path)
150 } else {
151 None
152 };
153 let runtime = self
154 .prepare_runtime(tenant, pack_path, archive_source)
155 .await
156 .with_context(|| format!("failed to load tenant {tenant}"))?;
157 self.active.insert_pack(tenant, runtime);
158 tracing::info!(tenant, pack = %pack_path.display(), "pack loaded");
159 Ok(())
160 }
161
162 pub async fn handle_activity(&self, tenant: &str, activity: Activity) -> Result<Vec<Activity>> {
163 let runtime = self
164 .active
165 .load_pack(tenant)
166 .with_context(|| format!("tenant {tenant} not loaded"))?;
167 self.dispatch_activity(&runtime, tenant, activity).await
168 }
169
170 pub async fn handle_activity_for_revision(
199 &self,
200 tenant: &str,
201 deployment_id: DeploymentId,
202 bundle_id: BundleId,
203 revision_id: RevisionId,
204 activity: Activity,
205 ) -> Result<Vec<Activity>> {
206 let runtime = self
207 .active
208 .load_revision(tenant, deployment_id, bundle_id, revision_id)
209 .with_context(|| {
210 format!(
211 "revision runtime not loaded for tenant {tenant} \
212 (deployment {deployment_id}, revision {revision_id})"
213 )
214 })?;
215 self.dispatch_activity(&runtime, tenant, activity).await
216 }
217
218 fn load_revision_runtime(
223 &self,
224 tenant: &str,
225 deployment_id: DeploymentId,
226 bundle_id: BundleId,
227 revision_id: RevisionId,
228 ) -> Result<Arc<crate::runtime::TenantRuntime>> {
229 self.active
230 .load_revision(tenant, deployment_id, bundle_id, revision_id)
231 .with_context(|| {
232 format!(
233 "revision runtime not loaded for tenant {tenant} \
234 (deployment {deployment_id}, revision {revision_id})"
235 )
236 })
237 }
238
239 pub async fn identify_messaging_endpoints_for_revision(
271 &self,
272 tenant: &str,
273 deployment_id: DeploymentId,
274 bundle_id: BundleId,
275 revision_id: RevisionId,
276 provider_types: &[&str],
277 payload: &[u8],
278 ) -> Result<HashMap<String, IdentifyOutcome>> {
279 if provider_types.is_empty() {
280 return Ok(HashMap::new());
281 }
282 let runtime = self.load_revision_runtime(tenant, deployment_id, bundle_id, revision_id)?;
283 let mut merged: HashMap<String, IdentifyOutcome> = provider_types
286 .iter()
287 .map(|ty| ((*ty).to_string(), IdentifyOutcome::Unsupported))
288 .collect();
289 for pack in runtime.all_packs() {
290 let remaining: Vec<&str> = provider_types
292 .iter()
293 .copied()
294 .filter(|ty| !matches!(merged.get(*ty), Some(IdentifyOutcome::Identified(_))))
295 .collect();
296 if remaining.is_empty() {
297 break;
298 }
299 let probe = pack
300 .identify_endpoints_by_provider_type(&remaining, payload)
301 .await?;
302 for (ty, outcome) in probe {
303 if let Some(existing) = merged.get_mut(&ty) {
304 existing.merge_in(outcome);
305 }
306 }
307 }
308 Ok(merged)
309 }
310
311 #[allow(clippy::too_many_arguments)]
326 pub async fn identify_messaging_endpoints_for_revision_scoped(
327 &self,
328 tenant: &str,
329 deployment_id: DeploymentId,
330 bundle_id: BundleId,
331 revision_id: RevisionId,
332 provider_types: &[&str],
333 headers: &[(String, String)],
334 body: &Value,
335 ) -> Result<HashMap<String, IdentifyOutcome>> {
336 if provider_types.is_empty() {
337 return Ok(HashMap::new());
338 }
339 let runtime = self.load_revision_runtime(tenant, deployment_id, bundle_id, revision_id)?;
340 let mut merged: HashMap<String, IdentifyOutcome> = provider_types
341 .iter()
342 .map(|ty| ((*ty).to_string(), IdentifyOutcome::Unsupported))
343 .collect();
344 for pack in runtime.all_packs() {
345 let remaining: Vec<&str> = provider_types
346 .iter()
347 .copied()
348 .filter(|ty| !matches!(merged.get(*ty), Some(IdentifyOutcome::Identified(_))))
349 .collect();
350 if remaining.is_empty() {
351 break;
352 }
353 let probe = pack
354 .identify_endpoints_by_provider_type_scoped(&remaining, headers, body)
355 .await?;
356 for (ty, outcome) in probe {
357 if let Some(existing) = merged.get_mut(&ty) {
358 existing.merge_in(outcome);
359 }
360 }
361 }
362 Ok(merged)
363 }
364
365 pub async fn describe_identify_instances_for_revision(
376 &self,
377 tenant: &str,
378 deployment_id: DeploymentId,
379 bundle_id: BundleId,
380 revision_id: RevisionId,
381 provider_types: &[&str],
382 ) -> Result<HashMap<String, Option<crate::identify_hint::IdentifyInstanceHint>>> {
383 if provider_types.is_empty() {
384 return Ok(HashMap::new());
385 }
386 let runtime = self.load_revision_runtime(tenant, deployment_id, bundle_id, revision_id)?;
387 let mut merged: HashMap<String, Option<crate::identify_hint::IdentifyInstanceHint>> =
388 provider_types
389 .iter()
390 .map(|ty| ((*ty).to_string(), None))
391 .collect();
392 for pack in runtime.all_packs() {
393 let remaining: Vec<&str> = provider_types
397 .iter()
398 .copied()
399 .filter(|ty| !matches!(merged.get(*ty), Some(Some(_))))
400 .collect();
401 if remaining.is_empty() {
402 break;
403 }
404 let probe = pack
405 .describe_identify_hints_by_provider_type(&remaining)
406 .await?;
407 for (ty, hint) in probe {
408 if let Some(slot) = merged.get_mut(&ty)
409 && slot.is_none()
410 {
411 *slot = hint;
412 }
413 }
414 }
415 Ok(merged)
416 }
417
418 #[allow(clippy::too_many_arguments)]
455 pub async fn invoke_provider_for_revision(
456 &self,
457 tenant: &str,
458 deployment_id: DeploymentId,
459 bundle_id: BundleId,
460 revision_id: RevisionId,
461 provider_type: &str,
462 op: &str,
463 input_json: Vec<u8>,
464 correlation_id: Option<String>,
465 trace_id: Option<String>,
466 ) -> Result<Value> {
467 let runtime = self.load_revision_runtime(tenant, deployment_id, bundle_id, revision_id)?;
468 let mut matched = None;
473 for pack in runtime.all_packs() {
474 let Some(registry) = pack.provider_registry_optional()? else {
475 continue;
476 };
477 let Some((binding, declared_ops)) =
478 registry.try_resolve_with_ops(None, Some(provider_type))?
479 else {
480 continue;
481 };
482 if matched.is_some() {
483 bail!(
484 "ambiguous provider_type `{provider_type}` in revision \
485 (deployment {deployment_id}, revision {revision_id}): \
486 multiple packs bind the same type; pack manifests must \
487 declare each provider_type at most once across main + overlays"
488 );
489 }
490 matched = Some((Arc::clone(pack), binding, declared_ops));
491 }
492 let Some((pack, binding, declared_ops)) = matched else {
493 bail!(
494 "no pack in revision binds provider_type `{provider_type}` \
495 (deployment {deployment_id}, revision {revision_id})"
496 );
497 };
498 if !declared_ops.iter().any(|d| d == op) {
499 bail!(
500 "op `{op}` is not declared for provider_type `{provider_type}` \
501 in revision (deployment {deployment_id}, revision {revision_id}); \
502 declared ops: {declared_ops:?}"
503 );
504 }
505 let exec_ctx = ComponentExecCtx {
506 tenant: ComponentTenantCtx {
507 tenant: tenant.to_string(),
508 team: None,
509 user: None,
510 trace_id,
511 i18n_id: None,
512 correlation_id: correlation_id.clone(),
513 deadline_unix_ms: None,
514 attempt: 1,
515 idempotency_key: correlation_id,
516 },
517 i18n_id: None,
518 flow_id: format!("provider-webhook/{provider_type}"),
519 node_id: None,
520 };
521 pack.invoke_provider(&binding, exec_ctx, op, input_json)
522 .await
523 }
524
525 async fn dispatch_activity(
530 &self,
531 runtime: &TenantRuntime,
532 tenant: &str,
533 activity: Activity,
534 ) -> Result<Vec<Activity>> {
535 let activity = apply_fast2flow_routing(runtime, tenant, activity)?;
536
537 if activity.action() == Some("response") && activity.flow_id().is_none() {
543 return Ok(vec![activity]);
544 }
545
546 let (pack_id, flow_id) = resolve_flow_id(runtime, &activity)?;
547 let action = activity.action().map(|value| value.to_string());
548 let session = activity.session_id().map(|value| value.to_string());
549 let provider = activity.provider_id().map(|value| value.to_string());
550 let messaging_endpoint_id = activity
551 .messaging_endpoint_id()
552 .map(|value| value.to_string());
553 let channel = activity.channel().map(|value| value.to_string());
554 let conversation = activity.conversation().map(|value| value.to_string());
555 let user = activity.user().map(|value| value.to_string());
556 let welcome_flow_hint = activity.welcome_flow_hint().cloned();
557 let resolved_flow_type =
558 activity
559 .flow_type()
560 .map(|value| value.to_string())
561 .or_else(|| {
562 runtime
563 .engine()
564 .flow_by_key(&pack_id, &flow_id)
565 .map(|desc| desc.flow_type.clone())
566 });
567 let payload = activity.into_payload();
568
569 let mut envelope = IngressEnvelope {
570 tenant: tenant.to_string(),
571 env: std::env::var("GREENTIC_ENV").ok(),
572 pack_id: Some(pack_id.clone()),
573 flow_id: flow_id.clone(),
574 flow_type: resolved_flow_type,
575 action,
576 session_hint: session,
577 provider,
578 messaging_endpoint_id,
579 channel,
580 conversation,
581 user,
582 activity_id: None,
583 timestamp: None,
584 payload,
585 metadata: None,
586 reply_scope: None,
587 }
588 .canonicalize();
589
590 let hint_flow_type = welcome_flow_hint.as_ref().and_then(|hint| {
591 runtime
592 .engine()
593 .flow_by_key(&hint.pack_id, &hint.flow_id)
594 .map(|desc| desc.flow_type.clone())
595 });
596 apply_welcome_flow_override(
597 runtime.session_store(),
598 &mut envelope,
599 welcome_flow_hint.as_ref(),
600 hint_flow_type,
601 )?;
602
603 let result = runtime.state_machine().handle(envelope).await?;
604 Ok(normalize_replies(result, tenant))
605 }
606
607 pub async fn tenant(&self, tenant: &str) -> Option<TenantHandle> {
608 self.active
609 .load_pack(tenant)
610 .map(|runtime| TenantHandle { runtime })
611 }
612
613 pub fn active_packs(&self) -> Arc<ActivePacks> {
614 Arc::clone(&self.active)
615 }
616
617 pub fn health_state(&self) -> Arc<HealthState> {
618 Arc::clone(&self.health)
619 }
620
621 pub fn wasi_policy(&self) -> Arc<RunnerWasiPolicy> {
622 Arc::clone(&self.wasi_policy)
623 }
624
625 pub fn session_store(&self) -> DynSessionStore {
626 Arc::clone(&self.session_store)
627 }
628
629 pub fn state_store(&self) -> DynStateStore {
630 Arc::clone(&self.state_store)
631 }
632
633 pub fn session_host(&self) -> Arc<dyn SessionHost> {
634 Arc::clone(&self.session_host)
635 }
636
637 pub fn state_host(&self) -> Arc<dyn StateHost> {
638 Arc::clone(&self.state_host)
639 }
640
641 pub fn secrets_manager(&self) -> DynSecretsManager {
642 Arc::clone(&self.secrets_manager)
643 }
644
645 pub fn tenant_configs(&self) -> HashMap<String, Arc<HostConfig>> {
646 self.configs.clone()
647 }
648
649 #[cfg(test)]
654 pub(crate) fn for_test() -> Arc<Self> {
655 use crate::config::{
656 FlowRetryConfig, OperatorPolicy, RateLimits, SecretsPolicy, StateStorePolicy,
657 WebhookPolicy,
658 };
659 let config = Arc::new(HostConfig {
660 tenant: "test".into(),
661 bindings_path: std::path::PathBuf::from("<test>"),
662 flow_type_bindings: std::collections::HashMap::new(),
663 rate_limits: RateLimits::default(),
664 retry: FlowRetryConfig::default(),
665 http_enabled: false,
666 secrets_policy: SecretsPolicy::allow_all(),
667 state_store_policy: StateStorePolicy::default(),
668 webhook_policy: WebhookPolicy::default(),
669 timers: Vec::new(),
670 oauth: None,
671 mocks: None,
672 pack_bindings: Vec::new(),
673 env_passthrough: Vec::new(),
674 trace: crate::trace::TraceConfig::from_env(),
675 validation: crate::validate::ValidationConfig::from_env(),
676 operator_policy: OperatorPolicy::allow_all(),
677 fast2flow: Default::default(),
678 #[cfg(feature = "agentic-worker")]
679 agents: std::collections::HashMap::new(),
680 #[cfg(feature = "agentic-worker")]
681 graphs: std::collections::HashMap::new(),
682 });
683 let session_store = new_session_store();
684 let session_host = session_host_from(Arc::clone(&session_store));
685 let state_store = new_state_store();
686 let state_host = state_host_from(Arc::clone(&state_store));
687 let secrets_manager = default_manager().expect("test secrets manager");
688 Arc::new(Self {
689 configs: std::collections::HashMap::from([("test".to_string(), config)]),
690 active: Arc::new(ActivePacks::new()),
691 health: Arc::new(HealthState::new()),
692 session_store,
693 state_store,
694 session_host,
695 state_host,
696 wasi_policy: Arc::new(RunnerWasiPolicy::default()),
697 secrets_manager,
698 telemetry: None,
699 })
700 }
701
702 async fn prepare_runtime(
703 &self,
704 tenant: &str,
705 pack_path: &Path,
706 archive_source: Option<&Path>,
707 ) -> Result<Arc<TenantRuntime>> {
708 let config = self
709 .configs
710 .get(tenant)
711 .cloned()
712 .with_context(|| format!("tenant {tenant} not registered"))?;
713 if config.tenant != tenant {
714 bail!(
715 "tenant mismatch: config declares '{}' but '{tenant}' was requested",
716 config.tenant
717 );
718 }
719 let runtime = TenantRuntime::load(
720 pack_path,
721 Arc::clone(&config),
722 None,
723 archive_source,
724 None,
725 self.wasi_policy(),
726 self.session_host(),
727 self.session_store(),
728 self.state_store(),
729 self.state_host(),
730 self.secrets_manager(),
731 )
732 .await?;
733 let timers = adapt_timer::spawn_timers(Arc::clone(&runtime))?;
734 runtime.register_timers(timers);
735 Ok(runtime)
736 }
737}
738
739impl TenantHandle {
740 pub fn config(&self) -> Arc<HostConfig> {
741 Arc::clone(self.runtime.config())
742 }
743
744 pub fn pack(&self) -> Arc<PackRuntime> {
745 self.runtime.pack()
746 }
747
748 pub fn engine(&self) -> Arc<FlowEngine> {
749 Arc::clone(self.runtime.engine())
750 }
751
752 pub fn overlays(&self) -> Vec<Arc<PackRuntime>> {
753 self.runtime.overlays()
754 }
755
756 pub fn overlay_digests(&self) -> Vec<Option<String>> {
757 self.runtime.overlay_digests()
758 }
759}
760
761fn apply_welcome_flow_override(
785 session_store: &DynSessionStore,
786 envelope: &mut IngressEnvelope,
787 hint: Option<&WelcomeFlowHint>,
788 hint_flow_type: Option<String>,
789) -> Result<()> {
790 let Some(hint) = hint else {
791 return Ok(());
792 };
793 if envelope.messaging_endpoint_id.is_none() {
794 return Ok(());
795 }
796
797 if !try_mark_welcome_first_contact(session_store, envelope)? {
798 return Ok(());
799 }
800
801 let resume = FlowResumeStore::new(Arc::clone(session_store));
802 let snapshot = resume
803 .fetch(envelope)
804 .map_err(|err| anyhow!("welcome-flow first-contact probe failed: {err}"))?;
805 if snapshot.is_some() {
806 return Ok(());
807 }
808
809 envelope.pack_id = Some(hint.pack_id.clone());
810 envelope.flow_id = hint.flow_id.clone();
811 envelope.flow_type = hint_flow_type;
812 Ok(())
813}
814
815fn try_mark_welcome_first_contact(
828 store: &DynSessionStore,
829 envelope: &IngressEnvelope,
830) -> Result<bool> {
831 let Some(scope) = welcome_marker_scope(envelope) else {
832 return Ok(false);
833 };
834 let (ctx, user) = FlowResumeStore::contact_identity(envelope)
835 .map_err(|e| anyhow!("welcome marker identity probe failed: {e}"))?;
836
837 if store
838 .find_wait_by_scope(&ctx, &user, &scope)
839 .map_err(|e| anyhow!("welcome marker probe failed: {e}"))?
840 .is_some()
841 {
842 return Ok(false);
843 }
844
845 let data = marker_session_data(&ctx, &user);
846 let session_key = marker_session_key(&ctx, &user, &scope);
847 store
848 .register_wait(&ctx, &user, &scope, &session_key, data, None)
849 .map_err(|e| anyhow!("welcome marker register failed: {e}"))?;
850 Ok(true)
851}
852
853fn marker_session_key(
866 ctx: &greentic_types::TenantCtx,
867 user: &greentic_types::UserId,
868 scope: &greentic_types::ReplyScope,
869) -> greentic_session::SessionKey {
870 use sha2::{Digest, Sha256};
871 let team = match ctx.team_id.as_ref().or(ctx.team.as_ref()) {
872 Some(t) => t.as_str(),
873 None => "<none>",
874 };
875 let digest = Sha256::digest(
876 format!(
877 "welcome-marker:v1\0{}\0{}\0team={team}\0{}\0{}",
878 ctx.env.as_str(),
879 ctx.tenant_id.as_str(),
880 user.as_str(),
881 scope.conversation,
882 )
883 .as_bytes(),
884 );
885 greentic_session::SessionKey::new(format!("welcome-marker::{}", hex::encode(digest)))
886}
887
888fn welcome_marker_scope(envelope: &IngressEnvelope) -> Option<greentic_types::ReplyScope> {
893 let eid = envelope.messaging_endpoint_id.as_deref()?;
894 Some(greentic_types::ReplyScope {
895 conversation: format!("welcome-seen::ep={eid}"),
896 thread: None,
897 reply_to: None,
898 correlation: None,
899 })
900}
901
902fn marker_session_data(
907 ctx: &greentic_types::TenantCtx,
908 user: &greentic_types::UserId,
909) -> greentic_session::SessionData {
910 use std::str::FromStr;
911 use std::sync::LazyLock;
912 static FLOW_ID: LazyLock<greentic_types::FlowId> =
913 LazyLock::new(|| greentic_types::FlowId::from_str("welcome-marker").expect("valid id"));
914 static PACK_ID: LazyLock<greentic_types::PackId> =
915 LazyLock::new(|| greentic_types::PackId::from_str("welcome-marker").expect("valid id"));
916 let cursor = greentic_types::SessionCursor::new("marker".to_string());
917 let ctx = ctx.clone().with_user(Some(user.clone()));
918 greentic_session::SessionData {
919 tenant_ctx: ctx,
920 flow_id: FLOW_ID.clone(),
921 pack_id: Some(PACK_ID.clone()),
922 cursor,
923 context_json: "{}".to_string(),
924 }
925}
926
927fn apply_fast2flow_routing(
928 runtime: &TenantRuntime,
929 tenant: &str,
930 activity: Activity,
931) -> Result<Activity> {
932 let config = &runtime.config().fast2flow;
933 if !config.enabled || activity.flow_id().is_some() {
934 return Ok(activity);
935 }
936 apply_fast2flow_routing_enabled(runtime, tenant, activity, config)
937}
938
939#[cfg(feature = "greentic-x-provider")]
940fn apply_fast2flow_routing_enabled(
941 runtime: &TenantRuntime,
942 tenant: &str,
943 activity: Activity,
944 config: &Fast2FlowRoutingConfig,
945) -> Result<Activity> {
946 let Some(text) = activity.payload().get("text").and_then(Value::as_str) else {
947 return Ok(activity);
948 };
949 if text.trim().is_empty() {
950 return Ok(activity);
951 }
952
953 let mut envelope = Fast2FlowMessageEnvelope::new(text.trim().to_owned());
954 if let Some(channel) = activity.channel() {
955 envelope = envelope.with_channel(channel.to_owned());
956 }
957 if let Some(provider) = activity.provider_id() {
958 envelope = envelope.with_provider(provider.to_owned());
959 }
960 let request = Fast2FlowRouteRequest {
961 scope: config.scope.clone().unwrap_or_else(|| tenant.to_owned()),
962 envelope,
963 session_active: activity.session_id().is_some(),
964 input_locale: "en".to_owned(),
965 time_budget_ms: config.time_budget_ms,
966 registry_path: config.registry_path.clone(),
967 indexes_path: config.indexes_path.clone(),
968 now_unix_ms: chrono::Utc::now().timestamp_millis().max(0) as u64,
969 metadata: Default::default(),
970 };
971 let provider = RunnerPackFast2FlowRoutingProvider::new(runtime.pack())
972 .map_err(|err| anyhow!(err.to_string()))?
973 .with_component_ref(config.component_ref.clone())
974 .with_operation(config.operation.clone())
975 .with_tenant(tenant.to_owned());
976 let route = provider
977 .route_intent(request)
978 .map_err(|err| anyhow!(err.to_string()))?;
979
980 match route.directive {
981 Fast2FlowDirective::Continue => Ok(activity),
982 Fast2FlowDirective::Dispatch {
983 target, entities, ..
984 } => apply_fast2flow_target(activity, &target, entities),
985 Fast2FlowDirective::Respond { message } => Ok(Activity::custom(
986 "response",
987 serde_json::json!({ "messages": [{ "text": message }] }),
988 )
989 .ensure_tenant(tenant)),
990 Fast2FlowDirective::Deny { reason } => Ok(Activity::custom(
991 "response",
992 serde_json::json!({ "messages": [{ "text": reason }] }),
993 )
994 .ensure_tenant(tenant)),
995 }
996}
997
998#[cfg(not(feature = "greentic-x-provider"))]
999fn apply_fast2flow_routing_enabled(
1000 _runtime: &TenantRuntime,
1001 _tenant: &str,
1002 _activity: Activity,
1003 _config: &Fast2FlowRoutingConfig,
1004) -> Result<Activity> {
1005 bail!("fast2flow routing requires the greentic-x-provider feature")
1006}
1007
1008#[cfg(feature = "greentic-x-provider")]
1009fn apply_fast2flow_target(
1010 activity: Activity,
1011 target: &str,
1012 entities: Vec<greentic_x_runtime::Fast2FlowRoutingEntity>,
1013) -> Result<Activity> {
1014 let target = target.trim();
1015 if target.is_empty() {
1016 bail!("fast2flow dispatch target is empty");
1017 }
1018 if let Some((pack_id, flow_id)) = target.split_once('/') {
1019 if pack_id.trim().is_empty() || flow_id.trim().is_empty() {
1020 bail!("fast2flow dispatch target `{target}` must be `pack_id/flow_id` or `flow_id`");
1021 }
1022 return Ok(attach_fast2flow_entities(
1023 activity.with_pack(pack_id.trim()).with_flow(flow_id.trim()),
1024 entities,
1025 ));
1026 }
1027 Ok(attach_fast2flow_entities(
1028 activity.with_flow(target),
1029 entities,
1030 ))
1031}
1032
1033#[cfg(feature = "greentic-x-provider")]
1034fn attach_fast2flow_entities(
1035 activity: Activity,
1036 entities: Vec<greentic_x_runtime::Fast2FlowRoutingEntity>,
1037) -> Activity {
1038 if entities.is_empty() {
1039 return activity;
1040 }
1041 activity.with_payload_field(
1042 "fast2flow",
1043 serde_json::json!({
1044 "entities": entities,
1045 }),
1046 )
1047}
1048
1049fn resolve_flow_id(runtime: &TenantRuntime, activity: &Activity) -> Result<(String, String)> {
1050 let engine = runtime.engine();
1051 if let Some(flow_id) = activity.flow_id() {
1052 if let Some(pack_id) = activity.pack_id() {
1053 if engine.flow_by_key(pack_id, flow_id).is_none() {
1054 bail!("flow {flow_id} not registered for pack {pack_id}");
1055 }
1056 return Ok((pack_id.to_string(), flow_id.to_string()));
1057 }
1058 if let Some(flow) = engine.flow_by_id(flow_id) {
1059 return Ok((flow.pack_id.clone(), flow.id.clone()));
1060 }
1061 bail!("flow {flow_id} is ambiguous; pack_id is required");
1062 }
1063
1064 if let Some(flow_type) = activity.flow_type() {
1065 if let Some(pack_id) = activity.pack_id() {
1066 if let Some(flow) = engine
1067 .flows()
1068 .iter()
1069 .find(|flow| flow.pack_id == pack_id && flow.flow_type == flow_type)
1070 {
1071 return Ok((pack_id.to_string(), flow.id.clone()));
1072 }
1073 bail!("flow type {flow_type} not registered for pack {pack_id}");
1074 }
1075 if let Some(flow) = engine.flow_by_type(flow_type) {
1076 return Ok((flow.pack_id.clone(), flow.id.clone()));
1077 }
1078 if let Some(flow) = engine.entry_flow_by_type(flow_type) {
1085 return Ok((flow.pack_id.clone(), flow.id.clone()));
1086 }
1087 bail!("flow type {flow_type} is ambiguous; pack_id is required");
1088 }
1089
1090 let pack = runtime.pack();
1091 let flow_id = pack
1092 .metadata()
1093 .entry_flows
1094 .first()
1095 .cloned()
1096 .ok_or_else(|| anyhow!("no entry flows registered for tenant {}", runtime.tenant()))?;
1097 Ok((pack.metadata().pack_id.clone(), flow_id))
1098}
1099
1100fn normalize_replies(result: Value, tenant: &str) -> Vec<Activity> {
1101 result
1102 .as_array()
1103 .cloned()
1104 .unwrap_or_else(|| vec![result])
1105 .into_iter()
1106 .map(|payload| Activity::from_output(payload, tenant))
1107 .collect()
1108}
1109
1110fn is_pack_archive(path: &Path) -> bool {
1111 path.extension()
1112 .and_then(|ext| ext.to_str())
1113 .map(|ext| ext.eq_ignore_ascii_case("gtpack"))
1114 .unwrap_or(false)
1115}
1116
1117#[cfg(test)]
1118mod welcome_flow_tests {
1119 use super::*;
1120 use crate::engine::runtime::IngressEnvelope;
1121 use crate::runner::engine::{ExecutionState, FlowSnapshot, FlowWait};
1122 use crate::storage::new_session_store;
1123 use greentic_types::ReplyScope;
1124 use serde_json::json;
1125
1126 fn sample_envelope(endpoint_id: Option<&str>) -> IngressEnvelope {
1127 sample_envelope_for_user(endpoint_id, "user-1")
1128 }
1129
1130 fn sample_envelope_for_user(endpoint_id: Option<&str>, user: &str) -> IngressEnvelope {
1131 IngressEnvelope {
1132 tenant: "demo".into(),
1133 env: Some("local".into()),
1134 pack_id: Some("pack.default".into()),
1135 flow_id: "flow.default".into(),
1136 flow_type: Some("messaging".into()),
1137 action: Some("messaging".into()),
1138 session_hint: None,
1139 provider: Some("teams".into()),
1140 messaging_endpoint_id: endpoint_id.map(String::from),
1141 channel: Some("chan".into()),
1142 conversation: Some(format!("conv-{user}")),
1143 user: Some(user.to_string()),
1144 activity_id: None,
1145 timestamp: None,
1146 payload: json!({}),
1147 metadata: None,
1148 reply_scope: Some(ReplyScope {
1149 conversation: format!("conv-{user}"),
1150 thread: None,
1151 reply_to: None,
1152 correlation: None,
1153 }),
1154 }
1155 .canonicalize()
1156 }
1157
1158 fn hint() -> WelcomeFlowHint {
1159 WelcomeFlowHint {
1160 pack_id: "pack.welcome".into(),
1161 flow_id: "flow.welcome".into(),
1162 }
1163 }
1164
1165 fn seed_resume(store: &DynSessionStore, envelope: &IngressEnvelope) {
1166 let resume = FlowResumeStore::new(Arc::clone(store));
1170 let state: ExecutionState = serde_json::from_value(json!({
1171 "input": { "text": "hi" },
1172 "nodes": {},
1173 "egress": []
1174 }))
1175 .expect("state");
1176 let wait = FlowWait {
1177 reason: Some("await-user".into()),
1178 snapshot: FlowSnapshot {
1179 pack_id: envelope.pack_id.clone().expect("pack_id"),
1180 flow_id: envelope.flow_id.clone(),
1181 next_flow: None,
1182 next_node: "node-2".into(),
1183 state,
1184 },
1185 };
1186 resume.save(envelope, &wait).expect("seed save");
1187 }
1188
1189 #[test]
1190 fn override_is_no_op_when_hint_absent() {
1191 let store = new_session_store();
1194 let mut envelope = sample_envelope(Some("teams-legal"));
1195 let before = envelope.clone();
1196 apply_welcome_flow_override(&store, &mut envelope, None, None).expect("ok");
1197 assert_eq!(envelope.pack_id, before.pack_id);
1198 assert_eq!(envelope.flow_id, before.flow_id);
1199 assert_eq!(envelope.flow_type, before.flow_type);
1200 }
1201
1202 #[test]
1203 fn override_is_no_op_when_endpoint_id_absent() {
1204 let store = new_session_store();
1207 let mut envelope = sample_envelope(None);
1208 let before = envelope.clone();
1209 apply_welcome_flow_override(&store, &mut envelope, Some(&hint()), Some("welcome".into()))
1210 .expect("ok");
1211 assert_eq!(envelope.pack_id, before.pack_id);
1212 assert_eq!(envelope.flow_id, before.flow_id);
1213 }
1214
1215 #[test]
1216 fn override_swaps_pack_flow_and_threads_flow_type_through() {
1217 for hint_flow_type in [Some("welcome".to_string()), None] {
1222 let store = new_session_store();
1223 let mut envelope = sample_envelope(Some("teams-legal"));
1224 apply_welcome_flow_override(
1225 &store,
1226 &mut envelope,
1227 Some(&hint()),
1228 hint_flow_type.clone(),
1229 )
1230 .expect("ok");
1231 assert_eq!(envelope.pack_id.as_deref(), Some("pack.welcome"));
1232 assert_eq!(envelope.flow_id, "flow.welcome");
1233 assert_eq!(envelope.flow_type, hint_flow_type);
1234 }
1235 }
1236
1237 #[test]
1238 fn override_is_no_op_on_repeat_turn_with_existing_session() {
1239 let store = new_session_store();
1243 let envelope_template = sample_envelope(Some("teams-legal"));
1244 seed_resume(&store, &envelope_template);
1245
1246 let mut envelope = envelope_template.clone();
1247 apply_welcome_flow_override(&store, &mut envelope, Some(&hint()), Some("welcome".into()))
1248 .expect("ok");
1249 assert_eq!(envelope.pack_id, envelope_template.pack_id);
1250 assert_eq!(envelope.flow_id, envelope_template.flow_id);
1251 assert_eq!(envelope.flow_type, envelope_template.flow_type);
1252 }
1253
1254 #[test]
1255 fn override_is_no_op_post_completion_when_marker_present() {
1256 let store = new_session_store();
1262 let mut first = sample_envelope(Some("teams-legal"));
1263 apply_welcome_flow_override(&store, &mut first, Some(&hint()), Some("welcome".into()))
1264 .expect("first turn ok");
1265 assert_eq!(
1266 first.pack_id.as_deref(),
1267 Some("pack.welcome"),
1268 "first turn fires welcome"
1269 );
1270
1271 let resume = FlowResumeStore::new(Arc::clone(&store));
1276 resume.clear(&first).expect("clear post-completion wait");
1277
1278 let mut second = sample_envelope(Some("teams-legal"));
1282 apply_welcome_flow_override(&store, &mut second, Some(&hint()), Some("welcome".into()))
1283 .expect("second turn ok");
1284 assert_eq!(
1285 second.pack_id.as_deref(),
1286 Some("pack.default"),
1287 "second turn must NOT re-fire welcome"
1288 );
1289 assert_eq!(second.flow_id, "flow.default");
1290 }
1291
1292 #[test]
1293 fn override_is_no_op_on_second_turn_after_marker_set() {
1294 let store = new_session_store();
1298 let mut first = sample_envelope(Some("teams-legal"));
1299 apply_welcome_flow_override(&store, &mut first, Some(&hint()), Some("welcome".into()))
1300 .expect("first turn ok");
1301 assert_eq!(first.pack_id.as_deref(), Some("pack.welcome"));
1302
1303 let mut second = sample_envelope(Some("teams-legal"));
1304 apply_welcome_flow_override(&store, &mut second, Some(&hint()), Some("welcome".into()))
1305 .expect("second turn ok");
1306 assert_eq!(
1307 second.pack_id.as_deref(),
1308 Some("pack.default"),
1309 "second turn must NOT re-fire welcome"
1310 );
1311 }
1312
1313 #[test]
1314 fn override_partitions_marker_per_endpoint() {
1315 let store = new_session_store();
1320 let mut legal = sample_envelope(Some("teams-legal"));
1321 apply_welcome_flow_override(&store, &mut legal, Some(&hint()), Some("welcome".into()))
1322 .expect("legal first turn ok");
1323 assert_eq!(legal.pack_id.as_deref(), Some("pack.welcome"));
1324
1325 let mut accounting = sample_envelope(Some("teams-accounting"));
1326 apply_welcome_flow_override(
1327 &store,
1328 &mut accounting,
1329 Some(&hint()),
1330 Some("welcome".into()),
1331 )
1332 .expect("accounting first turn ok");
1333 assert_eq!(
1334 accounting.pack_id.as_deref(),
1335 Some("pack.welcome"),
1336 "different endpoint = independent first contact"
1337 );
1338 }
1339
1340 #[test]
1341 fn override_partitions_marker_per_user_on_same_endpoint() {
1342 let store = new_session_store();
1352
1353 let mut a1 = sample_envelope_for_user(Some("teams-legal"), "user-a");
1355 apply_welcome_flow_override(&store, &mut a1, Some(&hint()), Some("welcome".into()))
1356 .expect("user-a first ok");
1357 assert_eq!(a1.pack_id.as_deref(), Some("pack.welcome"));
1358
1359 let mut b1 = sample_envelope_for_user(Some("teams-legal"), "user-b");
1361 apply_welcome_flow_override(&store, &mut b1, Some(&hint()), Some("welcome".into()))
1362 .expect("user-b first must not collide with user-a marker");
1363 assert_eq!(
1364 b1.pack_id.as_deref(),
1365 Some("pack.welcome"),
1366 "user-b is independent first contact"
1367 );
1368
1369 let mut a2 = sample_envelope_for_user(Some("teams-legal"), "user-a");
1371 apply_welcome_flow_override(&store, &mut a2, Some(&hint()), Some("welcome".into()))
1372 .expect("user-a second ok");
1373 assert_eq!(
1374 a2.pack_id.as_deref(),
1375 Some("pack.default"),
1376 "user-a must not be re-welcomed after user-b joined"
1377 );
1378
1379 let mut b2 = sample_envelope_for_user(Some("teams-legal"), "user-b");
1381 apply_welcome_flow_override(&store, &mut b2, Some(&hint()), Some("welcome".into()))
1382 .expect("user-b second ok");
1383 assert_eq!(b2.pack_id.as_deref(), Some("pack.default"));
1384 }
1385
1386 #[test]
1387 fn marker_is_not_written_when_hint_absent() {
1388 let store = new_session_store();
1397 let mut envelope = sample_envelope(Some("teams-legal"));
1398 apply_welcome_flow_override(&store, &mut envelope, None, None).expect("ok");
1399
1400 let mut next = sample_envelope(Some("teams-legal"));
1401 apply_welcome_flow_override(&store, &mut next, Some(&hint()), Some("welcome".into()))
1402 .expect("ok");
1403 assert_eq!(
1404 next.pack_id.as_deref(),
1405 Some("pack.welcome"),
1406 "no marker leaked from hint-absent path"
1407 );
1408 }
1409}
1410
1411#[cfg(test)]
1412mod identify_endpoints_tests {
1413 use super::*;
1414
1415 fn dummy_runner_host() -> RunnerHost {
1416 let session_store = new_session_store();
1417 let state_store = new_state_store();
1418 RunnerHost {
1419 configs: HashMap::new(),
1420 active: Arc::new(ActivePacks::new()),
1421 health: Arc::new(HealthState::new()),
1422 session_host: session_host_from(session_store.clone()),
1423 state_host: state_host_from(state_store.clone()),
1424 session_store,
1425 state_store,
1426 wasi_policy: Arc::new(RunnerWasiPolicy::new()),
1427 secrets_manager: default_manager().expect("default secrets manager"),
1428 telemetry: None,
1429 }
1430 }
1431
1432 #[tokio::test]
1433 async fn empty_provider_types_returns_empty_map_without_loading_revision() {
1434 let host = dummy_runner_host();
1438 let map = host
1439 .identify_messaging_endpoints_for_revision(
1440 "demo",
1441 DeploymentId::new(),
1442 BundleId::new("anything"),
1443 RevisionId::new(),
1444 &[],
1445 b"{}",
1446 )
1447 .await
1448 .expect("empty types is the cheap fast path");
1449 assert!(map.is_empty());
1450 }
1451
1452 #[tokio::test]
1453 async fn missing_revision_surfaces_clear_error() {
1454 let host = dummy_runner_host();
1458 let deployment = DeploymentId::new();
1459 let revision = RevisionId::new();
1460 let err = host
1461 .identify_messaging_endpoints_for_revision(
1462 "demo",
1463 deployment,
1464 BundleId::new("missing"),
1465 revision,
1466 &["teams"],
1467 b"{}",
1468 )
1469 .await
1470 .expect_err("missing revision must fail closed");
1471 let msg = format!("{err:#}");
1472 assert!(
1473 msg.contains("revision runtime not loaded"),
1474 "error chain should name the failure mode, got: {msg}"
1475 );
1476 assert!(
1477 msg.contains(&deployment.to_string()),
1478 "error chain should name the deployment id, got: {msg}"
1479 );
1480 assert!(
1481 msg.contains(&revision.to_string()),
1482 "error chain should name the revision id, got: {msg}"
1483 );
1484 }
1485
1486 #[tokio::test]
1487 async fn scoped_empty_provider_types_returns_empty_map() {
1488 let host = dummy_runner_host();
1489 let map = host
1490 .identify_messaging_endpoints_for_revision_scoped(
1491 "demo",
1492 DeploymentId::new(),
1493 BundleId::new("anything"),
1494 RevisionId::new(),
1495 &[],
1496 &[],
1497 &Value::Null,
1498 )
1499 .await
1500 .expect("empty types is the cheap fast path");
1501 assert!(map.is_empty());
1502 }
1503
1504 #[tokio::test]
1505 async fn scoped_missing_revision_surfaces_clear_error() {
1506 let host = dummy_runner_host();
1507 let deployment = DeploymentId::new();
1508 let revision = RevisionId::new();
1509 let err = host
1510 .identify_messaging_endpoints_for_revision_scoped(
1511 "demo",
1512 deployment,
1513 BundleId::new("missing"),
1514 revision,
1515 &["teams"],
1516 &[],
1517 &Value::Null,
1518 )
1519 .await
1520 .expect_err("missing revision must fail closed");
1521 let msg = format!("{err:#}");
1522 assert!(
1523 msg.contains("revision runtime not loaded"),
1524 "error chain should name the failure mode, got: {msg}"
1525 );
1526 assert!(
1527 msg.contains(&deployment.to_string()),
1528 "error chain should name the deployment id, got: {msg}"
1529 );
1530 assert!(
1531 msg.contains(&revision.to_string()),
1532 "error chain should name the revision id, got: {msg}"
1533 );
1534 }
1535
1536 #[test]
1551 fn identify_futures_are_send() {
1552 fn assert_send<F: Send>(_: F) {}
1553 let host = dummy_runner_host();
1554 assert_send(host.identify_messaging_endpoints_for_revision(
1558 "demo",
1559 DeploymentId::new(),
1560 BundleId::new("anything"),
1561 RevisionId::new(),
1562 &["teams"],
1563 b"{}",
1564 ));
1565 assert_send(host.identify_messaging_endpoints_for_revision_scoped(
1566 "demo",
1567 DeploymentId::new(),
1568 BundleId::new("anything"),
1569 RevisionId::new(),
1570 &["teams"],
1571 &[("x-telegram-bot-api-secret-token".into(), "tok".into())],
1572 &Value::Null,
1573 ));
1574 assert_send(host.describe_identify_instances_for_revision(
1575 "demo",
1576 DeploymentId::new(),
1577 BundleId::new("anything"),
1578 RevisionId::new(),
1579 &["teams"],
1580 ));
1581 assert_send(host.invoke_provider_for_revision(
1582 "demo",
1583 DeploymentId::new(),
1584 BundleId::new("anything"),
1585 RevisionId::new(),
1586 "messaging.telegram.bot",
1587 "ingest_http",
1588 b"{}".to_vec(),
1589 None,
1590 None,
1591 ));
1592 }
1593
1594 #[tokio::test]
1595 async fn invoke_provider_missing_revision_surfaces_clear_error() {
1596 let host = dummy_runner_host();
1601 let deployment = DeploymentId::new();
1602 let revision = RevisionId::new();
1603 let err = host
1604 .invoke_provider_for_revision(
1605 "demo",
1606 deployment,
1607 BundleId::new("missing"),
1608 revision,
1609 "messaging.telegram.bot",
1610 "ingest_http",
1611 b"{}".to_vec(),
1612 None,
1613 None,
1614 )
1615 .await
1616 .expect_err("missing revision must fail closed");
1617 let msg = format!("{err:#}");
1618 assert!(
1619 msg.contains("revision runtime not loaded"),
1620 "error chain should name the failure mode, got: {msg}"
1621 );
1622 assert!(
1623 msg.contains(&deployment.to_string()),
1624 "error chain should name the deployment id, got: {msg}"
1625 );
1626 assert!(
1627 msg.contains(&revision.to_string()),
1628 "error chain should name the revision id, got: {msg}"
1629 );
1630 }
1631}
1632
1633#[cfg(all(test, feature = "greentic-x-provider"))]
1634mod fast2flow_tests {
1635 use greentic_x_runtime::Fast2FlowRoutingEntity;
1636
1637 use super::*;
1638
1639 #[test]
1640 fn dispatch_target_attaches_prefill_entities_to_payload() {
1641 let activity = Activity::text("show traffic tomorrow");
1642 let routed = apply_fast2flow_target(
1643 activity,
1644 "telco-x/prefix-traffic",
1645 vec![Fast2FlowRoutingEntity::new("date", "20260611").with_format("iso", "2026-06-11")],
1646 )
1647 .expect("target should route");
1648
1649 assert_eq!(routed.pack_id(), Some("telco-x"));
1650 assert_eq!(routed.flow_id(), Some("prefix-traffic"));
1651 assert_eq!(
1652 routed.payload()["fast2flow"]["entities"][0]["normalized"],
1653 "20260611"
1654 );
1655 assert_eq!(
1656 routed.payload()["fast2flow"]["entities"][0]["formats"]["iso"],
1657 "2026-06-11"
1658 );
1659 }
1660}