use std::time::Duration;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone)]
pub struct ThqConfig {
pub torpi_url: String,
pub advertise_url: String,
pub agent_name: String,
pub agent_role: String,
pub capabilities: Vec<String>,
pub tags: Vec<String>,
pub heartbeat_interval: u64,
}
impl ThqConfig {
pub fn from_toml(config_toml: &str) -> Option<Self> {
let table: toml::Table = toml::from_str(config_toml).ok()?;
let thq = table.get("thq")?.as_table()?;
let torpi_url = thq
.get("torpi_url")
.and_then(|v| v.as_str())
.map(|s| s.trim_end_matches('/').to_string())?;
let advertise_url = thq
.get("advertise_url")
.and_then(|v| v.as_str())
.map(|s| s.trim_end_matches('/').to_string())?;
let agent_name = thq
.get("agent_name")
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_else(|| {
std::env::var("HOSTNAME")
.or_else(|_| std::env::var("COMPUTERNAME"))
.unwrap_or_else(|_| "trustee".to_string())
});
let agent_role = thq
.get("agent_role")
.and_then(|v| v.as_str())
.unwrap_or("general")
.to_string();
let capabilities = thq
.get("capabilities")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let tags = thq
.get("tags")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let heartbeat_interval = thq
.get("heartbeat_interval")
.and_then(|v| v.as_integer())
.map(|v| v as u64)
.unwrap_or(30);
Some(Self {
torpi_url,
advertise_url,
agent_name,
agent_role,
capabilities,
tags,
heartbeat_interval,
})
}
}
#[derive(Debug, Serialize, Deserialize)]
struct AgentEntry {
id: String,
name: String,
endpoint: String,
role: String,
capabilities: Vec<String>,
status: String,
tags: Vec<String>,
last_seen: String,
}
fn resolve_agent_id() -> Result<String, std::io::Error> {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_else(|_| ".".to_string());
let id_file = std::path::PathBuf::from(home)
.join(".trustee")
.join("agent_id");
if id_file.exists() {
let id = std::fs::read_to_string(&id_file)?;
let id = id.trim().to_string();
if !id.is_empty() {
return Ok(id);
}
}
let id = uuid::Uuid::new_v4().to_string();
if let Some(parent) = id_file.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&id_file, &id)?;
tracing::info!("Generated new agent ID: {} -> {}", id, id_file.display());
Ok(id)
}
fn build_http_client() -> reqwest::Client {
reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.timeout(Duration::from_secs(10))
.build()
.expect("Failed to build HTTP client for THQ registration")
}
pub fn spawn(config: ThqConfig) {
let agent_id = match resolve_agent_id() {
Ok(id) => id,
Err(e) => {
tracing::error!("Failed to resolve agent ID: {} — THQ registration disabled", e);
return;
}
};
tracing::info!(
"THQ registration enabled: agent={} (id={}) -> {}",
config.agent_name,
agent_id,
config.torpi_url
);
let client = build_http_client();
let entry = AgentEntry {
id: agent_id.clone(),
name: config.agent_name.clone(),
endpoint: config.advertise_url.clone(),
role: config.agent_role.clone(),
capabilities: config.capabilities.clone(),
status: "idle".to_string(),
tags: config.tags.clone(),
last_seen: chrono::Utc::now().to_rfc3339(),
};
let url = format!("{}/thq/api/agents", config.torpi_url);
let interval_secs = config.heartbeat_interval;
tokio::spawn(async move {
let mut first = true;
loop {
let body = serde_json::to_value(&entry).unwrap_or_default();
let mut body = body;
body["last_seen"] = serde_json::Value::String(chrono::Utc::now().to_rfc3339());
match client.post(&url).json(&body).send().await {
Ok(resp) => {
let status = resp.status();
if status.is_success() {
if first {
tracing::info!("THQ registration successful: {} at {}", entry.name, entry.endpoint);
first = false;
} else {
tracing::debug!("THQ heartbeat successful");
}
} else {
let text = resp.text().await.unwrap_or_default();
tracing::warn!(
"THQ registration returned {}: {}",
status,
text.chars().take(200).collect::<String>()
);
}
}
Err(e) => {
if first {
tracing::warn!("THQ registration failed (will retry): {}", e);
} else {
tracing::debug!("THQ heartbeat failed: {}", e);
}
}
}
tokio::time::sleep(Duration::from_secs(interval_secs)).await;
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_full_config() {
let toml = r#"
[thq]
torpi_url = "https://torpi.example.com"
advertise_url = "https://10.0.0.5:3000"
agent_name = "edge-paris"
agent_role = "code-review"
capabilities = ["rust", "docker"]
tags = ["edge", "arm64"]
heartbeat_interval = 60
"#;
let cfg = ThqConfig::from_toml(toml).expect("should parse");
assert_eq!(cfg.torpi_url, "https://torpi.example.com");
assert_eq!(cfg.advertise_url, "https://10.0.0.5:3000");
assert_eq!(cfg.agent_name, "edge-paris");
assert_eq!(cfg.agent_role, "code-review");
assert_eq!(cfg.capabilities, vec!["rust", "docker"]);
assert_eq!(cfg.tags, vec!["edge", "arm64"]);
assert_eq!(cfg.heartbeat_interval, 60);
}
#[test]
fn parse_minimal_config() {
let toml = r#"
[thq]
torpi_url = "https://torpi.example.com/"
advertise_url = "https://10.0.0.5:3000/"
"#;
let cfg = ThqConfig::from_toml(toml).expect("should parse");
assert_eq!(cfg.torpi_url, "https://torpi.example.com"); assert_eq!(cfg.advertise_url, "https://10.0.0.5:3000");
assert_eq!(cfg.agent_role, "general");
assert!(cfg.capabilities.is_empty());
assert!(cfg.tags.is_empty());
assert_eq!(cfg.heartbeat_interval, 30);
}
#[test]
fn parse_no_section_returns_none() {
let toml = r#"
[oidc]
issuer_url = "https://example.com"
"#;
assert!(ThqConfig::from_toml(toml).is_none());
}
#[test]
fn parse_missing_required_returns_none() {
let toml = r#"
[thq]
torpi_url = "https://torpi.example.com"
"#;
assert!(ThqConfig::from_toml(toml).is_none()); }
#[test]
fn agent_entry_serializes_correctly() {
let entry = AgentEntry {
id: "test-id".to_string(),
name: "test".to_string(),
endpoint: "https://localhost:3000".to_string(),
role: "general".to_string(),
capabilities: vec!["rust".to_string()],
status: "idle".to_string(),
tags: vec![],
last_seen: "2025-01-01T00:00:00Z".to_string(),
};
let json = serde_json::to_string(&entry).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["id"], "test-id");
assert_eq!(v["status"], "idle");
assert!(v["capabilities"].is_array());
}
}