From cb0d20f95dbe37cefac1fee9a6173197b0f0c769 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 28 May 2026 07:15:35 +0000 Subject: [PATCH] feat(worker): add remote task submit stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Daemon: - POST /api/workers/tasks/stub — validates prompt non-empty, checks payload size (64 KiB limit), builds a remote_task_id, returns RemoteTaskStubResponse with stubbed status, prompt preview (200 chars), and worker health summary. - Route is unauthenticated (under /api/workers/) so remote controllers can submit without a daemon token. No execution occurs. CLI: - codra worker submit "" — loads worker from store, requires trust level >= standard (rejects untrusted/limited with instructional message), POSTs to worker's stub endpoint, prints task id, status, message, prompt preview, worker summary, next step. Tests (5 unit): - stub_request_serializes - stub_request_empty_prompt_rejected - stub_response_deserializes - prompt_preview_truncated - stub_request_exceeds_max_size Co-authored-by: CommandCodeBot --- Cargo.lock | 2 + crates/codra-cli/Cargo.toml | 1 + crates/codra-cli/src/main.rs | 101 +++++++++++++++++++ crates/codra-daemon/Cargo.toml | 1 + crates/codra-daemon/src/main.rs | 170 +++++++++++++++++++++++++++++++- 5 files changed, 273 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 822a3a2..35df920 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -555,6 +555,7 @@ dependencies = [ "codra-runtime", "codra-tools", "reqwest 0.12.28", + "serde", "serde_json", "tokio", ] @@ -585,6 +586,7 @@ dependencies = [ "clap", "codra-core", "codra-protocol", + "codra-runtime", "dirs 5.0.1", "futures", "reqwest 0.12.28", diff --git a/crates/codra-cli/Cargo.toml b/crates/codra-cli/Cargo.toml index ae1912b..a656485 100644 --- a/crates/codra-cli/Cargo.toml +++ b/crates/codra-cli/Cargo.toml @@ -8,6 +8,7 @@ name = "codra" path = "src/main.rs" [dependencies] +serde = { workspace = true } serde_json.workspace = true codra-core = { path = "../codra-core" } codra-protocol = { path = "../codra-protocol" } diff --git a/crates/codra-cli/src/main.rs b/crates/codra-cli/src/main.rs index ef97282..6a1cee2 100644 --- a/crates/codra-cli/src/main.rs +++ b/crates/codra-cli/src/main.rs @@ -6,6 +6,7 @@ use codra_runtime::{ }; use codra_tools::design::load_design_system; use codra_tools::registry::builtin_tool_definitions; +use serde::{Deserialize, Serialize}; use std::env; use std::io::{self, BufRead, Write}; use std::path::PathBuf; @@ -123,6 +124,7 @@ fn worker_command(args: &[String]) -> Result<(), String> { "list" => worker_list(), "pair" => worker_pair(&args[1..]), "remove" | "unpair" => worker_remove(&args[1..]), + "submit" => worker_submit(&args[1..]), "trust" => worker_trust(&args[1..]), _ => { println!("codra worker "); @@ -133,6 +135,7 @@ fn worker_command(args: &[String]) -> Result<(), String> { println!( " remove|unpair Remove/unpair a registered worker" ); + println!(" submit Submit a remote task (stub)"); println!(" trust Update trust level"); Ok(()) } @@ -399,6 +402,104 @@ fn worker_trust(args: &[String]) -> Result<(), String> { Ok(()) } +#[derive(Serialize)] +struct RemoteTaskSubmitRequest { + task_prompt: String, + #[serde(skip_serializing_if = "Option::is_none")] + controller_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + workspace_hint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + requested_runtime_id: Option, + #[serde(default = "yes")] + dry_run: bool, +} + +fn yes() -> bool { + true +} + +#[derive(Deserialize)] +struct RemoteTaskSubmitResponse { + accepted: bool, + remote_task_id: String, + status: String, + message: String, + received_prompt_preview: String, + worker_health_summary: String, + next_step: String, +} + +fn worker_submit(args: &[String]) -> Result<(), String> { + let worker_id = args + .first() + .ok_or_else(|| "Usage: codra worker submit ".to_string())?; + let prompt = args + .get(1) + .ok_or_else(|| "Usage: codra worker submit ".to_string())?; + + let store = WorkerStore::new_global(); + let worker = store + .get_worker(&WorkerId(worker_id.clone())) + .ok_or_else(|| format!("Worker '{}' not found in registry", worker_id))?; + + // Trust gate: reject untrusted or limited workers + if worker.trust_level == TrustLevel::Untrusted || worker.trust_level == TrustLevel::Limited { + return Err(format!( + "Worker '{}' has trust level '{}'. Remote task submission requires at least 'standard'.\nUse 'codra worker trust {} ' to upgrade.", + worker_id, worker.trust_level, worker_id + )); + } + + let url = format!( + "http://{}:{}/api/workers/tasks/stub", + worker.worker_host, worker.worker_port + ); + + 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 payload = RemoteTaskSubmitRequest { + task_prompt: prompt.clone(), + controller_id: None, + workspace_hint: None, + requested_runtime_id: None, + dry_run: true, + }; + + let resp = client + .post(&url) + .json(&payload) + .send() + .map_err(|e| format!("Failed to reach worker '{}' at {}: {}", worker_id, url, e))?; + + let status = resp.status(); + if !status.is_success() { + let body = resp.text().unwrap_or_default(); + return Err(format!( + "Worker '{}' returned HTTP {}: {}", + worker_id, status, body + )); + } + + let result: RemoteTaskSubmitResponse = resp + .json() + .map_err(|e| format!("Malformed response from worker '{}': {}", worker_id, e))?; + + println!("Remote task submitted:"); + println!(" worker id: {}", worker_id); + println!(" task id: {}", result.remote_task_id); + println!(" status: {}", result.status); + println!(" message: {}", result.message); + println!(" prompt: {}", result.received_prompt_preview); + println!(" worker: {}", result.worker_health_summary); + println!(" next: {}", result.next_step); + + Ok(()) +} + fn worker_check(args: &[String]) -> Result<(), String> { let worker_id = args .first() diff --git a/crates/codra-daemon/Cargo.toml b/crates/codra-daemon/Cargo.toml index 7ead7ee..ffcc584 100644 --- a/crates/codra-daemon/Cargo.toml +++ b/crates/codra-daemon/Cargo.toml @@ -16,6 +16,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } clap = { version = "4.5", features = ["derive"] } codra-core = { path = "../codra-core" } codra-protocol = { path = "../codra-protocol" } +codra-runtime = { path = "../codra-runtime" } anyhow = { workspace = true } async-stream = "0.3" futures = "0.3" diff --git a/crates/codra-daemon/src/main.rs b/crates/codra-daemon/src/main.rs index 6fc23aa..9d06915 100644 --- a/crates/codra-daemon/src/main.rs +++ b/crates/codra-daemon/src/main.rs @@ -10,7 +10,7 @@ use axum::{ use clap::Parser; use codra_core::workspace_scanner::WorkspaceScanner; use codra_protocol::*; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::convert::Infallible; use std::net::SocketAddr; use std::sync::Arc; @@ -67,7 +67,9 @@ async fn main() -> anyhow::Result<()> { // Worker health routes are outside the auth gate so remote controllers // can probe without a token. The health payload is safe: no secrets, // no filesystem paths, no tokens. - let worker_routes = Router::new().route("/workers/health", get(worker_health)); + let worker_routes = Router::new() + .route("/workers/health", get(worker_health)) + .route("/workers/tasks/stub", post(worker_tasks_stub)); let api_routes = Router::new() .route("/workspace/scan", get(scan_workspace)) @@ -155,6 +157,87 @@ async fn health() -> Json { })) } +/// POST /api/workers/tasks/stub — stub remote task submission. +/// Does not execute anything. Validates payload and returns a receipt. +#[derive(Serialize, Deserialize)] +struct RemoteTaskStubRequest { + task_prompt: String, + + #[serde(skip_serializing_if = "Option::is_none")] + controller_id: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + workspace_hint: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + requested_runtime_id: Option, + + #[serde(default = "default_true")] + dry_run: bool, +} + +fn default_true() -> bool { + true +} + +#[derive(Serialize, Deserialize)] +struct RemoteTaskStubResponse { + accepted: bool, + remote_task_id: String, + status: String, + message: String, + received_prompt_preview: String, + worker_health_summary: String, + next_step: String, +} + +const MAX_TASK_PROMPT_BYTES: usize = 1024 * 64; // 64 KiB +const PROMPT_PREVIEW_CHARS: usize = 200; + +async fn worker_tasks_stub( + State(state): State, + Json(payload): Json, +) -> Result, AppError> { + if payload.task_prompt.trim().is_empty() { + return Err(AppError::BadRequest("task_prompt is required".into())); + } + + if payload.task_prompt.len() > MAX_TASK_PROMPT_BYTES { + return Err(AppError::BadRequest(format!( + "task_prompt exceeds maximum size of {} bytes", + MAX_TASK_PROMPT_BYTES + ))); + } + + let task_id = uuid::Uuid::new_v4().to_string(); + let prompt_preview: String = payload + .task_prompt + .chars() + .take(PROMPT_PREVIEW_CHARS) + .collect(); + let health = state.inner.worker_health(); + let health_summary = format!( + "{} | {} {} | {}s uptime", + health.status, health.os, health.arch, health.uptime_seconds + ); + + Ok(Json(RemoteTaskStubResponse { + accepted: true, + remote_task_id: task_id, + status: "stubbed".to_string(), + message: "Remote task submission reached worker. Execution is not enabled yet.".to_string(), + received_prompt_preview: if prompt_preview.len() < payload.task_prompt.len() { + format!("{}…", prompt_preview) + } else { + prompt_preview + }, + worker_health_summary: health_summary, + next_step: + "Use 'codra worker submit' with --dry-run=false once remote execution is enabled." + .to_string(), + })) +} + /// GET /api/workers/health — unauthenticated worker health probe. async fn worker_health(State(state): State) -> Json { Json(state.inner.worker_health()) @@ -361,3 +444,86 @@ impl IntoResponse for AppError { (status, body).into_response() } } + +// ── Tests ──────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use serde_json; + + #[test] + fn stub_request_serializes() { + let req = RemoteTaskStubRequest { + task_prompt: "write a test".to_string(), + controller_id: Some("ctrl-001".to_string()), + workspace_hint: None, + requested_runtime_id: None, + dry_run: true, + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["task_prompt"], "write a test"); + assert_eq!(json["controller_id"], "ctrl-001"); + assert_eq!(json["dry_run"], true); + // Optional fields omitted when None + assert!(json.get("workspace_hint").is_none()); + } + + #[test] + fn stub_request_empty_prompt_rejected() { + // Simulate the validation check done by the handler + let payload = RemoteTaskStubRequest { + task_prompt: " ".to_string(), + controller_id: None, + workspace_hint: None, + requested_runtime_id: None, + dry_run: true, + }; + assert!(payload.task_prompt.trim().is_empty()); + } + + #[test] + fn stub_response_deserializes() { + let json = serde_json::json!({ + "accepted": true, + "remote_task_id": "abc-123", + "status": "stubbed", + "message": "Remote task submission reached worker. Execution is not enabled yet.", + "received_prompt_preview": "write a test", + "worker_health_summary": "ok | linux aarch64 | 42s uptime", + "next_step": "Use 'codra worker submit' with --dry-run=false once remote execution is enabled." + }); + let resp: RemoteTaskStubResponse = serde_json::from_value(json).unwrap(); + assert!(resp.accepted); + assert_eq!(resp.status, "stubbed"); + assert_eq!(resp.remote_task_id, "abc-123"); + assert!(resp.message.contains("not enabled yet")); + assert!(resp.next_step.contains("dry-run")); + } + + #[test] + fn prompt_preview_truncated() { + let long_prompt = "x".repeat(500); + let preview: String = long_prompt.chars().take(PROMPT_PREVIEW_CHARS).collect(); + assert_eq!(preview.len(), PROMPT_PREVIEW_CHARS); + let truncated = if preview.len() < long_prompt.len() { + format!("{}…", preview) + } else { + preview.clone() + }; + assert_eq!(truncated.len(), PROMPT_PREVIEW_CHARS + 3); // '…' is 3 bytes + assert!(truncated.ends_with('…')); + } + + #[test] + fn stub_request_exceeds_max_size() { + let req = RemoteTaskStubRequest { + task_prompt: "x".repeat(MAX_TASK_PROMPT_BYTES + 1), + controller_id: None, + workspace_hint: None, + requested_runtime_id: None, + dry_run: true, + }; + assert!(req.task_prompt.len() > MAX_TASK_PROMPT_BYTES); + } +}