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