diff --git a/Cargo.lock b/Cargo.lock index fb5e00d..822a3a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -549,10 +549,14 @@ dependencies = [ name = "codra-cli" version = "0.1.0" dependencies = [ + "chrono", "codra-core", "codra-protocol", + "codra-runtime", "codra-tools", + "reqwest 0.12.28", "serde_json", + "tokio", ] [[package]] @@ -631,6 +635,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "tempfile", "thiserror 1.0.69", "tokio", "uuid", diff --git a/crates/codra-cli/Cargo.toml b/crates/codra-cli/Cargo.toml index c845ee0..ae1912b 100644 --- a/crates/codra-cli/Cargo.toml +++ b/crates/codra-cli/Cargo.toml @@ -11,4 +11,8 @@ path = "src/main.rs" serde_json.workspace = true codra-core = { path = "../codra-core" } codra-protocol = { path = "../codra-protocol" } +codra-runtime = { path = "../codra-runtime" } codra-tools = { path = "../codra-tools" } +chrono = { workspace = true } +reqwest = { workspace = true, features = ["blocking"] } +tokio = { workspace = true } diff --git a/crates/codra-cli/src/main.rs b/crates/codra-cli/src/main.rs index 1ef5f6d..81d6e86 100644 --- a/crates/codra-cli/src/main.rs +++ b/crates/codra-cli/src/main.rs @@ -1,6 +1,7 @@ use codra_core::provider::{create_provider, EchoMockProvider, IntelligenceProvider}; use codra_core::provider_config::ProviderConfigService; use codra_protocol::{McpServerInfo, ProviderConfig, ProviderKind}; +use codra_runtime::{StoredPairing, TrustLevel, WorkerId, WorkerStore}; use codra_tools::design::load_design_system; use codra_tools::registry::builtin_tool_definitions; use std::env; @@ -20,6 +21,10 @@ fn main() { help() } } + "worker" => { + args.remove(0); + worker_command(&args) + } "headless" => headless( args.get(1) .cloned() @@ -106,10 +111,165 @@ fn default_provider_config() -> ProviderConfig { } } +// ── Worker Commands ────────────────────────────────────────────── + +fn worker_command(args: &[String]) -> Result<(), String> { + let sub = args.first().map(String::as_str).unwrap_or("help"); + match sub { + "add" => worker_add(&args[1..]), + "list" => worker_list(), + "remove" => worker_remove(&args[1..]), + _ => { + println!("codra worker "); + println!(" add --fingerprint Register a remote worker"); + println!(" list List registered workers"); + println!(" remove Remove a registered worker"); + Ok(()) + } + } +} + +fn worker_add(args: &[String]) -> Result<(), String> { + let url = args.first().ok_or_else(|| { + "Usage: codra worker add --fingerprint ".to_string() + })?; + + let fingerprint_idx = args + .iter() + .position(|a| a == "--fingerprint") + .ok_or_else(|| "Missing --fingerprint argument".to_string())?; + let fingerprint = args + .get(fingerprint_idx + 1) + .ok_or_else(|| "Missing value for --fingerprint".to_string())?; + + let health_url = format!("{}/api/workers/health", url.trim_end_matches('/')); + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| format!("Failed to create HTTP client: {}", e))?; + + let resp = client + .get(&health_url) + .send() + .map_err(|e| format!("Failed to reach worker at {}: {}", health_url, e))?; + + if !resp.status().is_success() { + return Err(format!( + "Worker at {} returned status {}", + health_url, + resp.status() + )); + } + + let health: serde_json::Value = resp + .json() + .map_err(|e| format!("Failed to parse health response: {}", e))?; + + let worker_id = health["worker_id"] + .as_str() + .unwrap_or("unknown") + .to_string(); + let worker_label = health["hostname"] + .as_str() + .unwrap_or("remote-worker") + .to_string(); + let version = health["version"].as_str().unwrap_or("0.0.0").to_string(); + + let url_trimmed = url.trim_end_matches('/'); + let (host, port) = parse_worker_url(url_trimmed)?; + + let worker = StoredPairing { + worker_id: WorkerId(worker_id.clone()), + worker_label, + pin_sha256: fingerprint.clone(), + worker_host: host, + worker_port: port, + trust_level: TrustLevel::Standard, + paired_at: chrono::Utc::now().to_rfc3339(), + last_seen: chrono::Utc::now().to_rfc3339(), + }; + + let store = WorkerStore::new_global(); + store + .add_worker(worker) + .map_err(|e| format!("Failed to register worker: {}", e))?; + + println!("Registered worker:"); + println!(" ID: {}", worker_id); + println!(" URL: {}", url_trimmed); + println!(" Version: {}", version); + println!(" Store: {}", store.file_path().display()); + + Ok(()) +} + +fn worker_list() -> Result<(), String> { + let store = WorkerStore::new_global(); + let workers = store.list_workers(); + + if workers.is_empty() { + println!("No workers registered."); + println!(" Use: codra worker add --fingerprint "); + return Ok(()); + } + + println!("Registered workers ({}):", workers.len()); + for w in &workers { + println!( + " {} {} {} {}:{} {}", + w.worker_id.0, + w.worker_label, + serde_json::to_value(&w.trust_level) + .map(|v| v.as_str().unwrap_or("?").to_string()) + .unwrap_or_else(|_| "?".to_string()), + w.worker_host, + w.worker_port, + w.last_seen, + ); + } + Ok(()) +} + +fn worker_remove(args: &[String]) -> Result<(), String> { + let worker_id = args + .first() + .ok_or_else(|| "Usage: codra worker remove ".to_string())?; + + let store = WorkerStore::new_global(); + let removed = store + .remove_worker(&WorkerId(worker_id.clone())) + .map_err(|e| format!("Failed to remove worker: {}", e))?; + + if removed { + println!("Removed worker '{}'.", worker_id); + } else { + println!("Worker '{}' not found.", worker_id); + } + Ok(()) +} + +/// Parse a URL like `http://192.168.1.100:8080` into (host, port). +fn parse_worker_url(url: &str) -> Result<(String, u16), String> { + let url = url + .strip_prefix("http://") + .or_else(|| url.strip_prefix("https://")) + .unwrap_or(url); + let parts: Vec<&str> = url.splitn(2, ':').collect(); + let host = parts[0].to_string(); + let port = parts + .get(1) + .and_then(|p| p.parse::().ok()) + .unwrap_or(80); + Ok((host, port)) +} + fn help() -> Result<(), String> { println!("codra "); println!(" smoke Validate local tool registry and workspace readiness"); println!(" provider check Check active provider health"); + println!(" worker add Register a remote worker"); + println!(" worker list List registered workers"); + println!(" worker remove Remove a registered worker"); println!(" headless Run a dry-run headless planning surface"); println!(" mcp-server Print MCP-compatible server/tool metadata"); Ok(()) diff --git a/crates/codra-runtime/Cargo.toml b/crates/codra-runtime/Cargo.toml index e2cfa35..9bbc908 100644 --- a/crates/codra-runtime/Cargo.toml +++ b/crates/codra-runtime/Cargo.toml @@ -13,3 +13,6 @@ uuid = { version = "1.7", features = ["v4", "fast-rng"] } async-trait = "0.1" futures = "0.3" sha2 = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/codra-runtime/src/lib.rs b/crates/codra-runtime/src/lib.rs index b87fde2..8a198da 100644 --- a/crates/codra-runtime/src/lib.rs +++ b/crates/codra-runtime/src/lib.rs @@ -3,6 +3,7 @@ pub mod pairing; pub mod registry; pub mod traits; pub mod types; +pub mod worker_store; pub mod stub; @@ -12,3 +13,4 @@ pub use pairing::{PairingFingerprint, PairingPin, PairingPreview, PairingVerific pub use registry::RuntimeRegistry; pub use traits::{CodraRuntime, EventStream}; pub use types::*; +pub use worker_store::WorkerStore; diff --git a/crates/codra-runtime/src/worker_store.rs b/crates/codra-runtime/src/worker_store.rs new file mode 100644 index 0000000..90c700b --- /dev/null +++ b/crates/codra-runtime/src/worker_store.rs @@ -0,0 +1,244 @@ +use crate::types::{StoredPairing, WorkerId}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +struct WorkersFile { + workers: Vec, +} + +/// File-backed persistent store for registered workers. +#[derive(Debug, Clone)] +pub struct WorkerStore { + file_path: PathBuf, +} + +impl WorkerStore { + /// Open (or create) the worker store at `~/.codra/workers.json`. + pub fn new_global() -> Self { + let home = std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .unwrap_or_else(|_| ".".to_string()); + let dir = PathBuf::from(home).join(".codra"); + let _ = fs::create_dir_all(&dir); + let file_path = dir.join("workers.json"); + Self { file_path } + } + + /// Open (or create) the worker store at a custom path. + /// Used by tests to avoid touching ~/.codra. + pub fn new_at(path: PathBuf) -> Self { + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + Self { file_path: path } + } + + fn load(&self) -> WorkersFile { + fs::read_to_string(&self.file_path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() + } + + fn save(&self, wf: &WorkersFile) -> Result<(), String> { + let json = serde_json::to_string_pretty(wf).map_err(|e| e.to_string())?; + fs::write(&self.file_path, json).map_err(|e| e.to_string()) + } + + /// Register a new worker. + pub fn add_worker(&self, worker: StoredPairing) -> Result<(), String> { + let mut wf = self.load(); + if wf.workers.iter().any(|w| w.worker_id == worker.worker_id) { + return Err(format!( + "Worker '{}' is already registered", + worker.worker_id.0 + )); + } + wf.workers.push(worker); + self.save(&wf) + } + + /// Return all registered workers. + pub fn list_workers(&self) -> Vec { + self.load().workers + } + + /// Look up a worker by its ID. + pub fn get_worker(&self, worker_id: &WorkerId) -> Option { + self.load() + .workers + .into_iter() + .find(|w| w.worker_id == *worker_id) + } + + /// Remove a registered worker by its ID. + pub fn remove_worker(&self, worker_id: &WorkerId) -> Result { + let mut wf = self.load(); + let before = wf.workers.len(); + wf.workers.retain(|w| w.worker_id != *worker_id); + let removed = wf.workers.len() < before; + if removed { + self.save(&wf)?; + } + Ok(removed) + } + + /// Update the `last_seen` timestamp for a worker. + pub fn update_last_seen( + &self, + worker_id: &WorkerId, + last_seen: impl Into, + ) -> Result { + let mut wf = self.load(); + if let Some(worker) = wf.workers.iter_mut().find(|w| w.worker_id == *worker_id) { + worker.last_seen = last_seen.into(); + self.save(&wf)?; + Ok(true) + } else { + Ok(false) + } + } + + /// Returns the file path used by this store. + pub fn file_path(&self) -> &PathBuf { + &self.file_path + } +} + +// ── Tests ──────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::TrustLevel; + fn test_store() -> (WorkerStore, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("workers.json"); + let store = WorkerStore::new_at(path); + (store, dir) + } + + fn test_worker(id: &str) -> StoredPairing { + StoredPairing { + worker_id: WorkerId(id.to_string()), + worker_label: format!("Worker {}", id), + pin_sha256: "a3f1c8e2b7d4...".to_string(), + worker_host: "127.0.0.1".to_string(), + worker_port: 8080, + trust_level: TrustLevel::Standard, + paired_at: "2026-05-28T05:00:00Z".to_string(), + last_seen: "2026-05-28T05:00:00Z".to_string(), + } + } + + #[test] + fn add_and_list_workers() { + let (store, _dir) = test_store(); + store.add_worker(test_worker("wkr-001")).unwrap(); + store.add_worker(test_worker("wkr-002")).unwrap(); + let workers = store.list_workers(); + assert_eq!(workers.len(), 2); + } + + #[test] + fn add_duplicate_worker_rejected() { + let (store, _dir) = test_store(); + store.add_worker(test_worker("wkr-001")).unwrap(); + let err = store.add_worker(test_worker("wkr-001")).unwrap_err(); + assert!(err.contains("already registered")); + } + + #[test] + fn get_worker_returns_some() { + let (store, _dir) = test_store(); + store.add_worker(test_worker("wkr-001")).unwrap(); + let worker = store.get_worker(&WorkerId("wkr-001".to_string())); + assert!(worker.is_some()); + assert_eq!(worker.unwrap().worker_id.0, "wkr-001"); + } + + #[test] + fn get_worker_returns_none() { + let (store, _dir) = test_store(); + let worker = store.get_worker(&WorkerId("nonexistent".to_string())); + assert!(worker.is_none()); + } + + #[test] + fn remove_worker() { + let (store, _dir) = test_store(); + store.add_worker(test_worker("wkr-001")).unwrap(); + let removed = store + .remove_worker(&WorkerId("wkr-001".to_string())) + .unwrap(); + assert!(removed); + assert!(store.list_workers().is_empty()); + } + + #[test] + fn remove_nonexistent_worker_returns_false() { + let (store, _dir) = test_store(); + let removed = store.remove_worker(&WorkerId("ghost".to_string())).unwrap(); + assert!(!removed); + } + + #[test] + fn update_last_seen() { + let (store, _dir) = test_store(); + store.add_worker(test_worker("wkr-001")).unwrap(); + let updated = store + .update_last_seen(&WorkerId("wkr-001".to_string()), "2026-06-01T12:00:00Z") + .unwrap(); + assert!(updated); + let worker = store.get_worker(&WorkerId("wkr-001".to_string())).unwrap(); + assert_eq!(worker.last_seen, "2026-06-01T12:00:00Z"); + } + + #[test] + fn trust_level_preserved() { + let (store, _dir) = test_store(); + let mut w = test_worker("wkr-001"); + w.trust_level = TrustLevel::Elevated; + store.add_worker(w).unwrap(); + let worker = store.get_worker(&WorkerId("wkr-001".to_string())).unwrap(); + assert_eq!(worker.trust_level, TrustLevel::Elevated); + } + + #[test] + fn fingerprint_preserved() { + let (store, _dir) = test_store(); + let mut w = test_worker("wkr-001"); + w.pin_sha256 = + "deadbeef1234567890abcdef1234567890abcdef1234567890abcdef12345678".to_string(); + store.add_worker(w).unwrap(); + let worker = store.get_worker(&WorkerId("wkr-001".to_string())).unwrap(); + assert_eq!( + worker.pin_sha256, + "deadbeef1234567890abcdef1234567890abcdef1234567890abcdef12345678" + ); + } + + #[test] + fn stored_data_survives_reload() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("workers.json"); + + // First session: add workers + { + let store = WorkerStore::new_at(path.clone()); + store.add_worker(test_worker("wkr-001")).unwrap(); + store.add_worker(test_worker("wkr-002")).unwrap(); + } + + // Second session: reload from same path + { + let store = WorkerStore::new_at(path.clone()); + let workers = store.list_workers(); + assert_eq!(workers.len(), 2); + assert_eq!(workers[0].worker_id.0, "wkr-001"); + assert_eq!(workers[1].worker_id.0, "wkr-002"); + } + } +}