Skip to main content

roboticus_api/api/routes/
mod.rs

1mod admin;
2mod agent;
3mod channels;
4mod cron;
5mod health;
6mod interview;
7pub(crate) mod mcp;
8mod memory;
9mod sessions;
10mod skills;
11pub(crate) mod subagent_integrity;
12mod themes;
13pub(crate) use self::agent::execute_scheduled_agent_task;
14mod observability;
15mod subagents;
16mod traces;
17
18use std::collections::HashMap;
19use std::path::PathBuf;
20use std::sync::Arc;
21
22use axum::extract::DefaultBodyLimit;
23use axum::response::IntoResponse;
24use axum::{
25    Router, middleware,
26    routing::{get, post, put},
27};
28use tokio::sync::RwLock;
29
30use crate::config_runtime::ConfigApplyStatus;
31use roboticus_agent::policy::PolicyEngine;
32use roboticus_agent::subagents::SubagentRegistry;
33use roboticus_browser::Browser;
34use roboticus_channels::a2a::A2aProtocol;
35use roboticus_channels::router::ChannelRouter;
36use roboticus_channels::telegram::TelegramAdapter;
37use roboticus_channels::whatsapp::WhatsAppAdapter;
38use roboticus_core::RoboticusConfig;
39// personality types accessed via roboticus_core::personality:: in InterviewSession
40use roboticus_db::Database;
41use roboticus_llm::LlmService;
42use roboticus_llm::OAuthManager;
43use roboticus_llm::semantic_classifier::SemanticClassifier;
44use roboticus_plugin_sdk::registry::PluginRegistry;
45use roboticus_wallet::WalletService;
46
47use roboticus_agent::approvals::ApprovalManager;
48use roboticus_agent::capability::CapabilityRegistry;
49use roboticus_agent::obsidian::ObsidianVault;
50use roboticus_agent::tools::ToolRegistry;
51use roboticus_channels::discord::DiscordAdapter;
52use roboticus_channels::email::EmailAdapter;
53use roboticus_channels::media::MediaService;
54use roboticus_channels::signal::SignalAdapter;
55use roboticus_channels::voice::VoicePipeline;
56
57use crate::ws::EventBus;
58
59// ── RFC 9457 Problem Details error response ─────────────────
60//
61// All API errors are serialized as RFC 9457 "Problem Details for HTTP
62// APIs" (application/problem+json). The `JsonError` type is the single
63// chokepoint — all 250+ error sites construct one, and the `IntoResponse`
64// impl serializes it.
65
66/// RFC 9457 Problem Details response.
67///
68/// Wire format:
69/// ```json
70/// {
71///   "type": "about:blank",
72///   "title": "Bad Request",
73///   "status": 400,
74///   "detail": "api_key cannot be empty"
75/// }
76/// ```
77///
78/// Constructors (`bad_request()`, `not_found()`, `internal_err()`) are
79/// unchanged — call sites do not need modification.
80#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
81pub struct ProblemDetails {
82    /// URI reference identifying the problem type. Defaults to `"about:blank"`
83    /// per RFC 9457 §4.2.1 when no specific type URI is defined.
84    #[serde(rename = "type")]
85    pub problem_type: String,
86    /// Short, human-readable summary (typically the HTTP reason phrase).
87    pub title: String,
88    /// HTTP status code (duplicated in body per RFC 9457 §4.2.4).
89    pub status: u16,
90    /// Human-readable explanation specific to this occurrence.
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub detail: Option<String>,
93    /// URI reference identifying this specific occurrence (optional).
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub instance: Option<String>,
96}
97
98/// The API error type. Wraps a status code and message, serialized as
99/// RFC 9457 Problem Details on the wire.
100#[derive(Debug)]
101pub(crate) struct JsonError(pub axum::http::StatusCode, pub String);
102
103impl JsonError {
104    /// Build the RFC 9457 Problem Details body.
105    fn to_problem_details(&self) -> ProblemDetails {
106        ProblemDetails {
107            problem_type: "about:blank".into(),
108            title: canonical_reason(self.0).into(),
109            status: self.0.as_u16(),
110            detail: if self.1.is_empty() {
111                None
112            } else {
113                Some(self.1.clone())
114            },
115            instance: None,
116        }
117    }
118}
119
120/// Map a status code to its canonical reason phrase.
121fn canonical_reason(status: axum::http::StatusCode) -> &'static str {
122    status.canonical_reason().unwrap_or("Error")
123}
124
125impl axum::response::IntoResponse for JsonError {
126    fn into_response(self) -> axum::response::Response {
127        let status = self.0;
128        let body = self.to_problem_details();
129        (
130            status,
131            [(axum::http::header::CONTENT_TYPE, "application/problem+json")],
132            axum::Json(body),
133        )
134            .into_response()
135    }
136}
137
138impl From<(axum::http::StatusCode, String)> for JsonError {
139    fn from((status, msg): (axum::http::StatusCode, String)) -> Self {
140        Self(status, msg)
141    }
142}
143
144/// Shorthand for a 400 Bad Request JSON error.
145pub(crate) fn bad_request(msg: impl std::fmt::Display) -> JsonError {
146    JsonError(axum::http::StatusCode::BAD_REQUEST, msg.to_string())
147}
148
149/// Shorthand for a 404 Not Found JSON error.
150pub(crate) fn not_found(msg: impl std::fmt::Display) -> JsonError {
151    JsonError(axum::http::StatusCode::NOT_FOUND, msg.to_string())
152}
153
154/// Build an RFC 9457 Problem Details JSON value for handlers that return
155/// `(StatusCode, Json<Value>)` instead of `JsonError`.
156///
157/// Prefer using `JsonError` where possible. This helper is for response
158/// paths that return a tuple directly (e.g. channel webhooks that also
159/// return `{"ok": false}`).
160pub(crate) fn problem_json(status: axum::http::StatusCode, detail: &str) -> serde_json::Value {
161    serde_json::json!({
162        "type": "about:blank",
163        "title": canonical_reason(status),
164        "status": status.as_u16(),
165        "detail": detail,
166    })
167}
168
169/// Build an RFC 9457 response tuple with `application/problem+json` content type.
170pub(crate) fn problem_response(
171    status: axum::http::StatusCode,
172    detail: &str,
173) -> axum::response::Response {
174    (
175        status,
176        [(axum::http::header::CONTENT_TYPE, "application/problem+json")],
177        axum::Json(problem_json(status, detail)),
178    )
179        .into_response()
180}
181
182// ── Helpers (used by submodules) ──────────────────────────────
183
184/// Sanitizes error messages before returning to clients (strip paths, internal details, cap length).
185///
186/// LIMITATIONS: This is a best-effort filter that strips known wrapper
187/// prefixes and truncates. It does NOT guarantee that internal details
188/// (file paths, SQL fragments, stack traces) are fully redacted. If a new
189/// error source leaks sensitive info, add its prefix to the stripping list
190/// below or, better, ensure the call site maps the error before it reaches
191/// this function.
192pub(crate) fn sanitize_error_message(msg: &str) -> String {
193    let sanitized = msg.lines().next().unwrap_or(msg);
194
195    let sanitized = sanitized
196        .trim_start_matches("Database(\"")
197        .trim_end_matches("\")")
198        .trim_start_matches("Wallet(\"")
199        .trim_end_matches("\")");
200
201    // Strip content after common internal-detail prefixes that may leak
202    // implementation specifics (connection strings, file paths, etc.).
203    let sensitive_prefixes = [
204        "at /", // stack trace file paths
205        "called `Result::unwrap()` on an `Err` value:",
206        "SQLITE_",                // raw SQLite error codes
207        "Connection refused",     // infra details
208        "constraint failed",      // SQLite constraint errors (leaks table/column names)
209        "no such table",          // SQLite schema details
210        "no such column",         // SQLite schema details
211        "UNIQUE constraint",      // SQLite constraint (leaks table.column)
212        "FOREIGN KEY constraint", // SQLite constraint
213        "NOT NULL constraint",    // SQLite constraint
214    ];
215    let sanitized = {
216        let mut s = sanitized.to_string();
217        for prefix in &sensitive_prefixes {
218            if let Some(pos) = s.find(prefix) {
219                s.truncate(pos);
220                s.push_str("[details redacted]");
221                break;
222            }
223        }
224        s
225    };
226
227    if sanitized.len() > 200 {
228        let boundary = sanitized
229            .char_indices()
230            .map(|(i, _)| i)
231            .take_while(|&i| i <= 200)
232            .last()
233            .unwrap_or(0);
234        format!("{}...", &sanitized[..boundary])
235    } else {
236        sanitized
237    }
238}
239
240/// Logs the full error and returns a JSON 500 error for API responses.
241pub(crate) fn internal_err(e: &impl std::fmt::Display) -> JsonError {
242    tracing::error!(error = %e, "request failed");
243    JsonError(
244        axum::http::StatusCode::INTERNAL_SERVER_ERROR,
245        sanitize_error_message(&e.to_string()),
246    )
247}
248
249// ── Input validation helpers ──────────────────────────────────
250
251/// Maximum allowed length for short identifier fields (agent_id, name, etc.).
252const MAX_SHORT_FIELD: usize = 256;
253/// Maximum allowed length for long text fields (description, content, etc.).
254const MAX_LONG_FIELD: usize = 4096;
255
256/// Validate a user-supplied string field: reject empty/whitespace-only, null bytes, and enforce length.
257pub(crate) fn validate_field(
258    field_name: &str,
259    value: &str,
260    max_len: usize,
261) -> Result<(), JsonError> {
262    if value.trim().is_empty() {
263        return Err(bad_request(format!("{field_name} must not be empty")));
264    }
265    if value.contains('\0') {
266        return Err(bad_request(format!(
267            "{field_name} must not contain null bytes"
268        )));
269    }
270    if value.len() > max_len {
271        return Err(bad_request(format!(
272            "{field_name} exceeds max length ({max_len})"
273        )));
274    }
275    Ok(())
276}
277
278/// Validate a short identifier field (agent_id, name, session_id, etc.).
279pub(crate) fn validate_short(field_name: &str, value: &str) -> Result<(), JsonError> {
280    validate_field(field_name, value, MAX_SHORT_FIELD)
281}
282
283/// Validate a long text field (description, content, etc.).
284pub(crate) fn validate_long(field_name: &str, value: &str) -> Result<(), JsonError> {
285    validate_field(field_name, value, MAX_LONG_FIELD)
286}
287
288/// Strip HTML tags from a string to prevent injection in stored values.
289pub(crate) fn sanitize_html(input: &str) -> String {
290    input
291        .replace('&', "&amp;")
292        .replace('<', "&lt;")
293        .replace('>', "&gt;")
294        .replace('"', "&quot;")
295        .replace('\'', "&#x27;")
296}
297
298// ── Pagination helpers ──────────────────────────────────────────
299
300/// Default maximum items per page for list endpoints.
301const DEFAULT_PAGE_SIZE: i64 = 200;
302/// Absolute maximum items per page (prevents memory abuse via huge limits).
303const MAX_PAGE_SIZE: i64 = 500;
304
305/// Shared pagination query parameters for list endpoints.
306#[derive(Debug, serde::Deserialize)]
307pub(crate) struct PaginationQuery {
308    pub limit: Option<i64>,
309    pub offset: Option<i64>,
310}
311
312impl PaginationQuery {
313    /// Returns (limit, offset) clamped to safe ranges.
314    pub fn resolve(&self) -> (i64, i64) {
315        let limit = self
316            .limit
317            .unwrap_or(DEFAULT_PAGE_SIZE)
318            .clamp(1, MAX_PAGE_SIZE);
319        let offset = self.offset.unwrap_or(0).max(0);
320        (limit, offset)
321    }
322}
323
324// ── Shared state and types ────────────────────────────────────
325
326pub use roboticus_pipeline::personality::PersonalityState;
327
328/// Tracks a multi-turn personality interview for a single user.
329#[derive(Debug)]
330pub struct InterviewSession {
331    pub history: Vec<roboticus_llm::format::UnifiedMessage>,
332    pub awaiting_confirmation: bool,
333    pub pending_output: Option<roboticus_core::personality::InterviewOutput>,
334    pub created_at: std::time::Instant,
335}
336
337impl Default for InterviewSession {
338    fn default() -> Self {
339        Self::new()
340    }
341}
342
343impl InterviewSession {
344    pub fn new() -> Self {
345        Self {
346            history: vec![roboticus_llm::format::UnifiedMessage {
347                role: "system".into(),
348                content: roboticus_agent::interview::build_interview_prompt(),
349                parts: None,
350            }],
351            awaiting_confirmation: false,
352            pending_output: None,
353            created_at: std::time::Instant::now(),
354        }
355    }
356}
357
358#[derive(Clone)]
359pub struct AppState {
360    pub db: Database,
361    pub config: Arc<RwLock<RoboticusConfig>>,
362    pub llm: Arc<RwLock<LlmService>>,
363    pub wallet: Arc<WalletService>,
364    pub a2a: Arc<RwLock<A2aProtocol>>,
365    pub personality: Arc<RwLock<PersonalityState>>,
366    pub hmac_secret: Arc<Vec<u8>>,
367    pub interviews: Arc<RwLock<HashMap<String, InterviewSession>>>,
368    pub plugins: Arc<PluginRegistry>,
369    pub policy_engine: Arc<PolicyEngine>,
370    pub browser: Arc<Browser>,
371    pub registry: Arc<SubagentRegistry>,
372    pub event_bus: EventBus,
373    pub channel_router: Arc<ChannelRouter>,
374    pub telegram: Option<Arc<TelegramAdapter>>,
375    pub whatsapp: Option<Arc<WhatsAppAdapter>>,
376    pub retriever: Arc<roboticus_agent::retrieval::MemoryRetriever>,
377    pub ann_index: roboticus_db::ann::AnnIndex,
378    pub tools: Arc<ToolRegistry>,
379    /// Mirror of `tools` for LLM catalog and capability-aware execution paths.
380    pub capabilities: Arc<CapabilityRegistry>,
381    pub approvals: Arc<ApprovalManager>,
382    pub discord: Option<Arc<DiscordAdapter>>,
383    pub signal: Option<Arc<SignalAdapter>>,
384    pub email: Option<Arc<EmailAdapter>>,
385    pub voice: Option<Arc<RwLock<VoicePipeline>>>,
386    pub media_service: Option<Arc<MediaService>>,
387    pub discovery: Arc<RwLock<roboticus_agent::discovery::DiscoveryRegistry>>,
388    pub devices: Arc<RwLock<roboticus_agent::device::DeviceManager>>,
389    pub mcp_clients: Arc<RwLock<roboticus_agent::mcp::McpClientManager>>,
390    pub mcp_server: Arc<RwLock<roboticus_agent::mcp::McpServerRegistry>>,
391    pub live_mcp: Arc<roboticus_agent::mcp::manager::McpConnectionManager>,
392    pub oauth: Arc<OAuthManager>,
393    pub keystore: Arc<roboticus_core::keystore::Keystore>,
394    pub obsidian: Option<Arc<RwLock<ObsidianVault>>>,
395    pub started_at: std::time::Instant,
396    pub config_path: Arc<PathBuf>,
397    pub config_apply_status: Arc<RwLock<ConfigApplyStatus>>,
398    pub pending_specialist_proposals: Arc<RwLock<HashMap<String, serde_json::Value>>>,
399    pub ws_tickets: crate::ws_ticket::TicketStore,
400    pub rate_limiter: crate::rate_limit::GlobalRateLimitLayer,
401    /// Shared semantic classifier backed by the embedding client.
402    /// Used for guard pre-computation and specialist workflow detection.
403    pub semantic_classifier: Arc<SemanticClassifier>,
404}
405
406impl AppState {
407    /// Rebuild `CapabilityRegistry` from the current `ToolRegistry` (e.g. after plugin hot-load).
408    pub async fn resync_capabilities_from_tools(&self) {
409        if let Err(e) = self
410            .capabilities
411            .sync_from_tool_registry(Arc::clone(&self.tools))
412            .await
413        {
414            tracing::warn!(error = %e, "capability resync from tools reported errors");
415        }
416    }
417
418    pub async fn reload_personality(&self) {
419        let workspace = {
420            let config = self.config.read().await;
421            config.agent.workspace.clone()
422        };
423        let new_state = PersonalityState::from_workspace(&workspace);
424        tracing::info!(
425            personality = %new_state.identity.name,
426            generated_by = %new_state.identity.generated_by,
427            "Hot-reloaded personality from workspace"
428        );
429        *self.personality.write().await = new_state;
430    }
431}
432
433// ── Capability trait implementations ─────────────────────────────────────
434//
435impl AppState {
436    /// Construct the full pipeline dependency set from AppState.
437    ///
438    /// This is the canonical bridge from the API-side god object to the
439    /// pipeline's trait-scoped deps. Connectors call this once per request.
440    pub(crate) fn pipeline_deps(&self) -> roboticus_pipeline::stage_deps::PipelineDeps<'_> {
441        roboticus_pipeline::stage_deps::PipelineDeps {
442            core: self,
443            security: self,
444            reasoning: self,
445            retrieval: self,
446            coordination: self,
447            personality: self,
448            tooling: self,
449            execution: self,
450            tool_executor: self,
451            inference_runner: self,
452        }
453    }
454}
455
456// These impls bridge AppState (the API-side god object) to the pipeline
457// crate's narrow capability traits. Each stage's dep struct composes
458// these traits, giving it compile-time-enforced access to only the
459// capabilities it declares.
460
461impl roboticus_pipeline::capabilities::PipelineCore for AppState {
462    fn db(&self) -> &Database {
463        &self.db
464    }
465    fn config(&self) -> &Arc<RwLock<RoboticusConfig>> {
466        &self.config
467    }
468}
469
470impl roboticus_pipeline::capabilities::PipelineSecurity for AppState {
471    fn hmac_secret(&self) -> &[u8] {
472        &self.hmac_secret
473    }
474}
475
476impl roboticus_pipeline::capabilities::PipelineReasoning for AppState {
477    fn llm(&self) -> &Arc<RwLock<LlmService>> {
478        &self.llm
479    }
480    fn semantic_classifier(&self) -> &Arc<SemanticClassifier> {
481        &self.semantic_classifier
482    }
483}
484
485impl roboticus_pipeline::capabilities::PipelineRetrieval for AppState {
486    fn retriever(&self) -> &Arc<roboticus_agent::retrieval::MemoryRetriever> {
487        &self.retriever
488    }
489    fn ann_index(&self) -> &roboticus_db::ann::AnnIndex {
490        &self.ann_index
491    }
492}
493
494impl roboticus_pipeline::capabilities::PipelineCoordination for AppState {
495    fn channel_router(&self) -> &Arc<ChannelRouter> {
496        &self.channel_router
497    }
498    fn event_bus(&self) -> &roboticus_pipeline::event_bus::EventBus {
499        &self.event_bus
500    }
501}
502
503impl roboticus_pipeline::capabilities::PipelinePersonality for AppState {
504    fn personality(&self) -> &Arc<RwLock<PersonalityState>> {
505        &self.personality
506    }
507}
508
509impl roboticus_pipeline::capabilities::PipelineTooling for AppState {
510    fn tools(&self) -> &Arc<ToolRegistry> {
511        &self.tools
512    }
513    fn capabilities(&self) -> &Arc<CapabilityRegistry> {
514        &self.capabilities
515    }
516    fn policy_engine(&self) -> &Arc<PolicyEngine> {
517        &self.policy_engine
518    }
519    fn approvals(&self) -> &Arc<ApprovalManager> {
520        &self.approvals
521    }
522    fn registry(&self) -> &Arc<SubagentRegistry> {
523        &self.registry
524    }
525    fn plugins(&self) -> &Arc<PluginRegistry> {
526        &self.plugins
527    }
528    fn mcp_server(&self) -> &Arc<RwLock<roboticus_agent::mcp::McpServerRegistry>> {
529        &self.mcp_server
530    }
531    fn pending_specialist_proposals(&self) -> &Arc<RwLock<HashMap<String, serde_json::Value>>> {
532        &self.pending_specialist_proposals
533    }
534}
535
536impl roboticus_pipeline::capabilities::PipelineExecution for AppState {
537    fn keystore(&self) -> &Arc<roboticus_core::Keystore> {
538        &self.keystore
539    }
540    fn oauth(&self) -> &Arc<OAuthManager> {
541        &self.oauth
542    }
543    fn live_mcp(&self) -> &Arc<roboticus_agent::mcp::manager::McpConnectionManager> {
544        &self.live_mcp
545    }
546    fn mcp_clients(&self) -> &Arc<RwLock<roboticus_agent::mcp::McpClientManager>> {
547        &self.mcp_clients
548    }
549    fn browser(&self) -> &Arc<Browser> {
550        &self.browser
551    }
552}
553
554#[async_trait::async_trait]
555impl roboticus_pipeline::tool_executor::ToolExecutor for AppState {
556    async fn execute_tool(
557        &self,
558        tool_name: &str,
559        params: &serde_json::Value,
560        turn_id: &str,
561        authority: roboticus_core::InputAuthority,
562        channel: Option<&str>,
563    ) -> Result<String, String> {
564        agent::execute_tool_call(self, tool_name, params, turn_id, authority, channel).await
565    }
566
567    async fn execute_tool_detailed(
568        &self,
569        tool_name: &str,
570        params: &serde_json::Value,
571        turn_id: &str,
572        authority: roboticus_core::InputAuthority,
573        channel: Option<&str>,
574    ) -> Result<roboticus_pipeline::tool_executor::ToolExecutionDetails, String> {
575        let details =
576            agent::execute_tool_call_detailed(self, tool_name, params, turn_id, authority, channel)
577                .await?;
578        Ok(roboticus_pipeline::tool_executor::ToolExecutionDetails {
579            output: details.output,
580            source: details.source,
581        })
582    }
583}
584
585#[async_trait::async_trait]
586impl roboticus_pipeline::capabilities::InferenceRunner for AppState {
587    async fn select_and_audit_model(
588        &self,
589        user_content: &str,
590        session_id: &str,
591        turn_id: &str,
592        channel_label: &str,
593        complexity: Option<&str>,
594    ) -> String {
595        let audit =
596            agent::select_routed_model_with_audit(self, user_content, Some(session_id)).await;
597        let selected = audit.selected_model.clone();
598        agent::persist_model_selection_audit(
599            self,
600            self,
601            turn_id,
602            session_id,
603            channel_label,
604            complexity,
605            user_content,
606            &audit,
607        )
608        .await;
609        selected
610    }
611
612    async fn infer_with_fallback(
613        &self,
614        request: &roboticus_llm::format::UnifiedRequest,
615        initial_model: &str,
616    ) -> Result<roboticus_pipeline::core_types::InferenceResult, String> {
617        let result = agent::infer_with_fallback(self, request, initial_model).await?;
618        Ok(roboticus_pipeline::core_types::InferenceResult {
619            content: result.content,
620            model: result.model,
621            provider: result.provider,
622            tokens_in: result.tokens_in,
623            tokens_out: result.tokens_out,
624            cost: result.cost,
625            latency_ms: result.latency_ms,
626            quality_score: result.quality_score,
627            escalated: result.escalated,
628        })
629    }
630
631    async fn infer_stream_with_fallback(
632        &self,
633        request: &roboticus_llm::format::UnifiedRequest,
634        initial_model: &str,
635    ) -> Result<roboticus_pipeline::core_types::StreamResolvedInference, String> {
636        let ctx = agent::infer_stream_with_fallback(self, request, initial_model).await?;
637        Ok(roboticus_pipeline::core_types::StreamResolvedInference {
638            stream: ctx.stream,
639            selected_model: ctx.selected_model,
640            provider_prefix: ctx.provider_prefix,
641            cost_in: ctx.cost_in,
642            cost_out: ctx.cost_out,
643        })
644    }
645}
646
647// ── JSON error normalization middleware ────────────────────────
648//
649// BUG-006/014/016/017: axum returns plain-text bodies for its built-in
650// rejections (JSON parse errors, wrong Content-Type, 405 Method Not
651// Allowed). This middleware intercepts any non-JSON error response and
652// wraps it in the standard `{"error":"..."}` format.
653
654async fn json_error_layer(
655    req: axum::extract::Request,
656    next: middleware::Next,
657) -> axum::response::Response {
658    let response = next.run(req).await;
659    let status = response.status();
660
661    if !(status.is_client_error() || status.is_server_error()) {
662        return response;
663    }
664
665    let content_type = response
666        .headers()
667        .get(axum::http::header::CONTENT_TYPE)
668        .and_then(|v| v.to_str().ok())
669        .unwrap_or("");
670    // Already RFC 9457 or structured JSON — pass through
671    if content_type.contains("application/problem+json")
672        || content_type.contains("application/json")
673    {
674        return response;
675    }
676
677    let code = response.status();
678    let (_parts, body) = response.into_parts();
679    let bytes = match axum::body::to_bytes(body, 8192).await {
680        Ok(b) => b,
681        Err(e) => {
682            tracing::warn!(error = %e, "failed to read response body for JSON wrapping");
683            axum::body::Bytes::new()
684        }
685    };
686    let original_text = String::from_utf8_lossy(&bytes);
687
688    let error_msg = if original_text.trim().is_empty() {
689        match code {
690            axum::http::StatusCode::METHOD_NOT_ALLOWED => "method not allowed".to_string(),
691            axum::http::StatusCode::NOT_FOUND => "not found".to_string(),
692            axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE => {
693                "unsupported content type: expected application/json".to_string()
694            }
695            other => other.to_string(),
696        }
697    } else {
698        sanitize_error_message(original_text.trim())
699    };
700
701    let json_body = problem_json(code, &error_msg);
702    let body_bytes = serde_json::to_vec(&json_body)
703        .unwrap_or_else(|_| br#"{"type":"about:blank","title":"Internal Server Error","status":500,"detail":"internal error"}"#.to_vec());
704    let mut resp = axum::response::Response::new(axum::body::Body::from(body_bytes));
705    *resp.status_mut() = code;
706    resp.headers_mut().insert(
707        axum::http::header::CONTENT_TYPE,
708        axum::http::HeaderValue::from_static("application/problem+json"),
709    );
710    resp
711}
712
713// ── Security headers ─────────────────────────────────────────────
714// BUG-018: Content-Security-Policy
715// BUG-019: X-Frame-Options
716
717// CSP nonce handling: the dashboard handler generates a per-request nonce,
718// injects it into inline <script> tags, and sets its own CSP header with
719// 'nonce-<value>'.  This middleware only sets the default (non-inline) CSP
720// for responses that do not already carry a Content-Security-Policy header
721// (i.e. API endpoints, redirects, etc.).
722
723async fn security_headers_layer(
724    req: axum::extract::Request,
725    next: middleware::Next,
726) -> axum::response::Response {
727    let mut response = next.run(req).await;
728    let headers = response.headers_mut();
729
730    // Only set the default CSP if the handler did not already set one
731    // (the dashboard handler sets a nonce-based CSP).
732    let csp_name = axum::http::header::HeaderName::from_static("content-security-policy");
733    if !headers.contains_key(&csp_name) {
734        headers.insert(
735            csp_name,
736            axum::http::HeaderValue::from_static(
737                "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:; frame-ancestors 'none'",
738            ),
739        );
740    }
741    headers.insert(
742        axum::http::header::X_FRAME_OPTIONS,
743        axum::http::HeaderValue::from_static("DENY"),
744    );
745    headers.insert(
746        axum::http::header::X_CONTENT_TYPE_OPTIONS,
747        axum::http::HeaderValue::from_static("nosniff"),
748    );
749    response
750}
751
752async fn dashboard_redirect() -> axum::response::Redirect {
753    axum::response::Redirect::permanent("/")
754}
755
756// ── Router ──────────────────────────────────────────────────────
757
758pub fn build_router(state: AppState) -> Router {
759    use admin::{
760        a2a_hello, breaker_open, breaker_reset, breaker_status, browser_action, browser_start,
761        browser_status, browser_stop, change_agent_model, confirm_revenue_swap_task,
762        confirm_revenue_tax_task, create_service_quote, delete_provider_key, execute_plugin_tool,
763        fail_revenue_swap_task, fail_revenue_tax_task, fail_service_request,
764        fulfill_revenue_opportunity, fulfill_service_request, generate_deep_analysis, get_agents,
765        get_available_models, get_cache_stats, get_capacity_stats, get_config,
766        get_config_apply_status, get_config_capabilities, get_config_raw, get_costs,
767        get_efficiency, get_mcp_runtime, get_memory_analytics, get_overview_timeseries,
768        get_plugins, get_recommendations, get_revenue_opportunity, get_routing_dataset,
769        get_routing_diagnostics, get_runtime_surfaces, get_service_request, get_task_events,
770        get_throttle_stats, get_transactions, get_workspace_tasks, intake_micro_bounty_opportunity,
771        intake_oracle_feed_opportunity, intake_revenue_opportunity, keystore_status,
772        keystore_unlock, list_discovered_agents, list_paired_devices, list_revenue_opportunities,
773        list_revenue_swap_tasks, list_revenue_tax_tasks, list_services_catalog,
774        mcp_client_disconnect, mcp_client_discover, pair_device, plan_revenue_opportunity,
775        qualify_revenue_opportunity, reconcile_revenue_swap_task, reconcile_revenue_tax_task,
776        record_revenue_opportunity_feedback, register_discovered_agent, roster, run_routing_eval,
777        score_revenue_opportunity, set_provider_key, settle_revenue_opportunity, start_agent,
778        start_revenue_swap_task, start_revenue_tax_task, stop_agent, submit_revenue_swap_task,
779        submit_revenue_tax_task, toggle_plugin, unpair_device, update_config, update_config_raw,
780        verify_discovered_agent, verify_paired_device, verify_service_payment, wallet_address,
781        wallet_balance, workspace_state,
782    };
783    use agent::{agent_message, agent_message_stream, agent_status};
784    use channels::{
785        get_channels_status, get_dead_letters, get_integrations, replay_dead_letter, test_channel,
786    };
787    use cron::{
788        create_cron_job, delete_cron_job, get_cron_job, list_cron_jobs, list_cron_runs,
789        run_cron_job_now, update_cron_job,
790    };
791    use health::{get_logs, health};
792    use memory::{
793        get_episodic_memory, get_semantic_categories, get_semantic_memory, get_semantic_memory_all,
794        get_working_memory, get_working_memory_all, knowledge_ingest, memory_health, memory_search,
795        trigger_consolidation, trigger_reindex,
796    };
797    use sessions::{
798        analyze_session, analyze_turn, archive_session_handler, backfill_nicknames, create_session,
799        get_session, get_session_feedback, get_session_insights, get_turn, get_turn_context,
800        get_turn_feedback, get_turn_model_selection, get_turn_tips, get_turn_tools, list_messages,
801        list_model_selection_events, list_session_turns, list_sessions, post_message,
802        post_turn_feedback, put_turn_feedback,
803    };
804    use skills::{
805        audit_skills, catalog_activate, catalog_install, catalog_list, delete_skill, get_skill,
806        list_skills, reload_skills, toggle_skill, update_skill,
807    };
808    use subagents::{
809        create_sub_agent, delete_sub_agent, get_subagent_retirement_candidates, list_sub_agents,
810        retire_unused_subagents, toggle_sub_agent, update_sub_agent,
811    };
812    use themes::{install_catalog_theme, list_theme_catalog, list_themes};
813
814    Router::new()
815        .route("/", get(crate::dashboard::dashboard_handler))
816        .route("/dashboard", get(dashboard_redirect))
817        .route("/dashboard/", get(dashboard_redirect))
818        .route("/api/health", get(health))
819        .route("/health", get(health))
820        .route("/api/config", get(get_config).put(update_config))
821        .route(
822            "/api/config/raw",
823            get(get_config_raw).put(update_config_raw),
824        )
825        .route("/api/config/capabilities", get(get_config_capabilities))
826        .route("/api/config/status", get(get_config_apply_status))
827        .route(
828            "/api/providers/{name}/key",
829            put(set_provider_key).delete(delete_provider_key),
830        )
831        .route("/api/keystore/status", get(keystore_status))
832        .route("/api/keystore/unlock", post(keystore_unlock))
833        .route("/api/logs", get(get_logs))
834        .route("/api/sessions", get(list_sessions).post(create_session))
835        .route("/api/sessions/backfill-nicknames", post(backfill_nicknames))
836        .route("/api/sessions/{id}", get(get_session))
837        .route(
838            "/api/sessions/{id}/messages",
839            get(list_messages).post(post_message),
840        )
841        .route("/api/sessions/{id}/turns", get(list_session_turns))
842        .route("/api/sessions/{id}/archive", post(archive_session_handler))
843        .route("/api/sessions/{id}/insights", get(get_session_insights))
844        .route("/api/sessions/{id}/feedback", get(get_session_feedback))
845        .route("/api/turns/{id}", get(get_turn))
846        .route("/api/turns/{id}/context", get(get_turn_context))
847        .route(
848            "/api/turns/{id}/model-selection",
849            get(get_turn_model_selection),
850        )
851        .route("/api/turns/{id}/tools", get(get_turn_tools))
852        .route("/api/turns/{id}/tips", get(get_turn_tips))
853        .route("/api/models/selections", get(list_model_selection_events))
854        .route(
855            "/api/turns/{id}/feedback",
856            get(get_turn_feedback)
857                .post(post_turn_feedback)
858                .put(put_turn_feedback),
859        )
860        .route("/api/memory/working", get(get_working_memory_all))
861        .route("/api/memory/working/{session_id}", get(get_working_memory))
862        .route("/api/memory/episodic", get(get_episodic_memory))
863        .route("/api/memory/semantic", get(get_semantic_memory_all))
864        .route(
865            "/api/memory/semantic/categories",
866            get(get_semantic_categories),
867        )
868        .route("/api/memory/semantic/{category}", get(get_semantic_memory))
869        .route("/api/memory/search", get(memory_search))
870        .route("/api/memory/health", get(memory_health))
871        .route("/api/memory/consolidate", post(trigger_consolidation))
872        .route("/api/memory/reindex", post(trigger_reindex))
873        .route("/api/knowledge/ingest", post(knowledge_ingest))
874        .route("/api/cron/jobs", get(list_cron_jobs).post(create_cron_job))
875        .route("/api/cron/runs", get(list_cron_runs))
876        .route(
877            "/api/cron/jobs/{id}",
878            get(get_cron_job)
879                .put(update_cron_job)
880                .delete(delete_cron_job),
881        )
882        .route(
883            "/api/cron/jobs/{id}/run",
884            axum::routing::post(run_cron_job_now),
885        )
886        .route("/api/stats/costs", get(get_costs))
887        .route("/api/stats/timeseries", get(get_overview_timeseries))
888        .route("/api/stats/efficiency", get(get_efficiency))
889        .route("/api/stats/memory-analytics", get(get_memory_analytics))
890        .route("/api/recommendations", get(get_recommendations))
891        .route("/api/stats/transactions", get(get_transactions))
892        .route("/api/services/catalog", get(list_services_catalog))
893        .route("/api/services/quote", post(create_service_quote))
894        .route("/api/services/requests/{id}", get(get_service_request))
895        .route(
896            "/api/services/requests/{id}/payment/verify",
897            post(verify_service_payment),
898        )
899        .route(
900            "/api/services/requests/{id}/fulfill",
901            post(fulfill_service_request),
902        )
903        .route(
904            "/api/services/requests/{id}/fail",
905            post(fail_service_request),
906        )
907        .route(
908            "/api/services/opportunities/intake",
909            get(list_revenue_opportunities).post(intake_revenue_opportunity),
910        )
911        .route(
912            "/api/services/opportunities/adapters/micro-bounty/intake",
913            post(intake_micro_bounty_opportunity),
914        )
915        .route(
916            "/api/services/opportunities/adapters/oracle-feed/intake",
917            post(intake_oracle_feed_opportunity),
918        )
919        .route(
920            "/api/services/opportunities/{id}",
921            get(get_revenue_opportunity),
922        )
923        .route(
924            "/api/services/opportunities/{id}/score",
925            post(score_revenue_opportunity),
926        )
927        .route(
928            "/api/services/opportunities/{id}/qualify",
929            post(qualify_revenue_opportunity),
930        )
931        .route(
932            "/api/services/opportunities/{id}/feedback",
933            post(record_revenue_opportunity_feedback),
934        )
935        .route(
936            "/api/services/opportunities/{id}/plan",
937            post(plan_revenue_opportunity),
938        )
939        .route(
940            "/api/services/opportunities/{id}/fulfill",
941            post(fulfill_revenue_opportunity),
942        )
943        .route(
944            "/api/services/opportunities/{id}/settle",
945            post(settle_revenue_opportunity),
946        )
947        .route("/api/services/swaps", get(list_revenue_swap_tasks))
948        .route("/api/services/tax-payouts", get(list_revenue_tax_tasks))
949        .route(
950            "/api/services/swaps/{id}/start",
951            post(start_revenue_swap_task),
952        )
953        .route(
954            "/api/services/swaps/{id}/submit",
955            post(submit_revenue_swap_task),
956        )
957        .route(
958            "/api/services/swaps/{id}/reconcile",
959            post(reconcile_revenue_swap_task),
960        )
961        .route(
962            "/api/services/swaps/{id}/confirm",
963            post(confirm_revenue_swap_task),
964        )
965        .route(
966            "/api/services/swaps/{id}/fail",
967            post(fail_revenue_swap_task),
968        )
969        .route(
970            "/api/services/tax-payouts/{id}/start",
971            post(start_revenue_tax_task),
972        )
973        .route(
974            "/api/services/tax-payouts/{id}/submit",
975            post(submit_revenue_tax_task),
976        )
977        .route(
978            "/api/services/tax-payouts/{id}/reconcile",
979            post(reconcile_revenue_tax_task),
980        )
981        .route(
982            "/api/services/tax-payouts/{id}/confirm",
983            post(confirm_revenue_tax_task),
984        )
985        .route(
986            "/api/services/tax-payouts/{id}/fail",
987            post(fail_revenue_tax_task),
988        )
989        .route("/api/stats/cache", get(get_cache_stats))
990        .route("/api/stats/capacity", get(get_capacity_stats))
991        .route("/api/stats/throttle", get(get_throttle_stats))
992        .route("/api/models/available", get(get_available_models))
993        .route(
994            "/api/models/routing-diagnostics",
995            get(get_routing_diagnostics),
996        )
997        .route("/api/models/routing-dataset", get(get_routing_dataset))
998        .route("/api/models/routing-eval", post(run_routing_eval))
999        .route("/api/models/reset", post(admin::reset_model_scores))
1000        .route("/api/breaker/status", get(breaker_status))
1001        .route("/api/breaker/open/{provider}", post(breaker_open))
1002        .route("/api/breaker/reset/{provider}", post(breaker_reset))
1003        .route("/api/agent/status", get(agent_status))
1004        .route("/api/agent/message", post(agent_message))
1005        .route("/api/agent/message/stream", post(agent_message_stream))
1006        .route("/api/wallet/balance", get(wallet_balance))
1007        .route("/api/wallet/address", get(wallet_address))
1008        .route("/api/skills", get(list_skills))
1009        .route("/api/skills/catalog", get(catalog_list))
1010        .route("/api/skills/catalog/install", post(catalog_install))
1011        .route("/api/skills/catalog/activate", post(catalog_activate))
1012        .route("/api/skills/audit", get(audit_skills))
1013        .route(
1014            "/api/skills/{id}",
1015            get(get_skill).put(update_skill).delete(delete_skill),
1016        )
1017        .route("/api/skills/reload", post(reload_skills))
1018        .route("/api/skills/{id}/toggle", put(toggle_skill))
1019        .route("/api/themes", get(list_themes))
1020        .route("/api/themes/catalog", get(list_theme_catalog))
1021        .route("/api/themes/catalog/install", post(install_catalog_theme))
1022        .route("/api/plugins/catalog/install", post(catalog_install))
1023        .route("/api/plugins", get(get_plugins))
1024        .route("/api/plugins/{name}/toggle", put(toggle_plugin))
1025        .route(
1026            "/api/plugins/{name}/execute/{tool}",
1027            post(execute_plugin_tool),
1028        )
1029        .route("/api/browser/status", get(browser_status))
1030        .route("/api/browser/start", post(browser_start))
1031        .route("/api/browser/stop", post(browser_stop))
1032        .route("/api/browser/action", post(browser_action))
1033        .route("/api/agents", get(get_agents))
1034        .route("/api/agents/{id}/start", post(start_agent))
1035        .route("/api/agents/{id}/stop", post(stop_agent))
1036        .route(
1037            "/api/subagents",
1038            get(list_sub_agents).post(create_sub_agent),
1039        )
1040        .route(
1041            "/api/subagents/retirement-candidates",
1042            get(get_subagent_retirement_candidates),
1043        )
1044        .route(
1045            "/api/subagents/retire-unused",
1046            post(retire_unused_subagents),
1047        )
1048        .route(
1049            "/api/subagents/{name}",
1050            put(update_sub_agent).delete(delete_sub_agent),
1051        )
1052        .route("/api/subagents/{name}/toggle", put(toggle_sub_agent))
1053        .route("/api/workspace/state", get(workspace_state))
1054        .route("/api/workspace/tasks", get(get_workspace_tasks))
1055        .route("/api/admin/task-events", get(get_task_events))
1056        .route("/api/roster", get(roster))
1057        .route("/api/roster/{name}/model", put(change_agent_model))
1058        .route("/api/a2a/hello", post(a2a_hello))
1059        .route("/api/channels/status", get(get_channels_status))
1060        .route("/api/integrations", get(get_integrations))
1061        .route("/api/channels/{platform}/test", post(test_channel))
1062        .route("/api/channels/dead-letter", get(get_dead_letters))
1063        .route(
1064            "/api/channels/dead-letter/{id}/replay",
1065            post(replay_dead_letter),
1066        )
1067        .route("/api/runtime/surfaces", get(get_runtime_surfaces))
1068        .route(
1069            "/api/runtime/discovery",
1070            get(list_discovered_agents).post(register_discovered_agent),
1071        )
1072        .route(
1073            "/api/runtime/discovery/{id}/verify",
1074            post(verify_discovered_agent),
1075        )
1076        .route("/api/runtime/devices", get(list_paired_devices))
1077        .route("/api/runtime/devices/pair", post(pair_device))
1078        .route(
1079            "/api/runtime/devices/{id}/verify",
1080            post(verify_paired_device),
1081        )
1082        .route(
1083            "/api/runtime/devices/{id}",
1084            axum::routing::delete(unpair_device),
1085        )
1086        .route("/api/runtime/mcp", get(get_mcp_runtime))
1087        .route(
1088            "/api/runtime/mcp/clients/{name}/discover",
1089            post(mcp_client_discover),
1090        )
1091        .route(
1092            "/api/runtime/mcp/clients/{name}/disconnect",
1093            post(mcp_client_disconnect),
1094        )
1095        .route("/api/mcp/servers", get(mcp::list_servers))
1096        .route("/api/mcp/servers/{name}", get(mcp::get_server))
1097        .route("/api/mcp/servers/{name}/test", post(mcp::test_server))
1098        .route("/api/approvals", get(admin::list_approvals))
1099        .route("/api/approvals/{id}/approve", post(admin::approve_request))
1100        .route("/api/approvals/{id}/deny", post(admin::deny_request))
1101        .route("/api/ws-ticket", post(admin::issue_ws_ticket))
1102        .route("/api/interview/start", post(interview::start_interview))
1103        .route("/api/interview/turn", post(interview::interview_turn))
1104        .route("/api/interview/finish", post(interview::finish_interview))
1105        .route("/api/audit/policy/{turn_id}", get(admin::get_policy_audit))
1106        .route("/api/audit/tools/{turn_id}", get(admin::get_tool_audit))
1107        .route("/api/traces/{turn_id}", get(traces::get_trace))
1108        .route(
1109            "/api/traces/{turn_id}/react",
1110            get(traces::get_react_trace_handler),
1111        )
1112        .route("/api/traces/{turn_id}/export", get(traces::export_trace))
1113        .route("/api/traces/{turn_id}/flow", get(traces::get_trace_flow))
1114        .route(
1115            "/api/traces/{turn_id}/replay",
1116            axum::routing::post(traces::replay_trace),
1117        )
1118        .route("/api/traces/search", get(traces::search_traces))
1119        .route("/api/observability/traces", get(observability::list_traces))
1120        .route(
1121            "/api/observability/traces/{turn_id}/waterfall",
1122            get(observability::trace_waterfall),
1123        )
1124        .route(
1125            "/api/observability/delegation/outcomes",
1126            get(observability::delegation_outcomes),
1127        )
1128        .route(
1129            "/api/observability/delegation/stats",
1130            get(observability::delegation_stats),
1131        )
1132        .route(
1133            "/favicon.ico",
1134            get(|| async { axum::http::StatusCode::NO_CONTENT }),
1135        )
1136        // LLM analysis routes have their own concurrency limit to prevent
1137        // expensive analysis requests from starving lightweight API calls.
1138        .merge(
1139            Router::new()
1140                .route("/api/sessions/{id}/analyze", post(analyze_session))
1141                .route("/api/turns/{id}/analyze", post(analyze_turn))
1142                .route(
1143                    "/api/recommendations/generate",
1144                    post(generate_deep_analysis),
1145                )
1146                .layer(tower::limit::ConcurrencyLimitLayer::new(3))
1147                .with_state(state.clone()),
1148        )
1149        .fallback(|| async { JsonError(axum::http::StatusCode::NOT_FOUND, "not found".into()) })
1150        .layer(DefaultBodyLimit::max(1024 * 1024)) // 1MB
1151        .layer(middleware::from_fn(json_error_layer))
1152        .layer(middleware::from_fn(security_headers_layer))
1153        // CORS is applied at the top level in lib.rs (wraps authed + ws + public).
1154        .with_state(state)
1155}
1156
1157/// Routes that must be accessible without API key authentication
1158/// (webhooks from external services, discovery endpoints).
1159pub fn build_public_router(state: AppState) -> Router {
1160    use admin::agent_card;
1161    use channels::{webhook_telegram, webhook_whatsapp, webhook_whatsapp_verify};
1162
1163    Router::new()
1164        .route("/.well-known/agent.json", get(agent_card))
1165        .route("/api/webhooks/telegram", post(webhook_telegram))
1166        .route(
1167            "/api/webhooks/whatsapp",
1168            get(webhook_whatsapp_verify).post(webhook_whatsapp),
1169        )
1170        .layer(DefaultBodyLimit::max(1024 * 1024)) // 1MB — match auth router
1171        .with_state(state)
1172}
1173
1174// ── MCP Gateway (P.1) ─────────────────────────────────────────
1175
1176/// Builds an axum `Router` that serves the MCP protocol endpoint.
1177///
1178/// The returned router should be merged at the top level — it handles
1179/// its own transport (POST for JSON-RPC, GET for SSE, DELETE for sessions)
1180/// under the `/mcp` prefix via rmcp's `StreamableHttpService`.
1181///
1182/// Auth: MCP clients authenticate via `Authorization: Bearer <api_key>`.
1183/// The same API key used for the REST API is accepted here.
1184pub fn build_mcp_router(state: &AppState, api_key: Option<String>) -> Router {
1185    use crate::auth::ApiKeyLayer;
1186    use rmcp::transport::streamable_http_server::{
1187        StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
1188    };
1189    use roboticus_agent::mcp_handler::{McpToolContext, RoboticusMcpHandler};
1190    use std::time::Duration;
1191
1192    let mcp_ctx = {
1193        let (workspace_root, agent_name, tool_allowed_paths, sandbox) = state
1194            .config
1195            .try_read()
1196            .map(|c| {
1197                (
1198                    c.agent.workspace.clone(),
1199                    c.agent.name.clone(),
1200                    c.security.filesystem.tool_allowed_paths.clone(),
1201                    roboticus_agent::tools::ToolSandboxSnapshot::from_config(
1202                        &c.security.filesystem,
1203                        &c.skills,
1204                    ),
1205                )
1206            })
1207            .unwrap_or_else(|_| {
1208                (
1209                    std::path::PathBuf::from("."),
1210                    "roboticus".to_string(),
1211                    Vec::new(),
1212                    roboticus_agent::tools::ToolSandboxSnapshot::default(),
1213                )
1214            });
1215        McpToolContext {
1216            agent_id: "roboticus-mcp-gateway".to_string(),
1217            agent_name,
1218            workspace_root,
1219            tool_allowed_paths,
1220            sandbox,
1221            db: Some(state.db.clone()),
1222        }
1223    };
1224
1225    let handler = RoboticusMcpHandler::new(state.tools.clone(), mcp_ctx);
1226
1227    let config = StreamableHttpServerConfig {
1228        sse_keep_alive: Some(Duration::from_secs(15)),
1229        stateful_mode: true,
1230        ..Default::default()
1231    };
1232
1233    let service = StreamableHttpService::new(
1234        move || Ok(handler.clone()),
1235        Arc::new(LocalSessionManager::default()),
1236        config,
1237    );
1238
1239    Router::new()
1240        .nest_service("/mcp", service)
1241        .layer(ApiKeyLayer::new(api_key))
1242}
1243
1244// ── Re-exports for api.rs and lib.rs ────────────────────────────
1245
1246pub use agent::{discord_poll_loop, email_poll_loop, signal_poll_loop, telegram_poll_loop};
1247pub use health::LogEntry;
1248
1249// ── Tests ─────────────────────────────────────────────────────
1250
1251#[cfg(test)]
1252#[path = "tests.rs"]
1253mod tests;