Skip to main content

ares/api/handlers/
admin.rs

1use crate::db::agent_runs;
2use crate::db::agent_versions;
3use crate::db::alerts as db_alerts;
4use crate::db::audit_log;
5use crate::db::tenant_agents::{
6    clone_templates_for_tenant, create_tenant_agent as db_create_tenant_agent,
7    delete_tenant_agent as db_delete_tenant_agent, list_agent_templates,
8    list_tenant_agents as db_list_tenant_agents, update_tenant_agent as db_update_tenant_agent,
9    AgentTemplate, CreateTenantAgentRequest, TenantAgent, UpdateTenantAgentRequest,
10};
11use crate::db::tenants::UsageSummary;
12use crate::llm::provider_registry::ModelInfo;
13use crate::models::{Tenant, TenantTier};
14use crate::types::{AppError, Result};
15use crate::AppState;
16use axum::{
17    extract::{Path, Query, State},
18    http::StatusCode,
19    middleware::Next,
20    response::Response,
21    Json,
22};
23use serde::{Deserialize, Serialize};
24use std::collections::HashMap;
25
26/// Extended JWT claims that include Eruka's roles map.
27#[derive(Debug, Deserialize)]
28struct AdminClaims {
29    pub sub: String,
30    pub email: String,
31    pub exp: usize,
32    pub iat: usize,
33    #[serde(default)]
34    pub roles: HashMap<String, Vec<RoleEntry>>,
35}
36
37#[derive(Debug, Deserialize)]
38struct RoleEntry {
39    pub role: String,
40    #[allow(dead_code)]
41    pub resource_id: Option<String>,
42}
43
44/// Check if JWT claims have admin role in any of: "admin", "ares", "eruka".
45fn has_admin_role(claims: &AdminClaims) -> bool {
46    for product in ["admin", "ares", "eruka"] {
47        if let Some(entries) = claims.roles.get(product) {
48            if entries.iter().any(|e| e.role == "admin") {
49                return true;
50            }
51        }
52    }
53    false
54}
55
56pub async fn admin_middleware(req: axum::extract::Request, next: Next) -> Response {
57    // Method 1: X-Admin-Secret header (legacy, backward-compatible)
58    let admin_secret = std::env::var("ADMIN_API_KEY").ok();
59    let header_secret = req
60        .headers()
61        .get("x-admin-secret")
62        .and_then(|v| v.to_str().ok())
63        .map(String::from);
64
65    if let (Some(expected), Some(given)) = (&admin_secret, &header_secret) {
66        if expected == given {
67            return next.run(req).await;
68        }
69    }
70
71    // Method 2: JWT Bearer token with admin role
72    let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_default();
73    if !jwt_secret.is_empty() {
74        if let Some(token) = req
75            .headers()
76            .get("authorization")
77            .and_then(|v| v.to_str().ok())
78            .and_then(|v| v.strip_prefix("Bearer "))
79        {
80            let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256);
81            validation.leeway = 60;
82            if let Ok(data) = jsonwebtoken::decode::<AdminClaims>(
83                token,
84                &jsonwebtoken::DecodingKey::from_secret(jwt_secret.as_bytes()),
85                &validation,
86            ) {
87                if has_admin_role(&data.claims) {
88                    return next.run(req).await;
89                }
90            }
91        }
92    }
93
94    Response::builder()
95        .status(StatusCode::UNAUTHORIZED)
96        .header("Content-Type", "application/json")
97        .body(r#"{"error":"Admin access requires X-Admin-Secret header or JWT with admin role"}"#.into())
98        .unwrap()
99}
100
101#[derive(Debug, Deserialize, Serialize)]
102pub struct CreateTenantRequest {
103    pub name: String,
104    pub tier: String,
105}
106
107#[derive(Debug, Deserialize, Serialize)]
108pub struct CreateApiKeyRequest {
109    pub name: String,
110}
111
112#[derive(Debug, Deserialize, Serialize)]
113pub struct UpdateQuotaRequest {
114    pub tier: String,
115}
116
117#[derive(Debug, Serialize)]
118pub struct TenantResponse {
119    pub id: String,
120    pub name: String,
121    pub tier: String,
122    pub created_at: i64,
123}
124
125impl From<Tenant> for TenantResponse {
126    fn from(t: Tenant) -> Self {
127        Self {
128            id: t.id,
129            name: t.name,
130            tier: t.tier.as_str().to_string(),
131            created_at: t.created_at,
132        }
133    }
134}
135
136#[derive(Debug, Serialize)]
137pub struct ApiKeyResponse {
138    pub id: String,
139    pub tenant_id: String,
140    pub key_prefix: String,
141    pub name: String,
142    pub is_active: bool,
143    pub created_at: i64,
144}
145
146impl From<crate::models::ApiKey> for ApiKeyResponse {
147    fn from(k: crate::models::ApiKey) -> Self {
148        Self {
149            id: k.id,
150            tenant_id: k.tenant_id,
151            key_prefix: k.key_prefix,
152            name: k.name,
153            is_active: k.is_active,
154            created_at: k.created_at,
155        }
156    }
157}
158
159#[derive(Debug, Serialize)]
160pub struct UsageResponse {
161    pub monthly_requests: u64,
162    pub monthly_tokens: u64,
163    pub daily_requests: u64,
164}
165
166impl From<UsageSummary> for UsageResponse {
167    fn from(u: UsageSummary) -> Self {
168        Self {
169            monthly_requests: u.monthly_requests,
170            monthly_tokens: u.monthly_tokens,
171            daily_requests: u.daily_requests,
172        }
173    }
174}
175
176pub async fn create_tenant(
177    State(state): State<AppState>,
178    Json(payload): Json<CreateTenantRequest>,
179) -> Result<Json<TenantResponse>> {
180    let tier = TenantTier::from_str(&payload.tier).ok_or_else(|| {
181        AppError::InvalidInput("Invalid tier. Must be: free, dev, pro, or enterprise".to_string())
182    })?;
183
184    let tenant = state.tenant_db.create_tenant(payload.name, tier).await?;
185
186    let pool = state.tenant_db.pool().clone();
187    let tid = tenant.id.clone();
188    tokio::spawn(async move {
189        let _ =
190            audit_log::log_admin_action(&pool, "create_tenant", "tenant", &tid, None, None).await;
191    });
192
193    Ok(Json(TenantResponse::from(tenant)))
194}
195
196pub async fn list_tenants(State(state): State<AppState>) -> Result<Json<Vec<TenantResponse>>> {
197    let tenants = state.tenant_db.list_tenants().await?;
198    let response: Vec<TenantResponse> = tenants.into_iter().map(|t| t.into()).collect();
199
200    Ok(Json(response))
201}
202
203pub async fn get_tenant(
204    State(state): State<AppState>,
205    Path(tenant_id): Path<String>,
206) -> Result<Json<TenantResponse>> {
207    let tenant = state
208        .tenant_db
209        .get_tenant(&tenant_id)
210        .await?
211        .ok_or_else(|| AppError::NotFound("Tenant not found".to_string()))?;
212
213    Ok(Json(TenantResponse::from(tenant)))
214}
215
216pub async fn create_api_key(
217    State(state): State<AppState>,
218    Path(tenant_id): Path<String>,
219    Json(payload): Json<CreateApiKeyRequest>,
220) -> Result<Json<serde_json::Value>> {
221    let (api_key, raw_key) = state
222        .tenant_db
223        .create_api_key(&tenant_id, payload.name)
224        .await?;
225
226    let pool = state.tenant_db.pool().clone();
227    let kid = api_key.id.clone();
228    tokio::spawn(async move {
229        let _ =
230            audit_log::log_admin_action(&pool, "create_api_key", "api_key", &kid, None, None).await;
231    });
232
233    Ok(Json(serde_json::json!({
234        "api_key": api_key,
235        "raw_key": raw_key,
236        "warning": "Store this raw key securely. You will not be able to retrieve it again."
237    })))
238}
239
240pub async fn list_api_keys(
241    State(state): State<AppState>,
242    Path(tenant_id): Path<String>,
243) -> Result<Json<Vec<ApiKeyResponse>>> {
244    let keys = state.tenant_db.list_api_keys(&tenant_id).await?;
245    let response: Vec<ApiKeyResponse> = keys.into_iter().map(|k| k.into()).collect();
246
247    Ok(Json(response))
248}
249
250pub async fn get_tenant_usage(
251    State(state): State<AppState>,
252    Path(tenant_id): Path<String>,
253) -> Result<Json<UsageResponse>> {
254    let _ = state
255        .tenant_db
256        .get_tenant(&tenant_id)
257        .await?
258        .ok_or_else(|| AppError::NotFound("Tenant not found".to_string()))?;
259
260    let usage = state.tenant_db.get_usage_summary(&tenant_id).await?;
261
262    Ok(Json(UsageResponse::from(usage)))
263}
264
265pub async fn update_tenant_quota(
266    State(state): State<AppState>,
267    Path(tenant_id): Path<String>,
268    Json(payload): Json<UpdateQuotaRequest>,
269) -> Result<Json<TenantResponse>> {
270    let tier = TenantTier::from_str(&payload.tier).ok_or_else(|| {
271        AppError::InvalidInput("Invalid tier. Must be: free, dev, pro, or enterprise".to_string())
272    })?;
273
274    state
275        .tenant_db
276        .update_tenant_quota(&tenant_id, tier)
277        .await?;
278
279    let tenant = state
280        .tenant_db
281        .get_tenant(&tenant_id)
282        .await?
283        .ok_or_else(|| AppError::NotFound("Tenant not found".to_string()))?;
284
285    let pool = state.tenant_db.pool().clone();
286    let tid = tenant_id.clone();
287    let details = format!("{{\"new_tier\":\"{}\"}}", payload.tier);
288    tokio::spawn(async move {
289        let _ = audit_log::log_admin_action(
290            &pool,
291            "update_quota",
292            "tenant",
293            &tid,
294            Some(&details),
295            None,
296        )
297        .await;
298    });
299
300    Ok(Json(TenantResponse::from(tenant)))
301}
302
303// =============================================================================
304// Provision Client
305// =============================================================================
306
307#[derive(Debug, Deserialize)]
308pub struct ProvisionClientRequest {
309    pub name: String,
310    pub tier: String,
311    pub product_type: String,
312    pub api_key_name: String,
313}
314
315#[derive(Debug, Serialize)]
316pub struct ProvisionClientResponse {
317    pub tenant_id: String,
318    pub tenant_name: String,
319    pub tier: String,
320    pub product_type: String,
321    pub api_key_id: String,
322    pub api_key_prefix: String,
323    pub raw_api_key: String,
324    pub agents_created: Vec<String>,
325}
326
327pub async fn provision_client(
328    State(state): State<AppState>,
329    Json(req): Json<ProvisionClientRequest>,
330) -> Result<Json<ProvisionClientResponse>> {
331    let tier = TenantTier::from_str(&req.tier).ok_or_else(|| {
332        AppError::InvalidInput("Invalid tier. Must be: free, dev, pro, or enterprise".to_string())
333    })?;
334
335    // product_type is used only to select which agent templates to clone into tenant_agents.
336    // It does NOT create product-specific DB tables — client domain data lives in the client's own backend.
337    let product_type = req.product_type.to_lowercase();
338
339    let tenant = state.tenant_db.create_tenant(req.name, tier).await?;
340
341    let agents =
342        clone_templates_for_tenant(state.tenant_db.pool(), &tenant.id, &product_type).await?;
343
344    let (api_key, raw_key) = state
345        .tenant_db
346        .create_api_key(&tenant.id, req.api_key_name)
347        .await?;
348
349    let pool = state.tenant_db.pool().clone();
350    let tid = tenant.id.clone();
351    let details = format!(
352        "{{\"product_type\":\"{}\",\"tier\":\"{}\"}}",
353        product_type,
354        tenant.tier.as_str()
355    );
356    tokio::spawn(async move {
357        let _ = audit_log::log_admin_action(
358            &pool,
359            "provision_client",
360            "tenant",
361            &tid,
362            Some(&details),
363            None,
364        )
365        .await;
366    });
367
368    Ok(Json(ProvisionClientResponse {
369        tenant_id: tenant.id,
370        tenant_name: tenant.name,
371        tier: tenant.tier.as_str().to_string(),
372        product_type,
373        api_key_id: api_key.id,
374        api_key_prefix: api_key.key_prefix,
375        raw_api_key: raw_key,
376        agents_created: agents.into_iter().map(|a| a.agent_name).collect(),
377    }))
378}
379
380// =============================================================================
381// Tenant Agent CRUD
382// =============================================================================
383
384pub async fn list_tenant_agents_handler(
385    State(state): State<AppState>,
386    Path(tenant_id): Path<String>,
387) -> Result<Json<Vec<TenantAgent>>> {
388    let agents = db_list_tenant_agents(state.tenant_db.pool(), &tenant_id).await?;
389    Ok(Json(agents))
390}
391
392pub async fn create_tenant_agent_handler(
393    State(state): State<AppState>,
394    Path(tenant_id): Path<String>,
395    Json(req): Json<CreateTenantAgentRequest>,
396) -> Result<Json<TenantAgent>> {
397    let agent = db_create_tenant_agent(state.tenant_db.pool(), &tenant_id, req).await?;
398
399    let pool = state.tenant_db.pool().clone();
400    let aid = agent.id.clone();
401    tokio::spawn(async move {
402        let _ = audit_log::log_admin_action(&pool, "create_agent", "agent", &aid, None, None).await;
403    });
404
405    Ok(Json(agent))
406}
407
408pub async fn update_tenant_agent_handler(
409    State(state): State<AppState>,
410    Path((tenant_id, agent_name)): Path<(String, String)>,
411    Json(req): Json<UpdateTenantAgentRequest>,
412) -> Result<Json<TenantAgent>> {
413    let agent =
414        db_update_tenant_agent(state.tenant_db.pool(), &tenant_id, &agent_name, req).await?;
415
416    let pool = state.tenant_db.pool().clone();
417    let aid = agent.id.clone();
418    tokio::spawn(async move {
419        let _ = audit_log::log_admin_action(&pool, "update_agent", "agent", &aid, None, None).await;
420    });
421
422    Ok(Json(agent))
423}
424
425pub async fn delete_tenant_agent_handler(
426    State(state): State<AppState>,
427    Path((tenant_id, agent_name)): Path<(String, String)>,
428) -> Result<StatusCode> {
429    db_delete_tenant_agent(state.tenant_db.pool(), &tenant_id, &agent_name).await?;
430
431    let pool = state.tenant_db.pool().clone();
432    let resource_id = format!("{}:{}", tenant_id, agent_name);
433    tokio::spawn(async move {
434        let _ =
435            audit_log::log_admin_action(&pool, "delete_agent", "agent", &resource_id, None, None)
436                .await;
437    });
438
439    Ok(StatusCode::NO_CONTENT)
440}
441
442// =============================================================================
443// Templates and Models
444// =============================================================================
445
446pub async fn list_agent_templates_handler(
447    State(state): State<AppState>,
448    Query(params): Query<HashMap<String, String>>,
449) -> Result<Json<Vec<AgentTemplate>>> {
450    let product_type = params.get("product_type").map(|s| s.as_str());
451    let templates = list_agent_templates(state.tenant_db.pool(), product_type).await?;
452    Ok(Json(templates))
453}
454
455pub async fn list_models_handler(State(state): State<AppState>) -> Result<Json<Vec<ModelInfo>>> {
456    Ok(Json(state.provider_registry.list_models()))
457}
458
459// =============================================================================
460// Alerts
461// =============================================================================
462
463#[derive(Debug, Deserialize)]
464pub struct AlertsQuery {
465    pub severity: Option<String>,
466    pub resolved: Option<bool>,
467    pub limit: Option<i64>,
468}
469
470pub async fn list_alerts(
471    State(state): State<AppState>,
472    Query(q): Query<AlertsQuery>,
473) -> Result<Json<Vec<db_alerts::Alert>>> {
474    let limit = q.limit.unwrap_or(50).min(200);
475    let alerts = db_alerts::list_alerts(
476        state.tenant_db.pool(),
477        q.severity.as_deref(),
478        q.resolved,
479        limit,
480    )
481    .await?;
482    Ok(Json(alerts))
483}
484
485#[derive(Debug, Deserialize)]
486pub struct ResolveAlertRequest {
487    pub resolved_by: Option<String>,
488}
489
490pub async fn resolve_alert(
491    State(state): State<AppState>,
492    Path(alert_id): Path<String>,
493    Json(payload): Json<ResolveAlertRequest>,
494) -> Result<StatusCode> {
495    db_alerts::resolve_alert(
496        state.tenant_db.pool(),
497        &alert_id,
498        payload.resolved_by.as_deref(),
499    )
500    .await?;
501
502    let pool = state.tenant_db.pool().clone();
503    tokio::spawn(async move {
504        let _ = audit_log::log_admin_action(&pool, "resolve_alert", "alert", &alert_id, None, None)
505            .await;
506    });
507
508    Ok(StatusCode::OK)
509}
510
511// =============================================================================
512// Audit Log
513// =============================================================================
514
515#[derive(Debug, Deserialize)]
516pub struct AuditLogQuery {
517    pub limit: Option<i64>,
518    pub offset: Option<i64>,
519}
520
521pub async fn list_audit_log(
522    State(state): State<AppState>,
523    Query(q): Query<AuditLogQuery>,
524) -> Result<Json<Vec<audit_log::AuditLogEntry>>> {
525    let limit = q.limit.unwrap_or(50).min(200);
526    let offset = q.offset.unwrap_or(0);
527    let entries = audit_log::list_audit_log(state.tenant_db.pool(), limit, offset).await?;
528    Ok(Json(entries))
529}
530
531// =============================================================================
532// Daily Usage
533// =============================================================================
534
535#[derive(Debug, Deserialize)]
536pub struct DailyUsageQuery {
537    pub days: Option<i64>,
538}
539
540#[derive(Debug, Serialize)]
541pub struct DailyUsageEntry {
542    pub date: i64,
543    pub requests: i64,
544    pub tokens: i64,
545}
546
547pub async fn get_daily_usage(
548    State(state): State<AppState>,
549    Path(tenant_id): Path<String>,
550    Query(q): Query<DailyUsageQuery>,
551) -> Result<Json<Vec<DailyUsageEntry>>> {
552    let days = q.days.unwrap_or(30).min(90);
553    let now_ts = std::time::SystemTime::now()
554        .duration_since(std::time::UNIX_EPOCH)
555        .unwrap()
556        .as_secs() as i64;
557    let start_ts = now_ts - (days * 86400);
558
559    let rows = sqlx::query(
560        "SELECT
561            (created_at / 86400) * 86400 as day_ts,
562            COUNT(*) as requests,
563            COALESCE(SUM(input_tokens + output_tokens)::bigint, 0) as tokens
564         FROM agent_runs
565         WHERE tenant_id = $1 AND created_at >= $2
566         GROUP BY day_ts ORDER BY day_ts",
567    )
568    .bind(&tenant_id)
569    .bind(start_ts)
570    .fetch_all(state.tenant_db.pool())
571    .await
572    .map_err(|e| AppError::Database(e.to_string()))?;
573
574    use sqlx::Row;
575    let entries: Vec<DailyUsageEntry> = rows
576        .iter()
577        .map(|row| DailyUsageEntry {
578            date: row.get("day_ts"),
579            requests: row.get("requests"),
580            tokens: row.get("tokens"),
581        })
582        .collect();
583
584    Ok(Json(entries))
585}
586
587// =============================================================================
588// Agent Runs (Admin view)
589// =============================================================================
590
591#[derive(Debug, Deserialize)]
592pub struct AgentRunsQuery {
593    pub limit: Option<i64>,
594    pub offset: Option<i64>,
595}
596
597pub async fn list_agent_runs_handler(
598    State(state): State<AppState>,
599    Path((tenant_id, agent_name)): Path<(String, String)>,
600    Query(q): Query<AgentRunsQuery>,
601) -> Result<Json<Vec<agent_runs::AgentRun>>> {
602    let limit = q.limit.unwrap_or(50).min(200);
603    let offset = q.offset.unwrap_or(0);
604    let runs = agent_runs::list_agent_runs(
605        state.tenant_db.pool(),
606        &tenant_id,
607        Some(&agent_name),
608        limit,
609        offset,
610    )
611    .await?;
612    Ok(Json(runs))
613}
614
615pub async fn get_agent_stats_handler(
616    State(state): State<AppState>,
617    Path((tenant_id, agent_name)): Path<(String, String)>,
618) -> Result<Json<agent_runs::AgentRunStats>> {
619    let stats =
620        agent_runs::get_agent_run_stats(state.tenant_db.pool(), &tenant_id, &agent_name).await?;
621    Ok(Json(stats))
622}
623
624// =============================================================================
625// Cross-tenant agents list
626// =============================================================================
627
628pub async fn list_all_agents_handler(
629    State(state): State<AppState>,
630) -> Result<Json<Vec<agent_runs::AllAgentsEntry>>> {
631    let agents = agent_runs::list_all_agents(state.tenant_db.pool()).await?;
632    Ok(Json(agents))
633}
634
635// =============================================================================
636// Platform Stats
637// =============================================================================
638
639pub async fn get_platform_stats(
640    State(state): State<AppState>,
641) -> Result<Json<agent_runs::PlatformStats>> {
642    let stats = agent_runs::get_platform_stats(state.tenant_db.pool()).await?;
643    Ok(Json(stats))
644}
645
646// =============================================================================
647// Agent Versioning — Rollback + Kill Switch (Sprint 12)
648// =============================================================================
649
650/// GET /api/admin/agents/{agent_id}/versions
651/// List all recorded versions for a TOON agent (most recent first).
652pub async fn list_agent_versions_handler(
653    State(state): State<AppState>,
654    Path(agent_id): Path<String>,
655) -> Result<Json<Vec<agent_versions::AgentVersionRecord>>> {
656    let records = agent_versions::get_agent_version_history(
657        state.tenant_db.pool(),
658        &agent_id,
659        50,
660    )
661    .await
662    .map_err(|e| AppError::Database(e.to_string()))?;
663
664    Ok(Json(records))
665}
666
667/// POST /api/admin/agents/{agent_id}/rollback/{version}
668/// Restore a TOON agent to a specific previously-recorded version.
669/// Hot-swaps the in-memory config; writes a new "rollback" row to agent_config_versions.
670pub async fn rollback_agent_handler(
671    State(state): State<AppState>,
672    Path((agent_id, version)): Path<(String, String)>,
673) -> Result<Json<serde_json::Value>> {
674    // Fetch the target version from DB
675    let history = agent_versions::get_agent_version_history(
676        state.tenant_db.pool(),
677        &agent_id,
678        100,
679    )
680    .await
681    .map_err(|e| AppError::Database(e.to_string()))?;
682
683    let record = history
684        .into_iter()
685        .find(|r| r.version == version)
686        .ok_or_else(|| {
687            AppError::NotFound(format!(
688                "No version '{}' found for agent '{}'",
689                version, agent_id
690            ))
691        })?;
692
693    // Deserialize config_json back to ToonAgentConfig
694    let agent_config: crate::utils::toon_config::ToonAgentConfig =
695        serde_json::from_value(record.config_json).map_err(|e| {
696            AppError::InvalidInput(format!("Failed to deserialize agent config: {}", e))
697        })?;
698
699    // Hot-swap into the in-memory DynamicConfigManager
700    state.dynamic_config.upsert_agent(agent_config.clone());
701
702    // Record the rollback as a new version entry
703    let pool = state.tenant_db.pool().clone();
704    let _ = agent_versions::record_agent_versions(&pool, &[agent_config], "rollback").await;
705
706    // Audit log
707    let pool2 = state.tenant_db.pool().clone();
708    let aid = agent_id.clone();
709    let ver = version.clone();
710    tokio::spawn(async move {
711        let _ = audit_log::log_admin_action(
712            &pool2,
713            "agent_rollback",
714            "agent",
715            &aid,
716            Some(&format!("Rolled back to version {}", ver)),
717            None,
718        )
719        .await;
720    });
721
722    tracing::info!(agent_id = %agent_id, version = %version, "Agent rolled back");
723
724    Ok(Json(serde_json::json!({
725        "agent_id": agent_id,
726        "version": version,
727        "status": "rolled_back"
728    })))
729}
730
731#[derive(Debug, Deserialize)]
732pub struct EmergencyStopRequest {
733    pub active: bool,
734}
735
736/// POST /api/admin/agents/emergency-stop
737/// Enable or disable the global emergency stop.
738/// When active, ALL /api/v1/chat requests are rejected with 503.
739pub async fn emergency_stop_handler(
740    State(state): State<AppState>,
741    Json(payload): Json<EmergencyStopRequest>,
742) -> Result<Json<serde_json::Value>> {
743    state
744        .emergency_stop
745        .store(payload.active, std::sync::atomic::Ordering::Relaxed);
746
747    let action = if payload.active {
748        "emergency_stop_enabled"
749    } else {
750        "emergency_stop_disabled"
751    };
752    tracing::warn!(active = payload.active, "Emergency stop toggled");
753
754    let pool = state.tenant_db.pool().clone();
755    tokio::spawn(async move {
756        let _ = audit_log::log_admin_action(&pool, action, "platform", "all_agents", None, None)
757            .await;
758    });
759
760    Ok(Json(serde_json::json!({
761        "emergency_stop": payload.active,
762        "message": if payload.active {
763            "All agents are now in emergency stop mode. /api/v1/chat requests will return 503."
764        } else {
765            "Emergency stop cleared. Agents are operational."
766        }
767    })))
768}