1use 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
71pub const DEFAULT_WEBHOOK_COMPAT_INSTANCE_ID: &str = "gtx.cf.chat_engine.webhook_compat_plugin.v1~";
76
77struct RuntimeState {
79 services: ChatEngineServices,
80 webhooks: Arc<dyn WebhookEmitter>,
81 intelligence: Arc<IntelligenceService>,
82 stream_buffer: Arc<dyn crate::domain::ports::StreamEventBuffer>,
86 config: Arc<ChatEngineConfig>,
87 leader: Arc<dyn LeaderElector>,
92}
93
94const STREAM_BUFFER_SWEEP_PERIOD: Duration = Duration::from_mins(5);
98
99#[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 #[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 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 interval.tick().await;
189
190 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
230async 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
248async 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#[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 let leader = create_leader_elector().await?;
313
314 let db_raw = ctx.db_required()?;
326 let db: Arc<ChatEngineDb> = Arc::new(DBProvider::new(db_raw.db()));
327
328 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 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 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; 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 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 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 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}