Skip to main content

greentic_runner_host/
host.rs

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
39/// Builder for composing multi-tenant host instances.
40pub 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
116/// Runtime host that manages tenant-bound packs and flow execution.
117pub 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/// Handle exposing tenant internals for embedding hosts (e.g. CLI server).
131#[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    /// Execute an activity against a specific deployment/bundle/revision runtime.
171    ///
172    /// Unlike [`handle_activity`](Self::handle_activity), which resolves the
173    /// tenant-only (legacy) runtime, this targets a fully-qualified revision
174    /// entry inserted by [`ActivePacks::insert_revision`]. A tenant can host
175    /// several concurrent revisions under a traffic split, so the legacy
176    /// tenant-only lookup cannot disambiguate them — the ingress revision
177    /// dispatcher selects the revision and calls this.
178    ///
179    /// # Session isolation contract
180    ///
181    /// This method runs the selected revision's runtime against **whatever
182    /// session/state stores that runtime was built with** (at
183    /// [`TenantRuntime::load_revision`] time). It does *not* add a revision
184    /// dimension to the session key: the session/resume/state backend keys on
185    /// `(env, tenant, user)` plus pack/flow, **not** on the revision. If two
186    /// live revisions of the same pack for one tenant share a single session
187    /// backend, a `wait`/resume snapshot created by revision A can be fetched —
188    /// or clobbered — by revision B during a traffic split, retry, or
189    /// rebalance, resuming a snapshot against a different flow graph.
190    ///
191    /// Callers that load more than one revision per tenant onto one host
192    /// (i.e. every traffic-split producer) **MUST give each revision an
193    /// isolated session and state store** (a per-revision store instance, or a
194    /// revision-namespaced backend) when calling `load_revision`. The shared
195    /// `RunnerHost` stores (`session_store()`/`state_store()`) are only safe to
196    /// reuse across revisions when at most one revision is ever live per
197    /// tenant. The greentic-start activation path enforces this.
198    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    /// Resolve the per-revision tenant runtime, attaching a uniform "not
219    /// loaded" context to the error. The three per-revision identify
220    /// fan-out APIs all need this exact lookup; sharing it keeps the
221    /// error chain identical across them.
222    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    /// Per-revision per-`provider_type` `identify-instance` probe (M1 IID.4).
240    ///
241    /// Given the candidate `provider_types` an env declares messaging
242    /// endpoints for, ask each pack loaded under this revision (main +
243    /// overlays) which `provider_id` the inbound `payload` claims to address.
244    /// The greentic-start resolver pairs the returned `provider_id` with the
245    /// `provider_type` and looks the `MessagingEndpointId` up in the env's
246    /// admit table; that's how a header-less webhook gets auto-routed to the
247    /// right endpoint.
248    ///
249    /// `payload` is forwarded opaque to every probed component. The M1
250    /// IID.4d wrapper convention from `greentic-start` is
251    /// `{headers: [{name,value}], body: <parsed-or-null>}`. See the WIT
252    /// docstring on `greentic:provider-instance-identity@0.1.0/identify-instance`
253    /// for the full contract.
254    ///
255    /// This is the unscoped legacy API; new callers should use
256    /// [`identify_messaging_endpoints_for_revision_scoped`] for per-provider
257    /// header allowlist scoping (Phase D). Merge lattice:
258    /// `Identified > NoMatch > Unsupported` — first pack to `Identified`
259    /// wins and that type drops out of remaining probing.
260    ///
261    /// The per-pack loop is inlined (rather than factored into a shared
262    /// `AsyncFnMut`-based helper) deliberately: routing the loop through an
263    /// `AsyncFnMut` closure destabilises HRTB `Send` inference for
264    /// downstream consumers spawning the returned future (greentic-start's
265    /// hyper `service_fn`). The `Send`-bound test
266    /// [`identify_futures_are_send`] guards against silent regression.
267    ///
268    /// [`identify_messaging_endpoints_for_revision_scoped`]:
269    ///     RunnerHost::identify_messaging_endpoints_for_revision_scoped
270    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        // Seed every type at Unsupported — the floor of the merge lattice
284        // (see `IdentifyOutcome::merge_in`).
285        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            // Skip types already at the lattice top — no probe could improve them.
291            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    /// Per-provider scoped variant of
312    /// [`identify_messaging_endpoints_for_revision`].
313    ///
314    /// The wrapper is built **per-provider** from the component's cached
315    /// `describe-identify-instance` hint (see
316    /// [`PackRuntime::resolve_identify_hint`]): hinted components receive
317    /// ONLY the headers their hint declares; unhinted components receive
318    /// every header the caller passed in (back-compat).
319    ///
320    /// Loop inlined for the same reason as
321    /// [`identify_messaging_endpoints_for_revision`].
322    ///
323    /// [`identify_messaging_endpoints_for_revision`]:
324    ///     RunnerHost::identify_messaging_endpoints_for_revision
325    #[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    /// Per-revision describe-identify-instance hint discovery.
366    ///
367    /// Fans the cached describe probe out across main pack + overlays;
368    /// first non-`None` hint per `provider_type` wins. Lets callers inspect
369    /// the per-provider header allowlist without running the expensive
370    /// identify-instance probe. `None` value means no pack in this revision
371    /// exposes a usable hint for that `provider_type`.
372    ///
373    /// Loop inlined for the same reason as
374    /// [`identify_messaging_endpoints_for_revision`].
375    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            // First non-`None` hint per type wins — anything already populated
394            // is at the lattice top. Mirror the `matches!` shape the sibling
395            // identify fns use so the predicate is consistent across files.
396            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    /// Per-revision provider invocation (Phase D).
419    ///
420    /// Locates the unique pack in `(deployment_id, bundle_id, revision_id)`
421    /// whose `greentic.provider-extension.v1` binds the requested
422    /// `provider_type`, verifies `op` is in that declaration's allowlist,
423    /// then calls `op` on it with `input_json`.
424    ///
425    /// Used by greentic-start's Phase D `ProviderRoute` admission arm to
426    /// run provider webhooks (e.g. `ingest_http`) without round-tripping
427    /// through the flow engine. The provider component returns the parsed
428    /// HTTP-out envelope verbatim; greentic-start dispatches the events
429    /// it carries back through the flow runtime separately.
430    ///
431    /// `correlation_id` is threaded into the `ComponentExecCtx` as both
432    /// `correlation_id` and `idempotency_key` (mirroring the operator-API
433    /// pattern in `build_exec_ctx`). `trace_id` rides through as-is.
434    ///
435    /// Fails closed when:
436    /// - the revision isn't loaded (error chain names deployment + revision)
437    /// - no pack in the revision binds `provider_type`
438    /// - **multiple packs in the revision bind `provider_type`** — the URL
439    ///   → provider routing the caller did at the route table is unable to
440    ///   disambiguate at invoke time. Mirrors the within-pack ambiguity
441    ///   check in [`ProviderRegistry::resolve`], lifted to the revision
442    ///   level so an overlay accidentally redeclaring a main-pack provider
443    ///   can't silently shadow the wrong runtime. A future revision of
444    ///   this API can accept an explicit `provider_id` to disambiguate
445    ///   when D.3 wires identification + invocation together.
446    /// - `op` is not in the resolved provider's declared `ops` allowlist
447    ///   (defense-in-depth: a caller bug or misconfigured `ProviderRoute`
448    ///   cannot smuggle an undeclared op past the schema-core boundary).
449    ///
450    /// Loop inlined for the same reason as
451    /// [`identify_messaging_endpoints_for_revision`].
452    ///
453    /// [`ProviderRegistry::resolve`]: crate::provider::ProviderRegistry::resolve
454    #[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        // Walk ALL packs to detect cross-pack ambiguity. First-match-wins
469        // would let main pack silently shadow an overlay that binds the
470        // same provider_type — the identify-side merge lattice can return
471        // outcomes from any pack, so the invoke side must refuse to guess.
472        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    /// Shared activity-execution body: resolve the flow, build the canonical
526    /// ingress envelope, run the state machine, and normalize replies. Both the
527    /// legacy and revision entry points funnel through here so flow resolution
528    /// and reply shaping never drift between them.
529    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        // Fast2Flow Respond/Deny returns a pre-built response activity
538        // (kind = Custom { action: "response" }, no flow_id/pack_id).
539        // Short-circuit: return it directly — do NOT resolve a flow or
540        // enter the state machine, otherwise a Deny still executes the
541        // tenant entry flow with the denial payload.
542        if activity.action() == Some("response") && activity.flow_id().is_none() {
543            return Ok(vec![activity]);
544        }
545
546        let (pack_id, flow_id) = resolve_flow_id(runtime, &activity)?;
547        let action = activity.action().map(|value| value.to_string());
548        let session = activity.session_id().map(|value| value.to_string());
549        let provider = activity.provider_id().map(|value| value.to_string());
550        let messaging_endpoint_id = activity
551            .messaging_endpoint_id()
552            .map(|value| value.to_string());
553        let channel = activity.channel().map(|value| value.to_string());
554        let conversation = activity.conversation().map(|value| value.to_string());
555        let user = activity.user().map(|value| value.to_string());
556        let welcome_flow_hint = activity.welcome_flow_hint().cloned();
557        let resolved_flow_type =
558            activity
559                .flow_type()
560                .map(|value| value.to_string())
561                .or_else(|| {
562                    runtime
563                        .engine()
564                        .flow_by_key(&pack_id, &flow_id)
565                        .map(|desc| desc.flow_type.clone())
566                });
567        let payload = activity.into_payload();
568
569        let mut envelope = IngressEnvelope {
570            tenant: tenant.to_string(),
571            env: std::env::var("GREENTIC_ENV").ok(),
572            pack_id: Some(pack_id.clone()),
573            flow_id: flow_id.clone(),
574            flow_type: resolved_flow_type,
575            action,
576            session_hint: session,
577            provider,
578            messaging_endpoint_id,
579            channel,
580            conversation,
581            user,
582            activity_id: None,
583            timestamp: None,
584            payload,
585            metadata: None,
586            reply_scope: None,
587        }
588        .canonicalize();
589
590        let hint_flow_type = welcome_flow_hint.as_ref().and_then(|hint| {
591            runtime
592                .engine()
593                .flow_by_key(&hint.pack_id, &hint.flow_id)
594                .map(|desc| desc.flow_type.clone())
595        });
596        apply_welcome_flow_override(
597            runtime.session_store(),
598            &mut envelope,
599            welcome_flow_hint.as_ref(),
600            hint_flow_type,
601        )?;
602
603        let result = runtime.state_machine().handle(envelope).await?;
604        Ok(normalize_replies(result, tenant))
605    }
606
607    pub async fn tenant(&self, tenant: &str) -> Option<TenantHandle> {
608        self.active
609            .load_pack(tenant)
610            .map(|runtime| TenantHandle { runtime })
611    }
612
613    pub fn active_packs(&self) -> Arc<ActivePacks> {
614        Arc::clone(&self.active)
615    }
616
617    pub fn health_state(&self) -> Arc<HealthState> {
618        Arc::clone(&self.health)
619    }
620
621    pub fn wasi_policy(&self) -> Arc<RunnerWasiPolicy> {
622        Arc::clone(&self.wasi_policy)
623    }
624
625    pub fn session_store(&self) -> DynSessionStore {
626        Arc::clone(&self.session_store)
627    }
628
629    pub fn state_store(&self) -> DynStateStore {
630        Arc::clone(&self.state_store)
631    }
632
633    pub fn session_host(&self) -> Arc<dyn SessionHost> {
634        Arc::clone(&self.session_host)
635    }
636
637    pub fn state_host(&self) -> Arc<dyn StateHost> {
638        Arc::clone(&self.state_host)
639    }
640
641    pub fn secrets_manager(&self) -> DynSecretsManager {
642        Arc::clone(&self.secrets_manager)
643    }
644
645    pub fn tenant_configs(&self) -> HashMap<String, Arc<HostConfig>> {
646        self.configs.clone()
647    }
648
649    /// Build a minimal `RunnerHost` suitable for unit tests. The host has no
650    /// packs loaded into `active`, so `handle_activity` for any tenant will
651    /// return a "not loaded" error — which is exactly the error path the
652    /// `POST /agent/chat` handler tests exercise.
653    #[cfg(test)]
654    pub(crate) fn for_test() -> Arc<Self> {
655        use crate::config::{
656            FlowRetryConfig, OperatorPolicy, RateLimits, SecretsPolicy, StateStorePolicy,
657            WebhookPolicy,
658        };
659        let config = Arc::new(HostConfig {
660            tenant: "test".into(),
661            bindings_path: std::path::PathBuf::from("<test>"),
662            flow_type_bindings: std::collections::HashMap::new(),
663            rate_limits: RateLimits::default(),
664            retry: FlowRetryConfig::default(),
665            http_enabled: false,
666            secrets_policy: SecretsPolicy::allow_all(),
667            state_store_policy: StateStorePolicy::default(),
668            webhook_policy: WebhookPolicy::default(),
669            timers: Vec::new(),
670            oauth: None,
671            mocks: None,
672            pack_bindings: Vec::new(),
673            env_passthrough: Vec::new(),
674            trace: crate::trace::TraceConfig::from_env(),
675            validation: crate::validate::ValidationConfig::from_env(),
676            operator_policy: OperatorPolicy::allow_all(),
677            fast2flow: Default::default(),
678            #[cfg(feature = "agentic-worker")]
679            agents: std::collections::HashMap::new(),
680            #[cfg(feature = "agentic-worker")]
681            graphs: std::collections::HashMap::new(),
682        });
683        let session_store = new_session_store();
684        let session_host = session_host_from(Arc::clone(&session_store));
685        let state_store = new_state_store();
686        let state_host = state_host_from(Arc::clone(&state_store));
687        let secrets_manager = default_manager().expect("test secrets manager");
688        Arc::new(Self {
689            configs: std::collections::HashMap::from([("test".to_string(), config)]),
690            active: Arc::new(ActivePacks::new()),
691            health: Arc::new(HealthState::new()),
692            session_store,
693            state_store,
694            session_host,
695            state_host,
696            wasi_policy: Arc::new(RunnerWasiPolicy::default()),
697            secrets_manager,
698            telemetry: None,
699        })
700    }
701
702    async fn prepare_runtime(
703        &self,
704        tenant: &str,
705        pack_path: &Path,
706        archive_source: Option<&Path>,
707    ) -> Result<Arc<TenantRuntime>> {
708        let config = self
709            .configs
710            .get(tenant)
711            .cloned()
712            .with_context(|| format!("tenant {tenant} not registered"))?;
713        if config.tenant != tenant {
714            bail!(
715                "tenant mismatch: config declares '{}' but '{tenant}' was requested",
716                config.tenant
717            );
718        }
719        let runtime = TenantRuntime::load(
720            pack_path,
721            Arc::clone(&config),
722            None,
723            archive_source,
724            None,
725            self.wasi_policy(),
726            self.session_host(),
727            self.session_store(),
728            self.state_store(),
729            self.state_host(),
730            self.secrets_manager(),
731        )
732        .await?;
733        let timers = adapt_timer::spawn_timers(Arc::clone(&runtime))?;
734        runtime.register_timers(timers);
735        Ok(runtime)
736    }
737}
738
739impl TenantHandle {
740    pub fn config(&self) -> Arc<HostConfig> {
741        Arc::clone(self.runtime.config())
742    }
743
744    pub fn pack(&self) -> Arc<PackRuntime> {
745        self.runtime.pack()
746    }
747
748    pub fn engine(&self) -> Arc<FlowEngine> {
749        Arc::clone(self.runtime.engine())
750    }
751
752    pub fn overlays(&self) -> Vec<Arc<PackRuntime>> {
753        self.runtime.overlays()
754    }
755
756    pub fn overlay_digests(&self) -> Vec<Option<String>> {
757        self.runtime.overlay_digests()
758    }
759}
760
761/// M1.5 welcome-flow override: swap the envelope's `(pack_id, flow_id,
762/// flow_type)` to the producer-supplied [`WelcomeFlowHint`] when ALL of:
763/// the hint is present, the envelope carries a `messaging_endpoint_id`,
764/// the welcome-seen marker is absent for this `(tenant, env, eid, user)`
765/// (set atomically on success), and `FlowResumeStore::fetch` finds no
766/// active wait snapshot. Any missing precondition is a silent no-op.
767///
768/// **The welcome-seen marker is the durable first-contact gate.** Without
769/// it, post-completion / no-wait / TTL-expired turns would re-fire welcome
770/// because the wait-snapshot check is only positive while a flow is paused.
771/// The marker lives in the shared session store under a synthetic scope
772/// (`welcome-seen::ep=<eid>`) distinct from the flow's own conversation, so
773/// flow-completion `clear_wait` does NOT drop it.
774///
775/// The wait-snapshot check is kept as a belt-and-braces safety net: the
776/// marker check + write is two operations against the store with a small
777/// race window (Phase D will add an atomic `register_wait_if_absent`).
778/// The safety net guarantees an in-flight flow is never overridden even if
779/// two concurrent first-ever turns both pass the marker probe.
780///
781/// `session_store` + `hint_flow_type` are passed as primitives so the logic
782/// is unit-testable without a `TenantRuntime`; the caller does the engine
783/// lookup that produces `hint_flow_type`.
784fn apply_welcome_flow_override(
785    session_store: &DynSessionStore,
786    envelope: &mut IngressEnvelope,
787    hint: Option<&WelcomeFlowHint>,
788    hint_flow_type: Option<String>,
789) -> Result<()> {
790    let Some(hint) = hint else {
791        return Ok(());
792    };
793    if envelope.messaging_endpoint_id.is_none() {
794        return Ok(());
795    }
796
797    if !try_mark_welcome_first_contact(session_store, envelope)? {
798        return Ok(());
799    }
800
801    let resume = FlowResumeStore::new(Arc::clone(session_store));
802    let snapshot = resume
803        .fetch(envelope)
804        .map_err(|err| anyhow!("welcome-flow first-contact probe failed: {err}"))?;
805    if snapshot.is_some() {
806        return Ok(());
807    }
808
809    envelope.pack_id = Some(hint.pack_id.clone());
810    envelope.flow_id = hint.flow_id.clone();
811    envelope.flow_type = hint_flow_type;
812    Ok(())
813}
814
815/// Persists a per-`(tenant, env, eid, user)` welcome-seen marker on first
816/// contact and returns `true` only when this turn observed no marker AND
817/// wrote one. Subsequent turns short-circuit to `false`.
818///
819/// Returns `false` (without writing) if the envelope lacks a
820/// `messaging_endpoint_id` — no marker bucket is derivable.
821///
822/// Race window: check + mark is two store calls, not one atomic CAS. Two
823/// concurrent first-ever turns can both observe "no marker" and both fire
824/// welcome once — bounded harm, mitigated by the wait-snapshot safety net
825/// in [`apply_welcome_flow_override`]. A real atomic primitive
826/// (`register_wait_if_absent`) is Phase D.
827fn try_mark_welcome_first_contact(
828    store: &DynSessionStore,
829    envelope: &IngressEnvelope,
830) -> Result<bool> {
831    let Some(scope) = welcome_marker_scope(envelope) else {
832        return Ok(false);
833    };
834    let (ctx, user) = FlowResumeStore::contact_identity(envelope)
835        .map_err(|e| anyhow!("welcome marker identity probe failed: {e}"))?;
836
837    if store
838        .find_wait_by_scope(&ctx, &user, &scope)
839        .map_err(|e| anyhow!("welcome marker probe failed: {e}"))?
840        .is_some()
841    {
842        return Ok(false);
843    }
844
845    let data = marker_session_data(&ctx, &user);
846    let session_key = marker_session_key(&ctx, &user, &scope);
847    store
848        .register_wait(&ctx, &user, &scope, &session_key, data, None)
849        .map_err(|e| anyhow!("welcome marker register failed: {e}"))?;
850    Ok(true)
851}
852
853/// Stable, identity-scoped session key for the welcome marker.
854///
855/// **The session key is the store's per-entry identity.** Both backends
856/// (in-memory + Redis) overwrite or reject an existing entry on a
857/// `register_wait` collision, so a scope-only `SessionKey` would collapse
858/// every `(tenant, env, user)` on the same endpoint onto one row:
859/// in-memory's `ensure_ctx_preserved` would reject User B's first turn
860/// outright; Redis's unconditional `SET` would overwrite User A's entry
861/// and dangle User A's scope index to User B's data.
862///
863/// Fix: SHA-256 over `(env, tenant, team, user, conversation)`. The `v1`
864/// prefix lets us bump the derivation without colliding on old markers.
865fn marker_session_key(
866    ctx: &greentic_types::TenantCtx,
867    user: &greentic_types::UserId,
868    scope: &greentic_types::ReplyScope,
869) -> greentic_session::SessionKey {
870    use sha2::{Digest, Sha256};
871    let team = match ctx.team_id.as_ref().or(ctx.team.as_ref()) {
872        Some(t) => t.as_str(),
873        None => "<none>",
874    };
875    let digest = Sha256::digest(
876        format!(
877            "welcome-marker:v1\0{}\0{}\0team={team}\0{}\0{}",
878            ctx.env.as_str(),
879            ctx.tenant_id.as_str(),
880            user.as_str(),
881            scope.conversation,
882        )
883        .as_bytes(),
884    );
885    greentic_session::SessionKey::new(format!("welcome-marker::{}", hex::encode(digest)))
886}
887
888/// Synthetic [`ReplyScope`] keyed on `messaging_endpoint_id` so the marker
889/// is partitioned per-endpoint AND disjoint from any real conversation
890/// scope. Returns `None` when the envelope lacks an eid — the marker has
891/// no meaningful bucket then, and the caller exits early.
892fn welcome_marker_scope(envelope: &IngressEnvelope) -> Option<greentic_types::ReplyScope> {
893    let eid = envelope.messaging_endpoint_id.as_deref()?;
894    Some(greentic_types::ReplyScope {
895        conversation: format!("welcome-seen::ep={eid}"),
896        thread: None,
897        reply_to: None,
898        correlation: None,
899    })
900}
901
902/// Minimal `SessionData` for the marker. The store accepts any record
903/// aligned with `(ctx, user)`; the marker carries no flow semantics, so
904/// the placeholder `flow_id`/`pack_id` is fixed and validates as an
905/// identifier (ascii + `.`/`-`/`_`).
906fn marker_session_data(
907    ctx: &greentic_types::TenantCtx,
908    user: &greentic_types::UserId,
909) -> greentic_session::SessionData {
910    use std::str::FromStr;
911    use std::sync::LazyLock;
912    static FLOW_ID: LazyLock<greentic_types::FlowId> =
913        LazyLock::new(|| greentic_types::FlowId::from_str("welcome-marker").expect("valid id"));
914    static PACK_ID: LazyLock<greentic_types::PackId> =
915        LazyLock::new(|| greentic_types::PackId::from_str("welcome-marker").expect("valid id"));
916    let cursor = greentic_types::SessionCursor::new("marker".to_string());
917    let ctx = ctx.clone().with_user(Some(user.clone()));
918    greentic_session::SessionData {
919        tenant_ctx: ctx,
920        flow_id: FLOW_ID.clone(),
921        pack_id: Some(PACK_ID.clone()),
922        cursor,
923        context_json: "{}".to_string(),
924    }
925}
926
927fn apply_fast2flow_routing(
928    runtime: &TenantRuntime,
929    tenant: &str,
930    activity: Activity,
931) -> Result<Activity> {
932    let config = &runtime.config().fast2flow;
933    if !config.enabled || activity.flow_id().is_some() {
934        return Ok(activity);
935    }
936    apply_fast2flow_routing_enabled(runtime, tenant, activity, config)
937}
938
939#[cfg(feature = "greentic-x-provider")]
940fn apply_fast2flow_routing_enabled(
941    runtime: &TenantRuntime,
942    tenant: &str,
943    activity: Activity,
944    config: &Fast2FlowRoutingConfig,
945) -> Result<Activity> {
946    let Some(text) = activity.payload().get("text").and_then(Value::as_str) else {
947        return Ok(activity);
948    };
949    if text.trim().is_empty() {
950        return Ok(activity);
951    }
952
953    let mut envelope = Fast2FlowMessageEnvelope::new(text.trim().to_owned());
954    if let Some(channel) = activity.channel() {
955        envelope = envelope.with_channel(channel.to_owned());
956    }
957    if let Some(provider) = activity.provider_id() {
958        envelope = envelope.with_provider(provider.to_owned());
959    }
960    let request = Fast2FlowRouteRequest {
961        scope: config.scope.clone().unwrap_or_else(|| tenant.to_owned()),
962        envelope,
963        session_active: activity.session_id().is_some(),
964        input_locale: "en".to_owned(),
965        time_budget_ms: config.time_budget_ms,
966        registry_path: config.registry_path.clone(),
967        indexes_path: config.indexes_path.clone(),
968        now_unix_ms: chrono::Utc::now().timestamp_millis().max(0) as u64,
969        metadata: Default::default(),
970    };
971    let provider = RunnerPackFast2FlowRoutingProvider::new(runtime.pack())
972        .map_err(|err| anyhow!(err.to_string()))?
973        .with_component_ref(config.component_ref.clone())
974        .with_operation(config.operation.clone())
975        .with_tenant(tenant.to_owned());
976    let route = provider
977        .route_intent(request)
978        .map_err(|err| anyhow!(err.to_string()))?;
979
980    match route.directive {
981        Fast2FlowDirective::Continue => Ok(activity),
982        Fast2FlowDirective::Dispatch {
983            target, entities, ..
984        } => apply_fast2flow_target(activity, &target, entities),
985        Fast2FlowDirective::Respond { message } => Ok(Activity::custom(
986            "response",
987            serde_json::json!({ "messages": [{ "text": message }] }),
988        )
989        .ensure_tenant(tenant)),
990        Fast2FlowDirective::Deny { reason } => Ok(Activity::custom(
991            "response",
992            serde_json::json!({ "messages": [{ "text": reason }] }),
993        )
994        .ensure_tenant(tenant)),
995    }
996}
997
998#[cfg(not(feature = "greentic-x-provider"))]
999fn apply_fast2flow_routing_enabled(
1000    _runtime: &TenantRuntime,
1001    _tenant: &str,
1002    _activity: Activity,
1003    _config: &Fast2FlowRoutingConfig,
1004) -> Result<Activity> {
1005    bail!("fast2flow routing requires the greentic-x-provider feature")
1006}
1007
1008#[cfg(feature = "greentic-x-provider")]
1009fn apply_fast2flow_target(
1010    activity: Activity,
1011    target: &str,
1012    entities: Vec<greentic_x_runtime::Fast2FlowRoutingEntity>,
1013) -> Result<Activity> {
1014    let target = target.trim();
1015    if target.is_empty() {
1016        bail!("fast2flow dispatch target is empty");
1017    }
1018    if let Some((pack_id, flow_id)) = target.split_once('/') {
1019        if pack_id.trim().is_empty() || flow_id.trim().is_empty() {
1020            bail!("fast2flow dispatch target `{target}` must be `pack_id/flow_id` or `flow_id`");
1021        }
1022        return Ok(attach_fast2flow_entities(
1023            activity.with_pack(pack_id.trim()).with_flow(flow_id.trim()),
1024            entities,
1025        ));
1026    }
1027    Ok(attach_fast2flow_entities(
1028        activity.with_flow(target),
1029        entities,
1030    ))
1031}
1032
1033#[cfg(feature = "greentic-x-provider")]
1034fn attach_fast2flow_entities(
1035    activity: Activity,
1036    entities: Vec<greentic_x_runtime::Fast2FlowRoutingEntity>,
1037) -> Activity {
1038    if entities.is_empty() {
1039        return activity;
1040    }
1041    activity.with_payload_field(
1042        "fast2flow",
1043        serde_json::json!({
1044            "entities": entities,
1045        }),
1046    )
1047}
1048
1049fn resolve_flow_id(runtime: &TenantRuntime, activity: &Activity) -> Result<(String, String)> {
1050    let engine = runtime.engine();
1051    if let Some(flow_id) = activity.flow_id() {
1052        if let Some(pack_id) = activity.pack_id() {
1053            if engine.flow_by_key(pack_id, flow_id).is_none() {
1054                bail!("flow {flow_id} not registered for pack {pack_id}");
1055            }
1056            return Ok((pack_id.to_string(), flow_id.to_string()));
1057        }
1058        if let Some(flow) = engine.flow_by_id(flow_id) {
1059            return Ok((flow.pack_id.clone(), flow.id.clone()));
1060        }
1061        bail!("flow {flow_id} is ambiguous; pack_id is required");
1062    }
1063
1064    if let Some(flow_type) = activity.flow_type() {
1065        if let Some(pack_id) = activity.pack_id() {
1066            if let Some(flow) = engine
1067                .flows()
1068                .iter()
1069                .find(|flow| flow.pack_id == pack_id && flow.flow_type == flow_type)
1070            {
1071                return Ok((pack_id.to_string(), flow.id.clone()));
1072            }
1073            bail!("flow type {flow_type} not registered for pack {pack_id}");
1074        }
1075        if let Some(flow) = engine.flow_by_type(flow_type) {
1076            return Ok((flow.pack_id.clone(), flow.id.clone()));
1077        }
1078        // More than one flow of this type exists. Provider ingress is routed by
1079        // type alone (no flow_id/pack_id), so a pack with one public entrypoint
1080        // plus internal helper flows of the same type would otherwise be
1081        // rejected as ambiguous. Narrow to entrypoint flows before giving up:
1082        // internal flows are only reachable via `flow.call` and must never be
1083        // selected for an inbound event.
1084        if let Some(flow) = engine.entry_flow_by_type(flow_type) {
1085            return Ok((flow.pack_id.clone(), flow.id.clone()));
1086        }
1087        bail!("flow type {flow_type} is ambiguous; pack_id is required");
1088    }
1089
1090    let pack = runtime.pack();
1091    let flow_id = pack
1092        .metadata()
1093        .entry_flows
1094        .first()
1095        .cloned()
1096        .ok_or_else(|| anyhow!("no entry flows registered for tenant {}", runtime.tenant()))?;
1097    Ok((pack.metadata().pack_id.clone(), flow_id))
1098}
1099
1100fn normalize_replies(result: Value, tenant: &str) -> Vec<Activity> {
1101    result
1102        .as_array()
1103        .cloned()
1104        .unwrap_or_else(|| vec![result])
1105        .into_iter()
1106        .map(|payload| Activity::from_output(payload, tenant))
1107        .collect()
1108}
1109
1110fn is_pack_archive(path: &Path) -> bool {
1111    path.extension()
1112        .and_then(|ext| ext.to_str())
1113        .map(|ext| ext.eq_ignore_ascii_case("gtpack"))
1114        .unwrap_or(false)
1115}
1116
1117#[cfg(test)]
1118mod welcome_flow_tests {
1119    use super::*;
1120    use crate::engine::runtime::IngressEnvelope;
1121    use crate::runner::engine::{ExecutionState, FlowSnapshot, FlowWait};
1122    use crate::storage::new_session_store;
1123    use greentic_types::ReplyScope;
1124    use serde_json::json;
1125
1126    fn sample_envelope(endpoint_id: Option<&str>) -> IngressEnvelope {
1127        sample_envelope_for_user(endpoint_id, "user-1")
1128    }
1129
1130    fn sample_envelope_for_user(endpoint_id: Option<&str>, user: &str) -> IngressEnvelope {
1131        IngressEnvelope {
1132            tenant: "demo".into(),
1133            env: Some("local".into()),
1134            pack_id: Some("pack.default".into()),
1135            flow_id: "flow.default".into(),
1136            flow_type: Some("messaging".into()),
1137            action: Some("messaging".into()),
1138            session_hint: None,
1139            provider: Some("teams".into()),
1140            messaging_endpoint_id: endpoint_id.map(String::from),
1141            channel: Some("chan".into()),
1142            conversation: Some(format!("conv-{user}")),
1143            user: Some(user.to_string()),
1144            activity_id: None,
1145            timestamp: None,
1146            payload: json!({}),
1147            metadata: None,
1148            reply_scope: Some(ReplyScope {
1149                conversation: format!("conv-{user}"),
1150                thread: None,
1151                reply_to: None,
1152                correlation: None,
1153            }),
1154        }
1155        .canonicalize()
1156    }
1157
1158    fn hint() -> WelcomeFlowHint {
1159        WelcomeFlowHint {
1160            pack_id: "pack.welcome".into(),
1161            flow_id: "flow.welcome".into(),
1162        }
1163    }
1164
1165    fn seed_resume(store: &DynSessionStore, envelope: &IngressEnvelope) {
1166        // Plant a snapshot in the exact bucket `fetch` would query so the
1167        // next call resolves to a resume — proves the override skips when a
1168        // session already exists.
1169        let resume = FlowResumeStore::new(Arc::clone(store));
1170        let state: ExecutionState = serde_json::from_value(json!({
1171            "input": { "text": "hi" },
1172            "nodes": {},
1173            "egress": []
1174        }))
1175        .expect("state");
1176        let wait = FlowWait {
1177            reason: Some("await-user".into()),
1178            snapshot: FlowSnapshot {
1179                pack_id: envelope.pack_id.clone().expect("pack_id"),
1180                flow_id: envelope.flow_id.clone(),
1181                next_flow: None,
1182                next_node: "node-2".into(),
1183                state,
1184            },
1185        };
1186        resume.save(envelope, &wait).expect("seed save");
1187    }
1188
1189    #[test]
1190    fn override_is_no_op_when_hint_absent() {
1191        // Pre-M1.5 producers don't attach a hint — flow resolution must
1192        // stay exactly the same.
1193        let store = new_session_store();
1194        let mut envelope = sample_envelope(Some("teams-legal"));
1195        let before = envelope.clone();
1196        apply_welcome_flow_override(&store, &mut envelope, None, None).expect("ok");
1197        assert_eq!(envelope.pack_id, before.pack_id);
1198        assert_eq!(envelope.flow_id, before.flow_id);
1199        assert_eq!(envelope.flow_type, before.flow_type);
1200    }
1201
1202    #[test]
1203    fn override_is_no_op_when_endpoint_id_absent() {
1204        // Non-messaging traffic carries no endpoint id and must never hit
1205        // the welcome-flow path even if the hint is somehow set.
1206        let store = new_session_store();
1207        let mut envelope = sample_envelope(None);
1208        let before = envelope.clone();
1209        apply_welcome_flow_override(&store, &mut envelope, Some(&hint()), Some("welcome".into()))
1210            .expect("ok");
1211        assert_eq!(envelope.pack_id, before.pack_id);
1212        assert_eq!(envelope.flow_id, before.flow_id);
1213    }
1214
1215    #[test]
1216    fn override_swaps_pack_flow_and_threads_flow_type_through() {
1217        // Both axes covered: when the caller pre-resolved the welcome
1218        // flow's type, it lands on the envelope; when the resolver
1219        // returned None (unknown flow in engine), it lands as None and
1220        // downstream resolution defaults take over.
1221        for hint_flow_type in [Some("welcome".to_string()), None] {
1222            let store = new_session_store();
1223            let mut envelope = sample_envelope(Some("teams-legal"));
1224            apply_welcome_flow_override(
1225                &store,
1226                &mut envelope,
1227                Some(&hint()),
1228                hint_flow_type.clone(),
1229            )
1230            .expect("ok");
1231            assert_eq!(envelope.pack_id.as_deref(), Some("pack.welcome"));
1232            assert_eq!(envelope.flow_id, "flow.welcome");
1233            assert_eq!(envelope.flow_type, hint_flow_type);
1234        }
1235    }
1236
1237    #[test]
1238    fn override_is_no_op_on_repeat_turn_with_existing_session() {
1239        // Resume path: an already-active session in the same bucket means
1240        // this isn't first contact. The user must continue on the resumed
1241        // flow, NOT be redirected to the welcome flow.
1242        let store = new_session_store();
1243        let envelope_template = sample_envelope(Some("teams-legal"));
1244        seed_resume(&store, &envelope_template);
1245
1246        let mut envelope = envelope_template.clone();
1247        apply_welcome_flow_override(&store, &mut envelope, Some(&hint()), Some("welcome".into()))
1248            .expect("ok");
1249        assert_eq!(envelope.pack_id, envelope_template.pack_id);
1250        assert_eq!(envelope.flow_id, envelope_template.flow_id);
1251        assert_eq!(envelope.flow_type, envelope_template.flow_type);
1252    }
1253
1254    #[test]
1255    fn override_is_no_op_post_completion_when_marker_present() {
1256        // POST-COMPLETION REGRESSION GUARD (Codex #201): the welcome-seen
1257        // marker is durable and survives flow completion. After welcome
1258        // fires once + the flow finishes (wait cleared), the next turn
1259        // must NOT re-fire welcome — the marker is the gate, not the
1260        // active-wait snapshot.
1261        let store = new_session_store();
1262        let mut first = sample_envelope(Some("teams-legal"));
1263        apply_welcome_flow_override(&store, &mut first, Some(&hint()), Some("welcome".into()))
1264            .expect("first turn ok");
1265        assert_eq!(
1266            first.pack_id.as_deref(),
1267            Some("pack.welcome"),
1268            "first turn fires welcome"
1269        );
1270
1271        // Simulate the welcome flow completing: the engine clears the
1272        // wait at end-of-flow (mirror `FlowResumeStore::clear`). The
1273        // marker must NOT be in the wait scope, so this clear has no
1274        // effect on the marker.
1275        let resume = FlowResumeStore::new(Arc::clone(&store));
1276        resume.clear(&first).expect("clear post-completion wait");
1277
1278        // Second turn arrives — producer still attaches the hint (it
1279        // does not know flow-completion happened). The marker keeps the
1280        // override off.
1281        let mut second = sample_envelope(Some("teams-legal"));
1282        apply_welcome_flow_override(&store, &mut second, Some(&hint()), Some("welcome".into()))
1283            .expect("second turn ok");
1284        assert_eq!(
1285            second.pack_id.as_deref(),
1286            Some("pack.default"),
1287            "second turn must NOT re-fire welcome"
1288        );
1289        assert_eq!(second.flow_id, "flow.default");
1290    }
1291
1292    #[test]
1293    fn override_is_no_op_on_second_turn_after_marker_set() {
1294        // No-wait variant of the post-completion test: a welcome flow
1295        // without `session.wait` leaves no snapshot AT ALL. Marker is the
1296        // only thing standing between turn 2 and a welcome re-fire.
1297        let store = new_session_store();
1298        let mut first = sample_envelope(Some("teams-legal"));
1299        apply_welcome_flow_override(&store, &mut first, Some(&hint()), Some("welcome".into()))
1300            .expect("first turn ok");
1301        assert_eq!(first.pack_id.as_deref(), Some("pack.welcome"));
1302
1303        let mut second = sample_envelope(Some("teams-legal"));
1304        apply_welcome_flow_override(&store, &mut second, Some(&hint()), Some("welcome".into()))
1305            .expect("second turn ok");
1306        assert_eq!(
1307            second.pack_id.as_deref(),
1308            Some("pack.default"),
1309            "second turn must NOT re-fire welcome"
1310        );
1311    }
1312
1313    #[test]
1314    fn override_partitions_marker_per_endpoint() {
1315        // The marker is keyed by `(tenant, env, eid, user)` — a user
1316        // marked seen on `teams-legal` is still first contact on
1317        // `teams-accounting`. Welcome must fire independently on each
1318        // endpoint.
1319        let store = new_session_store();
1320        let mut legal = sample_envelope(Some("teams-legal"));
1321        apply_welcome_flow_override(&store, &mut legal, Some(&hint()), Some("welcome".into()))
1322            .expect("legal first turn ok");
1323        assert_eq!(legal.pack_id.as_deref(), Some("pack.welcome"));
1324
1325        let mut accounting = sample_envelope(Some("teams-accounting"));
1326        apply_welcome_flow_override(
1327            &store,
1328            &mut accounting,
1329            Some(&hint()),
1330            Some("welcome".into()),
1331        )
1332        .expect("accounting first turn ok");
1333        assert_eq!(
1334            accounting.pack_id.as_deref(),
1335            Some("pack.welcome"),
1336            "different endpoint = independent first contact"
1337        );
1338    }
1339
1340    #[test]
1341    fn override_partitions_marker_per_user_on_same_endpoint() {
1342        // Codex adversarial review of #382 (high): a session-key derived
1343        // only from the eid collapses every user on that endpoint onto one
1344        // store row — in-memory rejects User B's first turn with a hard
1345        // error, Redis silently overwrites and lets User A re-welcome on
1346        // their next turn.
1347        //
1348        // Regression guard: two users on the same eid each get welcome on
1349        // their own first turn; the second user's first contact does NOT
1350        // fail; both subsequent turns are no-ops.
1351        let store = new_session_store();
1352
1353        // User A's first turn
1354        let mut a1 = sample_envelope_for_user(Some("teams-legal"), "user-a");
1355        apply_welcome_flow_override(&store, &mut a1, Some(&hint()), Some("welcome".into()))
1356            .expect("user-a first ok");
1357        assert_eq!(a1.pack_id.as_deref(), Some("pack.welcome"));
1358
1359        // User B's first turn — must independently fire welcome, NOT error.
1360        let mut b1 = sample_envelope_for_user(Some("teams-legal"), "user-b");
1361        apply_welcome_flow_override(&store, &mut b1, Some(&hint()), Some("welcome".into()))
1362            .expect("user-b first must not collide with user-a marker");
1363        assert_eq!(
1364            b1.pack_id.as_deref(),
1365            Some("pack.welcome"),
1366            "user-b is independent first contact"
1367        );
1368
1369        // User A's second turn — marker still intact, no re-fire.
1370        let mut a2 = sample_envelope_for_user(Some("teams-legal"), "user-a");
1371        apply_welcome_flow_override(&store, &mut a2, Some(&hint()), Some("welcome".into()))
1372            .expect("user-a second ok");
1373        assert_eq!(
1374            a2.pack_id.as_deref(),
1375            Some("pack.default"),
1376            "user-a must not be re-welcomed after user-b joined"
1377        );
1378
1379        // User B's second turn — same.
1380        let mut b2 = sample_envelope_for_user(Some("teams-legal"), "user-b");
1381        apply_welcome_flow_override(&store, &mut b2, Some(&hint()), Some("welcome".into()))
1382            .expect("user-b second ok");
1383        assert_eq!(b2.pack_id.as_deref(), Some("pack.default"));
1384    }
1385
1386    #[test]
1387    fn marker_is_not_written_when_hint_absent() {
1388        // Marker writes are gated on the hint+eid preconditions — a
1389        // pre-M1.5 turn (no hint) MUST NOT leak a marker, otherwise a
1390        // producer that later enables welcome would treat that user as
1391        // already-contacted and never fire the override.
1392        //
1393        // Mid-conversation users (active-wait safety net path) DO get a
1394        // marker — that's deliberate: they don't get retroactive welcomes.
1395        // This test only guards the no-hint gate.
1396        let store = new_session_store();
1397        let mut envelope = sample_envelope(Some("teams-legal"));
1398        apply_welcome_flow_override(&store, &mut envelope, None, None).expect("ok");
1399
1400        let mut next = sample_envelope(Some("teams-legal"));
1401        apply_welcome_flow_override(&store, &mut next, Some(&hint()), Some("welcome".into()))
1402            .expect("ok");
1403        assert_eq!(
1404            next.pack_id.as_deref(),
1405            Some("pack.welcome"),
1406            "no marker leaked from hint-absent path"
1407        );
1408    }
1409}
1410
1411#[cfg(test)]
1412mod identify_endpoints_tests {
1413    use super::*;
1414
1415    fn dummy_runner_host() -> RunnerHost {
1416        let session_store = new_session_store();
1417        let state_store = new_state_store();
1418        RunnerHost {
1419            configs: HashMap::new(),
1420            active: Arc::new(ActivePacks::new()),
1421            health: Arc::new(HealthState::new()),
1422            session_host: session_host_from(session_store.clone()),
1423            state_host: state_host_from(state_store.clone()),
1424            session_store,
1425            state_store,
1426            wasi_policy: Arc::new(RunnerWasiPolicy::new()),
1427            secrets_manager: default_manager().expect("default secrets manager"),
1428            telemetry: None,
1429        }
1430    }
1431
1432    #[tokio::test]
1433    async fn empty_provider_types_returns_empty_map_without_loading_revision() {
1434        // No revision is loaded; this proves the fast-path short-circuits
1435        // before `load_revision` so the caller can ask "any types?" cheaply
1436        // when an env declares zero messaging endpoints.
1437        let host = dummy_runner_host();
1438        let map = host
1439            .identify_messaging_endpoints_for_revision(
1440                "demo",
1441                DeploymentId::new(),
1442                BundleId::new("anything"),
1443                RevisionId::new(),
1444                &[],
1445                b"{}",
1446            )
1447            .await
1448            .expect("empty types is the cheap fast path");
1449        assert!(map.is_empty());
1450    }
1451
1452    #[tokio::test]
1453    async fn missing_revision_surfaces_clear_error() {
1454        // Non-empty types but the revision was never loaded — the error
1455        // chain must name the revision so operators can correlate it with
1456        // their dispatch log.
1457        let host = dummy_runner_host();
1458        let deployment = DeploymentId::new();
1459        let revision = RevisionId::new();
1460        let err = host
1461            .identify_messaging_endpoints_for_revision(
1462                "demo",
1463                deployment,
1464                BundleId::new("missing"),
1465                revision,
1466                &["teams"],
1467                b"{}",
1468            )
1469            .await
1470            .expect_err("missing revision must fail closed");
1471        let msg = format!("{err:#}");
1472        assert!(
1473            msg.contains("revision runtime not loaded"),
1474            "error chain should name the failure mode, got: {msg}"
1475        );
1476        assert!(
1477            msg.contains(&deployment.to_string()),
1478            "error chain should name the deployment id, got: {msg}"
1479        );
1480        assert!(
1481            msg.contains(&revision.to_string()),
1482            "error chain should name the revision id, got: {msg}"
1483        );
1484    }
1485
1486    #[tokio::test]
1487    async fn scoped_empty_provider_types_returns_empty_map() {
1488        let host = dummy_runner_host();
1489        let map = host
1490            .identify_messaging_endpoints_for_revision_scoped(
1491                "demo",
1492                DeploymentId::new(),
1493                BundleId::new("anything"),
1494                RevisionId::new(),
1495                &[],
1496                &[],
1497                &Value::Null,
1498            )
1499            .await
1500            .expect("empty types is the cheap fast path");
1501        assert!(map.is_empty());
1502    }
1503
1504    #[tokio::test]
1505    async fn scoped_missing_revision_surfaces_clear_error() {
1506        let host = dummy_runner_host();
1507        let deployment = DeploymentId::new();
1508        let revision = RevisionId::new();
1509        let err = host
1510            .identify_messaging_endpoints_for_revision_scoped(
1511                "demo",
1512                deployment,
1513                BundleId::new("missing"),
1514                revision,
1515                &["teams"],
1516                &[],
1517                &Value::Null,
1518            )
1519            .await
1520            .expect_err("missing revision must fail closed");
1521        let msg = format!("{err:#}");
1522        assert!(
1523            msg.contains("revision runtime not loaded"),
1524            "error chain should name the failure mode, got: {msg}"
1525        );
1526        assert!(
1527            msg.contains(&deployment.to_string()),
1528            "error chain should name the deployment id, got: {msg}"
1529        );
1530        assert!(
1531            msg.contains(&revision.to_string()),
1532            "error chain should name the revision id, got: {msg}"
1533        );
1534    }
1535
1536    /// Regression guard: the futures returned by the per-revision identify
1537    /// APIs MUST be `Send`. Downstream consumers (greentic-start's hyper
1538    /// `service_fn`) spawn them through tokio; a non-`Send` future at this
1539    /// boundary breaks every spawned-service consumer with a confusing
1540    /// "implementation of Send is not general enough" diagnostic that
1541    /// surfaces far from the offending change.
1542    ///
1543    /// Concrete history: PR #394 routed all three identify entry points
1544    /// through a shared `fan_out_across_packs` helper bounded on
1545    /// `AsyncFnMut`. The HRTB inference for the resulting future
1546    /// destabilised `Send` proof for all three APIs, even the legacy
1547    /// `identify_messaging_endpoints_for_revision` that itself hadn't
1548    /// changed shape — greentic-start failed to compile on the next
1549    /// dev-publish bump. This test would have caught it.
1550    #[test]
1551    fn identify_futures_are_send() {
1552        fn assert_send<F: Send>(_: F) {}
1553        let host = dummy_runner_host();
1554        // Each call has to be wrapped in its own scope so the borrows
1555        // don't outlive the host's reference per call — the point is to
1556        // assert each returned future type is Send-clean in isolation.
1557        assert_send(host.identify_messaging_endpoints_for_revision(
1558            "demo",
1559            DeploymentId::new(),
1560            BundleId::new("anything"),
1561            RevisionId::new(),
1562            &["teams"],
1563            b"{}",
1564        ));
1565        assert_send(host.identify_messaging_endpoints_for_revision_scoped(
1566            "demo",
1567            DeploymentId::new(),
1568            BundleId::new("anything"),
1569            RevisionId::new(),
1570            &["teams"],
1571            &[("x-telegram-bot-api-secret-token".into(), "tok".into())],
1572            &Value::Null,
1573        ));
1574        assert_send(host.describe_identify_instances_for_revision(
1575            "demo",
1576            DeploymentId::new(),
1577            BundleId::new("anything"),
1578            RevisionId::new(),
1579            &["teams"],
1580        ));
1581        assert_send(host.invoke_provider_for_revision(
1582            "demo",
1583            DeploymentId::new(),
1584            BundleId::new("anything"),
1585            RevisionId::new(),
1586            "messaging.telegram.bot",
1587            "ingest_http",
1588            b"{}".to_vec(),
1589            None,
1590            None,
1591        ));
1592    }
1593
1594    #[tokio::test]
1595    async fn invoke_provider_missing_revision_surfaces_clear_error() {
1596        // Non-empty types but the revision was never loaded — the error
1597        // chain must name the revision so operators can correlate it with
1598        // their dispatch log. Mirrors the identify-side sibling so a future
1599        // refactor can't quietly drop the lookup-context wrapper.
1600        let host = dummy_runner_host();
1601        let deployment = DeploymentId::new();
1602        let revision = RevisionId::new();
1603        let err = host
1604            .invoke_provider_for_revision(
1605                "demo",
1606                deployment,
1607                BundleId::new("missing"),
1608                revision,
1609                "messaging.telegram.bot",
1610                "ingest_http",
1611                b"{}".to_vec(),
1612                None,
1613                None,
1614            )
1615            .await
1616            .expect_err("missing revision must fail closed");
1617        let msg = format!("{err:#}");
1618        assert!(
1619            msg.contains("revision runtime not loaded"),
1620            "error chain should name the failure mode, got: {msg}"
1621        );
1622        assert!(
1623            msg.contains(&deployment.to_string()),
1624            "error chain should name the deployment id, got: {msg}"
1625        );
1626        assert!(
1627            msg.contains(&revision.to_string()),
1628            "error chain should name the revision id, got: {msg}"
1629        );
1630    }
1631}
1632
1633#[cfg(all(test, feature = "greentic-x-provider"))]
1634mod fast2flow_tests {
1635    use greentic_x_runtime::Fast2FlowRoutingEntity;
1636
1637    use super::*;
1638
1639    #[test]
1640    fn dispatch_target_attaches_prefill_entities_to_payload() {
1641        let activity = Activity::text("show traffic tomorrow");
1642        let routed = apply_fast2flow_target(
1643            activity,
1644            "telco-x/prefix-traffic",
1645            vec![Fast2FlowRoutingEntity::new("date", "20260611").with_format("iso", "2026-06-11")],
1646        )
1647        .expect("target should route");
1648
1649        assert_eq!(routed.pack_id(), Some("telco-x"));
1650        assert_eq!(routed.flow_id(), Some("prefix-traffic"));
1651        assert_eq!(
1652            routed.payload()["fast2flow"]["entities"][0]["normalized"],
1653            "20260611"
1654        );
1655        assert_eq!(
1656            routed.payload()["fast2flow"]["entities"][0]["formats"]["iso"],
1657            "2026-06-11"
1658        );
1659    }
1660}