Skip to main content

chat_engine/
module.rs

1//! `ChatEngineModule` — Phase 15 integration entrypoint.
2//!
3//! Wires the per-feature services produced by Phases 1-13, mounts the
4//! REST surface assembled by Phase 14, and runs the retention-cleanup
5//! background task on a `tokio::time::interval` driven by a
6//! `CancellationToken`.
7//!
8//! # Topology
9//!
10//! ```text
11//!  GearCtx ─▶ ChatEngineModule::new() (deferred wiring lives in init())
12//!                  │
13//!                  ├── ChatEngineConfig ─ validated
14//!                  ├── toolkit-db Db ── sea_orm::DatabaseConnection
15//!                  ├── SeaORM repos     (session, message, reaction, plugin_config,
16//!                  │                     session_type)
17//!                  ├── ClientHub        (registers LlmGatewayPlugin + WebhookCompatPlugin
18//!                  │                     under `ChatEngineBackendPlugin`)
19//!                  ├── domain services  (PluginService, SessionService, MessageService,
20//!                  │                     VariantService, IntelligenceService,
21//!                  │                     ReactionService, SearchService, ExportService)
22//!                  └── REST router      (api::rest::register_routes + Extension DI)
23//!
24//!  serve(cancel, ready)
25//!     ├── spawn retention-cleanup task (tokio::time::interval)
26//!     ├── ready.notify()
27//!     └── await cancel.cancelled() → graceful shutdown
28//! ```
29//
30// @cpt-cf-chat-engine-module-registration:p15
31// @cpt-cf-chat-engine-module-lifecycle:p15
32
33use std::sync::{Arc, OnceLock};
34use std::time::Duration;
35
36use async_trait::async_trait;
37use axum::Router;
38use sea_orm_migration::MigrationTrait;
39use tokio_util::sync::CancellationToken;
40use toolkit::api::OpenApiRegistry;
41use toolkit::api::canonical_error_middleware;
42use toolkit::client_hub::ClientScope;
43use toolkit::{DatabaseCapability, Gear, GearCtx, RestApiCapability};
44use toolkit_db::DBProvider;
45use tracing::{error, info, warn};
46
47use crate::infra::db::repo::ChatEngineDb;
48
49use chat_engine_sdk::plugin::ChatEngineBackendPlugin;
50
51use crate::api::rest::routes::ChatEngineServices;
52use crate::api::rest::{NoopWebhookEmitter, WebhookEmitter, WebhookEmitterAdapter};
53use crate::config::ChatEngineConfig;
54use crate::domain::export::NotImplementedExportStorage;
55use crate::domain::service::webhook::WebhookEmitter as DomainWebhookEmitter;
56use crate::domain::service::{
57    ExportService, IntelligenceService, MessageService, PluginService, ReactionService,
58    SearchService, SessionService, ShareUrlBuilder, VariantService,
59};
60use crate::infra::db::migrations::Migrator;
61use crate::infra::db::repo::message_repo::SeaMessageRepo;
62use crate::infra::db::repo::plugin_config_repo::SeaPluginConfigRepo;
63use crate::infra::db::repo::reaction_repo::SeaReactionRepo;
64use crate::infra::db::repo::session_repo::SeaSessionRepo;
65use crate::infra::db::repo::session_type_repo::SeaSessionTypeRepo;
66use crate::infra::leader::{LeaderElector, work_fn};
67use crate::infra::llm_gateway::LlmGatewayPlugin;
68use crate::infra::search::NotImplementedSearchBackend;
69use crate::infra::webhook_compat::WebhookCompatPlugin;
70
71/// GTS plugin instance ID used to register the default `WebhookCompatPlugin`
72/// instance. Operators that want multiple webhook bindings can register
73/// additional `WebhookCompatPlugin` instances themselves; the default one
74/// is keyed under this stable id.
75pub const DEFAULT_WEBHOOK_COMPAT_INSTANCE_ID: &str = "gtx.cf.chat_engine.webhook_compat_plugin.v1~";
76
77/// Aggregated runtime state filled in during [`Gear::init`].
78struct RuntimeState {
79    services: ChatEngineServices,
80    webhooks: Arc<dyn WebhookEmitter>,
81    intelligence: Arc<IntelligenceService>,
82    /// Resume buffer (FR-024) shared between the streaming driver (writer) and
83    /// the `Last-Event-ID` reconnect handler (reader); also swept by the TTL
84    /// cleanup loop.
85    stream_buffer: Arc<dyn crate::domain::ports::StreamEventBuffer>,
86    config: Arc<ChatEngineConfig>,
87    /// Leader elector gating the retention-cleanup loop so only one replica
88    /// sweeps at a time under horizontal scaling
89    /// (`@cpt-cf-chat-engine-adr-stateless-scaling`). Single-process / non-k8s
90    /// builds use the noop elector (always leader).
91    leader: Arc<dyn LeaderElector>,
92}
93
94/// How often the resume-buffer TTL sweep runs (FR-024). The buffer's TTL is
95/// `RESUME_BUFFER_TTL` (minutes), so a few-minute cadence keeps expired rows
96/// from accumulating without competing with the hours-scale retention loop.
97const STREAM_BUFFER_SWEEP_PERIOD: Duration = Duration::from_mins(5);
98
99/// Chat Engine module entrypoint.
100///
101/// Construction is two-phased so the macro-generated registrator can
102/// instantiate the struct with `ChatEngineModule::new()` before
103/// [`Gear::init`] runs. All runtime handles live behind a
104/// [`OnceLock`] that is populated inside `init()` once the
105/// `GearCtx` is available.
106#[toolkit::gear(
107    name = "chat-engine",
108    capabilities = [db, rest, stateful],
109    client = chat_engine_sdk::ChatEngineBackendPlugin,
110    ctor = ChatEngineModule::new(),
111    lifecycle(entry = "serve", stop_timeout = "30s", await_ready)
112)]
113pub struct ChatEngineModule {
114    runtime: OnceLock<RuntimeState>,
115}
116
117impl Default for ChatEngineModule {
118    fn default() -> Self {
119        Self::new()
120    }
121}
122
123impl ChatEngineModule {
124    /// Construct an uninitialised module. The macro-generated registrator
125    /// uses this at link time; production wiring (config load, repo /
126    /// service construction, ClientHub registration) runs in
127    /// [`Gear::init`].
128    #[must_use]
129    pub fn new() -> Self {
130        Self {
131            runtime: OnceLock::new(),
132        }
133    }
134
135    fn runtime(&self) -> anyhow::Result<&RuntimeState> {
136        self.runtime
137            .get()
138            .ok_or_else(|| anyhow::anyhow!("ChatEngineModule not initialised"))
139    }
140
141    /// Lifecycle entry — periodic retention cleanup, gated by leader election.
142    ///
143    /// The per-tenant sweep is wrapped in
144    /// [`LeaderElector::run_role`](crate::infra::leader::LeaderElector::run_role)
145    /// so that under horizontal scaling
146    /// (`@cpt-cf-chat-engine-adr-stateless-scaling`) only the lease holder
147    /// runs it — every replica running the full sweep concurrently would
148    /// duplicate work, and the per-tenant advisory lock the cleanup relies on
149    /// is a no-op on the SQLite path. Single-process / non-k8s builds use the
150    /// noop elector (always leader), preserving the original behaviour.
151    ///
152    /// `ready.notify()` fires before entering `run_role`: readiness gates
153    /// dependent gears, not leadership, so a follower replica that never wins
154    /// the lease is still "ready". On lease loss the work token is cancelled
155    /// mid-loop and the elector re-campaigns; `cancel` (gear shutdown) tears
156    /// the whole thing down.
157    pub async fn serve(
158        self: Arc<Self>,
159        cancel: CancellationToken,
160        ready: toolkit::lifecycle::ReadySignal,
161    ) -> anyhow::Result<()> {
162        let runtime = self.runtime()?;
163        let interval_hours = runtime.config.retention_cleanup_interval_hours;
164        let period = Duration::from_secs(interval_hours.saturating_mul(3600));
165        let intelligence = Arc::clone(&runtime.intelligence);
166        let stream_buffer = Arc::clone(&runtime.stream_buffer);
167        let leader = Arc::clone(&runtime.leader);
168
169        ready.notify();
170        info!(
171            interval_hours,
172            "chat-engine retention-cleanup task running (leader-gated)"
173        );
174
175        leader
176            .run_role(
177                "retention-cleanup",
178                cancel,
179                work_fn(move |cancel| {
180                    let intelligence = Arc::clone(&intelligence);
181                    let stream_buffer = Arc::clone(&stream_buffer);
182                    async move {
183                        let mut interval = tokio::time::interval(period);
184                        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
185                        // Skip the immediate tick that `tokio::time::interval`
186                        // fires synchronously — the first cleanup runs one
187                        // period after acquiring leadership, not instantly.
188                        interval.tick().await;
189
190                        // Resume-buffer TTL sweep (FR-024) on its own short
191                        // cadence: the buffer's TTL is minutes, so it is swept
192                        // far more often than the hours-scale retention loop.
193                        // Shares the leader gate — the buffer is one shared DB
194                        // table, so a single sweeper suffices.
195                        let mut sweep = tokio::time::interval(STREAM_BUFFER_SWEEP_PERIOD);
196                        sweep.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
197                        sweep.tick().await;
198
199                        loop {
200                            tokio::select! {
201                                () = cancel.cancelled() => {
202                                    info!(
203                                        "chat-engine retention-cleanup loop stopping \
204                                         (leadership lost or shutdown)"
205                                    );
206                                    return Ok(());
207                                }
208                                _ = interval.tick() => {
209                                    if let Err(err) =
210                                        run_retention_cleanup_tick(intelligence.as_ref()).await
211                                    {
212                                        error!(
213                                            error = %err,
214                                            "chat-engine retention-cleanup tick failed; continuing",
215                                        );
216                                    }
217                                }
218                                _ = sweep.tick() => {
219                                    run_stream_buffer_sweep_tick(stream_buffer.as_ref()).await;
220                                }
221                            }
222                        }
223                    }
224                }),
225            )
226            .await
227    }
228}
229
230/// Single retention-cleanup tick.
231///
232/// Enumerates every tenant that currently owns an `active` session via
233/// [`IntelligenceService::run_retention_cleanup_all_tenants`] and runs
234/// the per-tenant cleanup against each. The session repository is the
235/// source of truth for the tenant directory, so the tick activates
236/// retention for real traffic — no sentinel / marker placeholder.
237async fn run_retention_cleanup_tick(intelligence: &IntelligenceService) -> anyhow::Result<()> {
238    let report = intelligence.run_retention_cleanup_all_tenants().await?;
239    info!(
240        sessions_scanned = report.sessions.len(),
241        sessions_skipped_locked = report.skipped_count(),
242        total_messages_deleted = report.total_messages_deleted(),
243        "chat-engine retention-cleanup tick completed"
244    );
245    Ok(())
246}
247
248/// Single resume-buffer TTL sweep tick (FR-024): delete every event past its
249/// `expires_at`. Best-effort — a failed sweep is logged and the loop continues;
250/// expired rows are simply collected on the next tick. The buffer is short-TTL
251/// reconnect scratch, never durable history, so a missed sweep is harmless.
252async fn run_stream_buffer_sweep_tick(buffer: &dyn crate::domain::ports::StreamEventBuffer) {
253    match buffer.delete_expired(time::OffsetDateTime::now_utc()).await {
254        Ok(removed) if removed > 0 => {
255            info!(
256                removed,
257                "chat-engine stream-buffer TTL sweep removed expired events"
258            );
259        }
260        Ok(_) => {}
261        Err(err) => {
262            warn!(error = %err, "chat-engine stream-buffer TTL sweep failed; continuing");
263        }
264    }
265}
266
267/// Construct the leader elector that gates the retention-cleanup loop.
268///
269/// With the `k8s` feature, uses a `coordination.k8s.io/v1` Lease keyed under
270/// `chat-engine-{role}` (requires `POD_NAMESPACE` / `POD_NAME` + kube client
271/// access). Otherwise a noop elector that is always leader — correct for
272/// single-process / on-prem deployments. Mirrors mini-chat's
273/// `background_workers::create_leader_elector`.
274#[allow(
275    clippy::unused_async,
276    reason = "async is needed when the k8s feature is enabled"
277)]
278async fn create_leader_elector() -> anyhow::Result<Arc<dyn LeaderElector>> {
279    #[cfg(feature = "k8s")]
280    {
281        use crate::infra::leader::k8s_lease::{K8sLeaseConfig, K8sLeaseElector};
282        use anyhow::Context as _;
283
284        let config = K8sLeaseConfig::from_env("chat-engine")
285            .context("k8s feature enabled: POD_NAMESPACE and POD_NAME are required")?;
286        let elector = K8sLeaseElector::from_default(config)
287            .await
288            .context("k8s feature enabled: kube client init failed")?;
289        info!("chat-engine: using k8s Lease leader election");
290        Ok(Arc::new(elector))
291    }
292    #[cfg(not(feature = "k8s"))]
293    {
294        info!("chat-engine: using noop leader election (single-process mode)");
295        Ok(crate::infra::leader::noop())
296    }
297}
298
299#[async_trait]
300impl Gear for ChatEngineModule {
301    async fn init(&self, ctx: &GearCtx) -> anyhow::Result<()> {
302        info!("initialising {} module", Self::MODULE_NAME);
303
304        let cfg: ChatEngineConfig = ctx.config_or_default()?;
305        cfg.validate()
306            .map_err(|e| anyhow::anyhow!("invalid chat-engine config: {e}"))?;
307        let config = Arc::new(cfg);
308
309        // Leader elector gating the retention-cleanup loop (one sweeper per
310        // cluster under horizontal scaling). Constructed up front so a
311        // misconfigured k8s runtime fails init() rather than the serve loop.
312        let leader = create_leader_elector().await?;
313
314        // --- DB wiring ------------------------------------------------------
315        //
316        // Thread the toolkit-db `DBProvider` returned by `ctx.db_required()`
317        // straight into every repo so reads/writes land on the same handle
318        // the migration runner used. Earlier revisions opened a sibling
319        // `sea_orm::DatabaseConnection` from a private `database.dsn`
320        // config key — that path silently fell back to in-memory SQLite
321        // when the key was absent and bypassed toolkit-db's pool sizing,
322        // observability, and SecureConn enforcement. The provider is
323        // reparameterised over `ChatEngineError` so `?` lifts both
324        // `DbError` and `ScopeError` into the crate's domain enum.
325        let db_raw = ctx.db_required()?;
326        let db: Arc<ChatEngineDb> = Arc::new(DBProvider::new(db_raw.db()));
327
328        // --- Repositories ---------------------------------------------------
329        let sessions_repo: Arc<dyn crate::domain::ports::SessionRepo> =
330            Arc::new(SeaSessionRepo::new(Arc::clone(&db)));
331        let session_types_repo: Arc<dyn crate::domain::ports::SessionTypeRepo> =
332            Arc::new(SeaSessionTypeRepo::new(Arc::clone(&db)));
333        let messages_repo: Arc<dyn crate::domain::ports::MessageRepo> =
334            Arc::new(SeaMessageRepo::new(Arc::clone(&db)));
335        let plugin_config_repo: Arc<dyn crate::domain::ports::PluginConfigRepo> =
336            Arc::new(SeaPluginConfigRepo::new(Arc::clone(&db)));
337        let reactions_repo: Arc<dyn crate::domain::ports::ReactionRepo> =
338            Arc::new(SeaReactionRepo::new(Arc::clone(&db)));
339        let variants_repo: Arc<dyn crate::domain::service::VariantRepo> = Arc::new(
340            crate::infra::db::repo::variant_repo::SeaVariantRepo::new(Arc::clone(&db)),
341        );
342
343        // --- ClientHub plugin registration ----------------------------------
344        let client_hub = ctx.client_hub();
345        let webhook_compat = Arc::new(
346            WebhookCompatPlugin::new(DEFAULT_WEBHOOK_COMPAT_INSTANCE_ID)
347                .map_err(|e| anyhow::anyhow!("failed to build webhook-compat plugin: {e}"))?,
348        );
349        client_hub.register_scoped::<dyn ChatEngineBackendPlugin>(
350            ClientScope::gts_id(DEFAULT_WEBHOOK_COMPAT_INSTANCE_ID),
351            webhook_compat.clone() as Arc<dyn ChatEngineBackendPlugin>,
352        );
353
354        // The LLM Gateway plugin's transport clients are owned by Phase 15;
355        // until the production `reqwest`-backed implementations land we
356        // register a stub-friendly variant only when the operator has
357        // explicitly configured `llm_gateway_base_url`. Tests / smoke
358        // bring-up rely on the FakeLlmGatewayClient registered out of
359        // band via ClientHub.
360        if config.llm_gateway_base_url.is_some() {
361            warn!(
362                "llm-gateway plugin instantiation requested but production transport clients \
363                 are not yet wired in this build; the plugin slot remains empty"
364            );
365        }
366        let _ = LlmGatewayPlugin::new; // explicit reference so the unused-import lint stays clean
367
368        // --- Domain services -----------------------------------------------
369        let plugin_service = PluginService::new(client_hub.clone(), plugin_config_repo.clone());
370
371        let webhooks_rest: Arc<dyn WebhookEmitter> = Arc::new(NoopWebhookEmitter::default());
372        let webhooks_domain: Arc<dyn DomainWebhookEmitter> =
373            Arc::new(WebhookEmitterAdapter::new(webhooks_rest.clone()));
374
375        let plugin_deadline = Duration::from_secs(config.plugin_deadline_secs);
376
377        let sessions = Arc::new(
378            SessionService::new(
379                sessions_repo.clone(),
380                session_types_repo.clone(),
381                plugin_service.clone(),
382                webhooks_domain.clone(),
383            )
384            .with_plugin_timeout(plugin_deadline),
385        );
386
387        // Resume buffer (FR-024): the driver tees wire events here so dropped
388        // connections can resume via `Last-Event-ID`.
389        let stream_buffer: Arc<dyn crate::domain::ports::StreamEventBuffer> = Arc::new(
390            crate::infra::db::repo::stream_event_repo::SeaStreamEventBuffer::new(Arc::clone(&db)),
391        );
392
393        let messages = Arc::new(
394            MessageService::new(
395                sessions_repo.clone(),
396                session_types_repo.clone(),
397                messages_repo.clone(),
398                plugin_service.clone(),
399            )
400            .with_webhook_emitter(webhooks_domain.clone())
401            .with_streaming_buffer_size(config.ndjson_buffer_size)
402            .with_plugin_deadline(plugin_deadline)
403            .with_stream_buffer(Arc::clone(&stream_buffer)),
404        );
405
406        let variants = Arc::new(
407            VariantService::new(
408                sessions_repo.clone(),
409                session_types_repo.clone(),
410                messages_repo.clone(),
411                variants_repo.clone(),
412                plugin_service.clone(),
413                Arc::clone(&messages),
414            )
415            .with_plugin_timeout(plugin_deadline),
416        );
417
418        let reactions = Arc::new(ReactionService::new(
419            sessions_repo.clone(),
420            session_types_repo.clone(),
421            messages_repo.clone(),
422            reactions_repo.clone(),
423            plugin_service.clone(),
424        ));
425
426        // Production wiring intentionally uses the not-implemented backend:
427        // the `tsvector` / `LIKE` backends are not yet wired, so enabling
428        // search must surface an honest 501 rather than the in-memory
429        // backend's silent empty result set (RUST-NO-001). Swap for a real
430        // backend here once it lands.
431        let search_backend: Arc<dyn crate::domain::service::SearchBackend> =
432            Arc::new(NotImplementedSearchBackend::new());
433        let search = Arc::new(SearchService::new(
434            sessions_repo.clone(),
435            messages_repo.clone(),
436            search_backend,
437        ));
438
439        let intelligence = Arc::new(
440            IntelligenceService::new(
441                sessions_repo.clone(),
442                session_types_repo.clone(),
443                messages_repo.clone(),
444                plugin_service.clone(),
445            )
446            .with_buffer_size(config.summary_buffer_size)
447            .with_summary_deadline(plugin_deadline)
448            .with_retention_caps(
449                config.retention_max_sessions_per_tick,
450                config.retention_max_deletes_per_session,
451            ),
452        );
453
454        let share_urls =
455            config
456                .share_base_url
457                .as_ref()
458                .map_or_else(ShareUrlBuilder::default, |base| ShareUrlBuilder {
459                    base_url: base.clone(),
460                });
461        // Not-implemented until a real object-storage backend is wired:
462        // returns 501 rather than discarding the bytes behind a dead
463        // `memory://` URL (RUST-NO-001).
464        let export_storage = Arc::new(NotImplementedExportStorage);
465        let export = Arc::new(
466            ExportService::new(sessions_repo.clone(), messages_repo.clone(), export_storage)
467                .with_share_urls(share_urls),
468        );
469
470        let services = ChatEngineServices {
471            sessions,
472            messages,
473            variants,
474            reactions,
475            search,
476            intelligence: Arc::clone(&intelligence),
477            export,
478        };
479
480        let runtime = RuntimeState {
481            services,
482            webhooks: webhooks_rest,
483            intelligence,
484            stream_buffer,
485            config,
486            leader,
487        };
488        self.runtime
489            .set(runtime)
490            .map_err(|_| anyhow::anyhow!("chat-engine module already initialised"))?;
491
492        info!("{} module initialised", Self::MODULE_NAME);
493        Ok(())
494    }
495}
496
497impl DatabaseCapability for ChatEngineModule {
498    fn migrations(&self) -> Vec<Box<dyn MigrationTrait>> {
499        use sea_orm_migration::MigratorTrait;
500        Migrator::migrations()
501    }
502}
503
504impl RestApiCapability for ChatEngineModule {
505    fn register_rest(
506        &self,
507        _ctx: &GearCtx,
508        router: Router,
509        openapi: &dyn OpenApiRegistry,
510    ) -> anyhow::Result<Router> {
511        let runtime = self.runtime()?;
512        let router = router.layer(axum::middleware::from_fn(canonical_error_middleware));
513        if !runtime.config.enable_search {
514            info!(
515                "chat-engine search endpoints disabled (enable_search=false); \
516                 production search backends are still stubs",
517            );
518        }
519        let router = crate::api::rest::register_routes(
520            router,
521            openapi,
522            runtime.services.clone(),
523            Arc::clone(&runtime.webhooks),
524            Arc::clone(&runtime.stream_buffer),
525            runtime.config.enable_search,
526        );
527        Ok(router)
528    }
529}