Skip to main content

ant_core/node/daemon/
server.rs

1use std::net::SocketAddr;
2use std::path::PathBuf;
3use std::sync::Arc;
4use std::time::Instant;
5
6use axum::extract::{Path, State};
7use axum::http::StatusCode;
8use axum::response::sse::{Event, Sse};
9use axum::response::{Html, IntoResponse};
10use axum::routing::{get, post};
11use axum::{Json, Router};
12use tokio::sync::broadcast;
13use tokio::sync::RwLock;
14use tokio_util::sync::CancellationToken;
15
16use crate::error::Result;
17use crate::node::binary::NoopProgress;
18use crate::node::daemon::health::{DiskThresholds, FleetHealth};
19use crate::node::daemon::supervisor::{
20    spawn_eviction_monitor, spawn_liveness_monitor, Supervisor, EVICTION_POLL_INTERVAL,
21    LIVENESS_POLL_INTERVAL,
22};
23use crate::node::events::NodeEvent;
24use crate::node::registry::NodeRegistry;
25use crate::node::types::{
26    AddNodeOpts, AddNodeResult, DaemonConfig, DaemonStatus, NodeInfo, NodeStarted, NodeStatus,
27    NodeStatusResult, NodeStatusSummary, NodeStopped, RemoveNodeResult, ResetResult,
28    StartNodeResult, StopNodeResult,
29};
30
31/// Shared application state for the daemon HTTP server.
32pub struct AppState {
33    pub registry: Arc<RwLock<NodeRegistry>>,
34    pub supervisor: Arc<RwLock<Supervisor>>,
35    pub event_tx: broadcast::Sender<NodeEvent>,
36    pub start_time: Instant,
37    pub config: DaemonConfig,
38    /// The actual address the server bound to (resolves port 0 to real port).
39    pub bound_port: u16,
40    /// Latest fleet health snapshot, refreshed by the eviction monitor and served at
41    /// `GET /api/v1/health`.
42    pub health: Arc<RwLock<FleetHealth>>,
43}
44
45/// Start the daemon HTTP server.
46///
47/// Returns the actual address the server bound to (useful when port is 0).
48pub async fn start(
49    config: DaemonConfig,
50    mut registry: NodeRegistry,
51    shutdown: CancellationToken,
52) -> Result<SocketAddr> {
53    let (event_tx, _) = broadcast::channel(256);
54
55    let addr = SocketAddr::new(config.listen_addr, config.port.unwrap_or(0));
56    let listener = tokio::net::TcpListener::bind(addr)
57        .await
58        .map_err(|e| crate::error::Error::BindError(e.to_string()))?;
59    let bound_addr = listener
60        .local_addr()
61        .map_err(|e| crate::error::Error::BindError(e.to_string()))?;
62
63    // Heal any stale `version` entries in the registry. If an earlier daemon ran without the
64    // upgrade-aware supervisor, the on-disk binary may have been replaced without the registry
65    // being updated. We re-read each binary's version and persist any differences before the
66    // supervisor comes up, so subsequent status queries reflect reality.
67    reconcile_registry_versions(&mut registry).await;
68
69    let registry = Arc::new(RwLock::new(registry));
70    let supervisor = Arc::new(RwLock::new(Supervisor::new(event_tx.clone())));
71
72    // Adopt node processes spawned by a previous daemon instance. Must run before
73    // `axum::serve` starts accepting requests — the window between supervisor
74    // creation and adoption is where `/api/v1/nodes/status` would otherwise report
75    // live nodes as Stopped (the supervisor's default when it has no runtime entry).
76    {
77        let reg = registry.read().await;
78        let mut sup = supervisor.write().await;
79        let adopted = sup.adopt_from_registry(&reg);
80        if !adopted.is_empty() {
81            tracing::info!(
82                "Adopted {} running node(s) from a previous daemon instance: {:?}",
83                adopted.len(),
84                adopted
85            );
86        }
87    }
88
89    let health = Arc::new(RwLock::new(FleetHealth::healthy()));
90
91    let state = Arc::new(AppState {
92        registry: registry.clone(),
93        supervisor: supervisor.clone(),
94        event_tx: event_tx.clone(),
95        start_time: Instant::now(),
96        config: config.clone(),
97        bound_port: bound_addr.port(),
98        health: health.clone(),
99    });
100
101    // Background task: monitor free disk space at node data directories. Refreshes the fleet health
102    // snapshot every tick and auto-evicts a node (smallest data dir) on any partition that has
103    // fallen to the eviction threshold while ≥2 nodes remain. The threshold is a fixed internal
104    // constant (mirroring ant-node's own refuse-to-store reserve), not user-configurable.
105    spawn_eviction_monitor(
106        registry.clone(),
107        supervisor.clone(),
108        event_tx.clone(),
109        health,
110        DiskThresholds::default(),
111        EVICTION_POLL_INTERVAL,
112        shutdown.clone(),
113    );
114
115    // Background task: poll adopted nodes' PIDs for OS liveness. Daemon-spawned nodes
116    // get exit detection via `monitor_node`'s owned `Child` handle; adopted nodes don't,
117    // so this poll is the only way the supervisor learns when one of them exits.
118    spawn_liveness_monitor(
119        registry,
120        supervisor,
121        event_tx,
122        LIVENESS_POLL_INTERVAL,
123        shutdown.clone(),
124    );
125
126    let app = build_router(state.clone());
127
128    // Write port and PID files
129    write_file(&config.port_file_path, &bound_addr.port().to_string())?;
130    write_file(&config.pid_file_path, &std::process::id().to_string())?;
131
132    let port_file = config.port_file_path.clone();
133    let pid_file = config.pid_file_path.clone();
134
135    tokio::spawn(async move {
136        axum::serve(listener, app)
137            .with_graceful_shutdown(shutdown.cancelled_owned())
138            .await
139            .ok();
140
141        // Clean up port and PID files on shutdown
142        let _ = std::fs::remove_file(&port_file);
143        let _ = std::fs::remove_file(&pid_file);
144    });
145
146    Ok(bound_addr)
147}
148
149fn build_router(state: Arc<AppState>) -> Router {
150    use axum::http::HeaderValue;
151    use tower_http::cors::{Any, CorsLayer};
152
153    // Restrict CORS to the daemon's own origin to prevent cross-origin CSRF
154    // attacks from malicious webpages. Non-browser clients (CLI, AI agents)
155    // don't send Origin headers so CORS doesn't affect them.
156    let origin = format!("http://127.0.0.1:{}", state.bound_port);
157    let cors = CorsLayer::new()
158        .allow_origin([origin.parse::<HeaderValue>().unwrap()])
159        .allow_methods(Any)
160        .allow_headers(Any);
161
162    Router::new()
163        .route("/console", get(get_console))
164        .route("/api/v1/status", get(get_status))
165        .route("/api/v1/health", get(get_health))
166        .route("/api/v1/events", get(get_events))
167        .route("/api/v1/nodes/status", get(get_nodes_status))
168        .route("/api/v1/nodes", post(post_nodes))
169        .route(
170            "/api/v1/nodes/{id}",
171            get(get_node_detail).delete(delete_node),
172        )
173        .route("/api/v1/nodes/{id}/start", post(post_start_node))
174        .route("/api/v1/nodes/start-all", post(post_start_all))
175        .route("/api/v1/nodes/{id}/stop", post(post_stop_node))
176        .route("/api/v1/nodes/stop-all", post(post_stop_all))
177        .route("/api/v1/reset", post(post_reset))
178        .route("/api/v1/openapi.json", get(get_openapi))
179        .layer(cors)
180        .with_state(state)
181}
182
183async fn get_status(State(state): State<Arc<AppState>>) -> Json<DaemonStatus> {
184    let registry = state.registry.read().await;
185    let supervisor = state.supervisor.read().await;
186    let (running, stopped, errored) = supervisor.node_counts();
187
188    Json(DaemonStatus {
189        running: true,
190        pid: Some(std::process::id()),
191        port: Some(state.bound_port),
192        uptime_secs: Some(state.start_time.elapsed().as_secs()),
193        nodes_total: registry.len() as u32,
194        nodes_running: running,
195        nodes_stopped: stopped,
196        nodes_errored: errored,
197    })
198}
199
200/// GET /api/v1/health — Current fleet health (overall level + per-check findings).
201///
202/// Refreshed by the eviction monitor; reflects disk pressure and the next eviction candidate.
203async fn get_health(State(state): State<Arc<AppState>>) -> Json<FleetHealth> {
204    Json(state.health.read().await.clone())
205}
206
207async fn get_events(
208    State(state): State<Arc<AppState>>,
209) -> Sse<impl futures_core::Stream<Item = std::result::Result<Event, std::convert::Infallible>>> {
210    let mut rx = state.event_tx.subscribe();
211
212    let stream = async_stream::stream! {
213        loop {
214            match rx.recv().await {
215                Ok(event) => {
216                    let event_type = event.event_type().to_string();
217                    if let Ok(data) = serde_json::to_string(&event) {
218                        yield Ok(Event::default().event(event_type).data(data));
219                    }
220                }
221                Err(broadcast::error::RecvError::Lagged(_)) => continue,
222                Err(broadcast::error::RecvError::Closed) => break,
223            }
224        }
225    };
226
227    Sse::new(stream)
228}
229
230/// GET /api/v1/nodes/status — Get status of all registered nodes.
231async fn get_nodes_status(State(state): State<Arc<AppState>>) -> Json<NodeStatusResult> {
232    let registry = state.registry.read().await;
233    let supervisor = state.supervisor.read().await;
234
235    let mut nodes = Vec::new();
236    let mut total_running = 0u32;
237    let mut total_stopped = 0u32;
238
239    for config in registry.list() {
240        // An evicted node has no live process: its persisted marker takes precedence over any
241        // runtime status the supervisor might still report.
242        let status = if config.eviction.is_some() {
243            NodeStatus::Evicted
244        } else {
245            supervisor
246                .node_status(config.id)
247                .unwrap_or(NodeStatus::Stopped)
248        };
249
250        match status {
251            NodeStatus::Running | NodeStatus::Starting => total_running += 1,
252            _ => total_stopped += 1,
253        }
254
255        let (pid, uptime_secs) = if config.eviction.is_some() {
256            (None, None)
257        } else {
258            (
259                supervisor.node_pid(config.id),
260                supervisor.node_uptime_secs(config.id),
261            )
262        };
263
264        nodes.push(NodeStatusSummary {
265            node_id: config.id,
266            name: config.service_name.clone(),
267            version: config.version.clone(),
268            status,
269            pid,
270            uptime_secs,
271            eviction: config.eviction.clone(),
272        });
273    }
274
275    Json(NodeStatusResult {
276        nodes,
277        total_running,
278        total_stopped,
279    })
280}
281
282/// GET /api/v1/nodes/:id — Get full detail for a single node.
283async fn get_node_detail(
284    State(state): State<Arc<AppState>>,
285    Path(id): Path<u32>,
286) -> std::result::Result<Json<NodeInfo>, (StatusCode, Json<serde_json::Value>)> {
287    let registry = state.registry.read().await;
288    let config = match registry.get(id) {
289        Ok(config) => config.clone(),
290        Err(_) => {
291            return Err((
292                StatusCode::NOT_FOUND,
293                Json(serde_json::json!({ "error": format!("Node not found: {id}") })),
294            ))
295        }
296    };
297
298    let supervisor = state.supervisor.read().await;
299    // A persisted eviction marker takes precedence over any runtime status.
300    let (status, pid, uptime_secs) = if config.eviction.is_some() {
301        (NodeStatus::Evicted, None, None)
302    } else {
303        (
304            supervisor.node_status(id).unwrap_or(NodeStatus::Stopped),
305            supervisor.node_pid(id),
306            supervisor.node_uptime_secs(id),
307        )
308    };
309
310    Ok(Json(NodeInfo {
311        config,
312        status,
313        pid,
314        uptime_secs,
315    }))
316}
317
318/// POST /api/v1/nodes — Add one or more nodes to the registry.
319async fn post_nodes(
320    State(state): State<Arc<AppState>>,
321    Json(opts): Json<AddNodeOpts>,
322) -> std::result::Result<(StatusCode, Json<AddNodeResult>), (StatusCode, Json<serde_json::Value>)> {
323    let registry_path = state.config.registry_path.clone();
324    let progress = NoopProgress;
325
326    match crate::node::add_nodes(opts, &registry_path, &progress).await {
327        Ok(result) => {
328            // Update the in-memory registry to stay in sync
329            let mut registry = state.registry.write().await;
330            if let Ok(fresh) = NodeRegistry::load(&registry_path) {
331                *registry = fresh;
332            }
333            Ok((StatusCode::CREATED, Json(result)))
334        }
335        Err(e) => Err((
336            StatusCode::BAD_REQUEST,
337            Json(serde_json::json!({ "error": e.to_string() })),
338        )),
339    }
340}
341
342/// DELETE /api/v1/nodes/:id — Remove a node from the registry.
343async fn delete_node(
344    State(state): State<Arc<AppState>>,
345    Path(id): Path<u32>,
346) -> std::result::Result<Json<RemoveNodeResult>, (StatusCode, Json<serde_json::Value>)> {
347    // Prevent removing a running node (would orphan the process)
348    let supervisor = state.supervisor.read().await;
349    if supervisor.is_running(id) {
350        return Err((
351            StatusCode::CONFLICT,
352            Json(serde_json::json!({
353                "error": format!("Cannot remove node {id} while it is running. Stop it first."),
354                "current_state": { "node_id": id, "status": "running" }
355            })),
356        ));
357    }
358    drop(supervisor);
359
360    let registry_path = state.config.registry_path.clone();
361
362    match crate::node::remove_node(id, &registry_path) {
363        Ok(result) => {
364            // Update the in-memory registry to stay in sync
365            let mut registry = state.registry.write().await;
366            if let Ok(fresh) = NodeRegistry::load(&registry_path) {
367                *registry = fresh;
368            }
369            Ok(Json(result))
370        }
371        Err(crate::error::Error::NodeNotFound(id)) => Err((
372            StatusCode::NOT_FOUND,
373            Json(serde_json::json!({ "error": format!("Node not found: {id}") })),
374        )),
375        Err(e) => Err((
376            StatusCode::INTERNAL_SERVER_ERROR,
377            Json(serde_json::json!({ "error": e.to_string() })),
378        )),
379    }
380}
381
382/// POST /api/v1/nodes/:id/start — Start a specific node.
383async fn post_start_node(
384    State(state): State<Arc<AppState>>,
385    Path(id): Path<u32>,
386) -> std::result::Result<Json<NodeStarted>, (StatusCode, Json<serde_json::Value>)> {
387    let registry = state.registry.read().await;
388    let config = match registry.get(id) {
389        Ok(config) => config.clone(),
390        Err(_) => {
391            return Err((
392                StatusCode::NOT_FOUND,
393                Json(serde_json::json!({ "error": format!("Node not found: {id}") })),
394            ))
395        }
396    };
397    drop(registry);
398
399    // An evicted node's data directory is gone; refuse to start it (recovery is to dismiss and
400    // re-add, not restart).
401    if config.eviction.is_some() {
402        return Err((
403            StatusCode::CONFLICT,
404            Json(serde_json::json!({
405                "error": format!(
406                    "Node {id} has been evicted and cannot be started. Dismiss it with \
407                     `ant node dismiss {id}` and add a new node instead."
408                ),
409                "current_state": { "node_id": id, "status": "evicted" }
410            })),
411        ));
412    }
413
414    let supervisor_ref = state.supervisor.clone();
415
416    // Acquire write lock once for atomic check-and-act (avoids TOCTOU race)
417    let mut supervisor = state.supervisor.write().await;
418    if supervisor.is_running(id) {
419        let pid = supervisor.node_pid(id);
420        let uptime_secs = supervisor.node_uptime_secs(id);
421        return Err((
422            StatusCode::CONFLICT,
423            Json(serde_json::json!({
424                "error": format!("Node {id} is already running"),
425                "current_state": {
426                    "node_id": id,
427                    "status": "running",
428                    "pid": pid,
429                    "uptime_secs": uptime_secs,
430                }
431            })),
432        ));
433    }
434
435    let registry_ref = state.registry.clone();
436    match supervisor
437        .start_node(&config, supervisor_ref, registry_ref)
438        .await
439    {
440        Ok(started) => Ok(Json(started)),
441        Err(crate::error::Error::NodeAlreadyRunning(id)) => {
442            let pid = supervisor.node_pid(id);
443            let uptime_secs = supervisor.node_uptime_secs(id);
444            Err((
445                StatusCode::CONFLICT,
446                Json(serde_json::json!({
447                    "error": format!("Node {id} is already running"),
448                    "current_state": {
449                        "node_id": id,
450                        "status": "running",
451                        "pid": pid,
452                        "uptime_secs": uptime_secs,
453                    }
454                })),
455            ))
456        }
457        Err(e) => Err((
458            StatusCode::INTERNAL_SERVER_ERROR,
459            Json(serde_json::json!({ "error": e.to_string() })),
460        )),
461    }
462}
463
464/// POST /api/v1/nodes/start-all — Start all registered nodes.
465async fn post_start_all(State(state): State<Arc<AppState>>) -> Json<StartNodeResult> {
466    let registry = state.registry.read().await;
467    // Evicted nodes have no data directory; skip them silently rather than attempting a spawn that
468    // would fail. They remain visible as `Evicted` in status until dismissed.
469    let configs: Vec<_> = registry
470        .list()
471        .into_iter()
472        .filter(|c| c.eviction.is_none())
473        .cloned()
474        .collect();
475    drop(registry);
476
477    let mut started = Vec::new();
478    let mut failed = Vec::new();
479    let mut already_running = Vec::new();
480
481    let supervisor_ref = state.supervisor.clone();
482    let registry_ref = state.registry.clone();
483
484    for config in &configs {
485        let mut supervisor = state.supervisor.write().await;
486        if supervisor.is_running(config.id) {
487            already_running.push(config.id);
488            continue;
489        }
490
491        match supervisor
492            .start_node(config, supervisor_ref.clone(), registry_ref.clone())
493            .await
494        {
495            Ok(result) => started.push(result),
496            Err(crate::error::Error::NodeAlreadyRunning(id)) => {
497                already_running.push(id);
498            }
499            Err(e) => {
500                failed.push(crate::node::types::NodeStartFailed {
501                    node_id: config.id,
502                    service_name: config.service_name.clone(),
503                    error: e.to_string(),
504                });
505            }
506        }
507    }
508
509    Json(StartNodeResult {
510        started,
511        failed,
512        already_running,
513    })
514}
515
516/// POST /api/v1/nodes/:id/stop — Stop a specific node.
517async fn post_stop_node(
518    State(state): State<Arc<AppState>>,
519    Path(id): Path<u32>,
520) -> std::result::Result<Json<NodeStopped>, (StatusCode, Json<serde_json::Value>)> {
521    let registry = state.registry.read().await;
522    let config = match registry.get(id) {
523        Ok(config) => config.clone(),
524        Err(_) => {
525            return Err((
526                StatusCode::NOT_FOUND,
527                Json(serde_json::json!({ "error": format!("Node not found: {id}") })),
528            ))
529        }
530    };
531    drop(registry);
532
533    // An evicted node is already stopped and its data directory deleted; there is nothing to stop.
534    if config.eviction.is_some() {
535        return Err((
536            StatusCode::CONFLICT,
537            Json(serde_json::json!({
538                "error": format!(
539                    "Node {id} has been evicted; there is nothing to stop. Dismiss it with \
540                     `ant node dismiss {id}`."
541                ),
542                "current_state": { "node_id": id, "status": "evicted" }
543            })),
544        ));
545    }
546
547    // Acquire write lock once for atomic check-and-act (avoids TOCTOU race)
548    let mut supervisor = state.supervisor.write().await;
549    if !supervisor.is_running(id) {
550        let status = supervisor
551            .node_status(id)
552            .unwrap_or(crate::node::types::NodeStatus::Stopped);
553        return Err((
554            StatusCode::CONFLICT,
555            Json(serde_json::json!({
556                "error": format!("Node {id} is not running"),
557                "current_state": {
558                    "node_id": id,
559                    "status": status,
560                }
561            })),
562        ));
563    }
564
565    match supervisor.stop_node(id).await {
566        Ok(()) => Ok(Json(NodeStopped {
567            node_id: id,
568            service_name: config.service_name,
569        })),
570        Err(crate::error::Error::NodeNotRunning(id)) => {
571            let status = supervisor
572                .node_status(id)
573                .unwrap_or(crate::node::types::NodeStatus::Stopped);
574            Err((
575                StatusCode::CONFLICT,
576                Json(serde_json::json!({
577                    "error": format!("Node {id} is not running"),
578                    "current_state": {
579                        "node_id": id,
580                        "status": status,
581                    }
582                })),
583            ))
584        }
585        Err(e) => Err((
586            StatusCode::INTERNAL_SERVER_ERROR,
587            Json(serde_json::json!({ "error": e.to_string() })),
588        )),
589    }
590}
591
592/// POST /api/v1/nodes/stop-all — Stop all running nodes.
593async fn post_stop_all(State(state): State<Arc<AppState>>) -> Json<StopNodeResult> {
594    let registry = state.registry.read().await;
595    // Skip evicted nodes — there is nothing to stop, and they should stay `Evicted` until dismissed.
596    let configs: Vec<(u32, String)> = registry
597        .list()
598        .into_iter()
599        .filter(|c| c.eviction.is_none())
600        .map(|c| (c.id, c.service_name.clone()))
601        .collect();
602    drop(registry);
603
604    let mut supervisor = state.supervisor.write().await;
605    let result = supervisor.stop_all_nodes(&configs).await;
606
607    Json(result)
608}
609
610/// POST /api/v1/reset — Reset all node state.
611async fn post_reset(
612    State(state): State<Arc<AppState>>,
613) -> std::result::Result<Json<ResetResult>, (StatusCode, Json<serde_json::Value>)> {
614    // Hold write lock for atomic check-and-act (prevents nodes being started
615    // between the running check and the reset operation)
616    let supervisor = state.supervisor.write().await;
617    let (running, _, _) = supervisor.node_counts();
618    if running > 0 {
619        return Err((
620            StatusCode::CONFLICT,
621            Json(serde_json::json!({
622                "error": format!("Cannot reset while nodes are running ({running} node(s) still running). Stop all nodes first."),
623                "nodes_running": running,
624            })),
625        ));
626    }
627    drop(supervisor);
628
629    let registry_path = state.config.registry_path.clone();
630
631    match crate::node::reset(&registry_path) {
632        Ok(result) => {
633            // Update the in-memory registry to stay in sync
634            let mut registry = state.registry.write().await;
635            if let Ok(fresh) = NodeRegistry::load(&registry_path) {
636                *registry = fresh;
637            }
638            Ok(Json(result))
639        }
640        Err(e) => Err((
641            StatusCode::INTERNAL_SERVER_ERROR,
642            Json(serde_json::json!({ "error": e.to_string() })),
643        )),
644    }
645}
646
647async fn get_openapi() -> impl IntoResponse {
648    // TODO: Migrate to utoipa-generated OpenAPI spec. Types already derive
649    // utoipa::ToSchema but this spec is still hand-written JSON.
650    let spec = serde_json::json!({
651        "openapi": "3.1.0",
652        "info": {
653            "title": "Ant Daemon API",
654            "version": "0.1.0",
655            "description": "REST API for the ant node management daemon"
656        },
657        "paths": {
658            "/api/v1/status": {
659                "get": {
660                    "summary": "Daemon status",
661                    "description": "Returns daemon health, uptime, and node count summary",
662                    "responses": {
663                        "200": {
664                            "description": "Daemon status",
665                            "content": {
666                                "application/json": {
667                                    "schema": { "$ref": "#/components/schemas/DaemonStatus" }
668                                }
669                            }
670                        }
671                    }
672                }
673            },
674            "/api/v1/events": {
675                "get": {
676                    "summary": "Event stream",
677                    "description": "SSE stream of real-time node events",
678                    "responses": {
679                        "200": {
680                            "description": "SSE event stream"
681                        }
682                    }
683                }
684            },
685            "/api/v1/nodes": {
686                "post": {
687                    "summary": "Add nodes",
688                    "description": "Add one or more nodes to the registry",
689                    "requestBody": {
690                        "required": true,
691                        "content": {
692                            "application/json": {
693                                "schema": { "$ref": "#/components/schemas/AddNodeOpts" }
694                            }
695                        }
696                    },
697                    "responses": {
698                        "201": {
699                            "description": "Nodes added",
700                            "content": {
701                                "application/json": {
702                                    "schema": { "$ref": "#/components/schemas/AddNodeResult" }
703                                }
704                            }
705                        },
706                        "400": {
707                            "description": "Invalid request"
708                        }
709                    }
710                }
711            },
712            "/api/v1/nodes/{id}": {
713                "delete": {
714                    "summary": "Remove node",
715                    "description": "Remove a node from the registry",
716                    "parameters": [{
717                        "name": "id",
718                        "in": "path",
719                        "required": true,
720                        "schema": { "type": "integer" }
721                    }],
722                    "responses": {
723                        "200": {
724                            "description": "Node removed",
725                            "content": {
726                                "application/json": {
727                                    "schema": { "$ref": "#/components/schemas/RemoveNodeResult" }
728                                }
729                            }
730                        },
731                        "404": {
732                            "description": "Node not found"
733                        }
734                    }
735                }
736            },
737            "/api/v1/nodes/{id}/start": {
738                "post": {
739                    "summary": "Start a node",
740                    "description": "Start a specific node by ID. Returns 409 if already running with current_state.",
741                    "parameters": [{
742                        "name": "id",
743                        "in": "path",
744                        "required": true,
745                        "schema": { "type": "integer" }
746                    }],
747                    "responses": {
748                        "200": {
749                            "description": "Node started",
750                            "content": {
751                                "application/json": {
752                                    "schema": { "$ref": "#/components/schemas/NodeStarted" }
753                                }
754                            }
755                        },
756                        "404": {
757                            "description": "Node not found"
758                        },
759                        "409": {
760                            "description": "Node already running (includes current_state)"
761                        },
762                        "500": {
763                            "description": "Failed to start node"
764                        }
765                    }
766                }
767            },
768            "/api/v1/nodes/start-all": {
769                "post": {
770                    "summary": "Start all nodes",
771                    "description": "Start all registered nodes. Returns per-node results.",
772                    "responses": {
773                        "200": {
774                            "description": "Start results",
775                            "content": {
776                                "application/json": {
777                                    "schema": { "$ref": "#/components/schemas/StartNodeResult" }
778                                }
779                            }
780                        }
781                    }
782                }
783            },
784            "/api/v1/nodes/{id}/stop": {
785                "post": {
786                    "summary": "Stop a node",
787                    "description": "Stop a specific node by ID. Returns 409 if already stopped with current_state.",
788                    "parameters": [{
789                        "name": "id",
790                        "in": "path",
791                        "required": true,
792                        "schema": { "type": "integer" }
793                    }],
794                    "responses": {
795                        "200": {
796                            "description": "Node stopped",
797                            "content": {
798                                "application/json": {
799                                    "schema": { "$ref": "#/components/schemas/NodeStopped" }
800                                }
801                            }
802                        },
803                        "404": {
804                            "description": "Node not found"
805                        },
806                        "409": {
807                            "description": "Node not running (includes current_state)"
808                        },
809                        "500": {
810                            "description": "Failed to stop node"
811                        }
812                    }
813                }
814            },
815            "/api/v1/nodes/stop-all": {
816                "post": {
817                    "summary": "Stop all nodes",
818                    "description": "Stop all running nodes. Returns per-node results.",
819                    "responses": {
820                        "200": {
821                            "description": "Stop results",
822                            "content": {
823                                "application/json": {
824                                    "schema": { "$ref": "#/components/schemas/StopNodeResult" }
825                                }
826                            }
827                        }
828                    }
829                }
830            },
831            "/api/v1/reset": {
832                "post": {
833                    "summary": "Reset all node state",
834                    "description": "Remove all node data directories, log directories, and clear the registry. Fails if any nodes are running.",
835                    "responses": {
836                        "200": {
837                            "description": "Reset successful",
838                            "content": {
839                                "application/json": {
840                                    "schema": { "$ref": "#/components/schemas/ResetResult" }
841                                }
842                            }
843                        },
844                        "409": {
845                            "description": "Nodes still running"
846                        }
847                    }
848                }
849            }
850        },
851        "components": {
852            "schemas": {
853                "DaemonStatus": {
854                    "type": "object",
855                    "properties": {
856                        "running": { "type": "boolean" },
857                        "pid": { "type": "integer", "nullable": true },
858                        "port": { "type": "integer", "nullable": true },
859                        "uptime_secs": { "type": "integer", "nullable": true },
860                        "nodes_total": { "type": "integer" },
861                        "nodes_running": { "type": "integer" },
862                        "nodes_stopped": { "type": "integer" },
863                        "nodes_errored": { "type": "integer" }
864                    }
865                }
866            }
867        }
868    });
869    Json(spec)
870}
871
872async fn get_console() -> Html<&'static str> {
873    Html(include_str!("console.html"))
874}
875
876fn write_file(path: &PathBuf, contents: &str) -> Result<()> {
877    if let Some(parent) = path.parent() {
878        std::fs::create_dir_all(parent)?;
879    }
880    std::fs::write(path, contents)?;
881    Ok(())
882}
883
884/// Refresh each registered node's `version` against what its on-disk binary reports.
885///
886/// Intended as a one-time pass at daemon startup to heal registries left in a stale state by
887/// earlier daemon versions that didn't track auto-upgrades. Missing binaries and transient
888/// `--version` failures are silently skipped so daemon startup never aborts on this.
889async fn reconcile_registry_versions(registry: &mut NodeRegistry) {
890    let node_ids: Vec<u32> = registry.list().iter().map(|c| c.id).collect();
891    let mut changed = false;
892
893    for id in node_ids {
894        let (binary_path, recorded_version) = match registry.get(id) {
895            Ok(c) => (c.binary_path.clone(), c.version.clone()),
896            Err(_) => continue,
897        };
898
899        if !binary_path.exists() {
900            continue;
901        }
902
903        let Ok(disk_version) = crate::node::binary::extract_version(&binary_path).await else {
904            continue;
905        };
906
907        if disk_version == recorded_version {
908            continue;
909        }
910
911        if let Ok(entry) = registry.get_mut(id) {
912            entry.version = disk_version;
913            changed = true;
914        }
915    }
916
917    if changed {
918        let _ = registry.save();
919    }
920}
921
922#[cfg(all(test, unix))]
923mod tests {
924    use super::*;
925    use crate::node::registry::NodeRegistry;
926    use crate::node::types::{EvmNetwork, NodeConfig};
927    use std::collections::HashMap;
928    use std::os::unix::fs::PermissionsExt;
929
930    fn write_fake_binary(path: &std::path::Path, stdout: &str) {
931        let script = format!("#!/bin/sh\nprintf '%s\\n' '{stdout}'\n");
932        std::fs::write(path, script).unwrap();
933        let mut perm = std::fs::metadata(path).unwrap().permissions();
934        perm.set_mode(0o755);
935        std::fs::set_permissions(path, perm).unwrap();
936    }
937
938    fn seed_config(binary_path: PathBuf, version: &str, data_dir: PathBuf) -> NodeConfig {
939        NodeConfig {
940            id: 0,
941            service_name: String::new(),
942            rewards_address: "0x0".into(),
943            data_dir,
944            log_dir: None,
945            node_port: None,
946            binary_path,
947            version: version.into(),
948            env_variables: HashMap::new(),
949            bootstrap_peers: vec![],
950            upgrade_channel: None,
951            evm_network: EvmNetwork::default(),
952            eviction: None,
953        }
954    }
955
956    #[tokio::test]
957    async fn reconcile_updates_stale_version_and_persists() {
958        let tmp = tempfile::tempdir().unwrap();
959        let reg_path = tmp.path().join("registry.json");
960        let bin_path = tmp.path().join("ant-node");
961        write_fake_binary(&bin_path, "ant-node 0.10.11-rc.1");
962
963        let mut registry = NodeRegistry::load(&reg_path).unwrap();
964        let id = registry.add(seed_config(
965            bin_path.clone(),
966            "0.10.1",
967            tmp.path().join("data"),
968        ));
969        registry.save().unwrap();
970
971        reconcile_registry_versions(&mut registry).await;
972
973        assert_eq!(registry.get(id).unwrap().version, "0.10.11-rc.1");
974
975        let reloaded = NodeRegistry::load(&reg_path).unwrap();
976        assert_eq!(reloaded.get(id).unwrap().version, "0.10.11-rc.1");
977    }
978
979    #[tokio::test]
980    async fn reconcile_leaves_matching_version_alone() {
981        let tmp = tempfile::tempdir().unwrap();
982        let reg_path = tmp.path().join("registry.json");
983        let bin_path = tmp.path().join("ant-node");
984        write_fake_binary(&bin_path, "ant-node 0.10.1");
985
986        let mut registry = NodeRegistry::load(&reg_path).unwrap();
987        let id = registry.add(seed_config(
988            bin_path.clone(),
989            "0.10.1",
990            tmp.path().join("data"),
991        ));
992
993        reconcile_registry_versions(&mut registry).await;
994
995        assert_eq!(registry.get(id).unwrap().version, "0.10.1");
996    }
997
998    #[tokio::test]
999    async fn reconcile_skips_missing_binary() {
1000        let tmp = tempfile::tempdir().unwrap();
1001        let reg_path = tmp.path().join("registry.json");
1002
1003        let mut registry = NodeRegistry::load(&reg_path).unwrap();
1004        let id = registry.add(seed_config(
1005            tmp.path().join("does-not-exist"),
1006            "0.10.1",
1007            tmp.path().join("data"),
1008        ));
1009
1010        reconcile_registry_versions(&mut registry).await;
1011
1012        assert_eq!(registry.get(id).unwrap().version, "0.10.1");
1013    }
1014}