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 mut payload = activity.into_payload();
568
569 let entry_node = {
581 let node_ids = runtime.engine().flow_node_ids(&pack_id, &flow_id).await;
582 let target = crate::runner::card_nav::entry_node_from_card_nav(
583 payload.get("metadata").unwrap_or(&serde_json::Value::Null),
584 &node_ids,
585 );
586 if target.is_some()
587 && let Some(metadata) = payload.get_mut("metadata")
588 {
589 crate::runner::card_nav::strip_card_nav_keys(metadata);
590 }
591 target
592 };
593
594 let mut envelope = IngressEnvelope {
595 entry_node,
596 tenant: tenant.to_string(),
597 env: std::env::var("GREENTIC_ENV").ok(),
598 pack_id: Some(pack_id.clone()),
599 flow_id: flow_id.clone(),
600 flow_type: resolved_flow_type,
601 action,
602 session_hint: session,
603 provider,
604 messaging_endpoint_id,
605 channel,
606 conversation,
607 user,
608 activity_id: None,
609 timestamp: None,
610 payload,
611 metadata: None,
612 reply_scope: None,
613 }
614 .canonicalize();
615
616 let hint_flow_type = welcome_flow_hint.as_ref().and_then(|hint| {
617 runtime
618 .engine()
619 .flow_by_key(&hint.pack_id, &hint.flow_id)
620 .map(|desc| desc.flow_type.clone())
621 });
622 apply_welcome_flow_override(
623 runtime.session_store(),
624 &mut envelope,
625 welcome_flow_hint.as_ref(),
626 hint_flow_type,
627 )?;
628
629 let result = runtime.state_machine().handle(envelope).await?;
630 Ok(normalize_replies(result, tenant))
631 }
632
633 pub async fn tenant(&self, tenant: &str) -> Option<TenantHandle> {
634 self.active
635 .load_pack(tenant)
636 .map(|runtime| TenantHandle { runtime })
637 }
638
639 pub fn active_packs(&self) -> Arc<ActivePacks> {
640 Arc::clone(&self.active)
641 }
642
643 pub fn health_state(&self) -> Arc<HealthState> {
644 Arc::clone(&self.health)
645 }
646
647 pub fn wasi_policy(&self) -> Arc<RunnerWasiPolicy> {
648 Arc::clone(&self.wasi_policy)
649 }
650
651 pub fn session_store(&self) -> DynSessionStore {
652 Arc::clone(&self.session_store)
653 }
654
655 pub fn state_store(&self) -> DynStateStore {
656 Arc::clone(&self.state_store)
657 }
658
659 pub fn session_host(&self) -> Arc<dyn SessionHost> {
660 Arc::clone(&self.session_host)
661 }
662
663 pub fn state_host(&self) -> Arc<dyn StateHost> {
664 Arc::clone(&self.state_host)
665 }
666
667 pub fn secrets_manager(&self) -> DynSecretsManager {
668 Arc::clone(&self.secrets_manager)
669 }
670
671 pub fn tenant_configs(&self) -> HashMap<String, Arc<HostConfig>> {
672 self.configs.clone()
673 }
674
675 #[cfg(test)]
680 pub(crate) fn for_test() -> Arc<Self> {
681 use crate::config::{
682 FlowRetryConfig, OperatorPolicy, RateLimits, SecretsPolicy, StateStorePolicy,
683 WebhookPolicy,
684 };
685 let config = Arc::new(HostConfig {
686 tenant: "test".into(),
687 bindings_path: std::path::PathBuf::from("<test>"),
688 flow_type_bindings: std::collections::HashMap::new(),
689 rate_limits: RateLimits::default(),
690 retry: FlowRetryConfig::default(),
691 http_enabled: false,
692 secrets_policy: SecretsPolicy::allow_all(),
693 state_store_policy: StateStorePolicy::default(),
694 webhook_policy: WebhookPolicy::default(),
695 timers: Vec::new(),
696 oauth: None,
697 mocks: None,
698 pack_bindings: Vec::new(),
699 env_passthrough: Vec::new(),
700 trace: crate::trace::TraceConfig::from_env(),
701 validation: crate::validate::ValidationConfig::from_env(),
702 operator_policy: OperatorPolicy::allow_all(),
703 fast2flow: Default::default(),
704 #[cfg(feature = "agentic-worker")]
705 agents: std::collections::HashMap::new(),
706 #[cfg(feature = "agentic-worker")]
707 graphs: std::collections::HashMap::new(),
708 });
709 let session_store = new_session_store();
710 let session_host = session_host_from(Arc::clone(&session_store));
711 let state_store = new_state_store();
712 let state_host = state_host_from(Arc::clone(&state_store));
713 let secrets_manager = default_manager().expect("test secrets manager");
714 Arc::new(Self {
715 configs: std::collections::HashMap::from([("test".to_string(), config)]),
716 active: Arc::new(ActivePacks::new()),
717 health: Arc::new(HealthState::new()),
718 session_store,
719 state_store,
720 session_host,
721 state_host,
722 wasi_policy: Arc::new(RunnerWasiPolicy::default()),
723 secrets_manager,
724 telemetry: None,
725 })
726 }
727
728 async fn prepare_runtime(
729 &self,
730 tenant: &str,
731 pack_path: &Path,
732 archive_source: Option<&Path>,
733 ) -> Result<Arc<TenantRuntime>> {
734 let config = self
735 .configs
736 .get(tenant)
737 .cloned()
738 .with_context(|| format!("tenant {tenant} not registered"))?;
739 if config.tenant != tenant {
740 bail!(
741 "tenant mismatch: config declares '{}' but '{tenant}' was requested",
742 config.tenant
743 );
744 }
745 let runtime = TenantRuntime::load(
746 pack_path,
747 Arc::clone(&config),
748 None,
749 archive_source,
750 None,
751 self.wasi_policy(),
752 self.session_host(),
753 self.session_store(),
754 self.state_store(),
755 self.state_host(),
756 self.secrets_manager(),
757 )
758 .await?;
759 let timers = adapt_timer::spawn_timers(Arc::clone(&runtime))?;
760 runtime.register_timers(timers);
761 Ok(runtime)
762 }
763}
764
765impl TenantHandle {
766 pub fn config(&self) -> Arc<HostConfig> {
767 Arc::clone(self.runtime.config())
768 }
769
770 pub fn pack(&self) -> Arc<PackRuntime> {
771 self.runtime.pack()
772 }
773
774 pub fn engine(&self) -> Arc<FlowEngine> {
775 Arc::clone(self.runtime.engine())
776 }
777
778 pub fn overlays(&self) -> Vec<Arc<PackRuntime>> {
779 self.runtime.overlays()
780 }
781
782 pub fn overlay_digests(&self) -> Vec<Option<String>> {
783 self.runtime.overlay_digests()
784 }
785}
786
787fn apply_welcome_flow_override(
811 session_store: &DynSessionStore,
812 envelope: &mut IngressEnvelope,
813 hint: Option<&WelcomeFlowHint>,
814 hint_flow_type: Option<String>,
815) -> Result<()> {
816 let Some(hint) = hint else {
817 return Ok(());
818 };
819 if envelope.messaging_endpoint_id.is_none() {
820 return Ok(());
821 }
822
823 if !try_mark_welcome_first_contact(session_store, envelope)? {
824 return Ok(());
825 }
826
827 let resume = FlowResumeStore::new(Arc::clone(session_store));
828 let snapshot = resume
829 .fetch(envelope)
830 .map_err(|err| anyhow!("welcome-flow first-contact probe failed: {err}"))?;
831 if snapshot.is_some() {
832 return Ok(());
833 }
834
835 envelope.pack_id = Some(hint.pack_id.clone());
836 envelope.flow_id = hint.flow_id.clone();
837 envelope.flow_type = hint_flow_type;
838 Ok(())
839}
840
841fn try_mark_welcome_first_contact(
854 store: &DynSessionStore,
855 envelope: &IngressEnvelope,
856) -> Result<bool> {
857 let Some(scope) = welcome_marker_scope(envelope) else {
858 return Ok(false);
859 };
860 let (ctx, user) = FlowResumeStore::contact_identity(envelope)
861 .map_err(|e| anyhow!("welcome marker identity probe failed: {e}"))?;
862
863 if store
864 .find_wait_by_scope(&ctx, &user, &scope)
865 .map_err(|e| anyhow!("welcome marker probe failed: {e}"))?
866 .is_some()
867 {
868 return Ok(false);
869 }
870
871 let data = marker_session_data(&ctx, &user);
872 let session_key = marker_session_key(&ctx, &user, &scope);
873 store
874 .register_wait(&ctx, &user, &scope, &session_key, data, None)
875 .map_err(|e| anyhow!("welcome marker register failed: {e}"))?;
876 Ok(true)
877}
878
879fn marker_session_key(
892 ctx: &greentic_types::TenantCtx,
893 user: &greentic_types::UserId,
894 scope: &greentic_types::ReplyScope,
895) -> greentic_session::SessionKey {
896 use sha2::{Digest, Sha256};
897 let team = match ctx.team_id.as_ref().or(ctx.team.as_ref()) {
898 Some(t) => t.as_str(),
899 None => "<none>",
900 };
901 let digest = Sha256::digest(
902 format!(
903 "welcome-marker:v1\0{}\0{}\0team={team}\0{}\0{}",
904 ctx.env.as_str(),
905 ctx.tenant_id.as_str(),
906 user.as_str(),
907 scope.conversation,
908 )
909 .as_bytes(),
910 );
911 greentic_session::SessionKey::new(format!("welcome-marker::{}", hex::encode(digest)))
912}
913
914fn welcome_marker_scope(envelope: &IngressEnvelope) -> Option<greentic_types::ReplyScope> {
919 let eid = envelope.messaging_endpoint_id.as_deref()?;
920 Some(greentic_types::ReplyScope {
921 conversation: format!("welcome-seen::ep={eid}"),
922 thread: None,
923 reply_to: None,
924 correlation: None,
925 })
926}
927
928fn marker_session_data(
933 ctx: &greentic_types::TenantCtx,
934 user: &greentic_types::UserId,
935) -> greentic_session::SessionData {
936 use std::str::FromStr;
937 use std::sync::LazyLock;
938 static FLOW_ID: LazyLock<greentic_types::FlowId> =
939 LazyLock::new(|| greentic_types::FlowId::from_str("welcome-marker").expect("valid id"));
940 static PACK_ID: LazyLock<greentic_types::PackId> =
941 LazyLock::new(|| greentic_types::PackId::from_str("welcome-marker").expect("valid id"));
942 let cursor = greentic_types::SessionCursor::new("marker".to_string());
943 let ctx = ctx.clone().with_user(Some(user.clone()));
944 greentic_session::SessionData {
945 tenant_ctx: ctx,
946 flow_id: FLOW_ID.clone(),
947 pack_id: Some(PACK_ID.clone()),
948 cursor,
949 context_json: "{}".to_string(),
950 }
951}
952
953fn apply_fast2flow_routing(
954 runtime: &TenantRuntime,
955 tenant: &str,
956 activity: Activity,
957) -> Result<Activity> {
958 let config = &runtime.config().fast2flow;
959 if !config.enabled || activity.flow_id().is_some() {
960 return Ok(activity);
961 }
962 apply_fast2flow_routing_enabled(runtime, tenant, activity, config)
963}
964
965#[cfg(feature = "greentic-x-provider")]
966fn apply_fast2flow_routing_enabled(
967 runtime: &TenantRuntime,
968 tenant: &str,
969 activity: Activity,
970 config: &Fast2FlowRoutingConfig,
971) -> Result<Activity> {
972 let Some(text) = activity.payload().get("text").and_then(Value::as_str) else {
973 return Ok(activity);
974 };
975 if text.trim().is_empty() {
976 return Ok(activity);
977 }
978
979 let mut envelope = Fast2FlowMessageEnvelope::new(text.trim().to_owned());
980 if let Some(channel) = activity.channel() {
981 envelope = envelope.with_channel(channel.to_owned());
982 }
983 if let Some(provider) = activity.provider_id() {
984 envelope = envelope.with_provider(provider.to_owned());
985 }
986 let request = Fast2FlowRouteRequest {
987 scope: config.scope.clone().unwrap_or_else(|| tenant.to_owned()),
988 envelope,
989 session_active: activity.session_id().is_some(),
990 input_locale: "en".to_owned(),
991 time_budget_ms: config.time_budget_ms,
992 registry_path: config.registry_path.clone(),
993 indexes_path: config.indexes_path.clone(),
994 now_unix_ms: chrono::Utc::now().timestamp_millis().max(0) as u64,
995 metadata: Default::default(),
996 };
997 let provider = RunnerPackFast2FlowRoutingProvider::new(runtime.pack())
998 .map_err(|err| anyhow!(err.to_string()))?
999 .with_component_ref(config.component_ref.clone())
1000 .with_operation(config.operation.clone())
1001 .with_tenant(tenant.to_owned());
1002 let route = provider
1003 .route_intent(request)
1004 .map_err(|err| anyhow!(err.to_string()))?;
1005
1006 match route.directive {
1007 Fast2FlowDirective::Continue => Ok(activity),
1008 Fast2FlowDirective::Dispatch {
1009 target, entities, ..
1010 } => apply_fast2flow_target(activity, &target, entities),
1011 Fast2FlowDirective::Respond { message } => Ok(Activity::custom(
1012 "response",
1013 serde_json::json!({ "messages": [{ "text": message }] }),
1014 )
1015 .ensure_tenant(tenant)),
1016 Fast2FlowDirective::Deny { reason } => Ok(Activity::custom(
1017 "response",
1018 serde_json::json!({ "messages": [{ "text": reason }] }),
1019 )
1020 .ensure_tenant(tenant)),
1021 }
1022}
1023
1024#[cfg(not(feature = "greentic-x-provider"))]
1025fn apply_fast2flow_routing_enabled(
1026 _runtime: &TenantRuntime,
1027 _tenant: &str,
1028 _activity: Activity,
1029 _config: &Fast2FlowRoutingConfig,
1030) -> Result<Activity> {
1031 bail!("fast2flow routing requires the greentic-x-provider feature")
1032}
1033
1034#[cfg(feature = "greentic-x-provider")]
1035fn apply_fast2flow_target(
1036 activity: Activity,
1037 target: &str,
1038 entities: Vec<greentic_x_runtime::Fast2FlowRoutingEntity>,
1039) -> Result<Activity> {
1040 let target = target.trim();
1041 if target.is_empty() {
1042 bail!("fast2flow dispatch target is empty");
1043 }
1044 if let Some((pack_id, flow_id)) = target.split_once('/') {
1045 if pack_id.trim().is_empty() || flow_id.trim().is_empty() {
1046 bail!("fast2flow dispatch target `{target}` must be `pack_id/flow_id` or `flow_id`");
1047 }
1048 return Ok(attach_fast2flow_entities(
1049 activity.with_pack(pack_id.trim()).with_flow(flow_id.trim()),
1050 entities,
1051 ));
1052 }
1053 Ok(attach_fast2flow_entities(
1054 activity.with_flow(target),
1055 entities,
1056 ))
1057}
1058
1059#[cfg(feature = "greentic-x-provider")]
1060fn attach_fast2flow_entities(
1061 activity: Activity,
1062 entities: Vec<greentic_x_runtime::Fast2FlowRoutingEntity>,
1063) -> Activity {
1064 if entities.is_empty() {
1065 return activity;
1066 }
1067 activity.with_payload_field(
1068 "fast2flow",
1069 serde_json::json!({
1070 "entities": entities,
1071 }),
1072 )
1073}
1074
1075fn resolve_flow_id(runtime: &TenantRuntime, activity: &Activity) -> Result<(String, String)> {
1076 let engine = runtime.engine();
1077 if let Some(flow_id) = activity.flow_id() {
1078 if let Some(pack_id) = activity.pack_id() {
1079 if engine.flow_by_key(pack_id, flow_id).is_none() {
1080 bail!("flow {flow_id} not registered for pack {pack_id}");
1081 }
1082 return Ok((pack_id.to_string(), flow_id.to_string()));
1083 }
1084 if let Some(flow) = engine.flow_by_id(flow_id) {
1085 return Ok((flow.pack_id.clone(), flow.id.clone()));
1086 }
1087 bail!("flow {flow_id} is ambiguous; pack_id is required");
1088 }
1089
1090 if let Some(flow_type) = activity.flow_type() {
1091 if let Some(pack_id) = activity.pack_id() {
1092 if let Some(flow) = engine
1093 .flows()
1094 .iter()
1095 .find(|flow| flow.pack_id == pack_id && flow.flow_type == flow_type)
1096 {
1097 return Ok((pack_id.to_string(), flow.id.clone()));
1098 }
1099 bail!("flow type {flow_type} not registered for pack {pack_id}");
1100 }
1101 if let Some(flow) = engine.flow_by_type(flow_type) {
1102 return Ok((flow.pack_id.clone(), flow.id.clone()));
1103 }
1104 if let Some(flow) = engine.entry_flow_by_type(flow_type) {
1111 return Ok((flow.pack_id.clone(), flow.id.clone()));
1112 }
1113 bail!("flow type {flow_type} is ambiguous; pack_id is required");
1114 }
1115
1116 let pack = runtime.pack();
1117 let flow_id = pack
1118 .metadata()
1119 .entry_flows
1120 .first()
1121 .cloned()
1122 .ok_or_else(|| anyhow!("no entry flows registered for tenant {}", runtime.tenant()))?;
1123 Ok((pack.metadata().pack_id.clone(), flow_id))
1124}
1125
1126fn normalize_replies(result: Value, tenant: &str) -> Vec<Activity> {
1127 result
1128 .as_array()
1129 .cloned()
1130 .unwrap_or_else(|| vec![result])
1131 .into_iter()
1132 .map(|payload| Activity::from_output(payload, tenant))
1133 .collect()
1134}
1135
1136fn is_pack_archive(path: &Path) -> bool {
1137 path.extension()
1138 .and_then(|ext| ext.to_str())
1139 .map(|ext| ext.eq_ignore_ascii_case("gtpack"))
1140 .unwrap_or(false)
1141}
1142
1143#[cfg(test)]
1144mod welcome_flow_tests {
1145 use super::*;
1146 use crate::engine::runtime::IngressEnvelope;
1147 use crate::runner::engine::{ExecutionState, FlowSnapshot, FlowWait};
1148 use crate::storage::new_session_store;
1149 use greentic_types::ReplyScope;
1150 use serde_json::json;
1151
1152 fn sample_envelope(endpoint_id: Option<&str>) -> IngressEnvelope {
1153 sample_envelope_for_user(endpoint_id, "user-1")
1154 }
1155
1156 fn sample_envelope_for_user(endpoint_id: Option<&str>, user: &str) -> IngressEnvelope {
1157 IngressEnvelope {
1158 tenant: "demo".into(),
1159 env: Some("local".into()),
1160 pack_id: Some("pack.default".into()),
1161 flow_id: "flow.default".into(),
1162 flow_type: Some("messaging".into()),
1163 action: Some("messaging".into()),
1164 session_hint: None,
1165 provider: Some("teams".into()),
1166 messaging_endpoint_id: endpoint_id.map(String::from),
1167 channel: Some("chan".into()),
1168 conversation: Some(format!("conv-{user}")),
1169 user: Some(user.to_string()),
1170 entry_node: None,
1171 activity_id: None,
1172 timestamp: None,
1173 payload: json!({}),
1174 metadata: None,
1175 reply_scope: Some(ReplyScope {
1176 conversation: format!("conv-{user}"),
1177 thread: None,
1178 reply_to: None,
1179 correlation: None,
1180 }),
1181 }
1182 .canonicalize()
1183 }
1184
1185 fn hint() -> WelcomeFlowHint {
1186 WelcomeFlowHint {
1187 pack_id: "pack.welcome".into(),
1188 flow_id: "flow.welcome".into(),
1189 }
1190 }
1191
1192 fn seed_resume(store: &DynSessionStore, envelope: &IngressEnvelope) {
1193 let resume = FlowResumeStore::new(Arc::clone(store));
1197 let state: ExecutionState = serde_json::from_value(json!({
1198 "input": { "text": "hi" },
1199 "nodes": {},
1200 "egress": []
1201 }))
1202 .expect("state");
1203 let wait = FlowWait {
1204 reason: Some("await-user".into()),
1205 snapshot: FlowSnapshot {
1206 pack_id: envelope.pack_id.clone().expect("pack_id"),
1207 flow_id: envelope.flow_id.clone(),
1208 next_flow: None,
1209 next_node: "node-2".into(),
1210 awaiting_submit: false,
1211 state,
1212 },
1213 };
1214 resume.save(envelope, &wait).expect("seed save");
1215 }
1216
1217 #[test]
1218 fn override_is_no_op_when_hint_absent() {
1219 let store = new_session_store();
1222 let mut envelope = sample_envelope(Some("teams-legal"));
1223 let before = envelope.clone();
1224 apply_welcome_flow_override(&store, &mut envelope, None, None).expect("ok");
1225 assert_eq!(envelope.pack_id, before.pack_id);
1226 assert_eq!(envelope.flow_id, before.flow_id);
1227 assert_eq!(envelope.flow_type, before.flow_type);
1228 }
1229
1230 #[test]
1231 fn override_is_no_op_when_endpoint_id_absent() {
1232 let store = new_session_store();
1235 let mut envelope = sample_envelope(None);
1236 let before = envelope.clone();
1237 apply_welcome_flow_override(&store, &mut envelope, Some(&hint()), Some("welcome".into()))
1238 .expect("ok");
1239 assert_eq!(envelope.pack_id, before.pack_id);
1240 assert_eq!(envelope.flow_id, before.flow_id);
1241 }
1242
1243 #[test]
1244 fn override_swaps_pack_flow_and_threads_flow_type_through() {
1245 for hint_flow_type in [Some("welcome".to_string()), None] {
1250 let store = new_session_store();
1251 let mut envelope = sample_envelope(Some("teams-legal"));
1252 apply_welcome_flow_override(
1253 &store,
1254 &mut envelope,
1255 Some(&hint()),
1256 hint_flow_type.clone(),
1257 )
1258 .expect("ok");
1259 assert_eq!(envelope.pack_id.as_deref(), Some("pack.welcome"));
1260 assert_eq!(envelope.flow_id, "flow.welcome");
1261 assert_eq!(envelope.flow_type, hint_flow_type);
1262 }
1263 }
1264
1265 #[test]
1266 fn override_is_no_op_on_repeat_turn_with_existing_session() {
1267 let store = new_session_store();
1271 let envelope_template = sample_envelope(Some("teams-legal"));
1272 seed_resume(&store, &envelope_template);
1273
1274 let mut envelope = envelope_template.clone();
1275 apply_welcome_flow_override(&store, &mut envelope, Some(&hint()), Some("welcome".into()))
1276 .expect("ok");
1277 assert_eq!(envelope.pack_id, envelope_template.pack_id);
1278 assert_eq!(envelope.flow_id, envelope_template.flow_id);
1279 assert_eq!(envelope.flow_type, envelope_template.flow_type);
1280 }
1281
1282 #[test]
1283 fn override_is_no_op_post_completion_when_marker_present() {
1284 let store = new_session_store();
1290 let mut first = sample_envelope(Some("teams-legal"));
1291 apply_welcome_flow_override(&store, &mut first, Some(&hint()), Some("welcome".into()))
1292 .expect("first turn ok");
1293 assert_eq!(
1294 first.pack_id.as_deref(),
1295 Some("pack.welcome"),
1296 "first turn fires welcome"
1297 );
1298
1299 let resume = FlowResumeStore::new(Arc::clone(&store));
1304 resume.clear(&first).expect("clear post-completion wait");
1305
1306 let mut second = sample_envelope(Some("teams-legal"));
1310 apply_welcome_flow_override(&store, &mut second, Some(&hint()), Some("welcome".into()))
1311 .expect("second turn ok");
1312 assert_eq!(
1313 second.pack_id.as_deref(),
1314 Some("pack.default"),
1315 "second turn must NOT re-fire welcome"
1316 );
1317 assert_eq!(second.flow_id, "flow.default");
1318 }
1319
1320 #[test]
1321 fn override_is_no_op_on_second_turn_after_marker_set() {
1322 let store = new_session_store();
1326 let mut first = sample_envelope(Some("teams-legal"));
1327 apply_welcome_flow_override(&store, &mut first, Some(&hint()), Some("welcome".into()))
1328 .expect("first turn ok");
1329 assert_eq!(first.pack_id.as_deref(), Some("pack.welcome"));
1330
1331 let mut second = sample_envelope(Some("teams-legal"));
1332 apply_welcome_flow_override(&store, &mut second, Some(&hint()), Some("welcome".into()))
1333 .expect("second turn ok");
1334 assert_eq!(
1335 second.pack_id.as_deref(),
1336 Some("pack.default"),
1337 "second turn must NOT re-fire welcome"
1338 );
1339 }
1340
1341 #[test]
1342 fn override_partitions_marker_per_endpoint() {
1343 let store = new_session_store();
1348 let mut legal = sample_envelope(Some("teams-legal"));
1349 apply_welcome_flow_override(&store, &mut legal, Some(&hint()), Some("welcome".into()))
1350 .expect("legal first turn ok");
1351 assert_eq!(legal.pack_id.as_deref(), Some("pack.welcome"));
1352
1353 let mut accounting = sample_envelope(Some("teams-accounting"));
1354 apply_welcome_flow_override(
1355 &store,
1356 &mut accounting,
1357 Some(&hint()),
1358 Some("welcome".into()),
1359 )
1360 .expect("accounting first turn ok");
1361 assert_eq!(
1362 accounting.pack_id.as_deref(),
1363 Some("pack.welcome"),
1364 "different endpoint = independent first contact"
1365 );
1366 }
1367
1368 #[test]
1369 fn override_partitions_marker_per_user_on_same_endpoint() {
1370 let store = new_session_store();
1380
1381 let mut a1 = sample_envelope_for_user(Some("teams-legal"), "user-a");
1383 apply_welcome_flow_override(&store, &mut a1, Some(&hint()), Some("welcome".into()))
1384 .expect("user-a first ok");
1385 assert_eq!(a1.pack_id.as_deref(), Some("pack.welcome"));
1386
1387 let mut b1 = sample_envelope_for_user(Some("teams-legal"), "user-b");
1389 apply_welcome_flow_override(&store, &mut b1, Some(&hint()), Some("welcome".into()))
1390 .expect("user-b first must not collide with user-a marker");
1391 assert_eq!(
1392 b1.pack_id.as_deref(),
1393 Some("pack.welcome"),
1394 "user-b is independent first contact"
1395 );
1396
1397 let mut a2 = sample_envelope_for_user(Some("teams-legal"), "user-a");
1399 apply_welcome_flow_override(&store, &mut a2, Some(&hint()), Some("welcome".into()))
1400 .expect("user-a second ok");
1401 assert_eq!(
1402 a2.pack_id.as_deref(),
1403 Some("pack.default"),
1404 "user-a must not be re-welcomed after user-b joined"
1405 );
1406
1407 let mut b2 = sample_envelope_for_user(Some("teams-legal"), "user-b");
1409 apply_welcome_flow_override(&store, &mut b2, Some(&hint()), Some("welcome".into()))
1410 .expect("user-b second ok");
1411 assert_eq!(b2.pack_id.as_deref(), Some("pack.default"));
1412 }
1413
1414 #[test]
1415 fn marker_is_not_written_when_hint_absent() {
1416 let store = new_session_store();
1425 let mut envelope = sample_envelope(Some("teams-legal"));
1426 apply_welcome_flow_override(&store, &mut envelope, None, None).expect("ok");
1427
1428 let mut next = sample_envelope(Some("teams-legal"));
1429 apply_welcome_flow_override(&store, &mut next, Some(&hint()), Some("welcome".into()))
1430 .expect("ok");
1431 assert_eq!(
1432 next.pack_id.as_deref(),
1433 Some("pack.welcome"),
1434 "no marker leaked from hint-absent path"
1435 );
1436 }
1437}
1438
1439#[cfg(test)]
1440mod identify_endpoints_tests {
1441 use super::*;
1442
1443 fn dummy_runner_host() -> RunnerHost {
1444 let session_store = new_session_store();
1445 let state_store = new_state_store();
1446 RunnerHost {
1447 configs: HashMap::new(),
1448 active: Arc::new(ActivePacks::new()),
1449 health: Arc::new(HealthState::new()),
1450 session_host: session_host_from(session_store.clone()),
1451 state_host: state_host_from(state_store.clone()),
1452 session_store,
1453 state_store,
1454 wasi_policy: Arc::new(RunnerWasiPolicy::new()),
1455 secrets_manager: default_manager().expect("default secrets manager"),
1456 telemetry: None,
1457 }
1458 }
1459
1460 #[tokio::test]
1461 async fn empty_provider_types_returns_empty_map_without_loading_revision() {
1462 let host = dummy_runner_host();
1466 let map = host
1467 .identify_messaging_endpoints_for_revision(
1468 "demo",
1469 DeploymentId::new(),
1470 BundleId::new("anything"),
1471 RevisionId::new(),
1472 &[],
1473 b"{}",
1474 )
1475 .await
1476 .expect("empty types is the cheap fast path");
1477 assert!(map.is_empty());
1478 }
1479
1480 #[tokio::test]
1481 async fn missing_revision_surfaces_clear_error() {
1482 let host = dummy_runner_host();
1486 let deployment = DeploymentId::new();
1487 let revision = RevisionId::new();
1488 let err = host
1489 .identify_messaging_endpoints_for_revision(
1490 "demo",
1491 deployment,
1492 BundleId::new("missing"),
1493 revision,
1494 &["teams"],
1495 b"{}",
1496 )
1497 .await
1498 .expect_err("missing revision must fail closed");
1499 let msg = format!("{err:#}");
1500 assert!(
1501 msg.contains("revision runtime not loaded"),
1502 "error chain should name the failure mode, got: {msg}"
1503 );
1504 assert!(
1505 msg.contains(&deployment.to_string()),
1506 "error chain should name the deployment id, got: {msg}"
1507 );
1508 assert!(
1509 msg.contains(&revision.to_string()),
1510 "error chain should name the revision id, got: {msg}"
1511 );
1512 }
1513
1514 #[tokio::test]
1515 async fn scoped_empty_provider_types_returns_empty_map() {
1516 let host = dummy_runner_host();
1517 let map = host
1518 .identify_messaging_endpoints_for_revision_scoped(
1519 "demo",
1520 DeploymentId::new(),
1521 BundleId::new("anything"),
1522 RevisionId::new(),
1523 &[],
1524 &[],
1525 &Value::Null,
1526 )
1527 .await
1528 .expect("empty types is the cheap fast path");
1529 assert!(map.is_empty());
1530 }
1531
1532 #[tokio::test]
1533 async fn scoped_missing_revision_surfaces_clear_error() {
1534 let host = dummy_runner_host();
1535 let deployment = DeploymentId::new();
1536 let revision = RevisionId::new();
1537 let err = host
1538 .identify_messaging_endpoints_for_revision_scoped(
1539 "demo",
1540 deployment,
1541 BundleId::new("missing"),
1542 revision,
1543 &["teams"],
1544 &[],
1545 &Value::Null,
1546 )
1547 .await
1548 .expect_err("missing revision must fail closed");
1549 let msg = format!("{err:#}");
1550 assert!(
1551 msg.contains("revision runtime not loaded"),
1552 "error chain should name the failure mode, got: {msg}"
1553 );
1554 assert!(
1555 msg.contains(&deployment.to_string()),
1556 "error chain should name the deployment id, got: {msg}"
1557 );
1558 assert!(
1559 msg.contains(&revision.to_string()),
1560 "error chain should name the revision id, got: {msg}"
1561 );
1562 }
1563
1564 #[test]
1579 fn identify_futures_are_send() {
1580 fn assert_send<F: Send>(_: F) {}
1581 let host = dummy_runner_host();
1582 assert_send(host.identify_messaging_endpoints_for_revision(
1586 "demo",
1587 DeploymentId::new(),
1588 BundleId::new("anything"),
1589 RevisionId::new(),
1590 &["teams"],
1591 b"{}",
1592 ));
1593 assert_send(host.identify_messaging_endpoints_for_revision_scoped(
1594 "demo",
1595 DeploymentId::new(),
1596 BundleId::new("anything"),
1597 RevisionId::new(),
1598 &["teams"],
1599 &[("x-telegram-bot-api-secret-token".into(), "tok".into())],
1600 &Value::Null,
1601 ));
1602 assert_send(host.describe_identify_instances_for_revision(
1603 "demo",
1604 DeploymentId::new(),
1605 BundleId::new("anything"),
1606 RevisionId::new(),
1607 &["teams"],
1608 ));
1609 assert_send(host.invoke_provider_for_revision(
1610 "demo",
1611 DeploymentId::new(),
1612 BundleId::new("anything"),
1613 RevisionId::new(),
1614 "messaging.telegram.bot",
1615 "ingest_http",
1616 b"{}".to_vec(),
1617 None,
1618 None,
1619 ));
1620 }
1621
1622 #[tokio::test]
1623 async fn invoke_provider_missing_revision_surfaces_clear_error() {
1624 let host = dummy_runner_host();
1629 let deployment = DeploymentId::new();
1630 let revision = RevisionId::new();
1631 let err = host
1632 .invoke_provider_for_revision(
1633 "demo",
1634 deployment,
1635 BundleId::new("missing"),
1636 revision,
1637 "messaging.telegram.bot",
1638 "ingest_http",
1639 b"{}".to_vec(),
1640 None,
1641 None,
1642 )
1643 .await
1644 .expect_err("missing revision must fail closed");
1645 let msg = format!("{err:#}");
1646 assert!(
1647 msg.contains("revision runtime not loaded"),
1648 "error chain should name the failure mode, got: {msg}"
1649 );
1650 assert!(
1651 msg.contains(&deployment.to_string()),
1652 "error chain should name the deployment id, got: {msg}"
1653 );
1654 assert!(
1655 msg.contains(&revision.to_string()),
1656 "error chain should name the revision id, got: {msg}"
1657 );
1658 }
1659}
1660
1661#[cfg(all(test, feature = "greentic-x-provider"))]
1662mod fast2flow_tests {
1663 use greentic_x_runtime::Fast2FlowRoutingEntity;
1664
1665 use super::*;
1666
1667 #[test]
1668 fn dispatch_target_attaches_prefill_entities_to_payload() {
1669 let activity = Activity::text("show traffic tomorrow");
1670 let routed = apply_fast2flow_target(
1671 activity,
1672 "telco-x/prefix-traffic",
1673 vec![Fast2FlowRoutingEntity::new("date", "20260611").with_format("iso", "2026-06-11")],
1674 )
1675 .expect("target should route");
1676
1677 assert_eq!(routed.pack_id(), Some("telco-x"));
1678 assert_eq!(routed.flow_id(), Some("prefix-traffic"));
1679 assert_eq!(
1680 routed.payload()["fast2flow"]["entities"][0]["normalized"],
1681 "20260611"
1682 );
1683 assert_eq!(
1684 routed.payload()["fast2flow"]["entities"][0]["formats"]["iso"],
1685 "2026-06-11"
1686 );
1687 }
1688}