Skip to main content

greentic_runner_host/
runtime.rs

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/// Key identifying a live runtime in [`ActivePacks`].
40///
41/// Tenant-only entries use [`RuntimeKey::legacy`] (all id fields `None`);
42/// fully-qualified entries use [`RuntimeKey::revision`] (all `Some`). The two
43/// forms never collide, so both can coexist in the same map.
44#[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    /// Tenant-only key for the pre-revision-routing path.
54    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    /// Fully-qualified key for a specific deployment/bundle/revision.
64    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    /// `true` for the tenant-only key produced by [`legacy`](Self::legacy).
79    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
84/// Build the next runtime map for a legacy (tenant-only) reload: install the
85/// freshly-resolved `legacy` entries and carry over every revision-keyed entry
86/// untouched. Revision runtimes are owned by the deployment lifecycle, not the
87/// pack watcher, so a tenant-pack reload must not evict them.
88fn 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
100/// Pure swap helper for [`ActivePacks::remove_revision`]: clone the prev map
101/// minus `key`, return it alongside the removed value. `None` when the key was
102/// absent so the caller can skip the `ArcSwap` store. Generic over `V` so the
103/// swap logic is testable without standing up a real `TenantRuntime`.
104fn 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
114/// Pure swap helper for [`ActivePacks::insert_revision`]: clone the prev map
115/// and insert `value` under `key`, returning the next map. Generic over `V` so
116/// the swap logic is testable without standing up a real `TenantRuntime`.
117fn 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
127/// Atomically swapped view of live tenant runtimes.
128///
129/// Reads are lock-free via `ArcSwap`. Mutations serialize on `write_lock` so a
130/// read-modify-write swap (e.g. a watcher reload preserving revision entries)
131/// cannot interleave with a concurrent insert and clobber the other's update.
132pub 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    /// Look up the tenant-only (legacy) runtime. Compatibility helper for the
146    /// pre-revision-routing path; see [`load_revision`](Self::load_revision).
147    pub fn load_pack(&self, tenant: &str) -> Option<Arc<TenantRuntime>> {
148        self.inner.load().get(&RuntimeKey::legacy(tenant)).cloned()
149    }
150
151    /// Look up the runtime for a specific deployment/bundle/revision.
152    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    /// Insert (or replace) a single tenant-only runtime, preserving all other
175    /// entries — including revision-keyed ones.
176    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    /// Insert (or replace) a single revision-keyed runtime, preserving every
184    /// other entry — the tenant-only legacy entry and sibling revisions alike.
185    /// This is the producer the deployment warm path calls once a revision's
186    /// packs are loaded; the pack watcher's [`replace_legacy`](Self::replace_legacy)
187    /// then carries the entry across tenant-pack reloads untouched.
188    ///
189    /// Fails closed when the runtime's identity does not match the key it would
190    /// be stored under: a wiring bug that files one tenant's runtime under
191    /// another tenant's revision (breaking isolation) or stores a runtime whose
192    /// telemetry reports a different revision than it routes (breaking rollout
193    /// attribution) is rejected here rather than silently serving traffic under
194    /// the wrong identity. Pairs with [`TenantRuntime::load_revision`], which
195    /// derives the runtime's rollout identity from these same ids.
196    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    /// Swap in a freshly-resolved set of tenant-only (legacy) runtimes while
235    /// carrying over every revision-keyed entry. Used by the pack watcher, whose
236    /// index is authoritative for tenant packs but not for deployment revisions.
237    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    /// Remove and return the runtime for a single revision-keyed entry,
245    /// preserving every other entry (legacy and revision alike). Used by the
246    /// drain coordinator (`gtc op revisions drain`) after the drain window
247    /// closes to tear down exactly the one revision being retired. `None` if
248    /// no such entry was present — idempotent for safe re-runs.
249    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    /// Replace the entire map, dropping every entry (legacy and revision alike).
265    /// Used for full host stop.
266    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
286/// Runtime bundle for a tenant pack.
287pub 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    /// The session store this runtime was loaded with. Shared with the inner
295    /// state machine — re-exposed here so callers outside the flow run loop
296    /// (M1.5 welcome-flow first-contact probe) can query the SAME bucket the
297    /// state machine will read/write. For revision mode each revision passes
298    /// its own store into `load_revision`; querying the RunnerHost-level
299    /// store would key the wrong bucket.
300    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/// One pinned pack of a deployment revision: the on-disk path plus the
318/// `algo:value` content digest the deployment staged it under.
319/// [`TenantRuntime::load_revision`] fails closed when the file no longer
320/// matches the digest, defending the stage→warm window against a swapped or
321/// stale cache path.
322#[derive(Clone, Debug)]
323pub struct RevisionPackRef {
324    pub path: PathBuf,
325    pub digest: String,
326}
327
328/// Block on a future whether or not we're already inside a tokio runtime.
329pub 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    /// Build a revision-keyed runtime from its pinned pack list (the resolved
382    /// `pack_list` of a deployment revision). The first entry is the main pack;
383    /// the rest are overlays.
384    ///
385    /// Fails closed if any pack file no longer matches the digest the
386    /// deployment pinned it under — defending the stage→warm window against a
387    /// swapped or stale cache path. The verified digests are threaded into the
388    /// runtime so admin status, traces, and contract hashes report the real
389    /// content (parity with the legacy index path). The rollout telemetry
390    /// identity is **derived from** `deployment_id` / `bundle_id` /
391    /// `revision_id` / `customer_id`, so the engine's attribution cannot drift
392    /// from the key the runtime is later inserted under.
393    #[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            // `matches_file` always hashes with SHA-256, so a digest pinned under
429            // any other algorithm could never match — reject it with a clear
430            // message rather than the misleading "does not match" below.
431            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            // Reject duplicate pack_id within a single revision — two refs
465            // resolving to the same pack_id would silently share config/routing
466            // entries and produce an ambiguous runtime.
467            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    /// Load a single [`PackRuntime`] from a path, sharing the tenant's session /
499    /// state / secrets backends. Shared by [`load`](Self::load) (one pack) and
500    /// [`load_revision`](Self::load_revision) (the revision's pack list).
501    ///
502    /// `runtime_configs_by_pack_id` is consulted AFTER the pack loads (the
503    /// `pack_id` is only known after the manifest read) and BEFORE the
504    /// `Arc<PackRuntime>` is created. A matching entry is injected via
505    /// [`PackRuntime::set_runtime_config_non_secret`] so the C4.3 producer
506    /// plumbing requires no post-hoc `Arc::get_mut` dance — the single-pack
507    /// [`load`](Self::load) path just passes an empty map.
508    ///
509    /// `runtime_refs_by_pack_id` mirrors the same shape for the C5
510    /// `pack-config.v1.runtime_refs` channel; a matching entry is injected
511    /// via [`PackRuntime::set_runtime_refs`] alongside `runtime_ref_resolver`.
512    #[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    /// Like [`from_packs`](Self::from_packs) but stamps `rollout` onto the flow
593    /// engine so every span this runtime emits carries the deployment / bundle /
594    /// revision / customer identity. [`from_packs`](Self::from_packs) is the
595    /// legacy (tenant-only) path and passes [`RolloutIds::default`].
596    #[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        // The deployed unit's identity, used as the `project_id` billing
636        // dimension for this runtime's agentic-worker spend. It is exactly what
637        // greentic-designer records as `pack_name`, so authoring-time and
638        // runtime spend join on it. Captured BEFORE `rollout` moves into the
639        // engine; `None` on the legacy tenant-only path (no bundle is pinned
640        // there), which makes billing omit the dimension rather than fall back
641        // to a non-unique in-pack agent id.
642        #[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        // Wire Sorla remote-dispatch (NATS) into the flow engine BEFORE it is
651        // moved behind an `Arc`. `set_remote_dispatch_handler` takes `&mut self`,
652        // so the dispatcher must be attached while `engine` is still owned and
653        // mutable. The response listener (which needs the post-build ingress
654        // handle) is spawned further below, after the runtime is constructed.
655        //
656        // Gated on `GREENTIC_EVENTS_NATS_URL`: when unset, `sorla.call` stays
657        // disabled and existing behaviour is unchanged. When set but NATS cannot
658        // be reached we log a warning and continue (the engine simply has no
659        // dispatch handler, so `sorla.call` nodes fail fast at execution time).
660        //
661        // Connected here (BEFORE the agentic-worker block below) rather than
662        // its original post-block position so the agent-node handler
663        // construction below can also thread the (possibly connected) client
664        // into an `AuditSink` for `dw.agent` step audit events (EPIC-B B-3);
665        // pure reordering, no behaviour change to the dispatch wiring itself.
666        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        // Clone the (possibly connected) client for the audit sink (EPIC-B
686        // B-2/B-3): threaded into `StateMachineRuntime::from_flow_engine` so
687        // `TraceRecorder` can publish best-effort audit events over NATS, and
688        // (as an `AuditSink`) into the `dw.agent` node handler below so agent
689        // tool-call/tool-result steps are audited too. Cloned BEFORE the
690        // response-listener loop further below moves `dispatch_nats_client`.
691        // `None` when NATS is unset/unreachable, which keeps both audit paths
692        // off by default (zero behaviour change).
693        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            // Collect agent configs from all New-manifest packs. When the same
703            // agent_id appears in multiple packs the last pack wins (packs are
704            // ordered: first = primary, rest = overlays). Collisions are logged
705            // so operators can audit cross-pack conflicts.
706            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                // Bridge: designer-built packs cannot populate `manifest.agents`
710                // (old greentic-pack); they embed a `dw-agents.json` sidecar.
711                // Fill any agent_id the manifest lacked (manifest stays authoritative).
712                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            // Operator config overrides pack-provided agents on collision.
732            let merged_agents = merge_agent_sources(pack_agents, config.agents.clone());
733
734            // First-boot ingest of any pack-baked knowledge corpus (W4 4c). Runs
735            // BEFORE the agent runtime mounts its serving knowledge connection:
736            // embedded SurrealDB allows one handle per store directory, so the
737            // temporary ingest connection must open and drop before the serving
738            // mount (inside build_agent_node_handler) opens its own. No-op without
739            // the `knowledge-chronicle` feature or when no pack carries a corpus.
740            #[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            // DwAgent state-store selection. With GREENTIC_AW_REDIS_URL set, use the
747            // Redis-backed stores (production multi-process default). Without it, when
748            // built with `desktop-agent-ephemeral`, fall back to the process-global
749            // in-memory stores so a single-process runner (e.g. the designer's
750            // loopback test-chat sidecar) runs agentic-worker turns with NO external
751            // infra. Otherwise DwAgent nodes stay disabled — unchanged server
752            // behaviour (build_agent_node_handler returns None when Redis is unset).
753            let redis_set = std::env::var("GREENTIC_AW_REDIS_URL")
754                .map(|v| !v.is_empty())
755                .unwrap_or(false);
756            // Best-effort agent-step audit sink (EPIC-B B-3), built from the
757            // same (possibly connected) NATS client the flow-level audit sink
758            // (B-2) uses. `None` when NATS is unset/unreachable, which keeps
759            // `dw.agent` execution on the plain `AgentRuntime::step` path
760            // (zero behaviour change).
761            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            // Collect agent-graph sidecars from each pack. Unlike agents (a
806            // manifest.cbor map), graphs arrive as a pack FILE (`agent-graph.json`)
807            // — one sidecar per pack — so the graph is keyed by `pack_id`. A
808            // sidecar that fails UTF-8 / JSON / schema validation is logged and
809            // skipped (lenient, mirroring `agent_configs_from_manifest`) so a bad
810            // graph never blocks the rest of pack loading. When the same pack_id
811            // appears twice (overlay), the last pack wins.
812            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            // Producer/operator-declared graphs (HostConfig.graphs) override
831            // pack-sidecar graphs on `graph_id` collision — mirroring the
832            // operator-wins merge for agents.
833            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        // Resolve how `dw.agent` nodes dispatch. When `GREENTIC_AW_DISPATCH=nats`
849        // is set, the node is rerouted over the durable agentic NATS path instead
850        // of the in-process handler. Must be wired while `engine` is still owned.
851        #[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        // Spawn the response listeners now that the ingress handle
889        // (`state_machine`) exists. Each listener resumes paused flows by feeding
890        // a synthesized ingress envelope through `StateMachineRuntime::handle`
891        // (see `RuntimeSessionResumer`). The resumer is runtime-agnostic (it
892        // resumes by correlation id), so one shared resumer serves all runtimes;
893        // we run one listener per runtime so every `*.call` node's responses
894        // (`greentic.<runtime>.response.v1`) are consumed. Only started when the
895        // dispatcher above connected successfully.
896        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            // Eagerly pre-cache local-wasm MCP components when the admin publishes
911            // a warm event. Feature-gated behind `agentic-worker` because
912            // `mcp_store_pull` lives in `greentic-aw-runtime` which is only
913            // present when that feature is enabled.
914            #[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    /// All packs in declaration order: main pack at index 0, overlays after.
980    /// Borrowed slice — no `Arc` clones, no allocation. Use this when you only
981    /// need to iterate the full pack list; prefer [`pack`]/[`overlays`] when
982    /// you need to hand out owned `Arc`s downstream.
983    ///
984    /// [`pack`]: TenantRuntime::pack
985    /// [`overlays`]: TenantRuntime::overlays
986    pub fn all_packs(&self) -> &[Arc<PackRuntime>] {
987        &self.packs
988    }
989
990    /// Resolved content digest of each loaded pack, index-aligned with the pack
991    /// list. `Some` for revision runtimes (verified at load) and the legacy
992    /// index path; `None` only when a digest was unavailable.
993    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    /// Shared session store. M1.5: lets `apply_welcome_flow_override`
1006    /// build a `FlowResumeStore` against the SAME bucket the state machine
1007    /// will read/write — important under revision mode where each revision
1008    /// is given its own store via `load_revision`.
1009    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); // stale legacy, refreshed below
1179        prev.insert(RuntimeKey::legacy("retired"), 2); // dropped: not in new index
1180        prev.insert(revision_key.clone(), 99); // revision runtime: must survive
1181
1182        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        // New revision lands; legacy entry and the sibling revision survive.
1283        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}