Skip to main content

mini_chat/
gear.rs

1use std::sync::{Arc, Mutex, OnceLock};
2
3use async_trait::async_trait;
4use authn_resolver_sdk::{AuthNResolverClient, ClientCredentialsRequest};
5use authz_resolver_sdk::AuthZResolverClient;
6use std::time::Duration;
7use toolkit::api::OpenApiRegistry;
8use toolkit::contracts::RunnableCapability;
9use toolkit::{DatabaseCapability, Gear, GearCtx, RestApiCapability};
10
11use oagw_sdk::ServiceGatewayClientV1;
12use sea_orm_migration::MigrationTrait;
13use tokio_util::sync::CancellationToken;
14use toolkit_db::outbox::{LeaseConfig, Outbox, OutboxHandle, Partitions};
15use tracing::info;
16
17use crate::api::rest::routes;
18use crate::background_workers::{self, WORKER_STOP_TIMEOUT, WorkerConfigs};
19use crate::config::ProviderEntry;
20use crate::domain::ports::MiniChatMetricsPort;
21use crate::domain::service::{AppServices as GenericAppServices, Repositories};
22use crate::infra::metrics::MiniChatMetricsMeter;
23use crate::infra::outbox::{AuditEventHandler, InfraOutboxEnqueuer, UsageEventHandler};
24use crate::infra::workers::WorkerHandles;
25
26pub(crate) type AppServices = GenericAppServices<
27    TurnRepository,
28    MessageRepository,
29    QuotaUsageRepository,
30    ReactionRepository,
31    ChatRepository,
32    ThreadSummaryRepository,
33    AttachmentRepository,
34    VectorStoreRepository,
35    MessageAttachmentRepository,
36>;
37use crate::infra::audit_gateway::AuditGateway;
38use crate::infra::db::repo::attachment_repo::AttachmentRepository;
39use crate::infra::db::repo::chat_repo::ChatRepository;
40use crate::infra::db::repo::message_attachment_repo::MessageAttachmentRepository;
41use crate::infra::db::repo::message_repo::MessageRepository;
42use crate::infra::db::repo::quota_usage_repo::QuotaUsageRepository;
43use crate::infra::db::repo::reaction_repo::ReactionRepository;
44use crate::infra::db::repo::thread_summary_repo::ThreadSummaryRepository;
45use crate::infra::db::repo::turn_repo::TurnRepository;
46use crate::infra::db::repo::vector_store_repo::VectorStoreRepository;
47use crate::infra::llm::provider_resolver::ProviderResolver;
48use crate::infra::model_policy::ModelPolicyGateway;
49
50/// Default URL prefix for all mini-chat REST routes.
51pub const DEFAULT_URL_PREFIX: &str = "/mini-chat";
52
53/// The mini-chat gear: multi-tenant AI chat with SSE streaming.
54#[toolkit::gear(
55    name = "mini-chat",
56    deps = [types_registry, authn_resolver, authz_resolver, oagw],
57    capabilities = [db, rest, stateful],
58)]
59pub struct MiniChatGear {
60    service: OnceLock<Arc<AppServices>>,
61    url_prefix: OnceLock<String>,
62    outbox_handle: Mutex<Option<OutboxHandle>>,
63    /// OAGW gateway + provider config for deferred upstream registration in `start()`.
64    oagw_deferred: OnceLock<OagwDeferred>,
65    /// Worker configs captured in `init()`, consumed by `start()`.
66    worker_configs: OnceLock<WorkerConfigs>,
67    worker_cancel: Mutex<Option<CancellationToken>>,
68    /// Handles to spawned background workers — joined during `stop()`.
69    worker_handles: Mutex<Option<WorkerHandles>>,
70    /// Deferred outbox pipeline params — built in `init()`, started in `start()`.
71    outbox_deferred: OnceLock<OutboxDeferred>,
72}
73
74/// State needed to register OAGW upstreams in `start()` (after GTS is ready).
75struct OagwDeferred {
76    gateway: Arc<dyn ServiceGatewayClientV1>,
77    authn: Arc<dyn AuthNResolverClient>,
78    client_credentials: crate::config::ClientCredentialsConfig,
79    providers: std::collections::HashMap<String, ProviderEntry>,
80}
81
82/// State needed to build + start the outbox pipeline in `start()`.
83/// Captured in `init()`, consumed in `start()` after OAGW registration.
84struct OutboxDeferred {
85    db: Arc<crate::domain::service::DbProvider>,
86    outbox_config: crate::config::OutboxConfig,
87    cleanup_config: crate::config::background::CleanupWorkerConfig,
88    model_policy_gw: Arc<ModelPolicyGateway>,
89    audit_gateway: Arc<AuditGateway>,
90    file_storage: Arc<dyn crate::domain::ports::FileStorageProvider>,
91    vector_store_prov: Arc<dyn crate::domain::ports::VectorStoreProvider>,
92    metrics: Arc<dyn MiniChatMetricsPort>,
93    enqueuer: Arc<InfraOutboxEnqueuer>,
94    provider_resolver: Arc<crate::infra::llm::provider_resolver::ProviderResolver>,
95    model_resolver: Arc<dyn crate::domain::repos::ModelResolver>,
96    thread_summary_config: crate::config::background::ThreadSummaryWorkerConfig,
97    /// `Some` when at least one configured provider uses the
98    /// `anthropic_messages` adapter. Cleanup handlers use it for the
99    /// secondary `DELETE /v1/files/{id}` after the primary delete succeeds.
100    anthropic_files_client:
101        Option<Arc<crate::infra::llm::providers::anthropic_files_client::AnthropicFilesClient>>,
102}
103
104impl Default for MiniChatGear {
105    fn default() -> Self {
106        Self {
107            service: OnceLock::new(),
108            url_prefix: OnceLock::new(),
109            outbox_handle: Mutex::new(None),
110            oagw_deferred: OnceLock::new(),
111            worker_configs: OnceLock::new(),
112            worker_cancel: Mutex::new(None),
113            worker_handles: Mutex::new(None),
114            outbox_deferred: OnceLock::new(),
115        }
116    }
117}
118
119#[allow(clippy::too_many_lines)]
120#[async_trait]
121impl Gear for MiniChatGear {
122    async fn init(&self, ctx: &GearCtx) -> anyhow::Result<()> {
123        info!("Initializing {} gear", Self::MODULE_NAME);
124
125        let mut cfg: crate::config::MiniChatConfig = ctx.config_expanded_or_default()?;
126        cfg.streaming
127            .validate()
128            .map_err(|e| anyhow::anyhow!("streaming config: {e}"))?;
129        cfg.estimation_budgets
130            .validate()
131            .map_err(|e| anyhow::anyhow!("estimation_budgets config: {e}"))?;
132        cfg.quota
133            .validate()
134            .map_err(|e| anyhow::anyhow!("quota config: {e}"))?;
135        cfg.outbox
136            .validate()
137            .map_err(|e| anyhow::anyhow!("outbox config: {e}"))?;
138        cfg.context
139            .validate()
140            .map_err(|e| anyhow::anyhow!("context config: {e}"))?;
141        cfg.client_credentials
142            .validate()
143            .map_err(|e| anyhow::anyhow!("client_credentials config: {e}"))?;
144        for (id, entry) in &cfg.providers {
145            entry
146                .validate(id)
147                .map_err(|e| anyhow::anyhow!("providers config: {e}"))?;
148        }
149        cfg.validate_provider_refs()
150            .map_err(|e| anyhow::anyhow!("providers config: {e}"))?;
151        cfg.orphan_watchdog
152            .validate()
153            .map_err(|e| anyhow::anyhow!("orphan_watchdog config: {e}"))?;
154        cfg.thread_summary_worker
155            .validate()
156            .map_err(|e| anyhow::anyhow!("thread_summary_worker config: {e}"))?;
157        cfg.cleanup_worker
158            .validate()
159            .map_err(|e| anyhow::anyhow!("cleanup_worker config: {e}"))?;
160        cfg.thumbnail
161            .validate()
162            .map_err(|e| anyhow::anyhow!("thumbnail config: {e}"))?;
163        cfg.rag
164            .validate()
165            .map_err(|e| anyhow::anyhow!("rag config: {e}"))?;
166        cfg.knowledge_search
167            .validate()
168            .map_err(|e| anyhow::anyhow!("knowledge_search config: {e}"))?;
169
170        let vendor = cfg.vendor.trim().to_owned();
171        if vendor.is_empty() {
172            return Err(anyhow::anyhow!(
173                "{}: vendor must be a non-empty string",
174                Self::MODULE_NAME
175            ));
176        }
177
178        // `MiniChatModelPolicyPluginSpecV1` and `MiniChatAuditPluginSpecV1`
179        // schemas reach `types-registry` automatically through the
180        // `toolkit-gts` link-time inventory. No per-gear schema registration
181        // is needed here.
182
183        self.url_prefix
184            .set(cfg.url_prefix)
185            .map_err(|_| anyhow::anyhow!("{} url_prefix already set", Self::MODULE_NAME))?;
186
187        let db_provider = ctx.db_required()?;
188        let db = Arc::new(db_provider);
189
190        // Create the model-policy gateway early for both outbox handler and services.
191        let model_policy_gw = Arc::new(ModelPolicyGateway::new(ctx.client_hub(), vendor.clone()));
192
193        // Audit gateway: lazily resolves audit plugin(s) on first emission.
194        let audit_gateway = Arc::new(AuditGateway::new(ctx.client_hub(), vendor));
195
196        // ── Resolve infrastructure deps needed by both outbox handlers and services ──
197
198        let authz = ctx
199            .client_hub()
200            .get::<dyn AuthZResolverClient>()
201            .map_err(|e| anyhow::anyhow!("failed to get AuthZ resolver: {e}"))?;
202
203        let authn_client = ctx
204            .client_hub()
205            .get::<dyn AuthNResolverClient>()
206            .map_err(|e| anyhow::anyhow!("failed to get AuthN resolver: {e}"))?;
207
208        let gateway = ctx
209            .client_hub()
210            .get::<dyn ServiceGatewayClientV1>()
211            .map_err(|e| anyhow::anyhow!("failed to get OAGW gateway: {e}"))?;
212
213        // Pre-fill upstream_alias with host as fallback so ProviderResolver
214        // works immediately. The actual OAGW registration is deferred to
215        // start() because GTS instances are not visible via list() until
216        // post_init (types-registry switches to ready mode there).
217        for entry in cfg.providers.values_mut() {
218            if entry.upstream_alias.is_none() {
219                entry.upstream_alias = Some(entry.host.clone());
220            }
221            for ovr in entry.tenant_overrides.values_mut() {
222                if ovr.upstream_alias.is_none()
223                    && let Some(ref h) = ovr.host
224                {
225                    ovr.upstream_alias = Some(h.clone());
226                }
227            }
228        }
229
230        // Save a copy for deferred OAGW registration in start().
231        // Ignore the result: if already set, we keep the first value.
232        drop(self.oagw_deferred.set(OagwDeferred {
233            gateway: Arc::clone(&gateway),
234            authn: Arc::clone(&authn_client),
235            client_credentials: cfg.client_credentials.clone(),
236            providers: cfg.providers.clone(),
237        }));
238
239        let provider_resolver = Arc::new(ProviderResolver::new(&gateway, cfg.providers));
240
241        let repos = Repositories {
242            chat: Arc::new(ChatRepository::new(toolkit_db::odata::LimitCfg {
243                default: 20,
244                max: 100,
245            })),
246            attachment: Arc::new(AttachmentRepository),
247            message: Arc::new(MessageRepository::new(toolkit_db::odata::LimitCfg {
248                default: 20,
249                max: 100,
250            })),
251            quota: Arc::new(QuotaUsageRepository),
252            turn: Arc::new(TurnRepository),
253            reaction: Arc::new(ReactionRepository),
254            thread_summary: Arc::new(ThreadSummaryRepository),
255            vector_store: Arc::new(VectorStoreRepository),
256            message_attachment: Arc::new(MessageAttachmentRepository),
257        };
258
259        let rag_client = Arc::new(
260            crate::infra::llm::providers::rag_http_client::RagHttpClient::new(Arc::clone(&gateway)),
261        );
262
263        // Build provider-specific file/vector store impls per provider entry.
264        // Dispatch by storage_kind: Azure → Azure impls, OpenAi → OpenAI impls.
265        let mut file_impls: std::collections::HashMap<
266            String,
267            Arc<dyn crate::domain::ports::FileStorageProvider>,
268        > = std::collections::HashMap::new();
269        let mut vs_impls: std::collections::HashMap<
270            String,
271            Arc<dyn crate::domain::ports::VectorStoreProvider>,
272        > = std::collections::HashMap::new();
273        for (provider_id, entry) in provider_resolver.entries() {
274            let (file, vs): (
275                Arc<dyn crate::domain::ports::FileStorageProvider>,
276                Arc<dyn crate::domain::ports::VectorStoreProvider>,
277            ) = match entry.storage_kind {
278                crate::config::StorageKind::Azure => {
279                    let api_version = entry.api_version.clone().unwrap_or_else(|| {
280                        panic!(
281                            "provider '{provider_id}': storage_kind is 'azure' \
282                             but api_version is not set"
283                        )
284                    });
285                    (
286                        Arc::new(
287                            crate::infra::llm::providers::azure_file_storage::AzureFileStorage::new(
288                                Arc::clone(&rag_client),
289                                Arc::clone(&provider_resolver),
290                                api_version.clone(),
291                            ),
292                        ),
293                        Arc::new(
294                            crate::infra::llm::providers::azure_vector_store::AzureVectorStore::new(
295                                Arc::clone(&rag_client),
296                                Arc::clone(&provider_resolver),
297                                api_version,
298                            ),
299                        ),
300                    )
301                }
302                crate::config::StorageKind::OpenAi => (
303                    Arc::new(
304                        crate::infra::llm::providers::openai_file_storage::OpenAiFileStorage::new(
305                            Arc::clone(&rag_client),
306                            Arc::clone(&provider_resolver),
307                        ),
308                    ),
309                    Arc::new(
310                        crate::infra::llm::providers::openai_vector_store::OpenAiVectorStore::new(
311                            Arc::clone(&rag_client),
312                            Arc::clone(&provider_resolver),
313                        ),
314                    ),
315                ),
316            };
317            file_impls.insert(provider_id.clone(), file);
318            vs_impls.insert(provider_id.clone(), vs);
319        }
320        let file_storage: Arc<dyn crate::domain::ports::FileStorageProvider> = Arc::new(
321            crate::infra::llm::providers::dispatching_storage::DispatchingFileStorage::new(
322                file_impls,
323            ),
324        );
325        let vector_store_prov: Arc<dyn crate::domain::ports::VectorStoreProvider> = Arc::new(
326            crate::infra::llm::providers::dispatching_storage::DispatchingVectorStore::new(
327                vs_impls,
328            ),
329        );
330
331        // ── Metrics ─────────────────────────────────────────────────────────
332
333        let metrics_prefix = cfg.metrics.effective_prefix(Self::MODULE_NAME);
334        let scope =
335            opentelemetry::InstrumentationScope::builder(Self::MODULE_NAME.to_owned()).build();
336        let metrics: Arc<dyn MiniChatMetricsPort> = Arc::new(MiniChatMetricsMeter::new(
337            &opentelemetry::global::meter_with_scope(scope),
338            &metrics_prefix,
339        ));
340
341        // ── Outbox enqueuer (lazy) ────────────────────────────────────────
342        //
343        // The enqueuer is created now (services need it), but the actual outbox
344        // pipeline starts in start() -- after OAGW upstreams are registered.
345        // HTTP traffic doesn't arrive until after start(), so enqueue() is never
346        // called before the outbox handle is set.
347
348        let outbox_enqueuer = Arc::new(InfraOutboxEnqueuer::new(
349            cfg.outbox.queue_name.clone(),
350            cfg.outbox.cleanup_queue_name.clone(),
351            cfg.outbox.chat_cleanup_queue_name.clone(),
352            cfg.outbox.thread_summary_queue_name.clone(),
353            cfg.outbox.audit_queue_name.clone(),
354            cfg.outbox.num_partitions,
355        ));
356
357        // ── Knowledge retriever ─────────────────────────────────────────────
358
359        let knowledge_retriever: Option<Arc<dyn crate::domain::ports::KnowledgeRetriever>> = if cfg
360            .knowledge_search
361            .enabled
362        {
363            Some(Arc::new(
364                    crate::infra::llm::providers::azure_knowledge_retriever::AzureKnowledgeRetriever::new(
365                        Arc::clone(&rag_client),
366                    ),
367                ))
368        } else {
369            None
370        };
371
372        // ── Anthropic Files API client ──────────────────────────────────────
373        //
374        // Constructed only when at least one provider entry uses the
375        // `anthropic_messages` adapter — `AttachmentService` uses it for the
376        // parallel "secondary" upload of attachments to Anthropic's Files API
377        // (see `anthropic-provider-support.md` §8.0). The client itself is
378        // upstream-agnostic; the upstream alias is passed per-call from the
379        // resolved `UploadContext`. The same client is reused by the cleanup
380        // worker to issue `DELETE /v1/files/{id}` on attachment / chat
381        // deletion.
382        let anthropic_files_client = if provider_resolver.entries().values().any(|entry| {
383            matches!(
384                entry.kind,
385                crate::infra::llm::providers::ProviderKind::AnthropicMessages
386            )
387        }) {
388            Some(Arc::new(
389                crate::infra::llm::providers::anthropic_files_client::AnthropicFilesClient::new(
390                    Arc::clone(&gateway),
391                ),
392            ))
393        } else {
394            None
395        };
396
397        // Save params for start() to build + start the outbox pipeline.
398        drop(self.outbox_deferred.set(OutboxDeferred {
399            db: Arc::clone(&db),
400            outbox_config: cfg.outbox,
401            cleanup_config: cfg.cleanup_worker,
402            model_policy_gw: model_policy_gw.clone(),
403            audit_gateway: Arc::clone(&audit_gateway),
404            file_storage: Arc::clone(&file_storage),
405            vector_store_prov: Arc::clone(&vector_store_prov),
406            metrics: Arc::clone(&metrics),
407            enqueuer: Arc::clone(&outbox_enqueuer),
408            provider_resolver: Arc::clone(&provider_resolver),
409            model_resolver: model_policy_gw.clone() as Arc<dyn crate::domain::repos::ModelResolver>,
410            thread_summary_config: cfg.thread_summary_worker.clone(),
411            anthropic_files_client: anthropic_files_client.clone(),
412        }));
413
414        // ── Services ────────────────────────────────────────────────────────
415
416        let services = Arc::new(AppServices::new(
417            &repos,
418            db,
419            authz,
420            &(model_policy_gw.clone() as Arc<dyn crate::domain::repos::ModelResolver>),
421            &provider_resolver,
422            cfg.streaming,
423            model_policy_gw.clone() as Arc<dyn crate::domain::repos::PolicySnapshotProvider>,
424            model_policy_gw as Arc<dyn crate::domain::repos::UserLimitsProvider>,
425            cfg.estimation_budgets,
426            cfg.quota,
427            &(outbox_enqueuer as Arc<dyn crate::domain::repos::OutboxEnqueuer>),
428            cfg.context,
429            file_storage,
430            vector_store_prov,
431            cfg.rag,
432            cfg.thumbnail,
433            metrics,
434            cfg.thread_summary_worker,
435            cfg.knowledge_search,
436            knowledge_retriever,
437            anthropic_files_client,
438        ));
439
440        self.service
441            .set(services)
442            .map_err(|_| anyhow::anyhow!("{} gear already initialized", Self::MODULE_NAME))?;
443
444        self.worker_configs
445            .set(WorkerConfigs {
446                orphan_watchdog: cfg.orphan_watchdog,
447            })
448            .map_err(|_| anyhow::anyhow!("{} worker_configs already set", Self::MODULE_NAME))?;
449
450        info!("{} gear initialized successfully", Self::MODULE_NAME);
451        Ok(())
452    }
453}
454
455impl DatabaseCapability for MiniChatGear {
456    fn migrations(&self) -> Vec<Box<dyn MigrationTrait>> {
457        use sea_orm_migration::MigratorTrait;
458        info!("Providing mini-chat database migrations");
459        let mut m = crate::infra::db::migrations::Migrator::migrations();
460        m.extend(toolkit_db::outbox::outbox_migrations());
461        m
462    }
463}
464
465impl RestApiCapability for MiniChatGear {
466    fn register_rest(
467        &self,
468        _ctx: &GearCtx,
469        router: axum::Router,
470        openapi: &dyn OpenApiRegistry,
471    ) -> anyhow::Result<axum::Router> {
472        let services = self
473            .service
474            .get()
475            .ok_or_else(|| anyhow::anyhow!("{} not initialized", Self::MODULE_NAME))?;
476
477        info!("Registering mini-chat REST routes");
478        let prefix = self
479            .url_prefix
480            .get()
481            .ok_or_else(|| anyhow::anyhow!("{} not initialized (url_prefix)", Self::MODULE_NAME))?;
482
483        let router = routes::register_routes(router, openapi, Arc::clone(services), prefix);
484        info!("Mini-chat REST routes registered successfully");
485        Ok(router)
486    }
487}
488
489#[async_trait]
490impl RunnableCapability for MiniChatGear {
491    async fn start(&self, cancel: CancellationToken) -> anyhow::Result<()> {
492        let wc = self.worker_configs.get().ok_or_else(|| {
493            anyhow::anyhow!(
494                "{} worker_configs not set - init() must run before start()",
495                Self::MODULE_NAME
496            )
497        })?;
498        let leader_elector = background_workers::prepare_worker_runtime(wc).await?;
499
500        // Register OAGW upstreams now that GTS is in ready mode (post_init
501        // has completed). During init() this fails because types-registry
502        // list() only queries the persistent store which is empty until
503        // switch_to_ready().
504        if let Some(deferred) = self.oagw_deferred.get() {
505            let ctx =
506                exchange_client_credentials(&deferred.authn, &deferred.client_credentials).await?;
507            let mut providers = deferred.providers.clone();
508            let report = crate::infra::oagw_provisioning::register_oagw_upstreams(
509                &deferred.gateway,
510                &ctx,
511                &mut providers,
512            )
513            .await?;
514            // Fail-fast: a deterministically misconfigured provider (OAGW
515            // rejected its request as invalid — retrying cannot help) aborts
516            // startup instead of leaving the provider silently unavailable.
517            // Only genuinely deferrable providers (secret not provisioned
518            // yet, transient errors) proceed to the background retry below.
519            report.ensure_no_misconfigured()?;
520            let deferred_ids = report.deferred;
521
522            // Providers whose backend secret was not accessible at boot are
523            // registered lazily. With the stateful credstore, provider secrets
524            // are created at runtime via the credstore API, so the upstream
525            // cannot be registered until the secret exists. Retry in the
526            // background rather than blocking startup: start() must return for
527            // the server to report healthy, which is itself a precondition for
528            // the secret to be provisioned.
529            if !deferred_ids.is_empty() {
530                tracing::warn!(
531                    deferred = ?deferred_ids,
532                    "OAGW upstreams deferred at boot (secret not yet accessible); \
533                     retrying registration in the background"
534                );
535                let gateway = Arc::clone(&deferred.gateway);
536                let authn = Arc::clone(&deferred.authn);
537                let creds = deferred.client_credentials.clone();
538                let providers = providers.clone();
539                let cancel = cancel.clone();
540                tokio::spawn(reconcile_deferred_upstreams_with_retry(
541                    gateway,
542                    authn,
543                    creds,
544                    providers,
545                    deferred_ids,
546                    cancel,
547                ));
548            }
549        }
550
551        // Start the outbox pipeline now that OAGW upstreams are registered.
552        // Cleanup handlers can immediately call provider DELETE via OAGW.
553        if let Some(od) = self.outbox_deferred.get() {
554            let outbox_db = od.db.db();
555            let num_partitions = od.outbox_config.num_partitions;
556            let max_cleanup_attempts = od.cleanup_config.max_attempts;
557
558            let partitions = Partitions::of(
559                u16::try_from(num_partitions)
560                    .map_err(|_| anyhow::anyhow!("num_partitions exceeds u16"))?,
561            );
562
563            let outbox_handle = Outbox::builder(outbox_db)
564                .queue(&od.outbox_config.queue_name, partitions)
565                .leased(UsageEventHandler {
566                    plugin_provider: od.model_policy_gw.clone(),
567                })
568                .queue(&od.outbox_config.cleanup_queue_name, partitions)
569                .leased(
570                    crate::infra::workers::cleanup_worker::AttachmentCleanupHandler::new(
571                        Arc::clone(&od.file_storage),
572                        Arc::clone(&od.db),
573                        ChatRepository::new(toolkit_db::odata::LimitCfg {
574                            default: 20,
575                            max: 100,
576                        }),
577                        max_cleanup_attempts,
578                        Arc::clone(&od.metrics),
579                        od.anthropic_files_client.clone(),
580                    ),
581                )
582                .queue(&od.outbox_config.chat_cleanup_queue_name, partitions)
583                .leased(
584                    crate::infra::workers::cleanup_worker::ChatCleanupHandler::new(
585                        Arc::clone(&od.file_storage),
586                        Arc::clone(&od.vector_store_prov),
587                        Arc::clone(&od.db),
588                        ChatRepository::new(toolkit_db::odata::LimitCfg {
589                            default: 20,
590                            max: 100,
591                        }),
592                        max_cleanup_attempts,
593                        Arc::clone(&od.metrics),
594                        od.anthropic_files_client.clone(),
595                    ),
596                )
597                .queue(&od.outbox_config.thread_summary_queue_name, partitions)
598                .leased(
599                    crate::infra::workers::thread_summary_worker::ThreadSummaryHandler::new(
600                        Arc::new(
601                            crate::infra::workers::thread_summary_worker::ThreadSummaryDeps {
602                                db: Arc::clone(&od.db),
603                                thread_summary_repo: Arc::new(ThreadSummaryRepository),
604                                message_repo: Arc::new(MessageRepository::new(
605                                    toolkit_db::odata::LimitCfg {
606                                        default: 20,
607                                        max: 100,
608                                    },
609                                )),
610                                outbox_enqueuer: Arc::clone(&od.enqueuer)
611                                    as Arc<dyn crate::domain::repos::OutboxEnqueuer>,
612                                metrics: Arc::clone(&od.metrics),
613                                provider_resolver: Arc::clone(&od.provider_resolver),
614                                model_resolver: Arc::clone(&od.model_resolver),
615                                config: od.thread_summary_config.clone(),
616                            },
617                        ),
618                    ),
619                )
620                .queue(&od.outbox_config.audit_queue_name, partitions)
621                .leased(AuditEventHandler {
622                    audit_gateway: Arc::clone(&od.audit_gateway),
623                    metrics: Arc::clone(&od.metrics),
624                })
625                .lease(LeaseConfig {
626                    duration: Duration::from_mins(1),
627                    ..LeaseConfig::default()
628                })
629                .start()
630                .await
631                .map_err(|e| anyhow::anyhow!("outbox start: {e}"))?;
632
633            // Wire the outbox handle into the lazy enqueuer.
634            od.enqueuer.set_outbox(Arc::clone(outbox_handle.outbox()));
635
636            let mut guard = self
637                .outbox_handle
638                .lock()
639                .map_err(|e| anyhow::anyhow!("outbox_handle lock: {e}"))?;
640            *guard = Some(outbox_handle);
641
642            info!("Outbox pipeline started (OAGW ready)");
643        }
644
645        let orphan_deps = if wc.orphan_watchdog.enabled {
646            let services = self.service.get().ok_or_else(|| {
647                anyhow::anyhow!(
648                    "{} not initialized - init() must run before start()",
649                    Self::MODULE_NAME
650                )
651            })?;
652            Some(crate::infra::workers::orphan_watchdog::OrphanWatchdogDeps {
653                finalization_svc: Arc::clone(&services.finalization),
654                turn_repo: Arc::clone(&services.turn_repo),
655                db: Arc::clone(&services.db),
656                metrics: Arc::clone(&services.metrics),
657            })
658        } else {
659            None
660        };
661
662        let (handles, worker_cancel) =
663            background_workers::spawn_workers(wc, &cancel, leader_elector.as_ref(), orphan_deps)?;
664        self.store_worker_runtime(handles, worker_cancel).await?;
665
666        Ok(())
667    }
668
669    async fn stop(&self, cancel: CancellationToken) -> anyhow::Result<()> {
670        if let Some(worker_cancel) = self
671            .worker_cancel
672            .lock()
673            .map_err(|e| anyhow::anyhow!("worker_cancel lock: {e}"))?
674            .take()
675        {
676            worker_cancel.cancel();
677        }
678
679        let workers = self
680            .worker_handles
681            .lock()
682            .map_err(|e| anyhow::anyhow!("worker_handles lock: {e}"))?
683            .take();
684        if let Some(handles) = workers {
685            info!("Waiting for background workers to stop");
686            handles.join_all(cancel.clone(), WORKER_STOP_TIMEOUT).await;
687            info!("Background workers stopped");
688        }
689
690        let handle = self
691            .outbox_handle
692            .lock()
693            .map_err(|e| anyhow::anyhow!("outbox_handle lock: {e}"))?
694            .take();
695        if let Some(handle) = handle {
696            info!("Stopping outbox pipeline");
697            tokio::select! {
698                () = handle.stop() => {
699                    info!("Outbox pipeline stopped");
700                }
701                () = cancel.cancelled() => {
702                    info!("Outbox pipeline stop cancelled by framework deadline");
703                }
704            }
705        }
706        Ok(())
707    }
708}
709
710impl MiniChatGear {
711    async fn store_worker_runtime(
712        &self,
713        handles: WorkerHandles,
714        worker_cancel: CancellationToken,
715    ) -> anyhow::Result<()> {
716        let worker_cancel_cleanup = worker_cancel.clone();
717
718        // Store cancel token. Guard must not live across an await point.
719        let cancel_already_set = {
720            let mut guard = self
721                .worker_cancel
722                .lock()
723                .map_err(|e| anyhow::anyhow!("worker_cancel lock: {e}"))?;
724            if guard.is_some() {
725                true
726            } else {
727                *guard = Some(worker_cancel);
728                false
729            }
730            // guard dropped here — before any await
731        };
732        if cancel_already_set {
733            worker_cancel_cleanup.cancel();
734            let hard_stop = CancellationToken::new();
735            hard_stop.cancel();
736            handles.join_all(hard_stop, WORKER_STOP_TIMEOUT).await;
737            anyhow::bail!("{} worker_cancel already set", Self::MODULE_NAME);
738        }
739
740        // Store handles. Guard must not live across an await point.
741        let mut handles = Some(handles);
742        let handles_err = {
743            match self.worker_handles.lock() {
744                Ok(mut guard) => {
745                    if guard.is_some() {
746                        Some("worker_handles already set".to_owned())
747                    } else {
748                        *guard = handles.take();
749                        None
750                    }
751                }
752                Err(e) => Some(format!("worker_handles lock: {e}")),
753            }
754            // guard dropped here — before any await
755        };
756        if let Some(msg) = handles_err {
757            if let Ok(mut cancel_guard) = self.worker_cancel.lock() {
758                cancel_guard.take();
759            }
760            worker_cancel_cleanup.cancel();
761            if let Some(handles) = handles {
762                let hard_stop = CancellationToken::new();
763                hard_stop.cancel();
764                handles.join_all(hard_stop, WORKER_STOP_TIMEOUT).await;
765            }
766            // handles was either moved into the mutex (not the error case)
767            // or never stored. In the "already set" case it was moved, so
768            // we rely on the cancel token to stop workers; their JoinHandles
769            // will be cleaned up when the existing WorkerHandles is joined
770            // in stop().
771            anyhow::bail!("{} {msg}", Self::MODULE_NAME);
772        }
773        Ok(())
774    }
775}
776
777/// Background retry loop for providers whose OAGW upstream could not be
778/// registered at boot because their credstore secret was not yet accessible.
779///
780/// Re-exchanges an S2S context and re-attempts registration on a backing-off
781/// cadence until every deferred provider is registered or the gear is
782/// cancelled. There is no attempt budget: with the stateful credstore a
783/// provider's secret can be provisioned at any time after boot, and giving up
784/// would leave that provider unavailable until an operator restart. The
785/// interval grows from `RETRY_INTERVAL_MIN` to `RETRY_INTERVAL_MAX` so late
786/// provisioning is still picked up without hammering OAGW indefinitely.
787/// Registration is idempotent, so any provider registered on a previous
788/// attempt is reused rather than duplicated.
789// The retry loop's `select!`/tracing macros inflate the measured cognitive
790// complexity; the control flow itself is a simple backing-off loop.
791#[allow(clippy::cognitive_complexity)]
792async fn reconcile_deferred_upstreams_with_retry(
793    gateway: Arc<dyn ServiceGatewayClientV1>,
794    authn: Arc<dyn AuthNResolverClient>,
795    creds: crate::config::ClientCredentialsConfig,
796    providers: std::collections::HashMap<String, ProviderEntry>,
797    mut deferred: Vec<String>,
798    cancel: CancellationToken,
799) {
800    const RETRY_INTERVAL_MIN: Duration = Duration::from_secs(2);
801    const RETRY_INTERVAL_MAX: Duration = Duration::from_mins(1);
802    // Warn once (not on every slow tick) after this much wall-clock time still
803    // deferred, so an operator notices without log spam. Time-based, not
804    // attempt-based: the interval backs off, so a fixed attempt count would
805    // drift far from the intended window.
806    const WARN_AFTER: Duration = Duration::from_mins(2);
807
808    let started = tokio::time::Instant::now();
809    let mut warned = false;
810    let mut backoff = RETRY_INTERVAL_MIN;
811    let mut attempt: u32 = 0;
812    loop {
813        tokio::select! {
814            () = cancel.cancelled() => {
815                info!(remaining = deferred.len(), "OAGW upstream reconcile cancelled");
816                return;
817            }
818            () = tokio::time::sleep(backoff) => {}
819        }
820
821        attempt = attempt.saturating_add(1);
822        deferred =
823            reconcile_deferred_once(&gateway, &authn, &creds, &providers, deferred, attempt).await;
824        if deferred.is_empty() {
825            info!("OAGW reconcile: all deferred provider upstreams registered");
826            return;
827        }
828
829        if !warned && started.elapsed() >= WARN_AFTER {
830            warned = true;
831            tracing::warn!(
832                remaining = ?deferred,
833                elapsed_secs = started.elapsed().as_secs(),
834                "OAGW reconcile: provider(s) still UNAVAILABLE; will keep retrying at a slower \
835                 cadence until their secret is provisioned. Check for a missing secret or a \
836                 misconfigured secret_ref/host for these providers"
837            );
838        }
839        backoff = (backoff * 2).min(RETRY_INTERVAL_MAX);
840    }
841}
842
843/// One reconcile attempt: exchange a fresh S2S context and re-register the
844/// still-deferred providers. Returns the ids that remain deferred (the input
845/// unchanged on a transient failure, so the caller keeps retrying).
846// Two match arms plus tracing macros push the measured complexity over the
847// threshold; the logic is linear.
848#[allow(clippy::cognitive_complexity)]
849async fn reconcile_deferred_once(
850    gateway: &Arc<dyn ServiceGatewayClientV1>,
851    authn: &Arc<dyn AuthNResolverClient>,
852    creds: &crate::config::ClientCredentialsConfig,
853    providers: &std::collections::HashMap<String, ProviderEntry>,
854    deferred: Vec<String>,
855    attempt: u32,
856) -> Vec<String> {
857    let ctx = match exchange_client_credentials(authn, creds).await {
858        Ok(ctx) => ctx,
859        Err(e) => {
860            tracing::warn!(error = %e, attempt, "OAGW reconcile: credential exchange failed; will retry");
861            return deferred;
862        }
863    };
864
865    match crate::infra::oagw_provisioning::reconcile_deferred_upstreams(
866        gateway, &ctx, providers, &deferred,
867    )
868    .await
869    {
870        Ok(still_deferred) => {
871            let registered = deferred.len().saturating_sub(still_deferred.len());
872            if registered > 0 {
873                info!(
874                    registered,
875                    remaining = still_deferred.len(),
876                    attempt,
877                    "OAGW reconcile: registered deferred provider upstream(s)"
878                );
879            }
880            still_deferred
881        }
882        Err(e) => {
883            tracing::warn!(error = %e, attempt, "OAGW reconcile attempt failed; will retry");
884            deferred
885        }
886    }
887}
888
889/// Exchange `OAuth2` client credentials via the `AuthN` resolver to obtain
890/// a `SecurityContext` for OAGW upstream provisioning.
891async fn exchange_client_credentials(
892    authn: &Arc<dyn AuthNResolverClient>,
893    creds: &crate::config::ClientCredentialsConfig,
894) -> anyhow::Result<toolkit_security::SecurityContext> {
895    info!("Exchanging client credentials for OAGW provisioning context");
896    let request = ClientCredentialsRequest {
897        client_id: creds.client_id.clone(),
898        client_secret: creds.client_secret.clone(),
899        scopes: Vec::new(),
900    };
901    let result = authn
902        .exchange_client_credentials(&request)
903        .await
904        .map_err(|e| anyhow::anyhow!("client credentials exchange failed: {e}"))?;
905    info!("Security context obtained for OAGW provisioning");
906    Ok(result.security_context)
907}