huskies: merge 766
This commit is contained in:
@@ -4,16 +4,13 @@ use bft_json_crdt::json_crdt::SignedOp;
|
|||||||
use futures::{SinkExt, StreamExt};
|
use futures::{SinkExt, StreamExt};
|
||||||
use poem::handler;
|
use poem::handler;
|
||||||
use poem::http::StatusCode;
|
use poem::http::StatusCode;
|
||||||
use poem::web::Data;
|
|
||||||
use poem::web::Query;
|
use poem::web::Query;
|
||||||
use poem::web::websocket::{Message as WsMessage, WebSocket};
|
use poem::web::websocket::{Message as WsMessage, WebSocket};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use crate::crdt_snapshot;
|
use crate::crdt_snapshot;
|
||||||
use crate::crdt_state;
|
use crate::crdt_state;
|
||||||
use crate::crdt_wire;
|
use crate::crdt_wire;
|
||||||
use crate::http::context::AppContext;
|
|
||||||
use crate::slog;
|
use crate::slog;
|
||||||
use crate::slog_warn;
|
use crate::slog_warn;
|
||||||
|
|
||||||
@@ -41,7 +38,6 @@ struct SyncQueryParams {
|
|||||||
|
|
||||||
pub async fn crdt_sync_handler(
|
pub async fn crdt_sync_handler(
|
||||||
ws: WebSocket,
|
ws: WebSocket,
|
||||||
_ctx: Data<&Arc<AppContext>>,
|
|
||||||
remote_addr: &poem::web::RemoteAddr,
|
remote_addr: &poem::web::RemoteAddr,
|
||||||
Query(params): Query<SyncQueryParams>,
|
Query(params): Query<SyncQueryParams>,
|
||||||
) -> poem::Response {
|
) -> poem::Response {
|
||||||
|
|||||||
+1
-196
@@ -12,7 +12,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
// Re-export public types that callers reference as `crate::gateway::*`.
|
// Re-export public types that callers reference as `crate::gateway::*`.
|
||||||
pub use crate::service::gateway::{
|
pub use crate::service::gateway::{
|
||||||
GatewayConfig, GatewayState as GatewayStateType, GatewayStatusEvent, JoinedAgent, ProjectEntry,
|
GatewayConfig, GatewayState as GatewayStateType, GatewayStatusEvent, ProjectEntry,
|
||||||
broadcast_status_event, fetch_all_project_pipeline_statuses, format_aggregate_status_compact,
|
broadcast_status_event, fetch_all_project_pipeline_statuses, format_aggregate_status_compact,
|
||||||
spawn_gateway_broadcaster_forwarder, spawn_gateway_notification_poller,
|
spawn_gateway_broadcaster_forwarder, spawn_gateway_notification_poller,
|
||||||
subscribe_status_events,
|
subscribe_status_events,
|
||||||
@@ -54,23 +54,6 @@ pub fn build_gateway_route(state_arc: Arc<GatewayState>) -> impl poem::Endpoint
|
|||||||
"/gateway/tokens",
|
"/gateway/tokens",
|
||||||
poem::post(gateway_generate_token_handler),
|
poem::post(gateway_generate_token_handler),
|
||||||
)
|
)
|
||||||
.at(
|
|
||||||
"/gateway/register",
|
|
||||||
poem::post(gateway_register_agent_handler),
|
|
||||||
)
|
|
||||||
.at("/gateway/agents", poem::get(gateway_list_agents_handler))
|
|
||||||
.at(
|
|
||||||
"/gateway/agents/:id",
|
|
||||||
poem::delete(gateway_remove_agent_handler),
|
|
||||||
)
|
|
||||||
.at(
|
|
||||||
"/gateway/agents/:id/assign",
|
|
||||||
poem::post(gateway_assign_agent_handler),
|
|
||||||
)
|
|
||||||
.at(
|
|
||||||
"/gateway/agents/:id/heartbeat",
|
|
||||||
poem::post(gateway_heartbeat_handler),
|
|
||||||
)
|
|
||||||
.at(
|
.at(
|
||||||
"/gateway/events/push",
|
"/gateway/events/push",
|
||||||
poem::get(gateway_event_push_handler),
|
poem::get(gateway_event_push_handler),
|
||||||
@@ -197,184 +180,6 @@ mod tests {
|
|||||||
assert!(tokens.contains_key(token));
|
assert!(tokens.contains_key(token));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn register_agent_consumes_token() {
|
|
||||||
let state = make_test_state();
|
|
||||||
|
|
||||||
let token = "test-token-123".to_string();
|
|
||||||
state.pending_tokens.write().await.insert(
|
|
||||||
token.clone(),
|
|
||||||
gateway::PendingToken {
|
|
||||||
created_at: chrono::Utc::now().timestamp() as f64,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let app = poem::Route::new()
|
|
||||||
.at(
|
|
||||||
"/gateway/register",
|
|
||||||
poem::post(gateway_register_agent_handler),
|
|
||||||
)
|
|
||||||
.data(state.clone());
|
|
||||||
let cli = poem::test::TestClient::new(app);
|
|
||||||
let resp = cli
|
|
||||||
.post("/gateway/register")
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.body(
|
|
||||||
serde_json::json!({
|
|
||||||
"token": token,
|
|
||||||
"label": "test-agent",
|
|
||||||
"address": "ws://localhost:3001/crdt-sync"
|
|
||||||
})
|
|
||||||
.to_string(),
|
|
||||||
)
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
assert_eq!(resp.0.status(), poem::http::StatusCode::OK);
|
|
||||||
assert!(state.pending_tokens.read().await.is_empty());
|
|
||||||
let agents = state.joined_agents.read().await;
|
|
||||||
assert_eq!(agents.len(), 1);
|
|
||||||
assert_eq!(agents[0].label, "test-agent");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn register_agent_rejects_invalid_token() {
|
|
||||||
let state = make_test_state();
|
|
||||||
let app = poem::Route::new()
|
|
||||||
.at(
|
|
||||||
"/gateway/register",
|
|
||||||
poem::post(gateway_register_agent_handler),
|
|
||||||
)
|
|
||||||
.data(state.clone());
|
|
||||||
let cli = poem::test::TestClient::new(app);
|
|
||||||
let resp = cli
|
|
||||||
.post("/gateway/register")
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.body(
|
|
||||||
serde_json::json!({
|
|
||||||
"token": "bad-token",
|
|
||||||
"label": "agent",
|
|
||||||
"address": "ws://localhost:3001/crdt-sync"
|
|
||||||
})
|
|
||||||
.to_string(),
|
|
||||||
)
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
assert_eq!(resp.0.status(), poem::http::StatusCode::UNAUTHORIZED);
|
|
||||||
assert!(state.joined_agents.read().await.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn list_agents_returns_registered_agents() {
|
|
||||||
let state = make_test_state();
|
|
||||||
state
|
|
||||||
.joined_agents
|
|
||||||
.write()
|
|
||||||
.await
|
|
||||||
.push(gateway::JoinedAgent {
|
|
||||||
id: "id-1".into(),
|
|
||||||
label: "agent-1".into(),
|
|
||||||
address: "ws://a:3001/crdt-sync".into(),
|
|
||||||
registered_at: 0.0,
|
|
||||||
last_seen: 0.0,
|
|
||||||
assigned_project: None,
|
|
||||||
});
|
|
||||||
let app = poem::Route::new()
|
|
||||||
.at("/gateway/agents", poem::get(gateway_list_agents_handler))
|
|
||||||
.data(state.clone());
|
|
||||||
let cli = poem::test::TestClient::new(app);
|
|
||||||
let resp = cli.get("/gateway/agents").send().await;
|
|
||||||
assert_eq!(resp.0.status(), poem::http::StatusCode::OK);
|
|
||||||
let agents: Vec<serde_json::Value> = resp.0.into_body().into_json().await.unwrap();
|
|
||||||
assert_eq!(agents.len(), 1);
|
|
||||||
assert_eq!(agents[0]["label"], "agent-1");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn remove_agent_deletes_by_id() {
|
|
||||||
let state = make_test_state();
|
|
||||||
state
|
|
||||||
.joined_agents
|
|
||||||
.write()
|
|
||||||
.await
|
|
||||||
.push(gateway::JoinedAgent {
|
|
||||||
id: "del-id".into(),
|
|
||||||
label: "to-delete".into(),
|
|
||||||
address: "ws://x:3001/crdt-sync".into(),
|
|
||||||
registered_at: 0.0,
|
|
||||||
last_seen: 0.0,
|
|
||||||
assigned_project: None,
|
|
||||||
});
|
|
||||||
let app = poem::Route::new()
|
|
||||||
.at(
|
|
||||||
"/gateway/agents/:id",
|
|
||||||
poem::delete(gateway_remove_agent_handler),
|
|
||||||
)
|
|
||||||
.data(state.clone());
|
|
||||||
let cli = poem::test::TestClient::new(app);
|
|
||||||
let resp = cli.delete("/gateway/agents/del-id").send().await;
|
|
||||||
assert_eq!(resp.0.status(), poem::http::StatusCode::NO_CONTENT);
|
|
||||||
assert!(state.joined_agents.read().await.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn remove_agent_unknown_id_returns_not_found() {
|
|
||||||
let state = make_test_state();
|
|
||||||
let app = poem::Route::new()
|
|
||||||
.at(
|
|
||||||
"/gateway/agents/:id",
|
|
||||||
poem::delete(gateway_remove_agent_handler),
|
|
||||||
)
|
|
||||||
.data(state.clone());
|
|
||||||
let cli = poem::test::TestClient::new(app);
|
|
||||||
let resp = cli.delete("/gateway/agents/no-such-id").send().await;
|
|
||||||
assert_eq!(resp.0.status(), poem::http::StatusCode::NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn heartbeat_updates_last_seen() {
|
|
||||||
let state = make_test_state();
|
|
||||||
state
|
|
||||||
.joined_agents
|
|
||||||
.write()
|
|
||||||
.await
|
|
||||||
.push(gateway::JoinedAgent {
|
|
||||||
id: "hb-id".into(),
|
|
||||||
label: "hb-agent".into(),
|
|
||||||
address: "ws://hb:3001/crdt-sync".into(),
|
|
||||||
registered_at: 0.0,
|
|
||||||
last_seen: 0.0,
|
|
||||||
assigned_project: None,
|
|
||||||
});
|
|
||||||
let app = poem::Route::new()
|
|
||||||
.at(
|
|
||||||
"/gateway/agents/:id/heartbeat",
|
|
||||||
poem::post(gateway_heartbeat_handler),
|
|
||||||
)
|
|
||||||
.data(state.clone());
|
|
||||||
let cli = poem::test::TestClient::new(app);
|
|
||||||
let resp = cli.post("/gateway/agents/hb-id/heartbeat").send().await;
|
|
||||||
assert_eq!(resp.0.status(), poem::http::StatusCode::NO_CONTENT);
|
|
||||||
let agents = state.joined_agents.read().await;
|
|
||||||
assert!(agents[0].last_seen > 0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn heartbeat_unknown_id_returns_not_found() {
|
|
||||||
let state = make_test_state();
|
|
||||||
let app = poem::Route::new()
|
|
||||||
.at(
|
|
||||||
"/gateway/agents/:id/heartbeat",
|
|
||||||
poem::post(gateway_heartbeat_handler),
|
|
||||||
)
|
|
||||||
.data(state.clone());
|
|
||||||
let cli = poem::test::TestClient::new(app);
|
|
||||||
let resp = cli
|
|
||||||
.post("/gateway/agents/no-such-id/heartbeat")
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
assert_eq!(resp.0.status(), poem::http::StatusCode::NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Notification poller integration tests ────────────────────────────
|
// ── Notification poller integration tests ────────────────────────────
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -513,127 +513,6 @@ pub async fn gateway_generate_token_handler(state: Data<&Arc<GatewayState>>) ->
|
|||||||
.body(Body::from(serde_json::to_vec(&body).unwrap_or_default()))
|
.body(Body::from(serde_json::to_vec(&body).unwrap_or_default()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request body sent by a build agent when registering with the gateway.
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct RegisterAgentRequest {
|
|
||||||
token: String,
|
|
||||||
label: String,
|
|
||||||
address: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `POST /gateway/register` — build agent presents its join token and registers.
|
|
||||||
#[handler]
|
|
||||||
pub async fn gateway_register_agent_handler(
|
|
||||||
body: Body,
|
|
||||||
state: Data<&Arc<GatewayState>>,
|
|
||||||
) -> Response {
|
|
||||||
let bytes = match body.into_bytes().await {
|
|
||||||
Ok(b) => b,
|
|
||||||
Err(_) => {
|
|
||||||
return Response::builder()
|
|
||||||
.status(StatusCode::BAD_REQUEST)
|
|
||||||
.body(Body::from("could not read request body"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let req: RegisterAgentRequest = match serde_json::from_slice(&bytes) {
|
|
||||||
Ok(r) => r,
|
|
||||||
Err(_) => {
|
|
||||||
return Response::builder()
|
|
||||||
.status(StatusCode::BAD_REQUEST)
|
|
||||||
.body(Body::from("invalid JSON body"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match gateway::register_agent(&state, &req.token, req.label, req.address).await {
|
|
||||||
Ok(agent) => {
|
|
||||||
let body = serde_json::to_vec(&agent).unwrap_or_default();
|
|
||||||
Response::builder()
|
|
||||||
.status(StatusCode::OK)
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.body(Body::from(body))
|
|
||||||
}
|
|
||||||
Err(_) => Response::builder()
|
|
||||||
.status(StatusCode::UNAUTHORIZED)
|
|
||||||
.body(Body::from("invalid or already-used join token")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `GET /gateway/agents` — list all registered build agents.
|
|
||||||
#[handler]
|
|
||||||
pub async fn gateway_list_agents_handler(state: Data<&Arc<GatewayState>>) -> Response {
|
|
||||||
let agents = state.joined_agents.read().await.clone();
|
|
||||||
let body = serde_json::to_vec(&agents).unwrap_or_default();
|
|
||||||
Response::builder()
|
|
||||||
.status(StatusCode::OK)
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.body(Body::from(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `DELETE /gateway/agents/:id` — remove a registered build agent.
|
|
||||||
#[handler]
|
|
||||||
pub async fn gateway_remove_agent_handler(
|
|
||||||
PoemPath(id): PoemPath<String>,
|
|
||||||
state: Data<&Arc<GatewayState>>,
|
|
||||||
) -> Response {
|
|
||||||
if gateway::remove_agent(&state, &id).await {
|
|
||||||
Response::builder()
|
|
||||||
.status(StatusCode::NO_CONTENT)
|
|
||||||
.body(Body::empty())
|
|
||||||
} else {
|
|
||||||
Response::builder()
|
|
||||||
.status(StatusCode::NOT_FOUND)
|
|
||||||
.body(Body::from("agent not found"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Request body for assigning an agent to a project.
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct AssignAgentRequest {
|
|
||||||
project: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `POST /gateway/agents/:id/assign` — assign or unassign an agent to a project.
|
|
||||||
#[handler]
|
|
||||||
pub async fn gateway_assign_agent_handler(
|
|
||||||
PoemPath(id): PoemPath<String>,
|
|
||||||
body: Json<AssignAgentRequest>,
|
|
||||||
state: Data<&Arc<GatewayState>>,
|
|
||||||
) -> Response {
|
|
||||||
match gateway::assign_agent(&state, &id, body.0.project).await {
|
|
||||||
Ok(agent) => {
|
|
||||||
let body = serde_json::to_vec(&agent).unwrap_or_default();
|
|
||||||
Response::builder()
|
|
||||||
.status(StatusCode::OK)
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.body(Body::from(body))
|
|
||||||
}
|
|
||||||
Err(gateway::Error::ProjectNotFound(msg)) => Response::builder()
|
|
||||||
.status(StatusCode::BAD_REQUEST)
|
|
||||||
.body(Body::from(msg)),
|
|
||||||
Err(_) => Response::builder()
|
|
||||||
.status(StatusCode::NOT_FOUND)
|
|
||||||
.body(Body::from("agent not found")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `POST /gateway/agents/:id/heartbeat` — update an agent's last-seen timestamp.
|
|
||||||
#[handler]
|
|
||||||
pub async fn gateway_heartbeat_handler(
|
|
||||||
PoemPath(id): PoemPath<String>,
|
|
||||||
state: Data<&Arc<GatewayState>>,
|
|
||||||
) -> Response {
|
|
||||||
if gateway::heartbeat_agent(&state, &id).await {
|
|
||||||
Response::builder()
|
|
||||||
.status(StatusCode::NO_CONTENT)
|
|
||||||
.body(Body::empty())
|
|
||||||
} else {
|
|
||||||
Response::builder()
|
|
||||||
.status(StatusCode::NOT_FOUND)
|
|
||||||
.body(Body::from("agent not found"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Event-push WebSocket handler ────────────────────────────────────────────
|
// ── Event-push WebSocket handler ────────────────────────────────────────────
|
||||||
|
|
||||||
/// Query parameters accepted on the `/gateway/events/push` WebSocket upgrade.
|
/// Query parameters accepted on the `/gateway/events/push` WebSocket upgrade.
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
//! spawning the Matrix bot task, and the notification poller background task.
|
//! spawning the Matrix bot task, and the notification poller background task.
|
||||||
|
|
||||||
use super::config::{GatewayConfig, ProjectEntry};
|
use super::config::{GatewayConfig, ProjectEntry};
|
||||||
use super::registration::JoinedAgent;
|
|
||||||
pub use reqwest::Client;
|
pub use reqwest::Client;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::{BTreeMap, HashMap};
|
||||||
@@ -20,16 +19,6 @@ pub fn load_config(path: &Path) -> Result<GatewayConfig, String> {
|
|||||||
toml::from_str(&contents).map_err(|e| format!("invalid projects.toml: {e}"))
|
toml::from_str(&contents).map_err(|e| format!("invalid projects.toml: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load persisted agents from `<config_dir>/gateway_agents.json`.
|
|
||||||
/// Returns an empty list if the file does not exist or cannot be parsed.
|
|
||||||
pub fn load_agents(config_dir: &Path) -> Vec<JoinedAgent> {
|
|
||||||
let path = config_dir.join("gateway_agents.json");
|
|
||||||
match std::fs::read(&path) {
|
|
||||||
Ok(data) => serde_json::from_slice(&data).unwrap_or_default(),
|
|
||||||
Err(_) => Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Persist the current projects map to `<config_dir>/projects.toml`.
|
/// Persist the current projects map to `<config_dir>/projects.toml`.
|
||||||
/// Silently ignores write errors or skips when `config_dir` is empty.
|
/// Silently ignores write errors or skips when `config_dir` is empty.
|
||||||
pub async fn save_config(projects: &BTreeMap<String, ProjectEntry>, config_dir: &Path) {
|
pub async fn save_config(projects: &BTreeMap<String, ProjectEntry>, config_dir: &Path) {
|
||||||
@@ -45,18 +34,6 @@ pub async fn save_config(projects: &BTreeMap<String, ProjectEntry>, config_dir:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persist the current agent list to `<config_dir>/gateway_agents.json`.
|
|
||||||
/// Silently ignores write errors.
|
|
||||||
pub async fn save_agents(agents: &[JoinedAgent], config_dir: &Path) {
|
|
||||||
if config_dir == Path::new("") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let path = config_dir.join("gateway_agents.json");
|
|
||||||
if let Ok(data) = serde_json::to_vec_pretty(agents) {
|
|
||||||
let _ = tokio::fs::write(&path, data).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Bot config I/O ──────────────────────────────────────────────────────────
|
// ── Bot config I/O ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Read the current raw bot.toml as key/value pairs for the configuration UI.
|
/// Read the current raw bot.toml as key/value pairs for the configuration UI.
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
//! - `mod.rs` (this file) — public API, typed [`Error`], orchestration, `GatewayState`
|
//! - `mod.rs` (this file) — public API, typed [`Error`], orchestration, `GatewayState`
|
||||||
//! - `io.rs` — the ONLY place that performs side effects (filesystem, network, process spawn)
|
//! - `io.rs` — the ONLY place that performs side effects (filesystem, network, process spawn)
|
||||||
//! - `config.rs` — pure config types and validation
|
//! - `config.rs` — pure config types and validation
|
||||||
//! - `registration.rs` — pure agent registration logic
|
|
||||||
//! - `aggregation.rs` — pure cross-project pipeline formatting
|
//! - `aggregation.rs` — pure cross-project pipeline formatting
|
||||||
//! - `polling.rs` — pure notification event formatting
|
//! - `polling.rs` — pure notification event formatting
|
||||||
|
|
||||||
@@ -12,7 +11,6 @@ pub mod aggregation;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub(crate) mod io;
|
pub(crate) mod io;
|
||||||
pub mod polling;
|
pub mod polling;
|
||||||
pub mod registration;
|
|
||||||
|
|
||||||
pub use aggregation::format_aggregate_status_compact;
|
pub use aggregation::format_aggregate_status_compact;
|
||||||
pub use config::{GatewayConfig, ProjectEntry};
|
pub use config::{GatewayConfig, ProjectEntry};
|
||||||
@@ -20,7 +18,6 @@ pub use io::{
|
|||||||
fetch_all_project_pipeline_statuses, spawn_gateway_broadcaster_forwarder,
|
fetch_all_project_pipeline_statuses, spawn_gateway_broadcaster_forwarder,
|
||||||
spawn_gateway_notification_poller,
|
spawn_gateway_notification_poller,
|
||||||
};
|
};
|
||||||
pub use registration::JoinedAgent;
|
|
||||||
|
|
||||||
use io::Client;
|
use io::Client;
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::{BTreeMap, HashMap};
|
||||||
@@ -29,6 +26,8 @@ use std::sync::Arc;
|
|||||||
use tokio::sync::Mutex as TokioMutex;
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
pub use crate::crdt_state::NodePresenceView;
|
||||||
|
|
||||||
// ── Status event broadcaster ────────────────────────────────────────────────
|
// ── Status event broadcaster ────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Capacity of the gateway status event broadcast channel.
|
/// Capacity of the gateway status event broadcast channel.
|
||||||
@@ -101,8 +100,6 @@ pub struct GatewayState {
|
|||||||
pub active_project: Arc<RwLock<String>>,
|
pub active_project: Arc<RwLock<String>>,
|
||||||
/// HTTP client for proxying requests to project containers.
|
/// HTTP client for proxying requests to project containers.
|
||||||
pub client: Client,
|
pub client: Client,
|
||||||
/// Build agents that have joined this gateway.
|
|
||||||
pub joined_agents: Arc<RwLock<Vec<JoinedAgent>>>,
|
|
||||||
/// One-time join tokens that have been issued but not yet consumed.
|
/// One-time join tokens that have been issued but not yet consumed.
|
||||||
pub(crate) pending_tokens: Arc<RwLock<HashMap<String, PendingToken>>>,
|
pub(crate) pending_tokens: Arc<RwLock<HashMap<String, PendingToken>>>,
|
||||||
/// Directory containing `projects.toml` and the `.huskies/` subfolder.
|
/// Directory containing `projects.toml` and the `.huskies/` subfolder.
|
||||||
@@ -121,20 +118,18 @@ impl GatewayState {
|
|||||||
/// Create a new gateway state from a config and config directory.
|
/// Create a new gateway state from a config and config directory.
|
||||||
///
|
///
|
||||||
/// The first project in the config becomes the active project by default.
|
/// The first project in the config becomes the active project by default.
|
||||||
/// Previously registered agents are loaded from `gateway_agents.json`.
|
/// Agent registrations are stored in the CRDT nodes collection.
|
||||||
pub fn new(
|
pub fn new(
|
||||||
gateway_config: GatewayConfig,
|
gateway_config: GatewayConfig,
|
||||||
config_dir: PathBuf,
|
config_dir: PathBuf,
|
||||||
port: u16,
|
port: u16,
|
||||||
) -> Result<Self, String> {
|
) -> Result<Self, String> {
|
||||||
let first = config::validate_config(&gateway_config)?;
|
let first = config::validate_config(&gateway_config)?;
|
||||||
let agents = io::load_agents(&config_dir);
|
|
||||||
let (event_tx, _) = tokio::sync::broadcast::channel(EVENT_CHANNEL_CAPACITY);
|
let (event_tx, _) = tokio::sync::broadcast::channel(EVENT_CHANNEL_CAPACITY);
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
projects: Arc::new(RwLock::new(gateway_config.projects)),
|
projects: Arc::new(RwLock::new(gateway_config.projects)),
|
||||||
active_project: Arc::new(RwLock::new(first)),
|
active_project: Arc::new(RwLock::new(first)),
|
||||||
client: Client::new(),
|
client: Client::new(),
|
||||||
joined_agents: Arc::new(RwLock::new(agents)),
|
|
||||||
pending_tokens: Arc::new(RwLock::new(HashMap::new())),
|
pending_tokens: Arc::new(RwLock::new(HashMap::new())),
|
||||||
config_dir,
|
config_dir,
|
||||||
port,
|
port,
|
||||||
@@ -187,82 +182,118 @@ pub async fn generate_join_token(state: &GatewayState) -> String {
|
|||||||
token
|
token
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register a build agent with a join token.
|
/// Register a new build agent using a one-time join token.
|
||||||
|
///
|
||||||
|
/// Validates and consumes the token, then writes the agent's node presence
|
||||||
|
/// and metadata to the CRDT collection. Returns the newly-created node view.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub async fn register_agent(
|
pub async fn register_agent(
|
||||||
state: &GatewayState,
|
state: &GatewayState,
|
||||||
token: &str,
|
token: &str,
|
||||||
label: String,
|
label: String,
|
||||||
address: String,
|
address: String,
|
||||||
) -> Result<JoinedAgent, Error> {
|
) -> Result<NodePresenceView, Error> {
|
||||||
// Validate and consume the token.
|
{
|
||||||
let mut tokens = state.pending_tokens.write().await;
|
let mut tokens = state.pending_tokens.write().await;
|
||||||
if !tokens.contains_key(token) {
|
if !tokens.contains_key(token) {
|
||||||
return Err(Error::DuplicateToken(
|
return Err(Error::InvalidAgent(
|
||||||
"invalid or already-used join token".into(),
|
"invalid or already-used join token".into(),
|
||||||
));
|
));
|
||||||
|
}
|
||||||
|
tokens.remove(token);
|
||||||
}
|
}
|
||||||
tokens.remove(token);
|
|
||||||
drop(tokens);
|
|
||||||
|
|
||||||
|
let node_id = uuid::Uuid::new_v4().to_string();
|
||||||
let now = chrono::Utc::now().timestamp() as f64;
|
let now = chrono::Utc::now().timestamp() as f64;
|
||||||
let agent = registration::create_agent(uuid::Uuid::new_v4().to_string(), label, address, now);
|
let now_ms = chrono::Utc::now().timestamp_millis() as f64;
|
||||||
|
|
||||||
|
crate::crdt_state::write_node_presence(&node_id, &address, now, true);
|
||||||
|
crate::crdt_state::write_node_metadata(&node_id, &label, None, now_ms);
|
||||||
|
|
||||||
crate::slog!(
|
crate::slog!(
|
||||||
"[gateway] Agent '{}' registered (id={})",
|
"[gateway] Registered agent '{label}' node_id={:.12}…",
|
||||||
agent.label,
|
&node_id
|
||||||
agent.id
|
|
||||||
);
|
);
|
||||||
|
|
||||||
{
|
crate::crdt_state::read_all_node_presence()
|
||||||
let mut agents = state.joined_agents.write().await;
|
.unwrap_or_default()
|
||||||
agents.push(agent.clone());
|
.into_iter()
|
||||||
io::save_agents(&agents, &state.config_dir).await;
|
.find(|n| n.node_id == node_id)
|
||||||
}
|
.ok_or_else(|| Error::Upstream("node write did not persist".into()))
|
||||||
|
|
||||||
Ok(agent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a registered agent by ID. Returns `true` if found and removed.
|
/// Tombstone a registered agent in the CRDT (set `alive = false`).
|
||||||
pub async fn remove_agent(state: &GatewayState, id: &str) -> bool {
|
///
|
||||||
let mut agents = state.joined_agents.write().await;
|
/// Returns `true` if the node was found and tombstoned.
|
||||||
let removed = registration::remove_agent(&mut agents, id);
|
#[allow(dead_code)]
|
||||||
if removed {
|
pub fn remove_agent(node_id: &str) -> bool {
|
||||||
io::save_agents(&agents, &state.config_dir).await;
|
let nodes = crate::crdt_state::read_all_node_presence().unwrap_or_default();
|
||||||
crate::slog!("[gateway] Removed agent id={id}");
|
let Some(node) = nodes.iter().find(|n| n.node_id == node_id) else {
|
||||||
}
|
return false;
|
||||||
removed
|
};
|
||||||
|
let now = chrono::Utc::now().timestamp() as f64;
|
||||||
|
crate::crdt_state::write_node_presence(node_id, &node.address, now, false);
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Assign or unassign an agent to a project.
|
/// Assign (or unassign) an agent to a project in the CRDT.
|
||||||
|
///
|
||||||
|
/// Validates that the project exists in the gateway config (when assigning),
|
||||||
|
/// then writes the updated `assigned_project` field to the CRDT.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub async fn assign_agent(
|
pub async fn assign_agent(
|
||||||
state: &GatewayState,
|
state: &GatewayState,
|
||||||
id: &str,
|
node_id: &str,
|
||||||
project: Option<String>,
|
project: Option<String>,
|
||||||
) -> Result<JoinedAgent, Error> {
|
) -> Result<NodePresenceView, Error> {
|
||||||
let project_clean = project.and_then(|p| if p.is_empty() { None } else { Some(p) });
|
if let Some(ref p) = project {
|
||||||
|
|
||||||
let updated = {
|
|
||||||
let projects = state.projects.read().await;
|
let projects = state.projects.read().await;
|
||||||
let mut agents = state.joined_agents.write().await;
|
if !projects.contains_key(p.as_str()) {
|
||||||
registration::assign_agent(&mut agents, id, project_clean, &projects)?
|
return Err(Error::ProjectNotFound(format!("unknown project '{p}'")));
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
|
||||||
crate::slog!(
|
let nodes = crate::crdt_state::read_all_node_presence().unwrap_or_default();
|
||||||
"[gateway] Agent '{}' (id={}) assigned to {:?}",
|
let node = nodes
|
||||||
updated.label,
|
.iter()
|
||||||
updated.id,
|
.find(|n| n.node_id == node_id)
|
||||||
updated.assigned_project
|
.ok_or_else(|| Error::InvalidAgent(format!("agent not found: {node_id}")))?;
|
||||||
|
|
||||||
|
let now_ms = chrono::Utc::now().timestamp_millis() as f64;
|
||||||
|
crate::crdt_state::write_node_metadata(
|
||||||
|
node_id,
|
||||||
|
node.label.as_deref().unwrap_or(""),
|
||||||
|
project.as_deref(),
|
||||||
|
now_ms,
|
||||||
);
|
);
|
||||||
let agents = state.joined_agents.read().await.clone();
|
|
||||||
io::save_agents(&agents, &state.config_dir).await;
|
crate::crdt_state::read_all_node_presence()
|
||||||
Ok(updated)
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.find(|n| n.node_id == node_id)
|
||||||
|
.ok_or_else(|| Error::Upstream("node write did not persist".into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update an agent's heartbeat. Returns `true` if found.
|
/// Update an agent's heartbeat via CRDT. Returns `true` if the node was found.
|
||||||
pub async fn heartbeat_agent(state: &GatewayState, id: &str) -> bool {
|
#[allow(dead_code)]
|
||||||
|
pub fn heartbeat_agent(id: &str) -> bool {
|
||||||
let now = chrono::Utc::now().timestamp() as f64;
|
let now = chrono::Utc::now().timestamp() as f64;
|
||||||
let mut agents = state.joined_agents.write().await;
|
let nodes = crate::crdt_state::read_all_node_presence().unwrap_or_default();
|
||||||
registration::heartbeat(&mut agents, id, now)
|
let Some(node) = nodes.iter().find(|n| n.node_id == id) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
crate::crdt_state::write_node_presence(id, &node.address, now, node.alive);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all registered build agents from the CRDT nodes collection.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn list_agents() -> Vec<NodePresenceView> {
|
||||||
|
crate::crdt_state::read_all_node_presence()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|n| n.alive)
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a new project to the gateway config.
|
/// Add a new project to the gateway config.
|
||||||
@@ -561,16 +592,18 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn generate_and_register_agent() {
|
async fn register_agent_consumes_token_and_writes_crdt() {
|
||||||
|
crate::crdt_state::init_for_test();
|
||||||
let config = make_config(&[("test", "http://test:3001")]);
|
let config = make_config(&[("test", "http://test:3001")]);
|
||||||
let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap();
|
let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap();
|
||||||
let token = generate_join_token(&state).await;
|
let token = generate_join_token(&state).await;
|
||||||
let agent = register_agent(&state, &token, "test-agent".into(), "ws://a".into())
|
let node = register_agent(&state, &token, "test-agent".into(), "ws://a".into())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(agent.label, "test-agent");
|
assert_eq!(node.label.as_deref(), Some("test-agent"));
|
||||||
assert!(state.pending_tokens.read().await.is_empty());
|
assert!(state.pending_tokens.read().await.is_empty());
|
||||||
assert_eq!(state.joined_agents.read().await.len(), 1);
|
let agents = list_agents();
|
||||||
|
assert!(agents.iter().any(|n| n.node_id == node.node_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -582,31 +615,29 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn remove_agent_success() {
|
async fn remove_agent_tombstones_crdt_node() {
|
||||||
|
crate::crdt_state::init_for_test();
|
||||||
let config = make_config(&[("test", "http://test:3001")]);
|
let config = make_config(&[("test", "http://test:3001")]);
|
||||||
let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap();
|
let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap();
|
||||||
let token = generate_join_token(&state).await;
|
let token = generate_join_token(&state).await;
|
||||||
let agent = register_agent(&state, &token, "a".into(), "ws://a".into())
|
let node = register_agent(&state, &token, "a".into(), "ws://a".into())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(remove_agent(&state, &agent.id).await);
|
assert!(remove_agent(&node.node_id));
|
||||||
assert!(state.joined_agents.read().await.is_empty());
|
let alive = list_agents();
|
||||||
|
assert!(!alive.iter().any(|n| n.node_id == node.node_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn heartbeat_agent_updates_timestamp() {
|
async fn heartbeat_agent_returns_true_for_known_node() {
|
||||||
|
crate::crdt_state::init_for_test();
|
||||||
let config = make_config(&[("test", "http://test:3001")]);
|
let config = make_config(&[("test", "http://test:3001")]);
|
||||||
let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap();
|
let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap();
|
||||||
let token = generate_join_token(&state).await;
|
let token = generate_join_token(&state).await;
|
||||||
let agent = register_agent(&state, &token, "a".into(), "ws://a".into())
|
let node = register_agent(&state, &token, "a".into(), "ws://a".into())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let old_ts = agent.last_seen;
|
assert!(heartbeat_agent(&node.node_id));
|
||||||
// Small sleep to ensure timestamp differs.
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
|
||||||
assert!(heartbeat_agent(&state, &agent.id).await);
|
|
||||||
let agents = state.joined_agents.read().await;
|
|
||||||
assert!(agents[0].last_seen >= old_ts);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
//! Gateway agent registration — pure logic for managing build agents.
|
|
||||||
//!
|
|
||||||
//! Contains `JoinedAgent` and functions that validate and manipulate agent
|
|
||||||
//! state in memory. All persistence (disk I/O) lives in `io.rs`.
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use super::config::ProjectEntry;
|
|
||||||
|
|
||||||
/// A build agent that has registered with this gateway.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct JoinedAgent {
|
|
||||||
/// Unique ID assigned by the gateway on registration.
|
|
||||||
pub id: String,
|
|
||||||
/// Human-readable label provided by the agent (e.g. `build-agent-abc123`).
|
|
||||||
pub label: String,
|
|
||||||
/// The agent's CRDT-sync WebSocket address (e.g. `ws://host:3001/crdt-sync`).
|
|
||||||
pub address: String,
|
|
||||||
/// Unix timestamp when the agent registered.
|
|
||||||
pub registered_at: f64,
|
|
||||||
/// Unix timestamp of the last heartbeat from this agent.
|
|
||||||
#[serde(default)]
|
|
||||||
pub last_seen: f64,
|
|
||||||
/// Project this agent is assigned to, if any.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub assigned_project: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new `JoinedAgent` from registration data.
|
|
||||||
pub fn create_agent(id: String, label: String, address: String, now: f64) -> JoinedAgent {
|
|
||||||
JoinedAgent {
|
|
||||||
id,
|
|
||||||
label,
|
|
||||||
address,
|
|
||||||
registered_at: now,
|
|
||||||
last_seen: now,
|
|
||||||
assigned_project: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove an agent by ID from the list. Returns `true` if found and removed.
|
|
||||||
pub fn remove_agent(agents: &mut Vec<JoinedAgent>, id: &str) -> bool {
|
|
||||||
let before = agents.len();
|
|
||||||
agents.retain(|a| a.id != id);
|
|
||||||
agents.len() < before
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Assign (or unassign) an agent to a project.
|
|
||||||
///
|
|
||||||
/// Returns the updated agent on success, or an error if the agent or project
|
|
||||||
/// is not found.
|
|
||||||
pub fn assign_agent(
|
|
||||||
agents: &mut [JoinedAgent],
|
|
||||||
id: &str,
|
|
||||||
project: Option<String>,
|
|
||||||
projects: &BTreeMap<String, ProjectEntry>,
|
|
||||||
) -> Result<JoinedAgent, super::Error> {
|
|
||||||
// Validate project exists if assigning.
|
|
||||||
if let Some(ref p) = project
|
|
||||||
&& !projects.contains_key(p.as_str())
|
|
||||||
{
|
|
||||||
return Err(super::Error::ProjectNotFound(format!(
|
|
||||||
"unknown project '{p}'"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
match agents.iter_mut().find(|a| a.id == id) {
|
|
||||||
None => Err(super::Error::InvalidAgent(format!("agent not found: {id}"))),
|
|
||||||
Some(a) => {
|
|
||||||
a.assigned_project = project;
|
|
||||||
Ok(a.clone())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update an agent's last-seen timestamp. Returns `true` if the agent was found.
|
|
||||||
pub fn heartbeat(agents: &mut [JoinedAgent], id: &str, now: f64) -> bool {
|
|
||||||
match agents.iter_mut().find(|a| a.id == id) {
|
|
||||||
None => false,
|
|
||||||
Some(a) => {
|
|
||||||
a.last_seen = now;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn create_agent_sets_fields() {
|
|
||||||
let agent = create_agent("id-1".into(), "lbl".into(), "ws://a".into(), 100.0);
|
|
||||||
assert_eq!(agent.id, "id-1");
|
|
||||||
assert_eq!(agent.label, "lbl");
|
|
||||||
assert_eq!(agent.address, "ws://a");
|
|
||||||
assert_eq!(agent.registered_at, 100.0);
|
|
||||||
assert_eq!(agent.last_seen, 100.0);
|
|
||||||
assert!(agent.assigned_project.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remove_agent_by_id() {
|
|
||||||
let mut agents = vec![
|
|
||||||
create_agent("a".into(), "A".into(), "ws://a".into(), 0.0),
|
|
||||||
create_agent("b".into(), "B".into(), "ws://b".into(), 0.0),
|
|
||||||
];
|
|
||||||
assert!(remove_agent(&mut agents, "a"));
|
|
||||||
assert_eq!(agents.len(), 1);
|
|
||||||
assert_eq!(agents[0].id, "b");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remove_agent_missing_returns_false() {
|
|
||||||
let mut agents = vec![];
|
|
||||||
assert!(!remove_agent(&mut agents, "x"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn assign_agent_to_valid_project() {
|
|
||||||
let mut projects = BTreeMap::new();
|
|
||||||
projects.insert(
|
|
||||||
"proj".into(),
|
|
||||||
ProjectEntry {
|
|
||||||
url: "http://p".into(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let mut agents = vec![create_agent("a".into(), "A".into(), "ws://a".into(), 0.0)];
|
|
||||||
let result = assign_agent(&mut agents, "a", Some("proj".into()), &projects);
|
|
||||||
assert!(result.is_ok());
|
|
||||||
assert_eq!(result.unwrap().assigned_project, Some("proj".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn assign_agent_to_unknown_project_fails() {
|
|
||||||
let projects = BTreeMap::new();
|
|
||||||
let mut agents = vec![create_agent("a".into(), "A".into(), "ws://a".into(), 0.0)];
|
|
||||||
let result = assign_agent(&mut agents, "a", Some("nope".into()), &projects);
|
|
||||||
assert!(result.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn assign_agent_unknown_id_fails() {
|
|
||||||
let projects = BTreeMap::new();
|
|
||||||
let mut agents: Vec<JoinedAgent> = vec![];
|
|
||||||
let result = assign_agent(&mut agents, "x", None, &projects);
|
|
||||||
assert!(result.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn heartbeat_updates_last_seen() {
|
|
||||||
let mut agents = vec![create_agent("a".into(), "A".into(), "ws://a".into(), 0.0)];
|
|
||||||
assert!(heartbeat(&mut agents, "a", 999.0));
|
|
||||||
assert_eq!(agents[0].last_seen, 999.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn heartbeat_unknown_id_returns_false() {
|
|
||||||
let mut agents: Vec<JoinedAgent> = vec![];
|
|
||||||
assert!(!heartbeat(&mut agents, "x", 1.0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user