1use std::collections::{BTreeMap, HashMap, HashSet};
2use std::future::Future;
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5
6use anyhow::{Context, Result, anyhow, bail};
7use arc_swap::ArcSwap;
8use parking_lot::Mutex;
9use reqwest::Client;
10use serde_json::Value;
11use tokio::runtime::{Handle, Runtime};
12use tokio::task::JoinHandle;
13
14use crate::config::HostConfig;
15use crate::engine::host::{SessionHost, StateHost};
16use crate::engine::runtime::StateMachineRuntime;
17use crate::oauth::{OAuthBrokerConfig, request_resource_token};
18use crate::operator_metrics::OperatorMetrics;
19use crate::operator_registry::OperatorRegistry;
20use crate::pack::{ComponentResolution, PackRuntime};
21use crate::runner::adapt_events_email::{
22 EmailExecutionPlan, EmailSendRequest, build_email_execution_plan, execute_email_request,
23};
24use crate::runner::contract_cache::{ContractCache, ContractCacheStats};
25use crate::runner::engine::FlowEngine;
26use crate::runner::mocks::MockLayer;
27use crate::secrets::{DynSecretsManager, canonicalize_secret_key, read_secret_blocking};
28use crate::storage::session::DynSessionStore;
29use crate::storage::state::DynStateStore;
30use crate::telemetry::RolloutIds;
31use crate::trace::PackTraceInfo;
32use crate::wasi::RunnerWasiPolicy;
33use greentic_deploy_spec::ids::{BundleId, DeploymentId, RevisionId};
34use greentic_types::SecretRequirement;
35use runner_core::packs::PackDigest;
36
37const RUNTIME_SECRETS_PACK_ID: &str = "_runner";
38
39#[derive(Clone, Debug, PartialEq, Eq, Hash)]
45pub struct RuntimeKey {
46 pub tenant: String,
47 pub deployment_id: Option<DeploymentId>,
48 pub bundle_id: Option<BundleId>,
49 pub revision_id: Option<RevisionId>,
50}
51
52impl RuntimeKey {
53 pub fn legacy(tenant: impl Into<String>) -> Self {
55 Self {
56 tenant: tenant.into(),
57 deployment_id: None,
58 bundle_id: None,
59 revision_id: None,
60 }
61 }
62
63 pub fn revision(
65 tenant: impl Into<String>,
66 deployment_id: DeploymentId,
67 bundle_id: BundleId,
68 revision_id: RevisionId,
69 ) -> Self {
70 Self {
71 tenant: tenant.into(),
72 deployment_id: Some(deployment_id),
73 bundle_id: Some(bundle_id),
74 revision_id: Some(revision_id),
75 }
76 }
77
78 pub fn is_legacy(&self) -> bool {
80 self.deployment_id.is_none() && self.bundle_id.is_none() && self.revision_id.is_none()
81 }
82}
83
84fn merge_legacy_reload<V: Clone>(
89 prev: &HashMap<RuntimeKey, V>,
90 mut legacy: HashMap<RuntimeKey, V>,
91) -> HashMap<RuntimeKey, V> {
92 for (key, value) in prev {
93 if !key.is_legacy() {
94 legacy.insert(key.clone(), value.clone());
95 }
96 }
97 legacy
98}
99
100fn remove_keyed_entry<V: Clone>(
105 prev: &HashMap<RuntimeKey, V>,
106 key: &RuntimeKey,
107) -> Option<(HashMap<RuntimeKey, V>, V)> {
108 let removed = prev.get(key)?.clone();
109 let mut next = prev.clone();
110 next.remove(key);
111 Some((next, removed))
112}
113
114fn insert_keyed_entry<V: Clone>(
118 prev: &HashMap<RuntimeKey, V>,
119 key: RuntimeKey,
120 value: V,
121) -> HashMap<RuntimeKey, V> {
122 let mut next = prev.clone();
123 next.insert(key, value);
124 next
125}
126
127pub struct ActivePacks {
133 inner: ArcSwap<HashMap<RuntimeKey, Arc<TenantRuntime>>>,
134 write_lock: Mutex<()>,
135}
136
137impl ActivePacks {
138 pub fn new() -> Self {
139 Self {
140 inner: ArcSwap::from_pointee(HashMap::new()),
141 write_lock: Mutex::new(()),
142 }
143 }
144
145 pub fn load_pack(&self, tenant: &str) -> Option<Arc<TenantRuntime>> {
148 self.inner.load().get(&RuntimeKey::legacy(tenant)).cloned()
149 }
150
151 pub fn load_revision(
153 &self,
154 tenant: &str,
155 deployment_id: DeploymentId,
156 bundle_id: BundleId,
157 revision_id: RevisionId,
158 ) -> Option<Arc<TenantRuntime>> {
159 self.inner
160 .load()
161 .get(&RuntimeKey::revision(
162 tenant,
163 deployment_id,
164 bundle_id,
165 revision_id,
166 ))
167 .cloned()
168 }
169
170 pub fn snapshot(&self) -> Arc<HashMap<RuntimeKey, Arc<TenantRuntime>>> {
171 self.inner.load_full()
172 }
173
174 pub fn insert_pack(&self, tenant: &str, runtime: Arc<TenantRuntime>) {
177 let _guard = self.write_lock.lock();
178 let mut next = (*self.inner.load_full()).clone();
179 next.insert(RuntimeKey::legacy(tenant), runtime);
180 self.inner.store(Arc::new(next));
181 }
182
183 pub fn insert_revision(
197 &self,
198 tenant: &str,
199 deployment_id: DeploymentId,
200 bundle_id: BundleId,
201 revision_id: RevisionId,
202 runtime: Arc<TenantRuntime>,
203 ) -> Result<()> {
204 if runtime.tenant() != tenant {
205 bail!(
206 "revision runtime tenant `{}` does not match key tenant `{tenant}`",
207 runtime.tenant()
208 );
209 }
210 let ids = runtime.engine().rollout_ids();
211 let key_deployment = deployment_id.to_string();
212 let key_bundle = bundle_id.as_str();
213 let key_revision = revision_id.to_string();
214 if ids.deployment_id.as_deref() != Some(key_deployment.as_str())
215 || ids.bundle_id.as_deref() != Some(key_bundle)
216 || ids.revision_id.as_deref() != Some(key_revision.as_str())
217 {
218 bail!(
219 "revision runtime rollout identity (deployment={:?}, bundle={:?}, revision={:?}) \
220 does not match key (deployment=`{key_deployment}`, bundle=`{key_bundle}`, \
221 revision=`{key_revision}`)",
222 ids.deployment_id,
223 ids.bundle_id,
224 ids.revision_id
225 );
226 }
227 let _guard = self.write_lock.lock();
228 let key = RuntimeKey::revision(tenant, deployment_id, bundle_id, revision_id);
229 let next = insert_keyed_entry(&self.inner.load_full(), key, runtime);
230 self.inner.store(Arc::new(next));
231 Ok(())
232 }
233
234 pub fn replace_legacy(&self, legacy: HashMap<RuntimeKey, Arc<TenantRuntime>>) {
238 let _guard = self.write_lock.lock();
239 let prev = self.inner.load_full();
240 let next = merge_legacy_reload(&prev, legacy);
241 self.inner.store(Arc::new(next));
242 }
243
244 pub fn remove_revision(
250 &self,
251 tenant: &str,
252 deployment_id: DeploymentId,
253 bundle_id: BundleId,
254 revision_id: RevisionId,
255 ) -> Option<Arc<TenantRuntime>> {
256 let _guard = self.write_lock.lock();
257 let prev = self.inner.load_full();
258 let key = RuntimeKey::revision(tenant, deployment_id, bundle_id, revision_id);
259 let (next, removed) = remove_keyed_entry(&prev, &key)?;
260 self.inner.store(Arc::new(next));
261 Some(removed)
262 }
263
264 pub fn replace(&self, next: HashMap<RuntimeKey, Arc<TenantRuntime>>) {
267 let _guard = self.write_lock.lock();
268 self.inner.store(Arc::new(next));
269 }
270
271 pub fn len(&self) -> usize {
272 self.inner.load().len()
273 }
274
275 pub fn is_empty(&self) -> bool {
276 self.len() == 0
277 }
278}
279
280impl Default for ActivePacks {
281 fn default() -> Self {
282 Self::new()
283 }
284}
285
286pub struct TenantRuntime {
288 tenant: String,
289 config: Arc<HostConfig>,
290 packs: Vec<Arc<PackRuntime>>,
291 digests: Vec<Option<String>>,
292 engine: Arc<FlowEngine>,
293 state_machine: Arc<StateMachineRuntime>,
294 session_store: DynSessionStore,
301 http_client: Client,
302 mocks: Option<Arc<MockLayer>>,
303 timer_handles: Mutex<Vec<JoinHandle<()>>>,
304 secrets: DynSecretsManager,
305 operator_registry: OperatorRegistry,
306 operator_metrics: Arc<OperatorMetrics>,
307 contract_cache: ContractCache,
308}
309
310#[derive(Clone)]
311pub struct ResolvedComponent {
312 pub digest: String,
313 pub component_ref: String,
314 pub pack: Arc<PackRuntime>,
315}
316
317#[derive(Clone, Debug)]
323pub struct RevisionPackRef {
324 pub path: PathBuf,
325 pub digest: String,
326}
327
328pub fn block_on<F: Future<Output = R>, R>(future: F) -> R {
330 if let Ok(handle) = Handle::try_current() {
331 handle.block_on(future)
332 } else {
333 Runtime::new()
334 .expect("failed to create tokio runtime")
335 .block_on(future)
336 }
337}
338
339impl TenantRuntime {
340 #[allow(clippy::too_many_arguments)]
341 pub async fn load(
342 pack_path: &Path,
343 config: Arc<HostConfig>,
344 mocks: Option<Arc<MockLayer>>,
345 archive_source: Option<&Path>,
346 digest: Option<String>,
347 wasi_policy: Arc<RunnerWasiPolicy>,
348 session_host: Arc<dyn SessionHost>,
349 session_store: DynSessionStore,
350 state_store: DynStateStore,
351 state_host: Arc<dyn StateHost>,
352 secrets_manager: DynSecretsManager,
353 ) -> Result<Arc<Self>> {
354 let pack = Self::load_pack_runtime(
355 pack_path,
356 &config,
357 mocks.clone(),
358 archive_source,
359 &wasi_policy,
360 &session_store,
361 &state_store,
362 &secrets_manager,
363 &BTreeMap::new(),
364 &BTreeMap::new(),
365 None,
366 )
367 .await?;
368 Self::from_packs(
369 config,
370 vec![(pack, digest)],
371 mocks,
372 session_host,
373 session_store,
374 state_store,
375 state_host,
376 secrets_manager,
377 )
378 .await
379 }
380
381 #[allow(clippy::too_many_arguments)]
394 pub async fn load_revision(
395 pack_refs: &[RevisionPackRef],
396 config: Arc<HostConfig>,
397 mocks: Option<Arc<MockLayer>>,
398 wasi_policy: Arc<RunnerWasiPolicy>,
399 session_host: Arc<dyn SessionHost>,
400 session_store: DynSessionStore,
401 state_store: DynStateStore,
402 state_host: Arc<dyn StateHost>,
403 secrets_manager: DynSecretsManager,
404 deployment_id: DeploymentId,
405 bundle_id: BundleId,
406 revision_id: RevisionId,
407 customer_id: Option<String>,
408 runtime_configs_by_pack_id: &BTreeMap<String, Arc<BTreeMap<String, Value>>>,
409 runtime_refs_by_pack_id: &BTreeMap<String, Arc<BTreeMap<String, String>>>,
410 runtime_ref_resolver: Option<Arc<dyn crate::runtime_refs::RuntimeRefResolver>>,
411 ) -> Result<Arc<Self>> {
412 if pack_refs.is_empty() {
413 bail!(
414 "revision runtime for tenant {} requires at least one pack",
415 config.tenant
416 );
417 }
418 let mut packs = Vec::with_capacity(pack_refs.len());
419 let mut seen_pack_ids = HashSet::with_capacity(pack_refs.len());
420 for pack_ref in pack_refs {
421 let expected = PackDigest::parse(&pack_ref.digest).with_context(|| {
422 format!(
423 "revision pack `{}` has an invalid digest `{}`",
424 pack_ref.path.display(),
425 pack_ref.digest
426 )
427 })?;
428 if expected.algorithm() != "sha256" {
432 bail!(
433 "revision pack `{}` pins unsupported digest algorithm `{}`; only sha256 is supported",
434 pack_ref.path.display(),
435 expected.algorithm()
436 );
437 }
438 if !expected.matches_file(&pack_ref.path).with_context(|| {
439 format!(
440 "hashing revision pack `{}` for digest verification",
441 pack_ref.path.display()
442 )
443 })? {
444 bail!(
445 "revision pack `{}` does not match pinned digest `{}`",
446 pack_ref.path.display(),
447 pack_ref.digest
448 );
449 }
450 let pack = Self::load_pack_runtime(
451 &pack_ref.path,
452 &config,
453 mocks.clone(),
454 None,
455 &wasi_policy,
456 &session_store,
457 &state_store,
458 &secrets_manager,
459 runtime_configs_by_pack_id,
460 runtime_refs_by_pack_id,
461 runtime_ref_resolver.as_ref(),
462 )
463 .await?;
464 let pack_id = pack.metadata().pack_id.clone();
468 if !seen_pack_ids.insert(pack_id.clone()) {
469 bail!(
470 "revision for tenant {} contains duplicate pack_id `{}` (path `{}`)",
471 config.tenant,
472 pack_id,
473 pack_ref.path.display(),
474 );
475 }
476 packs.push((pack, Some(expected.raw_string())));
477 }
478 let rollout = RolloutIds {
479 customer_id,
480 deployment_id: Some(deployment_id.to_string()),
481 bundle_id: Some(bundle_id.as_str().to_string()),
482 revision_id: Some(revision_id.to_string()),
483 };
484 Self::from_packs_with_rollout(
485 config,
486 packs,
487 mocks,
488 session_host,
489 session_store,
490 state_store,
491 state_host,
492 secrets_manager,
493 rollout,
494 )
495 .await
496 }
497
498 #[allow(clippy::too_many_arguments)]
513 async fn load_pack_runtime(
514 pack_path: &Path,
515 config: &Arc<HostConfig>,
516 mocks: Option<Arc<MockLayer>>,
517 archive_source: Option<&Path>,
518 wasi_policy: &Arc<RunnerWasiPolicy>,
519 session_store: &DynSessionStore,
520 state_store: &DynStateStore,
521 secrets_manager: &DynSecretsManager,
522 runtime_configs_by_pack_id: &BTreeMap<String, Arc<BTreeMap<String, Value>>>,
523 runtime_refs_by_pack_id: &BTreeMap<String, Arc<BTreeMap<String, String>>>,
524 runtime_ref_resolver: Option<&Arc<dyn crate::runtime_refs::RuntimeRefResolver>>,
525 ) -> Result<Arc<PackRuntime>> {
526 let oauth_config = config.oauth_broker_config();
527 let mut pack = PackRuntime::load(
528 pack_path,
529 Arc::clone(config),
530 mocks,
531 archive_source,
532 Some(Arc::clone(session_store)),
533 Some(Arc::clone(state_store)),
534 Arc::clone(wasi_policy),
535 Arc::clone(secrets_manager),
536 oauth_config,
537 true,
538 ComponentResolution::default(),
539 )
540 .await
541 .with_context(|| {
542 format!(
543 "failed to load pack {} for tenant {}",
544 pack_path.display(),
545 config.tenant
546 )
547 })?;
548 let pack_id = pack.metadata().pack_id.clone();
549 if let Some(non_secret) = runtime_configs_by_pack_id.get(pack_id.as_str()) {
550 pack.set_runtime_config_non_secret(Some(Arc::clone(non_secret)));
551 }
552 if let Some(refs) = runtime_refs_by_pack_id.get(pack_id.as_str()) {
553 let resolver = runtime_ref_resolver.ok_or_else(|| {
554 anyhow!(
555 "pack `{}` has runtime_refs bound but no RuntimeRefResolver was provided",
556 pack_id,
557 )
558 })?;
559 pack.set_runtime_refs(Some(crate::runtime_refs::RuntimeRefsInjection {
560 refs: Arc::clone(refs),
561 resolver: Arc::clone(resolver),
562 }));
563 }
564 Ok(Arc::new(pack))
565 }
566
567 #[allow(clippy::too_many_arguments)]
568 pub async fn from_packs(
569 config: Arc<HostConfig>,
570 packs: Vec<(Arc<PackRuntime>, Option<String>)>,
571 mocks: Option<Arc<MockLayer>>,
572 session_host: Arc<dyn SessionHost>,
573 session_store: DynSessionStore,
574 state_store: DynStateStore,
575 state_host: Arc<dyn StateHost>,
576 secrets_manager: DynSecretsManager,
577 ) -> Result<Arc<Self>> {
578 Self::from_packs_with_rollout(
579 config,
580 packs,
581 mocks,
582 session_host,
583 session_store,
584 state_store,
585 state_host,
586 secrets_manager,
587 RolloutIds::default(),
588 )
589 .await
590 }
591
592 #[allow(clippy::too_many_arguments)]
597 pub(crate) async fn from_packs_with_rollout(
598 config: Arc<HostConfig>,
599 packs: Vec<(Arc<PackRuntime>, Option<String>)>,
600 mocks: Option<Arc<MockLayer>>,
601 session_host: Arc<dyn SessionHost>,
602 session_store: DynSessionStore,
603 _state_store: DynStateStore,
604 state_host: Arc<dyn StateHost>,
605 secrets_manager: DynSecretsManager,
606 rollout: RolloutIds,
607 ) -> Result<Arc<Self>> {
608 let operator_registry = OperatorRegistry::build(&packs)?;
609 let operator_metrics = Arc::new(OperatorMetrics::default());
610 let pack_runtimes = packs
611 .iter()
612 .map(|(pack, _)| Arc::clone(pack))
613 .collect::<Vec<_>>();
614 let digests = packs
615 .iter()
616 .map(|(_, digest)| digest.clone())
617 .collect::<Vec<_>>();
618 let mut pack_trace = HashMap::new();
619 for (pack, digest) in &packs {
620 let pack_id = pack.metadata().pack_id.clone();
621 let pack_ref = config
622 .pack_bindings
623 .iter()
624 .find(|binding| binding.pack_id == pack_id)
625 .map(|binding| binding.pack_ref.clone())
626 .unwrap_or_else(|| pack_id.clone());
627 pack_trace.insert(
628 pack_id,
629 PackTraceInfo {
630 pack_ref,
631 resolved_digest: digest.clone(),
632 },
633 );
634 }
635 #[cfg(feature = "agentic-worker")]
643 let agent_project_id = rollout.bundle_id.clone();
644 #[cfg_attr(not(feature = "agentic-worker"), allow(unused_mut))]
645 let mut engine = FlowEngine::new(pack_runtimes.clone(), Arc::clone(&config))
646 .await
647 .context("failed to prime flow engine")?
648 .with_rollout_ids(rollout);
649
650 let dispatch_nats_client = match std::env::var("GREENTIC_EVENTS_NATS_URL") {
667 Ok(nats_url) => match async_nats::connect(&nats_url).await {
668 Ok(client) => {
669 engine.set_remote_dispatch_handler(Arc::new(
670 crate::runner::remote_dispatch::NatsDispatcher::new(client.clone()),
671 ));
672 Some(client)
673 }
674 Err(error) => {
675 tracing::warn!(
676 %error,
677 "GREENTIC_EVENTS_NATS_URL set but NATS connect failed; sorla.call disabled"
678 );
679 None
680 }
681 },
682 Err(_) => None,
683 };
684
685 let audit_nats_client = dispatch_nats_client.clone();
694
695 #[cfg(feature = "agentic-worker")]
696 {
697 use crate::runner::agent_node::{
698 agent_configs_from_manifest, merge_agent_sources, merge_sidecar_into,
699 };
700 use std::collections::HashMap;
701
702 let mut pack_agents: HashMap<String, greentic_aw_runtime::AgentConfig> = HashMap::new();
707 for pack in &pack_runtimes {
708 let mut blobs = pack.manifest_agent_blobs();
709 merge_sidecar_into(&mut blobs, pack.dw_agents_sidecar_blobs());
713 if blobs.is_empty() {
714 continue;
715 }
716 let pack_id = pack.metadata().pack_id.clone();
717 let configs = agent_configs_from_manifest(&pack_id, &blobs);
718 for (agent_id, agent_config) in configs {
719 if let Some(existing) = pack_agents.get(&agent_id) {
720 tracing::warn!(
721 agent_id,
722 prior_pack = existing.agent_id.as_str(),
723 new_pack = pack_id.as_str(),
724 "agent_id collision across packs; last pack wins"
725 );
726 }
727 pack_agents.insert(agent_id, agent_config);
728 }
729 }
730
731 let merged_agents = merge_agent_sources(pack_agents, config.agents.clone());
733
734 #[cfg(feature = "knowledge-chronicle")]
741 {
742 let corpus = crate::runner::knowledge_corpus::collect(&pack_runtimes);
743 crate::runner::knowledge_mount::ingest_corpus(&config.tenant_ctx(), corpus).await;
744 }
745
746 let redis_set = std::env::var("GREENTIC_AW_REDIS_URL")
754 .map(|v| !v.is_empty())
755 .unwrap_or(false);
756 let agent_audit_sink = audit_nats_client
762 .clone()
763 .map(crate::trace::audit_sink::AuditSink::new);
764 let agent_handler = if redis_set {
765 crate::runner::agent_node::build_agent_node_handler(
766 merged_agents,
767 config.tenant.clone(),
768 Arc::clone(&secrets_manager),
769 pack_runtimes.clone(),
770 agent_audit_sink.clone(),
771 agent_project_id.clone(),
772 )
773 .await
774 } else {
775 #[cfg(feature = "desktop-agent-ephemeral")]
776 {
777 crate::runner::agent_node::build_agent_node_handler_ephemeral(
778 merged_agents,
779 config.tenant.clone(),
780 Arc::clone(&secrets_manager),
781 pack_runtimes.clone(),
782 agent_audit_sink.clone(),
783 agent_project_id.clone(),
784 )
785 .await
786 }
787 #[cfg(not(feature = "desktop-agent-ephemeral"))]
788 {
789 crate::runner::agent_node::build_agent_node_handler(
790 merged_agents,
791 config.tenant.clone(),
792 Arc::clone(&secrets_manager),
793 pack_runtimes.clone(),
794 agent_audit_sink.clone(),
795 agent_project_id.clone(),
796 )
797 .await
798 }
799 };
800 if let Some(handler) = agent_handler {
801 engine.set_agent_node_handler(handler);
802 tracing::info!("DwAgent runtime wired into FlowEngine");
803 }
804
805 let mut graphs: HashMap<String, greentic_aw_runtime::graph::GraphConfig> =
813 HashMap::new();
814 for pack in &pack_runtimes {
815 let Some(bytes) = pack.read_agent_graph_sidecar() else {
816 continue;
817 };
818 let pack_id = pack.metadata().pack_id.clone();
819 if let Some(config) =
820 crate::runner::graph_node::graph_config_from_sidecar(&pack_id, &bytes)
821 && graphs.insert(pack_id.clone(), config).is_some()
822 {
823 tracing::warn!(
824 pack_id = pack_id.as_str(),
825 "agent-graph sidecar for pack_id seen twice; last pack wins"
826 );
827 }
828 }
829
830 for (graph_id, graph_config) in config.graphs.clone() {
834 graphs.insert(graph_id, graph_config);
835 }
836
837 if let Some(handler) = crate::runner::graph_node::build_graph_node_handler(
838 graphs,
839 agent_audit_sink.clone(),
840 )
841 .await
842 {
843 engine.set_graph_node_handler(handler);
844 tracing::info!("DwAgentGraph runtime wired into FlowEngine");
845 }
846 }
847
848 #[cfg(feature = "agentic-worker")]
852 {
853 let dw_dispatch =
854 crate::runner::agent_node::dw_agent_dispatch_mode(|k| std::env::var(k).ok());
855 engine.set_dw_agent_dispatch(dw_dispatch);
856 if matches!(
857 dw_dispatch,
858 crate::runner::agent_node::DwAgentDispatch::Nats
859 ) && std::env::var("GREENTIC_EVENTS_NATS_URL")
860 .ok()
861 .filter(|s| !s.is_empty())
862 .is_none()
863 {
864 tracing::warn!(
865 "GREENTIC_AW_DISPATCH=nats but GREENTIC_EVENTS_NATS_URL is unset; \
866 dw.agent nodes will fail (no remote dispatch handler). \
867 Set the NATS URL or unset the flag."
868 );
869 }
870 }
871
872 let engine = Arc::new(engine);
873 let state_machine = Arc::new(
874 StateMachineRuntime::from_flow_engine(
875 Arc::clone(&config),
876 Arc::clone(&engine),
877 pack_trace,
878 session_host,
879 Arc::clone(&session_store),
880 state_host,
881 Arc::clone(&secrets_manager),
882 mocks.clone(),
883 audit_nats_client,
884 )
885 .context("failed to initialise state machine runtime")?,
886 );
887
888 if let Some(client) = dispatch_nats_client {
897 let resumer = Arc::new(
898 crate::runner::runtime_session_resumer::RuntimeSessionResumer::new(Arc::clone(
899 &state_machine,
900 )),
901 );
902 for runtime_name in ["sorla", "operala", "agentic", "telco-x", "approval"] {
903 tokio::spawn(crate::runner::dispatch_listener::run_response_listener(
904 client.clone(),
905 runtime_name.to_string(),
906 Arc::clone(&resumer)
907 as Arc<dyn crate::runner::dispatch_listener::SessionResumer>,
908 ));
909 }
910 #[cfg(feature = "agentic-worker")]
915 tokio::spawn(crate::runner::mcp_warm_listener::run_mcp_warm_listener(
916 client.clone(),
917 ));
918 tracing::info!(
919 "Remote-dispatch (NATS) wired into runtime: sorla.call / operala.call / agentic.call / telco-x.call"
920 );
921 }
922 let http_client = Client::builder().build()?;
923 Ok(Arc::new(Self {
924 tenant: config.tenant.clone(),
925 config,
926 packs: pack_runtimes,
927 digests,
928 engine,
929 state_machine,
930 session_store,
931 http_client,
932 mocks,
933 timer_handles: Mutex::new(Vec::new()),
934 secrets: secrets_manager,
935 operator_registry,
936 operator_metrics,
937 contract_cache: ContractCache::from_env(),
938 }))
939 }
940
941 pub fn tenant(&self) -> &str {
942 &self.tenant
943 }
944
945 pub fn config(&self) -> &Arc<HostConfig> {
946 &self.config
947 }
948
949 pub fn operator_registry(&self) -> &OperatorRegistry {
950 &self.operator_registry
951 }
952
953 pub fn operator_metrics(&self) -> &OperatorMetrics {
954 &self.operator_metrics
955 }
956
957 pub fn contract_cache(&self) -> &ContractCache {
958 &self.contract_cache
959 }
960
961 pub fn contract_cache_stats(&self) -> ContractCacheStats {
962 self.contract_cache.stats()
963 }
964
965 pub fn main_pack(&self) -> &Arc<PackRuntime> {
966 self.packs
967 .first()
968 .expect("tenant runtime must contain at least one pack")
969 }
970
971 pub fn pack(&self) -> Arc<PackRuntime> {
972 Arc::clone(self.main_pack())
973 }
974
975 pub fn overlays(&self) -> Vec<Arc<PackRuntime>> {
976 self.packs.iter().skip(1).cloned().collect()
977 }
978
979 pub fn all_packs(&self) -> &[Arc<PackRuntime>] {
987 &self.packs
988 }
989
990 pub fn pack_digests(&self) -> &[Option<String>] {
994 &self.digests
995 }
996
997 pub fn engine(&self) -> &Arc<FlowEngine> {
998 &self.engine
999 }
1000
1001 pub fn state_machine(&self) -> &Arc<StateMachineRuntime> {
1002 &self.state_machine
1003 }
1004
1005 pub fn session_store(&self) -> &DynSessionStore {
1010 &self.session_store
1011 }
1012
1013 pub fn http_client(&self) -> &Client {
1014 &self.http_client
1015 }
1016
1017 pub fn oauth_config(&self) -> Option<OAuthBrokerConfig> {
1018 self.config.oauth_broker_config()
1019 }
1020
1021 pub fn digest(&self) -> Option<&str> {
1022 self.digests.first().and_then(|d| d.as_deref())
1023 }
1024
1025 pub fn overlay_digests(&self) -> Vec<Option<String>> {
1026 self.digests.iter().skip(1).cloned().collect()
1027 }
1028
1029 pub fn required_secrets(&self) -> Vec<SecretRequirement> {
1030 self.packs
1031 .iter()
1032 .flat_map(|pack| pack.required_secrets().iter().cloned())
1033 .collect()
1034 }
1035
1036 pub fn missing_secrets(&self) -> Vec<SecretRequirement> {
1037 self.packs
1038 .iter()
1039 .flat_map(|pack| pack.missing_secrets(&self.config.tenant_ctx()))
1040 .collect()
1041 }
1042
1043 pub fn mocks(&self) -> Option<&Arc<MockLayer>> {
1044 self.mocks.as_ref()
1045 }
1046
1047 pub fn register_timers(&self, handles: Vec<JoinHandle<()>>) {
1048 self.timer_handles.lock().extend(handles);
1049 }
1050
1051 pub fn get_secret(&self, key: &str) -> Result<String> {
1052 if crate::provider_core_only::is_enabled() {
1053 bail!(crate::provider_core_only::blocked_message("secrets"))
1054 }
1055 if !self.config.secrets_policy.is_allowed(key) {
1056 bail!("secret {key} is not permitted by bindings policy");
1057 }
1058 let ctx = self.config.tenant_ctx();
1059 let canonical_key = canonicalize_secret_key(key);
1060 let bytes =
1061 read_secret_blocking(&self.secrets, &ctx, RUNTIME_SECRETS_PACK_ID, &canonical_key)
1062 .context("failed to read secret from manager")?;
1063 let value = String::from_utf8(bytes).context("secret value is not valid UTF-8")?;
1064 Ok(value)
1065 }
1066
1067 pub fn build_events_email_execution_plan(
1068 &self,
1069 tenant: &greentic_types::TenantCtx,
1070 request: &EmailSendRequest,
1071 ) -> Result<EmailExecutionPlan> {
1072 let oauth = self
1073 .oauth_config()
1074 .ok_or_else(|| anyhow!("oauth broker config is not configured for tenant runtime"))?;
1075 build_email_execution_plan(&oauth, tenant, request)
1076 }
1077
1078 pub async fn execute_events_email_request(
1079 &self,
1080 access_token: &str,
1081 request: &EmailSendRequest,
1082 ) -> Result<()> {
1083 execute_email_request(self.http_client(), access_token, request).await
1084 }
1085
1086 pub async fn execute_events_email_with_oauth(
1087 &self,
1088 tenant: &greentic_types::TenantCtx,
1089 request: &EmailSendRequest,
1090 ) -> Result<()> {
1091 let plan = self.build_events_email_execution_plan(tenant, request)?;
1092 let token = request_resource_token(self.http_client(), &plan.token_request).await?;
1093 self.execute_events_email_request(&token.access_token, request)
1094 .await
1095 }
1096
1097 pub fn pack_for_component(&self, component_ref: &str) -> Option<Arc<PackRuntime>> {
1098 self.packs
1099 .iter()
1100 .find(|pack| pack.contains_component(component_ref))
1101 .cloned()
1102 }
1103
1104 pub fn pack_for_component_with_digest(
1105 &self,
1106 component_ref: &str,
1107 ) -> Option<(Arc<PackRuntime>, Option<String>)> {
1108 self.packs
1109 .iter()
1110 .zip(self.digests.iter())
1111 .find(|(pack, _)| pack.contains_component(component_ref))
1112 .map(|(pack, digest)| (Arc::clone(pack), digest.clone()))
1113 }
1114
1115 pub fn resolve_component(&self, component_ref: &str) -> Option<ResolvedComponent> {
1116 self.pack_for_component_with_digest(component_ref)
1117 .map(|(pack, digest)| ResolvedComponent {
1118 digest: digest
1119 .or_else(|| self.digest().map(ToString::to_string))
1120 .unwrap_or_else(|| "unknown".to_string()),
1121 component_ref: component_ref.to_string(),
1122 pack,
1123 })
1124 }
1125}
1126
1127impl Drop for TenantRuntime {
1128 fn drop(&mut self) {
1129 for handle in self.timer_handles.lock().drain(..) {
1130 handle.abort();
1131 }
1132 }
1133}
1134
1135#[cfg(test)]
1136mod runtime_key_tests {
1137 use super::*;
1138
1139 #[test]
1140 fn legacy_keys_match_only_on_tenant() {
1141 assert_eq!(RuntimeKey::legacy("acme"), RuntimeKey::legacy("acme"));
1142 assert_ne!(RuntimeKey::legacy("acme"), RuntimeKey::legacy("other"));
1143 }
1144
1145 #[test]
1146 fn legacy_and_revision_keys_never_collide() {
1147 let key = RuntimeKey::revision(
1148 "acme",
1149 DeploymentId::new(),
1150 BundleId::from("bundle-a"),
1151 RevisionId::new(),
1152 );
1153 assert_ne!(RuntimeKey::legacy("acme"), key);
1154 }
1155
1156 #[test]
1157 fn revision_keys_distinguish_revision_id() {
1158 let deployment = DeploymentId::new();
1159 let bundle = BundleId::from("bundle-a");
1160 let rev_a = RevisionId::new();
1161 let rev_b = RevisionId::new();
1162 let key_a = RuntimeKey::revision("acme", deployment, bundle.clone(), rev_a);
1163 let key_b = RuntimeKey::revision("acme", deployment, bundle.clone(), rev_b);
1164 let same = RuntimeKey::revision("acme", deployment, bundle, rev_a);
1165 assert_ne!(key_a, key_b);
1166 assert_eq!(key_a, same);
1167 }
1168
1169 #[test]
1170 fn legacy_reload_preserves_revision_entries() {
1171 let revision_key = RuntimeKey::revision(
1172 "acme",
1173 DeploymentId::new(),
1174 BundleId::from("bundle-a"),
1175 RevisionId::new(),
1176 );
1177 let mut prev: HashMap<RuntimeKey, u32> = HashMap::new();
1178 prev.insert(RuntimeKey::legacy("acme"), 1); prev.insert(RuntimeKey::legacy("retired"), 2); prev.insert(revision_key.clone(), 99); let mut legacy: HashMap<RuntimeKey, u32> = HashMap::new();
1183 legacy.insert(RuntimeKey::legacy("acme"), 10);
1184 legacy.insert(RuntimeKey::legacy("newcomer"), 20);
1185
1186 let next = merge_legacy_reload(&prev, legacy);
1187
1188 assert_eq!(next.get(&RuntimeKey::legacy("acme")), Some(&10));
1189 assert_eq!(next.get(&RuntimeKey::legacy("newcomer")), Some(&20));
1190 assert_eq!(next.get(&RuntimeKey::legacy("retired")), None);
1191 assert_eq!(next.get(&revision_key), Some(&99));
1192 }
1193
1194 #[test]
1195 fn remove_keyed_entry_pops_only_the_targeted_key() {
1196 let deployment = DeploymentId::new();
1197 let bundle = BundleId::from("bundle-a");
1198 let rev_a = RevisionId::new();
1199 let rev_b = RevisionId::new();
1200 let key_a = RuntimeKey::revision("acme", deployment, bundle.clone(), rev_a);
1201 let key_b = RuntimeKey::revision("acme", deployment, bundle, rev_b);
1202
1203 let mut prev: HashMap<RuntimeKey, u32> = HashMap::new();
1204 prev.insert(RuntimeKey::legacy("acme"), 1);
1205 prev.insert(key_a.clone(), 10);
1206 prev.insert(key_b.clone(), 20);
1207
1208 let (next, removed) = remove_keyed_entry(&prev, &key_a).expect("present");
1209 assert_eq!(removed, 10);
1210 assert_eq!(next.get(&key_a), None);
1211 assert_eq!(next.get(&key_b), Some(&20));
1212 assert_eq!(next.get(&RuntimeKey::legacy("acme")), Some(&1));
1213 }
1214
1215 #[test]
1216 fn remove_keyed_entry_returns_none_for_missing_key() {
1217 let prev: HashMap<RuntimeKey, u32> = HashMap::new();
1218 let ghost = RuntimeKey::revision(
1219 "acme",
1220 DeploymentId::new(),
1221 BundleId::from("bundle-a"),
1222 RevisionId::new(),
1223 );
1224 assert!(remove_keyed_entry(&prev, &ghost).is_none());
1225 }
1226
1227 #[test]
1228 fn remove_keyed_entry_leaves_other_deployments_alone() {
1229 let bundle = BundleId::from("bundle-a");
1230 let dep_a = DeploymentId::new();
1231 let dep_b = DeploymentId::new();
1232 let rev = RevisionId::new();
1233 let key_a = RuntimeKey::revision("acme", dep_a, bundle.clone(), rev);
1234 let key_b = RuntimeKey::revision("acme", dep_b, bundle, rev);
1235
1236 let mut prev: HashMap<RuntimeKey, u32> = HashMap::new();
1237 prev.insert(key_a.clone(), 100);
1238 prev.insert(key_b.clone(), 200);
1239
1240 let (next, removed) = remove_keyed_entry(&prev, &key_a).expect("present");
1241 assert_eq!(removed, 100);
1242 assert_eq!(next.get(&key_b), Some(&200));
1243 assert_eq!(next.len(), 1);
1244 }
1245
1246 #[test]
1247 fn map_lookup_separates_legacy_from_revision() {
1248 let deployment = DeploymentId::new();
1249 let bundle = BundleId::from("bundle-a");
1250 let revision = RevisionId::new();
1251
1252 let mut map: HashMap<RuntimeKey, u32> = HashMap::new();
1253 map.insert(RuntimeKey::legacy("acme"), 1);
1254 map.insert(
1255 RuntimeKey::revision("acme", deployment, bundle.clone(), revision),
1256 2,
1257 );
1258
1259 assert_eq!(map.get(&RuntimeKey::legacy("acme")), Some(&1));
1260 assert_eq!(
1261 map.get(&RuntimeKey::revision("acme", deployment, bundle, revision)),
1262 Some(&2)
1263 );
1264 assert_eq!(map.get(&RuntimeKey::legacy("ghost")), None);
1265 }
1266
1267 #[test]
1268 fn insert_keyed_entry_adds_revision_preserving_legacy_and_siblings() {
1269 let deployment = DeploymentId::new();
1270 let bundle = BundleId::from("bundle-a");
1271 let rev_a = RevisionId::new();
1272 let rev_b = RevisionId::new();
1273 let key_a = RuntimeKey::revision("acme", deployment, bundle.clone(), rev_a);
1274 let key_b = RuntimeKey::revision("acme", deployment, bundle, rev_b);
1275
1276 let mut prev: HashMap<RuntimeKey, u32> = HashMap::new();
1277 prev.insert(RuntimeKey::legacy("acme"), 1);
1278 prev.insert(key_a.clone(), 10);
1279
1280 let next = insert_keyed_entry(&prev, key_b.clone(), 20);
1281
1282 assert_eq!(next.get(&key_b), Some(&20));
1284 assert_eq!(next.get(&key_a), Some(&10));
1285 assert_eq!(next.get(&RuntimeKey::legacy("acme")), Some(&1));
1286 assert_eq!(next.len(), 3);
1287 }
1288
1289 #[test]
1290 fn insert_keyed_entry_replaces_existing_revision() {
1291 let key = RuntimeKey::revision(
1292 "acme",
1293 DeploymentId::new(),
1294 BundleId::from("bundle-a"),
1295 RevisionId::new(),
1296 );
1297 let mut prev: HashMap<RuntimeKey, u32> = HashMap::new();
1298 prev.insert(key.clone(), 10);
1299
1300 let next = insert_keyed_entry(&prev, key.clone(), 99);
1301
1302 assert_eq!(next.get(&key), Some(&99));
1303 assert_eq!(next.len(), 1);
1304 }
1305}