Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/codra-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
101 changes: 101 additions & 0 deletions crates/codra-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 <command>");
Expand All @@ -133,6 +135,7 @@ fn worker_command(args: &[String]) -> Result<(), String> {
println!(
" remove|unpair <worker_id> Remove/unpair a registered worker"
);
println!(" submit <worker_id> <prompt> Submit a remote task (stub)");
println!(" trust <worker_id> <level> Update trust level");
Ok(())
}
Expand Down Expand Up @@ -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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
workspace_hint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
requested_runtime_id: Option<String>,
#[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 <worker_id> <prompt>".to_string())?;
let prompt = args
.get(1)
.ok_or_else(|| "Usage: codra worker submit <worker_id> <prompt>".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 {} <level>' 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()
Expand Down
1 change: 1 addition & 0 deletions crates/codra-daemon/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
170 changes: 168 additions & 2 deletions crates/codra-daemon/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -155,6 +157,87 @@ async fn health() -> Json<serde_json::Value> {
}))
}

/// 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<String>,

#[serde(skip_serializing_if = "Option::is_none")]
workspace_hint: Option<String>,

#[serde(skip_serializing_if = "Option::is_none")]
requested_runtime_id: Option<String>,

#[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<AppState>,
Json(payload): Json<RemoteTaskStubRequest>,
) -> Result<Json<RemoteTaskStubResponse>, 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<AppState>) -> Json<state::WorkerHealthResponse> {
Json(state.inner.worker_health())
Expand Down Expand Up @@ -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);
}
}
Loading