Skip to main content

claw_spawn/server/
http.rs

1use super::state::AppState;
2use crate::application::ProvisioningError;
3use crate::domain::{
4    Account, AlgorithmMode, AssetFocus, Bot, BotConfig, BotSecrets, Persona, RiskConfig,
5    SignalKnobs, StrictnessLevel, TradingConfig,
6};
7use crate::infrastructure::{AccountRepository, DigitalOceanError};
8use axum::{
9    extract::{Path, Query, State},
10    http::{header, header::HeaderMap, StatusCode},
11    response::IntoResponse,
12    routing::{get, post},
13    Json, Router,
14};
15use serde::{Deserialize, Serialize};
16use tracing::{error, info};
17use utoipa::{IntoParams, OpenApi, ToSchema};
18use utoipa_swagger_ui::SwaggerUi;
19use uuid::Uuid;
20
21pub fn router(state: AppState) -> Router {
22    Router::new()
23        .route("/health", get(health_check))
24        .route("/accounts", post(create_account))
25        .route("/accounts/:id", get(get_account))
26        .route("/accounts/:id/bots", get(list_bots))
27        .route("/bots", post(create_bot))
28        .route("/bots/:id", get(get_bot))
29        .route("/bots/:id/config", get(get_bot_config))
30        .route("/bots/:id/actions", post(bot_action))
31        .route("/bot/register", post(register_bot))
32        .route("/bot/:id/config", get(get_desired_config))
33        .route("/bot/:id/config_ack", post(acknowledge_config))
34        .route("/bot/:id/heartbeat", post(record_heartbeat))
35        .merge(SwaggerUi::new("/docs").url("/api-docs/openapi.json", ApiDoc::openapi()))
36        .with_state(state)
37}
38
39fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
40    headers
41        .get(header::AUTHORIZATION)
42        .and_then(|v| v.to_str().ok())
43        .and_then(|s| s.strip_prefix("Bearer "))
44        .filter(|t| !t.is_empty())
45}
46
47fn parse_subscription_tier(tier: &str) -> Option<crate::domain::SubscriptionTier> {
48    match tier {
49        "free" => Some(crate::domain::SubscriptionTier::Free),
50        "basic" => Some(crate::domain::SubscriptionTier::Basic),
51        "pro" => Some(crate::domain::SubscriptionTier::Pro),
52        _ => None,
53    }
54}
55
56fn parse_persona(persona: &str) -> Option<Persona> {
57    match persona {
58        "beginner" => Some(Persona::Beginner),
59        "tweaker" => Some(Persona::Tweaker),
60        "quant_lite" => Some(Persona::QuantLite),
61        _ => None,
62    }
63}
64
65fn parse_asset_focus(asset_focus: &str) -> Option<AssetFocus> {
66    match asset_focus {
67        "majors" => Some(AssetFocus::Majors),
68        "memes" => Some(AssetFocus::Memes),
69        _ => None,
70    }
71}
72
73fn parse_algorithm(algorithm: &str) -> Option<AlgorithmMode> {
74    match algorithm {
75        "trend" => Some(AlgorithmMode::Trend),
76        "mean_reversion" => Some(AlgorithmMode::MeanReversion),
77        "breakout" => Some(AlgorithmMode::Breakout),
78        _ => None,
79    }
80}
81
82fn parse_strictness(strictness: &str) -> Option<StrictnessLevel> {
83    match strictness {
84        "low" => Some(StrictnessLevel::Low),
85        "medium" => Some(StrictnessLevel::Medium),
86        "high" => Some(StrictnessLevel::High),
87        _ => None,
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use axum::http::HeaderValue;
95
96    #[test]
97    fn extract_bearer_token_happy_path() {
98        let mut headers = HeaderMap::new();
99        headers.insert(
100            header::AUTHORIZATION,
101            HeaderValue::from_static("Bearer abc123"),
102        );
103        assert_eq!(extract_bearer_token(&headers), Some("abc123"));
104    }
105
106    #[test]
107    fn extract_bearer_token_rejects_missing_or_empty() {
108        let headers = HeaderMap::new();
109        assert_eq!(extract_bearer_token(&headers), None);
110
111        let mut headers2 = HeaderMap::new();
112        headers2.insert(header::AUTHORIZATION, HeaderValue::from_static("Bearer "));
113        assert_eq!(extract_bearer_token(&headers2), None);
114    }
115
116    #[test]
117    fn extract_bearer_token_rejects_wrong_scheme() {
118        let mut headers = HeaderMap::new();
119        headers.insert(
120            header::AUTHORIZATION,
121            HeaderValue::from_static("Basic abc123"),
122        );
123        assert_eq!(extract_bearer_token(&headers), None);
124    }
125
126    #[test]
127    fn parse_invalid_inputs_return_none() {
128        assert!(parse_subscription_tier("nope").is_none());
129        assert!(parse_persona("nope").is_none());
130        assert!(parse_asset_focus("nope").is_none());
131        assert!(parse_algorithm("nope").is_none());
132        assert!(parse_strictness("nope").is_none());
133    }
134}
135
136/// CLEAN-004: OpenAPI documentation structure
137#[derive(OpenApi)]
138#[openapi(
139    paths(
140        health_check,
141        create_account,
142        get_account,
143        list_bots,
144        create_bot,
145        get_bot,
146        get_bot_config,
147        bot_action,
148        register_bot,
149        get_desired_config,
150        acknowledge_config,
151        record_heartbeat,
152    ),
153    components(
154        schemas(
155            CreateAccountRequest,
156            CreateBotRequest,
157            BotActionRequest,
158            RegisterBotRequest,
159            AckConfigRequest,
160            BotResponse,
161            HealthResponse,
162        )
163    ),
164    tags(
165        (name = "Health", description = "Health check endpoints"),
166        (name = "Accounts", description = "Account management endpoints"),
167        (name = "Bots", description = "Bot management and lifecycle endpoints"),
168        (name = "Configuration", description = "Bot configuration endpoints"),
169    ),
170    info(
171        title = "Claw Spawn API",
172        version = "0.1.0",
173        description = "API for managing trading bot provisioning and lifecycle",
174        license(name = "MIT OR Apache-2.0")
175    )
176)]
177struct ApiDoc;
178
179/// Health check response
180#[derive(Serialize, ToSchema)]
181struct HealthResponse {
182    status: String,
183    #[serde(skip_serializing_if = "Option::is_none")]
184    error: Option<String>,
185}
186
187/// Health check endpoint
188///
189/// Verifies database connectivity and returns service health status.
190#[utoipa::path(
191    get,
192    path = "/health",
193    tag = "Health",
194    responses(
195        (status = 200, description = "Service is healthy", body = HealthResponse),
196        (status = 503, description = "Service is unhealthy", body = HealthResponse)
197    )
198)]
199async fn health_check(State(state): State<AppState>) -> impl IntoResponse {
200    match sqlx::query("SELECT 1").fetch_one(&state.pool).await {
201        Ok(_) => (
202            StatusCode::OK,
203            Json(HealthResponse {
204                status: "healthy".to_string(),
205                error: None,
206            }),
207        ),
208        Err(e) => {
209            error!(error = %e, "Health check failed: DB connectivity issue");
210            (
211                StatusCode::SERVICE_UNAVAILABLE,
212                Json(HealthResponse {
213                    status: "unhealthy".to_string(),
214                    error: Some("Database connectivity failed".to_string()),
215                }),
216            )
217        }
218    }
219}
220
221/// Create account request
222#[derive(Deserialize, ToSchema)]
223struct CreateAccountRequest {
224    #[schema(example = "user-123")]
225    external_id: String,
226    #[schema(example = "pro")]
227    tier: String,
228}
229
230/// Create a new account
231#[utoipa::path(
232    post,
233    path = "/accounts",
234    tag = "Accounts",
235    request_body = CreateAccountRequest,
236    responses(
237        (status = 201, description = "Account created successfully", body = Object),
238        (status = 400, description = "Invalid subscription tier", body = Object),
239        (status = 500, description = "Failed to create account", body = Object)
240    )
241)]
242async fn create_account(
243    State(state): State<AppState>,
244    Json(req): Json<CreateAccountRequest>,
245) -> impl IntoResponse {
246    let tier = match parse_subscription_tier(req.tier.as_str()) {
247        Some(t) => t,
248        None => {
249            return (
250                StatusCode::BAD_REQUEST,
251                Json(serde_json::json!({
252                    "error": "Invalid subscription tier",
253                    "allowed": ["free", "basic", "pro"]
254                })),
255            );
256        }
257    };
258
259    let account = Account::new(req.external_id, tier);
260    if let Err(e) = state.account_repo.create(&account).await {
261        error!(error = %e, "Failed to create account");
262        return (
263            StatusCode::INTERNAL_SERVER_ERROR,
264            Json(serde_json::json!({"error": "Failed to create account"})),
265        );
266    }
267
268    (
269        StatusCode::CREATED,
270        Json(serde_json::json!({"id": account.id})),
271    )
272}
273
274#[utoipa::path(
275    get,
276    path = "/accounts/{id}",
277    tag = "Accounts",
278    params(("id" = Uuid, Path, description = "Account ID")),
279    responses((status = 501, description = "Not implemented"))
280)]
281async fn get_account(State(_state): State<AppState>, Path(_id): Path<Uuid>) -> impl IntoResponse {
282    (StatusCode::NOT_IMPLEMENTED, "Get account not implemented")
283}
284
285#[derive(Deserialize, Debug, IntoParams, ToSchema)]
286struct PaginationParams {
287    #[serde(default = "default_limit")]
288    #[param(default = 100, maximum = 1000)]
289    limit: i64,
290    #[serde(default)]
291    #[param(default = 0)]
292    offset: i64,
293}
294
295fn default_limit() -> i64 {
296    100
297}
298
299const MAX_PAGINATION_LIMIT: i64 = 1000;
300
301#[utoipa::path(
302    get,
303    path = "/accounts/{id}/bots",
304    tag = "Bots",
305    params(("id" = Uuid, Path, description = "Account ID"), PaginationParams),
306    responses(
307        (status = 200, description = "List of bots", body = [BotResponse]),
308        (status = 500, description = "Failed to list bots", body = Object)
309    )
310)]
311async fn list_bots(
312    State(state): State<AppState>,
313    Path(account_id): Path<Uuid>,
314    Query(params): Query<PaginationParams>,
315) -> impl IntoResponse {
316    let limit = params.limit.clamp(1, MAX_PAGINATION_LIMIT);
317    let offset = params.offset.max(0);
318
319    match state
320        .lifecycle
321        .list_account_bots(account_id, limit, offset)
322        .await
323    {
324        Ok(bots) => {
325            let bot_responses: Vec<BotResponse> = bots.into_iter().map(Into::into).collect();
326            (StatusCode::OK, Json(serde_json::json!(bot_responses)))
327        }
328        Err(e) => {
329            error!(error = %e, "Failed to list bots");
330            (
331                StatusCode::INTERNAL_SERVER_ERROR,
332                Json(serde_json::json!({"error": "Failed to list bots"})),
333            )
334        }
335    }
336}
337
338#[derive(Deserialize, ToSchema)]
339struct CreateBotRequest {
340    account_id: Uuid,
341    name: String,
342    persona: String,
343    asset_focus: String,
344    algorithm: String,
345    strictness: String,
346    paper_mode: bool,
347    max_position_size_pct: f64,
348    max_daily_loss_pct: f64,
349    max_drawdown_pct: f64,
350    max_trades_per_day: i32,
351    llm_provider: String,
352    llm_api_key: String,
353}
354
355#[utoipa::path(
356    post,
357    path = "/bots",
358    tag = "Bots",
359    request_body = CreateBotRequest,
360    responses(
361        (status = 201, description = "Bot created successfully", body = BotResponse),
362        (status = 400, description = "Invalid risk configuration", body = Object),
363        (status = 403, description = "Account limit reached", body = Object),
364        (status = 429, description = "Rate limited by DigitalOcean", body = Object),
365        (status = 500, description = "Failed to create bot", body = Object)
366    )
367)]
368async fn create_bot(
369    State(state): State<AppState>,
370    Json(req): Json<CreateBotRequest>,
371) -> impl IntoResponse {
372    let persona = match parse_persona(req.persona.as_str()) {
373        Some(p) => p,
374        None => {
375            return (
376                StatusCode::BAD_REQUEST,
377                Json(serde_json::json!({
378                    "error": "Invalid persona",
379                    "allowed": ["beginner", "tweaker", "quant_lite"]
380                })),
381            );
382        }
383    };
384
385    let asset_focus = match parse_asset_focus(req.asset_focus.as_str()) {
386        Some(a) => a,
387        None => {
388            return (
389                StatusCode::BAD_REQUEST,
390                Json(serde_json::json!({
391                    "error": "Invalid asset_focus",
392                    "allowed": ["majors", "memes"]
393                })),
394            );
395        }
396    };
397
398    let algorithm = match parse_algorithm(req.algorithm.as_str()) {
399        Some(a) => a,
400        None => {
401            return (
402                StatusCode::BAD_REQUEST,
403                Json(serde_json::json!({
404                    "error": "Invalid algorithm",
405                    "allowed": ["trend", "mean_reversion", "breakout"]
406                })),
407            );
408        }
409    };
410
411    let strictness = match parse_strictness(req.strictness.as_str()) {
412        Some(s) => s,
413        None => {
414            return (
415                StatusCode::BAD_REQUEST,
416                Json(serde_json::json!({
417                    "error": "Invalid strictness",
418                    "allowed": ["low", "medium", "high"]
419                })),
420            );
421        }
422    };
423
424    let trading_config = TradingConfig {
425        asset_focus,
426        algorithm,
427        strictness,
428        paper_mode: req.paper_mode,
429        signal_knobs: if matches!(persona, Persona::QuantLite) {
430            Some(SignalKnobs {
431                volume_confirmation: true,
432                volatility_brake: true,
433                liquidity_filter: StrictnessLevel::Medium,
434                correlation_brake: true,
435            })
436        } else {
437            None
438        },
439    };
440
441    let risk_config = RiskConfig {
442        max_position_size_pct: req.max_position_size_pct,
443        max_daily_loss_pct: req.max_daily_loss_pct,
444        max_drawdown_pct: req.max_drawdown_pct,
445        max_trades_per_day: req.max_trades_per_day,
446    };
447
448    if let Err(errors) = risk_config.validate() {
449        error!(errors = ?errors, "RiskConfig validation failed");
450        return (
451            StatusCode::BAD_REQUEST,
452            Json(serde_json::json!({"error": "Invalid risk configuration", "details": errors})),
453        );
454    }
455
456    let config = BotConfig {
457        id: Uuid::new_v4(),
458        bot_id: Uuid::new_v4(),
459        version: 1,
460        trading_config,
461        risk_config,
462        secrets: BotSecrets {
463            llm_provider: req.llm_provider,
464            llm_api_key: req.llm_api_key,
465        },
466        created_at: chrono::Utc::now(),
467    };
468
469    match state
470        .provisioning
471        .create_bot(req.account_id, req.name, persona, config)
472        .await
473    {
474        Ok(bot) => (
475            StatusCode::CREATED,
476            Json(serde_json::json!(BotResponse::from(bot))),
477        ),
478        Err(ProvisioningError::AccountLimitReached(max)) => (
479            StatusCode::FORBIDDEN,
480            Json(serde_json::json!({
481                "error": format!("Account limit reached: maximum {} bots allowed", max)
482            })),
483        ),
484        Err(ProvisioningError::DigitalOcean(DigitalOceanError::RateLimited)) => (
485            StatusCode::TOO_MANY_REQUESTS,
486            Json(serde_json::json!({"error": "Rate limited by DigitalOcean, please retry"})),
487        ),
488        Err(e) => {
489            error!(error = %e, "Failed to create bot");
490            (
491                StatusCode::INTERNAL_SERVER_ERROR,
492                Json(serde_json::json!({"error": "Failed to create bot"})),
493            )
494        }
495    }
496}
497
498#[utoipa::path(
499    get,
500    path = "/bots/{id}",
501    tag = "Bots",
502    params(("id" = Uuid, Path, description = "Bot ID")),
503    responses(
504        (status = 200, description = "Bot found", body = BotResponse),
505        (status = 404, description = "Bot not found", body = Object)
506    )
507)]
508async fn get_bot(State(state): State<AppState>, Path(id): Path<Uuid>) -> impl IntoResponse {
509    match state.lifecycle.get_bot(id).await {
510        Ok(bot) => (
511            StatusCode::OK,
512            Json(serde_json::json!(BotResponse::from(bot))),
513        ),
514        Err(_) => (
515            StatusCode::NOT_FOUND,
516            Json(serde_json::json!({"error": "Bot not found"})),
517        ),
518    }
519}
520
521#[utoipa::path(
522    get,
523    path = "/bots/{id}/config",
524    tag = "Configuration",
525    params(("id" = Uuid, Path, description = "Bot ID")),
526    responses(
527        (status = 200, description = "Configuration found", body = Object),
528        (status = 404, description = "No config found", body = Object),
529        (status = 500, description = "Failed to get config", body = Object)
530    )
531)]
532async fn get_bot_config(State(state): State<AppState>, Path(id): Path<Uuid>) -> impl IntoResponse {
533    match state.lifecycle.get_desired_config(id).await {
534        Ok(Some(config)) => (StatusCode::OK, Json(serde_json::json!(config))),
535        Ok(None) => (
536            StatusCode::NOT_FOUND,
537            Json(serde_json::json!({"error": "No config found"})),
538        ),
539        Err(_) => (
540            StatusCode::INTERNAL_SERVER_ERROR,
541            Json(serde_json::json!({"error": "Failed to get config"})),
542        ),
543    }
544}
545
546#[derive(Deserialize, ToSchema)]
547struct BotActionRequest {
548    action: String,
549}
550
551#[utoipa::path(
552    post,
553    path = "/bots/{id}/actions",
554    tag = "Bots",
555    params(("id" = Uuid, Path, description = "Bot ID")),
556    request_body = BotActionRequest,
557    responses(
558        (status = 200, description = "Action completed successfully", body = Object),
559        (status = 400, description = "Invalid action", body = Object),
560        (status = 500, description = "Action failed", body = Object)
561    )
562)]
563async fn bot_action(
564    State(state): State<AppState>,
565    Path(id): Path<Uuid>,
566    Json(req): Json<BotActionRequest>,
567) -> impl IntoResponse {
568    let result = match req.action.as_str() {
569        "pause" => state.provisioning.pause_bot(id).await,
570        "resume" => state.provisioning.resume_bot(id).await,
571        "redeploy" => state.provisioning.redeploy_bot(id).await,
572        "destroy" => state.provisioning.destroy_bot(id).await,
573        _ => Err(ProvisioningError::InvalidConfig(
574            "Unknown action".to_string(),
575        )),
576    };
577
578    match result {
579        Ok(_) => (StatusCode::OK, Json(serde_json::json!({"status": "ok"}))),
580        Err(e) => {
581            error!(error = %e, "Bot action failed");
582            (
583                StatusCode::INTERNAL_SERVER_ERROR,
584                Json(serde_json::json!({"error": "Action failed"})),
585            )
586        }
587    }
588}
589
590#[derive(Deserialize, ToSchema)]
591struct RegisterBotRequest {
592    bot_id: Uuid,
593}
594
595#[utoipa::path(
596    post,
597    path = "/bot/register",
598    tag = "Bots",
599    request_body = RegisterBotRequest,
600    responses(
601        (status = 200, description = "Bot registered successfully", body = Object),
602        (status = 401, description = "Invalid or missing authorization token", body = Object)
603    )
604)]
605async fn register_bot(
606    State(state): State<AppState>,
607    headers: HeaderMap,
608    Json(req): Json<RegisterBotRequest>,
609) -> impl IntoResponse {
610    let token = match extract_bearer_token(&headers) {
611        Some(t) => t,
612        None => {
613            return (
614                StatusCode::UNAUTHORIZED,
615                Json(serde_json::json!({"error": "Missing or invalid authorization token"})),
616            );
617        }
618    };
619
620    match state.lifecycle.get_bot_with_token(req.bot_id, token).await {
621        Ok(bot) => {
622            info!(bot_id = %bot.id, "Bot registered successfully");
623            (
624                StatusCode::OK,
625                Json(serde_json::json!({"status": "registered"})),
626            )
627        }
628        Err(_) => (
629            StatusCode::UNAUTHORIZED,
630            Json(serde_json::json!({"error": "Invalid bot ID or registration token"})),
631        ),
632    }
633}
634
635#[derive(Deserialize, ToSchema)]
636struct AckConfigRequest {
637    config_id: Uuid,
638}
639
640#[utoipa::path(
641    get,
642    path = "/bot/{id}/config",
643    tag = "Configuration",
644    params(("id" = Uuid, Path, description = "Bot ID")),
645    responses(
646        (status = 200, description = "Desired config found", body = Object),
647        (status = 401, description = "Invalid or missing authorization token", body = Object),
648        (status = 404, description = "No desired config", body = Object),
649        (status = 500, description = "Failed to get config", body = Object)
650    )
651)]
652async fn get_desired_config(
653    State(state): State<AppState>,
654    Path(id): Path<Uuid>,
655    headers: HeaderMap,
656) -> impl IntoResponse {
657    let token = match extract_bearer_token(&headers) {
658        Some(t) => t,
659        None => {
660            return (
661                StatusCode::UNAUTHORIZED,
662                Json(serde_json::json!({"error": "Missing or invalid authorization token"})),
663            );
664        }
665    };
666
667    if state.lifecycle.get_bot_with_token(id, token).await.is_err() {
668        return (
669            StatusCode::UNAUTHORIZED,
670            Json(serde_json::json!({"error": "Invalid bot ID or registration token"})),
671        );
672    }
673
674    match state.lifecycle.get_desired_config(id).await {
675        Ok(Some(config)) => (StatusCode::OK, Json(serde_json::json!(config))),
676        Ok(None) => (
677            StatusCode::NOT_FOUND,
678            Json(serde_json::json!({"error": "No desired config"})),
679        ),
680        Err(_) => (
681            StatusCode::INTERNAL_SERVER_ERROR,
682            Json(serde_json::json!({"error": "Failed to get config"})),
683        ),
684    }
685}
686
687#[utoipa::path(
688    post,
689    path = "/bot/{id}/config_ack",
690    tag = "Configuration",
691    params(("id" = Uuid, Path, description = "Bot ID")),
692    request_body = AckConfigRequest,
693    responses(
694        (status = 200, description = "Config acknowledged", body = Object),
695        (status = 401, description = "Invalid or missing authorization token", body = Object),
696        (status = 400, description = "Failed to acknowledge config", body = Object)
697    )
698)]
699async fn acknowledge_config(
700    State(state): State<AppState>,
701    Path(id): Path<Uuid>,
702    headers: HeaderMap,
703    Json(req): Json<AckConfigRequest>,
704) -> impl IntoResponse {
705    let token = match extract_bearer_token(&headers) {
706        Some(t) => t,
707        None => {
708            return (
709                StatusCode::UNAUTHORIZED,
710                Json(serde_json::json!({"error": "Missing or invalid authorization token"})),
711            );
712        }
713    };
714
715    if state.lifecycle.get_bot_with_token(id, token).await.is_err() {
716        return (
717            StatusCode::UNAUTHORIZED,
718            Json(serde_json::json!({"error": "Invalid bot ID or registration token"})),
719        );
720    }
721
722    match state.lifecycle.acknowledge_config(id, req.config_id).await {
723        Ok(_) => (
724            StatusCode::OK,
725            Json(serde_json::json!({"status": "acknowledged"})),
726        ),
727        Err(_) => (
728            StatusCode::BAD_REQUEST,
729            Json(serde_json::json!({"error": "Failed to acknowledge config"})),
730        ),
731    }
732}
733
734#[utoipa::path(
735    post,
736    path = "/bot/{id}/heartbeat",
737    tag = "Bots",
738    params(("id" = Uuid, Path, description = "Bot ID")),
739    responses(
740        (status = 200, description = "Heartbeat recorded", body = Object),
741        (status = 401, description = "Invalid or missing authorization token", body = Object),
742        (status = 500, description = "Failed to record heartbeat", body = Object)
743    )
744)]
745async fn record_heartbeat(
746    State(state): State<AppState>,
747    Path(id): Path<Uuid>,
748    headers: HeaderMap,
749) -> impl IntoResponse {
750    let token = match extract_bearer_token(&headers) {
751        Some(t) => t,
752        None => {
753            return (
754                StatusCode::UNAUTHORIZED,
755                Json(serde_json::json!({"error": "Missing or invalid authorization token"})),
756            );
757        }
758    };
759
760    if state.lifecycle.get_bot_with_token(id, token).await.is_err() {
761        return (
762            StatusCode::UNAUTHORIZED,
763            Json(serde_json::json!({"error": "Invalid bot ID or registration token"})),
764        );
765    }
766
767    match state.lifecycle.record_heartbeat(id).await {
768        Ok(_) => (StatusCode::OK, Json(serde_json::json!({"status": "ok"}))),
769        Err(_) => (
770            StatusCode::INTERNAL_SERVER_ERROR,
771            Json(serde_json::json!({"error": "Failed to record heartbeat"})),
772        ),
773    }
774}
775
776#[derive(Serialize, ToSchema)]
777struct BotResponse {
778    id: Uuid,
779    account_id: Uuid,
780    name: String,
781    persona: String,
782    status: String,
783    droplet_id: Option<i64>,
784    desired_config_version_id: Option<Uuid>,
785    applied_config_version_id: Option<Uuid>,
786    created_at: chrono::DateTime<chrono::Utc>,
787    updated_at: chrono::DateTime<chrono::Utc>,
788    #[schema(format = "date-time")]
789    last_heartbeat_at: Option<chrono::DateTime<chrono::Utc>>,
790}
791
792impl From<Bot> for BotResponse {
793    fn from(bot: Bot) -> Self {
794        Self {
795            id: bot.id,
796            account_id: bot.account_id,
797            name: bot.name,
798            persona: bot.persona.to_string(),
799            status: bot.status.to_string(),
800            droplet_id: bot.droplet_id,
801            desired_config_version_id: bot.desired_config_version_id,
802            applied_config_version_id: bot.applied_config_version_id,
803            created_at: bot.created_at,
804            updated_at: bot.updated_at,
805            last_heartbeat_at: bot.last_heartbeat_at,
806        }
807    }
808}