diff options
Diffstat (limited to 'makima/src')
55 files changed, 22646 insertions, 196 deletions
diff --git a/makima/src/bin/makima.rs b/makima/src/bin/makima.rs new file mode 100644 index 0000000..649a8e7 --- /dev/null +++ b/makima/src/bin/makima.rs @@ -0,0 +1,564 @@ +//! Makima CLI - unified CLI for server, daemon, and task management. + +use std::io::{self, Read}; +use std::path::Path; +use std::sync::Arc; + +use makima::daemon::api::ApiClient; +use makima::daemon::cli::{ + Cli, Commands, ContractCommand, SupervisorCommand, +}; +use makima::daemon::config::{DaemonConfig, RepoEntry}; +use makima::daemon::db::LocalDb; +use makima::daemon::error::DaemonError; +use makima::daemon::task::{TaskConfig, TaskManager}; +use makima::daemon::ws::{DaemonCommand, WsClient}; +use tokio::process::Command; +use tokio::sync::mpsc; +use tracing_subscriber::{fmt, prelude::*, EnvFilter}; + +#[tokio::main] +async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { + let cli = Cli::parse_args(); + + match cli.command { + Commands::Server(args) => run_server(args).await, + Commands::Daemon(args) => run_daemon(args).await, + Commands::Supervisor(cmd) => run_supervisor(cmd).await, + Commands::Contract(cmd) => run_contract(cmd).await, + } +} + +/// Run the makima server. +async fn run_server( + args: makima::daemon::cli::ServerArgs, +) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { + // Initialize logging + init_logging(&args.log_level, "text"); + + eprintln!("=== Makima Server Starting ==="); + eprintln!("Port: {}", args.port); + + // Create app state + let mut app_state = makima::server::state::AppState::new( + &args.parakeet_model_dir, + &args.parakeet_eou_dir, + &args.sortformer_model_path, + ); + + // Connect to database if URL provided + if let Some(ref db_url) = args.database_url { + eprintln!("Connecting to database..."); + let pool = makima::db::create_pool(db_url).await?; + app_state = app_state.with_db_pool(pool); + eprintln!("Database connected"); + } + + let state = Arc::new(app_state); + let addr = format!("0.0.0.0:{}", args.port); + + eprintln!("Starting server on {}", addr); + makima::server::run_server(state, &addr).await?; + + Ok(()) +} + +/// Run the makima daemon. +async fn run_daemon( + args: makima::daemon::cli::DaemonArgs, +) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { + eprintln!("=== Makima Daemon Starting ==="); + + // Build a temporary CLI struct for config loading + let cli = makima::daemon::cli::daemon::DaemonArgs { + config: args.config, + repos_dir: args.repos_dir, + worktrees_dir: args.worktrees_dir, + server_url: args.server_url, + api_key: args.api_key, + max_tasks: args.max_tasks, + log_level: args.log_level, + }; + + // Load configuration with CLI overrides + eprintln!("[1/5] Loading configuration..."); + let config = match DaemonConfig::load_with_daemon_args(&cli) { + Ok(cfg) => { + eprintln!(" Config loaded: server={}", cfg.server.url); + cfg + } + Err(e) => { + eprintln!("Failed to load configuration: {}", e); + eprintln!(); + eprintln!("Use CLI flags:"); + eprintln!(" makima daemon --server-url ws://localhost:8080 --api-key your-api-key"); + eprintln!(); + eprintln!("Or set environment variables:"); + eprintln!(" MAKIMA_DAEMON_SERVER_URL=ws://localhost:8080"); + eprintln!(" MAKIMA_API_KEY=your-api-key"); + eprintln!(); + eprintln!("Or create a config file: makima-daemon.toml"); + std::process::exit(1); + } + }; + + // Initialize logging + init_logging(&config.logging.level, &config.logging.format); + eprintln!("[2/5] Logging initialized"); + + // Initialize local database + eprintln!( + "[3/5] Opening local database: {}", + config.local_db.path.display() + ); + let _local_db = LocalDb::open(&config.local_db.path)?; + eprintln!(" Database opened"); + + // Initialize worktree directories + eprintln!("[4/5] Setting up directories..."); + tokio::fs::create_dir_all(&config.worktree.base_dir).await?; + tokio::fs::create_dir_all(&config.worktree.repos_dir).await?; + tokio::fs::create_dir_all(&config.repos.home_dir).await?; + eprintln!( + " Worktree base: {}", + config.worktree.base_dir.display() + ); + eprintln!(" Repos cache: {}", config.worktree.repos_dir.display()); + eprintln!(" Home dir: {}", config.repos.home_dir.display()); + + // Auto-clone repositories if configured + if !config.repos.auto_clone.is_empty() { + eprintln!( + " Auto-cloning {} repositories...", + config.repos.auto_clone.len() + ); + for repo_entry in &config.repos.auto_clone { + if let Err(e) = auto_clone_repo(repo_entry, &config.repos.home_dir).await { + eprintln!(" WARNING: Failed to clone {}: {}", repo_entry.url(), e); + } + } + } + + // Create channels for communication + let (command_tx, mut command_rx) = mpsc::channel::<DaemonCommand>(64); + + // Get machine info + let machine_id = get_machine_id(); + let hostname = get_hostname(); + eprintln!(" Machine ID: {}", machine_id); + eprintln!(" Hostname: {}", hostname); + + // Create WebSocket client + eprintln!("[5/5] Connecting to server: {}", config.server.url); + let mut ws_client = WsClient::new( + config.server.clone(), + machine_id, + hostname, + config.process.max_concurrent_tasks as i32, + command_tx, + ); + + // Get sender for task manager + let ws_tx = ws_client.sender(); + + // Create task configuration + let task_config = TaskConfig { + max_concurrent_tasks: config.process.max_concurrent_tasks, + worktree_base_dir: config.worktree.base_dir.clone(), + env_vars: config.process.env_vars.clone(), + claude_command: config.process.claude_command.clone(), + claude_args: config.process.claude_args.clone(), + claude_pre_args: config.process.claude_pre_args.clone(), + enable_permissions: config.process.enable_permissions, + disable_verbose: config.process.disable_verbose, + }; + + // Create task manager + let task_manager = Arc::new(TaskManager::new(task_config, ws_tx.clone())); + + // Spawn command handler + let task_manager_clone = task_manager.clone(); + tokio::spawn(async move { + tracing::info!("Command handler started, waiting for commands..."); + while let Some(command) = command_rx.recv().await { + tracing::info!("Received command from channel: {:?}", command); + if let Err(e) = task_manager_clone.handle_command(command).await { + tracing::error!("Failed to handle command: {}", e); + } + } + tracing::info!("Command handler stopped"); + }); + + // Handle shutdown signals + let shutdown_signal = async { + tokio::signal::ctrl_c() + .await + .expect("Failed to install Ctrl+C handler"); + eprintln!("\nReceived shutdown signal"); + }; + + eprintln!("=== Daemon running (Ctrl+C to stop) ==="); + + // Run WebSocket client with shutdown handling + tokio::select! { + result = ws_client.run() => { + match result { + Ok(()) => eprintln!("WebSocket client exited cleanly"), + Err(DaemonError::AuthFailed(msg)) => { + eprintln!("ERROR: Authentication failed: {}", msg); + std::process::exit(1); + } + Err(e) => { + eprintln!("ERROR: WebSocket client error: {}", e); + std::process::exit(1); + } + } + } + _ = shutdown_signal => { + eprintln!("Shutting down..."); + } + } + + // Cleanup + tracing::info!("Daemon stopped"); + + Ok(()) +} + +/// Run supervisor commands. +async fn run_supervisor( + cmd: SupervisorCommand, +) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { + use makima::daemon::api::supervisor::*; + + match cmd { + SupervisorCommand::Tasks(args) => { + let client = ApiClient::new(args.api_url, args.api_key)?; + let result = client.supervisor_tasks(args.contract_id).await?; + println!("{}", serde_json::to_string(&result.0)?); + } + SupervisorCommand::Tree(args) => { + let client = ApiClient::new(args.api_url, args.api_key)?; + let result = client.supervisor_tree(args.contract_id).await?; + println!("{}", serde_json::to_string(&result.0)?); + } + SupervisorCommand::Spawn(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + eprintln!("Creating task: {}...", args.name); + let req = SpawnTaskRequest { + name: args.name, + plan: args.plan, + contract_id: args.common.contract_id, + parent_task_id: args.parent, + checkpoint_sha: args.checkpoint, + }; + let result = client.supervisor_spawn(req).await?; + println!("{}", serde_json::to_string(&result.0)?); + } + SupervisorCommand::Wait(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + eprintln!( + "Waiting for task {} (timeout: {}s)...", + args.task_id, args.timeout + ); + let result = client.supervisor_wait(args.task_id, args.timeout).await?; + println!("{}", serde_json::to_string(&result.0)?); + } + SupervisorCommand::ReadFile(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + let result = client + .supervisor_read_file(args.task_id, &args.file_path) + .await?; + println!("{}", serde_json::to_string(&result.0)?); + } + SupervisorCommand::Branch(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + eprintln!("Creating branch: {}...", args.name); + let result = client.supervisor_branch(&args.name, args.from).await?; + println!("{}", serde_json::to_string(&result.0)?); + } + SupervisorCommand::Merge(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + eprintln!("Merging task {}...", args.task_id); + let result = client + .supervisor_merge(args.task_id, args.to, args.squash) + .await?; + println!("{}", serde_json::to_string(&result.0)?); + } + SupervisorCommand::Pr(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + eprintln!("Creating PR for task {}...", args.task_id); + let body = args.body.as_deref().unwrap_or(""); + let result = client + .supervisor_pr(args.task_id, &args.title, body, &args.base) + .await?; + println!("{}", serde_json::to_string(&result.0)?); + } + SupervisorCommand::Diff(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + let result = client.supervisor_diff(args.task_id).await?; + println!("{}", serde_json::to_string(&result.0)?); + } + SupervisorCommand::Checkpoint(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + let task_id = args + .common + .task_id + .ok_or("MAKIMA_TASK_ID is required for checkpoint")?; + let result = client + .supervisor_checkpoint(task_id, &args.message) + .await?; + println!("{}", serde_json::to_string(&result.0)?); + } + SupervisorCommand::Checkpoints(args) => { + let client = ApiClient::new(args.api_url, args.api_key)?; + let task_id = args.task_id.ok_or("MAKIMA_TASK_ID is required")?; + let result = client.supervisor_checkpoints(task_id).await?; + println!("{}", serde_json::to_string(&result.0)?); + } + SupervisorCommand::Status(args) => { + let client = ApiClient::new(args.api_url, args.api_key)?; + let result = client.supervisor_status(args.contract_id).await?; + println!("{}", serde_json::to_string(&result.0)?); + } + } + + Ok(()) +} + +/// Run contract commands. +async fn run_contract( + cmd: ContractCommand, +) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { + match cmd { + ContractCommand::Status(args) => { + let client = ApiClient::new(args.api_url, args.api_key)?; + let result = client.contract_status(args.contract_id).await?; + println!("{}", serde_json::to_string(&result.0)?); + } + ContractCommand::Checklist(args) => { + let client = ApiClient::new(args.api_url, args.api_key)?; + let result = client.contract_checklist(args.contract_id).await?; + println!("{}", serde_json::to_string(&result.0)?); + } + ContractCommand::Goals(args) => { + let client = ApiClient::new(args.api_url, args.api_key)?; + let result = client.contract_goals(args.contract_id).await?; + println!("{}", serde_json::to_string(&result.0)?); + } + ContractCommand::Files(args) => { + let client = ApiClient::new(args.api_url, args.api_key)?; + let result = client.contract_files(args.contract_id).await?; + println!("{}", serde_json::to_string(&result.0)?); + } + ContractCommand::File(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + let result = client + .contract_file(args.common.contract_id, args.file_id) + .await?; + println!("{}", serde_json::to_string(&result.0)?); + } + ContractCommand::Report(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + let result = client + .contract_report(args.common.contract_id, &args.message, args.common.task_id) + .await?; + println!("{}", serde_json::to_string(&result.0)?); + } + ContractCommand::SuggestAction(args) => { + let client = ApiClient::new(args.api_url, args.api_key)?; + let result = client.contract_suggest_action(args.contract_id).await?; + println!("{}", serde_json::to_string(&result.0)?); + } + ContractCommand::CompletionAction(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + let files = args.files.map(|f| { + f.split(',') + .map(|s| s.trim().to_string()) + .collect::<Vec<_>>() + }); + let result = client + .contract_completion_action( + args.common.contract_id, + args.common.task_id, + files, + args.lines_added, + args.lines_removed, + args.code, + ) + .await?; + println!("{}", serde_json::to_string(&result.0)?); + } + ContractCommand::UpdateFile(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + // Read content from stdin + let mut content = String::new(); + io::stdin().read_to_string(&mut content)?; + let result = client + .contract_update_file(args.common.contract_id, args.file_id, &content) + .await?; + println!("{}", serde_json::to_string(&result.0)?); + } + ContractCommand::CreateFile(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + // Read content from stdin + let mut content = String::new(); + io::stdin().read_to_string(&mut content)?; + let result = client + .contract_create_file(args.common.contract_id, &args.name, &content) + .await?; + println!("{}", serde_json::to_string(&result.0)?); + } + } + + Ok(()) +} + +fn init_logging(level: &str, format: &str) { + let filter = EnvFilter::try_from_default_env() + .or_else(|_| EnvFilter::try_new(level)) + .unwrap_or_else(|_| EnvFilter::new("info")); + + let subscriber = tracing_subscriber::registry().with(filter); + + if format == "json" { + subscriber.with(fmt::layer().json()).init(); + } else { + subscriber.with(fmt::layer()).init(); + } +} + +fn get_machine_id() -> String { + // Try to read machine-id from standard locations + #[cfg(target_os = "linux")] + { + if let Ok(id) = std::fs::read_to_string("/etc/machine-id") { + return id.trim().to_string(); + } + if let Ok(id) = std::fs::read_to_string("/var/lib/dbus/machine-id") { + return id.trim().to_string(); + } + } + + #[cfg(target_os = "macos")] + { + // Use IOPlatformSerialNumber + if let Ok(output) = std::process::Command::new("ioreg") + .args(["-rd1", "-c", "IOPlatformExpertDevice"]) + .output() + { + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + if line.contains("IOPlatformUUID") { + if let Some(uuid) = line.split('"').nth(3) { + return uuid.to_string(); + } + } + } + } + } + + // Fallback: generate a random ID and persist it + let state_dir = dirs_next::data_local_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("makima"); + let machine_id_file = state_dir.join("machine-id"); + + if let Ok(id) = std::fs::read_to_string(&machine_id_file) { + return id.trim().to_string(); + } + + // Generate new ID + let new_id = uuid::Uuid::new_v4().to_string(); + std::fs::create_dir_all(&state_dir).ok(); + std::fs::write(&machine_id_file, &new_id).ok(); + new_id +} + +fn get_hostname() -> String { + hostname::get() + .map(|h| h.to_string_lossy().to_string()) + .unwrap_or_else(|_| "unknown".to_string()) +} + +/// Auto-clone a repository to the home directory if it doesn't exist. +async fn auto_clone_repo( + repo_entry: &RepoEntry, + home_dir: &Path, +) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { + let dir_name = repo_entry + .dir_name() + .ok_or("Could not determine directory name from URL")?; + let target_dir = home_dir.join(&dir_name); + + // Check if already cloned + if target_dir.exists() { + eprintln!(" [skip] {} (already exists)", dir_name); + return Ok(()); + } + + let url = repo_entry.expanded_url(); + eprintln!(" [clone] {} -> {}", url, target_dir.display()); + + // Build git clone command + let mut args = vec!["clone".to_string()]; + + // Add shallow clone if requested + if repo_entry.shallow() { + args.push("--depth".to_string()); + args.push("1".to_string()); + } + + // Add branch if specified + if let Some(branch) = repo_entry.branch() { + args.push("--branch".to_string()); + args.push(branch.to_string()); + } + + args.push(url.clone()); + args.push(target_dir.to_string_lossy().to_string()); + + // Run git clone + let output = Command::new("git").args(&args).output().await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("git clone failed: {}", stderr).into()); + } + + eprintln!(" [done] {}", dir_name); + Ok(()) +} + +/// dirs_next minimal replacement +mod dirs_next { + use std::path::PathBuf; + + pub fn data_local_dir() -> Option<PathBuf> { + #[cfg(target_os = "macos")] + { + std::env::var("HOME") + .ok() + .map(|h| PathBuf::from(h).join("Library").join("Application Support")) + } + #[cfg(target_os = "linux")] + { + std::env::var("XDG_DATA_HOME") + .ok() + .map(PathBuf::from) + .or_else(|| { + std::env::var("HOME") + .ok() + .map(|h| PathBuf::from(h).join(".local").join("share")) + }) + } + #[cfg(target_os = "windows")] + { + std::env::var("LOCALAPPDATA").ok().map(PathBuf::from) + } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + None + } + } +} diff --git a/makima/src/bin/server.rs b/makima/src/bin/server.rs deleted file mode 100644 index bbc56fd..0000000 --- a/makima/src/bin/server.rs +++ /dev/null @@ -1,72 +0,0 @@ -//! Makima Audio API Server binary. -//! -//! This server provides WebSocket-based speech-to-text streaming with optional persistence. - -use std::sync::Arc; - -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; - -use makima::server::{run_server, state::AppState}; - -/// Default model paths (can be overridden via environment variables). -const DEFAULT_PARAKEET_MODEL_DIR: &str = "models/parakeet-tdt-0.6b-v3"; -const DEFAULT_PARAKEET_EOU_DIR: &str = "models/realtime_eou_120m-v1-onnx"; -const DEFAULT_SORTFORMER_MODEL_PATH: &str = "models/diarization/diar_streaming_sortformer_4spk-v2.1.onnx"; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - // Initialize tracing subscriber with environment filter - tracing_subscriber::registry() - .with( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "makima=info,tower_http=info".into()), - ) - .with(tracing_subscriber::fmt::layer()) - .init(); - - tracing::info!("Starting Makima Listening API Server"); - - // Read configuration from environment - let port = std::env::var("PORT").unwrap_or_else(|_| "8080".to_string()); - let parakeet_dir = std::env::var("PARAKEET_MODEL_DIR") - .unwrap_or_else(|_| DEFAULT_PARAKEET_MODEL_DIR.to_string()); - let parakeet_eou_dir = std::env::var("PARAKEET_EOU_DIR") - .unwrap_or_else(|_| DEFAULT_PARAKEET_EOU_DIR.to_string()); - let sortformer_path = std::env::var("SORTFORMER_MODEL_PATH") - .unwrap_or_else(|_| DEFAULT_SORTFORMER_MODEL_PATH.to_string()); - - tracing::info!( - parakeet = %parakeet_dir, - eou = %parakeet_eou_dir, - sortformer = %sortformer_path, - "Loading ML models..." - ); - - // Load ML models - let mut app_state = AppState::new(¶keet_dir, ¶keet_eou_dir, &sortformer_path) - .map_err(|e| anyhow::anyhow!("Failed to load models: {}", e))?; - - tracing::info!("Models loaded successfully"); - - // Initialize database (optional - server works without it) - if let Ok(database_url) = std::env::var("POSTGRES_CONNECTION_URI") { - tracing::info!("Connecting to database..."); - match makima::db::create_pool(&database_url).await { - Ok(pool) => { - tracing::info!("Database connected successfully"); - app_state = app_state.with_db_pool(pool); - } - Err(e) => { - tracing::warn!("Failed to connect to database: {}. Running without persistence.", e); - } - } - } else { - tracing::info!("POSTGRES_CONNECTION_URI not set. Running without persistence."); - } - - let state = Arc::new(app_state); - - // Run the server - let addr = format!("0.0.0.0:{}", port); - run_server(state, &addr).await -} diff --git a/makima/src/daemon/api/client.rs b/makima/src/daemon/api/client.rs new file mode 100644 index 0000000..b27d606 --- /dev/null +++ b/makima/src/daemon/api/client.rs @@ -0,0 +1,129 @@ +//! Base HTTP client for makima API. + +use reqwest::Client; +use serde::{de::DeserializeOwned, Serialize}; +use thiserror::Error; + +/// API client errors. +#[derive(Error, Debug)] +pub enum ApiError { + #[error("HTTP request failed: {0}")] + Request(#[from] reqwest::Error), + + #[error("API error (HTTP {status}): {message}")] + Api { status: u16, message: String }, + + #[error("Failed to parse response: {0}")] + Parse(String), +} + +/// HTTP client for makima API. +pub struct ApiClient { + client: Client, + base_url: String, + api_key: String, +} + +impl ApiClient { + /// Create a new API client. + pub fn new(base_url: String, api_key: String) -> Result<Self, ApiError> { + let client = Client::builder() + .build()?; + + Ok(Self { + client, + base_url: base_url.trim_end_matches('/').to_string(), + api_key, + }) + } + + /// Make a GET request. + pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> { + let url = format!("{}{}", self.base_url, path); + let response = self.client + .get(&url) + .header("X-Makima-Tool-Key", &self.api_key) + .send() + .await?; + + self.handle_response(response).await + } + + /// Make a POST request with JSON body. + pub async fn post<T: DeserializeOwned, B: Serialize>( + &self, + path: &str, + body: &B, + ) -> Result<T, ApiError> { + let url = format!("{}{}", self.base_url, path); + let response = self.client + .post(&url) + .header("X-Makima-Tool-Key", &self.api_key) + .header("Content-Type", "application/json") + .json(body) + .send() + .await?; + + self.handle_response(response).await + } + + /// Make a POST request without body. + pub async fn post_empty<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> { + let url = format!("{}{}", self.base_url, path); + let response = self.client + .post(&url) + .header("X-Makima-Tool-Key", &self.api_key) + .send() + .await?; + + self.handle_response(response).await + } + + /// Make a PUT request with JSON body. + pub async fn put<T: DeserializeOwned, B: Serialize>( + &self, + path: &str, + body: &B, + ) -> Result<T, ApiError> { + let url = format!("{}{}", self.base_url, path); + let response = self.client + .put(&url) + .header("X-Makima-Tool-Key", &self.api_key) + .header("Content-Type", "application/json") + .json(body) + .send() + .await?; + + self.handle_response(response).await + } + + /// Handle API response. + async fn handle_response<T: DeserializeOwned>( + &self, + response: reqwest::Response, + ) -> Result<T, ApiError> { + let status = response.status(); + let status_code = status.as_u16(); + + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(ApiError::Api { + status: status_code, + message: body, + }); + } + + let body = response.text().await?; + + // Handle empty responses + if body.is_empty() || body == "null" { + // Try to parse empty/null as the target type + serde_json::from_str::<T>("null") + .or_else(|_| serde_json::from_str::<T>("{}")) + .map_err(|e| ApiError::Parse(e.to_string())) + } else { + serde_json::from_str::<T>(&body) + .map_err(|e| ApiError::Parse(format!("{}: {}", e, body))) + } + } +} diff --git a/makima/src/daemon/api/contract.rs b/makima/src/daemon/api/contract.rs new file mode 100644 index 0000000..aac6b94 --- /dev/null +++ b/makima/src/daemon/api/contract.rs @@ -0,0 +1,161 @@ +//! Contract API methods. + +use serde::Serialize; +use uuid::Uuid; + +use super::client::{ApiClient, ApiError}; +use super::supervisor::JsonValue; + +// Request types + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReportRequest { + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub task_id: Option<Uuid>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CompletionActionRequest { + pub lines_added: i32, + pub lines_removed: i32, + pub has_code_changes: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub task_id: Option<Uuid>, + #[serde(skip_serializing_if = "Option::is_none")] + pub files_modified: Option<Vec<String>>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateFileRequest { + pub content: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateFileRequest { + pub name: String, + pub content: String, +} + +impl ApiClient { + /// Get contract status. + pub async fn contract_status(&self, contract_id: Uuid) -> Result<JsonValue, ApiError> { + self.get(&format!("/api/v1/contracts/{}/daemon/status", contract_id)) + .await + } + + /// Get phase checklist. + pub async fn contract_checklist(&self, contract_id: Uuid) -> Result<JsonValue, ApiError> { + self.get(&format!("/api/v1/contracts/{}/daemon/checklist", contract_id)) + .await + } + + /// Get contract goals. + pub async fn contract_goals(&self, contract_id: Uuid) -> Result<JsonValue, ApiError> { + self.get(&format!("/api/v1/contracts/{}/daemon/goals", contract_id)) + .await + } + + /// List contract files. + pub async fn contract_files(&self, contract_id: Uuid) -> Result<JsonValue, ApiError> { + self.get(&format!("/api/v1/contracts/{}/daemon/files", contract_id)) + .await + } + + /// Get a specific file. + pub async fn contract_file( + &self, + contract_id: Uuid, + file_id: Uuid, + ) -> Result<JsonValue, ApiError> { + self.get(&format!( + "/api/v1/contracts/{}/daemon/files/{}", + contract_id, file_id + )) + .await + } + + /// Report progress. + pub async fn contract_report( + &self, + contract_id: Uuid, + message: &str, + task_id: Option<Uuid>, + ) -> Result<JsonValue, ApiError> { + let req = ReportRequest { + message: message.to_string(), + task_id, + }; + self.post(&format!("/api/v1/contracts/{}/daemon/report", contract_id), &req) + .await + } + + /// Get suggested action. + pub async fn contract_suggest_action(&self, contract_id: Uuid) -> Result<JsonValue, ApiError> { + self.post_empty(&format!( + "/api/v1/contracts/{}/daemon/suggest-action", + contract_id + )) + .await + } + + /// Get completion action recommendation. + pub async fn contract_completion_action( + &self, + contract_id: Uuid, + task_id: Option<Uuid>, + files_modified: Option<Vec<String>>, + lines_added: i32, + lines_removed: i32, + has_code_changes: bool, + ) -> Result<JsonValue, ApiError> { + let req = CompletionActionRequest { + task_id, + files_modified, + lines_added, + lines_removed, + has_code_changes, + }; + self.post( + &format!("/api/v1/contracts/{}/daemon/completion-action", contract_id), + &req, + ) + .await + } + + /// Update a file. + pub async fn contract_update_file( + &self, + contract_id: Uuid, + file_id: Uuid, + content: &str, + ) -> Result<JsonValue, ApiError> { + let req = UpdateFileRequest { + content: content.to_string(), + }; + self.put( + &format!("/api/v1/contracts/{}/daemon/files/{}", contract_id, file_id), + &req, + ) + .await + } + + /// Create a new file. + pub async fn contract_create_file( + &self, + contract_id: Uuid, + name: &str, + content: &str, + ) -> Result<JsonValue, ApiError> { + let req = CreateFileRequest { + name: name.to_string(), + content: content.to_string(), + }; + self.post(&format!("/api/v1/contracts/{}/daemon/files", contract_id), &req) + .await + } +} diff --git a/makima/src/daemon/api/mod.rs b/makima/src/daemon/api/mod.rs new file mode 100644 index 0000000..0c05fb4 --- /dev/null +++ b/makima/src/daemon/api/mod.rs @@ -0,0 +1,7 @@ +//! HTTP API client for makima CLI commands. + +pub mod client; +pub mod contract; +pub mod supervisor; + +pub use client::ApiClient; diff --git a/makima/src/daemon/api/supervisor.rs b/makima/src/daemon/api/supervisor.rs new file mode 100644 index 0000000..b691cc4 --- /dev/null +++ b/makima/src/daemon/api/supervisor.rs @@ -0,0 +1,186 @@ +//! Supervisor API methods. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::client::{ApiClient, ApiError}; + +// Request/Response types + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SpawnTaskRequest { + pub name: String, + pub plan: String, + pub contract_id: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_task_id: Option<Uuid>, + #[serde(skip_serializing_if = "Option::is_none")] + pub checkpoint_sha: Option<String>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WaitRequest { + pub timeout_seconds: i32, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReadFileRequest { + pub file_path: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateBranchRequest { + pub branch_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub from_ref: Option<String>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MergeRequest { + pub squash: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub target_branch: Option<String>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreatePrRequest { + pub task_id: Uuid, + pub title: String, + pub body: String, + pub base_branch: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CheckpointRequest { + pub message: String, +} + +// Generic response type for JSON output +#[derive(Deserialize, Serialize)] +pub struct JsonValue(pub serde_json::Value); + +impl ApiClient { + /// Get all tasks in a contract. + pub async fn supervisor_tasks(&self, contract_id: Uuid) -> Result<JsonValue, ApiError> { + self.get(&format!("/api/v1/mesh/supervisor/contracts/{}/tasks", contract_id)) + .await + } + + /// Get task tree structure. + pub async fn supervisor_tree(&self, contract_id: Uuid) -> Result<JsonValue, ApiError> { + self.get(&format!("/api/v1/mesh/supervisor/contracts/{}/tree", contract_id)) + .await + } + + /// Spawn a new task. + pub async fn supervisor_spawn(&self, req: SpawnTaskRequest) -> Result<JsonValue, ApiError> { + self.post("/api/v1/mesh/supervisor/tasks", &req).await + } + + /// Wait for a task to complete. + pub async fn supervisor_wait( + &self, + task_id: Uuid, + timeout_seconds: i32, + ) -> Result<JsonValue, ApiError> { + let req = WaitRequest { timeout_seconds }; + self.post(&format!("/api/v1/mesh/supervisor/tasks/{}/wait", task_id), &req) + .await + } + + /// Read a file from a task's worktree. + pub async fn supervisor_read_file( + &self, + task_id: Uuid, + file_path: &str, + ) -> Result<JsonValue, ApiError> { + let req = ReadFileRequest { + file_path: file_path.to_string(), + }; + self.post(&format!("/api/v1/mesh/supervisor/tasks/{}/read-file", task_id), &req) + .await + } + + /// Create a new branch. + pub async fn supervisor_branch( + &self, + branch_name: &str, + from_ref: Option<String>, + ) -> Result<JsonValue, ApiError> { + let req = CreateBranchRequest { + branch_name: branch_name.to_string(), + from_ref, + }; + self.post("/api/v1/mesh/supervisor/branches", &req).await + } + + /// Merge a task's changes. + pub async fn supervisor_merge( + &self, + task_id: Uuid, + target_branch: Option<String>, + squash: bool, + ) -> Result<JsonValue, ApiError> { + let req = MergeRequest { + squash, + target_branch, + }; + self.post(&format!("/api/v1/mesh/supervisor/tasks/{}/merge", task_id), &req) + .await + } + + /// Create a pull request. + pub async fn supervisor_pr( + &self, + task_id: Uuid, + title: &str, + body: &str, + base_branch: &str, + ) -> Result<JsonValue, ApiError> { + let req = CreatePrRequest { + task_id, + title: title.to_string(), + body: body.to_string(), + base_branch: base_branch.to_string(), + }; + self.post("/api/v1/mesh/supervisor/pr", &req).await + } + + /// Get task diff. + pub async fn supervisor_diff(&self, task_id: Uuid) -> Result<JsonValue, ApiError> { + self.get(&format!("/api/v1/mesh/supervisor/tasks/{}/diff", task_id)) + .await + } + + /// Create a checkpoint. + pub async fn supervisor_checkpoint( + &self, + task_id: Uuid, + message: &str, + ) -> Result<JsonValue, ApiError> { + let req = CheckpointRequest { + message: message.to_string(), + }; + self.post(&format!("/api/v1/mesh/tasks/{}/checkpoint", task_id), &req) + .await + } + + /// List checkpoints. + pub async fn supervisor_checkpoints(&self, task_id: Uuid) -> Result<JsonValue, ApiError> { + self.get(&format!("/api/v1/mesh/tasks/{}/checkpoints", task_id)) + .await + } + + /// Get contract status. + pub async fn supervisor_status(&self, contract_id: Uuid) -> Result<JsonValue, ApiError> { + self.get(&format!("/api/v1/contracts/{}/daemon/status", contract_id)) + .await + } +} diff --git a/makima/src/daemon/cli/contract.rs b/makima/src/daemon/cli/contract.rs new file mode 100644 index 0000000..5fef5ec --- /dev/null +++ b/makima/src/daemon/cli/contract.rs @@ -0,0 +1,87 @@ +//! Contract subcommand - task-contract interaction commands. + +use clap::Args; +use uuid::Uuid; + +/// Common arguments for contract commands. +#[derive(Args, Debug, Clone)] +pub struct ContractArgs { + /// API URL + #[arg(long, env = "MAKIMA_API_URL", default_value = "http://localhost:8080", global = true)] + pub api_url: String, + + /// API key for authentication + #[arg(long, env = "MAKIMA_API_KEY", global = true)] + pub api_key: String, + + /// Current task ID (optional) + #[arg(long, env = "MAKIMA_TASK_ID", global = true)] + pub task_id: Option<Uuid>, + + /// Contract ID + #[arg(long, env = "MAKIMA_CONTRACT_ID", global = true)] + pub contract_id: Uuid, +} + +/// Arguments for file command (get specific file). +#[derive(Args, Debug)] +pub struct FileArgs { + #[command(flatten)] + pub common: ContractArgs, + + /// File ID to retrieve + pub file_id: Uuid, +} + +/// Arguments for report command. +#[derive(Args, Debug)] +pub struct ReportArgs { + #[command(flatten)] + pub common: ContractArgs, + + /// Progress message + pub message: String, +} + +/// Arguments for completion-action command. +#[derive(Args, Debug)] +pub struct CompletionActionArgs { + #[command(flatten)] + pub common: ContractArgs, + + /// Comma-separated list of modified files + #[arg(long)] + pub files: Option<String>, + + /// Number of lines added + #[arg(long, default_value = "0")] + pub lines_added: i32, + + /// Number of lines removed + #[arg(long, default_value = "0")] + pub lines_removed: i32, + + /// Whether there are code changes + #[arg(long)] + pub code: bool, +} + +/// Arguments for update-file command. +#[derive(Args, Debug)] +pub struct UpdateFileArgs { + #[command(flatten)] + pub common: ContractArgs, + + /// File ID to update + pub file_id: Uuid, +} + +/// Arguments for create-file command. +#[derive(Args, Debug)] +pub struct CreateFileArgs { + #[command(flatten)] + pub common: ContractArgs, + + /// Name of the new file + pub name: String, +} diff --git a/makima/src/daemon/cli/daemon.rs b/makima/src/daemon/cli/daemon.rs new file mode 100644 index 0000000..de4cff4 --- /dev/null +++ b/makima/src/daemon/cli/daemon.rs @@ -0,0 +1,36 @@ +//! Daemon subcommand - connect to server and manage tasks. + +use clap::Args; +use std::path::PathBuf; + +/// Run the makima daemon (connect to server and manage tasks). +#[derive(Args, Debug)] +pub struct DaemonArgs { + /// Path to custom config file + #[arg(short, long)] + pub config: Option<PathBuf>, + + /// Directory where repositories are cloned + #[arg(long, env = "MAKIMA_DAEMON_REPOS_DIR")] + pub repos_dir: Option<PathBuf>, + + /// Directory where worktrees are created + #[arg(long, env = "MAKIMA_DAEMON_WORKTREES_DIR")] + pub worktrees_dir: Option<PathBuf>, + + /// WebSocket server URL to connect to + #[arg(long, env = "MAKIMA_DAEMON_SERVER_URL")] + pub server_url: Option<String>, + + /// API key for server authentication + #[arg(long, env = "MAKIMA_DAEMON_SERVER_APIKEY")] + pub api_key: Option<String>, + + /// Maximum number of concurrent tasks + #[arg(long)] + pub max_tasks: Option<u32>, + + /// Log level (trace, debug, info, warn, error) + #[arg(short, long, default_value = "info")] + pub log_level: String, +} diff --git a/makima/src/daemon/cli/mod.rs b/makima/src/daemon/cli/mod.rs new file mode 100644 index 0000000..24c19c6 --- /dev/null +++ b/makima/src/daemon/cli/mod.rs @@ -0,0 +1,120 @@ +//! Command-line interface for the makima CLI. + +pub mod contract; +pub mod daemon; +pub mod server; +pub mod supervisor; + +use clap::{Parser, Subcommand}; + +pub use contract::ContractArgs; +pub use daemon::DaemonArgs; +pub use server::ServerArgs; +pub use supervisor::SupervisorArgs; + +/// Makima - unified CLI for server, daemon, and task management. +#[derive(Parser, Debug)] +#[command(name = "makima")] +#[command(version, about = "Makima CLI - server, daemon, and task management", long_about = None)] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand, Debug)] +pub enum Commands { + /// Run the makima server + Server(ServerArgs), + + /// Run the daemon (connect to server, manage tasks) + Daemon(DaemonArgs), + + /// Supervisor commands for contract orchestration + #[command(subcommand)] + Supervisor(SupervisorCommand), + + /// Contract commands for task-contract interaction + #[command(subcommand)] + Contract(ContractCommand), +} + +/// Supervisor subcommands for contract orchestration. +#[derive(Subcommand, Debug)] +pub enum SupervisorCommand { + /// List all tasks in the contract + Tasks(SupervisorArgs), + + /// Get the task tree structure + Tree(SupervisorArgs), + + /// Create and start a new task + Spawn(supervisor::SpawnArgs), + + /// Wait for a task to complete + Wait(supervisor::WaitArgs), + + /// Read a file from a task's worktree + ReadFile(supervisor::ReadFileArgs), + + /// Create a git branch + Branch(supervisor::BranchArgs), + + /// Merge a task's changes to a branch + Merge(supervisor::MergeArgs), + + /// Create a pull request + Pr(supervisor::PrArgs), + + /// View task diff + Diff(supervisor::DiffArgs), + + /// Create a checkpoint + Checkpoint(supervisor::CheckpointArgs), + + /// List checkpoints + Checkpoints(SupervisorArgs), + + /// Get contract status + Status(SupervisorArgs), +} + +/// Contract subcommands for task-contract interaction. +#[derive(Subcommand, Debug)] +pub enum ContractCommand { + /// Get contract status + Status(ContractArgs), + + /// Get the phase checklist + Checklist(ContractArgs), + + /// Get contract goals + Goals(ContractArgs), + + /// List contract files + Files(ContractArgs), + + /// Get a specific file's content + File(contract::FileArgs), + + /// Report progress on the contract + Report(contract::ReportArgs), + + /// Get suggested next action + SuggestAction(ContractArgs), + + /// Get completion recommendation + CompletionAction(contract::CompletionActionArgs), + + /// Update a file (reads content from stdin) + UpdateFile(contract::UpdateFileArgs), + + /// Create a new file (reads content from stdin) + CreateFile(contract::CreateFileArgs), +} + +impl Cli { + /// Parse command-line arguments + pub fn parse_args() -> Self { + Self::parse() + } +} diff --git a/makima/src/daemon/cli/server.rs b/makima/src/daemon/cli/server.rs new file mode 100644 index 0000000..371a912 --- /dev/null +++ b/makima/src/daemon/cli/server.rs @@ -0,0 +1,43 @@ +//! Server subcommand - run the makima server. + +use clap::Args; + +/// Run the makima server. +#[derive(Args, Debug)] +pub struct ServerArgs { + /// Server port + #[arg(long, env = "PORT", default_value = "8080")] + pub port: u16, + + /// Path to parakeet model directory + #[arg( + long, + env = "PARAKEET_MODEL_DIR", + default_value = "models/parakeet-tdt-0.6b-v3" + )] + pub parakeet_model_dir: String, + + /// Path to parakeet EOU model directory + #[arg( + long, + env = "PARAKEET_EOU_DIR", + default_value = "models/realtime_eou_120m-v1-onnx" + )] + pub parakeet_eou_dir: String, + + /// Path to sortformer model + #[arg( + long, + env = "SORTFORMER_MODEL_PATH", + default_value = "models/diarization/diar_streaming_sortformer_4spk-v2.1.onnx" + )] + pub sortformer_model_path: String, + + /// PostgreSQL connection URI + #[arg(long, env = "POSTGRES_CONNECTION_URI")] + pub database_url: Option<String>, + + /// Log level (trace, debug, info, warn, error) + #[arg(short, long, default_value = "info")] + pub log_level: String, +} diff --git a/makima/src/daemon/cli/supervisor.rs b/makima/src/daemon/cli/supervisor.rs new file mode 100644 index 0000000..00c7ff4 --- /dev/null +++ b/makima/src/daemon/cli/supervisor.rs @@ -0,0 +1,146 @@ +//! Supervisor subcommand - contract orchestration commands. + +use clap::Args; +use uuid::Uuid; + +/// Common arguments for supervisor commands. +#[derive(Args, Debug, Clone)] +pub struct SupervisorArgs { + /// API URL + #[arg(long, env = "MAKIMA_API_URL", default_value = "http://localhost:8080", global = true)] + pub api_url: String, + + /// API key for authentication + #[arg(long, env = "MAKIMA_API_KEY", global = true)] + pub api_key: String, + + /// Current task ID (optional) + #[arg(long, env = "MAKIMA_TASK_ID", global = true)] + pub task_id: Option<Uuid>, + + /// Contract ID + #[arg(long, env = "MAKIMA_CONTRACT_ID", global = true)] + pub contract_id: Uuid, +} + +/// Arguments for spawn command. +#[derive(Args, Debug)] +pub struct SpawnArgs { + #[command(flatten)] + pub common: SupervisorArgs, + + /// Name of the task + pub name: String, + + /// Plan/description for the task + pub plan: String, + + /// Parent task ID to branch from + #[arg(long)] + pub parent: Option<Uuid>, + + /// Checkpoint SHA to start from + #[arg(long)] + pub checkpoint: Option<String>, +} + +/// Arguments for wait command. +#[derive(Args, Debug)] +pub struct WaitArgs { + #[command(flatten)] + pub common: SupervisorArgs, + + /// Task ID to wait for + pub task_id: Uuid, + + /// Timeout in seconds + #[arg(default_value = "300")] + pub timeout: i32, +} + +/// Arguments for read-file command. +#[derive(Args, Debug)] +pub struct ReadFileArgs { + #[command(flatten)] + pub common: SupervisorArgs, + + /// Task ID to read from + pub task_id: Uuid, + + /// File path to read + pub file_path: String, +} + +/// Arguments for branch command. +#[derive(Args, Debug)] +pub struct BranchArgs { + #[command(flatten)] + pub common: SupervisorArgs, + + /// Branch name to create + pub name: String, + + /// Reference (task ID or SHA) to branch from + #[arg(long)] + pub from: Option<String>, +} + +/// Arguments for merge command. +#[derive(Args, Debug)] +pub struct MergeArgs { + #[command(flatten)] + pub common: SupervisorArgs, + + /// Task ID to merge + pub task_id: Uuid, + + /// Target branch to merge into + #[arg(long)] + pub to: Option<String>, + + /// Squash commits on merge + #[arg(long)] + pub squash: bool, +} + +/// Arguments for pr command. +#[derive(Args, Debug)] +pub struct PrArgs { + #[command(flatten)] + pub common: SupervisorArgs, + + /// Task ID to create PR for + pub task_id: Uuid, + + /// PR title + #[arg(long)] + pub title: String, + + /// PR body/description + #[arg(long)] + pub body: Option<String>, + + /// Base branch (default: main) + #[arg(long, default_value = "main")] + pub base: String, +} + +/// Arguments for diff command. +#[derive(Args, Debug)] +pub struct DiffArgs { + #[command(flatten)] + pub common: SupervisorArgs, + + /// Task ID to get diff for + pub task_id: Uuid, +} + +/// Arguments for checkpoint command. +#[derive(Args, Debug)] +pub struct CheckpointArgs { + #[command(flatten)] + pub common: SupervisorArgs, + + /// Checkpoint message + pub message: String, +} diff --git a/makima/src/daemon/config.rs b/makima/src/daemon/config.rs new file mode 100644 index 0000000..866ee70 --- /dev/null +++ b/makima/src/daemon/config.rs @@ -0,0 +1,555 @@ +//! Configuration management for the makima daemon. + +use config::{Config, Environment, File}; +use serde::Deserialize; +use std::collections::HashMap; +use std::path::PathBuf; + +/// Root daemon configuration. +#[derive(Debug, Clone, Deserialize)] +pub struct DaemonConfig { + /// Server connection settings. + #[serde(default)] + pub server: ServerConfig, + + /// Worktree settings. + #[serde(default)] + pub worktree: WorktreeConfig, + + /// Process settings. + #[serde(default)] + pub process: ProcessConfig, + + /// Local database settings. + #[serde(default)] + pub local_db: LocalDbConfig, + + /// Logging settings. + #[serde(default)] + pub logging: LoggingConfig, + + /// Repositories to auto-clone on startup. + #[serde(default)] + pub repos: ReposConfig, +} + +/// Server connection configuration. +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct ServerConfig { + /// WebSocket URL of makima server (e.g., ws://localhost:8080 or wss://makima.example.com). + /// Defaults to wss://api.makima.jp. + #[serde(default = "default_server_url")] + pub url: String, + + /// API key for authentication. + #[serde(default, alias = "apikey")] + pub api_key: String, + + /// Heartbeat interval in seconds. + #[serde(default = "default_heartbeat_interval", alias = "heartbeatintervalsecs")] + pub heartbeat_interval_secs: u64, + + /// Reconnect interval in seconds after connection loss. + #[serde(default = "default_reconnect_interval", alias = "reconnectintervalsecs")] + pub reconnect_interval_secs: u64, + + /// Maximum reconnect attempts before giving up (0 = infinite). + #[serde(default, alias = "maxreconnectattempts")] + pub max_reconnect_attempts: u32, +} + +fn default_heartbeat_interval() -> u64 { + 30 +} + +fn default_reconnect_interval() -> u64 { + 5 +} + +fn default_server_url() -> String { + "wss://api.makima.jp".to_string() +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { + url: default_server_url(), + api_key: String::new(), + heartbeat_interval_secs: default_heartbeat_interval(), + reconnect_interval_secs: default_reconnect_interval(), + max_reconnect_attempts: 0, + } + } +} + +/// Worktree configuration for task isolation. +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct WorktreeConfig { + /// Base directory for worktrees (~/.makima/worktrees). + #[serde(default = "default_worktree_base_dir", alias = "basedir")] + pub base_dir: PathBuf, + + /// Base directory for cloned repositories (~/.makima/repos). + #[serde(default = "default_repos_base_dir", alias = "reposdir")] + pub repos_dir: PathBuf, + + /// Branch prefix for task branches. + #[serde(default = "default_branch_prefix", alias = "branchprefix")] + pub branch_prefix: String, + + /// Clean up worktrees on daemon start. + #[serde(default, alias = "cleanuponstart")] + pub cleanup_on_start: bool, + + /// Default target repository path for pushing completed branches. + /// Used when task.target_repo_path is not set. + #[serde(default, alias = "defaulttargetrepo")] + pub default_target_repo: Option<PathBuf>, +} + +fn default_worktree_base_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".makima") + .join("worktrees") +} + +fn default_repos_base_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".makima") + .join("repos") +} + +fn default_branch_prefix() -> String { + "makima/task-".to_string() +} + +impl Default for WorktreeConfig { + fn default() -> Self { + Self { + base_dir: default_worktree_base_dir(), + repos_dir: default_repos_base_dir(), + branch_prefix: default_branch_prefix(), + cleanup_on_start: false, + default_target_repo: None, + } + } +} + +/// Process configuration for Claude Code subprocess execution. +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct ProcessConfig { + /// Path or command for Claude Code CLI. + #[serde(default = "default_claude_command", alias = "claudecommand")] + pub claude_command: String, + + /// Additional arguments to pass to Claude Code. + /// These are added after the default arguments. + #[serde(default, alias = "claudeargs")] + pub claude_args: Vec<String>, + + /// Arguments to pass before the default arguments. + /// Useful for overriding defaults. + #[serde(default, alias = "claudepreargs")] + pub claude_pre_args: Vec<String>, + + /// Skip the --dangerously-skip-permissions flag (default: false). + /// Set to true if you want to use Claude's permission system. + #[serde(default, alias = "enablepermissions")] + pub enable_permissions: bool, + + /// Skip the --verbose flag (default: false). + #[serde(default, alias = "disableverbose")] + pub disable_verbose: bool, + + /// Maximum concurrent tasks. + #[serde(default = "default_max_tasks", alias = "maxconcurrenttasks")] + pub max_concurrent_tasks: u32, + + /// Default timeout for tasks in seconds (0 = no timeout). + #[serde(default, alias = "defaulttimeoutsecs")] + pub default_timeout_secs: u64, + + /// Additional environment variables to pass to Claude Code. + #[serde(default, alias = "envvars")] + pub env_vars: HashMap<String, String>, +} + +fn default_claude_command() -> String { + "claude".to_string() +} + +fn default_max_tasks() -> u32 { + 4 +} + +impl Default for ProcessConfig { + fn default() -> Self { + Self { + claude_command: default_claude_command(), + claude_args: Vec::new(), + claude_pre_args: Vec::new(), + enable_permissions: false, + disable_verbose: false, + max_concurrent_tasks: default_max_tasks(), + default_timeout_secs: 0, + env_vars: HashMap::new(), + } + } +} + +/// Local database configuration. +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct LocalDbConfig { + /// Path to local SQLite database. + #[serde(default = "default_db_path")] + pub path: PathBuf, +} + +impl Default for LocalDbConfig { + fn default() -> Self { + Self { + path: default_db_path(), + } + } +} + +fn default_db_path() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".makima") + .join("daemon.db") +} + +/// Logging configuration. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct LoggingConfig { + /// Log level: "trace", "debug", "info", "warn", "error". + #[serde(default = "default_log_level")] + pub level: String, + + /// Log format: "pretty" or "json". + #[serde(default = "default_log_format")] + pub format: String, +} + +fn default_log_level() -> String { + "info".to_string() +} + +fn default_log_format() -> String { + "pretty".to_string() +} + +/// Repository auto-clone configuration. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct ReposConfig { + /// Directory to clone repositories into (default: ~/.makima/home). + #[serde(default = "default_home_dir")] + pub home_dir: PathBuf, + + /// List of repositories to auto-clone on startup. + /// Each entry can be a URL (e.g., "https://github.com/user/repo.git") + /// or a shorthand (e.g., "github:user/repo"). + #[serde(default, alias = "autoclone")] + pub auto_clone: Vec<RepoEntry>, +} + +/// A repository entry for auto-cloning. +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum RepoEntry { + /// Simple URL string. + Url(String), + /// Detailed configuration. + Config { + /// Repository URL. + url: String, + /// Custom directory name (defaults to repo name from URL). + #[serde(default)] + name: Option<String>, + /// Branch to checkout after cloning (defaults to default branch). + #[serde(default)] + branch: Option<String>, + /// Whether to do a shallow clone (default: false). + #[serde(default)] + shallow: bool, + }, +} + +impl RepoEntry { + /// Get the URL for this repo entry. + pub fn url(&self) -> &str { + match self { + RepoEntry::Url(url) => url, + RepoEntry::Config { url, .. } => url, + } + } + + /// Get the custom name, if any. + pub fn name(&self) -> Option<&str> { + match self { + RepoEntry::Url(_) => None, + RepoEntry::Config { name, .. } => name.as_deref(), + } + } + + /// Get the branch to checkout, if any. + pub fn branch(&self) -> Option<&str> { + match self { + RepoEntry::Url(_) => None, + RepoEntry::Config { branch, .. } => branch.as_deref(), + } + } + + /// Whether to do a shallow clone. + pub fn shallow(&self) -> bool { + match self { + RepoEntry::Url(_) => false, + RepoEntry::Config { shallow, .. } => *shallow, + } + } + + /// Get the directory name to use (either custom name or derived from URL). + pub fn dir_name(&self) -> Option<String> { + if let Some(name) = self.name() { + return Some(name.to_string()); + } + + // Derive from URL + let url = self.url(); + + // Handle shorthand formats + let url = if url.starts_with("github:") { + url.strip_prefix("github:").unwrap_or(url) + } else if url.starts_with("gitlab:") { + url.strip_prefix("gitlab:").unwrap_or(url) + } else { + url + }; + + // Extract repo name from URL + url.trim_end_matches('/') + .trim_end_matches(".git") + .rsplit('/') + .next() + .map(|s| s.to_string()) + } + + /// Expand the URL (e.g., convert shorthand to full URL). + pub fn expanded_url(&self) -> String { + let url = self.url(); + + if url.starts_with("github:") { + format!("https://github.com/{}.git", url.strip_prefix("github:").unwrap_or("")) + } else if url.starts_with("gitlab:") { + format!("https://gitlab.com/{}.git", url.strip_prefix("gitlab:").unwrap_or("")) + } else { + url.to_string() + } + } +} + +fn default_home_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".makima") + .join("home") +} + +impl DaemonConfig { + /// Load configuration from files and environment variables. + /// + /// Configuration sources (in order of precedence): + /// 1. Environment variables (MAKIMA_API_KEY, MAKIMA_DAEMON_SERVER_URL, etc.) + /// 2. ./makima-daemon.toml (current directory) + /// 3. ~/.config/makima-daemon/config.toml + /// 4. /etc/makima-daemon/config.toml (Linux only) + /// + /// Environment variable examples: + /// - MAKIMA_API_KEY=your-api-key (preferred) + /// - MAKIMA_DAEMON_SERVER_URL=ws://localhost:8080 + /// - MAKIMA_DAEMON_PROCESS_MAXCONCURRENTTASKS=4 + pub fn load() -> Result<Self, config::ConfigError> { + Self::load_from_path(None) + } + + /// Load configuration from a specific path plus standard sources. + fn load_from_path(config_path: Option<&std::path::Path>) -> Result<Self, config::ConfigError> { + let mut builder = Config::builder(); + + // System-wide config (Linux only) + #[cfg(target_os = "linux")] + { + builder = builder.add_source( + File::with_name("/etc/makima-daemon/config").required(false), + ); + } + + // User config + if let Some(config_dir) = dirs::config_dir() { + let user_config = config_dir.join("makima-daemon").join("config"); + builder = builder.add_source( + File::with_name(user_config.to_str().unwrap_or("")).required(false), + ); + } + + // Local config + builder = builder.add_source(File::with_name("makima-daemon").required(false)); + + // Custom config file (if provided) + if let Some(path) = config_path { + builder = builder.add_source( + File::with_name(path.to_str().unwrap_or("")).required(true), + ); + } + + // Environment variables with underscore separator for nesting + // e.g., MAKIMA_DAEMON_SERVER_URL -> server.url + // MAKIMA_DAEMON_SERVER_APIKEY -> server.api_key + builder = builder.add_source( + Environment::with_prefix("MAKIMA_DAEMON") + .separator("_") + .try_parsing(true), + ); + + let config = builder.build()?; + let mut config: DaemonConfig = config.try_deserialize()?; + + // Check for MAKIMA_API_KEY environment variable (preferred over MAKIMA_DAEMON_SERVER_APIKEY) + if let Ok(api_key) = std::env::var("MAKIMA_API_KEY") { + config.server.api_key = api_key; + } + + // Validate required fields (don't validate here - let load_with_cli do final validation) + Ok(config) + } + + /// Validate that required configuration fields are set. + pub fn validate(&self) -> Result<(), config::ConfigError> { + if self.server.api_key.is_empty() { + return Err(config::ConfigError::Message( + "API key is required. Set via MAKIMA_API_KEY, config file, or --api-key".to_string() + )); + } + Ok(()) + } + + /// Load configuration with CLI argument overrides. + /// + /// Configuration sources (in order of precedence, highest first): + /// 1. CLI arguments + /// 2. Environment variables + /// 3. Custom config file (if --config specified) + /// 4. ./makima-daemon.toml (current directory) + /// 5. ~/.config/makima-daemon/config.toml + /// 6. /etc/makima-daemon/config.toml (Linux only) + /// 7. Default values + pub fn load_with_cli(cli: &super::cli::daemon::DaemonArgs) -> Result<Self, config::ConfigError> { + Self::load_with_daemon_args(cli) + } + + /// Load configuration from various sources with daemon CLI overrides. + pub fn load_with_daemon_args(args: &super::cli::daemon::DaemonArgs) -> Result<Self, config::ConfigError> { + // Load base config (with optional custom config file) + let mut config = Self::load_from_path(args.config.as_deref())?; + + // Apply CLI overrides (highest priority) + if let Some(ref repos_dir) = args.repos_dir { + config.worktree.repos_dir = repos_dir.clone(); + } + if let Some(ref worktrees_dir) = args.worktrees_dir { + config.worktree.base_dir = worktrees_dir.clone(); + } + if let Some(ref server_url) = args.server_url { + config.server.url = server_url.clone(); + } + if let Some(ref api_key) = args.api_key { + config.server.api_key = api_key.clone(); + } + if let Some(max_tasks) = args.max_tasks { + config.process.max_concurrent_tasks = max_tasks; + } + // Log level is always set (has default) + config.logging.level = args.log_level.clone(); + + // Validate required fields after all sources are merged + config.validate()?; + + Ok(config) + } + + /// Create a minimal config for testing. + #[cfg(test)] + pub fn test_config() -> Self { + Self { + server: ServerConfig { + url: "ws://localhost:8080".to_string(), + api_key: "test-key".to_string(), + heartbeat_interval_secs: 30, + reconnect_interval_secs: 5, + max_reconnect_attempts: 0, + }, + worktree: WorktreeConfig { + base_dir: PathBuf::from("/tmp/makima-daemon-test/worktrees"), + repos_dir: PathBuf::from("/tmp/makima-daemon-test/repos"), + branch_prefix: "makima/task-".to_string(), + cleanup_on_start: true, + default_target_repo: None, + }, + process: ProcessConfig { + claude_command: "claude".to_string(), + claude_args: Vec::new(), + claude_pre_args: Vec::new(), + enable_permissions: false, + disable_verbose: false, + max_concurrent_tasks: 2, + default_timeout_secs: 0, + env_vars: HashMap::new(), + }, + local_db: LocalDbConfig { + path: PathBuf::from("/tmp/makima-daemon-test/state.db"), + }, + logging: LoggingConfig::default(), + repos: ReposConfig::default(), + } + } +} + +/// Helper module for dirs crate (minimal subset). +mod dirs { + use std::path::PathBuf; + + pub fn home_dir() -> Option<PathBuf> { + std::env::var("HOME").ok().map(PathBuf::from) + } + + pub fn config_dir() -> Option<PathBuf> { + #[cfg(target_os = "macos")] + { + std::env::var("HOME") + .ok() + .map(|h| PathBuf::from(h).join("Library").join("Application Support")) + } + #[cfg(target_os = "linux")] + { + std::env::var("XDG_CONFIG_HOME") + .ok() + .map(PathBuf::from) + .or_else(|| std::env::var("HOME").ok().map(|h| PathBuf::from(h).join(".config"))) + } + #[cfg(target_os = "windows")] + { + std::env::var("APPDATA").ok().map(PathBuf::from) + } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + None + } + } +} diff --git a/makima/src/daemon/db/local.rs b/makima/src/daemon/db/local.rs new file mode 100644 index 0000000..f3ed45a --- /dev/null +++ b/makima/src/daemon/db/local.rs @@ -0,0 +1,391 @@ +//! Local SQLite database for crash recovery and state persistence. + +use std::path::Path; + +use chrono::{DateTime, Utc}; +use rusqlite::{params, Connection, Result as SqliteResult}; +use uuid::Uuid; + +use crate::daemon::task::TaskState; + +/// Local task record for persistence. +#[derive(Debug, Clone)] +pub struct LocalTask { + pub id: Uuid, + pub server_task_id: Uuid, + pub state: TaskState, + pub container_id: Option<String>, + pub overlay_path: Option<String>, + pub repo_url: Option<String>, + pub base_branch: Option<String>, + pub plan: String, + pub created_at: DateTime<Utc>, + pub started_at: Option<DateTime<Utc>>, + pub completed_at: Option<DateTime<Utc>>, + pub error_message: Option<String>, +} + +/// Buffered output for reliable delivery. +#[derive(Debug, Clone)] +pub struct BufferedOutput { + pub id: i64, + pub task_id: Uuid, + pub output: String, + pub is_partial: bool, + pub timestamp: DateTime<Utc>, +} + +/// Local database for daemon state persistence. +pub struct LocalDb { + conn: Connection, +} + +impl LocalDb { + /// Open or create the local database. + pub fn open(path: &Path) -> SqliteResult<Self> { + // Create parent directory if needed + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).ok(); + } + + let conn = Connection::open(path)?; + + // Initialize schema + conn.execute_batch(Self::schema())?; + + Ok(Self { conn }) + } + + /// Open an in-memory database (for testing). + #[cfg(test)] + pub fn open_memory() -> SqliteResult<Self> { + let conn = Connection::open_in_memory()?; + conn.execute_batch(Self::schema())?; + Ok(Self { conn }) + } + + /// Database schema. + fn schema() -> &'static str { + r#" + -- Local task state for crash recovery + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + server_task_id TEXT NOT NULL, + state TEXT NOT NULL, + container_id TEXT, + overlay_path TEXT, + repo_url TEXT, + base_branch TEXT, + plan TEXT NOT NULL, + created_at TEXT NOT NULL, + started_at TEXT, + completed_at TEXT, + error_message TEXT + ); + + -- Buffered output for reliable delivery + CREATE TABLE IF NOT EXISTS output_buffer ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + output TEXT NOT NULL, + is_partial INTEGER NOT NULL, + timestamp TEXT NOT NULL, + sent INTEGER NOT NULL DEFAULT 0 + ); + + -- Daemon state key-value store + CREATE TABLE IF NOT EXISTS daemon_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + -- Indexes + CREATE INDEX IF NOT EXISTS idx_tasks_state ON tasks(state); + CREATE INDEX IF NOT EXISTS idx_output_buffer_sent ON output_buffer(sent, id); + CREATE INDEX IF NOT EXISTS idx_output_buffer_task ON output_buffer(task_id); + "# + } + + /// Save a task. + pub fn save_task(&self, task: &LocalTask) -> SqliteResult<()> { + self.conn.execute( + r#" + INSERT OR REPLACE INTO tasks + (id, server_task_id, state, container_id, overlay_path, repo_url, base_branch, plan, created_at, started_at, completed_at, error_message) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) + "#, + params![ + task.id.to_string(), + task.server_task_id.to_string(), + task.state.as_str(), + task.container_id, + task.overlay_path, + task.repo_url, + task.base_branch, + task.plan, + task.created_at.to_rfc3339(), + task.started_at.map(|t| t.to_rfc3339()), + task.completed_at.map(|t| t.to_rfc3339()), + task.error_message, + ], + )?; + Ok(()) + } + + /// Get a task by ID. + pub fn get_task(&self, id: Uuid) -> SqliteResult<Option<LocalTask>> { + let mut stmt = self.conn.prepare( + "SELECT id, server_task_id, state, container_id, overlay_path, repo_url, base_branch, plan, created_at, started_at, completed_at, error_message FROM tasks WHERE id = ?1", + )?; + + let mut rows = stmt.query(params![id.to_string()])?; + + if let Some(row) = rows.next()? { + Ok(Some(Self::task_from_row(row)?)) + } else { + Ok(None) + } + } + + /// Get all running/active tasks (for recovery). + pub fn get_active_tasks(&self) -> SqliteResult<Vec<LocalTask>> { + let mut stmt = self.conn.prepare( + r#" + SELECT id, server_task_id, state, container_id, overlay_path, repo_url, base_branch, plan, created_at, started_at, completed_at, error_message + FROM tasks + WHERE state IN ('initializing', 'starting', 'running', 'paused', 'blocked') + "#, + )?; + + let rows = stmt.query_map([], |row| Self::task_from_row(row))?; + + rows.collect() + } + + /// Delete a task. + pub fn delete_task(&self, id: Uuid) -> SqliteResult<()> { + self.conn.execute( + "DELETE FROM tasks WHERE id = ?1", + params![id.to_string()], + )?; + Ok(()) + } + + /// Update task state. + pub fn update_task_state(&self, id: Uuid, state: TaskState) -> SqliteResult<()> { + self.conn.execute( + "UPDATE tasks SET state = ?2 WHERE id = ?1", + params![id.to_string(), state.as_str()], + )?; + Ok(()) + } + + /// Buffer output for reliable delivery. + pub fn buffer_output(&self, task_id: Uuid, output: &str, is_partial: bool) -> SqliteResult<i64> { + self.conn.execute( + r#" + INSERT INTO output_buffer (task_id, output, is_partial, timestamp, sent) + VALUES (?1, ?2, ?3, datetime('now'), 0) + "#, + params![task_id.to_string(), output, is_partial as i32], + )?; + Ok(self.conn.last_insert_rowid()) + } + + /// Get unsent outputs. + pub fn get_unsent_outputs(&self, limit: i64) -> SqliteResult<Vec<BufferedOutput>> { + let mut stmt = self.conn.prepare( + r#" + SELECT id, task_id, output, is_partial, timestamp + FROM output_buffer + WHERE sent = 0 + ORDER BY id + LIMIT ?1 + "#, + )?; + + let rows = stmt.query_map(params![limit], |row| { + let id: i64 = row.get(0)?; + let task_id_str: String = row.get(1)?; + let task_id = Uuid::parse_str(&task_id_str).unwrap_or_default(); + let output: String = row.get(2)?; + let is_partial: i32 = row.get(3)?; + let timestamp_str: String = row.get(4)?; + let timestamp = DateTime::parse_from_rfc3339(×tamp_str) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc::now()); + + Ok(BufferedOutput { + id, + task_id, + output, + is_partial: is_partial != 0, + timestamp, + }) + })?; + + rows.collect() + } + + /// Mark outputs as sent. + pub fn mark_outputs_sent(&self, ids: &[i64]) -> SqliteResult<()> { + if ids.is_empty() { + return Ok(()); + } + + let placeholders: Vec<&str> = ids.iter().map(|_| "?").collect(); + let sql = format!( + "UPDATE output_buffer SET sent = 1 WHERE id IN ({})", + placeholders.join(",") + ); + + let params: Vec<rusqlite::types::Value> = ids + .iter() + .map(|id| rusqlite::types::Value::Integer(*id)) + .collect(); + + self.conn.execute(&sql, rusqlite::params_from_iter(params))?; + Ok(()) + } + + /// Clean up old sent outputs. + pub fn cleanup_sent_outputs(&self, older_than_hours: i64) -> SqliteResult<usize> { + let result = self.conn.execute( + r#" + DELETE FROM output_buffer + WHERE sent = 1 AND timestamp < datetime('now', ?1 || ' hours') + "#, + params![format!("-{}", older_than_hours)], + )?; + Ok(result) + } + + /// Get daemon state value. + pub fn get_state(&self, key: &str) -> SqliteResult<Option<String>> { + let mut stmt = self.conn.prepare( + "SELECT value FROM daemon_state WHERE key = ?1", + )?; + + let mut rows = stmt.query(params![key])?; + + if let Some(row) = rows.next()? { + let value: String = row.get(0)?; + Ok(Some(value)) + } else { + Ok(None) + } + } + + /// Set daemon state value. + pub fn set_state(&self, key: &str, value: &str) -> SqliteResult<()> { + self.conn.execute( + r#" + INSERT OR REPLACE INTO daemon_state (key, value, updated_at) + VALUES (?1, ?2, datetime('now')) + "#, + params![key, value], + )?; + Ok(()) + } + + /// Parse a task from a database row. + fn task_from_row(row: &rusqlite::Row) -> SqliteResult<LocalTask> { + let id_str: String = row.get(0)?; + let server_task_id_str: String = row.get(1)?; + let state_str: String = row.get(2)?; + let container_id: Option<String> = row.get(3)?; + let overlay_path: Option<String> = row.get(4)?; + let repo_url: Option<String> = row.get(5)?; + let base_branch: Option<String> = row.get(6)?; + let plan: String = row.get(7)?; + let created_at_str: String = row.get(8)?; + let started_at_str: Option<String> = row.get(9)?; + let completed_at_str: Option<String> = row.get(10)?; + let error_message: Option<String> = row.get(11)?; + + let id = Uuid::parse_str(&id_str).unwrap_or_default(); + let server_task_id = Uuid::parse_str(&server_task_id_str).unwrap_or_default(); + let state = TaskState::from_str(&state_str).unwrap_or_default(); + let created_at = DateTime::parse_from_rfc3339(&created_at_str) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc::now()); + let started_at = started_at_str + .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) + .map(|dt| dt.with_timezone(&Utc)); + let completed_at = completed_at_str + .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) + .map(|dt| dt.with_timezone(&Utc)); + + Ok(LocalTask { + id, + server_task_id, + state, + container_id, + overlay_path, + repo_url, + base_branch, + plan, + created_at, + started_at, + completed_at, + error_message, + }) + } +} + +#[cfg(test)] +mod tests { + use crate::daemon::*; + + #[test] + fn test_open_memory() { + let db = LocalDb::open_memory().unwrap(); + assert!(db.get_active_tasks().unwrap().is_empty()); + } + + #[test] + fn test_save_and_get_task() { + let db = LocalDb::open_memory().unwrap(); + + let task = LocalTask { + id: Uuid::new_v4(), + server_task_id: Uuid::new_v4(), + state: TaskState::Running, + container_id: Some("abc123".to_string()), + overlay_path: Some("/tmp/overlay".to_string()), + repo_url: Some("https://github.com/test/repo".to_string()), + base_branch: Some("main".to_string()), + plan: "Build the feature".to_string(), + created_at: Utc::now(), + started_at: Some(Utc::now()), + completed_at: None, + error_message: None, + }; + + db.save_task(&task).unwrap(); + + let loaded = db.get_task(task.id).unwrap().unwrap(); + assert_eq!(loaded.id, task.id); + assert_eq!(loaded.state, TaskState::Running); + assert_eq!(loaded.plan, "Build the feature"); + } + + #[test] + fn test_output_buffer() { + let db = LocalDb::open_memory().unwrap(); + let task_id = Uuid::new_v4(); + + db.buffer_output(task_id, "line 1", false).unwrap(); + db.buffer_output(task_id, "line 2", false).unwrap(); + + let unsent = db.get_unsent_outputs(10).unwrap(); + assert_eq!(unsent.len(), 2); + + let ids: Vec<i64> = unsent.iter().map(|o| o.id).collect(); + db.mark_outputs_sent(&ids).unwrap(); + + let unsent = db.get_unsent_outputs(10).unwrap(); + assert!(unsent.is_empty()); + } +} diff --git a/makima/src/daemon/db/mod.rs b/makima/src/daemon/db/mod.rs new file mode 100644 index 0000000..2c6e0f3 --- /dev/null +++ b/makima/src/daemon/db/mod.rs @@ -0,0 +1,5 @@ +//! Local database for daemon state persistence. + +pub mod local; + +pub use local::{BufferedOutput, LocalDb, LocalTask}; diff --git a/makima/src/daemon/error.rs b/makima/src/daemon/error.rs new file mode 100644 index 0000000..b993169 --- /dev/null +++ b/makima/src/daemon/error.rs @@ -0,0 +1,75 @@ +//! Error types for the makima daemon. + +use thiserror::Error; +use uuid::Uuid; + +/// Top-level daemon error type. +#[derive(Error, Debug)] +pub enum DaemonError { + #[error("WebSocket error: {0}")] + WebSocket(#[from] tokio_tungstenite::tungstenite::Error), + + #[error("Worktree error: {0}")] + Worktree(#[from] crate::daemon::worktree::WorktreeError), + + #[error("Process error: {0}")] + Process(#[from] crate::daemon::process::ClaudeProcessError), + + #[error("Task error: {0}")] + Task(#[from] TaskError), + + #[error("Configuration error: {0}")] + Config(#[from] config::ConfigError), + + #[error("Database error: {0}")] + Database(#[from] rusqlite::Error), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + #[error("Authentication failed: {0}")] + AuthFailed(String), + + #[error("Connection lost")] + ConnectionLost, + + #[error("Server error: {code} - {message}")] + ServerError { code: String, message: String }, +} + +/// Task management errors. +#[derive(Error, Debug)] +pub enum TaskError { + #[error("Task not found: {0}")] + NotFound(Uuid), + + #[error("Invalid state transition from {from} to {to}")] + InvalidStateTransition { from: String, to: String }, + + #[error("Concurrency limit reached")] + ConcurrencyLimit, + + #[error("Task already exists: {0}")] + AlreadyExists(Uuid), + + #[error("Task not running: {0}")] + NotRunning(Uuid), + + #[error("Failed to send message to task: {0}")] + MessageFailed(String), + + #[error("Task setup failed: {0}")] + SetupFailed(String), + + #[error("Task execution failed: {0}")] + ExecutionFailed(String), +} + +/// Result type alias for daemon operations. +pub type Result<T> = std::result::Result<T, DaemonError>; + +/// Result type alias for task operations. +pub type TaskResult<T> = std::result::Result<T, TaskError>; diff --git a/makima/src/daemon/mod.rs b/makima/src/daemon/mod.rs new file mode 100644 index 0000000..d7ec3f0 --- /dev/null +++ b/makima/src/daemon/mod.rs @@ -0,0 +1,22 @@ +//! Makima CLI - Unified CLI for server, daemon, and task management. +//! +//! This crate provides: +//! - `makima server` - Run the makima server +//! - `makima daemon` - Run the daemon (connect to server, manage tasks) +//! - `makima supervisor` - Contract orchestration commands +//! - `makima contract` - Task-contract interaction commands + +pub mod api; +pub mod cli; +pub mod config; +pub mod db; +pub mod error; +pub mod process; +pub mod task; +pub mod temp; +pub mod worktree; +pub mod ws; + +pub use cli::{Cli, Commands, ContractCommand, SupervisorCommand}; +pub use config::DaemonConfig; +pub use error::{DaemonError, Result}; diff --git a/makima/src/daemon/process/claude.rs b/makima/src/daemon/process/claude.rs new file mode 100644 index 0000000..93b097c --- /dev/null +++ b/makima/src/daemon/process/claude.rs @@ -0,0 +1,509 @@ +//! Claude Code process management. + +use std::collections::HashMap; +use std::path::Path; +use std::process::Stdio; +use std::sync::Arc; + +use futures::Stream; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, ChildStdin, Command}; +use tokio::sync::{mpsc, Mutex}; + +use super::claude_protocol::ClaudeInputMessage; + +/// Errors that can occur during Claude process management. +#[derive(Debug, thiserror::Error)] +pub enum ClaudeProcessError { + #[error("Failed to spawn Claude process: {0}")] + SpawnFailed(#[from] std::io::Error), + + #[error("Claude command not found: {0}")] + CommandNotFound(String), + + #[error("Process already exited")] + AlreadyExited, + + #[error("Failed to read output: {0}")] + OutputRead(String), +} + +/// A line of output from Claude Code. +#[derive(Debug, Clone)] +pub struct OutputLine { + /// The raw content of the line. + pub content: String, + /// Whether this is from stdout (true) or stderr (false). + pub is_stdout: bool, + /// Parsed JSON type if available (e.g., "system", "assistant", "result"). + pub json_type: Option<String>, +} + +impl OutputLine { + /// Create a new stdout output line. + pub fn stdout(content: String) -> Self { + let json_type = extract_json_type(&content); + Self { + content, + is_stdout: true, + json_type, + } + } + + /// Create a new stderr output line. + pub fn stderr(content: String) -> Self { + Self { + content, + is_stdout: false, + json_type: None, + } + } +} + +/// Extract the "type" field from a JSON line if present. +fn extract_json_type(line: &str) -> Option<String> { + // Quick check for JSON + if !line.starts_with('{') { + return None; + } + + // Try to parse and extract type + if let Ok(json) = serde_json::from_str::<serde_json::Value>(line) { + json.get("type") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + } else { + None + } +} + +/// Handle to a running Claude Code process. +pub struct ClaudeProcess { + /// The child process. + child: Child, + /// Receiver for output lines. + output_rx: mpsc::Receiver<OutputLine>, + /// Stdin handle for sending input to the process (thread-safe). + stdin: Arc<Mutex<Option<ChildStdin>>>, +} + +impl ClaudeProcess { + /// Wait for the process to exit and return the exit code. + pub async fn wait(&mut self) -> Result<i64, ClaudeProcessError> { + let status = self.child.wait().await?; + Ok(status.code().unwrap_or(-1) as i64) + } + + /// Check if the process has exited. + pub fn try_wait(&mut self) -> Result<Option<i64>, ClaudeProcessError> { + match self.child.try_wait()? { + Some(status) => Ok(Some(status.code().unwrap_or(-1) as i64)), + None => Ok(None), + } + } + + /// Kill the process. + pub async fn kill(&mut self) -> Result<(), ClaudeProcessError> { + self.child.kill().await?; + Ok(()) + } + + /// Get the next output line, if available. + pub async fn next_output(&mut self) -> Option<OutputLine> { + self.output_rx.recv().await + } + + /// Send a raw message to the process via stdin. + /// + /// This can be used to provide input when Claude Code is waiting for user input. + pub async fn send_input(&self, message: &str) -> Result<(), ClaudeProcessError> { + let mut stdin_guard = self.stdin.lock().await; + if let Some(ref mut stdin) = *stdin_guard { + stdin + .write_all(message.as_bytes()) + .await + .map_err(|e| ClaudeProcessError::OutputRead(format!("Failed to write to stdin: {}", e)))?; + stdin + .write_all(b"\n") + .await + .map_err(|e| ClaudeProcessError::OutputRead(format!("Failed to write newline: {}", e)))?; + stdin + .flush() + .await + .map_err(|e| ClaudeProcessError::OutputRead(format!("Failed to flush stdin: {}", e)))?; + Ok(()) + } else { + Err(ClaudeProcessError::OutputRead("Stdin not available".to_string())) + } + } + + /// Send a user message to the process via stdin using JSON protocol. + /// + /// This is the preferred method when using `--input-format=stream-json`. + /// The message is serialized as JSON and sent as a single line. + pub async fn send_user_message(&self, content: &str) -> Result<(), ClaudeProcessError> { + let message = ClaudeInputMessage::user(content); + let json_line = message.to_json_line().map_err(|e| { + ClaudeProcessError::OutputRead(format!("Failed to serialize message: {}", e)) + })?; + + tracing::debug!(content_len = content.len(), "Sending user message to Claude process"); + + let mut stdin_guard = self.stdin.lock().await; + if let Some(ref mut stdin) = *stdin_guard { + stdin + .write_all(json_line.as_bytes()) + .await + .map_err(|e| ClaudeProcessError::OutputRead(format!("Failed to write to stdin: {}", e)))?; + stdin + .flush() + .await + .map_err(|e| ClaudeProcessError::OutputRead(format!("Failed to flush stdin: {}", e)))?; + Ok(()) + } else { + Err(ClaudeProcessError::OutputRead("Stdin not available".to_string())) + } + } + + /// Get a clone of the stdin handle for external use. + pub fn stdin_handle(&self) -> Arc<Mutex<Option<ChildStdin>>> { + Arc::clone(&self.stdin) + } + + /// Close stdin, signaling EOF to the process. + pub async fn close_stdin(&self) -> Result<(), ClaudeProcessError> { + let mut stdin_guard = self.stdin.lock().await; + if let Some(mut stdin) = stdin_guard.take() { + let _ = stdin.shutdown().await; + } + Ok(()) + } + + /// Convert to a stream of output lines. + pub fn into_stream(self) -> impl Stream<Item = OutputLine> { + futures::stream::unfold(self.output_rx, |mut rx| async move { + rx.recv().await.map(|line| (line, rx)) + }) + } +} + +/// Manages Claude Code process spawning. +pub struct ProcessManager { + /// Path to the claude command. + claude_command: String, + /// Additional arguments to pass to Claude Code (after defaults). + claude_args: Vec<String>, + /// Arguments to pass before defaults. + claude_pre_args: Vec<String>, + /// Whether to enable Claude's permission system (skip --dangerously-skip-permissions). + enable_permissions: bool, + /// Whether to disable verbose output. + disable_verbose: bool, + /// Default environment variables to pass. + default_env: HashMap<String, String>, +} + +impl Default for ProcessManager { + fn default() -> Self { + Self::new() + } +} + +impl ProcessManager { + /// Create a new ProcessManager with default settings. + pub fn new() -> Self { + Self { + claude_command: "claude".to_string(), + claude_args: Vec::new(), + claude_pre_args: Vec::new(), + enable_permissions: false, + disable_verbose: false, + default_env: HashMap::new(), + } + } + + /// Create a ProcessManager with a custom claude command path. + pub fn with_command(command: String) -> Self { + Self { + claude_command: command, + claude_args: Vec::new(), + claude_pre_args: Vec::new(), + enable_permissions: false, + disable_verbose: false, + default_env: HashMap::new(), + } + } + + /// Set additional arguments to pass after default arguments. + pub fn with_args(mut self, args: Vec<String>) -> Self { + self.claude_args = args; + self + } + + /// Set arguments to pass before default arguments. + pub fn with_pre_args(mut self, args: Vec<String>) -> Self { + self.claude_pre_args = args; + self + } + + /// Enable Claude's permission system (don't pass --dangerously-skip-permissions). + pub fn with_permissions_enabled(mut self, enabled: bool) -> Self { + self.enable_permissions = enabled; + self + } + + /// Disable verbose output. + pub fn with_verbose_disabled(mut self, disabled: bool) -> Self { + self.disable_verbose = disabled; + self + } + + /// Add default environment variables. + pub fn with_env(mut self, env: HashMap<String, String>) -> Self { + self.default_env = env; + self + } + + /// Get the claude command path. + pub fn claude_command(&self) -> &str { + &self.claude_command + } + + /// Spawn a Claude Code process to execute a plan. + /// + /// The process runs in the specified working directory with stream-json output format. + /// If `system_prompt` is provided, it will be passed via --system-prompt flag. + pub async fn spawn( + &self, + working_dir: &Path, + plan: &str, + extra_env: Option<HashMap<String, String>>, + ) -> Result<ClaudeProcess, ClaudeProcessError> { + self.spawn_with_system_prompt(working_dir, plan, extra_env, None).await + } + + /// Spawn a Claude Code process with an optional system prompt. + /// + /// The process runs in the specified working directory with stream-json output format. + /// If `system_prompt` is provided, it will be passed via --system-prompt flag as + /// behavioral constraints that Claude will treat as system-level instructions. + pub async fn spawn_with_system_prompt( + &self, + working_dir: &Path, + plan: &str, + extra_env: Option<HashMap<String, String>>, + system_prompt: Option<&str>, + ) -> Result<ClaudeProcess, ClaudeProcessError> { + tracing::info!( + working_dir = %working_dir.display(), + plan_len = plan.len(), + plan_preview = %if plan.len() > 200 { &plan[..200] } else { plan }, + has_system_prompt = system_prompt.is_some(), + "Spawning Claude Code process" + ); + + // Verify working directory exists + if !working_dir.exists() { + tracing::error!(working_dir = %working_dir.display(), "Working directory does not exist!"); + return Err(ClaudeProcessError::SpawnFailed(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("Working directory does not exist: {}", working_dir.display()), + ))); + } + + // Build environment + let mut env = self.default_env.clone(); + if let Some(extra) = extra_env { + env.extend(extra); + } + + // Build arguments list + let mut args = Vec::new(); + + // Pre-args (before defaults) + args.extend(self.claude_pre_args.clone()); + + // Required arguments for stream-json protocol + args.push("--output-format=stream-json".to_string()); + args.push("--input-format=stream-json".to_string()); + + // Optional default arguments + if !self.disable_verbose { + args.push("--verbose".to_string()); + } + if !self.enable_permissions { + args.push("--dangerously-skip-permissions".to_string()); + } + + // System prompt - passed via --system-prompt flag for system-level constraints + if let Some(prompt) = system_prompt { + args.push("--system-prompt".to_string()); + args.push(prompt.to_string()); + } + + // Additional user-configured arguments + args.extend(self.claude_args.clone()); + + tracing::debug!(args = ?args, "Claude command arguments"); + + // Spawn the process + let mut child = Command::new(&self.claude_command) + .args(&args) + .current_dir(working_dir) + .envs(env) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + ClaudeProcessError::CommandNotFound(self.claude_command.clone()) + } else { + ClaudeProcessError::SpawnFailed(e) + } + })?; + + // Create output channel + let (tx, rx) = mpsc::channel(1000); + + // Take stdout, stderr, and stdin + // With --input-format=stream-json, we keep stdin open for sending messages + let stdin = child.stdin.take(); + let stdin = Arc::new(Mutex::new(stdin)); + + let stdout = child.stdout.take().expect("stdout should be piped"); + let stderr = child.stderr.take().expect("stderr should be piped"); + + // Spawn task to read stdout + let tx_stdout = tx.clone(); + tokio::spawn(async move { + use tokio::io::AsyncReadExt; + let mut reader = BufReader::new(stdout); + let mut buffer = vec![0u8; 4096]; + let mut line_buffer = String::new(); + + loop { + // Try to read with a timeout to detect if we're stuck + match tokio::time::timeout( + tokio::time::Duration::from_secs(5), + reader.read(&mut buffer) + ).await { + Ok(Ok(0)) => { + // EOF + tracing::debug!("Claude stdout EOF"); + // Send any remaining content + if !line_buffer.is_empty() { + let _ = tx_stdout.send(OutputLine::stdout(line_buffer)).await; + } + break; + } + Ok(Ok(n)) => { + let chunk = String::from_utf8_lossy(&buffer[..n]); + tracing::debug!(bytes = n, chunk_preview = %if chunk.len() > 100 { &chunk[..100] } else { &chunk }, "Got stdout chunk from Claude"); + + // Accumulate into line buffer and emit complete lines + line_buffer.push_str(&chunk); + while let Some(newline_pos) = line_buffer.find('\n') { + let line = line_buffer[..newline_pos].to_string(); + line_buffer = line_buffer[newline_pos + 1..].to_string(); + if tx_stdout.send(OutputLine::stdout(line)).await.is_err() { + return; + } + } + } + Ok(Err(e)) => { + tracing::error!(error = %e, "Error reading Claude stdout"); + break; + } + Err(_) => { + // Timeout - no data for 5 seconds + tracing::warn!("No stdout data from Claude for 5 seconds"); + } + } + } + tracing::debug!("Claude stdout reader task ended"); + }); + + // Spawn task to read stderr + let tx_stderr = tx; + tokio::spawn(async move { + let reader = BufReader::new(stderr); + let mut lines = reader.lines(); + while let Ok(Some(line)) = lines.next_line().await { + tracing::debug!(line = %line, "Claude stderr"); + if tx_stderr.send(OutputLine::stderr(line)).await.is_err() { + break; + } + } + tracing::debug!("Claude stderr reader task ended"); + }); + + tracing::info!("Claude Code process spawned successfully"); + + let process = ClaudeProcess { + child, + output_rx: rx, + stdin, + }; + + // Send the initial plan as the first user message + tracing::info!(plan_len = plan.len(), "Sending initial plan to Claude via stdin"); + process.send_user_message(plan).await?; + + Ok(process) + } + + /// Check if the claude command is available. + pub async fn check_claude_available(&self) -> Result<String, ClaudeProcessError> { + let output = Command::new(&self.claude_command) + .arg("--version") + .output() + .await + .map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + ClaudeProcessError::CommandNotFound(self.claude_command.clone()) + } else { + ClaudeProcessError::SpawnFailed(e) + } + })?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + Err(ClaudeProcessError::CommandNotFound( + self.claude_command.clone(), + )) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_json_type() { + assert_eq!( + extract_json_type(r#"{"type":"system","subtype":"init"}"#), + Some("system".to_string()) + ); + assert_eq!( + extract_json_type(r#"{"type":"assistant","message":{}}"#), + Some("assistant".to_string()) + ); + assert_eq!(extract_json_type("not json"), None); + assert_eq!(extract_json_type(r#"{"no_type": true}"#), None); + } + + #[test] + fn test_output_line_creation() { + let line = OutputLine::stdout(r#"{"type":"result"}"#.to_string()); + assert!(line.is_stdout); + assert_eq!(line.json_type, Some("result".to_string())); + + let line = OutputLine::stderr("error message".to_string()); + assert!(!line.is_stdout); + assert_eq!(line.json_type, None); + } +} diff --git a/makima/src/daemon/process/claude_protocol.rs b/makima/src/daemon/process/claude_protocol.rs new file mode 100644 index 0000000..96e5377 --- /dev/null +++ b/makima/src/daemon/process/claude_protocol.rs @@ -0,0 +1,59 @@ +//! Claude Code JSON protocol types for stdin communication. +//! +//! When using `--input-format=stream-json`, Claude Code expects +//! newline-delimited JSON messages on stdin. + +use serde::Serialize; + +/// Message sent to Claude Code via stdin. +/// +/// Format based on Claude Code's stream-json input protocol. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ClaudeInputMessage { + /// A user message to send to Claude. + User { message: UserMessage }, +} + +/// The inner user message structure. +#[derive(Debug, Clone, Serialize)] +pub struct UserMessage { + /// Always "user" for user messages. + pub role: String, + /// The message content. + pub content: String, +} + +impl ClaudeInputMessage { + /// Create a new user message. + pub fn user(content: impl Into<String>) -> Self { + Self::User { + message: UserMessage { + role: "user".to_string(), + content: content.into(), + }, + } + } + + /// Serialize to a JSON string with trailing newline (NDJSON format). + pub fn to_json_line(&self) -> Result<String, serde_json::Error> { + let mut json = serde_json::to_string(self)?; + json.push('\n'); + Ok(json) + } +} + +#[cfg(test)] +mod tests { + use crate::daemon::*; + + #[test] + fn test_user_message_serialization() { + let msg = ClaudeInputMessage::user("Hello, Claude!"); + let json = msg.to_json_line().unwrap(); + + // Should produce: {"type":"user","message":{"role":"user","content":"Hello, Claude!"}}\n + assert!(json.starts_with(r#"{"type":"user","message":{"role":"user","content":"Hello, Claude!"}}"#)); + assert!(json.ends_with('\n')); + } +} diff --git a/makima/src/daemon/process/mod.rs b/makima/src/daemon/process/mod.rs new file mode 100644 index 0000000..814a3c5 --- /dev/null +++ b/makima/src/daemon/process/mod.rs @@ -0,0 +1,10 @@ +//! Process management for Claude Code subprocess execution. +//! +//! Spawns and manages Claude Code processes in worktree directories, +//! streaming JSON output back to the daemon. + +mod claude; +mod claude_protocol; + +pub use claude::{ClaudeProcess, ClaudeProcessError, OutputLine, ProcessManager}; +pub use claude_protocol::ClaudeInputMessage; diff --git a/makima/src/daemon/task/manager.rs b/makima/src/daemon/task/manager.rs new file mode 100644 index 0000000..8269083 --- /dev/null +++ b/makima/src/daemon/task/manager.rs @@ -0,0 +1,3215 @@ +//! Task lifecycle manager using git worktrees and Claude Code subprocesses. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Instant; + +use rand::Rng; +use tokio::io::AsyncWriteExt; +use tokio::sync::{mpsc, RwLock, Semaphore}; +use uuid::Uuid; + +use std::collections::HashSet; + +use super::state::TaskState; +use crate::daemon::error::{DaemonError, TaskError, TaskResult}; +use crate::daemon::process::{ClaudeInputMessage, ProcessManager}; +use crate::daemon::temp::TempManager; +use crate::daemon::worktree::{is_new_repo_request, ConflictResolution, WorktreeInfo, WorktreeManager}; +use crate::daemon::ws::{BranchInfo, DaemonCommand, DaemonMessage}; + +/// Generate a secure random API key for orchestrator tool access. +fn generate_tool_key() -> String { + let mut rng = rand::thread_rng(); + let bytes: [u8; 32] = rng.r#gen(); + hex::encode(bytes) +} + +/// Check if output contains an OAuth authentication error. +fn is_oauth_auth_error(output: &str) -> bool { + // Match various authentication error patterns from Claude Code + if output.contains("Please run /login") { + return true; + } + if output.contains("Invalid API key") { + return true; + } + if output.contains("authentication_error") + && (output.contains("OAuth token has expired") + || output.contains("Please obtain a new token")) + { + return true; + } + false +} + +/// Extract OAuth URL from text (looks for claude.ai OAuth URLs). +fn extract_url(text: &str) -> Option<String> { + // Look for claude.ai OAuth URLs - try multiple patterns + let patterns = [ + "https://claude.ai/oauth", + "https://console.anthropic.com/oauth", + ]; + + for pattern in patterns { + if let Some(start) = text.find(pattern) { + let remaining = &text[start..]; + // Find the end of the URL - stop at: + // - Whitespace, common URL terminators, escape sequences + let end = remaining + .find(|c: char| { + c.is_whitespace() || c == '"' || c == '\'' || c == '>' || c == ')' || c == ']' || c == '\x07' || c == '\x1b' + }) + .unwrap_or(remaining.len()); + + let url = &remaining[..end]; + + // Also check if there's another https:// within the match (hyperlink duplication) + // Skip first 20 chars to avoid matching the start + let url = if url.len() > 30 { + if let Some(second_https) = url[20..].find("https://") { + &url[..second_https + 20] // Keep only first URL + } else { + url + } + } else { + url + }; + + if url.len() > 20 { + return Some(url.to_string()); + } + } + } + None +} + +/// Global storage for pending OAuth flow (only one can be active at a time per daemon) +static PENDING_AUTH_FLOW: std::sync::OnceLock<std::sync::Mutex<Option<std::sync::mpsc::Sender<String>>>> = std::sync::OnceLock::new(); + +fn get_auth_flow_storage() -> &'static std::sync::Mutex<Option<std::sync::mpsc::Sender<String>>> { + PENDING_AUTH_FLOW.get_or_init(|| std::sync::Mutex::new(None)) +} + +/// Send an auth code to the pending OAuth flow. +pub fn send_auth_code(code: &str) -> bool { + let storage = get_auth_flow_storage(); + if let Ok(mut guard) = storage.lock() { + if let Some(sender) = guard.take() { + if sender.send(code.to_string()).is_ok() { + tracing::info!("Auth code sent to setup-token process"); + return true; + } + } + } + tracing::warn!("No pending auth flow to send code to"); + false +} + +/// Spawn `claude setup-token` to initiate OAuth flow and capture the login URL. +/// This spawns the process in a PTY (required by Ink) and reads output until we find a URL. +/// The process continues running in the background waiting for auth completion. +async fn get_oauth_login_url(claude_command: &str) -> Option<String> { + use portable_pty::{native_pty_system, CommandBuilder, PtySize}; + use std::io::{Read, Write}; + + tracing::info!("Spawning claude setup-token in PTY to get OAuth login URL"); + + // Create a PTY - Ink requires a real terminal + let pty_system = native_pty_system(); + let pair = match pty_system.openpty(PtySize { + rows: 24, + cols: 200, // Wide enough to avoid line wrapping for long URLs/codes + pixel_width: 0, + pixel_height: 0, + }) { + Ok(pair) => pair, + Err(e) => { + tracing::error!(error = %e, "Failed to open PTY"); + return None; + } + }; + + // Build the command + let mut cmd = CommandBuilder::new(claude_command); + cmd.arg("setup-token"); + // Set environment variables to prevent browser from opening and disable fancy output + // Use "false" so the browser command fails, forcing setup-token to show URL and wait for manual input + cmd.env("BROWSER", "false"); + cmd.env("TERM", "dumb"); // Disable hyperlinks and fancy terminal features + cmd.env("NO_COLOR", "1"); // Disable colors + + // Spawn the process in the PTY + let mut child = match pair.slave.spawn_command(cmd) { + Ok(child) => child, + Err(e) => { + tracing::error!(error = %e, "Failed to spawn claude setup-token in PTY"); + return None; + } + }; + + // Get the reader and writer from the master side + let mut reader = match pair.master.try_clone_reader() { + Ok(reader) => reader, + Err(e) => { + tracing::error!(error = %e, "Failed to clone PTY reader"); + return None; + } + }; + + let mut writer = match pair.master.take_writer() { + Ok(writer) => writer, + Err(e) => { + tracing::error!(error = %e, "Failed to take PTY writer"); + return None; + } + }; + + // Create channels for communication + let (code_tx, code_rx) = std::sync::mpsc::channel::<String>(); + let (url_tx, url_rx) = std::sync::mpsc::channel::<String>(); + + // Store the code sender globally so it can be used when AUTH_CODE message arrives + { + let storage = get_auth_flow_storage(); + if let Ok(mut guard) = storage.lock() { + *guard = Some(code_tx); + } + } + + // Spawn reader thread - reads PTY output and sends URL when found + let reader_handle = std::thread::spawn(move || { + let mut buffer = [0u8; 4096]; + let mut accumulated = String::new(); + let mut url_sent = false; + let mut read_count = 0; + + tracing::info!("setup-token reader thread started"); + + loop { + match reader.read(&mut buffer) { + Ok(0) => { + tracing::info!("setup-token PTY EOF reached after {} reads", read_count); + break; + } + Ok(n) => { + read_count += 1; + let chunk = String::from_utf8_lossy(&buffer[..n]); + accumulated.push_str(&chunk); + + // Process complete lines + while let Some(newline_pos) = accumulated.find('\n') { + let line = accumulated[..newline_pos].to_string(); + accumulated = accumulated[newline_pos + 1..].to_string(); + + let clean_line = strip_ansi_codes(&line); + if !clean_line.trim().is_empty() { + tracing::info!(line = %clean_line, "setup-token output"); + } + + // Look for OAuth URL if not found yet + if !url_sent { + if let Some(url) = extract_url(&line) { + tracing::info!(url = %url, "Found OAuth login URL"); + let _ = url_tx.send(url); + url_sent = true; + } + } + + // Check for success/failure messages + if clean_line.contains("successfully") || clean_line.contains("authenticated") || clean_line.contains("Success") { + tracing::info!("Authentication appears successful!"); + } + if clean_line.contains("error") || clean_line.contains("failed") || clean_line.contains("invalid") { + tracing::warn!(line = %clean_line, "setup-token may have encountered an error"); + } + } + } + Err(e) => { + tracing::warn!(error = %e, "PTY read error after {} reads", read_count); + break; + } + } + } + tracing::info!("setup-token reader thread ended"); + }); + + // Spawn writer thread - waits for auth code and writes it to PTY + std::thread::spawn(move || { + tracing::info!("setup-token writer thread started, waiting for auth code (10 min timeout)"); + + // Wait for auth code from frontend (with long timeout - user needs time to authenticate) + match code_rx.recv_timeout(std::time::Duration::from_secs(600)) { + Ok(code) => { + tracing::info!(code_len = code.len(), "Received auth code from frontend, writing to PTY"); + // Write code followed by carriage return (Enter key in raw terminal mode) + let code_with_enter = format!("{}\r", code); + if let Err(e) = writer.write_all(code_with_enter.as_bytes()) { + tracing::error!(error = %e, "Failed to write auth code to PTY"); + } else if let Err(e) = writer.flush() { + tracing::error!(error = %e, "Failed to flush PTY writer"); + } else { + tracing::info!("Auth code written to setup-token PTY successfully"); + // Give Ink a moment to process, then send another Enter in case first was buffered + std::thread::sleep(std::time::Duration::from_millis(100)); + let _ = writer.write_all(b"\r"); + let _ = writer.flush(); + tracing::info!("Sent additional Enter keypress"); + } + } + Err(e) => { + tracing::info!(error = %e, "Auth code receive ended (timeout or channel closed)"); + } + } + + // Wait for reader thread to finish + tracing::debug!("Waiting for reader thread to finish..."); + let _ = reader_handle.join(); + + // Wait for child to fully exit + tracing::debug!("Waiting for setup-token child process to exit..."); + match child.wait() { + Ok(status) => { + tracing::info!(exit_status = ?status, "setup-token process exited"); + } + Err(e) => { + tracing::error!(error = %e, "Failed to wait for setup-token process"); + } + } + }); + + // Wait for URL with timeout + match url_rx.recv_timeout(std::time::Duration::from_secs(30)) { + Ok(url) => Some(url), + Err(e) => { + tracing::error!(error = %e, "Timed out waiting for OAuth login URL"); + None + } + } +} + +/// Strip ANSI escape codes from a string for cleaner logging. +fn strip_ansi_codes(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + + while let Some(c) = chars.next() { + if c == '\x1b' { + // Check what type of escape sequence + match chars.peek() { + Some(&'[') => { + // CSI sequence: ESC [ ... letter + chars.next(); // consume '[' + while let Some(&next) = chars.peek() { + chars.next(); + if next.is_ascii_alphabetic() { + break; + } + } + } + Some(&']') => { + // OSC sequence: ESC ] ... ST (where ST is BEL or ESC \) + chars.next(); // consume ']' + while let Some(&next) = chars.peek() { + if next == '\x07' { + chars.next(); // consume BEL (string terminator) + break; + } + if next == '\x1b' { + chars.next(); // consume ESC + if chars.peek() == Some(&'\\') { + chars.next(); // consume \ (string terminator) + } + break; + } + chars.next(); + } + } + _ => { + // Unknown escape, skip just the ESC + } + } + } else if !c.is_control() || c == '\n' { + result.push(c); + } + } + + result +} + +/// System prompt for regular (non-orchestrator) subtasks. +/// This ensures subtasks work only within their isolated worktree directory. +const SUBTASK_SYSTEM_PROMPT: &str = r#"You are working in an isolated worktree directory that contains a snapshot of the codebase. + +## IMPORTANT: Directory Restrictions + +**You MUST only work within the current working directory (your worktree).** + +- DO NOT use `cd` to navigate to directories outside your worktree +- DO NOT use absolute paths that point outside your worktree (e.g., don't write to ~/some/path, /tmp, or the original repository) +- DO NOT modify files in parent directories or sibling directories +- All your file operations should be relative to the current directory + +Your working directory is your sandboxed workspace. When you complete your task, your changes will be reviewed and integrated by the orchestrator. + +**Why?** Your worktree is isolated so that: +1. Your changes don't affect other running tasks +2. Changes can be reviewed before integration +3. Multiple tasks can work on the codebase in parallel without conflicts + +--- + +"#; + +/// The orchestrator system prompt that tells Claude how to use the helper script. +const ORCHESTRATOR_SYSTEM_PROMPT: &str = r#"You are an orchestrator task. Your job is to coordinate subtasks and integrate their work, NOT to write code directly. + +## FIRST STEP + +Start by checking if you have existing subtasks: + +```bash +# List all subtasks to see what work needs to be done +./.makima/orchestrate.sh list +``` + +If subtasks exist, start them. If you need additional subtasks or no subtasks exist yet, you can create them. + +--- + +## Creating Subtasks + +You can create new subtasks to break down work: + +```bash +# Create a new subtask with a name and plan +./.makima/orchestrate.sh create "Subtask Name" "Detailed plan for what the subtask should do..." + +# The command returns the new subtask ID - use it to start the subtask +./.makima/orchestrate.sh start <new_subtask_id> +``` + +Create subtasks when you need to: +- Break down complex work into smaller pieces +- Run multiple tasks in parallel on different parts of the codebase +- Delegate specific implementation work + +## Task Continuation (Sequential Dependencies) + +When subtasks need to build on each other's work (e.g., Task B depends on Task A's changes), use `--continue-from`: + +```bash +# Create Task B that continues from Task A's worktree +./.makima/orchestrate.sh create "Task B" "Build on Task A's work..." --continue-from <task_a_id> +``` + +This copies all files from Task A's worktree into Task B's worktree, so Task B starts with Task A's changes. + +**When to use continuation:** +- Sequential work: Task B needs Task A's output files +- Staged implementation: Building features incrementally +- Fix-and-extend: One task fixes issues, another adds features on top + +**When NOT to use continuation:** +- Parallel tasks working on different files +- Independent subtasks that can be merged separately + +**Important for merging:** When tasks continue from each other, only merge the FINAL task in the chain. Earlier tasks' changes are already included in later tasks' worktrees. + +## Sharing Files with Subtasks + +Use `--files` to copy specific files from your orchestrator worktree to subtasks. This is useful for sharing plans, configs, or data files: + +```bash +# Create subtask with specific files copied from orchestrator +./.makima/orchestrate.sh create "Implement Feature" "Follow the plan in PLAN.md" --files "PLAN.md" + +# Copy multiple files (comma-separated) +./.makima/orchestrate.sh create "API Work" "Use the spec..." --files "PLAN.md,api-spec.yaml,types.ts" + +# Combine with --continue-from to share files AND continue from another task +./.makima/orchestrate.sh create "Step 2" "Continue..." --continue-from <task_a_id> --files "requirements.md" +``` + +**Use cases for --files:** +- Share a PLAN.md with detailed implementation steps +- Distribute configuration or spec files +- Pass generated data or intermediate results + +## How Subtasks Work + +Each subtask runs in its own **worktree** - a separate directory with a copy of the codebase. When subtasks complete: +- Their work remains in the worktree files (NOT committed to git) +- **Subtasks do NOT auto-merge** - YOU must integrate their work into your worktree +- You can view and copy files from subtask worktrees using their paths +- The worktree path is returned when you get subtask status + +**IMPORTANT:** Subtasks never create PRs or merge to the target repository. Only the orchestrator (you) can trigger completion actions like PR creation or merging after integrating all subtask work. + +## Subtask Commands +```bash +# List all subtasks and their current status +./.makima/orchestrate.sh list + +# Create a new subtask (returns the subtask ID) +./.makima/orchestrate.sh create "Name" "Plan/description" + +# Create a subtask that continues from another task's worktree +./.makima/orchestrate.sh create "Name" "Plan" --continue-from <other_task_id> + +# Create a subtask with specific files copied from orchestrator worktree +./.makima/orchestrate.sh create "Name" "Plan" --files "file1.md,file2.yaml" + +# Start a specific subtask (it will run in its own Claude instance) +./.makima/orchestrate.sh start <subtask_id> + +# Stop a running subtask +./.makima/orchestrate.sh stop <subtask_id> + +# Get detailed status of a subtask (includes worktree_path when available) +./.makima/orchestrate.sh status <subtask_id> + +# Get the output/logs of a subtask +./.makima/orchestrate.sh output <subtask_id> + +# Get the worktree path for a subtask +./.makima/orchestrate.sh worktree <subtask_id> +``` + +## Integrating Subtask Work + +When subtasks complete, their changes exist as files in their worktree directories: +- Files are NOT committed to git branches +- You must copy/integrate files from subtask worktrees into your worktree +- Use standard file operations (cp, cat, etc.) to review and integrate changes + +### Handling Continuation Chains + +**CRITICAL:** When subtasks use `--continue-from`, they form a chain where each task includes all changes from previous tasks. You must ONLY integrate the FINAL task in each chain. + +Example chain: Task A → Task B (continues from A) → Task C (continues from B) +- Task C's worktree contains ALL changes from A, B, and C +- You should ONLY integrate Task C's worktree +- DO NOT integrate Task A or Task B separately (their changes are already in C) + +**How to track continuation chains:** +1. When you create tasks with `--continue-from`, note which task continues from which +2. Build a mental model: Independent tasks (no continuation) + Continuation chains +3. For each chain, only integrate the LAST task in the chain + +**Example with mixed independent and chained tasks:** +``` +Independent tasks (integrate all): +- Task X: API endpoints +- Task Y: Database models + +Continuation chain (integrate ONLY the last one): +- Task A: Core feature → Task B: Tests (continues from A) → Task C: Docs (continues from B) + Only integrate Task C! +``` + +### Integration Examples + +For independent subtasks (no continuation): +```bash +# Get the worktree path for a completed subtask +SUBTASK_PATH=$(./.makima/orchestrate.sh worktree <subtask_id>) + +# View what files were changed +ls -la "$SUBTASK_PATH" +diff -r . "$SUBTASK_PATH" --exclude=.git --exclude=.makima + +# Copy specific files from subtask +cp "$SUBTASK_PATH/src/new_file.rs" ./src/ +cp "$SUBTASK_PATH/src/modified_file.rs" ./src/ + +# Or use diff/patch for more control +diff -u ./src/file.rs "$SUBTASK_PATH/src/file.rs" > changes.patch +patch -p0 < changes.patch +``` + +For continuation chains (only integrate the final task): +```bash +# If you have: Task A → Task B → Task C (each continues from previous) +# ONLY get and integrate Task C's worktree - it has everything! + +FINAL_TASK_PATH=$(./.makima/orchestrate.sh worktree <task_c_id>) + +# Copy all changes from the final task +rsync -av --exclude='.git' --exclude='.makima' "$FINAL_TASK_PATH/" ./ +``` + +## Completion +```bash +# Mark yourself as complete after integrating all subtask work +./.makima/orchestrate.sh done "Summary of what was accomplished" +``` + +## Workflow +1. **List existing subtasks**: Run `list` to see current subtasks +2. **Create subtasks if needed**: Use `create` to add new subtasks for the work + - For independent parallel work: create without `--continue-from` + - For sequential dependencies: use `--continue-from <previous_task_id>` + - Track which tasks continue from which (continuation chains) +3. **Start subtasks**: Run `start` for each subtask +4. **Monitor progress**: Check status and output as subtasks run +5. **Integrate work**: When subtasks complete: + - For independent tasks: integrate each one's worktree + - For continuation chains: ONLY integrate the FINAL task (it has all changes) + - Get worktree path with `worktree <subtask_id>` + - Copy or merge files into your worktree +6. **Complete**: Call `done` once all work is integrated + +## Important Notes +- Subtask files are in worktrees, NOT committed git branches +- **Subtasks do NOT auto-merge or create PRs** - you must integrate their work +- You can read files from subtask worktrees using their paths +- Use standard file tools (cp, diff, cat, rsync) to integrate changes +- You should NOT edit files directly - that's what subtasks are for +- DO NOT DO THE SUBTASKS' WORK! Your only job is to coordinate, not implement. +- When you call `done`, YOUR worktree may be used for the final PR/merge +"#; + + +/// System prompt for supervisor tasks (contract orchestrators). +/// Supervisors monitor all tasks in a contract, create new tasks, and drive the contract to completion. +const SUPERVISOR_SYSTEM_PROMPT: &str = r#"You are the SUPERVISOR for this contract. Your ONLY job is to coordinate work by spawning tasks, waiting for them to complete, and managing git operations. + +## CRITICAL RULES - READ CAREFULLY + +1. **NEVER write code or edit files yourself** - you are a coordinator ONLY +2. **NEVER make commits yourself** - tasks do their own commits +3. **ALWAYS spawn tasks** for ANY work that involves: + - Writing or editing code + - Creating or modifying files + - Making implementation changes + - Any actual development work +4. **ALWAYS wait for tasks to complete** - you MUST use `wait` after spawning +5. **Your role is ONLY to**: + - Analyze the contract goal and break it into tasks + - Spawn tasks AND wait for them to complete + - Review completed task results + - Merge completed work using `merge` + - Create PRs when ready using `pr` + +## REQUIRED WORKFLOW - Follow This Pattern + +For EVERY task you spawn, you MUST: +1. Spawn the task with `spawn` +2. IMMEDIATELY call `wait` to block until completion +3. Check the result and handle success/failure +4. Merge if successful + +```bash +# CORRECT PATTERN - spawn then wait +RESULT=$(makima supervisor spawn "Task Name" "Detailed plan...") +TASK_ID=$(echo "$RESULT" | jq -r '.taskId') +echo "Spawned task: $TASK_ID" + +# MUST wait for the task - DO NOT skip this step! +makima supervisor wait "$TASK_ID" + +# Check result, view diff, merge if successful +makima supervisor diff "$TASK_ID" +makima supervisor merge "$TASK_ID" +``` + +## Example - Full Workflow + +Goal: "Add user authentication" + +```bash +# Step 1: Create a makima branch for this work (use makima/{name} convention) +makima supervisor branch "makima/user-authentication" + +# Step 2: Spawn tasks, wait for each, and merge to the branch + +# Task 1: Research (spawn and wait) +RESULT=$(makima supervisor spawn "Research auth patterns" "Explore the codebase for existing authentication. Document findings.") +TASK_ID=$(echo "$RESULT" | jq -r '.taskId') +makima supervisor wait "$TASK_ID" +# Review findings before continuing + +# Task 2: Login endpoint (spawn and wait) +RESULT=$(makima supervisor spawn "Implement login" "Create POST /api/login endpoint...") +TASK_ID=$(echo "$RESULT" | jq -r '.taskId') +makima supervisor wait "$TASK_ID" +makima supervisor diff "$TASK_ID" +makima supervisor merge "$TASK_ID" --to "makima/user-authentication" + +# Task 3: Logout endpoint (spawn and wait) +RESULT=$(makima supervisor spawn "Implement logout" "Create POST /api/logout endpoint...") +TASK_ID=$(echo "$RESULT" | jq -r '.taskId') +makima supervisor wait "$TASK_ID" +makima supervisor merge "$TASK_ID" --to "makima/user-authentication" + +# Step 3: All tasks complete - create PR from makima branch +makima supervisor pr "makima/user-authentication" --title "Add user authentication" --base main +``` + +## Available Tools (via makima supervisor) + +### Task Management +```bash +# List all tasks in this contract +makima supervisor tasks + +# Spawn a new task (returns JSON with taskId) +makima supervisor spawn "Task Name" "Detailed plan..." + +# IMPORTANT: Wait for task to complete (blocks until done/failed) +makima supervisor wait <task_id> [timeout_seconds] + +# Read a file from any task's worktree +makima supervisor read-file <task_id> <file_path> + +# Get the full task tree structure +makima supervisor tree +``` + +### Git Operations +```bash +# Create a new branch +makima supervisor branch <branch_name> [--from <task_id|sha>] + +# Merge a task's changes to a branch +makima supervisor merge <task_id> [--to <branch>] [--squash] + +# Create a pull request +makima supervisor pr <task_id> --title "Title" [--body "Body"] [--base main] + +# View a task's diff +makima supervisor diff <task_id> + +# Create a git checkpoint +makima supervisor checkpoint "Checkpoint message" + +# List checkpoints for a task +makima supervisor checkpoints [task_id] +``` + +### Contract +```bash +# Get contract status +makima supervisor status +``` + +## Key Points + +1. **Create a makima branch first** - use `branch "makima/{name}"` for the contract's work +2. **spawn returns immediately** - the task runs in the background +3. **wait blocks until complete** - you MUST call this to know when a task finishes +4. **Never fire-and-forget** - always wait for each task before moving on +5. **Merge to your makima branch** - use `merge <task_id> --to "makima/{name}"` to collect completed work +6. **Create PR when done** - use `pr "makima/{name}" --title "..." --base main` + +## Standard Workflow + +1. `branch "makima/{name}"` - Create branch (e.g., "makima/add-auth") +2. For each piece of work: + - `spawn` - Create task + - `wait` - Block until complete + - `merge --to "makima/{name}"` - Merge to branch +3. `pr "makima/{name}" --title "..." --base main` - Create PR + +## Important Reminders + +- **ONLY YOU can spawn tasks** - regular tasks cannot create children +- **NEVER implement anything yourself** - always spawn tasks +- **ALWAYS create a makima branch** - use `makima/{name}` naming convention +- Tasks run independently - you just coordinate +- You will be resumed if interrupted - your conversation is preserved +- Create checkpoints before major transitions + +--- + +"#; + +/// System prompt for tasks that are part of a contract. +/// This tells the task about contract.sh and how to use it to interact with the contract. +const CONTRACT_INTEGRATION_PROMPT: &str = r##" +## Contract Integration + +This task is part of a contract. You have access to contract tools via the `makima contract` CLI. + +### Contract Commands + +```bash +# Get contract context (name, phase, goals) +makima contract status + +# Get phase checklist and deliverables +makima contract checklist + +# List contract files +makima contract files + +# Read a specific file content +makima contract file <file_id> + +# Report progress to the contract +makima contract report "Completed X, working on Y..." + +# Create a new contract file (content via stdin) +echo "# New Documentation" | makima contract create-file "New Document" + +# Update an existing contract file (content via stdin) +cat updated_content.md | makima contract update-file <file_id> + +# Get suggested next action when done +makima contract suggest-action + +# Report completion with metrics +makima contract completion-action --files "file1.rs,file2.rs" --code +``` + +### What You Should Do + +**Before starting:** +1. Run `makima contract status` to understand the contract context +2. Run `makima contract checklist` to see phase deliverables +3. Run `makima contract files` to see existing documentation + +**While working:** +- Report significant progress with `makima contract report "..."` + +**When completing:** +1. If your work should be documented, create or update contract files +2. Run `makima contract completion-action` to see recommended next steps +3. Consider what contract files or phases might need updating + +**Important:** Your work should contribute to the contract's goals. Check the contract status to understand what's expected. + +--- + +"##; + +/// Tracks merge state for an orchestrator task. +#[derive(Default)] +struct MergeTracker { + /// Subtask branches that have been successfully merged. + merged_subtasks: HashSet<Uuid>, + /// Subtask branches that were explicitly skipped (with reason). + skipped_subtasks: HashMap<Uuid, String>, +} + +/// Managed task information. +#[derive(Clone)] +pub struct ManagedTask { + /// Task ID. + pub id: Uuid, + /// Human-readable task name. + pub task_name: String, + /// Current state. + pub state: TaskState, + /// Worktree info if created. + pub worktree: Option<WorktreeInfo>, + /// Task plan. + pub plan: String, + /// Repository URL or path. + pub repo_source: Option<String>, + /// Base branch. + pub base_branch: Option<String>, + /// Target branch to merge into. + pub target_branch: Option<String>, + /// Parent task ID if this is a subtask. + pub parent_task_id: Option<Uuid>, + /// Depth in task hierarchy (0=top-level, 1=subtask, 2=sub-subtask). + pub depth: i32, + /// Whether this task runs as an orchestrator (coordinates subtasks). + pub is_orchestrator: bool, + /// Whether this task is a supervisor (long-running contract orchestrator). + pub is_supervisor: bool, + /// Path to target repository for completion actions. + pub target_repo_path: Option<String>, + /// Completion action: "none", "branch", "merge", "pr". + pub completion_action: Option<String>, + /// Task ID to continue from (copy worktree from this task). + pub continue_from_task_id: Option<Uuid>, + /// Files to copy from parent task's worktree. + pub copy_files: Option<Vec<String>>, + /// Contract ID if this task is associated with a contract. + pub contract_id: Option<Uuid>, + /// Time task was created. + pub created_at: Instant, + /// Time task started running. + pub started_at: Option<Instant>, + /// Time task completed. + pub completed_at: Option<Instant>, + /// Error message if failed. + pub error: Option<String>, +} + +/// Configuration for task execution. +#[derive(Clone)] +pub struct TaskConfig { + /// Maximum concurrent tasks. + pub max_concurrent_tasks: u32, + /// Base directory for worktrees. + pub worktree_base_dir: PathBuf, + /// Environment variables to pass to Claude. + pub env_vars: HashMap<String, String>, + /// Claude command path. + pub claude_command: String, + /// Additional arguments to pass to Claude Code. + pub claude_args: Vec<String>, + /// Arguments to pass before defaults. + pub claude_pre_args: Vec<String>, + /// Enable Claude's permission system. + pub enable_permissions: bool, + /// Disable verbose output. + pub disable_verbose: bool, +} + +impl Default for TaskConfig { + fn default() -> Self { + Self { + max_concurrent_tasks: 4, + worktree_base_dir: WorktreeManager::default_base_dir(), + env_vars: HashMap::new(), + claude_command: "claude".to_string(), + claude_args: Vec::new(), + claude_pre_args: Vec::new(), + enable_permissions: false, + disable_verbose: false, + } + } +} + +/// Task manager for handling task lifecycle. +pub struct TaskManager { + /// Worktree manager. + worktree_manager: Arc<WorktreeManager>, + /// Process manager. + process_manager: Arc<ProcessManager>, + /// Temp directory manager. + temp_manager: Arc<TempManager>, + /// Task configuration. + #[allow(dead_code)] + config: TaskConfig, + /// Active tasks. + tasks: Arc<RwLock<HashMap<Uuid, ManagedTask>>>, + /// Channel to send messages to server. + ws_tx: mpsc::Sender<DaemonMessage>, + /// Semaphore for limiting concurrent tasks. + semaphore: Arc<Semaphore>, + /// Channels for sending input to running tasks. + /// Each sender allows sending messages to the stdin of a running Claude process. + task_inputs: Arc<RwLock<HashMap<Uuid, mpsc::Sender<String>>>>, + /// Tracks merge state per orchestrator task (for completion gate). + merge_trackers: Arc<RwLock<HashMap<Uuid, MergeTracker>>>, +} + +impl TaskManager { + /// Create a new task manager. + pub fn new(config: TaskConfig, ws_tx: mpsc::Sender<DaemonMessage>) -> Self { + let max_concurrent = config.max_concurrent_tasks as usize; + let worktree_manager = Arc::new(WorktreeManager::new(config.worktree_base_dir.clone())); + let process_manager = Arc::new( + ProcessManager::with_command(config.claude_command.clone()) + .with_args(config.claude_args.clone()) + .with_pre_args(config.claude_pre_args.clone()) + .with_permissions_enabled(config.enable_permissions) + .with_verbose_disabled(config.disable_verbose) + .with_env(config.env_vars.clone()), + ); + let temp_manager = Arc::new(TempManager::new()); + + Self { + worktree_manager, + process_manager, + temp_manager, + config, + tasks: Arc::new(RwLock::new(HashMap::new())), + ws_tx, + semaphore: Arc::new(Semaphore::new(max_concurrent)), + task_inputs: Arc::new(RwLock::new(HashMap::new())), + merge_trackers: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Handle a command from the server. + pub async fn handle_command(&self, command: DaemonCommand) -> Result<(), DaemonError> { + tracing::info!("Received command from server: {:?}", command); + + match command { + DaemonCommand::SpawnTask { + task_id, + task_name, + plan, + repo_url, + base_branch, + target_branch, + parent_task_id, + depth, + is_orchestrator, + target_repo_path, + completion_action, + continue_from_task_id, + copy_files, + contract_id, + is_supervisor, + } => { + tracing::info!( + task_id = %task_id, + task_name = %task_name, + repo_url = ?repo_url, + base_branch = ?base_branch, + target_branch = ?target_branch, + parent_task_id = ?parent_task_id, + depth = depth, + is_orchestrator = is_orchestrator, + is_supervisor = is_supervisor, + target_repo_path = ?target_repo_path, + completion_action = ?completion_action, + continue_from_task_id = ?continue_from_task_id, + copy_files = ?copy_files, + contract_id = ?contract_id, + plan_len = plan.len(), + "Spawning new task" + ); + self.spawn_task( + task_id, task_name, plan, repo_url, base_branch, target_branch, + parent_task_id, depth, is_orchestrator, is_supervisor, + target_repo_path, completion_action, continue_from_task_id, + copy_files, contract_id + ).await?; + } + DaemonCommand::PauseTask { task_id } => { + tracing::info!(task_id = %task_id, "Pause not supported for subprocess tasks"); + // Subprocesses don't support pause, just log and ignore + } + DaemonCommand::ResumeTask { task_id } => { + tracing::info!(task_id = %task_id, "Resume not supported for subprocess tasks"); + // Subprocesses don't support resume, just log and ignore + } + DaemonCommand::InterruptTask { task_id, graceful: _ } => { + tracing::info!(task_id = %task_id, "Interrupting task"); + self.interrupt_task(task_id).await?; + } + DaemonCommand::SendMessage { task_id, message } => { + // Check if this is an auth code message + if message.starts_with("AUTH_CODE:") { + let code = message.strip_prefix("AUTH_CODE:").unwrap_or("").trim(); + tracing::info!(task_id = %task_id, "Received auth code from frontend"); + if send_auth_code(code) { + tracing::info!(task_id = %task_id, "Auth code forwarded to setup-token"); + } else { + tracing::warn!(task_id = %task_id, "No pending auth flow to receive code"); + } + } else { + // Regular message - send to task's stdin + tracing::info!(task_id = %task_id, message_len = message.len(), "Sending message to task"); + // Send message to the task's stdin via the input channel + let inputs = self.task_inputs.read().await; + if let Some(sender) = inputs.get(&task_id) { + if let Err(e) = sender.send(message).await { + tracing::warn!(task_id = %task_id, error = %e, "Failed to send message to task input channel"); + } else { + tracing::info!(task_id = %task_id, "Message sent to task successfully"); + } + } else { + drop(inputs); // Release read lock before checking if we need to respawn + + // Check if this is a supervisor that needs to be respawned + let task_info = { + let tasks = self.tasks.read().await; + tasks.get(&task_id).cloned() + }; + + if let Some(task) = task_info { + if task.is_supervisor { + tracing::info!( + task_id = %task_id, + "Supervisor has no active Claude process, respawning with message" + ); + + // Respawn the supervisor with the new message as the plan + // Claude Code will use --continue to maintain conversation history + let inner = self.clone_inner(); + let task_name = task.task_name.clone(); + let repo_source = task.repo_source.clone(); + let base_branch = task.base_branch.clone(); + let target_branch = task.target_branch.clone(); + let target_repo_path = task.target_repo_path.clone(); + let completion_action = task.completion_action.clone(); + let contract_id = task.contract_id; + + // Spawn in background to not block the command handler + tokio::spawn(async move { + if let Err(e) = inner.run_task( + task_id, + task_name, + message, // Use the message as the new prompt + repo_source, + base_branch, + target_branch, + false, // is_orchestrator + true, // is_supervisor + target_repo_path, + completion_action, + None, // continue_from_task_id + None, // copy_files + contract_id, + ).await { + tracing::error!( + task_id = %task_id, + error = %e, + "Failed to respawn supervisor" + ); + } + }); + } else { + tracing::warn!(task_id = %task_id, "No input channel for task (task may not be running)"); + } + } else { + tracing::warn!(task_id = %task_id, "Task not found"); + } + } + } + } + DaemonCommand::InjectSiblingContext { task_id, .. } => { + tracing::debug!(task_id = %task_id, "Sibling context injection not supported for subprocess tasks"); + } + DaemonCommand::Authenticated { daemon_id } => { + tracing::debug!(daemon_id = %daemon_id, "Authenticated command (handled by WS client)"); + } + DaemonCommand::Error { code, message } => { + tracing::warn!(code = %code, message = %message, "Error command from server"); + } + + // ========================================================================= + // Merge Commands + // ========================================================================= + + DaemonCommand::ListBranches { task_id } => { + tracing::info!(task_id = %task_id, "Listing task branches"); + self.handle_list_branches(task_id).await?; + } + DaemonCommand::MergeStart { task_id, source_branch } => { + tracing::info!(task_id = %task_id, source_branch = %source_branch, "Starting merge"); + self.handle_merge_start(task_id, source_branch).await?; + } + DaemonCommand::MergeStatus { task_id } => { + tracing::info!(task_id = %task_id, "Getting merge status"); + self.handle_merge_status(task_id).await?; + } + DaemonCommand::MergeResolve { task_id, file, strategy } => { + tracing::info!(task_id = %task_id, file = %file, strategy = %strategy, "Resolving conflict"); + self.handle_merge_resolve(task_id, file, strategy).await?; + } + DaemonCommand::MergeCommit { task_id, message } => { + tracing::info!(task_id = %task_id, "Committing merge"); + self.handle_merge_commit(task_id, message).await?; + } + DaemonCommand::MergeAbort { task_id } => { + tracing::info!(task_id = %task_id, "Aborting merge"); + self.handle_merge_abort(task_id).await?; + } + DaemonCommand::MergeSkip { task_id, subtask_id, reason } => { + tracing::info!(task_id = %task_id, subtask_id = %subtask_id, reason = %reason, "Skipping subtask merge"); + self.handle_merge_skip(task_id, subtask_id, reason).await?; + } + DaemonCommand::CheckMergeComplete { task_id } => { + tracing::info!(task_id = %task_id, "Checking merge completion"); + self.handle_check_merge_complete(task_id).await?; + } + + // ========================================================================= + // Completion Action Commands + // ========================================================================= + + DaemonCommand::RetryCompletionAction { + task_id, + task_name, + action, + target_repo_path, + target_branch, + } => { + tracing::info!( + task_id = %task_id, + task_name = %task_name, + action = %action, + target_repo_path = %target_repo_path, + target_branch = ?target_branch, + "Retrying completion action" + ); + self.handle_retry_completion_action(task_id, task_name, action, target_repo_path, target_branch).await?; + } + + DaemonCommand::CloneWorktree { task_id, target_dir } => { + tracing::info!( + task_id = %task_id, + target_dir = %target_dir, + "Cloning worktree to target directory" + ); + self.handle_clone_worktree(task_id, target_dir).await?; + } + + DaemonCommand::CheckTargetExists { task_id, target_dir } => { + tracing::debug!( + task_id = %task_id, + target_dir = %target_dir, + "Checking if target directory exists" + ); + self.handle_check_target_exists(task_id, target_dir).await?; + } + + // ========================================================================= + // Contract File Commands + // ========================================================================= + + DaemonCommand::ReadRepoFile { + request_id, + contract_id, + file_path, + repo_path, + } => { + tracing::info!( + request_id = %request_id, + contract_id = %contract_id, + file_path = %file_path, + repo_path = %repo_path, + "Reading file from repository" + ); + self.handle_read_repo_file(request_id, file_path, repo_path).await?; + } + DaemonCommand::CreateBranch { + task_id, + branch_name, + from_ref, + } => { + tracing::info!( + task_id = %task_id, + branch_name = %branch_name, + from_ref = ?from_ref, + "Creating branch" + ); + self.handle_create_branch(task_id, branch_name, from_ref).await?; + } + DaemonCommand::MergeTaskToTarget { + task_id, + target_branch, + squash, + } => { + tracing::info!( + task_id = %task_id, + target_branch = ?target_branch, + squash = squash, + "Merging task to target branch" + ); + self.handle_merge_task_to_target(task_id, target_branch, squash).await?; + } + DaemonCommand::CreatePR { + task_id, + title, + body, + base_branch, + } => { + tracing::info!( + task_id = %task_id, + title = %title, + base_branch = %base_branch, + "Creating pull request" + ); + self.handle_create_pr(task_id, title, body, base_branch).await?; + } + DaemonCommand::GetTaskDiff { + task_id, + } => { + tracing::info!(task_id = %task_id, "Getting task diff"); + self.handle_get_task_diff(task_id).await?; + } + } + Ok(()) + } + + /// Spawn a new task. + #[allow(clippy::too_many_arguments)] + pub async fn spawn_task( + &self, + task_id: Uuid, + task_name: String, + plan: String, + repo_url: Option<String>, + base_branch: Option<String>, + target_branch: Option<String>, + parent_task_id: Option<Uuid>, + depth: i32, + is_orchestrator: bool, + is_supervisor: bool, + target_repo_path: Option<String>, + completion_action: Option<String>, + continue_from_task_id: Option<Uuid>, + copy_files: Option<Vec<String>>, + contract_id: Option<Uuid>, + ) -> TaskResult<()> { + tracing::info!(task_id = %task_id, is_orchestrator = is_orchestrator, is_supervisor = is_supervisor, depth = depth, "=== SPAWN_TASK START ==="); + + // Check if task already exists - allow re-spawning if in terminal state + { + let mut tasks = self.tasks.write().await; + if let Some(existing) = tasks.get(&task_id) { + if existing.state.is_terminal() { + // Task exists but is in terminal state (completed, failed, interrupted) + // Remove it so we can re-spawn + tracing::info!(task_id = %task_id, old_state = ?existing.state, "Removing terminated task to allow re-spawn"); + tasks.remove(&task_id); + } else { + // Task is still active, reject + tracing::warn!(task_id = %task_id, state = ?existing.state, "Task already exists and is active, rejecting spawn"); + return Err(TaskError::AlreadyExists(task_id)); + } + } + } + + // Acquire semaphore permit + tracing::info!(task_id = %task_id, "Acquiring concurrency permit..."); + let permit = self + .semaphore + .clone() + .try_acquire_owned() + .map_err(|_| { + tracing::warn!(task_id = %task_id, "Concurrency limit reached, cannot spawn task"); + TaskError::ConcurrencyLimit + })?; + tracing::info!(task_id = %task_id, "Concurrency permit acquired"); + + // Create task entry + tracing::info!(task_id = %task_id, "Creating task entry in state: Initializing"); + let task = ManagedTask { + id: task_id, + task_name: task_name.clone(), + state: TaskState::Initializing, + worktree: None, + plan: plan.clone(), + repo_source: repo_url.clone(), + base_branch: base_branch.clone(), + target_branch: target_branch.clone(), + parent_task_id, + depth, + is_orchestrator, + is_supervisor, + target_repo_path: target_repo_path.clone(), + completion_action: completion_action.clone(), + continue_from_task_id, + copy_files: copy_files.clone(), + contract_id, + created_at: Instant::now(), + started_at: None, + completed_at: None, + error: None, + }; + + self.tasks.write().await.insert(task_id, task); + tracing::info!(task_id = %task_id, "Task entry created and stored"); + + // Notify server of status change + tracing::info!(task_id = %task_id, "Notifying server: pending -> initializing"); + self.send_status_change(task_id, "pending", "initializing").await; + + // Spawn task in background + tracing::info!(task_id = %task_id, "Spawning background task runner"); + let inner = self.clone_inner(); + tokio::spawn(async move { + let _permit = permit; // Hold permit until done + tracing::info!(task_id = %task_id, "Background task runner started"); + + if let Err(e) = inner.run_task( + task_id, task_name, plan, repo_url, base_branch, target_branch, + is_orchestrator, is_supervisor, target_repo_path, completion_action, + continue_from_task_id, copy_files, contract_id + ).await { + tracing::error!(task_id = %task_id, error = %e, "Task execution failed"); + inner.mark_failed(task_id, &e.to_string()).await; + } + tracing::info!(task_id = %task_id, "Background task runner completed"); + }); + + tracing::info!(task_id = %task_id, "=== SPAWN_TASK END (task running in background) ==="); + Ok(()) + } + + /// Clone inner state for spawned tasks. + fn clone_inner(&self) -> TaskManagerInner { + TaskManagerInner { + worktree_manager: self.worktree_manager.clone(), + process_manager: self.process_manager.clone(), + temp_manager: self.temp_manager.clone(), + tasks: self.tasks.clone(), + ws_tx: self.ws_tx.clone(), + task_inputs: self.task_inputs.clone(), + } + } + + /// Interrupt a task. + pub async fn interrupt_task(&self, task_id: Uuid) -> TaskResult<()> { + let mut tasks = self.tasks.write().await; + let task = tasks.get_mut(&task_id).ok_or(TaskError::NotFound(task_id))?; + + if task.state.is_terminal() { + return Ok(()); // Already done + } + + let old_state = task.state; + task.state = TaskState::Interrupted; + task.completed_at = Some(Instant::now()); + + // Notify server + drop(tasks); + self.send_status_change(task_id, old_state.as_str(), "interrupted").await; + + // Note: The process will be killed when the ClaudeProcess is dropped + // Worktrees are kept until explicitly deleted + + Ok(()) + } + + /// Get list of active task IDs. + pub async fn active_task_ids(&self) -> Vec<Uuid> { + self.tasks + .read() + .await + .iter() + .filter(|(_, t)| t.state.is_active()) + .map(|(id, _)| *id) + .collect() + } + + /// Get task state. + pub async fn get_task_state(&self, task_id: Uuid) -> Option<TaskState> { + self.tasks.read().await.get(&task_id).map(|t| t.state) + } + + /// Send status change notification to server. + async fn send_status_change(&self, task_id: Uuid, old_status: &str, new_status: &str) { + let msg = DaemonMessage::task_status_change(task_id, old_status, new_status); + let _ = self.ws_tx.send(msg).await; + } + + // ========================================================================= + // Merge Handler Methods + // ========================================================================= + + /// Get worktree path for a task, or return error if not found. + /// First checks in-memory tasks, then scans the worktrees directory. + async fn get_task_worktree_path(&self, task_id: Uuid) -> Result<std::path::PathBuf, DaemonError> { + // First try to get from in-memory tasks + { + let tasks = self.tasks.read().await; + if let Some(task) = tasks.get(&task_id) { + if let Some(ref worktree) = task.worktree { + return Ok(worktree.path.clone()); + } + } + } + + // Task not in memory - scan worktrees directory for matching task ID + let short_id = &task_id.to_string()[..8]; + let worktrees_dir = self.worktree_manager.base_dir(); + + if let Ok(mut entries) = tokio::fs::read_dir(worktrees_dir).await { + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with(short_id) { + let path = entry.path(); + // Verify it's a valid git directory + if path.join(".git").exists() { + tracing::info!( + task_id = %task_id, + worktree_path = %path.display(), + "Found worktree by scanning directory" + ); + return Ok(path); + } + } + } + } + + Err(DaemonError::Task(TaskError::SetupFailed( + format!("No worktree found for task {}. The worktree may have been cleaned up.", task_id) + ))) + } + + /// Handle ListBranches command. + async fn handle_list_branches(&self, task_id: Uuid) -> Result<(), DaemonError> { + let worktree_path = self.get_task_worktree_path(task_id).await?; + + match self.worktree_manager.list_task_branches(&worktree_path).await { + Ok(branches) => { + let branch_infos: Vec<BranchInfo> = branches + .into_iter() + .map(|b| BranchInfo { + name: b.name, + task_id: b.task_id, + is_merged: b.is_merged, + last_commit: b.last_commit, + last_commit_message: b.last_commit_message, + }) + .collect(); + + let msg = DaemonMessage::BranchList { + task_id, + branches: branch_infos, + }; + let _ = self.ws_tx.send(msg).await; + } + Err(e) => { + tracing::error!(task_id = %task_id, error = %e, "Failed to list branches"); + let msg = DaemonMessage::MergeResult { + task_id, + success: false, + message: e.to_string(), + commit_sha: None, + conflicts: None, + }; + let _ = self.ws_tx.send(msg).await; + } + } + Ok(()) + } + + /// Handle MergeStart command. + async fn handle_merge_start(&self, task_id: Uuid, source_branch: String) -> Result<(), DaemonError> { + let worktree_path = self.get_task_worktree_path(task_id).await?; + + match self.worktree_manager.merge_branch(&worktree_path, &source_branch).await { + Ok(None) => { + // Merge succeeded without conflicts + let msg = DaemonMessage::MergeResult { + task_id, + success: true, + message: "Merge completed without conflicts".to_string(), + commit_sha: None, + conflicts: None, + }; + let _ = self.ws_tx.send(msg).await; + } + Ok(Some(conflicts)) => { + // Merge has conflicts + let msg = DaemonMessage::MergeResult { + task_id, + success: false, + message: format!("Merge has {} conflicts", conflicts.len()), + commit_sha: None, + conflicts: Some(conflicts), + }; + let _ = self.ws_tx.send(msg).await; + } + Err(e) => { + let msg = DaemonMessage::MergeResult { + task_id, + success: false, + message: e.to_string(), + commit_sha: None, + conflicts: None, + }; + let _ = self.ws_tx.send(msg).await; + } + } + Ok(()) + } + + /// Handle MergeStatus command. + async fn handle_merge_status(&self, task_id: Uuid) -> Result<(), DaemonError> { + let worktree_path = self.get_task_worktree_path(task_id).await?; + + match self.worktree_manager.get_merge_state(&worktree_path).await { + Ok(state) => { + let msg = DaemonMessage::MergeStatusResponse { + task_id, + in_progress: state.in_progress, + source_branch: if state.in_progress { Some(state.source_branch) } else { None }, + conflicted_files: state.conflicted_files, + }; + let _ = self.ws_tx.send(msg).await; + } + Err(e) => { + tracing::error!(task_id = %task_id, error = %e, "Failed to get merge status"); + let msg = DaemonMessage::MergeStatusResponse { + task_id, + in_progress: false, + source_branch: None, + conflicted_files: vec![], + }; + let _ = self.ws_tx.send(msg).await; + } + } + Ok(()) + } + + /// Handle MergeResolve command. + async fn handle_merge_resolve(&self, task_id: Uuid, file: String, strategy: String) -> Result<(), DaemonError> { + let worktree_path = self.get_task_worktree_path(task_id).await?; + + let resolution = match strategy.to_lowercase().as_str() { + "ours" => ConflictResolution::Ours, + "theirs" => ConflictResolution::Theirs, + _ => { + let msg = DaemonMessage::MergeResult { + task_id, + success: false, + message: format!("Invalid strategy '{}', must be 'ours' or 'theirs'", strategy), + commit_sha: None, + conflicts: None, + }; + let _ = self.ws_tx.send(msg).await; + return Ok(()); + } + }; + + match self.worktree_manager.resolve_conflict(&worktree_path, &file, resolution).await { + Ok(()) => { + let msg = DaemonMessage::MergeResult { + task_id, + success: true, + message: format!("Resolved conflict in {}", file), + commit_sha: None, + conflicts: None, + }; + let _ = self.ws_tx.send(msg).await; + } + Err(e) => { + let msg = DaemonMessage::MergeResult { + task_id, + success: false, + message: e.to_string(), + commit_sha: None, + conflicts: None, + }; + let _ = self.ws_tx.send(msg).await; + } + } + Ok(()) + } + + /// Handle MergeCommit command. + async fn handle_merge_commit(&self, task_id: Uuid, message: String) -> Result<(), DaemonError> { + let worktree_path = self.get_task_worktree_path(task_id).await?; + + match self.worktree_manager.commit_merge(&worktree_path, &message).await { + Ok(commit_sha) => { + // Track this merge as completed (extract subtask ID from branch if possible) + // For now, we'll track it when MergeSkip is called or based on branch names + let msg = DaemonMessage::MergeResult { + task_id, + success: true, + message: "Merge committed successfully".to_string(), + commit_sha: Some(commit_sha), + conflicts: None, + }; + let _ = self.ws_tx.send(msg).await; + } + Err(e) => { + let msg = DaemonMessage::MergeResult { + task_id, + success: false, + message: e.to_string(), + commit_sha: None, + conflicts: None, + }; + let _ = self.ws_tx.send(msg).await; + } + } + Ok(()) + } + + /// Handle MergeAbort command. + async fn handle_merge_abort(&self, task_id: Uuid) -> Result<(), DaemonError> { + let worktree_path = self.get_task_worktree_path(task_id).await?; + + match self.worktree_manager.abort_merge(&worktree_path).await { + Ok(()) => { + let msg = DaemonMessage::MergeResult { + task_id, + success: true, + message: "Merge aborted".to_string(), + commit_sha: None, + conflicts: None, + }; + let _ = self.ws_tx.send(msg).await; + } + Err(e) => { + let msg = DaemonMessage::MergeResult { + task_id, + success: false, + message: e.to_string(), + commit_sha: None, + conflicts: None, + }; + let _ = self.ws_tx.send(msg).await; + } + } + Ok(()) + } + + /// Handle MergeSkip command. + async fn handle_merge_skip(&self, task_id: Uuid, subtask_id: Uuid, reason: String) -> Result<(), DaemonError> { + // Record that this subtask was skipped + { + let mut trackers = self.merge_trackers.write().await; + let tracker = trackers.entry(task_id).or_insert_with(MergeTracker::default); + tracker.skipped_subtasks.insert(subtask_id, reason.clone()); + } + + let msg = DaemonMessage::MergeResult { + task_id, + success: true, + message: format!("Subtask {} skipped: {}", subtask_id, reason), + commit_sha: None, + conflicts: None, + }; + let _ = self.ws_tx.send(msg).await; + Ok(()) + } + + /// Handle CheckMergeComplete command. + async fn handle_check_merge_complete(&self, task_id: Uuid) -> Result<(), DaemonError> { + let worktree_path = self.get_task_worktree_path(task_id).await?; + + // Get all task branches + let branches = match self.worktree_manager.list_task_branches(&worktree_path).await { + Ok(b) => b, + Err(e) => { + let msg = DaemonMessage::MergeCompleteCheck { + task_id, + can_complete: false, + unmerged_branches: vec![format!("Error listing branches: {}", e)], + merged_count: 0, + skipped_count: 0, + }; + let _ = self.ws_tx.send(msg).await; + return Ok(()); + } + }; + + // Get tracker state + let trackers = self.merge_trackers.read().await; + let empty_merged: HashSet<Uuid> = HashSet::new(); + let empty_skipped: HashMap<Uuid, String> = HashMap::new(); + let tracker = trackers.get(&task_id); + let merged_set = tracker.map(|t| &t.merged_subtasks).unwrap_or(&empty_merged); + let skipped_set = tracker.map(|t| &t.skipped_subtasks).unwrap_or(&empty_skipped); + + let mut merged_count = 0u32; + let mut skipped_count = 0u32; + let mut unmerged_branches = Vec::new(); + + for branch in &branches { + if branch.is_merged { + merged_count += 1; + } else if let Some(subtask_id) = branch.task_id { + if merged_set.contains(&subtask_id) { + merged_count += 1; + } else if skipped_set.contains_key(&subtask_id) { + skipped_count += 1; + } else { + unmerged_branches.push(branch.name.clone()); + } + } else { + // Branch without task ID - check if it's merged + unmerged_branches.push(branch.name.clone()); + } + } + + let can_complete = unmerged_branches.is_empty(); + + let msg = DaemonMessage::MergeCompleteCheck { + task_id, + can_complete, + unmerged_branches, + merged_count, + skipped_count, + }; + let _ = self.ws_tx.send(msg).await; + Ok(()) + } + + /// Mark a subtask as merged in the tracker. + #[allow(dead_code)] + pub async fn mark_subtask_merged(&self, orchestrator_task_id: Uuid, subtask_id: Uuid) { + let mut trackers = self.merge_trackers.write().await; + let tracker = trackers.entry(orchestrator_task_id).or_insert_with(MergeTracker::default); + tracker.merged_subtasks.insert(subtask_id); + } + + // ========================================================================= + // Completion Action Handler Methods + // ========================================================================= + + /// Handle RetryCompletionAction command. + async fn handle_retry_completion_action( + &self, + task_id: Uuid, + task_name: String, + action: String, + target_repo_path: String, + target_branch: Option<String>, + ) -> Result<(), DaemonError> { + // Get the task's worktree path + let worktree_path = self.get_task_worktree_path(task_id).await?; + + // Execute the completion action + let inner = self.clone_inner(); + let result = inner.execute_completion_action( + task_id, + &task_name, + &worktree_path, + &action, + Some(target_repo_path.as_str()), + target_branch.as_deref(), + ).await; + + // Send result back to server + let msg = match result { + Ok(pr_url) => DaemonMessage::CompletionActionResult { + task_id, + success: true, + message: match action.as_str() { + "branch" => format!("Branch pushed to {}", target_repo_path), + "merge" => format!("Merged into {}", target_branch.as_deref().unwrap_or("main")), + "pr" => format!("Pull request created"), + _ => format!("Completion action '{}' executed", action), + }, + pr_url, + }, + Err(e) => DaemonMessage::CompletionActionResult { + task_id, + success: false, + message: e, + pr_url: None, + }, + }; + let _ = self.ws_tx.send(msg).await; + Ok(()) + } + + /// Handle CloneWorktree command. + async fn handle_clone_worktree( + &self, + task_id: Uuid, + target_dir: String, + ) -> Result<(), DaemonError> { + // Get the task's worktree path + let worktree_path = self.get_task_worktree_path(task_id).await?; + + // Expand tilde in target path + let target_path = crate::daemon::worktree::expand_tilde(&target_dir); + + // Clone the worktree to target directory + let result = self.worktree_manager.clone_worktree_to_directory( + &worktree_path, + &target_path, + ).await; + + // Send result back to server + let msg = match result { + Ok(message) => DaemonMessage::CloneWorktreeResult { + task_id, + success: true, + message, + target_dir: Some(target_path.to_string_lossy().to_string()), + }, + Err(e) => DaemonMessage::CloneWorktreeResult { + task_id, + success: false, + message: e.to_string(), + target_dir: None, + }, + }; + let _ = self.ws_tx.send(msg).await; + Ok(()) + } + + /// Handle CheckTargetExists command. + async fn handle_check_target_exists( + &self, + task_id: Uuid, + target_dir: String, + ) -> Result<(), DaemonError> { + // Expand tilde in target path + let target_path = crate::daemon::worktree::expand_tilde(&target_dir); + + // Check if target exists + let exists = self.worktree_manager.target_directory_exists(&target_path).await; + + // Send result back to server + let msg = DaemonMessage::CheckTargetExistsResult { + task_id, + exists, + target_dir: target_path.to_string_lossy().to_string(), + }; + let _ = self.ws_tx.send(msg).await; + Ok(()) + } + + /// Handle ReadRepoFile command. + /// + /// Reads a file from a repository on the daemon's filesystem and sends + /// the content back to the server for syncing contract files. + async fn handle_read_repo_file( + &self, + request_id: Uuid, + file_path: String, + repo_path: String, + ) -> Result<(), DaemonError> { + // Expand tilde in repo path + let repo_path_expanded = crate::daemon::worktree::expand_tilde(&repo_path); + + // Construct full file path + let full_path = repo_path_expanded.join(&file_path); + + // Try to read the file + let (content, success, error) = match tokio::fs::read_to_string(&full_path).await { + Ok(content) => (Some(content), true, None), + Err(e) => { + tracing::warn!( + request_id = %request_id, + file_path = %file_path, + repo_path = %repo_path, + full_path = %full_path.display(), + error = %e, + "Failed to read repo file" + ); + (None, false, Some(e.to_string())) + } + }; + + // Send result back to server + let msg = DaemonMessage::RepoFileContent { + request_id, + file_path, + content, + success, + error, + }; + let _ = self.ws_tx.send(msg).await; + Ok(()) + } + + /// Handle CreateBranch command - create a new branch in a task's worktree. + async fn handle_create_branch( + &self, + task_id: Uuid, + branch_name: String, + from_ref: Option<String>, + ) -> Result<(), DaemonError> { + // Get task's worktree path + let worktree_path = { + let tasks = self.tasks.read().await; + tasks.get(&task_id) + .and_then(|t| t.worktree.as_ref()) + .map(|w| w.path.clone()) + }; + + let (success, message) = if let Some(path) = worktree_path { + // Build git checkout command + let mut cmd = tokio::process::Command::new("git"); + cmd.current_dir(&path); + cmd.arg("checkout").arg("-b").arg(&branch_name); + + if let Some(ref from) = from_ref { + cmd.arg(from); + } + + match cmd.output().await { + Ok(output) => { + if output.status.success() { + (true, format!("Branch '{}' created successfully", branch_name)) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + (false, format!("Failed to create branch: {}", stderr)) + } + } + Err(e) => (false, format!("Failed to execute git: {}", e)), + } + } else { + (false, format!("Task {} not found or has no worktree", task_id)) + }; + + let msg = DaemonMessage::BranchCreated { + task_id, + success, + branch_name, + message, + }; + let _ = self.ws_tx.send(msg).await; + Ok(()) + } + + /// Handle MergeTaskToTarget command - merge a task's changes to a target branch. + async fn handle_merge_task_to_target( + &self, + task_id: Uuid, + target_branch: Option<String>, + squash: bool, + ) -> Result<(), DaemonError> { + // Get task info + let task_info = { + let tasks = self.tasks.read().await; + tasks.get(&task_id).map(|t| ( + t.worktree.as_ref().map(|w| w.path.clone()), + t.base_branch.clone(), + )) + }; + + let (success, message, commit_sha, conflicts) = match task_info { + Some((Some(worktree_path), base)) => { + let target = target_branch.unwrap_or_else(|| base.unwrap_or_else(|| "main".to_string())); + + // First, stage and commit any uncommitted changes + let add_result = tokio::process::Command::new("git") + .current_dir(&worktree_path) + .args(["add", "-A"]) + .output() + .await; + + if let Err(e) = add_result { + (false, format!("Failed to stage changes: {}", e), None, None) + } else { + // Commit if there are staged changes + let commit_result = tokio::process::Command::new("git") + .current_dir(&worktree_path) + .args(["commit", "-m", "Task completion checkpoint", "--allow-empty"]) + .output() + .await; + + if let Err(e) = commit_result { + tracing::warn!(task_id = %task_id, error = %e, "Commit failed (may be empty)"); + } + + // Get current branch name + let branch_output = tokio::process::Command::new("git") + .current_dir(&worktree_path) + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .output() + .await; + + let source_branch = branch_output + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_else(|_| "unknown".to_string()); + + // Checkout target branch + let checkout = tokio::process::Command::new("git") + .current_dir(&worktree_path) + .args(["checkout", &target]) + .output() + .await; + + match checkout { + Ok(output) if output.status.success() => { + // Merge the source branch + let mut merge_cmd = tokio::process::Command::new("git"); + merge_cmd.current_dir(&worktree_path); + merge_cmd.arg("merge"); + if squash { + merge_cmd.arg("--squash"); + } + merge_cmd.arg(&source_branch); + merge_cmd.arg("-m").arg(format!("Merge task {} into {}", task_id, target)); + + match merge_cmd.output().await { + Ok(output) if output.status.success() => { + // Get the commit SHA + let sha_output = tokio::process::Command::new("git") + .current_dir(&worktree_path) + .args(["rev-parse", "HEAD"]) + .output() + .await; + + let sha = sha_output + .ok() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()); + + if squash { + // For squash merge, we need to commit + let _ = tokio::process::Command::new("git") + .current_dir(&worktree_path) + .args(["commit", "-m", &format!("Squashed merge of task {}", task_id)]) + .output() + .await; + } + + (true, format!("Merged {} into {}", source_branch, target), sha, None) + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + // Check for merge conflicts + if stderr.contains("CONFLICT") { + let conflict_files = stderr + .lines() + .filter(|l| l.contains("CONFLICT")) + .map(|l| l.to_string()) + .collect::<Vec<_>>(); + (false, "Merge conflicts detected".to_string(), None, Some(conflict_files)) + } else { + (false, format!("Merge failed: {}", stderr), None, None) + } + } + Err(e) => (false, format!("Failed to merge: {}", e), None, None), + } + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + (false, format!("Failed to checkout target branch: {}", stderr), None, None) + } + Err(e) => (false, format!("Failed to checkout: {}", e), None, None), + } + } + } + Some((None, _)) => (false, format!("Task {} has no worktree", task_id), None, None), + None => (false, format!("Task {} not found", task_id), None, None), + }; + + let msg = DaemonMessage::MergeToTargetResult { + task_id, + success, + message, + commit_sha, + conflicts, + }; + let _ = self.ws_tx.send(msg).await; + Ok(()) + } + + /// Handle CreatePR command - create a pull request for a task's changes. + async fn handle_create_pr( + &self, + task_id: Uuid, + title: String, + body: Option<String>, + base_branch: String, + ) -> Result<(), DaemonError> { + // Get task's worktree path + let worktree_path = { + let tasks = self.tasks.read().await; + tasks.get(&task_id) + .and_then(|t| t.worktree.as_ref()) + .map(|w| w.path.clone()) + }; + + let (success, message, pr_url, pr_number) = if let Some(path) = worktree_path { + // Push the current branch first + let push_result = tokio::process::Command::new("git") + .current_dir(&path) + .args(["push", "-u", "origin", "HEAD"]) + .output() + .await; + + if let Err(e) = push_result { + (false, format!("Failed to push branch: {}", e), None, None) + } else { + // Create PR using gh CLI + let mut pr_cmd = tokio::process::Command::new("gh"); + pr_cmd.current_dir(&path); + pr_cmd.args(["pr", "create", "--title", &title, "--base", &base_branch]); + + if let Some(ref body_text) = body { + pr_cmd.args(["--body", body_text]); + } else { + pr_cmd.args(["--body", ""]); + } + + match pr_cmd.output().await { + Ok(output) if output.status.success() => { + let stdout = String::from_utf8_lossy(&output.stdout); + // gh pr create outputs the PR URL + let url = stdout.lines().last().map(|s| s.trim().to_string()); + // Extract PR number from URL + let number = url.as_ref().and_then(|u| { + u.split('/').last().and_then(|n| n.parse::<i32>().ok()) + }); + (true, "Pull request created".to_string(), url, number) + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + (false, format!("Failed to create PR: {}", stderr), None, None) + } + Err(e) => (false, format!("Failed to run gh: {}", e), None, None), + } + } + } else { + (false, format!("Task {} not found or has no worktree", task_id), None, None) + }; + + let msg = DaemonMessage::PRCreated { + task_id, + success, + message, + pr_url, + pr_number, + }; + let _ = self.ws_tx.send(msg).await; + Ok(()) + } + + /// Handle GetTaskDiff command - get the diff for a task's changes. + async fn handle_get_task_diff( + &self, + task_id: Uuid, + ) -> Result<(), DaemonError> { + // Get task's worktree path + let worktree_path = { + let tasks = self.tasks.read().await; + tasks.get(&task_id) + .and_then(|t| t.worktree.as_ref()) + .map(|w| w.path.clone()) + }; + + let (success, diff, error) = if let Some(path) = worktree_path { + // Get diff of all changes (staged and unstaged) + let diff_result = tokio::process::Command::new("git") + .current_dir(&path) + .args(["diff", "HEAD"]) + .output() + .await; + + match diff_result { + Ok(output) if output.status.success() => { + let diff_text = String::from_utf8_lossy(&output.stdout).to_string(); + if diff_text.is_empty() { + // No uncommitted changes, show diff from base + let base_diff = tokio::process::Command::new("git") + .current_dir(&path) + .args(["log", "-p", "--reverse", "HEAD~10..HEAD", "--"]) + .output() + .await; + + match base_diff { + Ok(o) => (true, Some(String::from_utf8_lossy(&o.stdout).to_string()), None), + Err(e) => (false, None, Some(format!("Failed to get diff: {}", e))), + } + } else { + (true, Some(diff_text), None) + } + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + (false, None, Some(format!("Git diff failed: {}", stderr))) + } + Err(e) => (false, None, Some(format!("Failed to run git: {}", e))), + } + } else { + (false, None, Some(format!("Task {} not found or has no worktree", task_id))) + }; + + let msg = DaemonMessage::TaskDiff { + task_id, + success, + diff, + error, + }; + let _ = self.ws_tx.send(msg).await; + Ok(()) + } +} + +/// Inner state for spawned tasks (cloneable). +struct TaskManagerInner { + worktree_manager: Arc<WorktreeManager>, + process_manager: Arc<ProcessManager>, + temp_manager: Arc<TempManager>, + tasks: Arc<RwLock<HashMap<Uuid, ManagedTask>>>, + ws_tx: mpsc::Sender<DaemonMessage>, + task_inputs: Arc<RwLock<HashMap<Uuid, mpsc::Sender<String>>>>, +} + +impl TaskManagerInner { + /// Run a task to completion. + #[allow(clippy::too_many_arguments)] + async fn run_task( + &self, + task_id: Uuid, + task_name: String, + plan: String, + repo_source: Option<String>, + base_branch: Option<String>, + target_branch: Option<String>, + is_orchestrator: bool, + is_supervisor: bool, + target_repo_path: Option<String>, + completion_action: Option<String>, + continue_from_task_id: Option<Uuid>, + copy_files: Option<Vec<String>>, + contract_id: Option<Uuid>, + ) -> Result<(), DaemonError> { + tracing::info!(task_id = %task_id, is_orchestrator = is_orchestrator, is_supervisor = is_supervisor, "=== RUN_TASK START ==="); + + // Determine working directory + let working_dir = if let Some(ref source) = repo_source { + if is_new_repo_request(source) { + // Explicit new repo request: new:// or new://project-name + tracing::info!( + task_id = %task_id, + source = %source, + "Creating new git repository" + ); + + let msg = DaemonMessage::task_output( + task_id, + format!("Initializing new git repository...\n"), + false, + ); + let _ = self.ws_tx.send(msg).await; + + let worktree_info = self.worktree_manager + .init_new_repo(task_id, source) + .await + .map_err(|e| DaemonError::Task(TaskError::SetupFailed(e.to_string())))?; + + tracing::info!( + task_id = %task_id, + path = %worktree_info.path.display(), + "New repository created" + ); + + // Store worktree info + { + let mut tasks = self.tasks.write().await; + if let Some(task) = tasks.get_mut(&task_id) { + task.worktree = Some(worktree_info.clone()); + } + } + + let msg = DaemonMessage::task_output( + task_id, + format!("Repository ready at {}\n", worktree_info.path.display()), + false, + ); + let _ = self.ws_tx.send(msg).await; + + worktree_info.path + } else { + // Send progress message + let msg = DaemonMessage::task_output( + task_id, + format!("Setting up worktree from {}...\n", source), + false, + ); + let _ = self.ws_tx.send(msg).await; + + // Ensure source repo exists (clone if URL, verify if path) + let source_repo = self.worktree_manager.ensure_repo(source).await + .map_err(|e| DaemonError::Task(TaskError::SetupFailed(e.to_string())))?; + + // Detect or use provided base branch + let branch = if let Some(ref b) = base_branch { + b.clone() + } else { + self.worktree_manager.detect_default_branch(&source_repo).await + .map_err(|e| DaemonError::Task(TaskError::SetupFailed(e.to_string())))? + }; + + tracing::info!( + task_id = %task_id, + source = %source, + branch = %branch, + continue_from_task_id = ?continue_from_task_id, + "Setting up worktree" + ); + + // Create worktree - either from scratch or copying from another task + let task_name = format!("task-{}", &task_id.to_string()[..8]); + let worktree_info = if let Some(from_task_id) = continue_from_task_id { + // Find the source task's worktree path + let source_worktree = self.find_worktree_for_task(from_task_id).await + .map_err(|e| DaemonError::Task(TaskError::SetupFailed( + format!("Cannot continue from task {}: {}", from_task_id, e) + )))?; + + let msg = DaemonMessage::task_output( + task_id, + format!("Continuing from task {} worktree...\n", &from_task_id.to_string()[..8]), + false, + ); + let _ = self.ws_tx.send(msg).await; + + // Create worktree by copying from source task + self.worktree_manager + .create_worktree_from_task(&source_worktree, task_id, &task_name) + .await + .map_err(|e| DaemonError::Task(TaskError::SetupFailed(e.to_string())))? + } else { + // Create fresh worktree from repo + self.worktree_manager + .create_worktree(&source_repo, task_id, &task_name, &branch) + .await + .map_err(|e| DaemonError::Task(TaskError::SetupFailed(e.to_string())))? + }; + + tracing::info!( + task_id = %task_id, + worktree_path = %worktree_info.path.display(), + branch = %worktree_info.branch, + continued_from = ?continue_from_task_id, + "Worktree created" + ); + + // Store worktree info + { + let mut tasks = self.tasks.write().await; + if let Some(task) = tasks.get_mut(&task_id) { + task.worktree = Some(worktree_info.clone()); + } + } + + let msg = DaemonMessage::task_output( + task_id, + format!("Worktree ready at {}\n", worktree_info.path.display()), + false, + ); + let _ = self.ws_tx.send(msg).await; + + worktree_info.path + } + } else { + // No repo specified - use managed temp directory in ~/.makima/temp/ + tracing::info!(task_id = %task_id, "Creating managed temp directory (no repo)"); + + let msg = DaemonMessage::task_output( + task_id, + "Creating temporary working directory...\n".to_string(), + false, + ); + let _ = self.ws_tx.send(msg).await; + + let temp_dir = self.temp_manager.create_task_dir(task_id).await?; + + let msg = DaemonMessage::task_output( + task_id, + format!("Working directory ready at {}\n", temp_dir.display()), + false, + ); + let _ = self.ws_tx.send(msg).await; + + temp_dir + }; + + // Copy files from parent task's worktree if specified + if let Some(ref files) = copy_files { + if !files.is_empty() { + // Get the parent task ID to find its worktree + let parent_task_id = { + let tasks = self.tasks.read().await; + tasks.get(&task_id).and_then(|t| t.parent_task_id) + }; + + if let Some(parent_id) = parent_task_id { + match self.find_worktree_for_task(parent_id).await { + Ok(parent_worktree) => { + let msg = DaemonMessage::task_output( + task_id, + format!("Copying {} files from orchestrator...\n", files.len()), + false, + ); + let _ = self.ws_tx.send(msg).await; + + for file_path in files { + let source = parent_worktree.join(file_path); + let dest = working_dir.join(file_path); + + // Create parent directories if needed + if let Some(parent) = dest.parent() { + if let Err(e) = tokio::fs::create_dir_all(parent).await { + tracing::warn!( + task_id = %task_id, + file = %file_path, + error = %e, + "Failed to create parent directory for file" + ); + continue; + } + } + + // Copy the file + match tokio::fs::copy(&source, &dest).await { + Ok(_) => { + tracing::info!( + task_id = %task_id, + source = %source.display(), + dest = %dest.display(), + "Copied file from orchestrator" + ); + } + Err(e) => { + tracing::warn!( + task_id = %task_id, + source = %source.display(), + dest = %dest.display(), + error = %e, + "Failed to copy file from orchestrator" + ); + // Notify but don't fail - the file might be optional + let msg = DaemonMessage::task_output( + task_id, + format!("Warning: Could not copy {}: {}\n", file_path, e), + false, + ); + let _ = self.ws_tx.send(msg).await; + } + } + } + + let msg = DaemonMessage::task_output( + task_id, + "Files copied from orchestrator.\n".to_string(), + false, + ); + let _ = self.ws_tx.send(msg).await; + } + Err(e) => { + tracing::warn!( + task_id = %task_id, + parent_id = %parent_id, + error = %e, + "Could not find parent task worktree for file copying" + ); + } + } + } else { + tracing::warn!( + task_id = %task_id, + "copy_files specified but no parent_task_id" + ); + } + } + } + + // Update state to Starting + tracing::info!(task_id = %task_id, "Updating state: Initializing -> Starting"); + self.update_state(task_id, TaskState::Starting).await; + self.send_status_change(task_id, "initializing", "starting").await; + + // Check Claude is available + match self.process_manager.check_claude_available().await { + Ok(version) => { + tracing::info!(task_id = %task_id, version = %version, "Claude Code available"); + let msg = DaemonMessage::task_output( + task_id, + format!("Claude Code {} ready\n", version), + false, + ); + let _ = self.ws_tx.send(msg).await; + } + Err(e) => { + let err_msg = format!("Claude Code not available: {}", e); + tracing::error!(task_id = %task_id, error = %err_msg); + return Err(DaemonError::Task(TaskError::SetupFailed(err_msg))); + } + } + + // Set up supervisor, orchestrator, or subtask mode + let (extra_env, full_plan, system_prompt) = if is_supervisor { + // Supervisor mode: long-running contract orchestrator + tracing::info!(task_id = %task_id, working_dir = %working_dir.display(), "Setting up supervisor mode"); + + let msg = DaemonMessage::task_output( + task_id, + "Setting up supervisor environment...\n".to_string(), + false, + ); + let _ = self.ws_tx.send(msg).await; + + // Generate tool key for API access + let tool_key = generate_tool_key(); + tracing::info!(task_id = %task_id, tool_key_len = tool_key.len(), "Generated tool key for supervisor"); + + // Register tool key with server + let register_msg = DaemonMessage::register_tool_key(task_id, tool_key.clone()); + if self.ws_tx.send(register_msg).await.is_err() { + tracing::warn!(task_id = %task_id, "Failed to register tool key"); + } else { + tracing::info!(task_id = %task_id, "Tool key registration message sent to server"); + } + + // Set up environment variables for makima CLI + let mut env = HashMap::new(); + // TODO: Make API URL configurable + env.insert("MAKIMA_API_URL".to_string(), "http://localhost:8080".to_string()); + env.insert("MAKIMA_API_KEY".to_string(), tool_key.clone()); + env.insert("MAKIMA_TASK_ID".to_string(), task_id.to_string()); + // Supervisor needs contract ID for its tools + if let Some(cid) = contract_id { + env.insert("MAKIMA_CONTRACT_ID".to_string(), cid.to_string()); + } + + tracing::info!( + task_id = %task_id, + api_url = "http://localhost:8080", + tool_key_preview = &tool_key[..8.min(tool_key.len())], + "Set supervisor environment variables" + ); + + // For supervisor, pass instructions as SYSTEM PROMPT (not user message) + // This ensures Claude treats them as behavioral constraints + let supervisor_user_plan = format!( + "Contract goal:\n{}", + plan + ); + + let msg = DaemonMessage::task_output( + task_id, + "Supervisor environment ready (makima CLI available)\n".to_string(), + false, + ); + let _ = self.ws_tx.send(msg).await; + + // Return system prompt separately - it will be passed via --system-prompt flag + (Some(env), supervisor_user_plan, Some(SUPERVISOR_SYSTEM_PROMPT.to_string())) + } else if is_orchestrator { + tracing::info!(task_id = %task_id, working_dir = %working_dir.display(), "Setting up orchestrator mode"); + + let msg = DaemonMessage::task_output( + task_id, + "Setting up orchestrator environment...\n".to_string(), + false, + ); + let _ = self.ws_tx.send(msg).await; + + // Generate tool key for API access + let tool_key = generate_tool_key(); + tracing::info!(task_id = %task_id, tool_key_len = tool_key.len(), "Generated tool key for orchestrator"); + + // Register tool key with server + let register_msg = DaemonMessage::register_tool_key(task_id, tool_key.clone()); + if self.ws_tx.send(register_msg).await.is_err() { + tracing::warn!(task_id = %task_id, "Failed to register tool key"); + } else { + tracing::info!(task_id = %task_id, "Tool key registration message sent to server"); + } + + // Set up environment variables for makima CLI + let mut env = HashMap::new(); + // TODO: Make API URL configurable + env.insert("MAKIMA_API_URL".to_string(), "http://localhost:8080".to_string()); + env.insert("MAKIMA_API_KEY".to_string(), tool_key.clone()); + env.insert("MAKIMA_TASK_ID".to_string(), task_id.to_string()); + + tracing::info!( + task_id = %task_id, + api_url = "http://localhost:8080", + tool_key_preview = &tool_key[..8.min(tool_key.len())], + "Set orchestrator environment variables" + ); + + // For orchestrator, pass instructions as SYSTEM PROMPT + let orchestrator_user_plan = format!( + "Your task:\n{}", + plan + ); + + let msg = DaemonMessage::task_output( + task_id, + "Orchestrator environment ready (makima CLI available)\n".to_string(), + false, + ); + let _ = self.ws_tx.send(msg).await; + + (Some(env), orchestrator_user_plan, Some(ORCHESTRATOR_SYSTEM_PROMPT.to_string())) + } else { + tracing::info!(task_id = %task_id, "Running as regular subtask (not orchestrator)"); + // For subtasks, pass worktree isolation instructions as system prompt + let subtask_user_plan = format!( + "Your task:\n{}", + plan + ); + (None, subtask_user_plan, Some(SUBTASK_SYSTEM_PROMPT.to_string())) + }; + + // Add contract environment if task has contract_id (skip for supervisors - they already have it) + let (extra_env, full_plan, system_prompt) = if let Some(cid) = contract_id { + if is_supervisor { + // Supervisors already have contract ID and API access set up + tracing::info!(task_id = %task_id, contract_id = %cid, "Supervisor already has contract integration"); + (extra_env, full_plan, system_prompt) + } else { + tracing::info!(task_id = %task_id, contract_id = %cid, "Setting up contract integration"); + + // Set up environment variables for makima CLI + let mut env = extra_env.unwrap_or_default(); + env.insert("MAKIMA_CONTRACT_ID".to_string(), cid.to_string()); + + // If not already an orchestrator, we need API access for makima CLI + if !is_orchestrator { + // Generate tool key for API access + let tool_key = generate_tool_key(); + tracing::info!(task_id = %task_id, "Generated tool key for contract access"); + + // Register tool key with server + let register_msg = DaemonMessage::register_tool_key(task_id, tool_key.clone()); + if self.ws_tx.send(register_msg).await.is_err() { + tracing::warn!(task_id = %task_id, "Failed to register contract tool key"); + } + + env.insert("MAKIMA_API_URL".to_string(), "http://localhost:8080".to_string()); + env.insert("MAKIMA_API_KEY".to_string(), tool_key); + env.insert("MAKIMA_TASK_ID".to_string(), task_id.to_string()); + } + + let msg = DaemonMessage::task_output( + task_id, + "Contract integration ready (makima CLI available)\n".to_string(), + false, + ); + let _ = self.ws_tx.send(msg).await; + + // Prepend contract integration prompt to the plan so the task knows to use makima CLI + let contract_plan = format!( + "{}{}", + CONTRACT_INTEGRATION_PROMPT, + full_plan + ); + + (Some(env), contract_plan, system_prompt) + } + } else { + (extra_env, full_plan, system_prompt) + }; + + // Spawn Claude process + let plan_bytes = full_plan.len(); + let plan_chars = full_plan.chars().count(); + // Rough token estimate: ~4 chars per token for English + let estimated_tokens = plan_chars / 4; + + tracing::info!( + task_id = %task_id, + working_dir = %working_dir.display(), + is_orchestrator = is_orchestrator, + plan_bytes = plan_bytes, + plan_chars = plan_chars, + estimated_tokens = estimated_tokens, + "Spawning Claude process" + ); + + // Warn if plan is very large (Claude's context is typically 100k-200k tokens) + if estimated_tokens > 50_000 { + tracing::warn!(task_id = %task_id, estimated_tokens = estimated_tokens, "Plan is very large - may hit context limits!"); + let msg = DaemonMessage::task_output( + task_id, + format!("Warning: Plan is very large (~{} tokens). This may cause issues.\n", estimated_tokens), + false, + ); + let _ = self.ws_tx.send(msg).await; + } + + let msg = DaemonMessage::task_output( + task_id, + if is_orchestrator { + format!("Starting Claude Code (orchestrator mode, ~{} tokens)...\n", estimated_tokens) + } else { + format!("Starting Claude Code (~{} tokens)...\n", estimated_tokens) + }, + false, + ); + let _ = self.ws_tx.send(msg).await; + + tracing::debug!(task_id = %task_id, has_system_prompt = system_prompt.is_some(), "Calling process_manager.spawn()..."); + let mut process = self.process_manager + .spawn_with_system_prompt(&working_dir, &full_plan, extra_env, system_prompt.as_deref()) + .await + .map_err(|e| { + tracing::error!(task_id = %task_id, error = %e, "Failed to spawn Claude process"); + DaemonError::Task(TaskError::SetupFailed(e.to_string())) + })?; + tracing::info!(task_id = %task_id, "Claude process spawned successfully"); + + // Set up input channel for this task so we can send messages to its stdin + tracing::debug!(task_id = %task_id, "Setting up input channel..."); + let (input_tx, mut input_rx) = mpsc::channel::<String>(100); + tracing::debug!(task_id = %task_id, "Acquiring task_inputs write lock..."); + self.task_inputs.write().await.insert(task_id, input_tx); + tracing::debug!(task_id = %task_id, "Input channel registered"); + + // Get stdin handle for input forwarding and completion signaling + let stdin_handle = process.stdin_handle(); + let stdin_handle_for_completion = stdin_handle.clone(); + + tracing::info!(task_id = %task_id, "Setting up stdin forwarder for task input (JSON protocol)"); + tokio::spawn(async move { + tracing::info!(task_id = %task_id, "Stdin forwarder task started, waiting for messages..."); + while let Some(msg) = input_rx.recv().await { + tracing::info!(task_id = %task_id, msg_len = msg.len(), msg_preview = %if msg.len() > 50 { &msg[..50] } else { &msg }, "Received message from input channel"); + + // Format as JSON user message for stream-json input protocol + let json_msg = ClaudeInputMessage::user(&msg); + let json_line = match json_msg.to_json_line() { + Ok(line) => line, + Err(e) => { + tracing::error!(task_id = %task_id, error = %e, "Failed to serialize input message"); + continue; + } + }; + + tracing::debug!(task_id = %task_id, json_line = %json_line.trim(), "Formatted JSON line for stdin"); + + let mut stdin_guard = stdin_handle.lock().await; + if let Some(ref mut stdin) = *stdin_guard { + tracing::debug!(task_id = %task_id, "Acquired stdin lock, writing..."); + if stdin.write_all(json_line.as_bytes()).await.is_err() { + tracing::warn!(task_id = %task_id, "Failed to write to stdin, breaking"); + break; + } + if stdin.flush().await.is_err() { + tracing::warn!(task_id = %task_id, "Failed to flush stdin, breaking"); + break; + } + tracing::info!(task_id = %task_id, json_len = json_line.len(), "Successfully wrote user message to Claude stdin"); + } else { + tracing::warn!(task_id = %task_id, "Stdin is None (already closed), cannot send message"); + break; + } + } + tracing::info!(task_id = %task_id, "Stdin forwarder task ended (channel closed or stdin unavailable)"); + }); + + // Update state to Running + { + tracing::debug!(task_id = %task_id, "Acquiring tasks write lock for Running state update"); + let mut tasks = self.tasks.write().await; + if let Some(task) = tasks.get_mut(&task_id) { + task.state = TaskState::Running; + task.started_at = Some(Instant::now()); + } + tracing::debug!(task_id = %task_id, "Released tasks write lock"); + } + tracing::info!(task_id = %task_id, "Updating state: Starting -> Running"); + self.send_status_change(task_id, "starting", "running").await; + tracing::debug!(task_id = %task_id, "Sent status change notification"); + + // Stream output with startup timeout check + tracing::info!(task_id = %task_id, "Starting output stream - waiting for Claude output..."); + tracing::debug!(task_id = %task_id, "Output will be forwarded via WebSocket to server"); + let ws_tx = self.ws_tx.clone(); + + // For auth error detection + let claude_command = self.process_manager.claude_command().to_string(); + let daemon_hostname = hostname::get().ok().and_then(|h| h.into_string().ok()); + let mut auth_error_handled = false; + + let mut output_count = 0u64; + let mut output_bytes = 0usize; + let startup_timeout = tokio::time::Duration::from_secs(30); + let mut startup_check = tokio::time::interval(tokio::time::Duration::from_secs(5)); + startup_check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let startup_deadline = tokio::time::Instant::now() + startup_timeout; + + loop { + tokio::select! { + maybe_line = process.next_output() => { + match maybe_line { + Some(line) => { + output_count += 1; + output_bytes += line.content.len(); + + if output_count == 1 { + tracing::info!(task_id = %task_id, "Received first output line from Claude"); + } + if output_count % 100 == 0 { + tracing::debug!(task_id = %task_id, output_count = output_count, output_bytes = output_bytes, "Output progress"); + } + + // Log output details for debugging + tracing::trace!( + task_id = %task_id, + line_num = output_count, + content_len = line.content.len(), + is_stdout = line.is_stdout, + json_type = ?line.json_type, + "Forwarding output to WebSocket" + ); + + // Check if this is a "result" message indicating task completion + // With --input-format=stream-json, Claude waits for more input after completion + // We close stdin to signal EOF and let the process exit + if line.json_type.as_deref() == Some("result") { + tracing::info!(task_id = %task_id, "Received result message, closing stdin to signal completion"); + let mut stdin_guard = stdin_handle_for_completion.lock().await; + if let Some(mut stdin) = stdin_guard.take() { + let _ = stdin.shutdown().await; + } + } + + // Check for OAuth auth error before sending output + let content_for_auth_check = line.content.clone(); + + let msg = DaemonMessage::task_output(task_id, line.content, false); + if ws_tx.send(msg).await.is_err() { + tracing::warn!(task_id = %task_id, "Failed to send output, channel closed"); + break; + } + + // Detect OAuth token expiration and trigger remote login flow + if !auth_error_handled && is_oauth_auth_error(&content_for_auth_check) { + auth_error_handled = true; + tracing::warn!(task_id = %task_id, "OAuth authentication error detected, initiating remote login flow"); + + // Spawn claude setup-token to get login URL + if let Some(login_url) = get_oauth_login_url(&claude_command).await { + tracing::info!(task_id = %task_id, login_url = %login_url, "Got OAuth login URL"); + let auth_msg = DaemonMessage::AuthenticationRequired { + task_id: Some(task_id), + login_url, + hostname: daemon_hostname.clone(), + }; + if ws_tx.send(auth_msg).await.is_err() { + tracing::warn!(task_id = %task_id, "Failed to send auth required message"); + } + } else { + tracing::error!(task_id = %task_id, "Failed to get OAuth login URL from setup-token"); + let fallback_msg = DaemonMessage::task_output( + task_id, + format!("Authentication required on daemon{}. Please run 'claude /login' on the daemon machine.\n", + daemon_hostname.as_ref().map(|h| format!(" ({})", h)).unwrap_or_default()), + false, + ); + let _ = ws_tx.send(fallback_msg).await; + } + } + } + None => { + tracing::info!(task_id = %task_id, output_count = output_count, output_bytes = output_bytes, "Output stream ended"); + break; + } + } + } + _ = startup_check.tick(), if output_count == 0 => { + // Check if process is still alive + match process.try_wait() { + Ok(Some(exit_code)) => { + tracing::error!(task_id = %task_id, exit_code = exit_code, "Claude process exited before producing output!"); + let msg = DaemonMessage::task_output( + task_id, + format!("Error: Claude process exited unexpectedly with code {}\n", exit_code), + false, + ); + let _ = ws_tx.send(msg).await; + break; + } + Ok(None) => { + // Still running but no output + if tokio::time::Instant::now() > startup_deadline { + tracing::warn!(task_id = %task_id, "Claude process not producing output after 30s - may be stuck"); + let msg = DaemonMessage::task_output( + task_id, + "Warning: Claude Code is taking longer than expected to start. It may be waiting for authentication or network access.\n".to_string(), + false, + ); + let _ = ws_tx.send(msg).await; + } else { + tracing::debug!(task_id = %task_id, "Claude process still running, waiting for output..."); + } + } + Err(e) => { + tracing::error!(task_id = %task_id, error = %e, "Failed to check Claude process status"); + } + } + } + } + } + + // Wait for process to exit + let exit_code = process.wait().await.unwrap_or(-1); + + // Clean up input channel for this task + self.task_inputs.write().await.remove(&task_id); + tracing::debug!(task_id = %task_id, "Removed task input channel"); + + // Update state based on exit code + let success = exit_code == 0; + let new_state = if success { + TaskState::Completed + } else { + TaskState::Failed + }; + + tracing::info!( + task_id = %task_id, + exit_code = exit_code, + success = success, + new_state = ?new_state, + "Claude process exited, updating task state" + ); + + { + let mut tasks = self.tasks.write().await; + if let Some(task) = tasks.get_mut(&task_id) { + task.state = new_state; + task.completed_at = Some(Instant::now()); + if !success { + task.error = Some(format!("Process exited with code {}", exit_code)); + } + } + } + + // Execute completion action if task succeeded + let completion_result = if success { + if let Some(ref action) = completion_action { + if action != "none" { + self.execute_completion_action( + task_id, + &task_name, + &working_dir, + action, + target_repo_path.as_deref(), + target_branch.as_deref(), + ).await + } else { + Ok(None) + } + } else { + Ok(None) + } + } else { + Ok(None) + }; + + // Log completion action result + match &completion_result { + Ok(Some(pr_url)) => { + tracing::info!(task_id = %task_id, pr_url = %pr_url, "Completion action created PR"); + } + Ok(None) => {} + Err(e) => { + tracing::warn!(task_id = %task_id, error = %e, "Completion action failed (task still marked as done)"); + } + } + + // Notify server - but NOT for supervisors which should never complete + if is_supervisor { + tracing::info!( + task_id = %task_id, + exit_code = exit_code, + "Supervisor Claude process exited - NOT marking as complete" + ); + // Update local state to reflect it's paused/waiting for input + { + let mut tasks = self.tasks.write().await; + if let Some(task) = tasks.get_mut(&task_id) { + task.state = TaskState::Running; // Keep it as running, not completed + task.completed_at = None; + } + } + // Send a status message to let the frontend know supervisor is ready for more input + let msg = DaemonMessage::task_output( + task_id, + "\n[Supervisor ready for next instruction]\n".to_string(), + false, + ); + let _ = self.ws_tx.send(msg).await; + } else { + let error = if success { + None + } else { + Some(format!("Exit code: {}", exit_code)) + }; + tracing::info!(task_id = %task_id, success = success, "Notifying server of task completion"); + let msg = DaemonMessage::task_complete(task_id, success, error); + let _ = self.ws_tx.send(msg).await; + } + + // Note: Worktrees are kept until explicitly deleted (per user preference) + // This allows inspection, PR creation, etc. + + tracing::info!(task_id = %task_id, "=== RUN_TASK END ==="); + Ok(()) + } + + /// Execute the completion action for a task. + async fn execute_completion_action( + &self, + task_id: Uuid, + task_name: &str, + worktree_path: &std::path::Path, + action: &str, + target_repo_path: Option<&str>, + target_branch: Option<&str>, + ) -> Result<Option<String>, String> { + let target_repo = match target_repo_path { + Some(path) => crate::daemon::worktree::expand_tilde(path), + None => { + tracing::warn!(task_id = %task_id, "No target_repo_path configured, skipping completion action"); + return Ok(None); + } + }; + + if !target_repo.exists() { + return Err(format!("Target repo not found: {} (expanded from {:?})", target_repo.display(), target_repo_path)); + } + + // Get the branch name: makima/{task-name-with-dashes}-{short-id} + let branch_name = format!( + "makima/{}-{}", + crate::daemon::worktree::sanitize_name(task_name), + crate::daemon::worktree::short_uuid(task_id) + ); + + // Determine target branch - use provided value or detect default branch of target repo + let target_branch = match target_branch { + Some(branch) => branch.to_string(), + None => { + // Detect default branch (main, master, develop, etc.) + self.worktree_manager + .detect_default_branch(&target_repo) + .await + .unwrap_or_else(|_| "master".to_string()) + } + }; + + let msg = DaemonMessage::task_output( + task_id, + format!("Executing completion action: {}...\n", action), + false, + ); + let _ = self.ws_tx.send(msg).await; + + match action { + "branch" => { + // Just push the branch to target repo + self.worktree_manager + .push_to_target_repo(worktree_path, &target_repo, &branch_name, task_name) + .await + .map_err(|e| e.to_string())?; + + let msg = DaemonMessage::task_output( + task_id, + format!("Branch '{}' pushed to {}\n", branch_name, target_repo.display()), + false, + ); + let _ = self.ws_tx.send(msg).await; + Ok(None) + } + "merge" => { + // Push and merge into target branch + let commit_sha = self.worktree_manager + .merge_to_target(worktree_path, &target_repo, &branch_name, &target_branch, task_name) + .await + .map_err(|e| e.to_string())?; + + let msg = DaemonMessage::task_output( + task_id, + format!("Branch merged into {} (commit: {})\n", target_branch, commit_sha), + false, + ); + let _ = self.ws_tx.send(msg).await; + Ok(None) + } + "pr" => { + // Push and create PR + let title = task_name.to_string(); + let body = format!( + "Automated PR from makima task.\n\nTask ID: `{}`", + task_id + ); + let pr_url = self.worktree_manager + .create_pull_request( + worktree_path, + &target_repo, + &branch_name, + &target_branch, + &title, + &body, + ) + .await + .map_err(|e| e.to_string())?; + + let msg = DaemonMessage::task_output( + task_id, + format!("Pull request created: {}\n", pr_url), + false, + ); + let _ = self.ws_tx.send(msg).await; + Ok(Some(pr_url)) + } + _ => { + tracing::warn!(task_id = %task_id, action = %action, "Unknown completion action"); + Ok(None) + } + } + } + + /// Find worktree path for a task ID. + /// First checks in-memory tasks, then scans the worktrees directory. + async fn find_worktree_for_task(&self, task_id: Uuid) -> Result<PathBuf, String> { + // First try to get from in-memory tasks + { + let tasks = self.tasks.read().await; + if let Some(task) = tasks.get(&task_id) { + if let Some(ref worktree) = task.worktree { + return Ok(worktree.path.clone()); + } + } + } + + // Task not in memory - scan worktrees directory for matching task ID + let short_id = &task_id.to_string()[..8]; + let worktrees_dir = self.worktree_manager.base_dir(); + + if let Ok(mut entries) = tokio::fs::read_dir(worktrees_dir).await { + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with(short_id) { + let path = entry.path(); + // Verify it's a valid git directory + if path.join(".git").exists() { + tracing::info!( + task_id = %task_id, + worktree_path = %path.display(), + "Found worktree by scanning directory" + ); + return Ok(path); + } + } + } + } + + Err(format!( + "No worktree found for task {}. The worktree may have been cleaned up.", + task_id + )) + } + + async fn update_state(&self, task_id: Uuid, state: TaskState) { + let mut tasks = self.tasks.write().await; + if let Some(task) = tasks.get_mut(&task_id) { + task.state = state; + } + } + + async fn send_status_change(&self, task_id: Uuid, old_status: &str, new_status: &str) { + let msg = DaemonMessage::task_status_change(task_id, old_status, new_status); + let _ = self.ws_tx.send(msg).await; + } + + /// Mark task as failed. + async fn mark_failed(&self, task_id: Uuid, error: &str) { + { + let mut tasks = self.tasks.write().await; + if let Some(task) = tasks.get_mut(&task_id) { + task.state = TaskState::Failed; + task.error = Some(error.to_string()); + task.completed_at = Some(Instant::now()); + } + } + + // Notify server + let msg = DaemonMessage::task_complete(task_id, false, Some(error.to_string())); + let _ = self.ws_tx.send(msg).await; + } +} + +impl Clone for TaskManagerInner { + fn clone(&self) -> Self { + Self { + worktree_manager: self.worktree_manager.clone(), + process_manager: self.process_manager.clone(), + temp_manager: self.temp_manager.clone(), + tasks: self.tasks.clone(), + ws_tx: self.ws_tx.clone(), + task_inputs: self.task_inputs.clone(), + } + } +} diff --git a/makima/src/daemon/task/mod.rs b/makima/src/daemon/task/mod.rs new file mode 100644 index 0000000..29c261e --- /dev/null +++ b/makima/src/daemon/task/mod.rs @@ -0,0 +1,7 @@ +//! Task management and execution. + +pub mod manager; +pub mod state; + +pub use manager::{ManagedTask, TaskConfig, TaskManager}; +pub use state::TaskState; diff --git a/makima/src/daemon/task/state.rs b/makima/src/daemon/task/state.rs new file mode 100644 index 0000000..ca5fc01 --- /dev/null +++ b/makima/src/daemon/task/state.rs @@ -0,0 +1,161 @@ +//! Task state machine. + +use std::fmt; + +/// Task execution state. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TaskState { + /// Task received, preparing overlay. + Initializing, + /// Overlay ready, starting container. + Starting, + /// Container running. + Running, + /// Container paused. + Paused, + /// Waiting for sibling or resource. + Blocked, + /// Task completed successfully. + Completed, + /// Task failed with error. + Failed, + /// Task interrupted by user. + Interrupted, +} + +impl TaskState { + /// Check if a state transition is valid. + pub fn can_transition_to(&self, target: TaskState) -> bool { + use TaskState::*; + + matches!( + (self, target), + // From Initializing + (Initializing, Starting) + | (Initializing, Failed) + | (Initializing, Interrupted) + // From Starting + | (Starting, Running) + | (Starting, Failed) + | (Starting, Interrupted) + // From Running + | (Running, Paused) + | (Running, Blocked) + | (Running, Completed) + | (Running, Failed) + | (Running, Interrupted) + // From Paused + | (Paused, Running) + | (Paused, Interrupted) + | (Paused, Failed) + // From Blocked + | (Blocked, Running) + | (Blocked, Failed) + | (Blocked, Interrupted) + ) + } + + /// Check if this state is terminal (no more transitions possible). + pub fn is_terminal(&self) -> bool { + matches!( + self, + TaskState::Completed | TaskState::Failed | TaskState::Interrupted + ) + } + + /// Check if the task is currently active (running or paused). + pub fn is_active(&self) -> bool { + matches!( + self, + TaskState::Initializing + | TaskState::Starting + | TaskState::Running + | TaskState::Paused + | TaskState::Blocked + ) + } + + /// Check if the task is running. + pub fn is_running(&self) -> bool { + matches!(self, TaskState::Running) + } + + /// Convert to string for protocol messages. + pub fn as_str(&self) -> &'static str { + match self { + TaskState::Initializing => "initializing", + TaskState::Starting => "starting", + TaskState::Running => "running", + TaskState::Paused => "paused", + TaskState::Blocked => "blocked", + TaskState::Completed => "done", + TaskState::Failed => "failed", + TaskState::Interrupted => "interrupted", + } + } + + /// Parse from string. + pub fn from_str(s: &str) -> Option<Self> { + match s.to_lowercase().as_str() { + "initializing" => Some(TaskState::Initializing), + "starting" => Some(TaskState::Starting), + "running" => Some(TaskState::Running), + "paused" => Some(TaskState::Paused), + "blocked" => Some(TaskState::Blocked), + "done" | "completed" => Some(TaskState::Completed), + "failed" => Some(TaskState::Failed), + "interrupted" => Some(TaskState::Interrupted), + _ => None, + } + } +} + +impl fmt::Display for TaskState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl Default for TaskState { + fn default() -> Self { + TaskState::Initializing + } +} + +#[cfg(test)] +mod tests { + use crate::daemon::*; + + #[test] + fn test_valid_transitions() { + use TaskState::*; + + // Valid transitions + assert!(Initializing.can_transition_to(Starting)); + assert!(Starting.can_transition_to(Running)); + assert!(Running.can_transition_to(Completed)); + assert!(Running.can_transition_to(Paused)); + assert!(Paused.can_transition_to(Running)); + + // Invalid transitions + assert!(!Completed.can_transition_to(Running)); + assert!(!Failed.can_transition_to(Running)); + assert!(!Running.can_transition_to(Initializing)); + } + + #[test] + fn test_terminal_states() { + assert!(TaskState::Completed.is_terminal()); + assert!(TaskState::Failed.is_terminal()); + assert!(TaskState::Interrupted.is_terminal()); + assert!(!TaskState::Running.is_terminal()); + assert!(!TaskState::Paused.is_terminal()); + } + + #[test] + fn test_parse() { + assert_eq!(TaskState::from_str("running"), Some(TaskState::Running)); + assert_eq!(TaskState::from_str("done"), Some(TaskState::Completed)); + assert_eq!(TaskState::from_str("invalid"), None); + } +} diff --git a/makima/src/daemon/temp.rs b/makima/src/daemon/temp.rs new file mode 100644 index 0000000..42d4a28 --- /dev/null +++ b/makima/src/daemon/temp.rs @@ -0,0 +1,224 @@ +//! Managed temporary directory for tasks without repositories. +//! +//! Tasks that don't have a repository URL and aren't subtasks (which inherit +//! from parent) use a managed temp directory in ~/.makima/temp/. The directory +//! is automatically cleaned up when it exceeds a size limit. + +use std::path::PathBuf; + +use tokio::fs; +use uuid::Uuid; + +/// Maximum size of the temp directory before cleanup (5GB). +const MAX_TEMP_SIZE_BYTES: u64 = 5 * 1024 * 1024 * 1024; + +/// Manages temporary directories for tasks without repositories. +pub struct TempManager { + /// Base directory for temp task directories (~/.makima/temp/). + temp_dir: PathBuf, +} + +impl TempManager { + /// Create a new TempManager. + pub fn new() -> Self { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + Self { + temp_dir: home.join(".makima").join("temp"), + } + } + + /// Create a new TempManager with a custom base directory. + #[allow(dead_code)] + pub fn with_base_dir(base_dir: PathBuf) -> Self { + Self { temp_dir: base_dir } + } + + /// Get the base temp directory path. + pub fn temp_dir(&self) -> &PathBuf { + &self.temp_dir + } + + /// Create a temp directory for a task. + /// + /// This creates a directory at ~/.makima/temp/task-{id}/ and triggers + /// cleanup if the total size exceeds the limit. + pub async fn create_task_dir(&self, task_id: Uuid) -> Result<PathBuf, std::io::Error> { + // Ensure base directory exists + fs::create_dir_all(&self.temp_dir).await?; + + // Check size and cleanup if needed + if let Err(e) = self.cleanup_if_needed().await { + tracing::warn!("Temp directory cleanup failed: {}", e); + // Continue anyway, cleanup is best-effort + } + + // Create task-specific directory + let task_dir = self.temp_dir.join(format!("task-{}", task_id)); + fs::create_dir_all(&task_dir).await?; + + tracing::info!( + task_id = %task_id, + path = %task_dir.display(), + "Created temp directory for task" + ); + + Ok(task_dir) + } + + /// Calculate total size of temp directory recursively. + async fn get_total_size(&self) -> Result<u64, std::io::Error> { + if !self.temp_dir.exists() { + return Ok(0); + } + + let mut total = 0u64; + let mut stack = vec![self.temp_dir.clone()]; + + while let Some(dir) = stack.pop() { + let mut entries = match fs::read_dir(&dir).await { + Ok(e) => e, + Err(_) => continue, + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let metadata = match entry.metadata().await { + Ok(m) => m, + Err(_) => continue, + }; + + if metadata.is_dir() { + stack.push(entry.path()); + } else { + total += metadata.len(); + } + } + } + + Ok(total) + } + + /// Remove oldest directories if total size exceeds limit. + async fn cleanup_if_needed(&self) -> Result<(), std::io::Error> { + let size = self.get_total_size().await?; + if size <= MAX_TEMP_SIZE_BYTES { + return Ok(()); + } + + tracing::info!( + current_size_mb = size / 1024 / 1024, + limit_mb = MAX_TEMP_SIZE_BYTES / 1024 / 1024, + "Temp directory exceeds size limit, starting cleanup" + ); + + // Get all task dirs with modification times + let mut dirs: Vec<(PathBuf, std::time::SystemTime, u64)> = vec![]; + let mut entries = fs::read_dir(&self.temp_dir).await?; + + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let metadata = match entry.metadata().await { + Ok(m) => m, + Err(_) => continue, + }; + + let modified = metadata.modified().unwrap_or(std::time::UNIX_EPOCH); + let dir_size = self.get_dir_size(&path).await.unwrap_or(0); + dirs.push((path, modified, dir_size)); + } + + // Sort by oldest first + dirs.sort_by(|a, b| a.1.cmp(&b.1)); + + // Remove oldest until under limit + let mut current_size = size; + for (path, _, dir_size) in dirs { + if current_size <= MAX_TEMP_SIZE_BYTES { + break; + } + + tracing::info!( + path = %path.display(), + size_mb = dir_size / 1024 / 1024, + "Removing old temp directory" + ); + + if let Err(e) = fs::remove_dir_all(&path).await { + tracing::warn!(path = %path.display(), error = %e, "Failed to remove temp directory"); + continue; + } + + current_size = current_size.saturating_sub(dir_size); + } + + tracing::info!( + new_size_mb = current_size / 1024 / 1024, + "Temp directory cleanup complete" + ); + + Ok(()) + } + + /// Calculate size of a directory recursively. + async fn get_dir_size(&self, path: &PathBuf) -> Result<u64, std::io::Error> { + let mut total = 0u64; + let mut stack = vec![path.clone()]; + + while let Some(dir) = stack.pop() { + let mut entries = match fs::read_dir(&dir).await { + Ok(e) => e, + Err(_) => continue, + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let metadata = match entry.metadata().await { + Ok(m) => m, + Err(_) => continue, + }; + + if metadata.is_dir() { + stack.push(entry.path()); + } else { + total += metadata.len(); + } + } + } + + Ok(total) + } + + /// Remove a specific task's temp directory. + #[allow(dead_code)] + pub async fn remove_task_dir(&self, task_id: Uuid) -> Result<(), std::io::Error> { + let task_dir = self.temp_dir.join(format!("task-{}", task_id)); + if task_dir.exists() { + fs::remove_dir_all(&task_dir).await?; + tracing::info!( + task_id = %task_id, + path = %task_dir.display(), + "Removed temp directory for task" + ); + } + Ok(()) + } +} + +impl Default for TempManager { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use crate::daemon::*; + + #[test] + fn test_temp_manager_default_dir() { + let manager = TempManager::new(); + assert!(manager.temp_dir().ends_with(".makima/temp")); + } +} diff --git a/makima/src/daemon/worktree/manager.rs b/makima/src/daemon/worktree/manager.rs new file mode 100644 index 0000000..9af5dcb --- /dev/null +++ b/makima/src/daemon/worktree/manager.rs @@ -0,0 +1,1623 @@ +//! Worktree manager implementation. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::LazyLock; + +use tokio::process::Command; +use tokio::sync::Mutex; +use uuid::Uuid; + +/// Errors that can occur during worktree operations. +#[derive(Debug, thiserror::Error)] +pub enum WorktreeError { + #[error("Git command failed: {0}")] + GitCommand(String), + + #[error("Repository not found: {0}")] + RepoNotFound(String), + + #[error("Failed to create directory: {0}")] + CreateDir(#[from] std::io::Error), + + #[error("Invalid repository path: {0}")] + InvalidPath(String), + + #[error("Worktree already exists: {0}")] + AlreadyExists(String), + + #[error("Clone failed: {0}")] + CloneFailed(String), + + #[error("Merge in progress")] + MergeInProgress, + + #[error("No merge in progress")] + NoMergeInProgress, + + #[error("Merge has conflicts: {0}")] + MergeConflicts(String), +} + +/// Strategy for resolving a merge conflict. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConflictResolution { + /// Use our version (the branch being merged into). + Ours, + /// Use their version (the branch being merged). + Theirs, +} + +/// State of an in-progress merge. +#[derive(Debug, Clone)] +pub struct MergeState { + /// The branch being merged. + pub source_branch: String, + /// Files with unresolved conflicts. + pub conflicted_files: Vec<String>, + /// Whether a merge is currently in progress. + pub in_progress: bool, +} + +/// Information about a task branch. +#[derive(Debug, Clone)] +pub struct TaskBranchInfo { + /// Full branch name. + pub name: String, + /// Task ID extracted from branch name (if parseable). + pub task_id: Option<Uuid>, + /// Whether this branch has been merged into the current branch. + pub is_merged: bool, + /// Short SHA of the last commit. + pub last_commit: String, + /// Subject line of the last commit. + pub last_commit_message: String, +} + +/// Information about a created worktree. +#[derive(Debug, Clone)] +pub struct WorktreeInfo { + /// Path to the worktree directory. + pub path: PathBuf, + /// Git branch name for this worktree. + pub branch: String, + /// Source repository path. + pub source_repo: PathBuf, +} + +/// Manages git worktrees for task isolation. +pub struct WorktreeManager { + /// Base directory for all worktrees (~/.makima/worktrees). + base_dir: PathBuf, + /// Base directory for cloned repos (~/.makima/repos). + repos_dir: PathBuf, + /// Branch prefix for task branches. + branch_prefix: String, +} + +/// Per-worktree locks to prevent concurrent creation issues. +static WORKTREE_LOCKS: LazyLock<Mutex<HashMap<String, std::sync::Arc<tokio::sync::Mutex<()>>>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +impl WorktreeManager { + /// Create a new WorktreeManager with the given base directory. + pub fn new(base_dir: PathBuf) -> Self { + let repos_dir = base_dir.parent() + .map(|p| p.join("repos")) + .unwrap_or_else(|| base_dir.join("repos")); + + Self { + base_dir, + repos_dir, + branch_prefix: "makima/task-".to_string(), + } + } + + /// Get the default worktree base directory (~/.makima/worktrees). + pub fn default_base_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".makima") + .join("worktrees") + } + + /// Get the base directory for worktrees. + pub fn base_dir(&self) -> &Path { + &self.base_dir + } + + /// Detect the default branch of a repository. + /// Tries to find HEAD's target, falling back to common branch names. + pub async fn detect_default_branch(&self, repo_path: &Path) -> Result<String, WorktreeError> { + // Try to get the branch that HEAD points to + let output = Command::new("git") + .args(["symbolic-ref", "refs/remotes/origin/HEAD", "--short"]) + .current_dir(repo_path) + .output() + .await?; + + if output.status.success() { + let branch = String::from_utf8_lossy(&output.stdout).trim().to_string(); + // Remove "origin/" prefix if present + let branch = branch.strip_prefix("origin/").unwrap_or(&branch).to_string(); + if !branch.is_empty() { + return Ok(branch); + } + } + + // Try common branch names + for branch in ["main", "master", "develop", "trunk"] { + let output = Command::new("git") + .args(["rev-parse", "--verify", &format!("refs/heads/{}", branch)]) + .current_dir(repo_path) + .output() + .await?; + + if output.status.success() { + return Ok(branch.to_string()); + } + } + + // Fall back to getting the current branch + let output = Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(repo_path) + .output() + .await?; + + if output.status.success() { + let branch = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !branch.is_empty() && branch != "HEAD" { + return Ok(branch); + } + } + + Err(WorktreeError::GitCommand( + "Could not detect default branch".to_string(), + )) + } + + /// Ensure the source repository exists locally and is up-to-date. + /// If repo_source is a URL, clone it. If it's a path, verify it exists. + /// For both cases, fetch latest changes from remote if available. + pub async fn ensure_repo(&self, repo_source: &str) -> Result<PathBuf, WorktreeError> { + // Check if it's a URL (simple heuristic) + if repo_source.starts_with("http://") + || repo_source.starts_with("https://") + || repo_source.starts_with("git@") + || repo_source.starts_with("ssh://") + { + self.clone_or_fetch_repo(repo_source).await + } else { + // Treat as local path - expand tilde if present + let path = expand_tilde(repo_source); + if !path.exists() { + return Err(WorktreeError::RepoNotFound(repo_source.to_string())); + } + // Verify it's a git repo + let git_dir = path.join(".git"); + if !git_dir.exists() { + return Err(WorktreeError::InvalidPath(format!( + "{} is not a git repository", + repo_source + ))); + } + + // Fetch latest changes from remote if configured + tracing::info!("Fetching latest changes for local repo: {}", repo_source); + let output = Command::new("git") + .args(["fetch", "--all", "--prune"]) + .current_dir(&path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + // Don't fail - repo might not have a remote configured + tracing::debug!("Git fetch for local repo (may not have remote): {}", stderr); + } else { + tracing::info!("Fetched latest changes for {}", repo_source); + } + + Ok(path) + } + } + + /// Clone a repository or fetch if already cloned. + async fn clone_or_fetch_repo(&self, url: &str) -> Result<PathBuf, WorktreeError> { + // Extract repo name from URL + let repo_name = extract_repo_name(url); + let repo_path = self.repos_dir.join(&repo_name); + + // Create repos directory if needed + tokio::fs::create_dir_all(&self.repos_dir).await?; + + if repo_path.exists() { + // Fetch latest changes + tracing::info!("Fetching updates for existing repo: {}", repo_name); + let output = Command::new("git") + .args(["fetch", "--all", "--prune"]) + .current_dir(&repo_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!("Git fetch warning: {}", stderr); + // Don't fail on fetch errors, repo might still be usable + } + } else { + // Clone the repository + tracing::info!("Cloning repository: {} -> {}", url, repo_path.display()); + let output = Command::new("git") + .args(["clone", "--bare", url]) + .arg(&repo_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::CloneFailed(stderr.to_string())); + } + } + + Ok(repo_path) + } + + /// Create a worktree for a task. + /// + /// This creates a unique directory with a git worktree checked out to a new branch. + pub async fn create_worktree( + &self, + source_repo: &Path, + task_id: Uuid, + task_name: &str, + base_branch: &str, + ) -> Result<WorktreeInfo, WorktreeError> { + // Generate unique directory name and branch + let dir_name = format!("{}-{}", short_uuid(task_id), sanitize_name(task_name)); + let worktree_path = self.base_dir.join(&dir_name); + // Branch name: makima/{task-name-with-dashes}-{short-id} + let branch_name = format!("{}{}-{}", self.branch_prefix, sanitize_name(task_name), short_uuid(task_id)); + + // Acquire lock for this worktree path + let lock = { + let mut locks = WORKTREE_LOCKS.lock().await; + locks + .entry(worktree_path.to_string_lossy().to_string()) + .or_insert_with(|| std::sync::Arc::new(tokio::sync::Mutex::new(()))) + .clone() + }; + let _guard = lock.lock().await; + + // Check if worktree already exists - reuse it if so + if worktree_path.exists() { + tracing::info!( + task_id = %task_id, + worktree_path = %worktree_path.display(), + "Worktree already exists, reusing" + ); + + // Verify it's a valid git directory + let git_dir = worktree_path.join(".git"); + if git_dir.exists() { + // Get the current branch name + let output = Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(&worktree_path) + .output() + .await?; + + let current_branch = if output.status.success() { + String::from_utf8_lossy(&output.stdout).trim().to_string() + } else { + branch_name.clone() + }; + + return Ok(WorktreeInfo { + path: worktree_path, + branch: current_branch, + source_repo: source_repo.to_path_buf(), + }); + } else { + // Directory exists but isn't a git worktree - remove and recreate + tracing::warn!( + task_id = %task_id, + worktree_path = %worktree_path.display(), + "Directory exists but is not a git worktree, removing" + ); + tokio::fs::remove_dir_all(&worktree_path).await?; + } + } + + // Create base directory + tokio::fs::create_dir_all(&self.base_dir).await?; + + tracing::info!( + task_id = %task_id, + worktree_path = %worktree_path.display(), + branch = %branch_name, + base_branch = %base_branch, + "Creating worktree from local branch" + ); + + // Create the worktree with a new branch based on the local base_branch + let output = Command::new("git") + .args([ + "worktree", + "add", + "-b", + &branch_name, + ]) + .arg(&worktree_path) + .arg(base_branch) + .current_dir(source_repo) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to create worktree: {}", + stderr + ))); + } + + tracing::info!( + task_id = %task_id, + worktree_path = %worktree_path.display(), + "Worktree created successfully" + ); + + Ok(WorktreeInfo { + path: worktree_path, + branch: branch_name, + source_repo: source_repo.to_path_buf(), + }) + } + + /// Create a worktree for a task by copying from another task's worktree. + /// + /// This allows sequential subtasks where one continues from another's work, + /// including uncommitted changes. + pub async fn create_worktree_from_task( + &self, + source_worktree: &Path, + task_id: Uuid, + task_name: &str, + ) -> Result<WorktreeInfo, WorktreeError> { + // Verify source worktree exists + if !source_worktree.exists() { + return Err(WorktreeError::RepoNotFound(format!( + "Source worktree not found: {}", + source_worktree.display() + ))); + } + + // Get the source repo from the source worktree + let source_repo = self.get_worktree_source(source_worktree).await?; + + // Get the base branch from source worktree's current HEAD + let output = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(source_worktree) + .output() + .await?; + + if !output.status.success() { + return Err(WorktreeError::GitCommand( + "Failed to get source worktree HEAD".to_string(), + )); + } + let source_commit = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + // Generate unique directory name and branch for new worktree + let dir_name = format!("{}-{}", short_uuid(task_id), sanitize_name(task_name)); + let worktree_path = self.base_dir.join(&dir_name); + let branch_name = format!("{}{}", self.branch_prefix, task_id); + + // Acquire lock for this worktree path + let lock = { + let mut locks = WORKTREE_LOCKS.lock().await; + locks + .entry(worktree_path.to_string_lossy().to_string()) + .or_insert_with(|| std::sync::Arc::new(tokio::sync::Mutex::new(()))) + .clone() + }; + let _guard = lock.lock().await; + + // Remove existing worktree if present + if worktree_path.exists() { + tracing::info!( + task_id = %task_id, + worktree_path = %worktree_path.display(), + "Removing existing worktree before creating from source" + ); + tokio::fs::remove_dir_all(&worktree_path).await?; + } + + // Create base directory + tokio::fs::create_dir_all(&self.base_dir).await?; + + tracing::info!( + task_id = %task_id, + source_worktree = %source_worktree.display(), + worktree_path = %worktree_path.display(), + branch = %branch_name, + source_commit = %source_commit, + "Creating worktree from source task" + ); + + // Create a new worktree based on the source commit + let output = Command::new("git") + .args([ + "worktree", + "add", + "-b", + &branch_name, + ]) + .arg(&worktree_path) + .arg(&source_commit) + .current_dir(&source_repo) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to create worktree: {}", + stderr + ))); + } + + // Now copy uncommitted changes from source worktree + // Use rsync to copy all files except .git + let output = Command::new("rsync") + .args([ + "-a", + "--exclude", ".git", + "--exclude", ".makima", + &format!("{}/", source_worktree.display()), + &format!("{}/", worktree_path.display()), + ]) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!( + task_id = %task_id, + "rsync warning (continuing anyway): {}", + stderr + ); + } + + tracing::info!( + task_id = %task_id, + worktree_path = %worktree_path.display(), + "Worktree created from source task successfully" + ); + + Ok(WorktreeInfo { + path: worktree_path, + branch: branch_name, + source_repo: source_repo.to_path_buf(), + }) + } + + /// Remove a worktree and optionally its branch. + pub async fn remove_worktree( + &self, + worktree_path: &Path, + delete_branch: bool, + ) -> Result<(), WorktreeError> { + if !worktree_path.exists() { + return Ok(()); // Already gone + } + + // Get the branch name before removing + let branch_name = if delete_branch { + self.get_worktree_branch(worktree_path).await.ok() + } else { + None + }; + + // Find the source repo from worktree + let source_repo = self.get_worktree_source(worktree_path).await?; + + tracing::info!( + worktree_path = %worktree_path.display(), + delete_branch = delete_branch, + "Removing worktree" + ); + + // Remove the worktree + let output = Command::new("git") + .args(["worktree", "remove", "--force"]) + .arg(worktree_path) + .current_dir(&source_repo) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + // Try force removal of directory if git worktree remove fails + if worktree_path.exists() { + tokio::fs::remove_dir_all(worktree_path).await?; + } + tracing::warn!("Git worktree remove warning: {}", stderr); + } + + // Prune worktree references + let _ = Command::new("git") + .args(["worktree", "prune"]) + .current_dir(&source_repo) + .output() + .await; + + // Delete the branch if requested + if let Some(branch) = branch_name { + let output = Command::new("git") + .args(["branch", "-D", &branch]) + .current_dir(&source_repo) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!("Failed to delete branch {}: {}", branch, stderr); + } + } + + Ok(()) + } + + /// Get the branch name of a worktree. + async fn get_worktree_branch(&self, worktree_path: &Path) -> Result<String, WorktreeError> { + let output = Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to get branch: {}", + stderr + ))); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } + + /// Get the source repository path for a worktree. + async fn get_worktree_source(&self, worktree_path: &Path) -> Result<PathBuf, WorktreeError> { + // Read the .git file in the worktree which contains the path to the main repo + let git_file = worktree_path.join(".git"); + + if git_file.is_file() { + let content = tokio::fs::read_to_string(&git_file).await?; + // Format: "gitdir: /path/to/repo/.git/worktrees/name" + if let Some(gitdir) = content.strip_prefix("gitdir: ") { + let gitdir = gitdir.trim(); + // Navigate from worktrees/name back to the main repo + let path = PathBuf::from(gitdir); + if let Some(worktrees_dir) = path.parent() { + if let Some(git_dir) = worktrees_dir.parent() { + if let Some(repo_dir) = git_dir.parent() { + return Ok(repo_dir.to_path_buf()); + } + } + } + } + } + + // Fallback: try to find it in our repos directory + Err(WorktreeError::InvalidPath(format!( + "Could not determine source repo for worktree: {}", + worktree_path.display() + ))) + } + + /// List all worktrees in the base directory. + pub async fn list_worktrees(&self) -> Result<Vec<PathBuf>, WorktreeError> { + let mut worktrees = Vec::new(); + + if !self.base_dir.exists() { + return Ok(worktrees); + } + + let mut entries = tokio::fs::read_dir(&self.base_dir).await?; + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.is_dir() && path.join(".git").exists() { + worktrees.push(path); + } + } + + Ok(worktrees) + } + + /// Initialize a new git repository for a task. + /// + /// This creates a fresh git repo (not a worktree) for tasks that don't need + /// an existing codebase. Use this when `repository_url` is `new://` or `new://project-name`. + pub async fn init_new_repo( + &self, + task_id: Uuid, + repo_source: &str, + ) -> Result<WorktreeInfo, WorktreeError> { + let project_name = extract_new_repo_name(repo_source); + let dir_name = match project_name { + Some(name) => format!("{}-{}", short_uuid(task_id), sanitize_name(name)), + None => format!("{}-new", short_uuid(task_id)), + }; + let repo_path = self.repos_dir.join(&dir_name); + + tracing::info!( + task_id = %task_id, + path = %repo_path.display(), + project_name = ?project_name, + "Initializing new git repository" + ); + + // Create directory + tokio::fs::create_dir_all(&repo_path).await?; + + // git init + let output = Command::new("git") + .args(["init"]) + .current_dir(&repo_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to init repository: {}", + stderr + ))); + } + + // Configure git user (needed for commits) + let _ = Command::new("git") + .args(["config", "user.email", "makima@localhost"]) + .current_dir(&repo_path) + .output() + .await; + let _ = Command::new("git") + .args(["config", "user.name", "Makima"]) + .current_dir(&repo_path) + .output() + .await; + + // Initial commit (required for worktrees to work later if needed) + let output = Command::new("git") + .args(["commit", "--allow-empty", "-m", "Initial commit"]) + .current_dir(&repo_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to create initial commit: {}", + stderr + ))); + } + + tracing::info!( + task_id = %task_id, + path = %repo_path.display(), + "New git repository initialized" + ); + + Ok(WorktreeInfo { + path: repo_path.clone(), + branch: "main".to_string(), + source_repo: repo_path, + }) + } + + // ========== Merge Operations ========== + + /// List all task branches in a repository. + /// + /// Returns branches matching the pattern `makima/task-*`. + pub async fn list_task_branches( + &self, + repo_path: &Path, + ) -> Result<Vec<TaskBranchInfo>, WorktreeError> { + // Get all branches matching our prefix + let output = Command::new("git") + .args([ + "branch", + "--list", + &format!("{}*", self.branch_prefix), + "--format=%(refname:short)|%(objectname:short)|%(subject)", + ]) + .current_dir(repo_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to list branches: {}", + stderr + ))); + } + + // Get list of merged branches + let merged_output = Command::new("git") + .args(["branch", "--merged", "HEAD", "--format=%(refname:short)"]) + .current_dir(repo_path) + .output() + .await?; + + let merged_branches: std::collections::HashSet<String> = if merged_output.status.success() { + String::from_utf8_lossy(&merged_output.stdout) + .lines() + .map(|s| s.trim().to_string()) + .collect() + } else { + std::collections::HashSet::new() + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut branches = Vec::new(); + + for line in stdout.lines() { + let parts: Vec<&str> = line.split('|').collect(); + if parts.len() >= 3 { + let name = parts[0].trim().to_string(); + let last_commit = parts[1].trim().to_string(); + let last_commit_message = parts[2].trim().to_string(); + + // Try to extract task ID from branch name + let task_id = name + .strip_prefix(&self.branch_prefix) + .and_then(|s| Uuid::parse_str(s).ok()); + + let is_merged = merged_branches.contains(&name); + + branches.push(TaskBranchInfo { + name, + task_id, + is_merged, + last_commit, + last_commit_message, + }); + } + } + + Ok(branches) + } + + /// Start a merge of a branch into the current worktree. + /// + /// Uses `--no-commit` to allow conflict resolution before committing. + /// Returns Ok(None) if merge succeeds without conflicts, or Ok(Some(files)) + /// with the list of conflicted files. + pub async fn merge_branch( + &self, + worktree_path: &Path, + source_branch: &str, + ) -> Result<Option<Vec<String>>, WorktreeError> { + // Check if there's already a merge in progress + if self.is_merge_in_progress(worktree_path).await? { + return Err(WorktreeError::MergeInProgress); + } + + tracing::info!( + worktree = %worktree_path.display(), + source_branch = %source_branch, + "Starting merge" + ); + + // Attempt the merge with --no-commit --no-ff + let output = Command::new("git") + .args(["merge", "--no-commit", "--no-ff", source_branch]) + .current_dir(worktree_path) + .output() + .await?; + + if output.status.success() { + tracing::info!("Merge completed without conflicts"); + return Ok(None); + } + + // Check if there are conflicts + let conflicts = self.get_conflicted_files(worktree_path).await?; + if !conflicts.is_empty() { + tracing::info!( + conflicts = ?conflicts, + "Merge has conflicts" + ); + return Ok(Some(conflicts)); + } + + // Other error + let stderr = String::from_utf8_lossy(&output.stderr); + Err(WorktreeError::GitCommand(format!( + "Merge failed: {}", + stderr + ))) + } + + /// Check if a merge is currently in progress. + pub async fn is_merge_in_progress(&self, worktree_path: &Path) -> Result<bool, WorktreeError> { + // Check for MERGE_HEAD file + let merge_head = worktree_path.join(".git").join("MERGE_HEAD"); + if merge_head.exists() { + return Ok(true); + } + + // Also check in .git file (for worktrees) + let git_file = worktree_path.join(".git"); + if git_file.is_file() { + if let Ok(content) = tokio::fs::read_to_string(&git_file).await { + if let Some(gitdir) = content.strip_prefix("gitdir: ") { + let gitdir = PathBuf::from(gitdir.trim()); + let merge_head = gitdir.join("MERGE_HEAD"); + if merge_head.exists() { + return Ok(true); + } + } + } + } + + Ok(false) + } + + /// Get the list of files with unresolved conflicts. + pub async fn get_conflicted_files( + &self, + worktree_path: &Path, + ) -> Result<Vec<String>, WorktreeError> { + let output = Command::new("git") + .args(["diff", "--name-only", "--diff-filter=U"]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + // No conflicts or not in merge state + return Ok(Vec::new()); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let files: Vec<String> = stdout + .lines() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + + Ok(files) + } + + /// Get the current merge state. + pub async fn get_merge_state( + &self, + worktree_path: &Path, + ) -> Result<MergeState, WorktreeError> { + let in_progress = self.is_merge_in_progress(worktree_path).await?; + + if !in_progress { + return Ok(MergeState { + source_branch: String::new(), + conflicted_files: Vec::new(), + in_progress: false, + }); + } + + // Get the branch being merged from MERGE_HEAD + let source_branch = self.get_merge_source_branch(worktree_path).await?; + let conflicted_files = self.get_conflicted_files(worktree_path).await?; + + Ok(MergeState { + source_branch, + conflicted_files, + in_progress: true, + }) + } + + /// Get the branch name being merged (from MERGE_HEAD). + async fn get_merge_source_branch(&self, worktree_path: &Path) -> Result<String, WorktreeError> { + // Get MERGE_HEAD commit + let output = Command::new("git") + .args(["rev-parse", "MERGE_HEAD"]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + return Ok("unknown".to_string()); + } + + let commit = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + // Try to find branch name for this commit + let output = Command::new("git") + .args(["name-rev", "--name-only", &commit]) + .current_dir(worktree_path) + .output() + .await?; + + if output.status.success() { + let name = String::from_utf8_lossy(&output.stdout).trim().to_string(); + // Clean up the name (remove ~N suffixes, etc.) + let name = name.split('~').next().unwrap_or(&name); + let name = name.split('^').next().unwrap_or(name); + return Ok(name.to_string()); + } + + Ok(commit[..8.min(commit.len())].to_string()) + } + + /// Resolve a conflict in a specific file. + pub async fn resolve_conflict( + &self, + worktree_path: &Path, + file_path: &str, + resolution: ConflictResolution, + ) -> Result<(), WorktreeError> { + if !self.is_merge_in_progress(worktree_path).await? { + return Err(WorktreeError::NoMergeInProgress); + } + + let strategy = match resolution { + ConflictResolution::Ours => "--ours", + ConflictResolution::Theirs => "--theirs", + }; + + tracing::info!( + worktree = %worktree_path.display(), + file = %file_path, + strategy = %strategy, + "Resolving conflict" + ); + + // Checkout the chosen version + let output = Command::new("git") + .args(["checkout", strategy, "--", file_path]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to resolve conflict: {}", + stderr + ))); + } + + // Stage the resolved file + let output = Command::new("git") + .args(["add", file_path]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to stage resolved file: {}", + stderr + ))); + } + + Ok(()) + } + + /// Abort the current merge. + pub async fn abort_merge(&self, worktree_path: &Path) -> Result<(), WorktreeError> { + if !self.is_merge_in_progress(worktree_path).await? { + return Err(WorktreeError::NoMergeInProgress); + } + + tracing::info!( + worktree = %worktree_path.display(), + "Aborting merge" + ); + + let output = Command::new("git") + .args(["merge", "--abort"]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to abort merge: {}", + stderr + ))); + } + + Ok(()) + } + + /// Commit the current merge. + pub async fn commit_merge( + &self, + worktree_path: &Path, + message: &str, + ) -> Result<String, WorktreeError> { + // Check for remaining conflicts + let conflicts = self.get_conflicted_files(worktree_path).await?; + if !conflicts.is_empty() { + return Err(WorktreeError::MergeConflicts(conflicts.join(", "))); + } + + tracing::info!( + worktree = %worktree_path.display(), + message = %message, + "Committing merge" + ); + + let output = Command::new("git") + .args(["commit", "-m", message]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to commit merge: {}", + stderr + ))); + } + + // Get the new commit SHA + let output = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(worktree_path) + .output() + .await?; + + if output.status.success() { + let sha = String::from_utf8_lossy(&output.stdout).trim().to_string(); + return Ok(sha); + } + + Ok("unknown".to_string()) + } + + // ========== Completion Action Operations ========== + + /// Push task branch from worktree to an external target repository. + /// + /// This stages and commits any uncommitted changes, then pushes to the target repo. + pub async fn push_to_target_repo( + &self, + worktree_path: &Path, + target_repo: &Path, + branch_name: &str, + task_name: &str, + ) -> Result<(), WorktreeError> { + tracing::info!( + worktree = %worktree_path.display(), + target_repo = %target_repo.display(), + branch = %branch_name, + "Pushing branch to target repository" + ); + + // First, stage all changes (including new files) + let output = Command::new("git") + .args(["add", "-A"]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to stage changes: {}", + stderr + ))); + } + + // Check if there are staged changes to commit + let output = Command::new("git") + .args(["diff", "--cached", "--quiet"]) + .current_dir(worktree_path) + .output() + .await?; + + // Exit code 1 means there are staged changes + if !output.status.success() { + tracing::info!("Committing staged changes before push"); + + let commit_message = format!("feat: {}", task_name); + let output = Command::new("git") + .args(["commit", "-m", &commit_message]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to commit changes: {}", + stderr + ))); + } + } + + // Ensure there are commits to push + let output = Command::new("git") + .args(["log", "--oneline", "-1"]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + return Err(WorktreeError::GitCommand( + "No commits in worktree".to_string(), + )); + } + + // Add target repo as a remote in the worktree (if not already) + let remote_name = "target"; + let target_path_str = target_repo.to_string_lossy(); + + // Remove existing remote if any (ignore errors) + let _ = Command::new("git") + .args(["remote", "remove", remote_name]) + .current_dir(worktree_path) + .output() + .await; + + // Add the target as a remote + let output = Command::new("git") + .args(["remote", "add", remote_name, &target_path_str]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to add remote: {}", + stderr + ))); + } + + // Push the branch to the target + let output = Command::new("git") + .args(["push", "-u", remote_name, &format!("HEAD:{}", branch_name)]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to push to target: {}", + stderr + ))); + } + + tracing::info!( + branch = %branch_name, + target_repo = %target_repo.display(), + "Branch pushed successfully" + ); + + // Detach HEAD in the worktree to release the branch + // This allows the branch to be checked out in the target repo + let output = Command::new("git") + .args(["checkout", "--detach", "HEAD"]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + // Non-fatal: log but don't fail the push + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!( + "Failed to detach HEAD in worktree (branch may not be checkable in target): {}", + stderr + ); + } else { + tracing::info!("Detached HEAD in worktree to release branch"); + } + + Ok(()) + } + + /// Merge a branch into the target branch in the target repository. + /// + /// This pushes the branch first (if needed), then performs a merge in the target repo. + pub async fn merge_to_target( + &self, + worktree_path: &Path, + target_repo: &Path, + source_branch: &str, + target_branch: &str, + task_name: &str, + ) -> Result<String, WorktreeError> { + tracing::info!( + worktree = %worktree_path.display(), + target_repo = %target_repo.display(), + source_branch = %source_branch, + target_branch = %target_branch, + "Merging branch to target" + ); + + // First, push the branch to target repo + self.push_to_target_repo(worktree_path, target_repo, source_branch, task_name) + .await?; + + // In target repo, checkout the target branch + let output = Command::new("git") + .args(["checkout", target_branch]) + .current_dir(target_repo) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to checkout target branch: {}", + stderr + ))); + } + + // Pull latest changes first + let _ = Command::new("git") + .args(["pull", "--ff-only"]) + .current_dir(target_repo) + .output() + .await; + + // Merge the source branch + let merge_message = format!("feat: {}", task_name); + let output = Command::new("git") + .args(["merge", "--no-ff", source_branch, "-m", &merge_message]) + .current_dir(target_repo) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + + // Check if it's a conflict + let conflicts = self.get_conflicted_files(target_repo).await?; + if !conflicts.is_empty() { + // Abort the merge + let _ = Command::new("git") + .args(["merge", "--abort"]) + .current_dir(target_repo) + .output() + .await; + + return Err(WorktreeError::MergeConflicts(format!( + "Merge conflicts in: {}. Consider creating a PR instead.", + conflicts.join(", ") + ))); + } + + return Err(WorktreeError::GitCommand(format!( + "Failed to merge: {}", + stderr + ))); + } + + // Get the merge commit SHA + let output = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(target_repo) + .output() + .await?; + + let commit_sha = if output.status.success() { + String::from_utf8_lossy(&output.stdout).trim().to_string() + } else { + "unknown".to_string() + }; + + tracing::info!( + commit_sha = %commit_sha, + "Merge completed successfully" + ); + + Ok(commit_sha) + } + + /// Create a GitHub pull request using the gh CLI. + /// + /// This pushes the branch first, then creates a PR. + pub async fn create_pull_request( + &self, + worktree_path: &Path, + target_repo: &Path, + source_branch: &str, + target_branch: &str, + title: &str, + body: &str, + ) -> Result<String, WorktreeError> { + tracing::info!( + worktree = %worktree_path.display(), + target_repo = %target_repo.display(), + source_branch = %source_branch, + target_branch = %target_branch, + title = %title, + "Creating pull request" + ); + + // First, push the branch to the target repo's remote + // For PRs, we need to push to origin (the GitHub remote) + + // Get the worktree's current branch + let output = Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(worktree_path) + .output() + .await?; + + let current_branch = if output.status.success() { + String::from_utf8_lossy(&output.stdout).trim().to_string() + } else { + source_branch.to_string() + }; + + // Push to the target repo's origin + // First, check if target_repo has an origin remote + let output = Command::new("git") + .args(["remote", "get-url", "origin"]) + .current_dir(target_repo) + .output() + .await?; + + if !output.status.success() { + return Err(WorktreeError::GitCommand( + "Target repository has no origin remote configured".to_string(), + )); + } + + let origin_url = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + // Push the branch from worktree to the remote + // First add the remote to worktree + let _ = Command::new("git") + .args(["remote", "remove", "pr-origin"]) + .current_dir(worktree_path) + .output() + .await; + + let output = Command::new("git") + .args(["remote", "add", "pr-origin", &origin_url]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to add remote: {}", + stderr + ))); + } + + // Push to the remote + let output = Command::new("git") + .args(["push", "-u", "pr-origin", &format!("{}:{}", current_branch, source_branch)]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to push branch: {}", + stderr + ))); + } + + // Create PR using gh CLI in the target repo + let output = Command::new("gh") + .args([ + "pr", + "create", + "--title", title, + "--body", body, + "--head", source_branch, + "--base", target_branch, + ]) + .current_dir(target_repo) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to create PR: {}", + stderr + ))); + } + + // The gh CLI outputs the PR URL + let pr_url = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + tracing::info!( + pr_url = %pr_url, + "Pull request created successfully" + ); + + Ok(pr_url) + } + + /// Clone/copy the worktree contents to a target directory. + /// + /// This creates a new git repository at the target path with the same contents + /// as the worktree. Returns (success, message). + pub async fn clone_worktree_to_directory( + &self, + worktree_path: &Path, + target_dir: &Path, + ) -> Result<String, WorktreeError> { + tracing::info!( + worktree = %worktree_path.display(), + target = %target_dir.display(), + "Cloning worktree to target directory" + ); + + // Check if target directory already exists + if target_dir.exists() { + return Err(WorktreeError::AlreadyExists(format!( + "Target directory already exists: {}", + target_dir.display() + ))); + } + + // Get parent directory to ensure it exists + if let Some(parent) = target_dir.parent() { + if !parent.exists() { + tokio::fs::create_dir_all(parent).await?; + } + } + + // Use git clone --local to efficiently copy the repository + // This is more efficient than cp -r for git repos + let output = Command::new("git") + .args([ + "clone", + "--local", + "--no-hardlinks", + &worktree_path.to_string_lossy(), + &target_dir.to_string_lossy(), + ]) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::CloneFailed(format!( + "Failed to clone worktree: {}", + stderr + ))); + } + + // Remove the 'origin' remote that points back to the worktree + let _ = Command::new("git") + .args(["remote", "remove", "origin"]) + .current_dir(target_dir) + .output() + .await; + + tracing::info!( + target = %target_dir.display(), + "Worktree cloned successfully" + ); + + Ok(format!("Cloned to {}", target_dir.display())) + } + + /// Check if a target directory exists. + pub async fn target_directory_exists(&self, target_dir: &Path) -> bool { + target_dir.exists() + } +} + +/// Check if repo_source is a "new repo" request. +/// +/// Accepts `new://` or `new://project-name` to create a fresh git repository. +pub fn is_new_repo_request(source: &str) -> bool { + source == "new" || source == "new://" || source.starts_with("new://") +} + +/// Extract optional project name from new:// URL. +fn extract_new_repo_name(source: &str) -> Option<&str> { + source.strip_prefix("new://").filter(|s| !s.is_empty()) +} + +/// Extract repository name from URL. +fn extract_repo_name(url: &str) -> String { + // Handle various URL formats: + // https://github.com/user/repo.git -> repo + // git@github.com:user/repo.git -> repo + // https://github.com/user/repo -> repo + + let url = url.trim_end_matches('/'); + let url = url.trim_end_matches(".git"); + + url.rsplit('/') + .next() + .or_else(|| url.rsplit(':').next()) + .unwrap_or("repo") + .to_string() +} + +/// Create a short UUID string for directory naming. +pub fn short_uuid(id: Uuid) -> String { + id.to_string()[..8].to_string() +} + +/// Expand tilde (~) in path to home directory. +pub fn expand_tilde(path: &str) -> PathBuf { + if let Some(rest) = path.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(rest); + } + } else if path == "~" { + if let Some(home) = dirs::home_dir() { + return home; + } + } + PathBuf::from(path) +} + +/// Sanitize a name for use in directory/branch names. +pub fn sanitize_name(name: &str) -> String { + name.chars() + .map(|c| { + if c.is_alphanumeric() || c == '-' || c == '_' { + c.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::<String>() + .chars() + .take(50) // Limit length + .collect() +} + +#[cfg(test)] +mod tests { + use crate::daemon::*; + + #[test] + fn test_extract_repo_name() { + assert_eq!( + extract_repo_name("https://github.com/user/repo.git"), + "repo" + ); + assert_eq!( + extract_repo_name("https://github.com/user/repo"), + "repo" + ); + assert_eq!( + extract_repo_name("git@github.com:user/repo.git"), + "repo" + ); + } + + #[test] + fn test_sanitize_name() { + assert_eq!(sanitize_name("Hello World!"), "hello-world-"); + assert_eq!(sanitize_name("test_name-123"), "test_name-123"); + assert_eq!(sanitize_name("A".repeat(100).as_str()).len(), 50); + } + + #[test] + fn test_short_uuid() { + let id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + assert_eq!(short_uuid(id), "550e8400"); + } +} diff --git a/makima/src/daemon/worktree/mod.rs b/makima/src/daemon/worktree/mod.rs new file mode 100644 index 0000000..eb9f031 --- /dev/null +++ b/makima/src/daemon/worktree/mod.rs @@ -0,0 +1,11 @@ +//! Git worktree management for task isolation. +//! +//! Each task gets a unique git worktree with its own branch, +//! providing isolation without the overhead of Docker containers. + +mod manager; + +pub use manager::{ + expand_tilde, is_new_repo_request, sanitize_name, short_uuid, ConflictResolution, MergeState, + TaskBranchInfo, WorktreeError, WorktreeInfo, WorktreeManager, +}; diff --git a/makima/src/daemon/ws/client.rs b/makima/src/daemon/ws/client.rs new file mode 100644 index 0000000..67594a2 --- /dev/null +++ b/makima/src/daemon/ws/client.rs @@ -0,0 +1,290 @@ +//! WebSocket client for connecting to the makima server. + +use std::sync::Arc; +use std::time::Duration; + +use backoff::backoff::Backoff; +use backoff::ExponentialBackoff; +use futures::{SinkExt, StreamExt}; +use tokio::sync::{mpsc, RwLock}; +use tokio_tungstenite::{connect_async, tungstenite::{client::IntoClientRequest, Message}}; +use uuid::Uuid; + +use super::protocol::{DaemonCommand, DaemonMessage}; +use crate::daemon::config::ServerConfig; +use crate::daemon::error::{DaemonError, Result}; + +/// WebSocket client state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConnectionState { + /// Not connected to server. + Disconnected, + /// Currently connecting. + Connecting, + /// Connected and authenticated. + Connected, + /// Connection failed, will retry. + Reconnecting, + /// Permanently failed (e.g., auth failure). + Failed, +} + +/// WebSocket client for daemon-server communication. +pub struct WsClient { + config: ServerConfig, + machine_id: String, + hostname: String, + max_concurrent_tasks: i32, + state: Arc<RwLock<ConnectionState>>, + daemon_id: Arc<RwLock<Option<Uuid>>>, + /// Channel to receive messages to send to server. + outgoing_rx: mpsc::Receiver<DaemonMessage>, + /// Sender for outgoing messages (clone this to send messages). + outgoing_tx: mpsc::Sender<DaemonMessage>, + /// Channel to send received commands to the task manager. + incoming_tx: mpsc::Sender<DaemonCommand>, +} + +impl WsClient { + /// Create a new WebSocket client. + pub fn new( + config: ServerConfig, + machine_id: String, + hostname: String, + max_concurrent_tasks: i32, + incoming_tx: mpsc::Sender<DaemonCommand>, + ) -> Self { + let (outgoing_tx, outgoing_rx) = mpsc::channel(256); + + Self { + config, + machine_id, + hostname, + max_concurrent_tasks, + state: Arc::new(RwLock::new(ConnectionState::Disconnected)), + daemon_id: Arc::new(RwLock::new(None)), + outgoing_rx, + outgoing_tx, + incoming_tx, + } + } + + /// Get a sender for outgoing messages. + pub fn sender(&self) -> mpsc::Sender<DaemonMessage> { + self.outgoing_tx.clone() + } + + /// Get current connection state. + pub async fn state(&self) -> ConnectionState { + *self.state.read().await + } + + /// Get daemon ID if authenticated. + pub async fn daemon_id(&self) -> Option<Uuid> { + *self.daemon_id.read().await + } + + /// Run the WebSocket client with automatic reconnection. + pub async fn run(&mut self) -> Result<()> { + let mut backoff = ExponentialBackoff { + initial_interval: Duration::from_secs(self.config.reconnect_interval_secs), + max_interval: Duration::from_secs(60), + max_elapsed_time: if self.config.max_reconnect_attempts > 0 { + Some(Duration::from_secs( + self.config.reconnect_interval_secs * self.config.max_reconnect_attempts as u64 * 10, + )) + } else { + None // Infinite retries + }, + ..Default::default() + }; + + loop { + *self.state.write().await = ConnectionState::Connecting; + tracing::info!("Connecting to server: {}", self.config.url); + + match self.connect_and_run().await { + Ok(()) => { + // Clean shutdown + tracing::info!("WebSocket connection closed cleanly"); + break; + } + Err(DaemonError::AuthFailed(msg)) => { + tracing::error!("Authentication failed: {}", msg); + *self.state.write().await = ConnectionState::Failed; + return Err(DaemonError::AuthFailed(msg)); + } + Err(e) => { + tracing::warn!("Connection error: {}", e); + *self.state.write().await = ConnectionState::Reconnecting; + + if let Some(delay) = backoff.next_backoff() { + tracing::info!("Reconnecting in {:?}...", delay); + tokio::time::sleep(delay).await; + } else { + tracing::error!("Max reconnection attempts reached"); + *self.state.write().await = ConnectionState::Failed; + return Err(DaemonError::ConnectionLost); + } + } + } + } + + Ok(()) + } + + /// Connect to server and run the message loop. + async fn connect_and_run(&mut self) -> Result<()> { + // Build WebSocket URL + let ws_url = format!("{}/api/v1/mesh/daemons/connect", self.config.url); + tracing::debug!("Connecting to WebSocket: {}", ws_url); + + // Build request with API key header + let mut request = ws_url.into_client_request()?; + request.headers_mut().insert( + "x-makima-api-key", + self.config.api_key.parse().map_err(|_| { + DaemonError::AuthFailed("Invalid API key format".into()) + })?, + ); + + // Connect with API key in headers + let (ws_stream, _response) = connect_async(request).await?; + let (mut write, mut read) = ws_stream.split(); + + // Send daemon info after connection (server authenticated us via header) + let info_msg = DaemonMessage::authenticate( + &self.config.api_key, + &self.machine_id, + &self.hostname, + self.max_concurrent_tasks, + ); + let info_json = serde_json::to_string(&info_msg)?; + write.send(Message::Text(info_json)).await?; + + // Wait for authentication response + let auth_response = read + .next() + .await + .ok_or(DaemonError::ConnectionLost)??; + + let auth_text = match auth_response { + Message::Text(text) => text, + Message::Close(_) => return Err(DaemonError::ConnectionLost), + _ => return Err(DaemonError::AuthFailed("Unexpected response type".into())), + }; + + let command: DaemonCommand = serde_json::from_str(&auth_text)?; + match command { + DaemonCommand::Authenticated { daemon_id } => { + tracing::info!("Authenticated with daemon ID: {}", daemon_id); + *self.daemon_id.write().await = Some(daemon_id); + *self.state.write().await = ConnectionState::Connected; + + // Send daemon directories info to server + let working_directory = std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| ".".to_string()); + let home_directory = dirs::home_dir() + .map(|h| h.join(".makima").join("home")) + .unwrap_or_else(|| std::path::PathBuf::from("~/.makima/home")); + // Create home directory if it doesn't exist + if let Err(e) = std::fs::create_dir_all(&home_directory) { + tracing::warn!("Failed to create home directory {:?}: {}", home_directory, e); + } + let home_directory_str = home_directory.to_string_lossy().to_string(); + let worktrees_directory = dirs::home_dir() + .map(|h| h.join(".makima").join("worktrees").to_string_lossy().to_string()) + .unwrap_or_else(|| "~/.makima/worktrees".to_string()); + + let dirs_msg = DaemonMessage::DaemonDirectories { + working_directory, + home_directory: home_directory_str, + worktrees_directory, + }; + let dirs_json = serde_json::to_string(&dirs_msg)?; + write.send(Message::Text(dirs_json)).await?; + tracing::info!("Sent daemon directories info to server"); + } + DaemonCommand::Error { code, message } => { + return Err(DaemonError::AuthFailed(format!("{}: {}", code, message))); + } + _ => { + return Err(DaemonError::AuthFailed( + "Unexpected response to authentication".into(), + )); + } + } + + // Start main message loop + let heartbeat_interval = Duration::from_secs(self.config.heartbeat_interval_secs); + let mut heartbeat_timer = tokio::time::interval(heartbeat_interval); + + loop { + tokio::select! { + // Handle incoming server commands + msg = read.next() => { + match msg { + Some(Ok(Message::Text(text))) => { + tracing::info!("Received WebSocket message: {} bytes", text.len()); + match serde_json::from_str::<DaemonCommand>(&text) { + Ok(command) => { + tracing::info!("Parsed command: {:?}", command); + tracing::info!("Sending command to task manager channel..."); + if self.incoming_tx.send(command).await.is_err() { + tracing::warn!("Command receiver dropped, shutting down"); + break; + } + tracing::info!("Command sent to task manager successfully"); + } + Err(e) => { + tracing::warn!("Failed to parse server message: {}", e); + tracing::debug!("Raw message: {}", text); + } + } + } + Some(Ok(Message::Ping(data))) => { + write.send(Message::Pong(data)).await?; + } + Some(Ok(Message::Close(_))) | None => { + tracing::info!("Server closed connection"); + return Err(DaemonError::ConnectionLost); + } + Some(Err(e)) => { + tracing::warn!("WebSocket error: {}", e); + return Err(e.into()); + } + _ => {} + } + } + + // Handle outgoing messages + msg = self.outgoing_rx.recv() => { + match msg { + Some(message) => { + let json = serde_json::to_string(&message)?; + tracing::trace!("Sending message: {}", json); + write.send(Message::Text(json)).await?; + } + None => { + // Sender dropped, shutdown + tracing::info!("Outgoing channel closed, shutting down"); + break; + } + } + } + + // Send heartbeat + _ = heartbeat_timer.tick() => { + // Get active task IDs from task manager + // For now, send empty list - will be connected to task manager + let heartbeat = DaemonMessage::heartbeat(vec![]); + let json = serde_json::to_string(&heartbeat)?; + write.send(Message::Text(json)).await?; + } + } + } + + Ok(()) + } +} diff --git a/makima/src/daemon/ws/mod.rs b/makima/src/daemon/ws/mod.rs new file mode 100644 index 0000000..5a0e9d1 --- /dev/null +++ b/makima/src/daemon/ws/mod.rs @@ -0,0 +1,7 @@ +//! WebSocket client and protocol types for daemon-server communication. + +pub mod client; +pub mod protocol; + +pub use client::{ConnectionState, WsClient}; +pub use protocol::{BranchInfo, DaemonCommand, DaemonMessage}; diff --git a/makima/src/daemon/ws/protocol.rs b/makima/src/daemon/ws/protocol.rs new file mode 100644 index 0000000..e86a577 --- /dev/null +++ b/makima/src/daemon/ws/protocol.rs @@ -0,0 +1,658 @@ +//! Protocol types for daemon-server communication. +//! +//! These types mirror the server's protocol exactly for compatibility. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Message from daemon to server. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum DaemonMessage { + /// Authentication request (first message required). + Authenticate { + #[serde(rename = "apiKey")] + api_key: String, + #[serde(rename = "machineId")] + machine_id: String, + hostname: String, + #[serde(rename = "maxConcurrentTasks")] + max_concurrent_tasks: i32, + }, + + /// Periodic heartbeat with current status. + Heartbeat { + #[serde(rename = "activeTasks")] + active_tasks: Vec<Uuid>, + }, + + /// Task output streaming (stdout/stderr from Claude Code). + TaskOutput { + #[serde(rename = "taskId")] + task_id: Uuid, + output: String, + #[serde(rename = "isPartial")] + is_partial: bool, + }, + + /// Task status change notification. + TaskStatusChange { + #[serde(rename = "taskId")] + task_id: Uuid, + #[serde(rename = "oldStatus")] + old_status: String, + #[serde(rename = "newStatus")] + new_status: String, + }, + + /// Task progress update with summary. + TaskProgress { + #[serde(rename = "taskId")] + task_id: Uuid, + summary: String, + }, + + /// Task completion notification. + TaskComplete { + #[serde(rename = "taskId")] + task_id: Uuid, + success: bool, + error: Option<String>, + }, + + /// Register a tool key for orchestrator API access. + RegisterToolKey { + #[serde(rename = "taskId")] + task_id: Uuid, + /// The API key for this orchestrator to use when calling mesh endpoints. + key: String, + }, + + /// Revoke a tool key when task completes. + RevokeToolKey { + #[serde(rename = "taskId")] + task_id: Uuid, + }, + + /// Authentication required - OAuth token expired, provides login URL. + AuthenticationRequired { + /// Task ID that triggered the auth error (if any). + #[serde(rename = "taskId")] + task_id: Option<Uuid>, + /// OAuth login URL for remote authentication. + #[serde(rename = "loginUrl")] + login_url: String, + /// Hostname of the daemon requiring auth. + hostname: Option<String>, + }, + + // ========================================================================= + // Merge Response Messages (sent by daemon after processing merge commands) + // ========================================================================= + + /// Response to ListBranches command. + BranchList { + #[serde(rename = "taskId")] + task_id: Uuid, + branches: Vec<BranchInfo>, + }, + + /// Response to MergeStatus command. + MergeStatusResponse { + #[serde(rename = "taskId")] + task_id: Uuid, + #[serde(rename = "inProgress")] + in_progress: bool, + #[serde(rename = "sourceBranch")] + source_branch: Option<String>, + #[serde(rename = "conflictedFiles")] + conflicted_files: Vec<String>, + }, + + /// Response to merge operations (MergeStart, MergeResolve, MergeCommit, MergeAbort). + MergeResult { + #[serde(rename = "taskId")] + task_id: Uuid, + success: bool, + message: String, + #[serde(rename = "commitSha")] + commit_sha: Option<String>, + /// Present only when conflicts occurred. + conflicts: Option<Vec<String>>, + }, + + /// Response to CheckMergeComplete command. + MergeCompleteCheck { + #[serde(rename = "taskId")] + task_id: Uuid, + #[serde(rename = "canComplete")] + can_complete: bool, + #[serde(rename = "unmergedBranches")] + unmerged_branches: Vec<String>, + #[serde(rename = "mergedCount")] + merged_count: u32, + #[serde(rename = "skippedCount")] + skipped_count: u32, + }, + + // ========================================================================= + // Completion Action Response Messages + // ========================================================================= + + /// Response to RetryCompletionAction command. + CompletionActionResult { + #[serde(rename = "taskId")] + task_id: Uuid, + success: bool, + message: String, + /// PR URL if action was "pr" and successful. + #[serde(rename = "prUrl")] + pr_url: Option<String>, + }, + + /// Report daemon's available directories for task output. + DaemonDirectories { + /// Current working directory of the daemon. + #[serde(rename = "workingDirectory")] + working_directory: String, + /// Path to ~/.makima/home directory (for cloning completed work). + #[serde(rename = "homeDirectory")] + home_directory: String, + /// Path to worktrees directory (~/.makima/worktrees). + #[serde(rename = "worktreesDirectory")] + worktrees_directory: String, + }, + + /// Response to CloneWorktree command. + CloneWorktreeResult { + #[serde(rename = "taskId")] + task_id: Uuid, + success: bool, + message: String, + /// The path where the worktree was cloned. + #[serde(rename = "targetDir")] + target_dir: Option<String>, + }, + + /// Response to CheckTargetExists command. + CheckTargetExistsResult { + #[serde(rename = "taskId")] + task_id: Uuid, + /// Whether the target directory exists. + exists: bool, + /// The path that was checked. + #[serde(rename = "targetDir")] + target_dir: String, + }, + + // ========================================================================= + // Contract File Response Messages + // ========================================================================= + + /// Response to ReadRepoFile command. + RepoFileContent { + /// Request ID from the original command. + #[serde(rename = "requestId")] + request_id: Uuid, + /// Path to the file that was read. + #[serde(rename = "filePath")] + file_path: String, + /// File content (None if error occurred). + content: Option<String>, + /// Whether the operation succeeded. + success: bool, + /// Error message if operation failed. + error: Option<String>, + }, + + // ========================================================================= + // Supervisor Git Response Messages + // ========================================================================= + + /// Response to CreateBranch command. + BranchCreated { + #[serde(rename = "taskId")] + task_id: Uuid, + success: bool, + #[serde(rename = "branchName")] + branch_name: String, + message: String, + }, + + /// Response to MergeTaskToTarget command. + MergeToTargetResult { + #[serde(rename = "taskId")] + task_id: Uuid, + success: bool, + message: String, + #[serde(rename = "commitSha")] + commit_sha: Option<String>, + conflicts: Option<Vec<String>>, + }, + + /// Response to CreatePR command. + PRCreated { + #[serde(rename = "taskId")] + task_id: Uuid, + success: bool, + message: String, + #[serde(rename = "prUrl")] + pr_url: Option<String>, + #[serde(rename = "prNumber")] + pr_number: Option<i32>, + }, + + /// Response to GetTaskDiff command. + TaskDiff { + #[serde(rename = "taskId")] + task_id: Uuid, + success: bool, + diff: Option<String>, + error: Option<String>, + }, +} + +/// Information about a branch (used in BranchList message). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BranchInfo { + /// Full branch name. + pub name: String, + /// Task ID extracted from branch name (if parseable). + #[serde(rename = "taskId")] + pub task_id: Option<Uuid>, + /// Whether this branch has been merged. + #[serde(rename = "isMerged")] + pub is_merged: bool, + /// Short SHA of the last commit. + #[serde(rename = "lastCommit")] + pub last_commit: String, + /// Subject line of the last commit. + #[serde(rename = "lastCommitMessage")] + pub last_commit_message: String, +} + +/// Command from server to daemon. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum DaemonCommand { + /// Confirm successful authentication. + Authenticated { + #[serde(rename = "daemonId")] + daemon_id: Uuid, + }, + + /// Spawn a new task in a container. + SpawnTask { + #[serde(rename = "taskId")] + task_id: Uuid, + /// Human-readable task name (used for commit messages). + #[serde(rename = "taskName")] + task_name: String, + plan: String, + #[serde(rename = "repoUrl")] + repo_url: Option<String>, + #[serde(rename = "baseBranch")] + base_branch: Option<String>, + /// Target branch to merge into (used for completion actions). + #[serde(rename = "targetBranch")] + target_branch: Option<String>, + /// Parent task ID if this is a subtask. + #[serde(rename = "parentTaskId")] + parent_task_id: Option<Uuid>, + /// Depth in task hierarchy (0=top-level, 1=subtask, 2=sub-subtask). + depth: i32, + /// Whether this task should run as an orchestrator (true if depth==0 and has subtasks). + #[serde(rename = "isOrchestrator")] + is_orchestrator: bool, + /// Path to user's local repository (outside ~/.makima) for completion actions. + #[serde(rename = "targetRepoPath")] + target_repo_path: Option<String>, + /// Action on completion: "none", "branch", "merge", "pr". + #[serde(rename = "completionAction")] + completion_action: Option<String>, + /// Task ID to continue from (copy worktree from this task). + #[serde(rename = "continueFromTaskId")] + continue_from_task_id: Option<Uuid>, + /// Files to copy from parent task's worktree. + #[serde(rename = "copyFiles")] + copy_files: Option<Vec<String>>, + /// Contract ID if this task is associated with a contract. + #[serde(rename = "contractId")] + contract_id: Option<Uuid>, + /// Whether this task is a supervisor (long-running contract orchestrator). + #[serde(rename = "isSupervisor", default)] + is_supervisor: bool, + }, + + /// Pause a running task. + PauseTask { + #[serde(rename = "taskId")] + task_id: Uuid, + }, + + /// Resume a paused task. + ResumeTask { + #[serde(rename = "taskId")] + task_id: Uuid, + }, + + /// Interrupt a task (gracefully or forced). + InterruptTask { + #[serde(rename = "taskId")] + task_id: Uuid, + graceful: bool, + }, + + /// Send a message to a running task. + SendMessage { + #[serde(rename = "taskId")] + task_id: Uuid, + message: String, + }, + + /// Inject context about sibling task progress. + InjectSiblingContext { + #[serde(rename = "taskId")] + task_id: Uuid, + #[serde(rename = "siblingTaskId")] + sibling_task_id: Uuid, + #[serde(rename = "siblingName")] + sibling_name: String, + #[serde(rename = "siblingStatus")] + sibling_status: String, + #[serde(rename = "progressSummary")] + progress_summary: Option<String>, + #[serde(rename = "changedFiles")] + changed_files: Vec<String>, + }, + + // ========================================================================= + // Merge Commands (for orchestrators to merge subtask branches) + // ========================================================================= + + /// List all subtask branches for a task. + ListBranches { + #[serde(rename = "taskId")] + task_id: Uuid, + }, + + /// Start merging a subtask branch. + MergeStart { + #[serde(rename = "taskId")] + task_id: Uuid, + #[serde(rename = "sourceBranch")] + source_branch: String, + }, + + /// Get current merge status. + MergeStatus { + #[serde(rename = "taskId")] + task_id: Uuid, + }, + + /// Resolve a merge conflict. + MergeResolve { + #[serde(rename = "taskId")] + task_id: Uuid, + file: String, + /// "ours" or "theirs" + strategy: String, + }, + + /// Commit the current merge. + MergeCommit { + #[serde(rename = "taskId")] + task_id: Uuid, + message: String, + }, + + /// Abort the current merge. + MergeAbort { + #[serde(rename = "taskId")] + task_id: Uuid, + }, + + /// Skip merging a subtask branch (mark as intentionally not merged). + MergeSkip { + #[serde(rename = "taskId")] + task_id: Uuid, + #[serde(rename = "subtaskId")] + subtask_id: Uuid, + reason: String, + }, + + /// Check if all subtask branches have been merged or skipped (completion gate). + CheckMergeComplete { + #[serde(rename = "taskId")] + task_id: Uuid, + }, + + // ========================================================================= + // Completion Action Commands + // ========================================================================= + + /// Retry a completion action for a completed task. + RetryCompletionAction { + #[serde(rename = "taskId")] + task_id: Uuid, + /// Human-readable task name (used for commit messages). + #[serde(rename = "taskName")] + task_name: String, + /// The action to execute: "branch", "merge", or "pr". + action: String, + /// Path to the target repository. + #[serde(rename = "targetRepoPath")] + target_repo_path: String, + /// Target branch to merge into (for merge/pr actions). + #[serde(rename = "targetBranch")] + target_branch: Option<String>, + }, + + /// Clone worktree to a target directory. + CloneWorktree { + #[serde(rename = "taskId")] + task_id: Uuid, + /// Path to the target directory. + #[serde(rename = "targetDir")] + target_dir: String, + }, + + /// Check if a target directory exists. + CheckTargetExists { + #[serde(rename = "taskId")] + task_id: Uuid, + /// Path to check. + #[serde(rename = "targetDir")] + target_dir: String, + }, + + // ========================================================================= + // Contract File Commands + // ========================================================================= + + /// Read a file from a repository linked to a contract. + ReadRepoFile { + /// Request ID for correlating response. + #[serde(rename = "requestId")] + request_id: Uuid, + /// Contract ID (used for logging/context). + #[serde(rename = "contractId")] + contract_id: Uuid, + /// Path to the file within the repository. + #[serde(rename = "filePath")] + file_path: String, + /// Full repository path on daemon's filesystem. + #[serde(rename = "repoPath")] + repo_path: String, + }, + + // ========================================================================= + // Supervisor Git Commands + // ========================================================================= + + /// Create a new branch in the supervisor's worktree. + CreateBranch { + #[serde(rename = "taskId")] + task_id: Uuid, + #[serde(rename = "branchName")] + branch_name: String, + /// Optional reference to create branch from (task_id or SHA). + #[serde(rename = "fromRef")] + from_ref: Option<String>, + }, + + /// Merge a task's changes to a target branch. + MergeTaskToTarget { + #[serde(rename = "taskId")] + task_id: Uuid, + /// Target branch to merge into (default: task's base branch). + #[serde(rename = "targetBranch")] + target_branch: Option<String>, + /// Whether to squash commits. + squash: bool, + }, + + /// Create a pull request for a task's changes. + CreatePR { + #[serde(rename = "taskId")] + task_id: Uuid, + title: String, + body: Option<String>, + /// Base branch for the PR (default: main). + #[serde(rename = "baseBranch")] + base_branch: String, + }, + + /// Get the diff for a task's changes. + GetTaskDiff { + #[serde(rename = "taskId")] + task_id: Uuid, + }, + + /// Error response. + Error { + code: String, + message: String, + }, +} + +impl DaemonMessage { + /// Create an authentication message. + pub fn authenticate( + api_key: &str, + machine_id: &str, + hostname: &str, + max_concurrent_tasks: i32, + ) -> Self { + Self::Authenticate { + api_key: api_key.to_string(), + machine_id: machine_id.to_string(), + hostname: hostname.to_string(), + max_concurrent_tasks, + } + } + + /// Create a heartbeat message. + pub fn heartbeat(active_tasks: Vec<Uuid>) -> Self { + Self::Heartbeat { active_tasks } + } + + /// Create a task output message. + pub fn task_output(task_id: Uuid, output: String, is_partial: bool) -> Self { + Self::TaskOutput { + task_id, + output, + is_partial, + } + } + + /// Create a task status change message. + pub fn task_status_change(task_id: Uuid, old_status: &str, new_status: &str) -> Self { + Self::TaskStatusChange { + task_id, + old_status: old_status.to_string(), + new_status: new_status.to_string(), + } + } + + /// Create a task progress message. + pub fn task_progress(task_id: Uuid, summary: String) -> Self { + Self::TaskProgress { task_id, summary } + } + + /// Create a task complete message. + pub fn task_complete(task_id: Uuid, success: bool, error: Option<String>) -> Self { + Self::TaskComplete { + task_id, + success, + error, + } + } + + /// Create a register tool key message. + pub fn register_tool_key(task_id: Uuid, key: String) -> Self { + Self::RegisterToolKey { task_id, key } + } + + /// Create a revoke tool key message. + pub fn revoke_tool_key(task_id: Uuid) -> Self { + Self::RevokeToolKey { task_id } + } +} + +#[cfg(test)] +mod tests { + use crate::daemon::*; + + #[test] + fn test_daemon_message_serialization() { + let msg = DaemonMessage::authenticate("key123", "machine-abc", "worker-1", 4); + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains("\"type\":\"authenticate\"")); + assert!(json.contains("\"apiKey\":\"key123\"")); + assert!(json.contains("\"machineId\":\"machine-abc\"")); + } + + #[test] + fn test_daemon_command_deserialization() { + let json = r#"{"type":"spawnTask","taskId":"550e8400-e29b-41d4-a716-446655440000","plan":"Build the feature","repoUrl":"https://github.com/test/repo","baseBranch":"main","parentTaskId":null,"depth":0,"isOrchestrator":false}"#; + let cmd: DaemonCommand = serde_json::from_str(json).unwrap(); + match cmd { + DaemonCommand::SpawnTask { + plan, + repo_url, + base_branch, + parent_task_id, + depth, + is_orchestrator, + .. + } => { + assert_eq!(plan, "Build the feature"); + assert_eq!(repo_url, Some("https://github.com/test/repo".to_string())); + assert_eq!(base_branch, Some("main".to_string())); + assert_eq!(parent_task_id, None); + assert_eq!(depth, 0); + assert!(!is_orchestrator); + } + _ => panic!("Expected SpawnTask"), + } + } + + #[test] + fn test_orchestrator_spawn_deserialization() { + let json = r#"{"type":"spawnTask","taskId":"550e8400-e29b-41d4-a716-446655440000","plan":"Coordinate subtasks","repoUrl":"https://github.com/test/repo","baseBranch":"main","parentTaskId":null,"depth":0,"isOrchestrator":true}"#; + let cmd: DaemonCommand = serde_json::from_str(json).unwrap(); + match cmd { + DaemonCommand::SpawnTask { + is_orchestrator, + depth, + .. + } => { + assert!(is_orchestrator); + assert_eq!(depth, 0); + } + _ => panic!("Expected SpawnTask"), + } + } +} diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index 5064b97..e16c43f 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -60,6 +60,8 @@ pub enum BodyElement { alt: Option<String>, caption: Option<String>, }, + /// Raw markdown content - renders as formatted markdown, edits as raw text + Markdown { content: String }, } /// File record from the database. @@ -68,6 +70,10 @@ pub enum BodyElement { pub struct File { pub id: Uuid, pub owner_id: Uuid, + /// Contract this file belongs to (optional) + pub contract_id: Option<Uuid>, + /// Phase of the contract when file was added (e.g., "research", "specify") + pub contract_phase: Option<String>, pub name: String, pub description: Option<String>, #[sqlx(json)] @@ -80,6 +86,12 @@ pub struct File { pub body: Vec<BodyElement>, /// Version number for optimistic locking pub version: i32, + /// Path to linked repository file (e.g., "README.md", "docs/design.md") + pub repo_file_path: Option<String>, + /// When the file was last synced from the repository + pub repo_synced_at: Option<DateTime<Utc>>, + /// Sync status: 'none', 'synced', 'modified', 'conflict' + pub repo_sync_status: Option<String>, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>, } @@ -88,14 +100,24 @@ pub struct File { #[derive(Debug, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct CreateFileRequest { + /// Contract this file belongs to (required - files must belong to a contract) + pub contract_id: Uuid, /// Name of the file (auto-generated if not provided) pub name: Option<String>, /// Optional description pub description: Option<String>, - /// Transcript entries + /// Transcript entries (default to empty) + #[serde(default)] pub transcript: Vec<TranscriptEntry>, /// Storage location (e.g., s3://bucket/path) - not used yet pub location: Option<String>, + /// Initial body elements (e.g., from a template) + #[serde(default)] + pub body: Vec<BodyElement>, + /// Path to linked repository file (e.g., "README.md") + pub repo_file_path: Option<String>, + /// Contract phase this file belongs to (for deliverable tracking) + pub contract_phase: Option<String>, } /// Request payload for updating an existing file. @@ -114,6 +136,8 @@ pub struct UpdateFileRequest { pub body: Option<Vec<BodyElement>>, /// Version for optimistic locking (required for updates from frontend) pub version: Option<i32>, + /// Path to linked repository file (e.g., "README.md") + pub repo_file_path: Option<String>, } /// Response for file list endpoint. @@ -129,6 +153,12 @@ pub struct FileListResponse { #[serde(rename_all = "camelCase")] pub struct FileSummary { pub id: Uuid, + /// Contract this file belongs to + pub contract_id: Option<Uuid>, + /// Contract name (joined from contracts table) + pub contract_name: Option<String>, + /// Phase when file was added to contract + pub contract_phase: Option<String>, pub name: String, pub description: Option<String>, pub transcript_count: usize, @@ -136,6 +166,10 @@ pub struct FileSummary { pub duration: Option<f32>, /// Version number for optimistic locking pub version: i32, + /// Path to linked repository file + pub repo_file_path: Option<String>, + /// Sync status with repository + pub repo_sync_status: Option<String>, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>, } @@ -149,11 +183,16 @@ impl From<File> for FileSummary { .fold(0.0_f32, f32::max); Self { id: file.id, + contract_id: file.contract_id, + contract_name: None, // Not available from File alone, requires JOIN + contract_phase: file.contract_phase, name: file.name, description: file.description, transcript_count: file.transcript.len(), duration: if duration > 0.0 { Some(duration) } else { None }, version: file.version, + repo_file_path: file.repo_file_path, + repo_sync_status: file.repo_sync_status, created_at: file.created_at, updated_at: file.updated_at, } @@ -345,8 +384,10 @@ impl std::str::FromStr for MergeMode { pub struct Task { pub id: Uuid, pub owner_id: Uuid, + /// Contract this task belongs to (required for new tasks) + pub contract_id: Option<Uuid>, pub parent_task_id: Option<Uuid>, - /// Depth in task hierarchy: 0=orchestrator (top-level), 1=subtask (max) + /// Depth in task hierarchy (no longer constrained) pub depth: i32, pub name: String, pub description: Option<String>, @@ -354,6 +395,11 @@ pub struct Task { pub priority: i32, pub plan: String, + // Supervisor flag + /// True for contract supervisor tasks. Only supervisors can spawn new tasks. + #[serde(default)] + pub is_supervisor: bool, + // Daemon/container info pub daemon_id: Option<Uuid>, pub container_id: Option<String>, @@ -379,6 +425,30 @@ pub struct Task { pub last_output: Option<String>, pub error_message: Option<String>, + // Git checkpoint tracking + /// Git commit SHA of the most recent checkpoint + #[serde(skip_serializing_if = "Option::is_none")] + pub last_checkpoint_sha: Option<String>, + /// Number of checkpoints created by this task + #[serde(default)] + pub checkpoint_count: i32, + /// Message from the most recent checkpoint + #[serde(skip_serializing_if = "Option::is_none")] + pub checkpoint_message: Option<String>, + + // Conversation state for resumption + /// Saved conversation context for task/supervisor resumption + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_state: Option<serde_json::Value>, + + // Daemon migration tracking + /// Previous daemon if task was migrated + #[serde(skip_serializing_if = "Option::is_none")] + pub migrated_from_daemon_id: Option<Uuid>, + /// Most recent daemon that worked on this task + #[serde(skip_serializing_if = "Option::is_none")] + pub last_active_daemon_id: Option<Uuid>, + // Timestamps pub started_at: Option<DateTime<Utc>>, pub completed_at: Option<DateTime<Utc>>, @@ -413,6 +483,12 @@ impl Task { #[serde(rename_all = "camelCase")] pub struct TaskSummary { pub id: Uuid, + /// Contract this task belongs to + pub contract_id: Option<Uuid>, + /// Contract name (joined from contracts table) + pub contract_name: Option<String>, + /// Contract phase (joined from contracts table) + pub contract_phase: Option<String>, pub parent_task_id: Option<Uuid>, /// Depth in task hierarchy: 0=orchestrator (top-level), 1=subtask (max) pub depth: i32, @@ -422,10 +498,36 @@ pub struct TaskSummary { pub progress_summary: Option<String>, pub subtask_count: i64, pub version: i32, + /// True for contract supervisor tasks + #[serde(default)] + pub is_supervisor: bool, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>, } +/// Convert a full Task to a TaskSummary +impl From<Task> for TaskSummary { + fn from(task: Task) -> Self { + Self { + id: task.id, + contract_id: task.contract_id, + contract_name: None, // Not available from Task directly + contract_phase: None, // Not available from Task directly + parent_task_id: task.parent_task_id, + depth: task.depth, + name: task.name, + status: task.status, + priority: task.priority, + progress_summary: task.progress_summary, + subtask_count: 0, // Would need separate query + version: task.version, + is_supervisor: task.is_supervisor, + created_at: task.created_at, + updated_at: task.updated_at, + } + } +} + /// Response for task list endpoint #[derive(Debug, Serialize, ToSchema)] #[serde(rename_all = "camelCase")] @@ -438,6 +540,8 @@ pub struct TaskListResponse { #[derive(Debug, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct CreateTaskRequest { + /// Contract this task belongs to (required) + pub contract_id: Uuid, /// Name of the task pub name: String, /// Optional description @@ -446,6 +550,9 @@ pub struct CreateTaskRequest { pub plan: String, /// Parent task ID (for subtasks) pub parent_task_id: Option<Uuid>, + /// True for contract supervisor tasks. Only supervisors can spawn new tasks. + #[serde(default)] + pub is_supervisor: bool, /// Priority (higher = more urgent) #[serde(default)] pub priority: i32, @@ -466,6 +573,8 @@ pub struct CreateTaskRequest { /// Files to copy from parent task's worktree when starting #[serde(skip_serializing_if = "Option::is_none")] pub copy_files: Option<Vec<String>>, + /// Checkpoint SHA to branch from (optional) + pub checkpoint_sha: Option<String>, } /// Request payload for updating a task @@ -482,6 +591,8 @@ pub struct UpdateTaskRequest { pub error_message: Option<String>, pub merge_mode: Option<String>, pub pr_url: Option<String>, + /// Repository URL for the task (e.g., when updating supervisor with repo info) + pub repository_url: Option<String>, /// Path to user's local repository (outside ~/.makima) pub target_repo_path: Option<String>, /// Action on completion: "none", "branch", "merge", "pr" @@ -733,6 +844,47 @@ pub struct MeshChatHistoryResponse { } // ============================================================================= +// Contract Chat History Types +// ============================================================================= + +/// Conversation thread for contract chat (scoped to a specific contract) +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ContractChatConversation { + pub id: Uuid, + pub contract_id: Uuid, + pub owner_id: Uuid, + pub name: Option<String>, + pub is_active: bool, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, +} + +/// Individual message in a contract chat conversation +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ContractChatMessageRecord { + pub id: Uuid, + pub conversation_id: Uuid, + pub role: String, + pub content: String, + /// Tool calls made during this message (JSON, nullable) + pub tool_calls: Option<serde_json::Value>, + /// Pending questions requiring user response (JSON, nullable) + pub pending_questions: Option<serde_json::Value>, + pub created_at: DateTime<Utc>, +} + +/// Response for contract chat history endpoint +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ContractChatHistoryResponse { + pub contract_id: Uuid, + pub conversation_id: Uuid, + pub messages: Vec<ContractChatMessageRecord>, +} + +// ============================================================================= // Merge API Types // ============================================================================= @@ -834,3 +986,450 @@ pub struct MergeCompleteCheckResponse { /// Count of skipped branches pub skipped_count: u32, } + +// ============================================================================= +// Contract Types +// ============================================================================= + +/// Contract phase for workflow progression +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum ContractPhase { + Research, + Specify, + Plan, + Execute, + Review, +} + +impl std::fmt::Display for ContractPhase { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ContractPhase::Research => write!(f, "research"), + ContractPhase::Specify => write!(f, "specify"), + ContractPhase::Plan => write!(f, "plan"), + ContractPhase::Execute => write!(f, "execute"), + ContractPhase::Review => write!(f, "review"), + } + } +} + +impl std::str::FromStr for ContractPhase { + type Err = String; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s.to_lowercase().as_str() { + "research" => Ok(ContractPhase::Research), + "specify" => Ok(ContractPhase::Specify), + "plan" => Ok(ContractPhase::Plan), + "execute" => Ok(ContractPhase::Execute), + "review" => Ok(ContractPhase::Review), + _ => Err(format!("Unknown contract phase: {}", s)), + } + } +} + +/// Contract status +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum ContractStatus { + Active, + Completed, + Archived, +} + +impl std::fmt::Display for ContractStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ContractStatus::Active => write!(f, "active"), + ContractStatus::Completed => write!(f, "completed"), + ContractStatus::Archived => write!(f, "archived"), + } + } +} + +impl std::str::FromStr for ContractStatus { + type Err = String; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s.to_lowercase().as_str() { + "active" => Ok(ContractStatus::Active), + "completed" => Ok(ContractStatus::Completed), + "archived" => Ok(ContractStatus::Archived), + _ => Err(format!("Unknown contract status: {}", s)), + } + } +} + +/// Repository source type +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum RepositorySourceType { + /// Existing remote repo (GitHub, GitLab, etc) + Remote, + /// Existing local repo + Local, + /// New repo created/managed by Makima daemon + Managed, +} + +impl std::fmt::Display for RepositorySourceType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RepositorySourceType::Remote => write!(f, "remote"), + RepositorySourceType::Local => write!(f, "local"), + RepositorySourceType::Managed => write!(f, "managed"), + } + } +} + +impl std::str::FromStr for RepositorySourceType { + type Err = String; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s.to_lowercase().as_str() { + "remote" => Ok(RepositorySourceType::Remote), + "local" => Ok(RepositorySourceType::Local), + "managed" => Ok(RepositorySourceType::Managed), + _ => Err(format!("Unknown repository source type: {}", s)), + } + } +} + +/// Repository status (for managed repos) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum RepositoryStatus { + /// Repo is usable + Ready, + /// Waiting for daemon to create + Pending, + /// Daemon is creating the repo + Creating, + /// Creation failed + Failed, +} + +impl std::fmt::Display for RepositoryStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RepositoryStatus::Ready => write!(f, "ready"), + RepositoryStatus::Pending => write!(f, "pending"), + RepositoryStatus::Creating => write!(f, "creating"), + RepositoryStatus::Failed => write!(f, "failed"), + } + } +} + +impl std::str::FromStr for RepositoryStatus { + type Err = String; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s.to_lowercase().as_str() { + "ready" => Ok(RepositoryStatus::Ready), + "pending" => Ok(RepositoryStatus::Pending), + "creating" => Ok(RepositoryStatus::Creating), + "failed" => Ok(RepositoryStatus::Failed), + _ => Err(format!("Unknown repository status: {}", s)), + } + } +} + +/// Contract record from the database +#[derive(Debug, Clone, FromRow, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Contract { + pub id: Uuid, + pub owner_id: Uuid, + pub name: String, + pub description: Option<String>, + pub phase: String, + pub status: String, + /// The long-running supervisor task that orchestrates this contract + #[serde(skip_serializing_if = "Option::is_none")] + pub supervisor_task_id: Option<Uuid>, + pub version: i32, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, +} + +impl Contract { + /// Parse phase string to ContractPhase enum + pub fn phase_enum(&self) -> Result<ContractPhase, String> { + self.phase.parse() + } + + /// Parse status string to ContractStatus enum + pub fn status_enum(&self) -> Result<ContractStatus, String> { + self.status.parse() + } +} + +/// Contract repository record from the database +#[derive(Debug, Clone, FromRow, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ContractRepository { + pub id: Uuid, + pub contract_id: Uuid, + pub name: String, + pub repository_url: Option<String>, + pub local_path: Option<String>, + pub source_type: String, + pub status: String, + pub is_primary: bool, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, +} + +impl ContractRepository { + /// Parse source_type string to RepositorySourceType enum + pub fn source_type_enum(&self) -> Result<RepositorySourceType, String> { + self.source_type.parse() + } + + /// Parse status string to RepositoryStatus enum + pub fn status_enum(&self) -> Result<RepositoryStatus, String> { + self.status.parse() + } +} + +/// Summary of a contract for list views +#[derive(Debug, Clone, FromRow, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ContractSummary { + pub id: Uuid, + pub name: String, + pub description: Option<String>, + pub phase: String, + pub status: String, + pub file_count: i64, + pub task_count: i64, + pub repository_count: i64, + pub version: i32, + pub created_at: DateTime<Utc>, +} + +/// Contract with all relations for detail view +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ContractWithRelations { + #[serde(flatten)] + pub contract: Contract, + pub repositories: Vec<ContractRepository>, + pub files: Vec<FileSummary>, + pub tasks: Vec<TaskSummary>, +} + +/// Response for contract list endpoint +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ContractListResponse { + pub contracts: Vec<ContractSummary>, + pub total: i64, +} + +/// Request payload for creating a new contract +#[derive(Debug, Clone, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateContractRequest { + /// Name of the contract + pub name: String, + /// Optional description + pub description: Option<String>, + /// Initial phase to start in (defaults to "research") + /// Valid values: "research", "specify", "plan", "execute", "review" + #[serde(default)] + pub initial_phase: Option<String>, +} + +/// Request payload for updating a contract +#[derive(Debug, Default, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateContractRequest { + pub name: Option<String>, + pub description: Option<String>, + pub phase: Option<String>, + pub status: Option<String>, + /// Supervisor task ID for contract orchestration + #[serde(skip_serializing_if = "Option::is_none")] + pub supervisor_task_id: Option<Uuid>, + /// Version for optimistic locking + pub version: Option<i32>, +} + +/// Request to add a remote repository to a contract +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AddRemoteRepositoryRequest { + pub name: String, + pub repository_url: String, + #[serde(default)] + pub is_primary: bool, +} + +/// Request to add a local repository to a contract +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AddLocalRepositoryRequest { + pub name: String, + pub local_path: String, + #[serde(default)] + pub is_primary: bool, +} + +/// Request to create a managed repository (daemon will create it) +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateManagedRepositoryRequest { + pub name: String, + #[serde(default)] + pub is_primary: bool, +} + +/// Request to change contract phase +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChangePhaseRequest { + pub phase: String, +} + +/// Contract event record from the database +#[derive(Debug, Clone, FromRow, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ContractEvent { + pub id: Uuid, + pub contract_id: Uuid, + pub event_type: String, + pub previous_phase: Option<String>, + pub new_phase: Option<String>, + #[sqlx(json)] + pub event_data: Option<serde_json::Value>, + pub created_at: DateTime<Utc>, +} + +/// Response for contract events list endpoint +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ContractEventListResponse { + pub events: Vec<ContractEvent>, + pub total: i64, +} + +// ============================================================================ +// Task Checkpoints (for git checkpoint tracking) +// ============================================================================ + +/// Task checkpoint record - represents a git commit checkpoint +#[derive(Debug, Clone, FromRow, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct TaskCheckpoint { + pub id: Uuid, + pub task_id: Uuid, + /// Sequential checkpoint number within this task + pub checkpoint_number: i32, + /// Git commit SHA + pub commit_sha: String, + /// Git branch name + pub branch_name: String, + /// Commit message + pub message: String, + /// Files changed in this commit: [{path, action: 'A'|'M'|'D'}] + #[sqlx(json)] + pub files_changed: Option<serde_json::Value>, + /// Lines added in this commit + pub lines_added: Option<i32>, + /// Lines removed in this commit + pub lines_removed: Option<i32>, + pub created_at: DateTime<Utc>, +} + +/// Request to create a checkpoint +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateCheckpointRequest { + /// Commit message + pub message: String, +} + +/// Response for checkpoint list endpoint +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CheckpointListResponse { + pub checkpoints: Vec<TaskCheckpoint>, + pub total: i64, +} + +// ============================================================================ +// Supervisor State (for supervisor resumability) +// ============================================================================ + +/// Supervisor state for contract supervisor tasks +/// Enables resumption after interruption +#[derive(Debug, Clone, FromRow, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct SupervisorState { + pub id: Uuid, + pub contract_id: Uuid, + pub task_id: Uuid, + /// Full Claude conversation history for resumption + #[sqlx(json)] + pub conversation_history: serde_json::Value, + /// Last checkpoint this supervisor created + pub last_checkpoint_id: Option<Uuid>, + /// Tasks the supervisor is waiting on + #[sqlx(try_from = "Vec<Uuid>")] + pub pending_task_ids: Vec<Uuid>, + /// Current contract phase when supervisor was last active + pub phase: String, + /// When supervisor was last active + pub last_activity: DateTime<Utc>, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, +} + +/// Request to update supervisor state +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSupervisorStateRequest { + /// Updated conversation history + pub conversation_history: Option<serde_json::Value>, + /// Tasks the supervisor is waiting on + pub pending_task_ids: Option<Vec<Uuid>>, + /// Current contract phase + pub phase: Option<String>, +} + +// ============================================================================ +// Daemon Task Assignments (for multi-daemon support) +// ============================================================================ + +/// Daemon task assignment record +#[derive(Debug, Clone, FromRow, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DaemonTaskAssignment { + pub id: Uuid, + pub daemon_id: Uuid, + pub task_id: Uuid, + pub assigned_at: DateTime<Utc>, + /// Status: 'active', 'migrating', 'completed' + pub status: String, +} + +/// Extended daemon info for selection +#[derive(Debug, Clone, FromRow, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DaemonWithCapacity { + pub id: Uuid, + pub owner_id: Uuid, + pub connection_id: String, + pub hostname: Option<String>, + pub machine_id: Option<String>, + pub max_concurrent_tasks: i32, + pub current_task_count: i32, + pub capacity_score: Option<i32>, + pub task_queue_length: Option<i32>, + pub supports_migration: Option<bool>, + pub status: String, + pub last_heartbeat_at: DateTime<Utc>, + pub connected_at: DateTime<Utc>, +} diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index ce1e97d..3b911c2 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -5,8 +5,12 @@ use sqlx::PgPool; use uuid::Uuid; use super::models::{ - CreateFileRequest, CreateTaskRequest, Daemon, File, FileVersion, MeshChatConversation, - MeshChatMessageRecord, Task, TaskEvent, TaskSummary, UpdateFileRequest, UpdateTaskRequest, + Contract, ContractChatConversation, ContractChatMessageRecord, ContractEvent, ContractRepository, + ContractSummary, CreateCheckpointRequest, CreateContractRequest, CreateFileRequest, + CreateTaskRequest, Daemon, DaemonTaskAssignment, DaemonWithCapacity, File, FileSummary, + FileVersion, MeshChatConversation, MeshChatMessageRecord, SupervisorState, Task, TaskCheckpoint, + TaskEvent, TaskSummary, UpdateContractRequest, UpdateFileRequest, UpdateSupervisorStateRequest, + UpdateTaskRequest, }; /// Repository error types. @@ -52,8 +56,18 @@ fn generate_default_name() -> String { now.format("Recording - %b %d %Y %H:%M:%S").to_string() } -/// Create a new file record. -pub async fn create_file(pool: &PgPool, req: CreateFileRequest) -> Result<File, sqlx::Error> { +/// Internal request for creating files without contract association (e.g., audio transcription). +/// User-facing file creation should use CreateFileRequest which requires contract_id. +pub struct InternalCreateFileRequest { + pub name: Option<String>, + pub description: Option<String>, + pub transcript: Vec<super::models::TranscriptEntry>, + pub location: Option<String>, +} + +/// Create a new file record (internal use, no contract required). +/// For user-facing file creation, use create_file_for_owner which requires a contract. +pub async fn create_file(pool: &PgPool, req: InternalCreateFileRequest) -> Result<File, sqlx::Error> { let name = req.name.unwrap_or_else(generate_default_name); let transcript_json = serde_json::to_value(&req.transcript).unwrap_or_default(); let body_json = serde_json::to_value::<Vec<super::models::BodyElement>>(vec![]).unwrap(); @@ -62,7 +76,7 @@ pub async fn create_file(pool: &PgPool, req: CreateFileRequest) -> Result<File, r#" INSERT INTO files (name, description, transcript, location, summary, body) VALUES ($1, $2, $3, $4, NULL, $5) - RETURNING id, owner_id, name, description, transcript, location, summary, body, version, created_at, updated_at + RETURNING id, owner_id, contract_id, contract_phase, name, description, transcript, location, summary, body, version, repo_file_path, repo_synced_at, repo_sync_status, created_at, updated_at "#, ) .bind(&name) @@ -78,7 +92,7 @@ pub async fn create_file(pool: &PgPool, req: CreateFileRequest) -> Result<File, pub async fn get_file(pool: &PgPool, id: Uuid) -> Result<Option<File>, sqlx::Error> { sqlx::query_as::<_, File>( r#" - SELECT id, owner_id, name, description, transcript, location, summary, body, version, created_at, updated_at + SELECT id, owner_id, contract_id, contract_phase, name, description, transcript, location, summary, body, version, repo_file_path, repo_synced_at, repo_sync_status, created_at, updated_at FROM files WHERE id = $1 "#, @@ -92,7 +106,7 @@ pub async fn get_file(pool: &PgPool, id: Uuid) -> Result<Option<File>, sqlx::Err pub async fn list_files(pool: &PgPool) -> Result<Vec<File>, sqlx::Error> { sqlx::query_as::<_, File>( r#" - SELECT id, owner_id, name, description, transcript, location, summary, body, version, created_at, updated_at + SELECT id, owner_id, contract_id, contract_phase, name, description, transcript, location, summary, body, version, repo_file_path, repo_synced_at, repo_sync_status, created_at, updated_at FROM files ORDER BY created_at DESC "#, @@ -144,7 +158,7 @@ pub async fn update_file( UPDATE files SET name = $2, description = $3, transcript = $4, summary = $5, body = $6, updated_at = NOW() WHERE id = $1 AND version = $7 - RETURNING id, owner_id, name, description, transcript, location, summary, body, version, created_at, updated_at + RETURNING id, owner_id, contract_id, contract_phase, name, description, transcript, location, summary, body, version, repo_file_path, repo_synced_at, repo_sync_status, created_at, updated_at "#, ) .bind(id) @@ -163,7 +177,7 @@ pub async fn update_file( UPDATE files SET name = $2, description = $3, transcript = $4, summary = $5, body = $6, updated_at = NOW() WHERE id = $1 - RETURNING id, owner_id, name, description, transcript, location, summary, body, version, created_at, updated_at + RETURNING id, owner_id, contract_id, contract_phase, name, description, transcript, location, summary, body, version, repo_file_path, repo_synced_at, repo_sync_status, created_at, updated_at "#, ) .bind(id) @@ -219,6 +233,7 @@ pub async fn count_files(pool: &PgPool) -> Result<i64, sqlx::Error> { // ============================================================================= /// Create a new file record for a specific owner. +/// Files must belong to a contract - the contract_id is required and the phase is looked up. pub async fn create_file_for_owner( pool: &PgPool, owner_id: Uuid, @@ -226,21 +241,38 @@ pub async fn create_file_for_owner( ) -> Result<File, sqlx::Error> { let name = req.name.unwrap_or_else(generate_default_name); let transcript_json = serde_json::to_value(&req.transcript).unwrap_or_default(); - let body_json = serde_json::to_value::<Vec<super::models::BodyElement>>(vec![]).unwrap(); + // Use body from request (may be empty or contain template elements) + let body_json = serde_json::to_value(&req.body).unwrap_or_default(); + + // Use provided contract_phase, or look up from contract's current phase + let contract_phase: Option<String> = if req.contract_phase.is_some() { + req.contract_phase + } else { + sqlx::query_scalar( + "SELECT phase FROM contracts WHERE id = $1 AND owner_id = $2", + ) + .bind(req.contract_id) + .bind(owner_id) + .fetch_optional(pool) + .await? + }; sqlx::query_as::<_, File>( r#" - INSERT INTO files (owner_id, name, description, transcript, location, summary, body) - VALUES ($1, $2, $3, $4, $5, NULL, $6) - RETURNING id, owner_id, name, description, transcript, location, summary, body, version, created_at, updated_at + INSERT INTO files (owner_id, contract_id, contract_phase, name, description, transcript, location, summary, body, repo_file_path) + VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, $8, $9) + RETURNING id, owner_id, contract_id, contract_phase, name, description, transcript, location, summary, body, version, repo_file_path, repo_synced_at, repo_sync_status, created_at, updated_at "#, ) .bind(owner_id) + .bind(req.contract_id) + .bind(&contract_phase) .bind(&name) .bind(&req.description) .bind(&transcript_json) .bind(&req.location) .bind(&body_json) + .bind(&req.repo_file_path) .fetch_one(pool) .await } @@ -253,7 +285,7 @@ pub async fn get_file_for_owner( ) -> Result<Option<File>, sqlx::Error> { sqlx::query_as::<_, File>( r#" - SELECT id, owner_id, name, description, transcript, location, summary, body, version, created_at, updated_at + SELECT id, owner_id, contract_id, contract_phase, name, description, transcript, location, summary, body, version, repo_file_path, repo_synced_at, repo_sync_status, created_at, updated_at FROM files WHERE id = $1 AND owner_id = $2 "#, @@ -268,7 +300,7 @@ pub async fn get_file_for_owner( pub async fn list_files_for_owner(pool: &PgPool, owner_id: Uuid) -> Result<Vec<File>, sqlx::Error> { sqlx::query_as::<_, File>( r#" - SELECT id, owner_id, name, description, transcript, location, summary, body, version, created_at, updated_at + SELECT id, owner_id, contract_id, contract_phase, name, description, transcript, location, summary, body, version, repo_file_path, repo_synced_at, repo_sync_status, created_at, updated_at FROM files WHERE owner_id = $1 ORDER BY created_at DESC @@ -279,6 +311,72 @@ pub async fn list_files_for_owner(pool: &PgPool, owner_id: Uuid) -> Result<Vec<F .await } +/// Database row type for file summary with contract info +#[derive(Debug, sqlx::FromRow)] +struct FileSummaryRow { + id: Uuid, + contract_id: Option<Uuid>, + contract_name: Option<String>, + contract_phase: Option<String>, + name: String, + description: Option<String>, + #[sqlx(json)] + transcript: Vec<crate::db::models::TranscriptEntry>, + version: i32, + repo_file_path: Option<String>, + repo_sync_status: Option<String>, + created_at: chrono::DateTime<chrono::Utc>, + updated_at: chrono::DateTime<chrono::Utc>, +} + +/// List file summaries for an owner with contract info (joined). +pub async fn list_file_summaries_for_owner( + pool: &PgPool, + owner_id: Uuid, +) -> Result<Vec<FileSummary>, sqlx::Error> { + let rows = sqlx::query_as::<_, FileSummaryRow>( + r#" + SELECT + f.id, f.contract_id, c.name as contract_name, f.contract_phase, + f.name, f.description, f.transcript, f.version, + f.repo_file_path, f.repo_sync_status, f.created_at, f.updated_at + FROM files f + LEFT JOIN contracts c ON f.contract_id = c.id + WHERE f.owner_id = $1 + ORDER BY f.created_at DESC + "#, + ) + .bind(owner_id) + .fetch_all(pool) + .await?; + + Ok(rows + .into_iter() + .map(|row| { + let duration = row + .transcript + .iter() + .map(|t| t.end) + .fold(0.0_f32, f32::max); + FileSummary { + id: row.id, + contract_id: row.contract_id, + contract_name: row.contract_name, + contract_phase: row.contract_phase, + name: row.name, + description: row.description, + transcript_count: row.transcript.len(), + duration: if duration > 0.0 { Some(duration) } else { None }, + version: row.version, + repo_file_path: row.repo_file_path, + repo_sync_status: row.repo_sync_status, + created_at: row.created_at, + updated_at: row.updated_at, + } + }) + .collect()) +} + /// Update a file by ID with optimistic locking, scoped to owner. pub async fn update_file_for_owner( pool: &PgPool, @@ -318,7 +416,7 @@ pub async fn update_file_for_owner( UPDATE files SET name = $3, description = $4, transcript = $5, summary = $6, body = $7, updated_at = NOW() WHERE id = $1 AND owner_id = $2 AND version = $8 - RETURNING id, owner_id, name, description, transcript, location, summary, body, version, created_at, updated_at + RETURNING id, owner_id, contract_id, contract_phase, name, description, transcript, location, summary, body, version, repo_file_path, repo_synced_at, repo_sync_status, created_at, updated_at "#, ) .bind(id) @@ -338,7 +436,7 @@ pub async fn update_file_for_owner( UPDATE files SET name = $3, description = $4, transcript = $5, summary = $6, body = $7, updated_at = NOW() WHERE id = $1 AND owner_id = $2 - RETURNING id, owner_id, name, description, transcript, location, summary, body, version, created_at, updated_at + RETURNING id, owner_id, contract_id, contract_phase, name, description, transcript, location, summary, body, version, repo_file_path, repo_synced_at, repo_sync_status, created_at, updated_at "#, ) .bind(id) @@ -511,6 +609,7 @@ pub async fn restore_file_version( summary: target.summary, body: Some(target.body), version: Some(current_version), + repo_file_path: None, }; update_file(pool, file_id, update_req).await @@ -540,26 +639,22 @@ pub async fn count_file_versions(pool: &PgPool, file_id: Uuid) -> Result<i64, sq /// to max 1 (2 levels: orchestrator at depth 0, subtasks at depth 1). /// /// NOTE: completion_action is NOT inherited - subtasks should not auto-merge unless -/// explicitly configured. The orchestrator controls when completion steps happen. +/// explicitly configured. The supervisor controls when completion steps happen. +/// +/// Task spawning is now controlled by supervisors at the application level. +/// Depth is no longer constrained in the database. pub async fn create_task(pool: &PgPool, req: CreateTaskRequest) -> Result<Task, sqlx::Error> { // Calculate depth and inherit settings from parent if applicable - let (depth, repo_url, base_branch, target_branch, merge_mode, target_repo_path, completion_action) = + let (depth, contract_id, repo_url, base_branch, target_branch, merge_mode, target_repo_path, completion_action) = if let Some(parent_id) = req.parent_task_id { - // Fetch parent task to get depth and inherit repo settings + // Fetch parent task to get depth and inherit settings let parent = get_task(pool, parent_id).await? .ok_or_else(|| sqlx::Error::RowNotFound)?; let new_depth = parent.depth + 1; - // Validate max depth (must be < 2, i.e., 0 or 1 only) - // Orchestrators are at depth 0, subtasks at depth 1 - // Subtasks cannot have their own children - if new_depth >= 2 { - return Err(sqlx::Error::Protocol(format!( - "Maximum task depth exceeded. Cannot create subtask at depth {} (max is 1). Subtasks cannot have children.", - new_depth - ))); - } + // Subtasks inherit contract_id from parent + let contract_id = parent.contract_id.unwrap_or(req.contract_id); // Inherit repo settings if not provided let repo_url = req.repository_url.clone().or(parent.repository_url); @@ -568,14 +663,15 @@ pub async fn create_task(pool: &PgPool, req: CreateTaskRequest) -> Result<Task, let merge_mode = req.merge_mode.clone().or(parent.merge_mode); let target_repo_path = req.target_repo_path.clone().or(parent.target_repo_path); // NOTE: completion_action is NOT inherited - subtasks should not auto-merge. - // The orchestrator integrates subtask work from their worktrees. + // The supervisor integrates subtask work from their worktrees. let completion_action = req.completion_action.clone(); - (new_depth, repo_url, base_branch, target_branch, merge_mode, target_repo_path, completion_action) + (new_depth, contract_id, repo_url, base_branch, target_branch, merge_mode, target_repo_path, completion_action) } else { - // Top-level task: depth 0 + // Top-level task: depth 0, use contract_id from request ( 0, + req.contract_id, req.repository_url.clone(), req.base_branch.clone(), req.target_branch.clone(), @@ -590,20 +686,22 @@ pub async fn create_task(pool: &PgPool, req: CreateTaskRequest) -> Result<Task, sqlx::query_as::<_, Task>( r#" INSERT INTO tasks ( - parent_task_id, depth, name, description, plan, priority, - repository_url, base_branch, target_branch, merge_mode, + contract_id, parent_task_id, depth, name, description, plan, priority, + is_supervisor, repository_url, base_branch, target_branch, merge_mode, target_repo_path, completion_action, continue_from_task_id, copy_files ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING * "#, ) + .bind(contract_id) .bind(req.parent_task_id) .bind(depth) .bind(&req.name) .bind(&req.description) .bind(&req.plan) .bind(req.priority) + .bind(req.is_supervisor) .bind(&repo_url) .bind(&base_branch) .bind(&target_branch) @@ -635,10 +733,13 @@ pub async fn list_tasks(pool: &PgPool) -> Result<Vec<TaskSummary>, sqlx::Error> sqlx::query_as::<_, TaskSummary>( r#" SELECT - t.id, t.parent_task_id, t.depth, t.name, t.status, t.priority, - t.progress_summary, t.version, t.created_at, t.updated_at, - (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count + t.id, t.contract_id, c.name as contract_name, c.phase as contract_phase, + t.parent_task_id, t.depth, t.name, t.status, t.priority, + t.progress_summary, + (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count, + t.version, t.is_supervisor, t.created_at, t.updated_at FROM tasks t + LEFT JOIN contracts c ON t.contract_id = c.id WHERE t.parent_task_id IS NULL ORDER BY t.priority DESC, t.created_at DESC "#, @@ -652,10 +753,13 @@ pub async fn list_subtasks(pool: &PgPool, parent_id: Uuid) -> Result<Vec<TaskSum sqlx::query_as::<_, TaskSummary>( r#" SELECT - t.id, t.parent_task_id, t.depth, t.name, t.status, t.priority, - t.progress_summary, t.version, t.created_at, t.updated_at, - (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count + t.id, t.contract_id, c.name as contract_name, c.phase as contract_phase, + t.parent_task_id, t.depth, t.name, t.status, t.priority, + t.progress_summary, + (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count, + t.version, t.is_supervisor, t.created_at, t.updated_at FROM tasks t + LEFT JOIN contracts c ON t.contract_id = c.id WHERE t.parent_task_id = $1 ORDER BY t.priority DESC, t.created_at DESC "#, @@ -665,6 +769,25 @@ pub async fn list_subtasks(pool: &PgPool, parent_id: Uuid) -> Result<Vec<TaskSum .await } +/// List all tasks in a contract (for supervisor tree view). +pub async fn list_tasks_by_contract( + pool: &PgPool, + contract_id: Uuid, + owner_id: Uuid, +) -> Result<Vec<Task>, sqlx::Error> { + sqlx::query_as::<_, Task>( + r#" + SELECT * FROM tasks + WHERE contract_id = $1 AND owner_id = $2 + ORDER BY is_supervisor DESC, depth ASC, created_at ASC + "#, + ) + .bind(contract_id) + .bind(owner_id) + .fetch_all(pool) + .await +} + /// Update a task by ID with optimistic locking. pub async fn update_task( pool: &PgPool, @@ -817,9 +940,9 @@ pub async fn create_task_for_owner( req: CreateTaskRequest, ) -> Result<Task, sqlx::Error> { // Calculate depth and inherit settings from parent if applicable - let (depth, repo_url, base_branch, target_branch, merge_mode, target_repo_path, completion_action) = + let (depth, contract_id, repo_url, base_branch, target_branch, merge_mode, target_repo_path, completion_action) = if let Some(parent_id) = req.parent_task_id { - // Fetch parent task to get depth and inherit repo settings (must belong to same owner) + // Fetch parent task to get depth and inherit settings (must belong to same owner) let parent = get_task_for_owner(pool, parent_id, owner_id).await? .ok_or_else(|| sqlx::Error::RowNotFound)?; @@ -833,6 +956,9 @@ pub async fn create_task_for_owner( ))); } + // Subtasks inherit contract_id from parent + let contract_id = parent.contract_id.unwrap_or(req.contract_id); + // Inherit repo settings if not provided let repo_url = req.repository_url.clone().or(parent.repository_url); let base_branch = req.base_branch.clone().or(parent.base_branch); @@ -843,11 +969,12 @@ pub async fn create_task_for_owner( // The orchestrator integrates subtask work from their worktrees. let completion_action = req.completion_action.clone(); - (new_depth, repo_url, base_branch, target_branch, merge_mode, target_repo_path, completion_action) + (new_depth, contract_id, repo_url, base_branch, target_branch, merge_mode, target_repo_path, completion_action) } else { - // Top-level task: depth 0 + // Top-level task: depth 0, use contract_id from request ( 0, + req.contract_id, req.repository_url.clone(), req.base_branch.clone(), req.target_branch.clone(), @@ -862,21 +989,23 @@ pub async fn create_task_for_owner( sqlx::query_as::<_, Task>( r#" INSERT INTO tasks ( - owner_id, parent_task_id, depth, name, description, plan, priority, - repository_url, base_branch, target_branch, merge_mode, + owner_id, contract_id, parent_task_id, depth, name, description, plan, priority, + is_supervisor, repository_url, base_branch, target_branch, merge_mode, target_repo_path, completion_action, continue_from_task_id, copy_files ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) RETURNING * "#, ) .bind(owner_id) + .bind(contract_id) .bind(req.parent_task_id) .bind(depth) .bind(&req.name) .bind(&req.description) .bind(&req.plan) .bind(req.priority) + .bind(req.is_supervisor) .bind(&repo_url) .bind(&base_branch) .bind(&target_branch) @@ -916,10 +1045,13 @@ pub async fn list_tasks_for_owner( sqlx::query_as::<_, TaskSummary>( r#" SELECT - t.id, t.parent_task_id, t.depth, t.name, t.status, t.priority, - t.progress_summary, t.version, t.created_at, t.updated_at, - (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count + t.id, t.contract_id, c.name as contract_name, c.phase as contract_phase, + t.parent_task_id, t.depth, t.name, t.status, t.priority, + t.progress_summary, + (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count, + t.version, t.is_supervisor, t.created_at, t.updated_at FROM tasks t + LEFT JOIN contracts c ON t.contract_id = c.id WHERE t.owner_id = $1 AND t.parent_task_id IS NULL ORDER BY t.priority DESC, t.created_at DESC "#, @@ -938,10 +1070,13 @@ pub async fn list_subtasks_for_owner( sqlx::query_as::<_, TaskSummary>( r#" SELECT - t.id, t.parent_task_id, t.depth, t.name, t.status, t.priority, - t.progress_summary, t.version, t.created_at, t.updated_at, - (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count + t.id, t.contract_id, c.name as contract_name, c.phase as contract_phase, + t.parent_task_id, t.depth, t.name, t.status, t.priority, + t.progress_summary, + (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count, + t.version, t.is_supervisor, t.created_at, t.updated_at FROM tasks t + LEFT JOIN contracts c ON t.contract_id = c.id WHERE t.owner_id = $1 AND t.parent_task_id = $2 ORDER BY t.priority DESC, t.created_at DESC "#, @@ -986,6 +1121,7 @@ pub async fn update_task_for_owner( let error_message = req.error_message.or(existing.error_message); let merge_mode = req.merge_mode.or(existing.merge_mode); let pr_url = req.pr_url.or(existing.pr_url); + let repository_url = req.repository_url.or(existing.repository_url); let target_repo_path = req.target_repo_path.or(existing.target_repo_path); let completion_action = req.completion_action.or(existing.completion_action); let daemon_id = if req.clear_daemon_id { @@ -1002,8 +1138,9 @@ pub async fn update_task_for_owner( SET name = $3, description = $4, plan = $5, status = $6, priority = $7, progress_summary = $8, last_output = $9, error_message = $10, merge_mode = $11, pr_url = $12, daemon_id = $13, - target_repo_path = $14, completion_action = $15, updated_at = NOW() - WHERE id = $1 AND owner_id = $2 AND version = $16 + target_repo_path = $14, completion_action = $15, repository_url = $16, + updated_at = NOW() + WHERE id = $1 AND owner_id = $2 AND version = $17 RETURNING * "#, ) @@ -1022,6 +1159,7 @@ pub async fn update_task_for_owner( .bind(daemon_id) .bind(&target_repo_path) .bind(&completion_action) + .bind(&repository_url) .bind(req.version.unwrap()) .fetch_optional(pool) .await? @@ -1032,7 +1170,8 @@ pub async fn update_task_for_owner( SET name = $3, description = $4, plan = $5, status = $6, priority = $7, progress_summary = $8, last_output = $9, error_message = $10, merge_mode = $11, pr_url = $12, daemon_id = $13, - target_repo_path = $14, completion_action = $15, updated_at = NOW() + target_repo_path = $14, completion_action = $15, repository_url = $16, + updated_at = NOW() WHERE id = $1 AND owner_id = $2 RETURNING * "#, @@ -1052,6 +1191,7 @@ pub async fn update_task_for_owner( .bind(daemon_id) .bind(&target_repo_path) .bind(&completion_action) + .bind(&repository_url) .fetch_optional(pool) .await? }; @@ -1328,6 +1468,26 @@ pub async fn update_daemon_status( Ok(result.rows_affected() > 0) } +/// Mark daemon as disconnected by connection_id. +pub async fn disconnect_daemon_by_connection( + pool: &PgPool, + connection_id: &str, +) -> Result<bool, sqlx::Error> { + let result = sqlx::query( + r#" + UPDATE daemons + SET status = 'disconnected', + disconnected_at = NOW() + WHERE connection_id = $1 + "#, + ) + .bind(connection_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + /// Update daemon task count. pub async fn update_daemon_task_count( pool: &PgPool, @@ -1393,6 +1553,25 @@ pub async fn count_daemons(pool: &PgPool) -> Result<i64, sqlx::Error> { Ok(result.0) } +/// Delete stale daemons that haven't sent a heartbeat within the timeout. +/// Returns the number of deleted daemons. +pub async fn delete_stale_daemons( + pool: &PgPool, + timeout_seconds: i64, +) -> Result<u64, sqlx::Error> { + let result = sqlx::query( + r#" + DELETE FROM daemons + WHERE last_heartbeat_at < NOW() - INTERVAL '1 second' * $1 + "#, + ) + .bind(timeout_seconds) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + // ============================================================================= // Sibling Awareness Functions // ============================================================================= @@ -1408,10 +1587,13 @@ pub async fn list_sibling_tasks( sqlx::query_as::<_, TaskSummary>( r#" SELECT - t.id, t.parent_task_id, t.depth, t.name, t.status, t.priority, - t.progress_summary, t.version, t.created_at, t.updated_at, - (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count + t.id, t.contract_id, c.name as contract_name, c.phase as contract_phase, + t.parent_task_id, t.depth, t.name, t.status, t.priority, + t.progress_summary, + (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count, + t.version, t.is_supervisor, t.created_at, t.updated_at FROM tasks t + LEFT JOIN contracts c ON t.contract_id = c.id WHERE t.parent_task_id = $1 AND t.id != $2 ORDER BY t.priority DESC, t.created_at DESC "#, @@ -1426,10 +1608,13 @@ pub async fn list_sibling_tasks( sqlx::query_as::<_, TaskSummary>( r#" SELECT - t.id, t.parent_task_id, t.depth, t.name, t.status, t.priority, - t.progress_summary, t.version, t.created_at, t.updated_at, - (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count + t.id, t.contract_id, c.name as contract_name, c.phase as contract_phase, + t.parent_task_id, t.depth, t.name, t.status, t.priority, + t.progress_summary, + (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count, + t.version, t.is_supervisor, t.created_at, t.updated_at FROM tasks t + LEFT JOIN contracts c ON t.contract_id = c.id WHERE t.parent_task_id IS NULL AND t.id != $1 ORDER BY t.priority DESC, t.created_at DESC "#, @@ -1710,3 +1895,1092 @@ pub async fn clear_conversation(pool: &PgPool, owner_id: Uuid) -> Result<MeshCha // Create new active conversation get_or_create_active_conversation(pool, owner_id).await } + +// ============================================================================= +// Contract Chat History Functions +// ============================================================================= + +/// Get or create the active conversation for a contract. +pub async fn get_or_create_contract_conversation( + pool: &PgPool, + contract_id: Uuid, + owner_id: Uuid, +) -> Result<ContractChatConversation, sqlx::Error> { + // Try to get existing active conversation for this contract + let existing = sqlx::query_as::<_, ContractChatConversation>( + r#" + SELECT * + FROM contract_chat_conversations + WHERE is_active = true AND contract_id = $1 AND owner_id = $2 + LIMIT 1 + "#, + ) + .bind(contract_id) + .bind(owner_id) + .fetch_optional(pool) + .await?; + + if let Some(conv) = existing { + return Ok(conv); + } + + // Create new conversation + sqlx::query_as::<_, ContractChatConversation>( + r#" + INSERT INTO contract_chat_conversations (contract_id, owner_id, is_active) + VALUES ($1, $2, true) + RETURNING * + "#, + ) + .bind(contract_id) + .bind(owner_id) + .fetch_one(pool) + .await +} + +/// List messages for a contract conversation. +pub async fn list_contract_chat_messages( + pool: &PgPool, + conversation_id: Uuid, + limit: Option<i32>, +) -> Result<Vec<ContractChatMessageRecord>, sqlx::Error> { + let limit = limit.unwrap_or(100); + sqlx::query_as::<_, ContractChatMessageRecord>( + r#" + SELECT * + FROM contract_chat_messages + WHERE conversation_id = $1 + ORDER BY created_at ASC + LIMIT $2 + "#, + ) + .bind(conversation_id) + .bind(limit) + .fetch_all(pool) + .await +} + +/// Add a message to a contract conversation. +pub async fn add_contract_chat_message( + pool: &PgPool, + conversation_id: Uuid, + role: &str, + content: &str, + tool_calls: Option<serde_json::Value>, + pending_questions: Option<serde_json::Value>, +) -> Result<ContractChatMessageRecord, sqlx::Error> { + sqlx::query_as::<_, ContractChatMessageRecord>( + r#" + INSERT INTO contract_chat_messages + (conversation_id, role, content, tool_calls, pending_questions) + VALUES ($1, $2, $3, $4, $5) + RETURNING * + "#, + ) + .bind(conversation_id) + .bind(role) + .bind(content) + .bind(tool_calls) + .bind(pending_questions) + .fetch_one(pool) + .await +} + +/// Clear contract conversation (archive existing and create new). +pub async fn clear_contract_conversation( + pool: &PgPool, + contract_id: Uuid, + owner_id: Uuid, +) -> Result<ContractChatConversation, sqlx::Error> { + // Mark existing as inactive for this contract + sqlx::query( + r#" + UPDATE contract_chat_conversations + SET is_active = false, updated_at = NOW() + WHERE is_active = true AND contract_id = $1 AND owner_id = $2 + "#, + ) + .bind(contract_id) + .bind(owner_id) + .execute(pool) + .await?; + + // Create new active conversation + get_or_create_contract_conversation(pool, contract_id, owner_id).await +} + +// ============================================================================= +// Contract Functions (Owner-Scoped) +// ============================================================================= + +/// Create a new contract for a specific owner. +pub async fn create_contract_for_owner( + pool: &PgPool, + owner_id: Uuid, + req: CreateContractRequest, +) -> Result<Contract, sqlx::Error> { + // Use provided initial_phase or default to "research" + let phase = req.initial_phase.as_deref().unwrap_or("research"); + + // Validate the phase + let valid_phases = ["research", "specify", "plan", "execute", "review"]; + if !valid_phases.contains(&phase) { + return Err(sqlx::Error::Protocol(format!( + "Invalid initial_phase '{}'. Must be one of: {}", + phase, + valid_phases.join(", ") + ))); + } + + sqlx::query_as::<_, Contract>( + r#" + INSERT INTO contracts (owner_id, name, description, phase) + VALUES ($1, $2, $3, $4) + RETURNING * + "#, + ) + .bind(owner_id) + .bind(&req.name) + .bind(&req.description) + .bind(phase) + .fetch_one(pool) + .await +} + +/// Get a contract by ID, scoped to owner. +pub async fn get_contract_for_owner( + pool: &PgPool, + id: Uuid, + owner_id: Uuid, +) -> Result<Option<Contract>, sqlx::Error> { + sqlx::query_as::<_, Contract>( + r#" + SELECT * + FROM contracts + WHERE id = $1 AND owner_id = $2 + "#, + ) + .bind(id) + .bind(owner_id) + .fetch_optional(pool) + .await +} + +/// List all contracts for an owner, ordered by created_at DESC. +pub async fn list_contracts_for_owner( + pool: &PgPool, + owner_id: Uuid, +) -> Result<Vec<ContractSummary>, sqlx::Error> { + sqlx::query_as::<_, ContractSummary>( + r#" + SELECT + c.id, c.name, c.description, c.phase, c.status, + c.version, c.created_at, + (SELECT COUNT(*) FROM files WHERE contract_id = c.id) as file_count, + (SELECT COUNT(*) FROM tasks WHERE contract_id = c.id) as task_count, + (SELECT COUNT(*) FROM contract_repositories WHERE contract_id = c.id) as repository_count + FROM contracts c + WHERE c.owner_id = $1 + ORDER BY c.created_at DESC + "#, + ) + .bind(owner_id) + .fetch_all(pool) + .await +} + +/// Get contract summary by ID. +pub async fn get_contract_summary_for_owner( + pool: &PgPool, + id: Uuid, + owner_id: Uuid, +) -> Result<Option<ContractSummary>, sqlx::Error> { + sqlx::query_as::<_, ContractSummary>( + r#" + SELECT + c.id, c.name, c.description, c.phase, c.status, + c.version, c.created_at, + (SELECT COUNT(*) FROM files WHERE contract_id = c.id) as file_count, + (SELECT COUNT(*) FROM tasks WHERE contract_id = c.id) as task_count, + (SELECT COUNT(*) FROM contract_repositories WHERE contract_id = c.id) as repository_count + FROM contracts c + WHERE c.id = $1 AND c.owner_id = $2 + "#, + ) + .bind(id) + .bind(owner_id) + .fetch_optional(pool) + .await +} + +/// Update a contract by ID with optimistic locking, scoped to owner. +pub async fn update_contract_for_owner( + pool: &PgPool, + id: Uuid, + owner_id: Uuid, + req: UpdateContractRequest, +) -> Result<Option<Contract>, RepositoryError> { + let existing = get_contract_for_owner(pool, id, owner_id).await?; + let Some(existing) = existing else { + return Ok(None); + }; + + // Check version if provided (optimistic locking) + if let Some(expected_version) = req.version { + if existing.version != expected_version { + return Err(RepositoryError::VersionConflict { + expected: expected_version, + actual: existing.version, + }); + } + } + + // Apply updates + let name = req.name.unwrap_or(existing.name); + let description = req.description.or(existing.description); + let phase = req.phase.unwrap_or(existing.phase); + let status = req.status.unwrap_or(existing.status); + let supervisor_task_id = req.supervisor_task_id.or(existing.supervisor_task_id); + + let result = if req.version.is_some() { + sqlx::query_as::<_, Contract>( + r#" + UPDATE contracts + SET name = $3, description = $4, phase = $5, status = $6, + supervisor_task_id = $7, version = version + 1, updated_at = NOW() + WHERE id = $1 AND owner_id = $2 AND version = $8 + RETURNING * + "#, + ) + .bind(id) + .bind(owner_id) + .bind(&name) + .bind(&description) + .bind(&phase) + .bind(&status) + .bind(supervisor_task_id) + .bind(req.version.unwrap()) + .fetch_optional(pool) + .await? + } else { + sqlx::query_as::<_, Contract>( + r#" + UPDATE contracts + SET name = $3, description = $4, phase = $5, status = $6, + supervisor_task_id = $7, version = version + 1, updated_at = NOW() + WHERE id = $1 AND owner_id = $2 + RETURNING * + "#, + ) + .bind(id) + .bind(owner_id) + .bind(&name) + .bind(&description) + .bind(&phase) + .bind(&status) + .bind(supervisor_task_id) + .fetch_optional(pool) + .await? + }; + + // If versioned update returned None, there was a race condition + if result.is_none() && req.version.is_some() { + if let Some(current) = get_contract_for_owner(pool, id, owner_id).await? { + return Err(RepositoryError::VersionConflict { + expected: req.version.unwrap(), + actual: current.version, + }); + } + } + + Ok(result) +} + +/// Delete a contract by ID, scoped to owner. +pub async fn delete_contract_for_owner( + pool: &PgPool, + id: Uuid, + owner_id: Uuid, +) -> Result<bool, sqlx::Error> { + let result = sqlx::query( + r#" + DELETE FROM contracts + WHERE id = $1 AND owner_id = $2 + "#, + ) + .bind(id) + .bind(owner_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// Change contract phase and record event. +pub async fn change_contract_phase_for_owner( + pool: &PgPool, + id: Uuid, + owner_id: Uuid, + new_phase: &str, +) -> Result<Option<Contract>, sqlx::Error> { + // Get current phase + let existing = get_contract_for_owner(pool, id, owner_id).await?; + let Some(existing) = existing else { + return Ok(None); + }; + + let previous_phase = existing.phase.clone(); + + // Update phase + let contract = sqlx::query_as::<_, Contract>( + r#" + UPDATE contracts + SET phase = $3, version = version + 1, updated_at = NOW() + WHERE id = $1 AND owner_id = $2 + RETURNING * + "#, + ) + .bind(id) + .bind(owner_id) + .bind(new_phase) + .fetch_optional(pool) + .await?; + + // Record event + if contract.is_some() { + sqlx::query( + r#" + INSERT INTO contract_events (contract_id, event_type, previous_phase, new_phase) + VALUES ($1, 'phase_change', $2, $3) + "#, + ) + .bind(id) + .bind(&previous_phase) + .bind(new_phase) + .execute(pool) + .await?; + } + + Ok(contract) +} + +// ============================================================================= +// Contract Repository Functions +// ============================================================================= + +/// List repositories for a contract. +pub async fn list_contract_repositories( + pool: &PgPool, + contract_id: Uuid, +) -> Result<Vec<ContractRepository>, sqlx::Error> { + sqlx::query_as::<_, ContractRepository>( + r#" + SELECT * + FROM contract_repositories + WHERE contract_id = $1 + ORDER BY is_primary DESC, created_at ASC + "#, + ) + .bind(contract_id) + .fetch_all(pool) + .await +} + +/// Add a remote repository to a contract. +pub async fn add_remote_repository( + pool: &PgPool, + contract_id: Uuid, + name: &str, + repository_url: &str, + is_primary: bool, +) -> Result<ContractRepository, sqlx::Error> { + // If is_primary, clear other primaries first + if is_primary { + sqlx::query( + r#" + UPDATE contract_repositories + SET is_primary = false, updated_at = NOW() + WHERE contract_id = $1 AND is_primary = true + "#, + ) + .bind(contract_id) + .execute(pool) + .await?; + } + + sqlx::query_as::<_, ContractRepository>( + r#" + INSERT INTO contract_repositories (contract_id, name, repository_url, source_type, status, is_primary) + VALUES ($1, $2, $3, 'remote', 'ready', $4) + RETURNING * + "#, + ) + .bind(contract_id) + .bind(name) + .bind(repository_url) + .bind(is_primary) + .fetch_one(pool) + .await +} + +/// Add a local repository to a contract. +pub async fn add_local_repository( + pool: &PgPool, + contract_id: Uuid, + name: &str, + local_path: &str, + is_primary: bool, +) -> Result<ContractRepository, sqlx::Error> { + // If is_primary, clear other primaries first + if is_primary { + sqlx::query( + r#" + UPDATE contract_repositories + SET is_primary = false, updated_at = NOW() + WHERE contract_id = $1 AND is_primary = true + "#, + ) + .bind(contract_id) + .execute(pool) + .await?; + } + + sqlx::query_as::<_, ContractRepository>( + r#" + INSERT INTO contract_repositories (contract_id, name, local_path, source_type, status, is_primary) + VALUES ($1, $2, $3, 'local', 'ready', $4) + RETURNING * + "#, + ) + .bind(contract_id) + .bind(name) + .bind(local_path) + .bind(is_primary) + .fetch_one(pool) + .await +} + +/// Create a managed repository (daemon will create it). +pub async fn create_managed_repository( + pool: &PgPool, + contract_id: Uuid, + name: &str, + is_primary: bool, +) -> Result<ContractRepository, sqlx::Error> { + // If is_primary, clear other primaries first + if is_primary { + sqlx::query( + r#" + UPDATE contract_repositories + SET is_primary = false, updated_at = NOW() + WHERE contract_id = $1 AND is_primary = true + "#, + ) + .bind(contract_id) + .execute(pool) + .await?; + } + + sqlx::query_as::<_, ContractRepository>( + r#" + INSERT INTO contract_repositories (contract_id, name, source_type, status, is_primary) + VALUES ($1, $2, 'managed', 'pending', $3) + RETURNING * + "#, + ) + .bind(contract_id) + .bind(name) + .bind(is_primary) + .fetch_one(pool) + .await +} + +/// Delete a repository from a contract. +pub async fn delete_contract_repository( + pool: &PgPool, + repo_id: Uuid, + contract_id: Uuid, +) -> Result<bool, sqlx::Error> { + let result = sqlx::query( + r#" + DELETE FROM contract_repositories + WHERE id = $1 AND contract_id = $2 + "#, + ) + .bind(repo_id) + .bind(contract_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// Set a repository as primary (and clear others). +pub async fn set_repository_primary( + pool: &PgPool, + repo_id: Uuid, + contract_id: Uuid, +) -> Result<bool, sqlx::Error> { + // Clear other primaries + sqlx::query( + r#" + UPDATE contract_repositories + SET is_primary = false, updated_at = NOW() + WHERE contract_id = $1 AND is_primary = true + "#, + ) + .bind(contract_id) + .execute(pool) + .await?; + + // Set this one as primary + let result = sqlx::query( + r#" + UPDATE contract_repositories + SET is_primary = true, updated_at = NOW() + WHERE id = $1 AND contract_id = $2 + "#, + ) + .bind(repo_id) + .bind(contract_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// Update managed repository status (used by daemon). +pub async fn update_managed_repository_status( + pool: &PgPool, + repo_id: Uuid, + status: &str, + repository_url: Option<&str>, +) -> Result<Option<ContractRepository>, sqlx::Error> { + sqlx::query_as::<_, ContractRepository>( + r#" + UPDATE contract_repositories + SET status = $2, repository_url = COALESCE($3, repository_url), updated_at = NOW() + WHERE id = $1 + RETURNING * + "#, + ) + .bind(repo_id) + .bind(status) + .bind(repository_url) + .fetch_optional(pool) + .await +} + +// ============================================================================= +// Contract Task Association Functions +// ============================================================================= + +/// Add a task to a contract. +pub async fn add_task_to_contract( + pool: &PgPool, + contract_id: Uuid, + task_id: Uuid, + owner_id: Uuid, +) -> Result<bool, sqlx::Error> { + let result = sqlx::query( + r#" + UPDATE tasks + SET contract_id = $2, updated_at = NOW() + WHERE id = $1 AND owner_id = $3 + "#, + ) + .bind(task_id) + .bind(contract_id) + .bind(owner_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// Remove a task from a contract. +pub async fn remove_task_from_contract( + pool: &PgPool, + contract_id: Uuid, + task_id: Uuid, + owner_id: Uuid, +) -> Result<bool, sqlx::Error> { + let result = sqlx::query( + r#" + UPDATE tasks + SET contract_id = NULL, updated_at = NOW() + WHERE id = $1 AND contract_id = $2 AND owner_id = $3 + "#, + ) + .bind(task_id) + .bind(contract_id) + .bind(owner_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// List files in a contract. +pub async fn list_files_in_contract( + pool: &PgPool, + contract_id: Uuid, + owner_id: Uuid, +) -> Result<Vec<FileSummary>, sqlx::Error> { + // Use a manual query since FileSummary doesn't have a FromRow derive with all the computed fields + let files = sqlx::query_as::<_, File>( + r#" + SELECT id, owner_id, contract_id, contract_phase, name, description, transcript, location, summary, body, version, repo_file_path, repo_synced_at, repo_sync_status, created_at, updated_at + FROM files + WHERE contract_id = $1 AND owner_id = $2 + ORDER BY created_at DESC + "#, + ) + .bind(contract_id) + .bind(owner_id) + .fetch_all(pool) + .await?; + + Ok(files.into_iter().map(FileSummary::from).collect()) +} + +/// List tasks in a contract. +pub async fn list_tasks_in_contract( + pool: &PgPool, + contract_id: Uuid, + owner_id: Uuid, +) -> Result<Vec<TaskSummary>, sqlx::Error> { + sqlx::query_as::<_, TaskSummary>( + r#" + SELECT + t.id, t.contract_id, c.name as contract_name, c.phase as contract_phase, + t.parent_task_id, t.depth, t.name, t.status, t.priority, + t.progress_summary, + (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count, + t.version, t.is_supervisor, t.created_at, t.updated_at + FROM tasks t + LEFT JOIN contracts c ON t.contract_id = c.id + WHERE t.contract_id = $1 AND t.owner_id = $2 + ORDER BY t.priority DESC, t.created_at DESC + "#, + ) + .bind(contract_id) + .bind(owner_id) + .fetch_all(pool) + .await +} + +// ============================================================================= +// Contract Events +// ============================================================================= + +/// List events for a contract. +pub async fn list_contract_events( + pool: &PgPool, + contract_id: Uuid, +) -> Result<Vec<ContractEvent>, sqlx::Error> { + sqlx::query_as::<_, ContractEvent>( + r#" + SELECT * + FROM contract_events + WHERE contract_id = $1 + ORDER BY created_at DESC + "#, + ) + .bind(contract_id) + .fetch_all(pool) + .await +} + +/// Record a contract event. +pub async fn record_contract_event( + pool: &PgPool, + contract_id: Uuid, + event_type: &str, + event_data: Option<serde_json::Value>, +) -> Result<ContractEvent, sqlx::Error> { + sqlx::query_as::<_, ContractEvent>( + r#" + INSERT INTO contract_events (contract_id, event_type, event_data) + VALUES ($1, $2, $3) + RETURNING * + "#, + ) + .bind(contract_id) + .bind(event_type) + .bind(event_data) + .fetch_one(pool) + .await +} + +// ============================================================================ +// Task Checkpoints +// ============================================================================ + +/// Create a checkpoint for a task. +pub async fn create_task_checkpoint( + pool: &PgPool, + task_id: Uuid, + commit_sha: &str, + branch_name: &str, + message: &str, + files_changed: Option<serde_json::Value>, + lines_added: Option<i32>, + lines_removed: Option<i32>, +) -> Result<TaskCheckpoint, sqlx::Error> { + // Get current checkpoint count and increment + let checkpoint_number: i32 = sqlx::query_scalar( + "SELECT COALESCE(MAX(checkpoint_number), 0) + 1 FROM task_checkpoints WHERE task_id = $1", + ) + .bind(task_id) + .fetch_one(pool) + .await?; + + // Update task's checkpoint tracking + sqlx::query( + r#" + UPDATE tasks + SET last_checkpoint_sha = $1, + checkpoint_count = $2, + checkpoint_message = $3, + updated_at = NOW() + WHERE id = $4 + "#, + ) + .bind(commit_sha) + .bind(checkpoint_number) + .bind(message) + .bind(task_id) + .execute(pool) + .await?; + + sqlx::query_as::<_, TaskCheckpoint>( + r#" + INSERT INTO task_checkpoints ( + task_id, checkpoint_number, commit_sha, branch_name, message, + files_changed, lines_added, lines_removed + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING * + "#, + ) + .bind(task_id) + .bind(checkpoint_number) + .bind(commit_sha) + .bind(branch_name) + .bind(message) + .bind(files_changed) + .bind(lines_added) + .bind(lines_removed) + .fetch_one(pool) + .await +} + +/// Get a checkpoint by ID. +pub async fn get_task_checkpoint( + pool: &PgPool, + id: Uuid, +) -> Result<Option<TaskCheckpoint>, sqlx::Error> { + sqlx::query_as::<_, TaskCheckpoint>("SELECT * FROM task_checkpoints WHERE id = $1") + .bind(id) + .fetch_optional(pool) + .await +} + +/// Get a checkpoint by commit SHA. +pub async fn get_task_checkpoint_by_sha( + pool: &PgPool, + commit_sha: &str, +) -> Result<Option<TaskCheckpoint>, sqlx::Error> { + sqlx::query_as::<_, TaskCheckpoint>("SELECT * FROM task_checkpoints WHERE commit_sha = $1") + .bind(commit_sha) + .fetch_optional(pool) + .await +} + +/// List checkpoints for a task. +pub async fn list_task_checkpoints( + pool: &PgPool, + task_id: Uuid, +) -> Result<Vec<TaskCheckpoint>, sqlx::Error> { + sqlx::query_as::<_, TaskCheckpoint>( + "SELECT * FROM task_checkpoints WHERE task_id = $1 ORDER BY checkpoint_number DESC", + ) + .bind(task_id) + .fetch_all(pool) + .await +} + +// ============================================================================ +// Supervisor State +// ============================================================================ + +/// Create or update supervisor state for a contract. +pub async fn upsert_supervisor_state( + pool: &PgPool, + contract_id: Uuid, + task_id: Uuid, + conversation_history: serde_json::Value, + pending_task_ids: &[Uuid], + phase: &str, +) -> Result<SupervisorState, sqlx::Error> { + sqlx::query_as::<_, SupervisorState>( + r#" + INSERT INTO supervisor_states (contract_id, task_id, conversation_history, pending_task_ids, phase, last_activity) + VALUES ($1, $2, $3, $4, $5, NOW()) + ON CONFLICT (contract_id) DO UPDATE SET + task_id = EXCLUDED.task_id, + conversation_history = EXCLUDED.conversation_history, + pending_task_ids = EXCLUDED.pending_task_ids, + phase = EXCLUDED.phase, + last_activity = NOW(), + updated_at = NOW() + RETURNING * + "#, + ) + .bind(contract_id) + .bind(task_id) + .bind(conversation_history) + .bind(pending_task_ids) + .bind(phase) + .fetch_one(pool) + .await +} + +/// Get supervisor state for a contract. +pub async fn get_supervisor_state( + pool: &PgPool, + contract_id: Uuid, +) -> Result<Option<SupervisorState>, sqlx::Error> { + sqlx::query_as::<_, SupervisorState>("SELECT * FROM supervisor_states WHERE contract_id = $1") + .bind(contract_id) + .fetch_optional(pool) + .await +} + +/// Get supervisor state by task ID. +pub async fn get_supervisor_state_by_task( + pool: &PgPool, + task_id: Uuid, +) -> Result<Option<SupervisorState>, sqlx::Error> { + sqlx::query_as::<_, SupervisorState>("SELECT * FROM supervisor_states WHERE task_id = $1") + .bind(task_id) + .fetch_optional(pool) + .await +} + +/// Update supervisor conversation history. +pub async fn update_supervisor_conversation( + pool: &PgPool, + contract_id: Uuid, + conversation_history: serde_json::Value, +) -> Result<SupervisorState, sqlx::Error> { + sqlx::query_as::<_, SupervisorState>( + r#" + UPDATE supervisor_states + SET conversation_history = $1, + last_activity = NOW(), + updated_at = NOW() + WHERE contract_id = $2 + RETURNING * + "#, + ) + .bind(conversation_history) + .bind(contract_id) + .fetch_one(pool) + .await +} + +/// Update supervisor pending tasks. +pub async fn update_supervisor_pending_tasks( + pool: &PgPool, + contract_id: Uuid, + pending_task_ids: &[Uuid], +) -> Result<SupervisorState, sqlx::Error> { + sqlx::query_as::<_, SupervisorState>( + r#" + UPDATE supervisor_states + SET pending_task_ids = $1, + last_activity = NOW(), + updated_at = NOW() + WHERE contract_id = $2 + RETURNING * + "#, + ) + .bind(pending_task_ids) + .bind(contract_id) + .fetch_one(pool) + .await +} + +// ============================================================================ +// Contract Supervisor +// ============================================================================ + +/// Update contract's supervisor task ID. +pub async fn update_contract_supervisor( + pool: &PgPool, + contract_id: Uuid, + supervisor_task_id: Uuid, +) -> Result<Contract, sqlx::Error> { + sqlx::query_as::<_, Contract>( + r#" + UPDATE contracts + SET supervisor_task_id = $1, + updated_at = NOW() + WHERE id = $2 + RETURNING * + "#, + ) + .bind(supervisor_task_id) + .bind(contract_id) + .fetch_one(pool) + .await +} + +/// Get the supervisor task for a contract. +pub async fn get_contract_supervisor_task( + pool: &PgPool, + contract_id: Uuid, +) -> Result<Option<Task>, sqlx::Error> { + sqlx::query_as::<_, Task>( + r#" + SELECT t.* FROM tasks t + JOIN contracts c ON c.supervisor_task_id = t.id + WHERE c.id = $1 + "#, + ) + .bind(contract_id) + .fetch_optional(pool) + .await +} + +// ============================================================================ +// Task Tree Queries +// ============================================================================ + +/// Get full task tree for a contract. +pub async fn get_contract_task_tree( + pool: &PgPool, + contract_id: Uuid, +) -> Result<Vec<Task>, sqlx::Error> { + sqlx::query_as::<_, Task>( + r#" + WITH RECURSIVE task_tree AS ( + -- Base case: root tasks (no parent) + SELECT * FROM tasks + WHERE contract_id = $1 AND parent_task_id IS NULL + UNION ALL + -- Recursive case: children of current level + SELECT t.* FROM tasks t + JOIN task_tree tt ON t.parent_task_id = tt.id + ) + SELECT * FROM task_tree + ORDER BY depth, created_at + "#, + ) + .bind(contract_id) + .fetch_all(pool) + .await +} + +/// Get task tree from a specific root task. +pub async fn get_task_tree(pool: &PgPool, root_task_id: Uuid) -> Result<Vec<Task>, sqlx::Error> { + sqlx::query_as::<_, Task>( + r#" + WITH RECURSIVE task_tree AS ( + -- Base case: the root task + SELECT * FROM tasks WHERE id = $1 + UNION ALL + -- Recursive case: children of current level + SELECT t.* FROM tasks t + JOIN task_tree tt ON t.parent_task_id = tt.id + ) + SELECT * FROM task_tree + ORDER BY depth, created_at + "#, + ) + .bind(root_task_id) + .fetch_all(pool) + .await +} + +// ============================================================================ +// Daemon Selection +// ============================================================================ + +/// Get daemons with capacity info for selection. +pub async fn get_available_daemons( + pool: &PgPool, + owner_id: Uuid, +) -> Result<Vec<DaemonWithCapacity>, sqlx::Error> { + sqlx::query_as::<_, DaemonWithCapacity>( + r#" + SELECT id, owner_id, connection_id, hostname, machine_id, + max_concurrent_tasks, current_task_count, + capacity_score, task_queue_length, supports_migration, + status, last_heartbeat_at, connected_at + FROM daemons + WHERE owner_id = $1 AND status = 'connected' + ORDER BY + COALESCE(capacity_score, 100) DESC, + (max_concurrent_tasks - current_task_count) DESC, + COALESCE(task_queue_length, 0) ASC + "#, + ) + .bind(owner_id) + .fetch_all(pool) + .await +} + +/// Create a daemon task assignment. +pub async fn create_daemon_task_assignment( + pool: &PgPool, + daemon_id: Uuid, + task_id: Uuid, +) -> Result<DaemonTaskAssignment, sqlx::Error> { + sqlx::query_as::<_, DaemonTaskAssignment>( + r#" + INSERT INTO daemon_task_assignments (daemon_id, task_id) + VALUES ($1, $2) + RETURNING * + "#, + ) + .bind(daemon_id) + .bind(task_id) + .fetch_one(pool) + .await +} + +/// Update daemon task assignment status. +pub async fn update_daemon_task_assignment_status( + pool: &PgPool, + task_id: Uuid, + status: &str, +) -> Result<DaemonTaskAssignment, sqlx::Error> { + sqlx::query_as::<_, DaemonTaskAssignment>( + r#" + UPDATE daemon_task_assignments + SET status = $1 + WHERE task_id = $2 + RETURNING * + "#, + ) + .bind(status) + .bind(task_id) + .fetch_one(pool) + .await +} + +/// Get daemon task assignment for a task. +pub async fn get_daemon_task_assignment( + pool: &PgPool, + task_id: Uuid, +) -> Result<Option<DaemonTaskAssignment>, sqlx::Error> { + sqlx::query_as::<_, DaemonTaskAssignment>( + "SELECT * FROM daemon_task_assignments WHERE task_id = $1", + ) + .bind(task_id) + .fetch_optional(pool) + .await +} diff --git a/makima/src/lib.rs b/makima/src/lib.rs index 064b123..8d3db58 100644 --- a/makima/src/lib.rs +++ b/makima/src/lib.rs @@ -1,4 +1,5 @@ pub mod audio; +pub mod daemon; pub mod db; pub mod listen; pub mod llm; diff --git a/makima/src/llm/contract_tools.rs b/makima/src/llm/contract_tools.rs new file mode 100644 index 0000000..0d6f9be --- /dev/null +++ b/makima/src/llm/contract_tools.rs @@ -0,0 +1,1091 @@ +//! Tool definitions for contract management via LLM. +//! +//! These tools allow the LLM to manage contracts: create tasks, add files, +//! manage repositories, and handle phase transitions. + +use serde_json::json; +use uuid::Uuid; + +use super::tools::Tool; + +/// Available tools for contract management +pub static CONTRACT_TOOLS: once_cell::sync::Lazy<Vec<Tool>> = once_cell::sync::Lazy::new(|| { + vec![ + // ============================================================================= + // Query Tools + // ============================================================================= + Tool { + name: "get_contract_status".to_string(), + description: "Get an overview of the contract including current phase, file count, task count, and repository count.".to_string(), + parameters: json!({ + "type": "object", + "properties": {} + }), + }, + Tool { + name: "list_contract_files".to_string(), + description: "List all files in the contract with their names, descriptions, and phases.".to_string(), + parameters: json!({ + "type": "object", + "properties": {} + }), + }, + Tool { + name: "list_contract_tasks".to_string(), + description: "List all tasks in the contract with their names, status, and progress.".to_string(), + parameters: json!({ + "type": "object", + "properties": {} + }), + }, + Tool { + name: "list_contract_repositories".to_string(), + description: "List all repositories attached to the contract with their types and URLs/paths.".to_string(), + parameters: json!({ + "type": "object", + "properties": {} + }), + }, + Tool { + name: "read_file".to_string(), + description: "Read the full contents of a file including its body, transcript, and summary.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "file_id": { + "type": "string", + "description": "ID of the file to read" + } + }, + "required": ["file_id"] + }), + }, + // ============================================================================= + // File Management Tools + // ============================================================================= + Tool { + name: "create_file_from_template".to_string(), + description: "Create a new file in the contract from a template. Templates are phase-appropriate document structures.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "template_id": { + "type": "string", + "description": "ID of the template to use (e.g., 'research-notes', 'requirements', 'architecture')" + }, + "name": { + "type": "string", + "description": "Name for the new file" + }, + "description": { + "type": "string", + "description": "Optional description for the file" + } + }, + "required": ["template_id", "name"] + }), + }, + Tool { + name: "create_empty_file".to_string(), + description: "Create a new empty file in the contract without using a template.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name for the new file" + }, + "description": { + "type": "string", + "description": "Optional description for the file" + } + }, + "required": ["name"] + }), + }, + Tool { + name: "list_available_templates".to_string(), + description: "List all available templates, optionally filtered by phase. Use this to see what templates can be used with create_file_from_template.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "phase": { + "type": "string", + "enum": ["research", "specify", "plan", "execute", "review"], + "description": "Optional filter to show only templates for a specific phase" + } + } + }), + }, + // ============================================================================= + // Task Management Tools + // ============================================================================= + Tool { + name: "create_contract_task".to_string(), + description: "Create a new task within this contract. The task will be associated with the contract and can optionally use a contract repository.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the task" + }, + "plan": { + "type": "string", + "description": "Detailed instructions/plan for what the task should accomplish" + }, + "repository_url": { + "type": "string", + "description": "Git repository URL or local path. If not specified, uses the contract's primary repository." + }, + "base_branch": { + "type": "string", + "description": "Optional base branch to start from (default: main)" + } + }, + "required": ["name", "plan"] + }), + }, + Tool { + name: "delegate_content_generation".to_string(), + description: "Create a task to generate substantial content instead of writing it directly. Use this for filling templates, writing documentation, generating user stories, or any substantial writing task. The task will be created and can be started separately.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "file_id": { + "type": "string", + "description": "ID of the file to update with generated content (optional - if not specified, creates a new task without file context)" + }, + "instruction": { + "type": "string", + "description": "Clear instructions for what content should be generated" + }, + "context": { + "type": "string", + "description": "Additional context to help generate appropriate content" + } + }, + "required": ["instruction"] + }), + }, + Tool { + name: "start_task".to_string(), + description: "Start a task that is in 'pending' status. The task will be sent to a connected daemon for execution. A daemon must be connected for this to work.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "ID of the task to start" + } + }, + "required": ["task_id"] + }), + }, + // ============================================================================= + // Phase Management Tools + // ============================================================================= + Tool { + name: "get_phase_info".to_string(), + description: "Get detailed information about the current phase and what it means for the contract workflow.".to_string(), + parameters: json!({ + "type": "object", + "properties": {} + }), + }, + Tool { + name: "suggest_phase_transition".to_string(), + description: "Analyze whether the contract is ready to advance to the NEXT phase. Returns: currentPhase, nextPhase (the phase to advance TO), readiness status, and what's missing. Use this BEFORE calling advance_phase to know exactly which phase to advance to.".to_string(), + parameters: json!({ + "type": "object", + "properties": {} + }), + }, + Tool { + name: "advance_phase".to_string(), + description: "Advance the contract to the NEXT phase in sequence. Phases progress: research -> specify -> plan -> execute -> review. You can ONLY advance forward one step. Always use suggest_phase_transition first to check readiness and find the correct next phase.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "new_phase": { + "type": "string", + "enum": ["specify", "plan", "execute", "review"], + "description": "The next phase to transition to. Must be exactly one step ahead of current phase (e.g., research->specify, specify->plan, plan->execute, execute->review)" + } + }, + "required": ["new_phase"] + }), + }, + // ============================================================================= + // Repository Management Tools + // ============================================================================= + Tool { + name: "list_daemon_directories".to_string(), + description: "List suggested directories from connected daemons. Use this to find valid local paths when the user wants to add a local repository or configure a target path. Returns working directories and home directories from connected agents.".to_string(), + parameters: json!({ + "type": "object", + "properties": {} + }), + }, + Tool { + name: "add_repository".to_string(), + description: "Add a repository to the contract. Can be a remote URL, local path, or create a managed repository.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["remote", "local", "managed"], + "description": "Type of repository to add" + }, + "name": { + "type": "string", + "description": "Display name for the repository" + }, + "url": { + "type": "string", + "description": "Repository URL (for remote type) or local path (for local type). Not needed for managed." + }, + "is_primary": { + "type": "boolean", + "description": "Whether this should be the primary repository for the contract" + } + }, + "required": ["type", "name"] + }), + }, + Tool { + name: "set_primary_repository".to_string(), + description: "Set a repository as the primary repository for this contract. The primary repo is used by default for new tasks.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "repository_id": { + "type": "string", + "description": "ID of the repository to set as primary" + } + }, + "required": ["repository_id"] + }), + }, + // ============================================================================= + // Phase Guidance Tools + // ============================================================================= + Tool { + name: "get_phase_checklist".to_string(), + description: "Get a detailed checklist of phase deliverables showing what's been created vs what's expected. Includes completion percentage and suggestions for next steps.".to_string(), + parameters: json!({ + "type": "object", + "properties": {} + }), + }, + // ============================================================================= + // Task Derivation Tools + // ============================================================================= + Tool { + name: "derive_tasks_from_file".to_string(), + description: "Parse a file (typically Task Breakdown) to extract a list of tasks. Returns structured task data that can be used with create_chained_tasks.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "file_id": { + "type": "string", + "description": "ID of the file to parse tasks from (usually a Task Breakdown document)" + } + }, + "required": ["file_id"] + }), + }, + Tool { + name: "create_chained_tasks".to_string(), + description: "Create multiple tasks in sequence with automatic chaining. Each task will continue from the previous task's work using continue_from_task_id.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "tasks": { + "type": "array", + "description": "List of tasks to create in order", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Task name" + }, + "plan": { + "type": "string", + "description": "Task plan/instructions" + } + }, + "required": ["name", "plan"] + } + } + }, + "required": ["tasks"] + }), + }, + // ============================================================================= + // Task Completion Processing Tools + // ============================================================================= + Tool { + name: "process_task_completion".to_string(), + description: "Analyze a completed task's output and suggest next actions. Returns summary, affected files, and suggestions for follow-up tasks or file updates.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "ID of the completed task to analyze" + } + }, + "required": ["task_id"] + }), + }, + Tool { + name: "update_file_from_task".to_string(), + description: "Update a contract file with information from a completed task. Useful for updating Dev Notes or Implementation Log with task summaries.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "file_id": { + "type": "string", + "description": "ID of the file to update" + }, + "task_id": { + "type": "string", + "description": "ID of the task whose output should be added" + }, + "section_title": { + "type": "string", + "description": "Optional title for the section being added (e.g., 'Task: Implement Authentication')" + } + }, + "required": ["file_id", "task_id"] + }), + }, + // ============================================================================= + // Interactive Tools + // ============================================================================= + Tool { + name: "ask_user".to_string(), + description: "Ask the user one or more questions. Use this when you need clarification, want to offer choices, or need user input before proceeding.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "questions": { + "type": "array", + "description": "List of questions to ask the user", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for this question" + }, + "question": { + "type": "string", + "description": "The question to ask the user" + }, + "options": { + "type": "array", + "items": { "type": "string" }, + "description": "Multiple choice options for the user to select from" + }, + "allowMultiple": { + "type": "boolean", + "description": "If true, user can select multiple options" + }, + "allowCustom": { + "type": "boolean", + "description": "If true, user can provide a custom answer" + } + }, + "required": ["id", "question", "options"] + } + } + }, + "required": ["questions"] + }), + }, + ] +}); + +/// Request for contract tool operations that require async database access +#[derive(Debug, Clone)] +pub enum ContractToolRequest { + // Query operations + GetContractStatus, + ListContractFiles, + ListContractTasks, + ListContractRepositories, + ReadFile { file_id: Uuid }, + + // File management + CreateFileFromTemplate { + template_id: String, + name: String, + description: Option<String>, + }, + CreateEmptyFile { + name: String, + description: Option<String>, + }, + ListAvailableTemplates { phase: Option<String> }, + + // Task management + CreateContractTask { + name: String, + plan: String, + repository_url: Option<String>, + base_branch: Option<String>, + }, + DelegateContentGeneration { + file_id: Option<Uuid>, + instruction: String, + context: Option<String>, + }, + StartTask { task_id: Uuid }, + + // Phase management + GetPhaseInfo, + SuggestPhaseTransition, + AdvancePhase { new_phase: String }, + + // Repository management + ListDaemonDirectories, + AddRepository { + repo_type: String, + name: String, + url: Option<String>, + is_primary: bool, + }, + SetPrimaryRepository { repository_id: Uuid }, + + // Phase guidance + GetPhaseChecklist, + + // Task derivation + DeriveTasksFromFile { file_id: Uuid }, + CreateChainedTasks { tasks: Vec<ChainedTaskDef> }, + + // Task completion processing + ProcessTaskCompletion { task_id: Uuid }, + UpdateFileFromTask { + file_id: Uuid, + task_id: Uuid, + section_title: Option<String>, + }, +} + +/// Task definition for chained task creation +#[derive(Debug, Clone, serde::Deserialize)] +pub struct ChainedTaskDef { + pub name: String, + pub plan: String, +} + +/// Result from executing a contract tool +#[derive(Debug)] +pub struct ContractToolExecutionResult { + pub success: bool, + pub message: String, + pub data: Option<serde_json::Value>, + /// Request for async operations (handled by contract_chat handler) + pub request: Option<ContractToolRequest>, + /// Questions to ask the user (pauses conversation) + pub pending_questions: Option<Vec<super::tools::UserQuestion>>, +} + +/// Parse and validate a contract tool call, returning a ContractToolRequest for async handling +pub fn parse_contract_tool_call(call: &super::tools::ToolCall) -> ContractToolExecutionResult { + match call.name.as_str() { + // Query operations + "get_contract_status" => parse_get_contract_status(), + "list_contract_files" => parse_list_contract_files(), + "list_contract_tasks" => parse_list_contract_tasks(), + "list_contract_repositories" => parse_list_contract_repositories(), + "read_file" => parse_read_file(call), + + // File management + "create_file_from_template" => parse_create_file_from_template(call), + "create_empty_file" => parse_create_empty_file(call), + "list_available_templates" => parse_list_available_templates(call), + + // Task management + "create_contract_task" => parse_create_contract_task(call), + "delegate_content_generation" => parse_delegate_content_generation(call), + "start_task" => parse_start_task(call), + + // Phase management + "get_phase_info" => parse_get_phase_info(), + "suggest_phase_transition" => parse_suggest_phase_transition(), + "advance_phase" => parse_advance_phase(call), + + // Repository management + "list_daemon_directories" => parse_list_daemon_directories(), + "add_repository" => parse_add_repository(call), + "set_primary_repository" => parse_set_primary_repository(call), + + // Phase guidance + "get_phase_checklist" => parse_get_phase_checklist(), + + // Task derivation + "derive_tasks_from_file" => parse_derive_tasks_from_file(call), + "create_chained_tasks" => parse_create_chained_tasks(call), + + // Task completion processing + "process_task_completion" => parse_process_task_completion(call), + "update_file_from_task" => parse_update_file_from_task(call), + + // Interactive tools + "ask_user" => parse_ask_user(call), + + _ => ContractToolExecutionResult { + success: false, + message: format!("Unknown contract tool: {}", call.name), + data: None, + request: None, + pending_questions: None, + }, + } +} + +// ============================================================================= +// Query Tool Parsing +// ============================================================================= + +fn parse_get_contract_status() -> ContractToolExecutionResult { + ContractToolExecutionResult { + success: true, + message: "Getting contract status...".to_string(), + data: None, + request: Some(ContractToolRequest::GetContractStatus), + pending_questions: None, + } +} + +fn parse_list_contract_files() -> ContractToolExecutionResult { + ContractToolExecutionResult { + success: true, + message: "Listing contract files...".to_string(), + data: None, + request: Some(ContractToolRequest::ListContractFiles), + pending_questions: None, + } +} + +fn parse_list_contract_tasks() -> ContractToolExecutionResult { + ContractToolExecutionResult { + success: true, + message: "Listing contract tasks...".to_string(), + data: None, + request: Some(ContractToolRequest::ListContractTasks), + pending_questions: None, + } +} + +fn parse_list_contract_repositories() -> ContractToolExecutionResult { + ContractToolExecutionResult { + success: true, + message: "Listing contract repositories...".to_string(), + data: None, + request: Some(ContractToolRequest::ListContractRepositories), + pending_questions: None, + } +} + +fn parse_read_file(call: &super::tools::ToolCall) -> ContractToolExecutionResult { + let file_id = parse_uuid_arg(call, "file_id"); + let Some(file_id) = file_id else { + return error_result("Missing or invalid required parameter: file_id"); + }; + + ContractToolExecutionResult { + success: true, + message: "Reading file...".to_string(), + data: None, + request: Some(ContractToolRequest::ReadFile { file_id }), + pending_questions: None, + } +} + +// ============================================================================= +// File Management Tool Parsing +// ============================================================================= + +fn parse_create_file_from_template(call: &super::tools::ToolCall) -> ContractToolExecutionResult { + let template_id = call.arguments.get("template_id").and_then(|v| v.as_str()); + let name = call.arguments.get("name").and_then(|v| v.as_str()); + + let Some(template_id) = template_id else { + return error_result("Missing required parameter: template_id"); + }; + let Some(name) = name else { + return error_result("Missing required parameter: name"); + }; + + let description = call + .arguments + .get("description") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + ContractToolExecutionResult { + success: true, + message: format!("Creating file '{}' from template '{}'...", name, template_id), + data: None, + request: Some(ContractToolRequest::CreateFileFromTemplate { + template_id: template_id.to_string(), + name: name.to_string(), + description, + }), + pending_questions: None, + } +} + +fn parse_create_empty_file(call: &super::tools::ToolCall) -> ContractToolExecutionResult { + let name = call.arguments.get("name").and_then(|v| v.as_str()); + + let Some(name) = name else { + return error_result("Missing required parameter: name"); + }; + + let description = call + .arguments + .get("description") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + ContractToolExecutionResult { + success: true, + message: format!("Creating empty file '{}'...", name), + data: None, + request: Some(ContractToolRequest::CreateEmptyFile { + name: name.to_string(), + description, + }), + pending_questions: None, + } +} + +fn parse_list_available_templates(call: &super::tools::ToolCall) -> ContractToolExecutionResult { + let phase = call + .arguments + .get("phase") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + ContractToolExecutionResult { + success: true, + message: "Listing available templates...".to_string(), + data: None, + request: Some(ContractToolRequest::ListAvailableTemplates { phase }), + pending_questions: None, + } +} + +// ============================================================================= +// Task Management Tool Parsing +// ============================================================================= + +fn parse_create_contract_task(call: &super::tools::ToolCall) -> ContractToolExecutionResult { + let name = call.arguments.get("name").and_then(|v| v.as_str()); + let plan = call.arguments.get("plan").and_then(|v| v.as_str()); + + let Some(name) = name else { + return error_result("Missing required parameter: name"); + }; + let Some(plan) = plan else { + return error_result("Missing required parameter: plan"); + }; + + let repository_url = call + .arguments + .get("repository_url") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let base_branch = call + .arguments + .get("base_branch") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + ContractToolExecutionResult { + success: true, + message: format!("Creating task '{}'...", name), + data: None, + request: Some(ContractToolRequest::CreateContractTask { + name: name.to_string(), + plan: plan.to_string(), + repository_url, + base_branch, + }), + pending_questions: None, + } +} + +fn parse_delegate_content_generation(call: &super::tools::ToolCall) -> ContractToolExecutionResult { + let instruction = call.arguments.get("instruction").and_then(|v| v.as_str()); + + let Some(instruction) = instruction else { + return error_result("Missing required parameter: instruction"); + }; + + let file_id = parse_uuid_arg(call, "file_id"); + let context = call + .arguments + .get("context") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + ContractToolExecutionResult { + success: true, + message: "Creating content generation task...".to_string(), + data: None, + request: Some(ContractToolRequest::DelegateContentGeneration { + file_id, + instruction: instruction.to_string(), + context, + }), + pending_questions: None, + } +} + +fn parse_start_task(call: &super::tools::ToolCall) -> ContractToolExecutionResult { + let task_id = parse_uuid_arg(call, "task_id"); + + let Some(task_id) = task_id else { + return error_result("Missing or invalid required parameter: task_id"); + }; + + ContractToolExecutionResult { + success: true, + message: "Starting task...".to_string(), + data: None, + request: Some(ContractToolRequest::StartTask { task_id }), + pending_questions: None, + } +} + +// ============================================================================= +// Phase Management Tool Parsing +// ============================================================================= + +fn parse_get_phase_info() -> ContractToolExecutionResult { + ContractToolExecutionResult { + success: true, + message: "Getting phase information...".to_string(), + data: None, + request: Some(ContractToolRequest::GetPhaseInfo), + pending_questions: None, + } +} + +fn parse_suggest_phase_transition() -> ContractToolExecutionResult { + ContractToolExecutionResult { + success: true, + message: "Analyzing phase transition readiness...".to_string(), + data: None, + request: Some(ContractToolRequest::SuggestPhaseTransition), + pending_questions: None, + } +} + +fn parse_advance_phase(call: &super::tools::ToolCall) -> ContractToolExecutionResult { + let new_phase = call.arguments.get("new_phase").and_then(|v| v.as_str()); + + let Some(new_phase) = new_phase else { + return error_result("Missing required parameter: new_phase"); + }; + + let valid_phases = ["research", "specify", "plan", "execute", "review"]; + if !valid_phases.contains(&new_phase) { + return error_result("Invalid phase. Must be one of: research, specify, plan, execute, review"); + } + + ContractToolExecutionResult { + success: true, + message: format!("Advancing to '{}' phase...", new_phase), + data: None, + request: Some(ContractToolRequest::AdvancePhase { + new_phase: new_phase.to_string(), + }), + pending_questions: None, + } +} + +// ============================================================================= +// Repository Management Tool Parsing +// ============================================================================= + +fn parse_list_daemon_directories() -> ContractToolExecutionResult { + ContractToolExecutionResult { + success: true, + message: "Listing daemon directories...".to_string(), + data: None, + request: Some(ContractToolRequest::ListDaemonDirectories), + pending_questions: None, + } +} + +fn parse_add_repository(call: &super::tools::ToolCall) -> ContractToolExecutionResult { + let repo_type = call.arguments.get("type").and_then(|v| v.as_str()); + let name = call.arguments.get("name").and_then(|v| v.as_str()); + + let Some(repo_type) = repo_type else { + return error_result("Missing required parameter: type"); + }; + let Some(name) = name else { + return error_result("Missing required parameter: name"); + }; + + let valid_types = ["remote", "local", "managed"]; + if !valid_types.contains(&repo_type) { + return error_result("Invalid type. Must be one of: remote, local, managed"); + } + + let url = call + .arguments + .get("url") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + // Validate URL is provided for remote and local types + if (repo_type == "remote" || repo_type == "local") && url.is_none() { + return error_result("URL/path is required for remote and local repository types"); + } + + let is_primary = call + .arguments + .get("is_primary") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + ContractToolExecutionResult { + success: true, + message: format!("Adding {} repository '{}'...", repo_type, name), + data: None, + request: Some(ContractToolRequest::AddRepository { + repo_type: repo_type.to_string(), + name: name.to_string(), + url, + is_primary, + }), + pending_questions: None, + } +} + +fn parse_set_primary_repository(call: &super::tools::ToolCall) -> ContractToolExecutionResult { + let repository_id = parse_uuid_arg(call, "repository_id"); + let Some(repository_id) = repository_id else { + return error_result("Missing or invalid required parameter: repository_id"); + }; + + ContractToolExecutionResult { + success: true, + message: "Setting primary repository...".to_string(), + data: None, + request: Some(ContractToolRequest::SetPrimaryRepository { repository_id }), + pending_questions: None, + } +} + +// ============================================================================= +// Interactive Tool Parsing +// ============================================================================= + +fn parse_ask_user(call: &super::tools::ToolCall) -> ContractToolExecutionResult { + let questions_value = call.arguments.get("questions"); + + let Some(questions_array) = questions_value.and_then(|v| v.as_array()) else { + return error_result("Missing or invalid 'questions' parameter"); + }; + + let mut questions: Vec<super::tools::UserQuestion> = Vec::new(); + + for q in questions_array { + let id = q.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let question = q.get("question").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let options: Vec<String> = q + .get("options") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|o| o.as_str()) + .map(|s| s.to_string()) + .collect() + }) + .unwrap_or_default(); + let allow_multiple = q.get("allowMultiple").and_then(|v| v.as_bool()).unwrap_or(false); + let allow_custom = q.get("allowCustom").and_then(|v| v.as_bool()).unwrap_or(true); + + if id.is_empty() || question.is_empty() || options.is_empty() { + continue; + } + + questions.push(super::tools::UserQuestion { + id, + question, + options, + allow_multiple, + allow_custom, + }); + } + + if questions.is_empty() { + return error_result("No valid questions provided"); + } + + let question_count = questions.len(); + ContractToolExecutionResult { + success: true, + message: format!("Asking user {} question(s). Waiting for response...", question_count), + data: None, + request: None, + pending_questions: Some(questions), + } +} + +// ============================================================================= +// Phase Guidance Tool Parsing +// ============================================================================= + +fn parse_get_phase_checklist() -> ContractToolExecutionResult { + ContractToolExecutionResult { + success: true, + message: "Getting phase checklist...".to_string(), + data: None, + request: Some(ContractToolRequest::GetPhaseChecklist), + pending_questions: None, + } +} + +// ============================================================================= +// Task Derivation Tool Parsing +// ============================================================================= + +fn parse_derive_tasks_from_file(call: &super::tools::ToolCall) -> ContractToolExecutionResult { + let file_id = parse_uuid_arg(call, "file_id"); + let Some(file_id) = file_id else { + return error_result("Missing or invalid required parameter: file_id"); + }; + + ContractToolExecutionResult { + success: true, + message: "Deriving tasks from file...".to_string(), + data: None, + request: Some(ContractToolRequest::DeriveTasksFromFile { file_id }), + pending_questions: None, + } +} + +fn parse_create_chained_tasks(call: &super::tools::ToolCall) -> ContractToolExecutionResult { + let tasks_value = call.arguments.get("tasks"); + + let Some(tasks_array) = tasks_value.and_then(|v| v.as_array()) else { + return error_result("Missing or invalid 'tasks' parameter"); + }; + + let mut tasks: Vec<ChainedTaskDef> = Vec::new(); + + for task in tasks_array { + let name = task.get("name").and_then(|v| v.as_str()); + let plan = task.get("plan").and_then(|v| v.as_str()); + + match (name, plan) { + (Some(n), Some(p)) => { + tasks.push(ChainedTaskDef { + name: n.to_string(), + plan: p.to_string(), + }); + } + _ => { + return error_result("Each task must have 'name' and 'plan' fields"); + } + } + } + + if tasks.is_empty() { + return error_result("No valid tasks provided"); + } + + let task_count = tasks.len(); + ContractToolExecutionResult { + success: true, + message: format!("Creating {} chained task(s)...", task_count), + data: None, + request: Some(ContractToolRequest::CreateChainedTasks { tasks }), + pending_questions: None, + } +} + +// ============================================================================= +// Task Completion Processing Tool Parsing +// ============================================================================= + +fn parse_process_task_completion(call: &super::tools::ToolCall) -> ContractToolExecutionResult { + let task_id = parse_uuid_arg(call, "task_id"); + let Some(task_id) = task_id else { + return error_result("Missing or invalid required parameter: task_id"); + }; + + ContractToolExecutionResult { + success: true, + message: "Processing task completion...".to_string(), + data: None, + request: Some(ContractToolRequest::ProcessTaskCompletion { task_id }), + pending_questions: None, + } +} + +fn parse_update_file_from_task(call: &super::tools::ToolCall) -> ContractToolExecutionResult { + let file_id = parse_uuid_arg(call, "file_id"); + let task_id = parse_uuid_arg(call, "task_id"); + + let Some(file_id) = file_id else { + return error_result("Missing or invalid required parameter: file_id"); + }; + let Some(task_id) = task_id else { + return error_result("Missing or invalid required parameter: task_id"); + }; + + let section_title = call + .arguments + .get("section_title") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + ContractToolExecutionResult { + success: true, + message: "Updating file from task...".to_string(), + data: None, + request: Some(ContractToolRequest::UpdateFileFromTask { + file_id, + task_id, + section_title, + }), + pending_questions: None, + } +} + +// ============================================================================= +// Helper Functions +// ============================================================================= + +fn parse_uuid_arg(call: &super::tools::ToolCall, key: &str) -> Option<Uuid> { + call.arguments + .get(key) + .and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()) +} + +fn error_result(message: &str) -> ContractToolExecutionResult { + ContractToolExecutionResult { + success: false, + message: message.to_string(), + data: None, + request: None, + pending_questions: None, + } +} diff --git a/makima/src/llm/markdown.rs b/makima/src/llm/markdown.rs new file mode 100644 index 0000000..482dc8c --- /dev/null +++ b/makima/src/llm/markdown.rs @@ -0,0 +1,334 @@ +//! Markdown conversion utilities for BodyElement arrays. +//! +//! Provides bidirectional conversion between structured BodyElement[] and markdown strings. + +use crate::db::models::BodyElement; + +/// Convert a slice of BodyElements to a markdown string. +/// +/// Handles: +/// - Headings: `# heading` through `###### heading` based on level +/// - Paragraphs: plain text with blank lines between +/// - Code blocks: ````language\ncontent\n```` +/// - Lists: ordered (1. 2. 3.) and unordered (- - -) +/// - Charts: rendered as fenced JSON with chart type +/// - Images: rendered as markdown image syntax +pub fn body_to_markdown(elements: &[BodyElement]) -> String { + elements + .iter() + .filter_map(|elem| match elem { + BodyElement::Heading { level, text } => { + let hashes = "#".repeat((*level).min(6) as usize); + Some(format!("{} {}", hashes, text)) + } + BodyElement::Paragraph { text } => Some(text.clone()), + BodyElement::Code { language, content } => { + let lang = language.as_deref().unwrap_or(""); + Some(format!("```{}\n{}\n```", lang, content)) + } + BodyElement::List { ordered, items } => { + let list: Vec<String> = items + .iter() + .enumerate() + .map(|(i, item)| { + if *ordered { + format!("{}. {}", i + 1, item) + } else { + format!("- {}", item) + } + }) + .collect(); + Some(list.join("\n")) + } + BodyElement::Chart { + chart_type, + title, + data, + config: _, + } => { + // Render chart as a fenced block with metadata + let title_str = title + .as_ref() + .map(|t| format!(" - {}", t)) + .unwrap_or_default(); + let data_str = serde_json::to_string_pretty(data).unwrap_or_default(); + Some(format!( + "```chart:{:?}{}\n{}\n```", + chart_type, title_str, data_str + )) + } + BodyElement::Image { src, alt, caption } => { + let alt_text = alt.as_deref().unwrap_or("image"); + let caption_str = caption + .as_ref() + .map(|c| format!("\n*{}*", c)) + .unwrap_or_default(); + Some(format!("{}", alt_text, src, caption_str)) + } + // Markdown elements output their content directly - it's already markdown + BodyElement::Markdown { content } => Some(content.clone()), + }) + .collect::<Vec<_>>() + .join("\n\n") +} + +/// Parse a markdown string into a vector of BodyElements. +/// +/// Handles: +/// - Headings: lines starting with # through ###### +/// - Code blocks: ````language ... ```` +/// - Ordered lists: lines starting with 1. 2. etc. +/// - Unordered lists: lines starting with - or * +/// - Paragraphs: all other non-empty lines +pub fn markdown_to_body(markdown: &str) -> Vec<BodyElement> { + let mut elements = Vec::new(); + let lines: Vec<&str> = markdown.lines().collect(); + let mut i = 0; + + while i < lines.len() { + let line = lines[i]; + let trimmed = line.trim(); + + // Skip empty lines + if trimmed.is_empty() { + i += 1; + continue; + } + + // Check for code blocks + if trimmed.starts_with("```") { + let language = trimmed.trim_start_matches('`').trim(); + let language = if language.is_empty() { + None + } else { + Some(language.to_string()) + }; + + let mut content_lines = Vec::new(); + i += 1; + + // Collect content until closing ``` + while i < lines.len() && !lines[i].trim().starts_with("```") { + content_lines.push(lines[i]); + i += 1; + } + + // Skip the closing ``` + if i < lines.len() { + i += 1; + } + + elements.push(BodyElement::Code { + language, + content: content_lines.join("\n"), + }); + continue; + } + + // Check for headings + if trimmed.starts_with('#') { + let level = trimmed.chars().take_while(|&c| c == '#').count() as u8; + let text = trimmed.trim_start_matches('#').trim().to_string(); + elements.push(BodyElement::Heading { level, text }); + i += 1; + continue; + } + + // Check for unordered lists (- or *) + if trimmed.starts_with("- ") || trimmed.starts_with("* ") { + let mut items = Vec::new(); + while i < lines.len() { + let current = lines[i].trim(); + if current.starts_with("- ") || current.starts_with("* ") { + items.push(current[2..].to_string()); + i += 1; + } else if current.is_empty() { + i += 1; + break; + } else { + break; + } + } + elements.push(BodyElement::List { + ordered: false, + items, + }); + continue; + } + + // Check for ordered lists (1. 2. etc.) + if let Some(rest) = try_parse_ordered_list_item(trimmed) { + let mut items = Vec::new(); + items.push(rest.to_string()); + i += 1; + + while i < lines.len() { + let current = lines[i].trim(); + if let Some(item_rest) = try_parse_ordered_list_item(current) { + items.push(item_rest.to_string()); + i += 1; + } else if current.is_empty() { + i += 1; + break; + } else { + break; + } + } + elements.push(BodyElement::List { + ordered: true, + items, + }); + continue; + } + + // Default: paragraph (collect consecutive non-empty lines) + let mut para_lines = Vec::new(); + while i < lines.len() { + let current = lines[i].trim(); + if current.is_empty() + || current.starts_with('#') + || current.starts_with("```") + || current.starts_with("- ") + || current.starts_with("* ") + || try_parse_ordered_list_item(current).is_some() + { + break; + } + para_lines.push(current); + i += 1; + } + + if !para_lines.is_empty() { + elements.push(BodyElement::Paragraph { + text: para_lines.join(" "), + }); + } + } + + elements +} + +/// Try to parse an ordered list item (e.g., "1. Item text") +/// Returns the text after the number and period, or None if not a list item. +fn try_parse_ordered_list_item(s: &str) -> Option<&str> { + let mut chars = s.char_indices(); + + // Must start with a digit + let (_, first) = chars.next()?; + if !first.is_ascii_digit() { + return None; + } + + // Consume remaining digits + let mut last_digit_end = 1; + for (idx, c) in chars.by_ref() { + if c.is_ascii_digit() { + last_digit_end = idx + 1; + } else if c == '.' { + // Found the period - check for space after + let rest = &s[last_digit_end + 1..]; + let rest = rest.trim_start(); + if !rest.is_empty() || s.ends_with(". ") { + return Some(rest); + } + return None; + } else { + return None; + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_body_to_markdown_heading() { + let elements = vec![BodyElement::Heading { + level: 2, + text: "Hello World".to_string(), + }]; + assert_eq!(body_to_markdown(&elements), "## Hello World"); + } + + #[test] + fn test_body_to_markdown_paragraph() { + let elements = vec![BodyElement::Paragraph { + text: "This is a paragraph.".to_string(), + }]; + assert_eq!(body_to_markdown(&elements), "This is a paragraph."); + } + + #[test] + fn test_body_to_markdown_code() { + let elements = vec![BodyElement::Code { + language: Some("rust".to_string()), + content: "fn main() {}".to_string(), + }]; + assert_eq!( + body_to_markdown(&elements), + "```rust\nfn main() {}\n```" + ); + } + + #[test] + fn test_body_to_markdown_list() { + let elements = vec![BodyElement::List { + ordered: false, + items: vec!["Item 1".to_string(), "Item 2".to_string()], + }]; + assert_eq!(body_to_markdown(&elements), "- Item 1\n- Item 2"); + } + + #[test] + fn test_markdown_to_body_heading() { + let md = "## Hello World"; + let elements = markdown_to_body(md); + assert_eq!(elements.len(), 1); + match &elements[0] { + BodyElement::Heading { level, text } => { + assert_eq!(*level, 2); + assert_eq!(text, "Hello World"); + } + _ => panic!("Expected Heading"), + } + } + + #[test] + fn test_markdown_to_body_code() { + let md = "```rust\nfn main() {}\n```"; + let elements = markdown_to_body(md); + assert_eq!(elements.len(), 1); + match &elements[0] { + BodyElement::Code { language, content } => { + assert_eq!(language.as_deref(), Some("rust")); + assert_eq!(content, "fn main() {}"); + } + _ => panic!("Expected Code"), + } + } + + #[test] + fn test_roundtrip() { + let original = vec![ + BodyElement::Heading { + level: 1, + text: "Title".to_string(), + }, + BodyElement::Paragraph { + text: "Some text here.".to_string(), + }, + BodyElement::List { + ordered: false, + items: vec!["A".to_string(), "B".to_string()], + }, + ]; + + let markdown = body_to_markdown(&original); + let parsed = markdown_to_body(&markdown); + + assert_eq!(parsed.len(), 3); + } +} diff --git a/makima/src/llm/mesh_tools.rs b/makima/src/llm/mesh_tools.rs index 1d12c66..ec9dd01 100644 --- a/makima/src/llm/mesh_tools.rs +++ b/makima/src/llm/mesh_tools.rs @@ -418,6 +418,140 @@ pub static MESH_TOOLS: once_cell::sync::Lazy<Vec<Tool>> = once_cell::sync::Lazy: "required": ["questions"] }), }, + // ============================================================================= + // Supervisor Tools (only available to supervisor tasks) + // ============================================================================= + Tool { + name: "get_all_contract_tasks".to_string(), + description: "Get status of all tasks in the contract tree. Only available to supervisor tasks.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "contract_id": { + "type": "string", + "description": "ID of the contract to query tasks for" + } + }, + "required": ["contract_id"] + }), + }, + Tool { + name: "wait_for_task_completion".to_string(), + description: "Block until a task completes or timeout. Only available to supervisor tasks.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "ID of the task to wait for" + }, + "timeout_seconds": { + "type": "integer", + "description": "Maximum time to wait in seconds (default: 300)", + "default": 300 + } + }, + "required": ["task_id"] + }), + }, + Tool { + name: "read_task_worktree".to_string(), + description: "Read a file from any task's worktree. Only available to supervisor tasks.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "ID of the task whose worktree to read from" + }, + "file_path": { + "type": "string", + "description": "Path to the file within the worktree" + } + }, + "required": ["task_id", "file_path"] + }), + }, + Tool { + name: "spawn_task".to_string(), + description: "Create and start a child task (fire and forget). Only available to supervisor tasks.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the task" + }, + "plan": { + "type": "string", + "description": "Detailed instructions/plan for what the task should accomplish" + }, + "parent_task_id": { + "type": "string", + "description": "Optional parent task to branch from" + }, + "checkpoint_sha": { + "type": "string", + "description": "Optional checkpoint SHA to branch from" + }, + "repository_url": { + "type": "string", + "description": "Git repository URL (optional - inherits from contract if not provided)" + }, + "base_branch": { + "type": "string", + "description": "Optional base branch to start from" + } + }, + "required": ["name", "plan"] + }), + }, + Tool { + name: "create_checkpoint".to_string(), + description: "Create a git checkpoint (commit) in the current task's worktree. Only available to supervisor tasks.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "ID of the task to checkpoint" + }, + "message": { + "type": "string", + "description": "Commit message for the checkpoint" + } + }, + "required": ["task_id", "message"] + }), + }, + Tool { + name: "list_task_checkpoints".to_string(), + description: "List all checkpoints for a task.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "ID of the task to list checkpoints for" + } + }, + "required": ["task_id"] + }), + }, + Tool { + name: "get_task_tree".to_string(), + description: "Get the full task tree starting from a specific task.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "ID of the root task" + } + }, + "required": ["task_id"] + }), + }, ] }); @@ -506,6 +640,37 @@ pub enum MeshToolRequest { task_id: Uuid, mode: String, }, + + // Supervisor tools (only for supervisor tasks) + GetAllContractTasks { + contract_id: Uuid, + }, + WaitForTaskCompletion { + task_id: Uuid, + timeout_seconds: i32, + }, + ReadTaskWorktree { + task_id: Uuid, + file_path: String, + }, + SpawnTask { + name: String, + plan: String, + parent_task_id: Option<Uuid>, + checkpoint_sha: Option<String>, + repository_url: Option<String>, + base_branch: Option<String>, + }, + CreateCheckpoint { + task_id: Uuid, + message: String, + }, + ListTaskCheckpoints { + task_id: Uuid, + }, + GetTaskTree { + task_id: Uuid, + }, } /// Result from executing a mesh tool @@ -560,6 +725,15 @@ pub fn parse_mesh_tool_call( // Interactive tools "ask_user" => parse_ask_user(call), + // Supervisor tools + "get_all_contract_tasks" => parse_get_all_contract_tasks(call), + "wait_for_task_completion" => parse_wait_for_task_completion(call), + "read_task_worktree" => parse_read_task_worktree(call), + "spawn_task" => parse_spawn_task(call), + "create_checkpoint" => parse_create_checkpoint(call), + "list_task_checkpoints" => parse_list_task_checkpoints(call), + "get_task_tree" => parse_get_task_tree(call), + _ => MeshToolExecutionResult { success: false, message: format!("Unknown mesh tool: {}", call.name), @@ -1059,6 +1233,163 @@ fn parse_ask_user(call: &super::tools::ToolCall) -> MeshToolExecutionResult { } // ============================================================================= +// Supervisor Tool Parsing Functions +// ============================================================================= + +fn parse_get_all_contract_tasks(call: &super::tools::ToolCall) -> MeshToolExecutionResult { + let Some(contract_id) = parse_uuid_arg(call, "contract_id") else { + return error_result("Missing or invalid contract_id"); + }; + + MeshToolExecutionResult { + success: true, + message: "Querying all contract tasks...".to_string(), + data: None, + request: Some(MeshToolRequest::GetAllContractTasks { contract_id }), + pending_questions: None, + } +} + +fn parse_wait_for_task_completion(call: &super::tools::ToolCall) -> MeshToolExecutionResult { + let Some(task_id) = parse_uuid_arg(call, "task_id") else { + return error_result("Missing or invalid task_id"); + }; + + let timeout_seconds = call + .arguments + .get("timeout_seconds") + .and_then(|v| v.as_i64()) + .map(|v| v as i32) + .unwrap_or(300); + + MeshToolExecutionResult { + success: true, + message: format!("Waiting for task completion (timeout: {}s)...", timeout_seconds), + data: None, + request: Some(MeshToolRequest::WaitForTaskCompletion { + task_id, + timeout_seconds, + }), + pending_questions: None, + } +} + +fn parse_read_task_worktree(call: &super::tools::ToolCall) -> MeshToolExecutionResult { + let Some(task_id) = parse_uuid_arg(call, "task_id") else { + return error_result("Missing or invalid task_id"); + }; + + let Some(file_path) = call.arguments.get("file_path").and_then(|v| v.as_str()) else { + return error_result("Missing required parameter: file_path"); + }; + + MeshToolExecutionResult { + success: true, + message: format!("Reading file from task worktree: {}", file_path), + data: None, + request: Some(MeshToolRequest::ReadTaskWorktree { + task_id, + file_path: file_path.to_string(), + }), + pending_questions: None, + } +} + +fn parse_spawn_task(call: &super::tools::ToolCall) -> MeshToolExecutionResult { + let Some(name) = call.arguments.get("name").and_then(|v| v.as_str()) else { + return error_result("Missing required parameter: name"); + }; + + let Some(plan) = call.arguments.get("plan").and_then(|v| v.as_str()) else { + return error_result("Missing required parameter: plan"); + }; + + let parent_task_id = parse_uuid_arg(call, "parent_task_id"); + + let checkpoint_sha = call + .arguments + .get("checkpoint_sha") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let repository_url = call + .arguments + .get("repository_url") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let base_branch = call + .arguments + .get("base_branch") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + MeshToolExecutionResult { + success: true, + message: format!("Spawning task: {}", name), + data: None, + request: Some(MeshToolRequest::SpawnTask { + name: name.to_string(), + plan: plan.to_string(), + parent_task_id, + checkpoint_sha, + repository_url, + base_branch, + }), + pending_questions: None, + } +} + +fn parse_create_checkpoint(call: &super::tools::ToolCall) -> MeshToolExecutionResult { + let Some(task_id) = parse_uuid_arg(call, "task_id") else { + return error_result("Missing or invalid task_id"); + }; + + let Some(message) = call.arguments.get("message").and_then(|v| v.as_str()) else { + return error_result("Missing required parameter: message"); + }; + + MeshToolExecutionResult { + success: true, + message: format!("Creating checkpoint: {}", message), + data: None, + request: Some(MeshToolRequest::CreateCheckpoint { + task_id, + message: message.to_string(), + }), + pending_questions: None, + } +} + +fn parse_list_task_checkpoints(call: &super::tools::ToolCall) -> MeshToolExecutionResult { + let Some(task_id) = parse_uuid_arg(call, "task_id") else { + return error_result("Missing or invalid task_id"); + }; + + MeshToolExecutionResult { + success: true, + message: "Listing task checkpoints...".to_string(), + data: None, + request: Some(MeshToolRequest::ListTaskCheckpoints { task_id }), + pending_questions: None, + } +} + +fn parse_get_task_tree(call: &super::tools::ToolCall) -> MeshToolExecutionResult { + let Some(task_id) = parse_uuid_arg(call, "task_id") else { + return error_result("Missing or invalid task_id"); + }; + + MeshToolExecutionResult { + success: true, + message: "Getting task tree...".to_string(), + data: None, + request: Some(MeshToolRequest::GetTaskTree { task_id }), + pending_questions: None, + } +} + +// ============================================================================= // Helper Functions // ============================================================================= diff --git a/makima/src/llm/mod.rs b/makima/src/llm/mod.rs index 39cdbdd..da8c0a4 100644 --- a/makima/src/llm/mod.rs +++ b/makima/src/llm/mod.rs @@ -1,13 +1,33 @@ //! LLM integration module for file editing via tool calling. pub mod claude; +pub mod contract_tools; pub mod groq; +pub mod markdown; pub mod mesh_tools; +pub mod phase_guidance; +pub mod task_output; +pub mod templates; pub mod tools; pub use claude::{ClaudeClient, ClaudeModel}; +pub use contract_tools::{ + parse_contract_tool_call, ChainedTaskDef, ContractToolExecutionResult, ContractToolRequest, + CONTRACT_TOOLS, +}; pub use groq::GroqClient; pub use mesh_tools::{parse_mesh_tool_call, MeshToolExecutionResult, MeshToolRequest, MESH_TOOLS}; +pub use phase_guidance::{ + check_phase_completion, format_checklist_markdown, get_phase_checklist, get_phase_deliverables, + DeliverableStatus, FileInfo, FilePriority, PhaseChecklist, PhaseDeliverables, RecommendedFile, + TaskInfo, TaskStats, +}; +pub use task_output::{ + analyze_task_output, format_parsed_tasks, parse_tasks_from_breakdown, ParsedTask, + PhaseImpact, SuggestedAction, TaskOutputAnalysis, TaskParseResult, +}; +pub use markdown::{body_to_markdown, markdown_to_body}; +pub use templates::{all_templates, templates_for_phase, FileTemplate}; pub use tools::{ execute_tool_call, Tool, ToolCall, ToolResult, UserAnswer, UserQuestion, VersionToolRequest, AVAILABLE_TOOLS, diff --git a/makima/src/llm/phase_guidance.rs b/makima/src/llm/phase_guidance.rs new file mode 100644 index 0000000..e2d6cd8 --- /dev/null +++ b/makima/src/llm/phase_guidance.rs @@ -0,0 +1,594 @@ +//! Phase guidance and deliverables tracking for contract management. +//! +//! This module provides structured guidance for each contract phase, tracking +//! expected deliverables and completion criteria. + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +/// Priority level for recommended deliverables +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum FilePriority { + /// Must exist before advancing phase + Required, + /// Strongly suggested for phase completion + Recommended, + /// Nice to have, not blocking + Optional, +} + +/// A recommended file for a phase +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecommendedFile { + /// Template ID to create from + pub template_id: String, + /// Suggested file name + pub name_suggestion: String, + /// Priority level + pub priority: FilePriority, + /// Brief description of purpose + pub description: String, +} + +/// Expected deliverables for a phase +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PhaseDeliverables { + /// Phase name + pub phase: String, + /// Recommended files to create + pub recommended_files: Vec<RecommendedFile>, + /// Whether a repository is required for this phase + pub requires_repository: bool, + /// Whether tasks should exist in this phase + pub requires_tasks: bool, + /// Guidance text for this phase + pub guidance: String, +} + +/// Status of a deliverable item +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct DeliverableStatus { + /// Template ID + pub template_id: String, + /// Expected name + pub name: String, + /// Priority + pub priority: FilePriority, + /// Whether it has been created + pub completed: bool, + /// File ID if created + pub file_id: Option<Uuid>, + /// Actual file name if created + pub actual_name: Option<String>, +} + +/// Checklist for phase completion +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct PhaseChecklist { + /// Current phase + pub phase: String, + /// File deliverables status + pub file_deliverables: Vec<DeliverableStatus>, + /// Whether repository is configured + pub has_repository: bool, + /// Whether repository was required + pub repository_required: bool, + /// Task statistics (for execute phase) + pub task_stats: Option<TaskStats>, + /// Overall completion percentage (0-100) + pub completion_percentage: u8, + /// Summary message + pub summary: String, + /// Suggestions for next actions + pub suggestions: Vec<String>, +} + +/// Task statistics for execute phase +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct TaskStats { + pub total: usize, + pub pending: usize, + pub running: usize, + pub done: usize, + pub failed: usize, +} + +/// Minimal file info for checklist building +pub struct FileInfo { + pub id: Uuid, + pub name: String, + pub contract_phase: Option<String>, +} + +/// Minimal task info for checklist building +pub struct TaskInfo { + pub id: Uuid, + pub name: String, + pub status: String, +} + +/// Get phase deliverables configuration +pub fn get_phase_deliverables(phase: &str) -> PhaseDeliverables { + match phase { + "research" => PhaseDeliverables { + phase: "research".to_string(), + recommended_files: vec![ + RecommendedFile { + template_id: "research-notes".to_string(), + name_suggestion: "Research Notes".to_string(), + priority: FilePriority::Recommended, + description: "Document findings and insights during research".to_string(), + }, + RecommendedFile { + template_id: "competitor-analysis".to_string(), + name_suggestion: "Competitor Analysis".to_string(), + priority: FilePriority::Recommended, + description: "Analyze competitors and market positioning".to_string(), + }, + RecommendedFile { + template_id: "user-research".to_string(), + name_suggestion: "User Research".to_string(), + priority: FilePriority::Optional, + description: "Document user interviews and persona insights".to_string(), + }, + ], + requires_repository: false, + requires_tasks: false, + guidance: "Focus on understanding the problem space, gathering information, and documenting findings. Create at least one research document before moving to Specify phase.".to_string(), + }, + "specify" => PhaseDeliverables { + phase: "specify".to_string(), + recommended_files: vec![ + RecommendedFile { + template_id: "requirements".to_string(), + name_suggestion: "Requirements Document".to_string(), + priority: FilePriority::Required, + description: "Define functional and non-functional requirements".to_string(), + }, + RecommendedFile { + template_id: "user-stories".to_string(), + name_suggestion: "User Stories".to_string(), + priority: FilePriority::Recommended, + description: "Define features from the user's perspective".to_string(), + }, + RecommendedFile { + template_id: "acceptance-criteria".to_string(), + name_suggestion: "Acceptance Criteria".to_string(), + priority: FilePriority::Recommended, + description: "Define testable conditions for completion".to_string(), + }, + ], + requires_repository: false, + requires_tasks: false, + guidance: "Define what needs to be built with clear requirements and acceptance criteria. Ensure specifications are detailed enough for planning.".to_string(), + }, + "plan" => PhaseDeliverables { + phase: "plan".to_string(), + recommended_files: vec![ + RecommendedFile { + template_id: "architecture".to_string(), + name_suggestion: "Architecture Document".to_string(), + priority: FilePriority::Recommended, + description: "Document system architecture and design decisions".to_string(), + }, + RecommendedFile { + template_id: "task-breakdown".to_string(), + name_suggestion: "Task Breakdown".to_string(), + priority: FilePriority::Required, + description: "Break down work into implementable tasks".to_string(), + }, + RecommendedFile { + template_id: "technical-design".to_string(), + name_suggestion: "Technical Design".to_string(), + priority: FilePriority::Optional, + description: "Detailed technical specification".to_string(), + }, + ], + requires_repository: true, + requires_tasks: false, + guidance: "Design the solution and break down work into tasks. A repository must be configured before moving to Execute phase.".to_string(), + }, + "execute" => PhaseDeliverables { + phase: "execute".to_string(), + recommended_files: vec![ + RecommendedFile { + template_id: "dev-notes".to_string(), + name_suggestion: "Development Notes".to_string(), + priority: FilePriority::Recommended, + description: "Track implementation details and decisions".to_string(), + }, + RecommendedFile { + template_id: "test-plan".to_string(), + name_suggestion: "Test Plan".to_string(), + priority: FilePriority::Optional, + description: "Document testing strategy and test cases".to_string(), + }, + RecommendedFile { + template_id: "implementation-log".to_string(), + name_suggestion: "Implementation Log".to_string(), + priority: FilePriority::Optional, + description: "Chronological log of implementation progress".to_string(), + }, + ], + requires_repository: true, + requires_tasks: true, + guidance: "Execute the planned tasks, implement features, and track progress. Complete all tasks before moving to Review phase.".to_string(), + }, + "review" => PhaseDeliverables { + phase: "review".to_string(), + recommended_files: vec![ + RecommendedFile { + template_id: "release-notes".to_string(), + name_suggestion: "Release Notes".to_string(), + priority: FilePriority::Required, + description: "Document changes for release communication".to_string(), + }, + RecommendedFile { + template_id: "review-checklist".to_string(), + name_suggestion: "Review Checklist".to_string(), + priority: FilePriority::Recommended, + description: "Comprehensive checklist for code and feature review".to_string(), + }, + RecommendedFile { + template_id: "retrospective".to_string(), + name_suggestion: "Retrospective".to_string(), + priority: FilePriority::Optional, + description: "Reflect on the project and capture learnings".to_string(), + }, + ], + requires_repository: false, + requires_tasks: false, + guidance: "Review completed work, document the release, and conduct a retrospective. The contract can be completed after review.".to_string(), + }, + _ => PhaseDeliverables { + phase: phase.to_string(), + recommended_files: vec![], + requires_repository: false, + requires_tasks: false, + guidance: "Unknown phase".to_string(), + }, + } +} + +/// Build a phase checklist comparing expected vs actual deliverables +pub fn get_phase_checklist( + phase: &str, + files: &[FileInfo], + tasks: &[TaskInfo], + has_repository: bool, +) -> PhaseChecklist { + let deliverables = get_phase_deliverables(phase); + + // Match files to expected deliverables + let file_deliverables: Vec<DeliverableStatus> = deliverables + .recommended_files + .iter() + .map(|rec| { + // Check if a file with matching template ID or similar name exists + let matched_file = files.iter().find(|f| { + // Match by phase first + f.contract_phase.as_deref() == Some(phase) && + // Then by name similarity (case-insensitive contains) + (f.name.to_lowercase().contains(&rec.name_suggestion.to_lowercase()) || + rec.name_suggestion.to_lowercase().contains(&f.name.to_lowercase()) || + f.name.to_lowercase().contains(&rec.template_id.replace("-", " "))) + }); + + DeliverableStatus { + template_id: rec.template_id.clone(), + name: rec.name_suggestion.clone(), + priority: rec.priority, + completed: matched_file.is_some(), + file_id: matched_file.map(|f| f.id), + actual_name: matched_file.map(|f| f.name.clone()), + } + }) + .collect(); + + // Calculate task stats for execute phase + let task_stats = if phase == "execute" { + let total = tasks.len(); + let pending = tasks.iter().filter(|t| t.status == "pending").count(); + let running = tasks.iter().filter(|t| t.status == "running").count(); + let done = tasks.iter().filter(|t| t.status == "done").count(); + let failed = tasks.iter().filter(|t| t.status == "failed" || t.status == "error").count(); + + Some(TaskStats { total, pending, running, done, failed }) + } else { + None + }; + + // Calculate completion percentage + let mut completed_items = 0; + let mut total_items = 0; + + // Count required and recommended files (not optional) + for status in &file_deliverables { + if status.priority != FilePriority::Optional { + total_items += 1; + if status.completed { + completed_items += 1; + } + } + } + + // Count repository if required + if deliverables.requires_repository { + total_items += 1; + if has_repository { + completed_items += 1; + } + } + + // Count tasks if in execute phase + if let Some(ref stats) = task_stats { + if stats.total > 0 { + total_items += 1; + if stats.done == stats.total && stats.total > 0 { + completed_items += 1; + } + } + } + + let completion_percentage = if total_items > 0 { + ((completed_items as f64 / total_items as f64) * 100.0) as u8 + } else { + 100 // No requirements means complete + }; + + // Generate suggestions + let mut suggestions = Vec::new(); + + // Suggest missing required files + for status in &file_deliverables { + if !status.completed { + match status.priority { + FilePriority::Required => { + suggestions.push(format!("Create {} (required)", status.name)); + } + FilePriority::Recommended => { + suggestions.push(format!("Consider creating {} (recommended)", status.name)); + } + FilePriority::Optional => { + // Don't suggest optional items + } + } + } + } + + // Suggest repository if needed + if deliverables.requires_repository && !has_repository { + suggestions.push("Configure a repository for task execution".to_string()); + } + + // Suggest task actions for execute phase + if let Some(ref stats) = task_stats { + if stats.total == 0 { + suggestions.push("Create tasks from the Task Breakdown document".to_string()); + } else if stats.pending > 0 { + suggestions.push(format!("Run {} pending task(s)", stats.pending)); + } else if stats.running > 0 { + suggestions.push(format!("Wait for {} running task(s) to complete", stats.running)); + } else if stats.failed > 0 { + suggestions.push(format!("Address {} failed task(s)", stats.failed)); + } + } + + // Generate summary + let summary = generate_phase_summary(phase, &file_deliverables, has_repository, &task_stats, completion_percentage); + + PhaseChecklist { + phase: phase.to_string(), + file_deliverables, + has_repository, + repository_required: deliverables.requires_repository, + task_stats, + completion_percentage, + summary, + suggestions, + } +} + +fn generate_phase_summary( + phase: &str, + deliverables: &[DeliverableStatus], + has_repository: bool, + task_stats: &Option<TaskStats>, + completion_percentage: u8, +) -> String { + let completed_count = deliverables.iter().filter(|d| d.completed).count(); + let total_count = deliverables.len(); + + match phase { + "research" => { + if completed_count == 0 { + "Research phase needs documentation. Create research notes or competitor analysis.".to_string() + } else { + format!("{}/{} research documents created. Consider transitioning to Specify phase.", completed_count, total_count) + } + } + "specify" => { + let has_required = deliverables.iter() + .filter(|d| d.priority == FilePriority::Required) + .all(|d| d.completed); + + if !has_required { + "Specify phase requires a Requirements Document before transitioning.".to_string() + } else if completion_percentage >= 66 { + "Specifications are ready. Consider transitioning to Plan phase.".to_string() + } else { + format!("{}/{} specification documents created.", completed_count, total_count) + } + } + "plan" => { + let has_task_breakdown = deliverables.iter() + .any(|d| d.template_id == "task-breakdown" && d.completed); + + if !has_task_breakdown { + "Plan phase requires a Task Breakdown document.".to_string() + } else if !has_repository { + "Repository not configured. Configure a repository before Execute phase.".to_string() + } else { + "Planning complete. Ready to transition to Execute phase.".to_string() + } + } + "execute" => { + if let Some(stats) = task_stats { + if stats.total == 0 { + "No tasks created. Create tasks from the Task Breakdown document.".to_string() + } else if stats.done == stats.total { + "All tasks complete! Ready for Review phase.".to_string() + } else { + format!("{}/{} tasks completed ({}% done)", stats.done, stats.total, + if stats.total > 0 { (stats.done * 100) / stats.total } else { 0 }) + } + } else { + "Execute phase in progress.".to_string() + } + } + "review" => { + let has_release_notes = deliverables.iter() + .any(|d| d.template_id == "release-notes" && d.completed); + + if !has_release_notes { + "Review phase requires Release Notes before completion.".to_string() + } else { + "Review documentation complete. Contract can be marked as done.".to_string() + } + } + _ => format!("Phase {} - {}% complete", phase, completion_percentage), + } +} + +/// Check if phase targets are met for transition +pub fn check_phase_completion( + phase: &str, + files: &[FileInfo], + tasks: &[TaskInfo], + has_repository: bool, +) -> bool { + let checklist = get_phase_checklist(phase, files, tasks, has_repository); + + // Check required files are complete + let required_files_complete = checklist.file_deliverables.iter() + .filter(|d| d.priority == FilePriority::Required) + .all(|d| d.completed); + + // Check repository if required + let repository_ok = !checklist.repository_required || checklist.has_repository; + + // Check tasks if in execute phase + let tasks_ok = if let Some(stats) = &checklist.task_stats { + stats.total > 0 && stats.done == stats.total + } else { + true + }; + + required_files_complete && repository_ok && tasks_ok +} + +/// Format checklist as markdown for LLM context +pub fn format_checklist_markdown(checklist: &PhaseChecklist) -> String { + let mut md = format!("## Phase Progress ({} Phase)\n\n", capitalize(&checklist.phase)); + + // File deliverables + md.push_str("### Deliverables\n"); + for status in &checklist.file_deliverables { + let check = if status.completed { "+" } else { "-" }; + let priority_label = match status.priority { + FilePriority::Required => " (required)", + FilePriority::Recommended => " (recommended)", + FilePriority::Optional => " (optional)", + }; + + if status.completed { + md.push_str(&format!("[{}] {} - \"{}\"\n", check, status.name, status.actual_name.as_deref().unwrap_or("created"))); + } else { + md.push_str(&format!("[{}] {}{}\n", check, status.name, priority_label)); + } + } + + // Repository status + if checklist.repository_required { + let check = if checklist.has_repository { "+" } else { "-" }; + md.push_str(&format!("[{}] Repository configured (required)\n", check)); + } + + // Task stats for execute phase + if let Some(ref stats) = checklist.task_stats { + md.push_str(&format!("\n### Task Progress\n")); + md.push_str(&format!("- Total: {}\n", stats.total)); + md.push_str(&format!("- Done: {}\n", stats.done)); + if stats.pending > 0 { + md.push_str(&format!("- Pending: {}\n", stats.pending)); + } + if stats.running > 0 { + md.push_str(&format!("- Running: {}\n", stats.running)); + } + if stats.failed > 0 { + md.push_str(&format!("- Failed: {}\n", stats.failed)); + } + } + + // Summary + md.push_str(&format!("\n**Status**: {} ({}% complete)\n", checklist.summary, checklist.completion_percentage)); + + // Suggestions + if !checklist.suggestions.is_empty() { + md.push_str("\n**Next Steps**:\n"); + for suggestion in &checklist.suggestions { + md.push_str(&format!("- {}\n", suggestion)); + } + } + + md +} + +fn capitalize(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_phase_deliverables() { + let research = get_phase_deliverables("research"); + assert_eq!(research.phase, "research"); + assert!(!research.requires_repository); + assert_eq!(research.recommended_files.len(), 3); + + let plan = get_phase_deliverables("plan"); + assert!(plan.requires_repository); + assert!(plan.recommended_files.iter().any(|f| f.template_id == "task-breakdown")); + } + + #[test] + fn test_phase_checklist_empty() { + let checklist = get_phase_checklist("research", &[], &[], false); + assert_eq!(checklist.completion_percentage, 0); + assert!(!checklist.suggestions.is_empty()); + } + + #[test] + fn test_check_phase_completion() { + let files = vec![ + FileInfo { + id: Uuid::new_v4(), + name: "Requirements Document".to_string(), + contract_phase: Some("specify".to_string()), + }, + ]; + + // Specify phase has required file + let complete = check_phase_completion("specify", &files, &[], false); + assert!(complete); + } +} diff --git a/makima/src/llm/task_output.rs b/makima/src/llm/task_output.rs new file mode 100644 index 0000000..c71c05a --- /dev/null +++ b/makima/src/llm/task_output.rs @@ -0,0 +1,461 @@ +//! Task output processing and task derivation utilities. +//! +//! This module provides utilities for: +//! - Parsing task lists from markdown documents +//! - Analyzing completed task outputs +//! - Suggesting follow-up actions based on task results + +use regex::Regex; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// A parsed task from a markdown document +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ParsedTask { + /// Task name/title + pub name: String, + /// Task description or plan + pub description: Option<String>, + /// Group/phase this task belongs to + pub group: Option<String>, + /// Order within the group (0-indexed) + pub order: usize, + /// Whether this task was marked as completed in source + pub completed: bool, + /// Dependencies (names of other tasks) + pub dependencies: Vec<String>, +} + +/// Result of parsing tasks from a document +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskParseResult { + /// Successfully parsed tasks + pub tasks: Vec<ParsedTask>, + /// Groups/phases found + pub groups: Vec<String>, + /// Total tasks found + pub total: usize, + /// Any parsing warnings + pub warnings: Vec<String>, +} + +/// Impact on contract phase +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PhaseImpact { + /// Current phase + pub phase: String, + /// Whether phase targets are now met + pub targets_met: bool, + /// Tasks remaining in phase + pub tasks_remaining: usize, + /// Suggestion for phase transition + pub transition_suggestion: Option<String>, +} + +/// Suggested action based on task output +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum SuggestedAction { + /// Create a follow-up task + CreateTask { + name: String, + plan: String, + chain_from: Option<Uuid>, + }, + /// Create a new file from template + CreateFile { + template_id: String, + name: String, + seed_content: Option<String>, + }, + /// Update an existing file + UpdateFile { + file_id: Uuid, + file_name: String, + additions: String, + }, + /// Advance to next phase + AdvancePhase { + to_phase: String, + }, + /// Run the next chained task + RunNextTask { + task_id: Uuid, + task_name: String, + }, +} + +/// Analysis of a completed task's output +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskOutputAnalysis { + /// Summary of what was accomplished + pub summary: String, + /// Files that were created/modified (from diff) + pub files_affected: Vec<String>, + /// Suggested next actions + pub next_steps: Vec<SuggestedAction>, + /// Impact on contract phase + pub phase_impact: Option<PhaseImpact>, +} + +/// Parse tasks from a markdown task breakdown document +/// +/// Supports formats like: +/// - `[ ] Task name` +/// - `[x] Completed task` +/// - `1. Task name` +/// - `- Task name` +/// +/// Groups are detected from `## Phase/Section` headings. +pub fn parse_tasks_from_breakdown(content: &str) -> TaskParseResult { + let mut tasks = Vec::new(); + let mut groups = Vec::new(); + let mut warnings = Vec::new(); + let mut current_group: Option<String> = None; + let mut task_order = 0; + + // Patterns for task items + let checkbox_pattern = Regex::new(r"^\s*[-*]\s*\[([ xX])\]\s*(.+)$").unwrap(); + let numbered_checkbox = Regex::new(r"^\s*\d+\.\s*\[([ xX])\]\s*(.+)$").unwrap(); + let numbered_pattern = Regex::new(r"^\s*\d+\.\s+(.+)$").unwrap(); + let bullet_pattern = Regex::new(r"^\s*[-*]\s+(.+)$").unwrap(); + let heading_pattern = Regex::new(r"^##\s+(?:Phase\s*\d*:?\s*)?(.+)$").unwrap(); + + // Patterns for dependencies (inline) + let depends_pattern = Regex::new(r"(?i)(?:depends on|after|requires):?\s*(.+)").unwrap(); + + for line in content.lines() { + let trimmed = line.trim(); + + // Skip empty lines + if trimmed.is_empty() { + continue; + } + + // Check for section headings + if let Some(caps) = heading_pattern.captures(trimmed) { + let group_name = caps[1].trim().to_string(); + if !groups.contains(&group_name) { + groups.push(group_name.clone()); + } + current_group = Some(group_name); + task_order = 0; + continue; + } + + // Try to parse as a task + let mut task_name: Option<String> = None; + let mut completed = false; + + // Try checkbox patterns first (more specific) + if let Some(caps) = checkbox_pattern.captures(trimmed) { + completed = &caps[1] != " "; + task_name = Some(caps[2].trim().to_string()); + } else if let Some(caps) = numbered_checkbox.captures(trimmed) { + completed = &caps[1] != " "; + task_name = Some(caps[2].trim().to_string()); + } else if let Some(caps) = numbered_pattern.captures(trimmed) { + task_name = Some(caps[1].trim().to_string()); + } else if let Some(caps) = bullet_pattern.captures(trimmed) { + // Only treat as task if it looks like a task (has actionable verbs) + let text = caps[1].trim(); + if looks_like_task(text) { + task_name = Some(text.to_string()); + } + } + + if let Some(name) = task_name { + // Skip items that are clearly not tasks + if name.to_lowercase().starts_with("note:") || + name.to_lowercase().starts_with("todo:") && name.len() < 10 || + name.starts_with('#') { + continue; + } + + // Extract dependencies if present + let dependencies = if let Some(dep_caps) = depends_pattern.captures(&name) { + dep_caps[1] + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + } else { + Vec::new() + }; + + // Clean task name (remove dependency info) + let clean_name = depends_pattern.replace(&name, "").trim().to_string(); + + // Extract description if there's a colon + let (final_name, description) = if let Some(idx) = clean_name.find(':') { + let (n, d) = clean_name.split_at(idx); + (n.trim().to_string(), Some(d[1..].trim().to_string())) + } else { + (clean_name, None) + }; + + tasks.push(ParsedTask { + name: final_name, + description, + group: current_group.clone(), + order: task_order, + completed, + dependencies, + }); + + task_order += 1; + } + } + + let total = tasks.len(); + + // Add warnings + if tasks.is_empty() { + warnings.push("No tasks found in document. Ensure tasks are formatted as checkbox items (- [ ] Task) or numbered lists (1. Task).".to_string()); + } + + TaskParseResult { + tasks, + groups, + total, + warnings, + } +} + +/// Check if text looks like a task (has action verbs) +fn looks_like_task(text: &str) -> bool { + let lower = text.to_lowercase(); + let action_verbs = [ + "add", "create", "implement", "build", "write", "fix", "update", + "refactor", "test", "configure", "set up", "setup", "deploy", + "integrate", "migrate", "design", "review", "document", "remove", + "delete", "modify", "change", "improve", "optimize", "enable", + "disable", "install", "initialize", "define", "extend", "extract", + ]; + + action_verbs.iter().any(|verb| lower.starts_with(verb) || lower.contains(&format!(" {}", verb))) +} + +/// Analyze a completed task's output to suggest next actions +pub fn analyze_task_output( + _task_id: Uuid, + task_name: &str, + task_result: Option<&str>, + task_diff: Option<&str>, + contract_phase: &str, + total_tasks: usize, + completed_tasks: usize, + next_task: Option<(Uuid, String)>, + dev_notes_file: Option<(Uuid, String)>, +) -> TaskOutputAnalysis { + let mut next_steps = Vec::new(); + let mut files_affected = Vec::new(); + + // Parse files from diff if available + if let Some(diff) = task_diff { + files_affected = extract_files_from_diff(diff); + } + + // Generate summary + let summary = if let Some(result) = task_result { + if result.len() > 200 { + format!("{}...", &result[..200]) + } else { + result.to_string() + } + } else { + format!("Task '{}' completed", task_name) + }; + + // If there's a next chained task, suggest running it + if let Some((next_id, next_name)) = next_task { + next_steps.push(SuggestedAction::RunNextTask { + task_id: next_id, + task_name: next_name, + }); + } + + // Suggest updating Dev Notes if in execute phase and file exists + if contract_phase == "execute" { + if let Some((file_id, file_name)) = dev_notes_file { + let additions = format!( + "\n## Task: {}\n\n{}\n\n### Files Modified\n{}\n", + task_name, + summary, + files_affected.iter() + .map(|f| format!("- {}", f)) + .collect::<Vec<_>>() + .join("\n") + ); + + next_steps.push(SuggestedAction::UpdateFile { + file_id, + file_name, + additions, + }); + } else { + // Suggest creating Dev Notes + next_steps.push(SuggestedAction::CreateFile { + template_id: "dev-notes".to_string(), + name: "Development Notes".to_string(), + seed_content: Some(format!( + "# Development Notes\n\n## Task: {}\n\n{}\n", + task_name, summary + )), + }); + } + } + + // Calculate phase impact + let new_completed = completed_tasks + 1; + let targets_met = new_completed >= total_tasks && total_tasks > 0; + let tasks_remaining = total_tasks.saturating_sub(new_completed); + + let transition_suggestion = if targets_met && contract_phase == "execute" { + Some("All tasks complete. Ready to advance to Review phase.".to_string()) + } else { + None + }; + + // If targets are met, suggest phase transition + if targets_met && contract_phase == "execute" { + next_steps.push(SuggestedAction::AdvancePhase { + to_phase: "review".to_string(), + }); + } + + let phase_impact = Some(PhaseImpact { + phase: contract_phase.to_string(), + targets_met, + tasks_remaining, + transition_suggestion, + }); + + TaskOutputAnalysis { + summary, + files_affected, + next_steps, + phase_impact, + } +} + +/// Extract file paths from a git diff +fn extract_files_from_diff(diff: &str) -> Vec<String> { + let mut files = Vec::new(); + let file_pattern = Regex::new(r"^(?:diff --git a/|[+]{3} b/|[-]{3} a/)(.+)$").unwrap(); + + for line in diff.lines() { + if let Some(caps) = file_pattern.captures(line) { + let path = caps[1].trim().to_string(); + // Skip /dev/null and duplicates + if path != "/dev/null" && !files.contains(&path) { + // Clean up path (remove a/ or b/ prefix from git diff) + let clean_path = path.trim_start_matches("a/").trim_start_matches("b/").to_string(); + if !files.contains(&clean_path) { + files.push(clean_path); + } + } + } + } + + files +} + +/// Format parsed tasks for display +pub fn format_parsed_tasks(result: &TaskParseResult) -> String { + let mut output = String::new(); + + if result.tasks.is_empty() { + output.push_str("No tasks found in the document.\n"); + for warning in &result.warnings { + output.push_str(&format!("Warning: {}\n", warning)); + } + return output; + } + + output.push_str(&format!("Found {} task(s)", result.total)); + if !result.groups.is_empty() { + output.push_str(&format!(" in {} group(s)", result.groups.len())); + } + output.push_str(":\n\n"); + + let mut current_group: Option<&str> = None; + for (i, task) in result.tasks.iter().enumerate() { + // Print group header if changed + if task.group.as_deref() != current_group { + current_group = task.group.as_deref(); + if let Some(group) = current_group { + output.push_str(&format!("**{}**\n", group)); + } + } + + let status = if task.completed { "[x]" } else { "[ ]" }; + output.push_str(&format!("{}. {} {}", i + 1, status, task.name)); + + if !task.dependencies.is_empty() { + output.push_str(&format!(" (depends on: {})", task.dependencies.join(", "))); + } + + output.push('\n'); + } + + output +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_checkbox_tasks() { + let content = r#" +## Phase 1: Setup +- [ ] Set up project structure +- [x] Configure dev environment + +## Phase 2: Features +1. [ ] Implement authentication +2. [ ] Add user dashboard +"#; + + let result = parse_tasks_from_breakdown(content); + assert_eq!(result.total, 4); + assert_eq!(result.groups.len(), 2); + assert!(!result.tasks[0].completed); + assert!(result.tasks[1].completed); + } + + #[test] + fn test_parse_with_dependencies() { + let content = r#" +- [ ] Task A +- [ ] Task B (depends on: Task A) +"#; + + let result = parse_tasks_from_breakdown(content); + assert_eq!(result.tasks[1].dependencies, vec!["Task A"]); + } + + #[test] + fn test_extract_files_from_diff() { + let diff = r#" +diff --git a/src/main.rs b/src/main.rs +--- a/src/main.rs ++++ b/src/main.rs +@@ -1,3 +1,4 @@ ++fn new_function() {} +"#; + + let files = extract_files_from_diff(diff); + assert!(files.contains(&"src/main.rs".to_string())); + } + + #[test] + fn test_looks_like_task() { + assert!(looks_like_task("Add authentication")); + assert!(looks_like_task("Create user model")); + assert!(looks_like_task("implement feature X")); + assert!(!looks_like_task("This is a note")); + assert!(!looks_like_task("Summary of changes")); + } +} diff --git a/makima/src/llm/templates.rs b/makima/src/llm/templates.rs new file mode 100644 index 0000000..18ef46d --- /dev/null +++ b/makima/src/llm/templates.rs @@ -0,0 +1,1011 @@ +//! Template definitions for phase-appropriate file structures. +//! +//! Templates provide starting structures for files based on the contract phase. +//! Each phase has templates suited for that stage of work. + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::db::models::BodyElement; + +/// A file template with suggested structure +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct FileTemplate { + /// Template identifier + pub id: String, + /// Display name + pub name: String, + /// Contract phase this template is designed for + pub phase: String, + /// Brief description of what this template is for + pub description: String, + /// Suggested body elements (structure only - content to be filled by LLM) + pub suggested_body: Vec<BodyElement>, +} + +/// Get templates appropriate for a given contract phase +pub fn templates_for_phase(phase: &str) -> Vec<FileTemplate> { + match phase { + "research" => vec![ + research_notes_template(), + competitor_analysis_template(), + user_research_template(), + ], + "specify" => vec![ + requirements_template(), + user_stories_template(), + acceptance_criteria_template(), + ], + "plan" => vec![ + architecture_template(), + technical_design_template(), + task_breakdown_template(), + ], + "execute" => vec![ + dev_notes_template(), + test_plan_template(), + implementation_log_template(), + ], + "review" => vec![ + review_checklist_template(), + release_notes_template(), + retrospective_template(), + ], + _ => vec![], + } +} + +/// Get all available templates across all phases +pub fn all_templates() -> Vec<FileTemplate> { + vec![ + // Research phase + research_notes_template(), + competitor_analysis_template(), + user_research_template(), + // Specify phase + requirements_template(), + user_stories_template(), + acceptance_criteria_template(), + // Plan phase + architecture_template(), + technical_design_template(), + task_breakdown_template(), + // Execute phase + dev_notes_template(), + test_plan_template(), + implementation_log_template(), + // Review phase + review_checklist_template(), + release_notes_template(), + retrospective_template(), + ] +} + +// ============================================================================= +// Research Phase Templates +// ============================================================================= + +fn research_notes_template() -> FileTemplate { + FileTemplate { + id: "research-notes".to_string(), + name: "Research Notes".to_string(), + phase: "research".to_string(), + description: "Document findings, insights, and questions during research".to_string(), + suggested_body: vec![ + BodyElement::Heading { + level: 1, + text: "Research Notes".to_string(), + }, + BodyElement::Heading { + level: 2, + text: "Context".to_string(), + }, + BodyElement::Paragraph { + text: "Describe the research objective and scope...".to_string(), + }, + BodyElement::Heading { + level: 2, + text: "Key Findings".to_string(), + }, + BodyElement::List { + ordered: false, + items: vec![ + "Finding 1...".to_string(), + "Finding 2...".to_string(), + "Finding 3...".to_string(), + ], + }, + BodyElement::Heading { + level: 2, + text: "Open Questions".to_string(), + }, + BodyElement::List { + ordered: true, + items: vec![ + "Question to investigate...".to_string(), + "Area needing more research...".to_string(), + ], + }, + BodyElement::Heading { + level: 2, + text: "Next Steps".to_string(), + }, + BodyElement::Paragraph { + text: "Outline follow-up actions...".to_string(), + }, + ], + } +} + +fn competitor_analysis_template() -> FileTemplate { + FileTemplate { + id: "competitor-analysis".to_string(), + name: "Competitor Analysis".to_string(), + phase: "research".to_string(), + description: "Analyze competitors and market positioning".to_string(), + suggested_body: vec![ + BodyElement::Heading { + level: 1, + text: "Competitor Analysis".to_string(), + }, + BodyElement::Heading { + level: 2, + text: "Market Overview".to_string(), + }, + BodyElement::Paragraph { + text: "Describe the market landscape...".to_string(), + }, + BodyElement::Heading { + level: 2, + text: "Competitor 1: [Name]".to_string(), + }, + BodyElement::List { + ordered: false, + items: vec![ + "Strengths: ...".to_string(), + "Weaknesses: ...".to_string(), + "Key Features: ...".to_string(), + "Pricing: ...".to_string(), + ], + }, + BodyElement::Heading { + level: 2, + text: "Competitive Advantages".to_string(), + }, + BodyElement::Paragraph { + text: "Our differentiation strategy...".to_string(), + }, + BodyElement::Heading { + level: 2, + text: "Gaps & Opportunities".to_string(), + }, + BodyElement::List { + ordered: false, + items: vec!["Opportunity 1...".to_string(), "Opportunity 2...".to_string()], + }, + ], + } +} + +fn user_research_template() -> FileTemplate { + FileTemplate { + id: "user-research".to_string(), + name: "User Research".to_string(), + phase: "research".to_string(), + description: "Document user interviews, surveys, and persona insights".to_string(), + suggested_body: vec![ + BodyElement::Heading { + level: 1, + text: "User Research".to_string(), + }, + BodyElement::Heading { + level: 2, + text: "Research Method".to_string(), + }, + BodyElement::Paragraph { + text: "Describe the research methodology used...".to_string(), + }, + BodyElement::Heading { + level: 2, + text: "User Personas".to_string(), + }, + BodyElement::Heading { + level: 3, + text: "Persona 1: [Name]".to_string(), + }, + BodyElement::List { + ordered: false, + items: vec![ + "Role: ...".to_string(), + "Goals: ...".to_string(), + "Pain Points: ...".to_string(), + "Behaviors: ...".to_string(), + ], + }, + BodyElement::Heading { + level: 2, + text: "Key Insights".to_string(), + }, + BodyElement::List { + ordered: true, + items: vec!["Insight from research...".to_string()], + }, + BodyElement::Heading { + level: 2, + text: "Recommendations".to_string(), + }, + BodyElement::Paragraph { + text: "Based on research findings...".to_string(), + }, + ], + } +} + +// ============================================================================= +// Specify Phase Templates +// ============================================================================= + +fn requirements_template() -> FileTemplate { + FileTemplate { + id: "requirements".to_string(), + name: "Requirements Document".to_string(), + phase: "specify".to_string(), + description: "Define functional and non-functional requirements".to_string(), + suggested_body: vec![ + BodyElement::Heading { + level: 1, + text: "Requirements Document".to_string(), + }, + BodyElement::Heading { + level: 2, + text: "Overview".to_string(), + }, + BodyElement::Paragraph { + text: "Brief description of the feature/project...".to_string(), + }, + BodyElement::Heading { + level: 2, + text: "Functional Requirements".to_string(), + }, + BodyElement::List { + ordered: true, + items: vec![ + "FR-001: The system shall...".to_string(), + "FR-002: Users must be able to...".to_string(), + "FR-003: ...".to_string(), + ], + }, + BodyElement::Heading { + level: 2, + text: "Non-Functional Requirements".to_string(), + }, + BodyElement::List { + ordered: true, + items: vec![ + "NFR-001: Performance - ...".to_string(), + "NFR-002: Security - ...".to_string(), + "NFR-003: Scalability - ...".to_string(), + ], + }, + BodyElement::Heading { + level: 2, + text: "Constraints".to_string(), + }, + BodyElement::List { + ordered: false, + items: vec![ + "Technical constraints...".to_string(), + "Business constraints...".to_string(), + ], + }, + BodyElement::Heading { + level: 2, + text: "Dependencies".to_string(), + }, + BodyElement::Paragraph { + text: "External dependencies and integrations...".to_string(), + }, + ], + } +} + +fn user_stories_template() -> FileTemplate { + FileTemplate { + id: "user-stories".to_string(), + name: "User Stories".to_string(), + phase: "specify".to_string(), + description: "Define features from the user's perspective".to_string(), + suggested_body: vec![ + BodyElement::Heading { + level: 1, + text: "User Stories".to_string(), + }, + BodyElement::Heading { + level: 2, + text: "Epic: [Feature Name]".to_string(), + }, + BodyElement::Paragraph { + text: "High-level description of the epic...".to_string(), + }, + BodyElement::Heading { + level: 3, + text: "US-001: [Story Title]".to_string(), + }, + BodyElement::Paragraph { + text: "As a [user type], I want to [action], so that [benefit].".to_string(), + }, + BodyElement::Heading { + level: 4, + text: "Acceptance Criteria".to_string(), + }, + BodyElement::List { + ordered: false, + items: vec![ + "Given... When... Then...".to_string(), + "Given... When... Then...".to_string(), + ], + }, + BodyElement::Heading { + level: 3, + text: "US-002: [Story Title]".to_string(), + }, + BodyElement::Paragraph { + text: "As a [user type], I want to [action], so that [benefit].".to_string(), + }, + ], + } +} + +fn acceptance_criteria_template() -> FileTemplate { + FileTemplate { + id: "acceptance-criteria".to_string(), + name: "Acceptance Criteria".to_string(), + phase: "specify".to_string(), + description: "Define testable conditions for feature completion".to_string(), + suggested_body: vec![ + BodyElement::Heading { + level: 1, + text: "Acceptance Criteria".to_string(), + }, + BodyElement::Heading { + level: 2, + text: "Feature: [Name]".to_string(), + }, + BodyElement::Paragraph { + text: "Description of the feature being specified...".to_string(), + }, + BodyElement::Heading { + level: 2, + text: "Scenarios".to_string(), + }, + BodyElement::Heading { + level: 3, + text: "Scenario 1: [Happy Path]".to_string(), + }, + BodyElement::Code { + language: Some("gherkin".to_string()), + content: "Given [precondition]\nWhen [action]\nThen [expected result]".to_string(), + }, + BodyElement::Heading { + level: 3, + text: "Scenario 2: [Edge Case]".to_string(), + }, + BodyElement::Code { + language: Some("gherkin".to_string()), + content: "Given [precondition]\nWhen [action]\nThen [expected result]".to_string(), + }, + BodyElement::Heading { + level: 2, + text: "Out of Scope".to_string(), + }, + BodyElement::List { + ordered: false, + items: vec!["Items explicitly not included...".to_string()], + }, + ], + } +} + +// ============================================================================= +// Plan Phase Templates +// ============================================================================= + +fn architecture_template() -> FileTemplate { + FileTemplate { + id: "architecture".to_string(), + name: "Architecture Document".to_string(), + phase: "plan".to_string(), + description: "Document system architecture and design decisions".to_string(), + suggested_body: vec![ + BodyElement::Heading { + level: 1, + text: "Architecture Document".to_string(), + }, + BodyElement::Heading { + level: 2, + text: "Overview".to_string(), + }, + BodyElement::Paragraph { + text: "High-level architecture description...".to_string(), + }, + BodyElement::Heading { + level: 2, + text: "System Components".to_string(), + }, + BodyElement::List { + ordered: false, + items: vec![ + "Component A: Description and responsibility".to_string(), + "Component B: Description and responsibility".to_string(), + "Component C: Description and responsibility".to_string(), + ], + }, + BodyElement::Heading { + level: 2, + text: "Data Flow".to_string(), + }, + BodyElement::Paragraph { + text: "Describe how data flows through the system...".to_string(), + }, + BodyElement::Heading { + level: 2, + text: "Technology Stack".to_string(), + }, + BodyElement::List { + ordered: false, + items: vec![ + "Frontend: ...".to_string(), + "Backend: ...".to_string(), + "Database: ...".to_string(), + "Infrastructure: ...".to_string(), + ], + }, + BodyElement::Heading { + level: 2, + text: "Design Decisions".to_string(), + }, + BodyElement::Heading { + level: 3, + text: "ADR-001: [Decision Title]".to_string(), + }, + BodyElement::List { + ordered: false, + items: vec![ + "Context: ...".to_string(), + "Decision: ...".to_string(), + "Consequences: ...".to_string(), + ], + }, + ], + } +} + +fn technical_design_template() -> FileTemplate { + FileTemplate { + id: "technical-design".to_string(), + name: "Technical Design".to_string(), + phase: "plan".to_string(), + description: "Detailed technical specification for implementation".to_string(), + suggested_body: vec![ + BodyElement::Heading { + level: 1, + text: "Technical Design".to_string(), + }, + BodyElement::Heading { + level: 2, + text: "Purpose".to_string(), + }, + BodyElement::Paragraph { + text: "What this design document covers...".to_string(), + }, + BodyElement::Heading { + level: 2, + text: "API Design".to_string(), + }, + BodyElement::Code { + language: Some("typescript".to_string()), + content: "// Interface definitions\ninterface Example {\n // ...\n}".to_string(), + }, + BodyElement::Heading { + level: 2, + text: "Database Schema".to_string(), + }, + BodyElement::Code { + language: Some("sql".to_string()), + content: "-- Table definitions\nCREATE TABLE example (\n -- ...\n);".to_string(), + }, + BodyElement::Heading { + level: 2, + text: "Implementation Notes".to_string(), + }, + BodyElement::List { + ordered: false, + items: vec![ + "Key implementation consideration...".to_string(), + "Performance consideration...".to_string(), + ], + }, + BodyElement::Heading { + level: 2, + text: "Migration Strategy".to_string(), + }, + BodyElement::Paragraph { + text: "How to migrate from current state...".to_string(), + }, + ], + } +} + +fn task_breakdown_template() -> FileTemplate { + FileTemplate { + id: "task-breakdown".to_string(), + name: "Task Breakdown".to_string(), + phase: "plan".to_string(), + description: "Break down work into implementable tasks".to_string(), + suggested_body: vec![ + BodyElement::Heading { + level: 1, + text: "Task Breakdown".to_string(), + }, + BodyElement::Heading { + level: 2, + text: "Overview".to_string(), + }, + BodyElement::Paragraph { + text: "Summary of the work to be done...".to_string(), + }, + BodyElement::Heading { + level: 2, + text: "Phase 1: Foundation".to_string(), + }, + BodyElement::List { + ordered: true, + items: vec![ + "[ ] Task 1: Set up project structure".to_string(), + "[ ] Task 2: Configure development environment".to_string(), + "[ ] Task 3: Create base components".to_string(), + ], + }, + BodyElement::Heading { + level: 2, + text: "Phase 2: Core Features".to_string(), + }, + BodyElement::List { + ordered: true, + items: vec![ + "[ ] Task 4: Implement feature A".to_string(), + "[ ] Task 5: Implement feature B".to_string(), + "[ ] Task 6: Add tests".to_string(), + ], + }, + BodyElement::Heading { + level: 2, + text: "Phase 3: Polish & Deploy".to_string(), + }, + BodyElement::List { + ordered: true, + items: vec![ + "[ ] Task 7: Error handling".to_string(), + "[ ] Task 8: Documentation".to_string(), + "[ ] Task 9: Deployment".to_string(), + ], + }, + BodyElement::Heading { + level: 2, + text: "Dependencies".to_string(), + }, + BodyElement::Paragraph { + text: "Task dependencies and blockers...".to_string(), + }, + ], + } +} + +// ============================================================================= +// Execute Phase Templates +// ============================================================================= + +fn dev_notes_template() -> FileTemplate { + FileTemplate { + id: "dev-notes".to_string(), + name: "Development Notes".to_string(), + phase: "execute".to_string(), + description: "Track implementation details, decisions, and learnings".to_string(), + suggested_body: vec![ + BodyElement::Heading { + level: 1, + text: "Development Notes".to_string(), + }, + BodyElement::Heading { + level: 2, + text: "Current Status".to_string(), + }, + BodyElement::Paragraph { + text: "Brief summary of implementation progress...".to_string(), + }, + BodyElement::Heading { + level: 2, + text: "Implementation Details".to_string(), + }, + BodyElement::Heading { + level: 3, + text: "[Component/Feature Name]".to_string(), + }, + BodyElement::Paragraph { + text: "How this was implemented and why...".to_string(), + }, + BodyElement::Code { + language: Some("typescript".to_string()), + content: "// Key code snippet or example".to_string(), + }, + BodyElement::Heading { + level: 2, + text: "Challenges & Solutions".to_string(), + }, + BodyElement::List { + ordered: false, + items: vec![ + "Challenge: ... | Solution: ...".to_string(), + "Challenge: ... | Solution: ...".to_string(), + ], + }, + BodyElement::Heading { + level: 2, + text: "TODOs".to_string(), + }, + BodyElement::List { + ordered: false, + items: vec![ + "[ ] Remaining item...".to_string(), + "[ ] Follow-up task...".to_string(), + ], + }, + ], + } +} + +fn test_plan_template() -> FileTemplate { + FileTemplate { + id: "test-plan".to_string(), + name: "Test Plan".to_string(), + phase: "execute".to_string(), + description: "Document testing strategy and test cases".to_string(), + suggested_body: vec![ + BodyElement::Heading { + level: 1, + text: "Test Plan".to_string(), + }, + BodyElement::Heading { + level: 2, + text: "Test Scope".to_string(), + }, + BodyElement::Paragraph { + text: "What is being tested and the testing approach...".to_string(), + }, + BodyElement::Heading { + level: 2, + text: "Test Types".to_string(), + }, + BodyElement::List { + ordered: false, + items: vec![ + "Unit Tests: Component-level testing".to_string(), + "Integration Tests: API and service integration".to_string(), + "E2E Tests: User flow testing".to_string(), + ], + }, + BodyElement::Heading { + level: 2, + text: "Test Cases".to_string(), + }, + BodyElement::Heading { + level: 3, + text: "TC-001: [Test Name]".to_string(), + }, + BodyElement::List { + ordered: false, + items: vec![ + "Preconditions: ...".to_string(), + "Steps: ...".to_string(), + "Expected Result: ...".to_string(), + ], + }, + BodyElement::Heading { + level: 2, + text: "Test Data".to_string(), + }, + BodyElement::Paragraph { + text: "Required test data and fixtures...".to_string(), + }, + BodyElement::Heading { + level: 2, + text: "Test Results".to_string(), + }, + BodyElement::List { + ordered: false, + items: vec![ + "[ ] TC-001: Pending".to_string(), + "[ ] TC-002: Pending".to_string(), + ], + }, + ], + } +} + +fn implementation_log_template() -> FileTemplate { + FileTemplate { + id: "implementation-log".to_string(), + name: "Implementation Log".to_string(), + phase: "execute".to_string(), + description: "Chronological log of implementation progress".to_string(), + suggested_body: vec![ + BodyElement::Heading { + level: 1, + text: "Implementation Log".to_string(), + }, + BodyElement::Paragraph { + text: "Tracking daily progress and decisions...".to_string(), + }, + BodyElement::Heading { + level: 2, + text: "[Date]".to_string(), + }, + BodyElement::Heading { + level: 3, + text: "Completed".to_string(), + }, + BodyElement::List { + ordered: false, + items: vec!["What was accomplished...".to_string()], + }, + BodyElement::Heading { + level: 3, + text: "In Progress".to_string(), + }, + BodyElement::List { + ordered: false, + items: vec!["Current work...".to_string()], + }, + BodyElement::Heading { + level: 3, + text: "Blockers".to_string(), + }, + BodyElement::List { + ordered: false, + items: vec!["Any blockers or issues...".to_string()], + }, + BodyElement::Heading { + level: 3, + text: "Notes".to_string(), + }, + BodyElement::Paragraph { + text: "Additional context or decisions made...".to_string(), + }, + ], + } +} + +// ============================================================================= +// Review Phase Templates +// ============================================================================= + +fn review_checklist_template() -> FileTemplate { + FileTemplate { + id: "review-checklist".to_string(), + name: "Review Checklist".to_string(), + phase: "review".to_string(), + description: "Comprehensive checklist for code and feature review".to_string(), + suggested_body: vec![ + BodyElement::Heading { + level: 1, + text: "Review Checklist".to_string(), + }, + BodyElement::Heading { + level: 2, + text: "Code Quality".to_string(), + }, + BodyElement::List { + ordered: false, + items: vec![ + "[ ] Code follows style guidelines".to_string(), + "[ ] No unnecessary complexity".to_string(), + "[ ] Functions are well-named and focused".to_string(), + "[ ] No dead code or commented-out code".to_string(), + ], + }, + BodyElement::Heading { + level: 2, + text: "Testing".to_string(), + }, + BodyElement::List { + ordered: false, + items: vec![ + "[ ] Unit tests pass".to_string(), + "[ ] Integration tests pass".to_string(), + "[ ] Edge cases covered".to_string(), + "[ ] Test coverage acceptable".to_string(), + ], + }, + BodyElement::Heading { + level: 2, + text: "Security".to_string(), + }, + BodyElement::List { + ordered: false, + items: vec![ + "[ ] No hardcoded credentials".to_string(), + "[ ] Input validation in place".to_string(), + "[ ] Authentication/authorization correct".to_string(), + ], + }, + BodyElement::Heading { + level: 2, + text: "Documentation".to_string(), + }, + BodyElement::List { + ordered: false, + items: vec![ + "[ ] README updated".to_string(), + "[ ] API documentation complete".to_string(), + "[ ] Inline comments where needed".to_string(), + ], + }, + BodyElement::Heading { + level: 2, + text: "Review Notes".to_string(), + }, + BodyElement::Paragraph { + text: "Additional review comments and feedback...".to_string(), + }, + ], + } +} + +fn release_notes_template() -> FileTemplate { + FileTemplate { + id: "release-notes".to_string(), + name: "Release Notes".to_string(), + phase: "review".to_string(), + description: "Document changes for release communication".to_string(), + suggested_body: vec![ + BodyElement::Heading { + level: 1, + text: "Release Notes - v[X.Y.Z]".to_string(), + }, + BodyElement::Paragraph { + text: "Release date: [DATE]".to_string(), + }, + BodyElement::Heading { + level: 2, + text: "Highlights".to_string(), + }, + BodyElement::Paragraph { + text: "Key features and improvements in this release...".to_string(), + }, + BodyElement::Heading { + level: 2, + text: "New Features".to_string(), + }, + BodyElement::List { + ordered: false, + items: vec![ + "Feature 1: Description".to_string(), + "Feature 2: Description".to_string(), + ], + }, + BodyElement::Heading { + level: 2, + text: "Improvements".to_string(), + }, + BodyElement::List { + ordered: false, + items: vec![ + "Improvement 1: Description".to_string(), + "Improvement 2: Description".to_string(), + ], + }, + BodyElement::Heading { + level: 2, + text: "Bug Fixes".to_string(), + }, + BodyElement::List { + ordered: false, + items: vec![ + "Fixed: Issue description".to_string(), + "Fixed: Issue description".to_string(), + ], + }, + BodyElement::Heading { + level: 2, + text: "Breaking Changes".to_string(), + }, + BodyElement::List { + ordered: false, + items: vec!["Breaking change description (if any)...".to_string()], + }, + BodyElement::Heading { + level: 2, + text: "Known Issues".to_string(), + }, + BodyElement::List { + ordered: false, + items: vec!["Known issue (if any)...".to_string()], + }, + ], + } +} + +fn retrospective_template() -> FileTemplate { + FileTemplate { + id: "retrospective".to_string(), + name: "Retrospective".to_string(), + phase: "review".to_string(), + description: "Reflect on the project and capture learnings".to_string(), + suggested_body: vec![ + BodyElement::Heading { + level: 1, + text: "Retrospective".to_string(), + }, + BodyElement::Paragraph { + text: "Project: [Name] | Date: [DATE]".to_string(), + }, + BodyElement::Heading { + level: 2, + text: "What Went Well".to_string(), + }, + BodyElement::List { + ordered: false, + items: vec![ + "Success 1...".to_string(), + "Success 2...".to_string(), + "Success 3...".to_string(), + ], + }, + BodyElement::Heading { + level: 2, + text: "What Could Be Improved".to_string(), + }, + BodyElement::List { + ordered: false, + items: vec![ + "Area for improvement 1...".to_string(), + "Area for improvement 2...".to_string(), + ], + }, + BodyElement::Heading { + level: 2, + text: "Lessons Learned".to_string(), + }, + BodyElement::List { + ordered: true, + items: vec![ + "Key lesson from this project...".to_string(), + "Technical insight gained...".to_string(), + ], + }, + BodyElement::Heading { + level: 2, + text: "Action Items".to_string(), + }, + BodyElement::List { + ordered: false, + items: vec![ + "[ ] Action to improve future projects...".to_string(), + "[ ] Process change to implement...".to_string(), + ], + }, + BodyElement::Heading { + level: 2, + text: "Metrics".to_string(), + }, + BodyElement::List { + ordered: false, + items: vec![ + "Timeline: Planned vs Actual".to_string(), + "Scope: Delivered vs Planned".to_string(), + "Quality: Bug count, test coverage".to_string(), + ], + }, + ], + } +} diff --git a/makima/src/llm/tools.rs b/makima/src/llm/tools.rs index 649633e..ae1dc5a 100644 --- a/makima/src/llm/tools.rs +++ b/makima/src/llm/tools.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use crate::db::models::{BodyElement, ChartType, TranscriptEntry}; +use crate::llm::templates; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Tool { @@ -411,6 +412,36 @@ pub static AVAILABLE_TOOLS: once_cell::sync::Lazy<Vec<Tool>> = "required": ["target_version"] }), }, + // Template tools + Tool { + name: "suggest_templates".to_string(), + description: "Get suggested file templates based on a contract phase. Returns templates with predefined structures appropriate for research, specify, plan, execute, or review phases. Use this to help users start documents with proper structure.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "phase": { + "type": "string", + "enum": ["research", "specify", "plan", "execute", "review"], + "description": "The contract phase to get templates for. If not provided, returns all templates." + } + }, + "required": [] + }), + }, + Tool { + name: "apply_template".to_string(), + description: "Apply a template to the current file, replacing the body with the template structure. The template provides a starting structure that should be customized for the user's needs.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "template_id": { + "type": "string", + "description": "The template ID to apply (e.g., 'research-notes', 'requirements', 'architecture')" + } + }, + "required": ["template_id"] + }), + }, ] }); @@ -500,6 +531,9 @@ pub fn execute_tool_call( "list_versions" => execute_list_versions(), "read_version" => execute_read_version(call), "restore_version" => execute_restore_version(call), + // Template tools + "suggest_templates" => execute_suggest_templates(call), + "apply_template" => execute_apply_template(call), _ => ToolExecutionResult { result: ToolResult { success: false, @@ -1350,6 +1384,11 @@ fn execute_view_body(current_body: &[BodyElement]) -> ToolExecutionResult { "alt": alt, "caption": caption }), + BodyElement::Markdown { content } => json!({ + "index": i, + "type": "markdown", + "content": content + }), } }) .collect(); @@ -1439,6 +1478,11 @@ fn execute_read_element(call: &ToolCall, current_body: &[BodyElement]) -> ToolEx "alt": alt, "caption": caption }), + BodyElement::Markdown { content } => json!({ + "index": index, + "type": "markdown", + "content": content + }), }; let type_str = match element { @@ -1448,6 +1492,7 @@ fn execute_read_element(call: &ToolCall, current_body: &[BodyElement]) -> ToolEx BodyElement::List { .. } => "list", BodyElement::Chart { .. } => "chart", BodyElement::Image { .. } => "image", + BodyElement::Markdown { .. } => "markdown", }; ToolExecutionResult { @@ -1603,6 +1648,131 @@ fn execute_restore_version(call: &ToolCall) -> ToolExecutionResult { } } +// ============================================================================= +// Template Tool Execution Functions +// ============================================================================= + +fn execute_suggest_templates(call: &ToolCall) -> ToolExecutionResult { + let phase = call.arguments.get("phase").and_then(|v| v.as_str()); + + let template_list = match phase { + Some(p) => templates::templates_for_phase(p), + None => templates::all_templates(), + }; + + if template_list.is_empty() { + return ToolExecutionResult { + result: ToolResult { + success: true, + message: format!( + "No templates available for phase: {}", + phase.unwrap_or("(none)") + ), + }, + new_body: None, + new_summary: None, + parsed_data: Some(json!([])), + version_request: None, + pending_questions: None, + }; + } + + // Convert templates to JSON (without the full body for display) + let templates_json: Vec<serde_json::Value> = template_list + .iter() + .map(|t| { + json!({ + "id": t.id, + "name": t.name, + "phase": t.phase, + "description": t.description, + "elementCount": t.suggested_body.len() + }) + }) + .collect(); + + let phase_msg = phase + .map(|p| format!(" for '{}' phase", p)) + .unwrap_or_default(); + + ToolExecutionResult { + result: ToolResult { + success: true, + message: format!( + "Found {} template(s){}. Use apply_template with a template_id to apply one.", + templates_json.len(), + phase_msg + ), + }, + new_body: None, + new_summary: None, + parsed_data: Some(json!(templates_json)), + version_request: None, + pending_questions: None, + } +} + +fn execute_apply_template(call: &ToolCall) -> ToolExecutionResult { + let template_id = call + .arguments + .get("template_id") + .and_then(|v| v.as_str()); + + let Some(template_id) = template_id else { + return ToolExecutionResult { + result: ToolResult { + success: false, + message: "Missing template_id parameter".to_string(), + }, + new_body: None, + new_summary: None, + parsed_data: None, + version_request: None, + pending_questions: None, + }; + }; + + // Find the template + let all = templates::all_templates(); + let template = all.iter().find(|t| t.id == template_id); + + let Some(template) = template else { + let available: Vec<String> = all.iter().map(|t| t.id.clone()).collect(); + return ToolExecutionResult { + result: ToolResult { + success: false, + message: format!( + "Template '{}' not found. Available: {}", + template_id, + available.join(", ") + ), + }, + new_body: None, + new_summary: None, + parsed_data: None, + version_request: None, + pending_questions: None, + }; + }; + + ToolExecutionResult { + result: ToolResult { + success: true, + message: format!( + "Applied template '{}' ({}) with {} elements. You can now customize the content.", + template.name, + template.phase, + template.suggested_body.len() + ), + }, + new_body: Some(template.suggested_body.clone()), + new_summary: None, + parsed_data: None, + version_request: None, + pending_questions: None, + } +} + /// Convert serde_json::Value to jaq_interpret::Val fn json_to_jaq(value: &serde_json::Value) -> jaq_interpret::Val { match value { diff --git a/makima/src/server/handlers/chat.rs b/makima/src/server/handlers/chat.rs index dfdb64e..9d8cd19 100644 --- a/makima/src/server/handlers/chat.rs +++ b/makima/src/server/handlers/chat.rs @@ -245,11 +245,12 @@ pub async fn chat_handler( ## Your Capabilities You have access to tools for: - **Viewing content**: view_body (see all elements), read_element (inspect specific element), view_transcript (read full transcript) -- **Adding content**: add_heading, add_paragraph, add_chart +- **Adding content**: add_heading, add_paragraph, add_code, add_list, add_chart - **Modifying content**: update_element, remove_element, reorder_elements, clear_body - **Document metadata**: set_summary - **Data processing**: parse_csv (convert CSV to JSON), jq (transform JSON data) - **Version history**: list_versions, read_version, restore_version +- **Templates**: suggest_templates (get phase-appropriate templates), apply_template (apply a template structure) ## Agentic Behavior Guidelines @@ -611,6 +612,7 @@ You have access to tools for: summary: current_summary.clone(), body: Some(current_body.clone()), version: None, // Internal update, skip version check + repo_file_path: None, }; match repository::update_file(pool, id, update_req).await { @@ -687,7 +689,27 @@ fn build_file_context(file: &crate::db::models::File) -> String { context.push_str(&format!("Summary: {}\n", summary)); } - context.push_str(&format!("Transcript entries: {}\n", file.transcript.len())); + // Include contract phase context if file belongs to a contract + if let Some(ref phase) = file.contract_phase { + context.push_str(&format!("\n## Contract Context\n")); + context.push_str(&format!("This file belongs to a contract in the '{}' phase.\n", phase)); + context.push_str("You can use 'suggest_templates' to get phase-appropriate templates, "); + context.push_str("or 'apply_template' to apply a template structure.\n"); + context.push_str(&format!( + "Templates for '{}' phase include: {}\n", + phase, + match phase.as_str() { + "research" => "research-notes, competitor-analysis, user-research", + "specify" => "requirements, user-stories, acceptance-criteria", + "plan" => "architecture, technical-design, task-breakdown", + "execute" => "dev-notes, test-plan, implementation-log", + "review" => "review-checklist, release-notes, retrospective", + _ => "(use suggest_templates to see available)", + } + )); + } + + context.push_str(&format!("\nTranscript entries: {}\n", file.transcript.len())); context.push_str(&format!("Body elements: {}\n", file.body.len())); // Add body overview @@ -727,6 +749,14 @@ fn build_file_context(file: &crate::db::models::File) -> String { BodyElement::Image { alt, .. } => { format!("Image{}", alt.as_ref().map(|a| format!(": {}", a)).unwrap_or_default()) } + BodyElement::Markdown { content } => { + let preview: String = content.chars().take(50).collect(); + if content.chars().count() > 50 { + format!("Markdown: {}...", preview) + } else { + format!("Markdown: {}", preview) + } + } }; context.push_str(&format!(" [{}] {}\n", i, desc)); } @@ -788,6 +818,9 @@ fn build_focused_element_context(body: &[BodyElement], focused_index: Option<usi let desc = alt.as_deref().or(caption.as_deref()).unwrap_or("no description"); ("Image".to_string(), desc.to_string()) } + BodyElement::Markdown { content } => { + ("Markdown".to_string(), content.clone()) + } }; format!( @@ -903,6 +936,14 @@ async fn handle_version_request( BodyElement::Image { alt, .. } => { format!("Image{}", alt.as_ref().map(|a| format!(": {}", a)).unwrap_or_default()) } + BodyElement::Markdown { content } => { + let preview: String = content.chars().take(100).collect(); + if content.chars().count() > 100 { + format!("Markdown: {}...", preview) + } else { + format!("Markdown: {}", preview) + } + } }; format!("[{}] {}", i, desc) }) diff --git a/makima/src/server/handlers/contract_chat.rs b/makima/src/server/handlers/contract_chat.rs new file mode 100644 index 0000000..d090999 --- /dev/null +++ b/makima/src/server/handlers/contract_chat.rs @@ -0,0 +1,2592 @@ +//! Chat endpoint for LLM-powered contract management. +//! +//! This handler provides an agentic loop for managing contracts: creating tasks, +//! adding files, managing repositories, and handling phase transitions. + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::db::{ + models::{ContractChatHistoryResponse, ContractWithRelations, CreateTaskRequest, UpdateFileRequest}, + repository, +}; +use crate::llm::{ + all_templates, analyze_task_output, body_to_markdown, format_checklist_markdown, + format_parsed_tasks, get_phase_checklist, parse_tasks_from_breakdown, + claude::{self, ClaudeClient, ClaudeError, ClaudeModel}, + groq::{GroqClient, GroqError, Message, ToolCallResponse}, + parse_contract_tool_call, templates_for_phase, ContractToolRequest, FileInfo, + LlmModel, TaskInfo, ToolCall, ToolResult, UserQuestion, CONTRACT_TOOLS, +}; +use crate::server::auth::Authenticated; +use crate::server::state::{DaemonCommand, SharedState}; + +/// Maximum number of tool-calling rounds to prevent infinite loops +const MAX_TOOL_ROUNDS: usize = 30; + +#[derive(Debug, Clone, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ContractChatHistoryMessage { + /// Role: "user" or "assistant" + pub role: String, + /// Message content + pub content: String, +} + +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ContractChatRequest { + /// The user's message/instruction + pub message: String, + /// Optional model selection: "claude-sonnet" (default), "claude-opus", or "groq" + #[serde(default)] + pub model: Option<String>, + /// Optional conversation history for context continuity + #[serde(default)] + pub history: Option<Vec<ContractChatHistoryMessage>>, +} + +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ContractChatResponse { + /// The LLM's response message + pub response: String, + /// Tool calls that were executed + pub tool_calls: Vec<ContractToolCallInfo>, + /// Questions pending user answers (pauses conversation) + #[serde(skip_serializing_if = "Option::is_none")] + pub pending_questions: Option<Vec<UserQuestion>>, +} + +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ContractToolCallInfo { + pub name: String, + pub result: ToolResult, +} + +/// Enum to hold LLM clients +enum LlmClient { + Groq(GroqClient), + Claude(ClaudeClient), +} + +/// Unified result from LLM call +struct LlmResult { + content: Option<String>, + tool_calls: Vec<ToolCall>, + raw_tool_calls: Vec<ToolCallResponse>, + finish_reason: String, +} + +/// Helper to get contract with all relations +async fn get_contract_with_relations( + pool: &sqlx::PgPool, + contract_id: Uuid, + owner_id: Uuid, +) -> Result<Option<ContractWithRelations>, sqlx::Error> { + let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await? { + Some(c) => c, + None => return Ok(None), + }; + + let repositories = repository::list_contract_repositories(pool, contract_id) + .await + .unwrap_or_default(); + + let files = repository::list_files_in_contract(pool, contract_id, owner_id) + .await + .unwrap_or_default(); + + let tasks = repository::list_tasks_in_contract(pool, contract_id, owner_id) + .await + .unwrap_or_default(); + + Ok(Some(ContractWithRelations { + contract, + repositories, + files, + tasks, + })) +} + +/// Chat with a contract using LLM tool calling for management +#[utoipa::path( + post, + path = "/api/v1/contracts/{id}/chat", + request_body = ContractChatRequest, + responses( + (status = 200, description = "Chat completed successfully", body = ContractChatResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Contract not found"), + (status = 500, description = "Internal server error") + ), + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Contracts" +)] +pub async fn contract_chat_handler( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(contract_id): Path<Uuid>, + Json(request): Json<ContractChatRequest>, +) -> impl IntoResponse { + // Check if database is configured + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ "error": "Database not configured" })), + ) + .into_response(); + }; + + // Get the contract (scoped by owner) + let contract = match get_contract_with_relations(pool, contract_id, auth.owner_id).await { + Ok(Some(c)) => c, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(json!({ "error": "Contract not found" })), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Database error: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("Database error: {}", e) })), + ) + .into_response(); + } + }; + + // Parse model selection (default to Claude Sonnet) + let model = request + .model + .as_ref() + .and_then(|m| LlmModel::from_str(m)) + .unwrap_or(LlmModel::ClaudeSonnet); + + tracing::info!("Contract chat using LLM model: {:?}", model); + + // Initialize the appropriate LLM client + let llm_client = match model { + LlmModel::ClaudeSonnet => match ClaudeClient::from_env(ClaudeModel::Sonnet) { + Ok(client) => LlmClient::Claude(client), + Err(ClaudeError::MissingApiKey) => { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("Claude client error: {}", e) })), + ) + .into_response(); + } + }, + LlmModel::ClaudeOpus => match ClaudeClient::from_env(ClaudeModel::Opus) { + Ok(client) => LlmClient::Claude(client), + Err(ClaudeError::MissingApiKey) => { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("Claude client error: {}", e) })), + ) + .into_response(); + } + }, + LlmModel::GroqKimi => match GroqClient::from_env() { + Ok(client) => LlmClient::Groq(client), + Err(GroqError::MissingApiKey) => { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ "error": "GROQ_API_KEY not configured" })), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("Groq client error: {}", e) })), + ) + .into_response(); + } + }, + }; + + // Build contract context + let contract_context = build_contract_context(&contract); + + // Build system prompt for contract management + let system_prompt = format!( + r#"You are an intelligent contract management agent. You guide users through the contract lifecycle from research to completion, helping them organize work, create documentation, set up repositories, and execute tasks. + +## Your Capabilities +You have access to tools for: +- **Query**: get_contract_status, list_contract_files, list_contract_tasks, list_contract_repositories, read_file +- **File Management**: create_file_from_template, create_empty_file, list_available_templates +- **Task Management**: create_contract_task, delegate_content_generation, start_task +- **Phase Management**: get_phase_info, suggest_phase_transition, advance_phase +- **Repository Management**: list_daemon_directories, add_repository, set_primary_repository +- **Interactive**: ask_user + +## Content Generation Deferral +When asked to write substantial content, fill templates, or generate documentation: +- **Use delegate_content_generation** to create a task for the content generation +- This delegates the work to a task agent that can do more thorough research and writing + +**Use delegation for:** +- Filling in template content with real data +- Writing documentation based on requirements +- Generating user stories or specifications +- Creating detailed design documents +- Any substantial writing that requires research or analysis + +**Direct actions (no delegation needed):** +- Listing files/tasks/repos +- Reading files +- Phase transitions +- Creating empty files or templates +- Simple queries and status checks +- Asking user questions + +## Contract Lifecycle Phases + +### 1. RESEARCH Phase +**Purpose**: Gather information and understand the problem space +**Key Activities**: +- Conduct user research and interviews +- Analyze competitors and existing solutions +- Document findings and insights +- Identify opportunities and constraints +**Suggested Actions**: +- Create a "Research Notes" document to capture findings +- Create a "Competitor Analysis" document +- When research is complete, suggest transitioning to Specify phase + +### 2. SPECIFY Phase +**Purpose**: Define what needs to be built +**Key Activities**: +- Write clear requirements +- Create user stories with acceptance criteria +- Define scope and constraints +- Document technical constraints +**Suggested Actions**: +- Create a "Requirements" document +- Create "User Stories" with acceptance criteria +- When specifications are clear, suggest transitioning to Plan phase + +### 3. PLAN Phase +**Purpose**: Design the solution and break down the work +**Key Activities**: +- Design system architecture +- Create technical specifications +- Break work into implementable tasks +- Set up repositories for development +**Suggested Actions**: +- Create an "Architecture" document +- Create a "Task Breakdown" document +- **IMPORTANT**: Help set up a repository if not already configured +- When planning is complete and a repository is set, suggest transitioning to Execute phase + +### 4. EXECUTE Phase +**Purpose**: Implement the solution +**Key Activities**: +- Create and run tasks to implement features +- Write and run tests +- Track progress +- Document implementation decisions +**Suggested Actions**: +- Create tasks based on the task breakdown +- Monitor task progress and help resolve blockers +- When all tasks are complete, suggest transitioning to Review phase + +### 5. REVIEW Phase +**Purpose**: Validate and document the completed work +**Key Activities**: +- Review completed work +- Create release notes +- Conduct retrospective +- Document learnings +**Suggested Actions**: +- Create a "Release Notes" document +- Create a "Retrospective" document +- Help mark the contract as complete when review is done + +## Current Contract +{contract_context} + +## Proactive Guidance + +### Repository Setup (Critical for Plan/Execute phases) +When the user wants to add a local repository or set up for execution: +1. **First call list_daemon_directories** to get available paths from connected agents +2. Present the suggested directories to the user +3. Ask which path they want to use, or let them specify a custom path +4. Then call add_repository with the chosen path + +Example flow: +``` +User: "Set up a repository for this contract" +You: Call list_daemon_directories first +You: "I found these directories from your connected agent: + - /Users/alice/projects (Working Directory) + - /Users/alice/.makima/home (Makima Home) + Which would you like to use, or provide a custom path?" +``` + +### Phase Transitions +- Phases progress in order: research -> specify -> plan -> execute -> review +- You can ONLY advance forward one step at a time to the NEXT phase +- ALWAYS use suggest_phase_transition FIRST to get the exact nextPhase value +- Then use advance_phase with that exact nextPhase value +- Example: If currentPhase is "specify", nextPhase will be "plan" - use advance_phase with new_phase="plan" +- NEVER suggest advancing to the same phase the contract is already in + +### New Users +When a new contract is created or the user seems unsure: +1. Explain the current phase and what should be done +2. Suggest creating appropriate documents +3. Guide them toward the next milestone + +## Agentic Behavior Guidelines + +### 1. Understand Before Acting +- For complex requests, first gather information about the contract's current state +- Use get_contract_status or list_contract_files to understand what exists +- Consider the current phase when suggesting actions + +### 2. Phase-Appropriate Suggestions +- Suggest templates and actions appropriate for the current phase +- When creating files, prefer templates that match the contract's phase +- Advise when the contract might be ready for the next phase + +### 3. Help Plan Work +- When asked to plan work, read existing files to understand context +- Suggest creating tasks based on requirements or plans in files +- Offer to create task breakdowns from design documents + +### 4. Repository Management +- When adding local repositories, ALWAYS use list_daemon_directories first to get suggestions +- This provides the user with valid paths from their connected agents +- Don't ask users to manually type paths when suggestions are available + +### 5. Task Creation and Execution +- When creating tasks, derive plans from existing contract files when possible +- Use the contract's primary repository for tasks by default +- Create clear, actionable task plans +- After creating a task, you can use **start_task** to immediately begin execution +- A daemon must be connected for start_task to work + +### 6. Be Proactive but Efficient +- Guide users through the contract flow +- Don't over-analyze simple requests +- Use the minimum number of tool calls needed +- Provide clear summaries of actions taken + +## Important Notes +- This contract's ID is: {contract_id} +- All operations are scoped to this contract +- When creating tasks or files, they are automatically associated with this contract"#, + contract_context = contract_context, + contract_id = contract_id + ); + + // Run the agentic loop + run_contract_agentic_loop( + pool, + &state, + &llm_client, + system_prompt, + &request, + contract_id, + auth.owner_id, + ) + .await +} + +fn build_contract_context(contract: &crate::db::models::ContractWithRelations) -> String { + let c = &contract.contract; + let mut context = format!( + "Name: {}\nID: {}\nPhase: {}\nStatus: {}\n", + c.name, c.id, c.phase, c.status + ); + + if let Some(ref desc) = c.description { + context.push_str(&format!("Description: {}\n", desc)); + } + + // Build phase checklist + let file_infos: Vec<FileInfo> = contract.files.iter().map(|f| FileInfo { + id: f.id, + name: f.name.clone(), + contract_phase: f.contract_phase.clone(), + }).collect(); + + let task_infos: Vec<TaskInfo> = contract.tasks.iter().map(|t| TaskInfo { + id: t.id, + name: t.name.clone(), + status: t.status.clone(), + }).collect(); + + let has_repository = !contract.repositories.is_empty(); + let phase_checklist = get_phase_checklist(&c.phase, &file_infos, &task_infos, has_repository); + + // Add phase checklist to context + context.push_str("\n"); + context.push_str(&format_checklist_markdown(&phase_checklist)); + + // Files summary + context.push_str(&format!("\n### Files ({} total)\n", contract.files.len())); + if !contract.files.is_empty() { + for file in contract.files.iter().take(5) { + let phase_label = file.contract_phase.as_deref().unwrap_or("none"); + context.push_str(&format!("- {} [{}] (ID: {})\n", file.name, phase_label, file.id)); + } + if contract.files.len() > 5 { + context.push_str(&format!("... and {} more\n", contract.files.len() - 5)); + } + } + + // Tasks summary + context.push_str(&format!("\n### Tasks ({} total)\n", contract.tasks.len())); + if !contract.tasks.is_empty() { + let pending = contract.tasks.iter().filter(|t| t.status == "pending").count(); + let running = contract.tasks.iter().filter(|t| t.status == "running").count(); + let done = contract.tasks.iter().filter(|t| t.status == "done").count(); + context.push_str(&format!("{} pending, {} running, {} done\n", pending, running, done)); + for task in contract.tasks.iter().take(5) { + context.push_str(&format!("- {} ({}) - ID: {}\n", task.name, task.status, task.id)); + } + if contract.tasks.len() > 5 { + context.push_str(&format!("... and {} more\n", contract.tasks.len() - 5)); + } + } + + // Repositories summary + context.push_str(&format!("\n### Repositories ({} total)\n", contract.repositories.len())); + if !contract.repositories.is_empty() { + for repo in &contract.repositories { + let primary = if repo.is_primary { " (primary)" } else { "" }; + let url_or_path = repo.repository_url.as_deref() + .or(repo.local_path.as_deref()) + .unwrap_or("managed"); + context.push_str(&format!("- {}: {}{}\n", repo.name, url_or_path, primary)); + } + } + + context +} + +/// Summarize older conversation history to reduce token usage +async fn summarize_conversation_history( + llm_client: &LlmClient, + messages: &[&crate::db::models::ContractChatMessageRecord], +) -> String { + // Build conversation text for summarization + let mut conversation_text = String::new(); + for msg in messages { + let role_label = if msg.role == "user" { "User" } else { "Assistant" }; + // Limit each message to avoid overwhelming the summarizer + let content = if msg.content.len() > 500 { + format!("{}...", &msg.content[..500]) + } else { + msg.content.clone() + }; + conversation_text.push_str(&format!("{}: {}\n", role_label, content)); + } + + // Limit total text to summarize + if conversation_text.len() > 8000 { + conversation_text = format!("{}...", &conversation_text[..8000]); + } + + let summary_prompt = format!( + "Summarize this conversation history in 2-3 sentences, focusing on key decisions, actions taken, and current state:\n\n{}", + conversation_text + ); + + // Use a simple chat call without tools for summarization + let summary = match llm_client { + LlmClient::Claude(client) => { + let claude_messages = vec![claude::Message { + role: "user".to_string(), + content: claude::MessageContent::Text(summary_prompt.clone()), + }]; + match client.chat_with_tools(claude_messages, &[]).await { + Ok(response) => response.content.unwrap_or_default(), + Err(e) => { + tracing::warn!("Failed to summarize conversation: {}", e); + "Previous conversation covered contract management tasks.".to_string() + } + } + } + LlmClient::Groq(client) => { + let groq_messages = vec![Message { + role: "user".to_string(), + content: Some(summary_prompt.clone()), + tool_calls: None, + tool_call_id: None, + }]; + match client.chat_with_tools(groq_messages, &[]).await { + Ok(response) => response.content.unwrap_or_default(), + Err(e) => { + tracing::warn!("Failed to summarize conversation: {}", e); + "Previous conversation covered contract management tasks.".to_string() + } + } + } + }; + + // Limit summary length + if summary.len() > 500 { + format!("{}...", &summary[..500]) + } else { + summary + } +} + +/// Run the agentic loop for contract chat +async fn run_contract_agentic_loop( + pool: &sqlx::PgPool, + state: &SharedState, + llm_client: &LlmClient, + system_prompt: String, + request: &ContractChatRequest, + contract_id: Uuid, + owner_id: Uuid, +) -> axum::response::Response { + // Get or create the conversation for persistent history + let conversation = match repository::get_or_create_contract_conversation(pool, contract_id, owner_id).await { + Ok(conv) => conv, + Err(e) => { + tracing::error!("Failed to get/create contract conversation: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("Failed to initialize conversation: {}", e) })), + ) + .into_response(); + } + }; + + // Load ALL existing messages from database + let saved_messages = match repository::list_contract_chat_messages(pool, conversation.id, None).await { + Ok(msgs) => msgs, + Err(e) => { + tracing::warn!("Failed to load contract chat history: {}", e); + Vec::new() + } + }; + + // Build initial messages + let mut messages = vec![Message { + role: "system".to_string(), + content: Some(system_prompt), + tool_calls: None, + tool_call_id: None, + }]; + + // Add saved conversation history, summarizing older messages if needed + // to stay under rate limits (~25k chars ≈ ~6k tokens for history) + const MAX_HISTORY_CHARS: usize = 25000; + const RECENT_MESSAGES_TO_KEEP: usize = 6; // Keep last 3 turns intact + + // Filter to user/assistant messages only + let history_messages: Vec<_> = saved_messages + .iter() + .filter(|m| m.role == "user" || m.role == "assistant") + .collect(); + + // Calculate total character count + let total_chars: usize = history_messages.iter().map(|m| m.content.len()).sum(); + + if total_chars > MAX_HISTORY_CHARS && history_messages.len() > RECENT_MESSAGES_TO_KEEP { + // Need to summarize older messages + let split_point = history_messages.len().saturating_sub(RECENT_MESSAGES_TO_KEEP); + let older_messages = &history_messages[..split_point]; + let recent_messages = &history_messages[split_point..]; + + // Generate summary of older conversation + let summary = summarize_conversation_history(&llm_client, older_messages).await; + + // Add summary as context + messages.push(Message { + role: "user".to_string(), + content: Some(format!("[Previous conversation summary: {}]", summary)), + tool_calls: None, + tool_call_id: None, + }); + messages.push(Message { + role: "assistant".to_string(), + content: Some("I understand the previous context. Let's continue.".to_string()), + tool_calls: None, + tool_call_id: None, + }); + + // Add recent messages in full + for saved_msg in recent_messages { + messages.push(Message { + role: saved_msg.role.clone(), + content: Some(saved_msg.content.clone()), + tool_calls: None, + tool_call_id: None, + }); + } + + tracing::info!( + total_messages = history_messages.len(), + summarized = older_messages.len(), + kept_recent = recent_messages.len(), + "Summarized older conversation history" + ); + } else { + // Add all messages directly + for saved_msg in history_messages { + messages.push(Message { + role: saved_msg.role.clone(), + content: Some(saved_msg.content.clone()), + tool_calls: None, + tool_call_id: None, + }); + } + } + + // Add current user message + messages.push(Message { + role: "user".to_string(), + content: Some(request.message.clone()), + tool_calls: None, + tool_call_id: None, + }); + + // Save the user message to database + if let Err(e) = repository::add_contract_chat_message( + pool, + conversation.id, + "user", + &request.message, + None, + None, + ).await { + tracing::warn!("Failed to save user message to contract chat history: {}", e); + } + + // State for tracking + let mut all_tool_call_infos: Vec<ContractToolCallInfo> = Vec::new(); + let mut final_response: Option<String> = None; + let mut consecutive_failures = 0; + const MAX_CONSECUTIVE_FAILURES: usize = 3; + let mut pending_questions: Option<Vec<UserQuestion>> = None; + + // Multi-turn agentic tool calling loop + for round in 0..MAX_TOOL_ROUNDS { + tracing::info!( + round = round, + total_tool_calls = all_tool_call_infos.len(), + "Contract agentic loop iteration" + ); + + // Check consecutive failures + if consecutive_failures >= MAX_CONSECUTIVE_FAILURES { + tracing::warn!( + "Breaking contract loop due to {} consecutive failures", + consecutive_failures + ); + final_response = Some( + "I encountered multiple consecutive errors and stopped. \ + Please check the contract state and try again." + .to_string(), + ); + break; + } + + // Call the appropriate LLM API + let result = match llm_client { + LlmClient::Groq(groq) => { + match groq.chat_with_tools(messages.clone(), &CONTRACT_TOOLS).await { + Ok(r) => LlmResult { + content: r.content, + tool_calls: r.tool_calls, + raw_tool_calls: r.raw_tool_calls, + finish_reason: r.finish_reason, + }, + Err(e) => { + tracing::error!("Groq API error: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("LLM API error: {}", e) })), + ) + .into_response(); + } + } + } + LlmClient::Claude(claude_client) => { + let claude_messages = claude::groq_messages_to_claude(&messages); + match claude_client + .chat_with_tools(claude_messages, &CONTRACT_TOOLS) + .await + { + Ok(r) => { + let raw_tool_calls: Vec<ToolCallResponse> = r + .tool_calls + .iter() + .map(|tc| ToolCallResponse { + id: tc.id.clone(), + call_type: "function".to_string(), + function: crate::llm::groq::FunctionCall { + name: tc.name.clone(), + arguments: tc.arguments.to_string(), + }, + }) + .collect(); + + LlmResult { + content: r.content, + tool_calls: r.tool_calls, + raw_tool_calls, + finish_reason: r.stop_reason, + } + } + Err(e) => { + tracing::error!("Claude API error: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("LLM API error: {}", e) })), + ) + .into_response(); + } + } + } + }; + + // Check if there are tool calls to execute + if result.tool_calls.is_empty() { + final_response = result.content; + break; + } + + // Add assistant message with tool calls to conversation + messages.push(Message { + role: "assistant".to_string(), + content: result.content.clone(), + tool_calls: Some(result.raw_tool_calls.clone()), + tool_call_id: None, + }); + + // Execute each tool call + for (i, tool_call) in result.tool_calls.iter().enumerate() { + tracing::info!(tool = %tool_call.name, round = round, "Executing contract tool call"); + + // Parse the tool call + let mut execution_result = parse_contract_tool_call(tool_call); + + // Handle async contract tool requests + if let Some(contract_request) = execution_result.request.take() { + let async_result = + handle_contract_request(pool, &state.daemon_connections, contract_request, contract_id, owner_id).await; + execution_result.success = async_result.success; + execution_result.message = async_result.message; + execution_result.data = async_result.data; + } + + // Track consecutive failures + if execution_result.success { + consecutive_failures = 0; + } else { + consecutive_failures += 1; + tracing::warn!( + tool = %tool_call.name, + consecutive_failures = consecutive_failures, + "Contract tool call failed" + ); + } + + // Check for pending user questions + if let Some(questions) = execution_result.pending_questions { + tracing::info!( + question_count = questions.len(), + "Contract LLM requesting user input" + ); + pending_questions = Some(questions); + all_tool_call_infos.push(ContractToolCallInfo { + name: tool_call.name.clone(), + result: ToolResult { + success: execution_result.success, + message: execution_result.message.clone(), + }, + }); + break; + } + + // Build tool result message + let result_content = if let Some(data) = &execution_result.data { + json!({ + "success": execution_result.success, + "message": execution_result.message, + "data": data + }) + .to_string() + } else { + json!({ + "success": execution_result.success, + "message": execution_result.message + }) + .to_string() + }; + + // Add tool result message + let tool_call_id = match llm_client { + LlmClient::Groq(_) => result.raw_tool_calls[i].id.clone(), + LlmClient::Claude(_) => tool_call.id.clone(), + }; + + messages.push(Message { + role: "tool".to_string(), + content: Some(result_content), + tool_calls: None, + tool_call_id: Some(tool_call_id), + }); + + // Track for response + all_tool_call_infos.push(ContractToolCallInfo { + name: tool_call.name.clone(), + result: ToolResult { + success: execution_result.success, + message: execution_result.message, + }, + }); + } + + // If user questions are pending, pause + if pending_questions.is_some() { + final_response = result.content; + break; + } + + // If finish reason indicates completion, exit loop + let finish_lower = result.finish_reason.to_lowercase(); + if finish_lower == "stop" || finish_lower == "end_turn" { + final_response = result.content; + break; + } + } + + // Build response + let response_text = final_response.unwrap_or_else(|| { + if all_tool_call_infos.is_empty() { + "I couldn't understand your request. Please try rephrasing.".to_string() + } else { + format!( + "Done! Executed {} tool{}.", + all_tool_call_infos.len(), + if all_tool_call_infos.len() == 1 { "" } else { "s" } + ) + } + }); + + // Save assistant response to database + let tool_calls_json = if all_tool_call_infos.is_empty() { + None + } else { + serde_json::to_value(&all_tool_call_infos).ok() + }; + + let pending_questions_json = pending_questions.as_ref().and_then(|q| serde_json::to_value(q).ok()); + + if let Err(e) = repository::add_contract_chat_message( + pool, + conversation.id, + "assistant", + &response_text, + tool_calls_json, + pending_questions_json, + ).await { + tracing::warn!("Failed to save assistant response to contract chat history: {}", e); + } + + ( + StatusCode::OK, + Json(ContractChatResponse { + response: response_text, + tool_calls: all_tool_call_infos, + pending_questions, + }), + ) + .into_response() +} + +/// Result from handling an async contract tool request +struct ContractRequestResult { + success: bool, + message: String, + data: Option<serde_json::Value>, +} + +/// Handle async contract tool requests that require database access +async fn handle_contract_request( + pool: &sqlx::PgPool, + daemon_connections: &dashmap::DashMap<String, crate::server::state::DaemonConnectionInfo>, + request: ContractToolRequest, + contract_id: Uuid, + owner_id: Uuid, +) -> ContractRequestResult { + match request { + ContractToolRequest::ListDaemonDirectories => { + let mut directories = Vec::new(); + + // Iterate over connected daemons belonging to this owner + for entry in daemon_connections.iter() { + let daemon = entry.value(); + + // Only include daemons belonging to this owner + if daemon.owner_id != owner_id { + continue; + } + + // Add working directory if available + if let Some(ref working_dir) = daemon.working_directory { + directories.push(json!({ + "path": working_dir, + "label": "Working Directory", + "type": "working", + "hostname": daemon.hostname, + })); + } + + // Add home directory if available + if let Some(ref home_dir) = daemon.home_directory { + directories.push(json!({ + "path": home_dir, + "label": "Makima Home", + "type": "home", + "hostname": daemon.hostname, + })); + } + } + + if directories.is_empty() { + ContractRequestResult { + success: true, + message: "No daemon directories available. Connect a daemon to get directory suggestions.".to_string(), + data: Some(json!({ "directories": [] })), + } + } else { + ContractRequestResult { + success: true, + message: format!("Found {} suggested directories from connected daemons", directories.len()), + data: Some(json!({ "directories": directories })), + } + } + } + + ContractToolRequest::GetContractStatus => { + match get_contract_with_relations(pool, contract_id, owner_id).await { + Ok(Some(cwr)) => { + let c = &cwr.contract; + ContractRequestResult { + success: true, + message: format!( + "Contract '{}' is in '{}' phase with status '{}'", + c.name, c.phase, c.status + ), + data: Some(json!({ + "name": c.name, + "phase": c.phase, + "status": c.status, + "description": c.description, + "fileCount": cwr.files.len(), + "taskCount": cwr.tasks.len(), + "repositoryCount": cwr.repositories.len(), + })), + } + } + Ok(None) => ContractRequestResult { + success: false, + message: "Contract not found".to_string(), + data: None, + }, + Err(e) => ContractRequestResult { + success: false, + message: format!("Database error: {}", e), + data: None, + }, + } + } + + ContractToolRequest::ListContractFiles => { + match get_contract_with_relations(pool, contract_id, owner_id).await { + Ok(Some(cwr)) => { + let files: Vec<serde_json::Value> = cwr + .files + .iter() + .map(|f| { + json!({ + "fileId": f.id, + "name": f.name, + "description": f.description, + "phase": f.contract_phase, + }) + }) + .collect(); + + ContractRequestResult { + success: true, + message: format!("Found {} files", files.len()), + data: Some(json!({ "files": files })), + } + } + Ok(None) => ContractRequestResult { + success: false, + message: "Contract not found".to_string(), + data: None, + }, + Err(e) => ContractRequestResult { + success: false, + message: format!("Database error: {}", e), + data: None, + }, + } + } + + ContractToolRequest::ListContractTasks => { + match get_contract_with_relations(pool, contract_id, owner_id).await { + Ok(Some(cwr)) => { + let tasks: Vec<serde_json::Value> = cwr + .tasks + .iter() + .map(|t| { + json!({ + "taskId": t.id, + "name": t.name, + "status": t.status, + "priority": t.priority, + }) + }) + .collect(); + + ContractRequestResult { + success: true, + message: format!("Found {} tasks", tasks.len()), + data: Some(json!({ "tasks": tasks })), + } + } + Ok(None) => ContractRequestResult { + success: false, + message: "Contract not found".to_string(), + data: None, + }, + Err(e) => ContractRequestResult { + success: false, + message: format!("Database error: {}", e), + data: None, + }, + } + } + + ContractToolRequest::ListContractRepositories => { + match get_contract_with_relations(pool, contract_id, owner_id).await { + Ok(Some(cwr)) => { + let repos: Vec<serde_json::Value> = cwr + .repositories + .iter() + .map(|r| { + json!({ + "repositoryId": r.id, + "name": r.name, + "repositoryUrl": r.repository_url, + "localPath": r.local_path, + "isPrimary": r.is_primary, + }) + }) + .collect(); + + ContractRequestResult { + success: true, + message: format!("Found {} repositories", repos.len()), + data: Some(json!({ "repositories": repos })), + } + } + Ok(None) => ContractRequestResult { + success: false, + message: "Contract not found".to_string(), + data: None, + }, + Err(e) => ContractRequestResult { + success: false, + message: format!("Database error: {}", e), + data: None, + }, + } + } + + ContractToolRequest::ReadFile { file_id } => { + match repository::get_file_for_owner(pool, file_id, owner_id).await { + Ok(Some(file)) => { + // Verify file belongs to this contract + if file.contract_id != Some(contract_id) { + return ContractRequestResult { + success: false, + message: "File does not belong to this contract".to_string(), + data: None, + }; + } + + // Convert body to markdown for LLM consumption + let markdown = body_to_markdown(&file.body); + + ContractRequestResult { + success: true, + message: format!("Read file '{}'", file.name), + data: Some(json!({ + "fileId": file.id, + "name": file.name, + "description": file.description, + "summary": file.summary, + "plainText": markdown, + "phase": file.contract_phase, + })), + } + } + Ok(None) => ContractRequestResult { + success: false, + message: "File not found".to_string(), + data: None, + }, + Err(e) => ContractRequestResult { + success: false, + message: format!("Database error: {}", e), + data: None, + }, + } + } + + ContractToolRequest::CreateFileFromTemplate { + template_id, + name, + description, + } => { + // Find the template + let templates = all_templates(); + let template = templates.iter().find(|t| t.id == template_id); + + let Some(template) = template else { + return ContractRequestResult { + success: false, + message: format!("Template '{}' not found", template_id), + data: None, + }; + }; + + // Verify contract exists and get current phase + let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { + Ok(Some(c)) => c, + Ok(None) => { + return ContractRequestResult { + success: false, + message: "Contract not found".to_string(), + data: None, + } + } + Err(e) => { + return ContractRequestResult { + success: false, + message: format!("Database error: {}", e), + data: None, + } + } + }; + + // Use template's phase if available, otherwise use contract's current phase + let contract_phase = Some(template.phase.clone()).or(Some(contract.phase.clone())); + + // Create the file (contract_id is now required) + let create_req = crate::db::models::CreateFileRequest { + contract_id, + name: Some(name.clone()), + description, + body: template.suggested_body.clone(), + transcript: Vec::new(), + location: None, + repo_file_path: None, + contract_phase, + }; + + match repository::create_file_for_owner(pool, owner_id, create_req).await { + Ok(file) => ContractRequestResult { + success: true, + message: format!( + "Created file '{}' from template '{}'", + name, template.name + ), + data: Some(json!({ + "fileId": file.id, + "name": file.name, + "templateId": template_id, + })), + }, + Err(e) => ContractRequestResult { + success: false, + message: format!("Failed to create file: {}", e), + data: None, + }, + } + } + + ContractToolRequest::CreateEmptyFile { name, description } => { + // Verify contract exists and get current phase + let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { + Ok(Some(c)) => c, + Ok(None) => { + return ContractRequestResult { + success: false, + message: "Contract not found".to_string(), + data: None, + } + } + Err(e) => { + return ContractRequestResult { + success: false, + message: format!("Database error: {}", e), + data: None, + } + } + }; + + // Create the file with current contract phase + let create_req = crate::db::models::CreateFileRequest { + contract_id, + name: Some(name.clone()), + description, + body: Vec::new(), + transcript: Vec::new(), + location: None, + repo_file_path: None, + contract_phase: Some(contract.phase.clone()), + }; + + match repository::create_file_for_owner(pool, owner_id, create_req).await { + Ok(file) => ContractRequestResult { + success: true, + message: format!("Created empty file '{}'", name), + data: Some(json!({ + "fileId": file.id, + "name": file.name, + })), + }, + Err(e) => ContractRequestResult { + success: false, + message: format!("Failed to create file: {}", e), + data: None, + }, + } + } + + ContractToolRequest::ListAvailableTemplates { phase } => { + let templates = if let Some(p) = phase { + templates_for_phase(&p) + } else { + all_templates() + }; + + let template_data: Vec<serde_json::Value> = templates + .iter() + .map(|t| { + json!({ + "id": t.id, + "name": t.name, + "phase": t.phase, + "description": t.description, + }) + }) + .collect(); + + ContractRequestResult { + success: true, + message: format!("Found {} templates", templates.len()), + data: Some(json!({ "templates": template_data })), + } + } + + ContractToolRequest::CreateContractTask { + name, + plan, + repository_url, + base_branch, + } => { + // Get primary repository if not specified + let repo_url = if repository_url.is_some() { + repository_url + } else { + // Find primary repository + match get_contract_with_relations(pool, contract_id, owner_id).await { + Ok(Some(contract)) => { + contract + .repositories + .iter() + .find(|r| r.is_primary) + .and_then(|r| r.repository_url.clone().or(r.local_path.clone())) + } + _ => None, + } + }; + + let create_req = CreateTaskRequest { + contract_id, + name: name.clone(), + description: None, + plan, + parent_task_id: None, + repository_url: repo_url, + base_branch, + target_branch: None, + merge_mode: None, + priority: 0, + target_repo_path: None, + completion_action: None, + continue_from_task_id: None, + copy_files: None, + is_supervisor: false, + checkpoint_sha: None, + }; + + match repository::create_task_for_owner(pool, owner_id, create_req).await { + Ok(task) => ContractRequestResult { + success: true, + message: format!("Created task '{}' in contract", name), + data: Some(json!({ + "taskId": task.id, + "name": task.name, + "status": task.status, + })), + }, + Err(e) => ContractRequestResult { + success: false, + message: format!("Failed to create task: {}", e), + data: None, + }, + } + } + + ContractToolRequest::DelegateContentGeneration { + file_id, + instruction, + context, + } => { + // Build a task plan that includes the content generation instruction + let mut plan = format!( + "Content Generation Task\n\n\ + ## Instruction\n{}\n\n", + instruction + ); + + if let Some(ctx) = context { + plan.push_str(&format!("## Context\n{}\n\n", ctx)); + } + + // If file_id is provided, get file details and include them + let (file_name, file_info) = if let Some(fid) = file_id { + match repository::get_file_for_owner(pool, fid, owner_id).await { + Ok(Some(file)) => { + let info = format!( + "## Target File\n\ + - File ID: {}\n\ + - Name: {}\n\ + - Description: {}\n\n\ + The generated content should be structured to update this file.\n", + fid, + file.name, + file.description.as_deref().unwrap_or("(no description)") + ); + (Some(file.name.clone()), Some(info)) + } + _ => (None, None), + } + } else { + (None, None) + }; + + if let Some(info) = file_info { + plan.push_str(&info); + } + + // Get primary repository + let repo_url = match get_contract_with_relations(pool, contract_id, owner_id).await { + Ok(Some(contract)) => contract + .repositories + .iter() + .find(|r| r.is_primary) + .and_then(|r| r.repository_url.clone().or(r.local_path.clone())), + _ => None, + }; + + let task_name = format!( + "Generate content{}", + file_name.map(|n| format!(": {}", n)).unwrap_or_default() + ); + + let create_req = CreateTaskRequest { + contract_id, + name: task_name.clone(), + description: Some(instruction.clone()), + plan, + parent_task_id: None, + repository_url: repo_url, + base_branch: None, + target_branch: None, + merge_mode: None, + priority: 0, + target_repo_path: None, + completion_action: None, + continue_from_task_id: None, + copy_files: None, + is_supervisor: false, + checkpoint_sha: None, + }; + + match repository::create_task_for_owner(pool, owner_id, create_req).await { + Ok(task) => ContractRequestResult { + success: true, + message: format!( + "Created content generation task '{}'. Start the task to generate the content.", + task_name + ), + data: Some(json!({ + "taskId": task.id, + "name": task.name, + "status": task.status, + "targetFileId": file_id, + })), + }, + Err(e) => ContractRequestResult { + success: false, + message: format!("Failed to create content generation task: {}", e), + data: None, + }, + } + } + + ContractToolRequest::StartTask { task_id } => { + // Get the task + let task = match repository::get_task_for_owner(pool, task_id, owner_id).await { + Ok(Some(t)) => t, + Ok(None) => { + return ContractRequestResult { + success: false, + message: "Task not found".to_string(), + data: None, + } + } + Err(e) => { + return ContractRequestResult { + success: false, + message: format!("Failed to get task: {}", e), + data: None, + } + } + }; + + // Check if task can be started + let startable_statuses = ["pending", "failed", "interrupted", "done", "merged"]; + if !startable_statuses.contains(&task.status.as_str()) { + return ContractRequestResult { + success: false, + message: format!("Task cannot be started from status: {}", task.status), + data: None, + }; + } + + // Find a connected daemon for this owner + let daemon_entry = daemon_connections + .iter() + .find(|d| d.value().owner_id == owner_id); + + let (target_daemon_id, command_sender) = match daemon_entry { + Some(entry) => { + let daemon = entry.value(); + (daemon.id, daemon.command_sender.clone()) + } + None => { + return ContractRequestResult { + success: false, + message: "No daemon connected. Start a daemon to run tasks.".to_string(), + data: None, + }; + } + }; + + // Check if this is an orchestrator + let subtask_count = match repository::list_subtasks_for_owner(pool, task_id, owner_id).await { + Ok(subtasks) => subtasks.len(), + Err(_) => 0, + }; + let is_orchestrator = task.depth == 0 && subtask_count > 0; + + // Update task status to 'starting' and assign daemon_id + let update_req = crate::db::models::UpdateTaskRequest { + status: Some("starting".to_string()), + daemon_id: Some(target_daemon_id), + version: Some(task.version), + ..Default::default() + }; + + let _updated_task = match repository::update_task_for_owner(pool, task_id, owner_id, update_req).await { + Ok(Some(t)) => t, + Ok(None) => { + return ContractRequestResult { + success: false, + message: "Task not found".to_string(), + data: None, + }; + } + Err(e) => { + return ContractRequestResult { + success: false, + message: format!("Failed to update task: {}", e), + data: None, + }; + } + }; + + // Send SpawnTask command to daemon + let command = DaemonCommand::SpawnTask { + task_id, + task_name: task.name.clone(), + plan: task.plan.clone(), + repo_url: task.repository_url.clone(), + base_branch: task.base_branch.clone(), + target_branch: task.target_branch.clone(), + parent_task_id: task.parent_task_id, + depth: task.depth, + is_orchestrator, + target_repo_path: task.target_repo_path.clone(), + completion_action: task.completion_action.clone(), + continue_from_task_id: task.continue_from_task_id, + copy_files: task.copy_files.as_ref().and_then(|v| serde_json::from_value(v.clone()).ok()), + contract_id: task.contract_id, + is_supervisor: task.is_supervisor, + }; + + if let Err(e) = command_sender.send(command).await { + // Rollback: reset status since command failed + let rollback_req = crate::db::models::UpdateTaskRequest { + status: Some("pending".to_string()), + clear_daemon_id: true, + ..Default::default() + }; + let _ = repository::update_task_for_owner(pool, task_id, owner_id, rollback_req).await; + return ContractRequestResult { + success: false, + message: format!("Failed to send task to daemon: {}", e), + data: None, + }; + } + + // Note: TaskUpdateNotification broadcast is handled by the mesh handler when daemon reports status + ContractRequestResult { + success: true, + message: format!("Started task '{}'. The task is now running on a connected daemon.", task.name), + data: Some(json!({ + "taskId": task_id, + "name": task.name, + "status": "starting", + })), + } + } + + ContractToolRequest::GetPhaseInfo => { + let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { + Ok(Some(c)) => c, + Ok(None) => { + return ContractRequestResult { + success: false, + message: "Contract not found".to_string(), + data: None, + } + } + Err(e) => { + return ContractRequestResult { + success: false, + message: format!("Database error: {}", e), + data: None, + } + } + }; + + let phase_info = get_phase_description(&contract.phase); + let templates = templates_for_phase(&contract.phase); + let template_names: Vec<String> = templates.iter().map(|t| t.name.clone()).collect(); + + ContractRequestResult { + success: true, + message: format!("Contract is in '{}' phase", contract.phase), + data: Some(json!({ + "phase": contract.phase, + "description": phase_info.0, + "activities": phase_info.1, + "suggestedTemplates": template_names, + "nextPhase": get_next_phase(&contract.phase), + })), + } + } + + ContractToolRequest::SuggestPhaseTransition => { + let contract = match get_contract_with_relations(pool, contract_id, owner_id).await { + Ok(Some(c)) => c, + Ok(None) => { + return ContractRequestResult { + success: false, + message: "Contract not found".to_string(), + data: None, + } + } + Err(e) => { + return ContractRequestResult { + success: false, + message: format!("Database error: {}", e), + data: None, + } + } + }; + + let analysis = analyze_phase_readiness(&contract); + + ContractRequestResult { + success: true, + message: analysis.summary.clone(), + data: Some(json!({ + "currentPhase": contract.contract.phase, + "nextPhase": get_next_phase(&contract.contract.phase), + "ready": analysis.ready, + "summary": analysis.summary, + "reasons": analysis.reasons, + "suggestions": analysis.suggestions, + })), + } + } + + ContractToolRequest::AdvancePhase { new_phase } => { + let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { + Ok(Some(c)) => c, + Ok(None) => { + return ContractRequestResult { + success: false, + message: "Contract not found".to_string(), + data: None, + } + } + Err(e) => { + return ContractRequestResult { + success: false, + message: format!("Database error: {}", e), + data: None, + } + } + }; + + // Validate phase transition + let current_phase = &contract.phase; + let valid_next = get_next_phase(current_phase); + + if valid_next.as_deref() != Some(&new_phase) { + return ContractRequestResult { + success: false, + message: format!( + "Cannot transition from '{}' to '{}'. Next valid phase is: {:?}", + current_phase, new_phase, valid_next + ), + data: None, + }; + } + + // Update phase + match repository::change_contract_phase_for_owner(pool, contract_id, owner_id, &new_phase).await { + Ok(Some(updated)) => { + // Get deliverables for the new phase + let deliverables = crate::llm::get_phase_deliverables(&new_phase); + + // Build suggested files list + let suggested_files: Vec<serde_json::Value> = deliverables + .recommended_files + .iter() + .map(|f| json!({ + "templateId": f.template_id, + "name": f.name_suggestion, + "priority": format!("{:?}", f.priority).to_lowercase(), + "description": f.description, + })) + .collect(); + + ContractRequestResult { + success: true, + message: format!( + "Advanced contract from '{}' to '{}' phase. {}", + current_phase, new_phase, deliverables.guidance + ), + data: Some(json!({ + "previousPhase": current_phase, + "newPhase": updated.phase, + "phaseGuidance": deliverables.guidance, + "suggestedFiles": suggested_files, + "requiresRepository": deliverables.requires_repository, + "requiresTasks": deliverables.requires_tasks, + })), + } + }, + Ok(None) => ContractRequestResult { + success: false, + message: "Failed to update phase".to_string(), + data: None, + }, + Err(e) => ContractRequestResult { + success: false, + message: format!("Failed to update phase: {}", e), + data: None, + }, + } + } + + ContractToolRequest::AddRepository { + repo_type, + name, + url, + is_primary, + } => { + let add_result = match repo_type.as_str() { + "remote" => { + let url = url.unwrap_or_default(); + repository::add_remote_repository( + pool, + contract_id, + &name, + &url, + is_primary, + ) + .await + } + "local" => { + let path = url.unwrap_or_default(); + repository::add_local_repository( + pool, + contract_id, + &name, + &path, + is_primary, + ) + .await + } + "managed" => { + repository::create_managed_repository(pool, contract_id, &name, is_primary) + .await + } + _ => { + return ContractRequestResult { + success: false, + message: format!("Invalid repository type: {}", repo_type), + data: None, + } + } + }; + + match add_result { + Ok(repo) => ContractRequestResult { + success: true, + message: format!("Added {} repository '{}'", repo_type, name), + data: Some(json!({ + "repositoryId": repo.id, + "name": repo.name, + "isPrimary": repo.is_primary, + })), + }, + Err(e) => ContractRequestResult { + success: false, + message: format!("Failed to add repository: {}", e), + data: None, + }, + } + } + + ContractToolRequest::SetPrimaryRepository { repository_id } => { + match repository::set_repository_primary(pool, repository_id, contract_id).await { + Ok(true) => ContractRequestResult { + success: true, + message: "Set repository as primary".to_string(), + data: Some(json!({ + "repositoryId": repository_id, + })), + }, + Ok(false) => ContractRequestResult { + success: false, + message: "Repository not found".to_string(), + data: None, + }, + Err(e) => ContractRequestResult { + success: false, + message: format!("Failed to set primary repository: {}", e), + data: None, + }, + } + } + + // ============================================================================= + // Phase Guidance Handlers + // ============================================================================= + + ContractToolRequest::GetPhaseChecklist => { + match get_contract_with_relations(pool, contract_id, owner_id).await { + Ok(Some(cwr)) => { + let file_infos: Vec<FileInfo> = cwr.files.iter().map(|f| FileInfo { + id: f.id, + name: f.name.clone(), + contract_phase: f.contract_phase.clone(), + }).collect(); + + let task_infos: Vec<TaskInfo> = cwr.tasks.iter().map(|t| TaskInfo { + id: t.id, + name: t.name.clone(), + status: t.status.clone(), + }).collect(); + + let has_repository = !cwr.repositories.is_empty(); + let checklist = get_phase_checklist(&cwr.contract.phase, &file_infos, &task_infos, has_repository); + + ContractRequestResult { + success: true, + message: checklist.summary.clone(), + data: Some(json!({ + "phase": checklist.phase, + "completionPercentage": checklist.completion_percentage, + "deliverables": checklist.file_deliverables, + "hasRepository": checklist.has_repository, + "repositoryRequired": checklist.repository_required, + "taskStats": checklist.task_stats, + "suggestions": checklist.suggestions, + "summary": checklist.summary, + })), + } + } + Ok(None) => ContractRequestResult { + success: false, + message: "Contract not found".to_string(), + data: None, + }, + Err(e) => ContractRequestResult { + success: false, + message: format!("Database error: {}", e), + data: None, + }, + } + } + + // ============================================================================= + // Task Derivation Handlers + // ============================================================================= + + ContractToolRequest::DeriveTasksFromFile { file_id } => { + // First get the file + match repository::get_file_for_owner(pool, file_id, owner_id).await { + Ok(Some(file)) => { + // Verify file belongs to this contract + if file.contract_id != Some(contract_id) { + return ContractRequestResult { + success: false, + message: "File does not belong to this contract".to_string(), + data: None, + }; + } + + // Convert body to markdown for task parsing + let markdown = body_to_markdown(&file.body); + + // Parse tasks from the content + let parse_result = parse_tasks_from_breakdown(&markdown); + + ContractRequestResult { + success: true, + message: format!("Found {} tasks in file '{}'", parse_result.total, file.name), + data: Some(json!({ + "fileId": file_id, + "fileName": file.name, + "tasks": parse_result.tasks, + "groups": parse_result.groups, + "total": parse_result.total, + "warnings": parse_result.warnings, + "formatted": format_parsed_tasks(&parse_result), + })), + } + } + Ok(None) => ContractRequestResult { + success: false, + message: "File not found".to_string(), + data: None, + }, + Err(e) => ContractRequestResult { + success: false, + message: format!("Database error: {}", e), + data: None, + }, + } + } + + ContractToolRequest::CreateChainedTasks { tasks } => { + // Get primary repository for tasks + let repo_url = match get_contract_with_relations(pool, contract_id, owner_id).await { + Ok(Some(contract)) => { + contract + .repositories + .iter() + .find(|r| r.is_primary) + .and_then(|r| r.repository_url.clone().or(r.local_path.clone())) + } + _ => None, + }; + + let mut created_tasks = Vec::new(); + let mut previous_task_id: Option<Uuid> = None; + + for task_def in &tasks { + let create_req = CreateTaskRequest { + contract_id, + name: task_def.name.clone(), + description: None, + plan: task_def.plan.clone(), + parent_task_id: None, + repository_url: repo_url.clone(), + base_branch: None, + target_branch: None, + merge_mode: None, + priority: 0, + target_repo_path: None, + completion_action: None, + continue_from_task_id: previous_task_id, + copy_files: None, + is_supervisor: false, + checkpoint_sha: None, + }; + + match repository::create_task_for_owner(pool, owner_id, create_req).await { + Ok(task) => { + created_tasks.push(json!({ + "taskId": task.id, + "name": task.name, + "status": task.status, + "chainedFrom": previous_task_id, + })); + previous_task_id = Some(task.id); + } + Err(e) => { + return ContractRequestResult { + success: false, + message: format!("Failed to create task '{}': {}", task_def.name, e), + data: Some(json!({ + "createdSoFar": created_tasks, + })), + }; + } + } + } + + ContractRequestResult { + success: true, + message: format!("Created {} chained tasks", created_tasks.len()), + data: Some(json!({ + "tasks": created_tasks, + "total": created_tasks.len(), + })), + } + } + + // ============================================================================= + // Task Completion Processing Handlers + // ============================================================================= + + ContractToolRequest::ProcessTaskCompletion { task_id } => { + // Get the task + match repository::get_task_for_owner(pool, task_id, owner_id).await { + Ok(Some(task)) => { + // Verify task belongs to this contract + if task.contract_id != Some(contract_id) { + return ContractRequestResult { + success: false, + message: "Task does not belong to this contract".to_string(), + data: None, + }; + } + + // Get contract for context + let contract = get_contract_with_relations(pool, contract_id, owner_id).await.ok().flatten(); + + let total_tasks = contract.as_ref().map(|c| c.tasks.len()).unwrap_or(0); + let completed_tasks = contract.as_ref() + .map(|c| c.tasks.iter().filter(|t| t.status == "done").count()) + .unwrap_or(0); + + // Note: Finding next chained task would require querying full Task objects + // Since TaskSummary doesn't have continue_from_task_id, we skip this for now + let next_task: Option<(Uuid, String)> = None; + + // Find Dev Notes file if exists + let dev_notes = if let Some(ref c) = contract { + c.files.iter() + .find(|f| f.name.to_lowercase().contains("dev") && f.name.to_lowercase().contains("notes")) + .map(|f| (f.id, f.name.clone())) + } else { + None + }; + + let contract_phase = contract.as_ref() + .map(|c| c.contract.phase.clone()) + .unwrap_or_else(|| "execute".to_string()); + + // Analyze the task output + let analysis = analyze_task_output( + task_id, + &task.name, + task.last_output.as_deref(), + task.progress_summary.as_deref(), + &contract_phase, + total_tasks, + completed_tasks, + next_task, + dev_notes, + ); + + ContractRequestResult { + success: true, + message: format!("Analyzed completion of task '{}'", task.name), + data: Some(json!({ + "taskId": task_id, + "taskName": task.name, + "taskStatus": task.status, + "summary": analysis.summary, + "filesAffected": analysis.files_affected, + "nextSteps": analysis.next_steps, + "phaseImpact": analysis.phase_impact, + })), + } + } + Ok(None) => ContractRequestResult { + success: false, + message: "Task not found".to_string(), + data: None, + }, + Err(e) => ContractRequestResult { + success: false, + message: format!("Database error: {}", e), + data: None, + }, + } + } + + ContractToolRequest::UpdateFileFromTask { file_id, task_id, section_title } => { + // Get the task + let task = match repository::get_task_for_owner(pool, task_id, owner_id).await { + Ok(Some(t)) => t, + Ok(None) => { + return ContractRequestResult { + success: false, + message: "Task not found".to_string(), + data: None, + }; + } + Err(e) => { + return ContractRequestResult { + success: false, + message: format!("Database error: {}", e), + data: None, + }; + } + }; + + // Get the file + let file = match repository::get_file_for_owner(pool, file_id, owner_id).await { + Ok(Some(f)) => f, + Ok(None) => { + return ContractRequestResult { + success: false, + message: "File not found".to_string(), + data: None, + }; + } + Err(e) => { + return ContractRequestResult { + success: false, + message: format!("Database error: {}", e), + data: None, + }; + } + }; + + // Verify file belongs to this contract + if file.contract_id != Some(contract_id) { + return ContractRequestResult { + success: false, + message: "File does not belong to this contract".to_string(), + data: None, + }; + } + + // Build the section to add + let title = section_title.unwrap_or_else(|| format!("Task: {}", task.name)); + let result_text = task.last_output.as_deref().unwrap_or("Task completed"); + + // Create new body elements to append + let mut new_body = file.body.clone(); + new_body.push(crate::db::models::BodyElement::Heading { + level: 2, + text: title, + }); + new_body.push(crate::db::models::BodyElement::Paragraph { + text: format!("Status: {}", task.status), + }); + new_body.push(crate::db::models::BodyElement::Paragraph { + text: result_text.to_string(), + }); + + // Update the file using UpdateFileRequest + let update_req = UpdateFileRequest { + name: None, + description: None, + transcript: None, + summary: None, + body: Some(new_body), + version: None, // Don't require version for this update + repo_file_path: None, + }; + + match repository::update_file_for_owner(pool, file_id, owner_id, update_req).await { + Ok(Some(updated_file)) => { + ContractRequestResult { + success: true, + message: format!("Updated file '{}' with task summary", file.name), + data: Some(json!({ + "fileId": file_id, + "fileName": updated_file.name, + "taskId": task_id, + "taskName": task.name, + })), + } + } + Ok(None) => ContractRequestResult { + success: false, + message: "Failed to update file".to_string(), + data: None, + }, + Err(e) => ContractRequestResult { + success: false, + message: format!("Database error: {}", e), + data: None, + }, + } + } + } +} + +/// Get description and activities for a phase +fn get_phase_description(phase: &str) -> (String, Vec<String>) { + match phase { + "research" => ( + "Gather information, analyze competitors, and understand user needs".to_string(), + vec![ + "Conduct user research".to_string(), + "Analyze competitors".to_string(), + "Document findings".to_string(), + "Identify opportunities".to_string(), + ], + ), + "specify" => ( + "Define requirements, user stories, and acceptance criteria".to_string(), + vec![ + "Write requirements".to_string(), + "Create user stories".to_string(), + "Define acceptance criteria".to_string(), + "Document constraints".to_string(), + ], + ), + "plan" => ( + "Design architecture, create task breakdowns, and technical designs".to_string(), + vec![ + "Design system architecture".to_string(), + "Create technical specifications".to_string(), + "Break down into tasks".to_string(), + "Plan implementation order".to_string(), + ], + ), + "execute" => ( + "Implement features, write code, and run tasks".to_string(), + vec![ + "Implement features".to_string(), + "Write tests".to_string(), + "Track progress".to_string(), + "Document implementation details".to_string(), + ], + ), + "review" => ( + "Review work, create release notes, and conduct retrospectives".to_string(), + vec![ + "Review code and features".to_string(), + "Create release notes".to_string(), + "Conduct retrospective".to_string(), + "Document learnings".to_string(), + ], + ), + _ => ( + "Unknown phase".to_string(), + vec![], + ), + } +} + +/// Get the next phase in the lifecycle +fn get_next_phase(current: &str) -> Option<String> { + match current { + "research" => Some("specify".to_string()), + "specify" => Some("plan".to_string()), + "plan" => Some("execute".to_string()), + "execute" => Some("review".to_string()), + "review" => None, // Final phase + _ => None, + } +} + +/// Phase readiness analysis result +struct PhaseReadinessAnalysis { + ready: bool, + summary: String, + reasons: Vec<String>, + suggestions: Vec<String>, +} + +/// Analyze if the contract is ready to transition to the next phase +fn analyze_phase_readiness(contract: &crate::db::models::ContractWithRelations) -> PhaseReadinessAnalysis { + let mut reasons = Vec::new(); + let mut suggestions = Vec::new(); + + match contract.contract.phase.as_str() { + "research" => { + // Check for research files + let research_files = contract.files.iter() + .filter(|f| f.contract_phase.as_deref() == Some("research")) + .count(); + + if research_files == 0 { + reasons.push("No research documents created yet".to_string()); + suggestions.push("Create research notes or competitor analysis documents".to_string()); + } else { + reasons.push(format!("{} research document(s) created", research_files)); + } + + let ready = research_files > 0; + PhaseReadinessAnalysis { + ready, + summary: if ready { + "Research phase has documentation. Consider transitioning to Specify phase.".to_string() + } else { + "Research phase needs more documentation before transitioning.".to_string() + }, + reasons, + suggestions, + } + } + "specify" => { + let spec_files = contract.files.iter() + .filter(|f| f.contract_phase.as_deref() == Some("specify")) + .count(); + + if spec_files == 0 { + reasons.push("No specification documents created yet".to_string()); + suggestions.push("Create requirements or user stories documents".to_string()); + } else { + reasons.push(format!("{} specification document(s) created", spec_files)); + } + + let ready = spec_files > 0; + PhaseReadinessAnalysis { + ready, + summary: if ready { + "Specification phase has documentation. Consider transitioning to Plan phase.".to_string() + } else { + "Specification phase needs requirements or user stories.".to_string() + }, + reasons, + suggestions, + } + } + "plan" => { + let plan_files = contract.files.iter() + .filter(|f| f.contract_phase.as_deref() == Some("plan")) + .count(); + + let has_repos = !contract.repositories.is_empty(); + + if plan_files == 0 { + reasons.push("No planning documents created yet".to_string()); + suggestions.push("Create architecture or task breakdown documents".to_string()); + } else { + reasons.push(format!("{} planning document(s) created", plan_files)); + } + + if !has_repos { + reasons.push("No repositories configured".to_string()); + suggestions.push("Add a repository for task execution".to_string()); + } else { + reasons.push(format!("{} repository(ies) configured", contract.repositories.len())); + } + + let ready = plan_files > 0 && has_repos; + PhaseReadinessAnalysis { + ready, + summary: if ready { + "Planning phase complete with documents and repositories. Ready for Execute phase.".to_string() + } else { + "Planning phase needs documentation and/or repository configuration.".to_string() + }, + reasons, + suggestions, + } + } + "execute" => { + let total_tasks = contract.tasks.len(); + let done_tasks = contract.tasks.iter().filter(|t| t.status == "done").count(); + let running_tasks = contract.tasks.iter().filter(|t| t.status == "running").count(); + + if total_tasks == 0 { + reasons.push("No tasks created yet".to_string()); + suggestions.push("Create tasks to implement the planned work".to_string()); + } else { + reasons.push(format!("{} of {} tasks completed", done_tasks, total_tasks)); + } + + if running_tasks > 0 { + reasons.push(format!("{} task(s) still running", running_tasks)); + suggestions.push("Wait for running tasks to complete".to_string()); + } + + let ready = total_tasks > 0 && done_tasks == total_tasks; + PhaseReadinessAnalysis { + ready, + summary: if ready { + "All tasks completed. Ready for Review phase.".to_string() + } else if total_tasks == 0 { + "No tasks created yet. Create and complete tasks before reviewing.".to_string() + } else { + format!("{}/{} tasks complete. Finish remaining tasks before review.", done_tasks, total_tasks) + }, + reasons, + suggestions, + } + } + "review" => { + let review_files = contract.files.iter() + .filter(|f| f.contract_phase.as_deref() == Some("review")) + .count(); + + if review_files == 0 { + suggestions.push("Create review checklist or release notes".to_string()); + } + + PhaseReadinessAnalysis { + ready: false, + summary: "Review is the final phase. Contract can be marked as complete when review is done.".to_string(), + reasons: vec!["Review phase is the final phase".to_string()], + suggestions, + } + } + _ => PhaseReadinessAnalysis { + ready: false, + summary: "Unknown phase".to_string(), + reasons: vec!["Phase not recognized".to_string()], + suggestions: vec![], + }, + } +} + +// ============================================================================= +// Contract Chat History Endpoints +// ============================================================================= + +/// Get contract chat history +#[utoipa::path( + get, + path = "/api/v1/contracts/{id}/chat/history", + responses( + (status = 200, description = "Chat history retrieved successfully", body = ContractChatHistoryResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Contract not found"), + (status = 500, description = "Internal server error") + ), + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Contracts" +)] +pub async fn get_contract_chat_history( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(contract_id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ "error": "Database not configured" })), + ) + .into_response(); + }; + + // Verify contract exists and belongs to owner + match repository::get_contract_for_owner(pool, contract_id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(json!({ "error": "Contract not found" })), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Database error: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("Database error: {}", e) })), + ) + .into_response(); + } + } + + // Get or create conversation + let conversation = match repository::get_or_create_contract_conversation(pool, contract_id, auth.owner_id).await { + Ok(conv) => conv, + Err(e) => { + tracing::error!("Failed to get contract conversation: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("Failed to get conversation: {}", e) })), + ) + .into_response(); + } + }; + + // Get messages + let messages = match repository::list_contract_chat_messages(pool, conversation.id, Some(100)).await { + Ok(msgs) => msgs, + Err(e) => { + tracing::error!("Failed to list contract chat messages: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("Failed to list messages: {}", e) })), + ) + .into_response(); + } + }; + + ( + StatusCode::OK, + Json(ContractChatHistoryResponse { + contract_id, + conversation_id: conversation.id, + messages, + }), + ) + .into_response() +} + +/// Clear contract chat history (creates a new conversation) +#[utoipa::path( + delete, + path = "/api/v1/contracts/{id}/chat/history", + responses( + (status = 200, description = "Chat history cleared successfully"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Contract not found"), + (status = 500, description = "Internal server error") + ), + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Contracts" +)] +pub async fn clear_contract_chat_history( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(contract_id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ "error": "Database not configured" })), + ) + .into_response(); + }; + + // Verify contract exists and belongs to owner + match repository::get_contract_for_owner(pool, contract_id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(json!({ "error": "Contract not found" })), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Database error: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("Database error: {}", e) })), + ) + .into_response(); + } + } + + // Clear conversation (archives existing and creates new) + match repository::clear_contract_conversation(pool, contract_id, auth.owner_id).await { + Ok(new_conversation) => { + ( + StatusCode::OK, + Json(json!({ + "message": "Chat history cleared", + "newConversationId": new_conversation.id + })), + ) + .into_response() + } + Err(e) => { + tracing::error!("Failed to clear contract conversation: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("Failed to clear history: {}", e) })), + ) + .into_response() + } + } +} diff --git a/makima/src/server/handlers/contract_daemon.rs b/makima/src/server/handlers/contract_daemon.rs new file mode 100644 index 0000000..13c5640 --- /dev/null +++ b/makima/src/server/handlers/contract_daemon.rs @@ -0,0 +1,960 @@ +//! HTTP handlers for daemon-to-contract interaction. +//! +//! These endpoints allow tasks running in daemons to interact with their +//! associated contracts via the contract.sh script. Authentication is via +//! tool keys registered by the daemon when starting a task. + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::db::{models::FileSummary, repository}; +use crate::llm::phase_guidance::{self, FileInfo, PhaseChecklist, TaskInfo}; +use crate::server::auth::Authenticated; +use crate::server::messages::ApiError; +use crate::server::state::SharedState; + +// ============================================================================= +// Request/Response Types +// ============================================================================= + +/// Contract status response for daemon. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ContractStatusResponse { + pub id: Uuid, + pub name: String, + pub phase: String, + pub status: String, + pub description: Option<String>, +} + +/// Contract goals response. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ContractGoalsResponse { + /// Description serves as goals for the contract + pub description: Option<String>, + pub phase: String, + pub phase_guidance: String, +} + +/// Progress report request from daemon. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ProgressReportRequest { + pub message: String, + #[serde(default)] + pub task_id: Option<Uuid>, +} + +/// Suggested action from server. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct SuggestedActionResponse { + pub action: String, + pub description: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option<serde_json::Value>, +} + +/// Completion action request. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CompletionActionRequest { + #[serde(default)] + pub task_id: Option<Uuid>, + #[serde(default)] + pub files_modified: Vec<String>, + #[serde(default)] + pub lines_added: i32, + #[serde(default)] + pub lines_removed: i32, + #[serde(default)] + pub has_code_changes: bool, +} + +/// Recommended completion action. +#[derive(Debug, Clone, Serialize, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum CompletionAction { + Branch, + Merge, + Pr, + None, +} + +impl std::fmt::Display for CompletionAction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CompletionAction::Branch => write!(f, "branch"), + CompletionAction::Merge => write!(f, "merge"), + CompletionAction::Pr => write!(f, "pr"), + CompletionAction::None => write!(f, "none"), + } + } +} + +/// Completion action response. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CompletionActionResponse { + pub action: String, + pub reason: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub branch_name: Option<String>, +} + +/// Create file request from daemon. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateFileRequest { + pub name: String, + pub content: String, + #[serde(default)] + pub template_id: Option<String>, +} + +/// Update file request from daemon. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DaemonUpdateFileRequest { + /// Content to update in the file (as markdown body element) + pub content: String, +} + +// ============================================================================= +// Handlers +// ============================================================================= + +/// Get contract status for daemon. +#[utoipa::path( + get, + path = "/api/v1/contracts/{id}/daemon/status", + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + responses( + (status = 200, description = "Contract status", body = ContractStatusResponse), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security( + ("tool_key" = []), + ("api_key" = []) + ), + tag = "Contract Daemon" +)] +pub async fn get_contract_status( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(contract)) => Json(ContractStatusResponse { + id: contract.id, + name: contract.name, + phase: contract.phase, + status: contract.status, + description: contract.description, + }) + .into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Get phase deliverables checklist. +#[utoipa::path( + get, + path = "/api/v1/contracts/{id}/daemon/checklist", + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + responses( + (status = 200, description = "Phase checklist", body = PhaseChecklist), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security( + ("tool_key" = []), + ("api_key" = []) + ), + tag = "Contract Daemon" +)] +pub async fn get_contract_checklist( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Get contract + let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(c)) => c, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + }; + + // Get files for this contract + let files = match repository::list_files_in_contract(pool, id, auth.owner_id).await { + Ok(f) => f + .into_iter() + .map(|f| FileInfo { + id: f.id, + name: f.name, + contract_phase: f.contract_phase, + }) + .collect::<Vec<_>>(), + Err(e) => { + tracing::warn!("Failed to get files for contract {}: {}", id, e); + Vec::new() + } + }; + + // Get tasks for this contract + let tasks = match repository::list_tasks_in_contract(pool, id, auth.owner_id).await { + Ok(t) => t + .into_iter() + .map(|t| TaskInfo { + id: t.id, + name: t.name, + status: t.status, + }) + .collect::<Vec<_>>(), + Err(e) => { + tracing::warn!("Failed to get tasks for contract {}: {}", id, e); + Vec::new() + } + }; + + // Check if repository is configured + let has_repository = match repository::list_contract_repositories(pool, id).await { + Ok(repos) => !repos.is_empty(), + Err(_) => false, + }; + + let checklist = phase_guidance::get_phase_checklist(&contract.phase, &files, &tasks, has_repository); + + Json(checklist).into_response() +} + +/// Get contract goals. +#[utoipa::path( + get, + path = "/api/v1/contracts/{id}/daemon/goals", + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + responses( + (status = 200, description = "Contract goals", body = ContractGoalsResponse), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security( + ("tool_key" = []), + ("api_key" = []) + ), + tag = "Contract Daemon" +)] +pub async fn get_contract_goals( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(contract)) => { + let deliverables = phase_guidance::get_phase_deliverables(&contract.phase); + Json(ContractGoalsResponse { + description: contract.description, + phase: contract.phase, + phase_guidance: deliverables.guidance, + }) + .into_response() + } + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Post progress report to contract. +#[utoipa::path( + post, + path = "/api/v1/contracts/{id}/daemon/report", + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + request_body = ProgressReportRequest, + responses( + (status = 200, description = "Report received"), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security( + ("tool_key" = []), + ("api_key" = []) + ), + tag = "Contract Daemon" +)] +pub async fn post_progress_report( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<ProgressReportRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify contract exists + match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + // Log the report as a contract event + let event_type = "progress_report"; + let payload = serde_json::json!({ + "message": req.message, + "task_id": req.task_id, + }); + + if let Err(e) = repository::record_contract_event(pool, id, event_type, Some(payload)).await { + tracing::warn!("Failed to create contract event: {}", e); + } + + Json(serde_json::json!({"status": "received"})).into_response() +} + +/// Get suggested action based on contract state. +#[utoipa::path( + post, + path = "/api/v1/contracts/{id}/daemon/suggest-action", + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + responses( + (status = 200, description = "Suggested action", body = SuggestedActionResponse), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security( + ("tool_key" = []), + ("api_key" = []) + ), + tag = "Contract Daemon" +)] +pub async fn get_suggest_action( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Get contract + let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(c)) => c, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + }; + + // Get files and tasks for checklist + let files = repository::list_files_in_contract(pool, id, auth.owner_id) + .await + .unwrap_or_default() + .into_iter() + .map(|f| FileInfo { + id: f.id, + name: f.name, + contract_phase: f.contract_phase, + }) + .collect::<Vec<_>>(); + + let tasks = repository::list_tasks_in_contract(pool, id, auth.owner_id) + .await + .unwrap_or_default() + .into_iter() + .map(|t| TaskInfo { + id: t.id, + name: t.name, + status: t.status, + }) + .collect::<Vec<_>>(); + + let has_repository = repository::list_contract_repositories(pool, id) + .await + .map(|r| !r.is_empty()) + .unwrap_or(false); + + let checklist = phase_guidance::get_phase_checklist(&contract.phase, &files, &tasks, has_repository); + + // Determine suggested action based on checklist + let (action, description) = if !checklist.suggestions.is_empty() { + ("follow_suggestion", checklist.suggestions.first().unwrap().clone()) + } else if checklist.completion_percentage >= 100 { + ("advance_phase", format!("Phase {} is complete, consider advancing to next phase", contract.phase)) + } else { + ("continue", format!("Continue working on {} phase ({}% complete)", contract.phase, checklist.completion_percentage)) + }; + + Json(SuggestedActionResponse { + action: action.to_string(), + description, + data: None, + }) + .into_response() +} + +/// Get recommended completion action. +#[utoipa::path( + post, + path = "/api/v1/contracts/{id}/daemon/completion-action", + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + request_body = CompletionActionRequest, + responses( + (status = 200, description = "Recommended completion action", body = CompletionActionResponse), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security( + ("tool_key" = []), + ("api_key" = []) + ), + tag = "Contract Daemon" +)] +pub async fn get_completion_action( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<CompletionActionRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Get contract + let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(c)) => c, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + }; + + // Determine completion action based on phase and changes + let has_changes = !req.files_modified.is_empty() || req.lines_added > 0 || req.lines_removed > 0; + let has_significant_changes = req.lines_added + req.lines_removed > 50; + + let (action, reason) = match contract.phase.as_str() { + "research" | "specify" => { + if has_changes { + (CompletionAction::Merge, "Early phase changes can be merged directly".to_string()) + } else { + (CompletionAction::None, "No changes to commit".to_string()) + } + } + "plan" => { + if has_significant_changes { + (CompletionAction::Pr, "Significant planning changes require review".to_string()) + } else if has_changes { + (CompletionAction::Merge, "Minor planning changes can be merged".to_string()) + } else { + (CompletionAction::None, "No changes to commit".to_string()) + } + } + "execute" => { + if req.has_code_changes { + (CompletionAction::Pr, "Code changes in execute phase require review".to_string()) + } else if has_changes { + (CompletionAction::Branch, "Documentation changes can be branched".to_string()) + } else { + (CompletionAction::None, "No changes to commit".to_string()) + } + } + "review" => { + if has_changes { + (CompletionAction::Pr, "Review phase changes should be reviewed".to_string()) + } else { + (CompletionAction::None, "No changes to commit".to_string()) + } + } + _ => (CompletionAction::None, "Unknown phase".to_string()), + }; + + // Generate branch name based on contract + let branch_name = if matches!(action, CompletionAction::Branch | CompletionAction::Pr) { + let slug = contract.name.to_lowercase().replace(' ', "-"); + Some(format!("contract/{}", slug)) + } else { + None + }; + + Json(CompletionActionResponse { + action: action.to_string(), + reason, + branch_name, + }) + .into_response() +} + +/// List contract files for daemon. +#[utoipa::path( + get, + path = "/api/v1/contracts/{id}/daemon/files", + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + responses( + (status = 200, description = "List of contract files", body = Vec<FileSummary>), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security( + ("tool_key" = []), + ("api_key" = []) + ), + tag = "Contract Daemon" +)] +pub async fn list_contract_files( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify contract exists + match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::list_files_in_contract(pool, id, auth.owner_id).await { + Ok(files) => Json(files).into_response(), + Err(e) => { + tracing::error!("Failed to list files for contract {}: {}", id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Get a specific contract file. +#[utoipa::path( + get, + path = "/api/v1/contracts/{id}/daemon/files/{file_id}", + params( + ("id" = Uuid, Path, description = "Contract ID"), + ("file_id" = Uuid, Path, description = "File ID") + ), + responses( + (status = 200, description = "File content"), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract or file not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security( + ("tool_key" = []), + ("api_key" = []) + ), + tag = "Contract Daemon" +)] +pub async fn get_contract_file( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, file_id)): Path<(Uuid, Uuid)>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify contract exists + match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + // Get file and verify it belongs to this contract + match repository::get_file_for_owner(pool, file_id, auth.owner_id).await { + Ok(Some(file)) => { + if file.contract_id != Some(id) { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "File not found in this contract")), + ) + .into_response(); + } + Json(file).into_response() + } + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "File not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to get file {}: {}", file_id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Update a contract file. +#[utoipa::path( + put, + path = "/api/v1/contracts/{id}/daemon/files/{file_id}", + params( + ("id" = Uuid, Path, description = "Contract ID"), + ("file_id" = Uuid, Path, description = "File ID") + ), + request_body = DaemonUpdateFileRequest, + responses( + (status = 200, description = "File updated"), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract or file not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security( + ("tool_key" = []), + ("api_key" = []) + ), + tag = "Contract Daemon" +)] +pub async fn update_contract_file( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, file_id)): Path<(Uuid, Uuid)>, + Json(req): Json<DaemonUpdateFileRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify contract exists + match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + // Get file and verify it belongs to this contract + let file = match repository::get_file_for_owner(pool, file_id, auth.owner_id).await { + Ok(Some(f)) => f, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "File not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get file {}: {}", file_id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + }; + + if file.contract_id != Some(id) { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "File not found in this contract")), + ) + .into_response(); + } + + // Update the file with content parsed as markdown + let body = crate::llm::markdown_to_body(&req.content); + let update_req = crate::db::models::UpdateFileRequest { + name: None, + description: None, + transcript: None, + summary: None, + body: Some(body), + version: None, + repo_file_path: None, + }; + + match repository::update_file_for_owner(pool, file_id, auth.owner_id, update_req).await { + Ok(Some(updated)) => Json(updated).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "File not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to update file {}: {}", file_id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", format!("{}", e))), + ) + .into_response() + } + } +} + +/// Create a new contract file. +#[utoipa::path( + post, + path = "/api/v1/contracts/{id}/daemon/files", + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + request_body = CreateFileRequest, + responses( + (status = 201, description = "File created"), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security( + ("tool_key" = []), + ("api_key" = []) + ), + tag = "Contract Daemon" +)] +pub async fn create_contract_file( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<CreateFileRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify contract exists + match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + }; + + // Create the file with content parsed as markdown + let body = crate::llm::markdown_to_body(&req.content); + let create_req = crate::db::models::CreateFileRequest { + contract_id: id, + name: Some(req.name), + description: None, + transcript: vec![], + location: None, + body, + repo_file_path: None, + contract_phase: None, // Will be looked up from contract's current phase + }; + + match repository::create_file_for_owner(pool, auth.owner_id, create_req).await { + Ok(file) => (StatusCode::CREATED, Json(file)).into_response(), + Err(e) => { + tracing::error!("Failed to create file for contract {}: {}", id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} diff --git a/makima/src/server/handlers/contracts.rs b/makima/src/server/handlers/contracts.rs new file mode 100644 index 0000000..3d726df --- /dev/null +++ b/makima/src/server/handlers/contracts.rs @@ -0,0 +1,1284 @@ +//! HTTP handlers for contract CRUD operations. + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use uuid::Uuid; + +use crate::db::models::{ + AddLocalRepositoryRequest, AddRemoteRepositoryRequest, ChangePhaseRequest, + ContractListResponse, ContractRepository, ContractSummary, ContractWithRelations, + CreateContractRequest, CreateManagedRepositoryRequest, UpdateContractRequest, + UpdateTaskRequest, +}; +use crate::db::repository::{self, RepositoryError}; +use crate::server::auth::Authenticated; +use crate::server::messages::ApiError; +use crate::server::state::SharedState; + +/// Helper function to update the supervisor task with repository info when a primary repo is added. +/// This ensures the supervisor has access to the repository when it starts. +async fn update_supervisor_with_repo_if_needed( + pool: &sqlx::PgPool, + contract_id: uuid::Uuid, + owner_id: uuid::Uuid, + repo: &ContractRepository, +) { + // Only update for primary repositories + if !repo.is_primary { + return; + } + + // Get the supervisor task + let supervisor = match repository::get_contract_supervisor_task(pool, contract_id).await { + Ok(Some(s)) => s, + Ok(None) => { + tracing::debug!(contract_id = %contract_id, "No supervisor task found"); + return; + } + Err(e) => { + tracing::warn!(contract_id = %contract_id, error = %e, "Failed to get supervisor task"); + return; + } + }; + + // Only update if supervisor doesn't have a repository URL yet + if supervisor.repository_url.is_some() { + tracing::debug!( + supervisor_id = %supervisor.id, + "Supervisor already has repository URL" + ); + return; + } + + // Get repository URL (for remote repos) or local path (for local repos) + let repo_url = repo.repository_url.clone().or_else(|| repo.local_path.clone()); + + if repo_url.is_none() && repo.source_type != "managed" { + tracing::debug!( + supervisor_id = %supervisor.id, + "Repository has no URL or path to assign" + ); + return; + } + + // Update supervisor task with repository info + let update_req = UpdateTaskRequest { + repository_url: repo_url, + version: Some(supervisor.version), + ..Default::default() + }; + + match repository::update_task_for_owner(pool, supervisor.id, owner_id, update_req).await { + Ok(Some(updated)) => { + tracing::info!( + supervisor_id = %updated.id, + repository_url = ?updated.repository_url, + "Updated supervisor task with repository URL" + ); + } + Ok(None) => { + tracing::warn!(supervisor_id = %supervisor.id, "Supervisor task not found during update"); + } + Err(e) => { + tracing::warn!( + supervisor_id = %supervisor.id, + error = %e, + "Failed to update supervisor with repository URL" + ); + } + } +} + +/// List all root contracts (no parent) for the authenticated user's owner. +#[utoipa::path( + get, + path = "/api/v1/contracts", + responses( + (status = 200, description = "List of root contracts", body = ContractListResponse), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Contracts" +)] +pub async fn list_contracts( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::list_contracts_for_owner(pool, auth.owner_id).await { + Ok(contracts) => { + let total = contracts.len() as i64; + Json(ContractListResponse { contracts, total }).into_response() + } + Err(e) => { + tracing::error!("Failed to list contracts: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Get a contract by ID with all its relations (repositories, files, tasks). +#[utoipa::path( + get, + path = "/api/v1/contracts/{id}", + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + responses( + (status = 200, description = "Contract details with relations", body = ContractWithRelations), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Contracts" +)] +pub async fn get_contract( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Get the contract + let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(c)) => c, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + }; + + // Get repositories + let repositories = match repository::list_contract_repositories(pool, id).await { + Ok(r) => r, + Err(e) => { + tracing::warn!("Failed to get repositories for {}: {}", id, e); + Vec::new() + } + }; + + // Get files + let files = match repository::list_files_in_contract(pool, id, auth.owner_id).await { + Ok(f) => f, + Err(e) => { + tracing::warn!("Failed to get files for contract {}: {}", id, e); + Vec::new() + } + }; + + // Get tasks + let tasks = match repository::list_tasks_in_contract(pool, id, auth.owner_id).await { + Ok(t) => t, + Err(e) => { + tracing::warn!("Failed to get tasks for contract {}: {}", id, e); + Vec::new() + } + }; + + Json(ContractWithRelations { + contract, + repositories, + files, + tasks, + }) + .into_response() +} + +/// Create a new contract. +#[utoipa::path( + post, + path = "/api/v1/contracts", + request_body = CreateContractRequest, + responses( + (status = 201, description = "Contract created", body = ContractSummary), + (status = 400, description = "Invalid request", body = ApiError), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Contracts" +)] +pub async fn create_contract( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Json(req): Json<CreateContractRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::create_contract_for_owner(pool, auth.owner_id, req.clone()).await { + Ok(contract) => { + // Create supervisor task for this contract + let supervisor_name = format!("{} Supervisor", contract.name); + let supervisor_plan = format!( + "You are the supervisor for contract '{}'. Your goal is to drive this contract to completion.\n\n{}", + contract.name, + contract.description.as_deref().unwrap_or("No description provided.") + ); + + // Get repository info from contract if available + let repo_url = { + // Try to get the first repository associated with this contract + match repository::list_contract_repositories(pool, contract.id).await { + Ok(repos) if !repos.is_empty() => { + let repo = &repos[0]; + repo.repository_url.clone() + } + _ => None, + } + }; + + let supervisor_req = crate::db::models::CreateTaskRequest { + name: supervisor_name, + description: None, + plan: supervisor_plan, + repository_url: repo_url, + base_branch: None, + target_branch: None, + parent_task_id: None, + contract_id: contract.id, + target_repo_path: None, + completion_action: None, + continue_from_task_id: None, + copy_files: None, + is_supervisor: true, + checkpoint_sha: None, + priority: 0, + merge_mode: None, + }; + + match repository::create_task_for_owner(pool, auth.owner_id, supervisor_req).await { + Ok(supervisor_task) => { + tracing::info!( + contract_id = %contract.id, + supervisor_task_id = %supervisor_task.id, + is_supervisor = supervisor_task.is_supervisor, + "Created supervisor task for contract" + ); + + // Update contract with supervisor_task_id + let update_req = crate::db::models::UpdateContractRequest { + supervisor_task_id: Some(supervisor_task.id), + version: Some(contract.version), + ..Default::default() + }; + if let Err(e) = repository::update_contract_for_owner(pool, contract.id, auth.owner_id, update_req).await { + tracing::warn!( + contract_id = %contract.id, + error = %e, + "Failed to link supervisor task to contract" + ); + } + } + Err(e) => { + tracing::warn!( + contract_id = %contract.id, + error = %e, + "Failed to create supervisor task for contract" + ); + } + } + + // Get the summary version with counts + match repository::get_contract_summary_for_owner(pool, contract.id, auth.owner_id).await + { + Ok(Some(summary)) => (StatusCode::CREATED, Json(summary)).into_response(), + Ok(None) => { + // Shouldn't happen, but return basic info if it does + ( + StatusCode::CREATED, + Json(ContractSummary { + id: contract.id, + name: contract.name, + description: contract.description, + phase: contract.phase, + status: contract.status, + file_count: 0, + task_count: 0, + repository_count: 0, + version: contract.version, + created_at: contract.created_at, + }), + ) + .into_response() + } + Err(e) => { + tracing::warn!("Failed to get contract summary: {}", e); + ( + StatusCode::CREATED, + Json(ContractSummary { + id: contract.id, + name: contract.name, + description: contract.description, + phase: contract.phase, + status: contract.status, + file_count: 0, + task_count: 0, + repository_count: 0, + version: contract.version, + created_at: contract.created_at, + }), + ) + .into_response() + } + } + } + Err(e) => { + tracing::error!("Failed to create contract: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Update a contract. +#[utoipa::path( + put, + path = "/api/v1/contracts/{id}", + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + request_body = UpdateContractRequest, + responses( + (status = 200, description = "Contract updated", body = ContractSummary), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract not found", body = ApiError), + (status = 409, description = "Version conflict", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Contracts" +)] +pub async fn update_contract( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<UpdateContractRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::update_contract_for_owner(pool, id, auth.owner_id, req).await { + Ok(Some(contract)) => { + // If contract is completed, stop the supervisor task + if contract.status == "completed" { + if let Some(supervisor_task_id) = contract.supervisor_task_id { + // Get the supervisor task to find its daemon + if let Ok(Some(supervisor)) = repository::get_task_for_owner(pool, supervisor_task_id, auth.owner_id).await { + if let Some(daemon_id) = supervisor.daemon_id { + let state_clone = state.clone(); + tokio::spawn(async move { + // Gracefully interrupt the supervisor + let cmd = crate::server::state::DaemonCommand::InterruptTask { + task_id: supervisor_task_id, + graceful: true, + }; + if let Err(e) = state_clone.send_daemon_command(daemon_id, cmd).await { + tracing::warn!( + supervisor_task_id = %supervisor_task_id, + daemon_id = %daemon_id, + error = %e, + "Failed to stop supervisor task on contract completion" + ); + } else { + tracing::info!( + supervisor_task_id = %supervisor_task_id, + contract_id = %id, + "Stopped supervisor task on contract completion" + ); + } + }); + } + } + } + } + + // Get summary with counts + match repository::get_contract_summary_for_owner(pool, contract.id, auth.owner_id).await + { + Ok(Some(summary)) => Json(summary).into_response(), + _ => Json(ContractSummary { + id: contract.id, + name: contract.name, + description: contract.description, + phase: contract.phase, + status: contract.status, + file_count: 0, + task_count: 0, + repository_count: 0, + version: contract.version, + created_at: contract.created_at, + }) + .into_response(), + } + } + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(), + Err(RepositoryError::VersionConflict { expected, actual }) => { + tracing::info!( + "Version conflict on contract {}: expected {}, actual {}", + id, + expected, + actual + ); + ( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "code": "VERSION_CONFLICT", + "message": format!( + "Contract was modified. Expected version {}, actual version {}", + expected, actual + ), + "expectedVersion": expected, + "actualVersion": actual, + })), + ) + .into_response() + } + Err(RepositoryError::Database(e)) => { + tracing::error!("Failed to update contract {}: {}", id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Delete a contract. +#[utoipa::path( + delete, + path = "/api/v1/contracts/{id}", + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + responses( + (status = 204, description = "Contract deleted"), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Contracts" +)] +pub async fn delete_contract( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::delete_contract_for_owner(pool, id, auth.owner_id).await { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to delete contract {}: {}", id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= +// Repository Management +// ============================================================================= + +/// Add a remote repository to a contract. +#[utoipa::path( + post, + path = "/api/v1/contracts/{id}/repositories/remote", + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + request_body = AddRemoteRepositoryRequest, + responses( + (status = 201, description = "Repository added", body = ContractRepository), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Contracts" +)] +pub async fn add_remote_repository( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<AddRemoteRepositoryRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify contract exists and belongs to owner + match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::add_remote_repository(pool, id, &req.name, &req.repository_url, req.is_primary) + .await + { + Ok(repo) => { + // Update supervisor task with repository info if this is a primary repo + update_supervisor_with_repo_if_needed(pool, id, auth.owner_id, &repo).await; + (StatusCode::CREATED, Json(repo)).into_response() + } + Err(e) => { + tracing::error!("Failed to add remote repository to contract {}: {}", id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Add a local repository to a contract. +#[utoipa::path( + post, + path = "/api/v1/contracts/{id}/repositories/local", + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + request_body = AddLocalRepositoryRequest, + responses( + (status = 201, description = "Repository added", body = ContractRepository), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Contracts" +)] +pub async fn add_local_repository( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<AddLocalRepositoryRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify contract exists and belongs to owner + match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::add_local_repository(pool, id, &req.name, &req.local_path, req.is_primary) + .await + { + Ok(repo) => { + // Update supervisor task with repository info if this is a primary repo + update_supervisor_with_repo_if_needed(pool, id, auth.owner_id, &repo).await; + (StatusCode::CREATED, Json(repo)).into_response() + } + Err(e) => { + tracing::error!("Failed to add local repository to contract {}: {}", id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Create a managed repository (daemon will create it). +#[utoipa::path( + post, + path = "/api/v1/contracts/{id}/repositories/managed", + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + request_body = CreateManagedRepositoryRequest, + responses( + (status = 201, description = "Repository creation requested", body = ContractRepository), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Contracts" +)] +pub async fn create_managed_repository( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<CreateManagedRepositoryRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify contract exists and belongs to owner + match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::create_managed_repository(pool, id, &req.name, req.is_primary).await { + Ok(repo) => { + // For managed repos, the daemon will create the repo and we'll update later + // For now, just mark that this is a managed repo configuration + // The helper handles the case where repo has no URL yet + update_supervisor_with_repo_if_needed(pool, id, auth.owner_id, &repo).await; + (StatusCode::CREATED, Json(repo)).into_response() + } + Err(e) => { + tracing::error!( + "Failed to create managed repository for contract {}: {}", + id, + e + ); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Delete a repository from a contract. +#[utoipa::path( + delete, + path = "/api/v1/contracts/{id}/repositories/{repo_id}", + params( + ("id" = Uuid, Path, description = "Contract ID"), + ("repo_id" = Uuid, Path, description = "Repository ID") + ), + responses( + (status = 204, description = "Repository removed"), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract or repository not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Contracts" +)] +pub async fn delete_repository( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, repo_id)): Path<(Uuid, Uuid)>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify contract exists and belongs to owner + match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::delete_contract_repository(pool, repo_id, id).await { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Repository not found")), + ) + .into_response(), + Err(e) => { + tracing::error!( + "Failed to delete repository {} from contract {}: {}", + repo_id, + id, + e + ); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Set a repository as primary for a contract. +#[utoipa::path( + put, + path = "/api/v1/contracts/{id}/repositories/{repo_id}/primary", + params( + ("id" = Uuid, Path, description = "Contract ID"), + ("repo_id" = Uuid, Path, description = "Repository ID") + ), + responses( + (status = 204, description = "Repository set as primary"), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract or repository not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Contracts" +)] +pub async fn set_repository_primary( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, repo_id)): Path<(Uuid, Uuid)>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify contract exists and belongs to owner + match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::set_repository_primary(pool, repo_id, id).await { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Repository not found")), + ) + .into_response(), + Err(e) => { + tracing::error!( + "Failed to set repository {} as primary for contract {}: {}", + repo_id, + id, + e + ); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= +// Task Association +// ============================================================================= + +/// Add a task to a contract. +#[utoipa::path( + post, + path = "/api/v1/contracts/{id}/tasks/{task_id}", + params( + ("id" = Uuid, Path, description = "Contract ID"), + ("task_id" = Uuid, Path, description = "Task ID") + ), + responses( + (status = 204, description = "Task added to contract"), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract or task not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Contracts" +)] +pub async fn add_task_to_contract( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, task_id)): Path<(Uuid, Uuid)>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify contract exists and belongs to owner + match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + // Verify task exists and belongs to owner + match repository::get_task_for_owner(pool, task_id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Task not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get task {}: {}", task_id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::add_task_to_contract(pool, id, task_id, auth.owner_id).await { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Task not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to add task {} to contract {}: {}", task_id, id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Remove a task from a contract. +#[utoipa::path( + delete, + path = "/api/v1/contracts/{id}/tasks/{task_id}", + params( + ("id" = Uuid, Path, description = "Contract ID"), + ("task_id" = Uuid, Path, description = "Task ID") + ), + responses( + (status = 204, description = "Task removed from contract"), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract or task not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Contracts" +)] +pub async fn remove_task_from_contract( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, task_id)): Path<(Uuid, Uuid)>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify contract exists and belongs to owner + match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::remove_task_from_contract(pool, id, task_id, auth.owner_id).await { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Task not found in this contract")), + ) + .into_response(), + Err(e) => { + tracing::error!( + "Failed to remove task {} from contract {}: {}", + task_id, + id, + e + ); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= +// Phase Management +// ============================================================================= + +/// Change contract phase. +#[utoipa::path( + post, + path = "/api/v1/contracts/{id}/phase", + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + request_body = ChangePhaseRequest, + responses( + (status = 200, description = "Phase changed", body = ContractSummary), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Contracts" +)] +pub async fn change_phase( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<ChangePhaseRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::change_contract_phase_for_owner(pool, id, auth.owner_id, &req.phase).await { + Ok(Some(contract)) => { + // Notify supervisor of phase change + if let Some(supervisor_task_id) = contract.supervisor_task_id { + if let Ok(Some(supervisor)) = repository::get_task_for_owner(pool, supervisor_task_id, auth.owner_id).await { + let state_clone = state.clone(); + let contract_id = contract.id; + let new_phase = contract.phase.clone(); + tokio::spawn(async move { + state_clone.notify_supervisor_of_phase_change( + supervisor.id, + supervisor.daemon_id, + contract_id, + &new_phase, + ).await; + }); + } + } + + // Get summary with counts + match repository::get_contract_summary_for_owner(pool, contract.id, auth.owner_id).await + { + Ok(Some(summary)) => Json(summary).into_response(), + _ => Json(ContractSummary { + id: contract.id, + name: contract.name, + description: contract.description, + phase: contract.phase, + status: contract.status, + file_count: 0, + task_count: 0, + repository_count: 0, + version: contract.version, + created_at: contract.created_at, + }) + .into_response(), + } + } + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to change phase for contract {}: {}", id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= +// Events +// ============================================================================= + +/// Get contract event history. +#[utoipa::path( + get, + path = "/api/v1/contracts/{id}/events", + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + responses( + (status = 200, description = "Event history", body = Vec<crate::db::models::ContractEvent>), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Contracts" +)] +pub async fn get_events( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify contract exists and belongs to owner + match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::list_contract_events(pool, id).await { + Ok(events) => Json(events).into_response(), + Err(e) => { + tracing::error!("Failed to get events for contract {}: {}", id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} diff --git a/makima/src/server/handlers/files.rs b/makima/src/server/handlers/files.rs index 9634b73..05e871c 100644 --- a/makima/src/server/handlers/files.rs +++ b/makima/src/server/handlers/files.rs @@ -8,11 +8,11 @@ use axum::{ }; use uuid::Uuid; -use crate::db::models::{CreateFileRequest, FileListResponse, FileSummary, UpdateFileRequest}; +use crate::db::models::{CreateFileRequest, FileListResponse, UpdateFileRequest}; use crate::db::repository::{self, RepositoryError}; use crate::server::auth::Authenticated; use crate::server::messages::ApiError; -use crate::server::state::{FileUpdateNotification, SharedState}; +use crate::server::state::{DaemonCommand, FileUpdateNotification, SharedState}; /// List all files for the authenticated user's owner. #[utoipa::path( @@ -42,9 +42,8 @@ pub async fn list_files( .into_response(); }; - match repository::list_files_for_owner(pool, auth.owner_id).await { - Ok(files) => { - let summaries: Vec<FileSummary> = files.into_iter().map(FileSummary::from).collect(); + match repository::list_file_summaries_for_owner(pool, auth.owner_id).await { + Ok(summaries) => { let total = summaries.len() as i64; Json(FileListResponse { files: summaries, @@ -114,7 +113,7 @@ pub async fn get_file( } } -/// Create a new file. +/// Create a new file. Files must belong to a contract. #[utoipa::path( post, path = "/api/v1/files", @@ -123,6 +122,7 @@ pub async fn get_file( (status = 201, description = "File created", body = crate::db::models::File), (status = 400, description = "Invalid request", body = ApiError), (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), @@ -145,6 +145,26 @@ pub async fn create_file( .into_response(); }; + // Verify the contract exists and belongs to the owner + match repository::get_contract_for_owner(pool, req.contract_id, auth.owner_id).await { + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("CONTRACT_NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to verify contract: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + Ok(Some(_)) => {} // Contract exists, proceed + } + match repository::create_file_for_owner(pool, auth.owner_id, req).await { Ok(file) => (StatusCode::CREATED, Json(file)).into_response(), Err(e) => { @@ -310,3 +330,190 @@ pub async fn delete_file( } } } + +/// Sync a file from its linked repository file. +/// +/// This endpoint triggers an async sync operation. The file must have a +/// repo_file_path set, and its contract must have a linked repository. +/// A connected daemon will read the file and update the file content. +#[utoipa::path( + post, + path = "/api/v1/files/{id}/sync-from-repo", + params( + ("id" = Uuid, Path, description = "File ID") + ), + responses( + (status = 202, description = "Sync operation started"), + (status = 400, description = "File not linked to repository", body = ApiError), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "File not found", body = ApiError), + (status = 503, description = "No daemon available", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Files" +)] +pub async fn sync_file_from_repo( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Get the file and verify it has a repo_file_path + let file = match repository::get_file_for_owner(pool, id, auth.owner_id).await { + Ok(Some(f)) => f, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "File not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get file {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + }; + + // Check if file has a repo path and contract_id + let contract_id = match file.contract_id { + Some(id) => id, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(ApiError::new( + "NO_CONTRACT", + "File is not associated with a contract", + )), + ) + .into_response(); + } + }; + + let repo_file_path = match file.repo_file_path { + Some(ref path) if !path.is_empty() => path.clone(), + _ => { + return ( + StatusCode::BAD_REQUEST, + Json(ApiError::new( + "NOT_LINKED", + "File is not linked to a repository file", + )), + ) + .into_response(); + } + }; + + // Get contract repositories + let repositories = match repository::list_contract_repositories(pool, contract_id).await { + Ok(repos) => repos, + Err(e) => { + tracing::error!("Failed to get contract repositories: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + }; + + // Check if contract has repositories + if repositories.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(ApiError::new( + "NO_REPOSITORY", + "Contract has no linked repositories", + )), + ) + .into_response(); + } + + // Use the first repository's local path + let repo = &repositories[0]; + let repo_local_path = match &repo.local_path { + Some(path) if !path.is_empty() => path.clone(), + _ => { + return ( + StatusCode::BAD_REQUEST, + Json(ApiError::new( + "NO_LOCAL_PATH", + "Repository has no local path configured", + )), + ) + .into_response(); + } + }; + + // Find a connected daemon for this owner + let daemon_id = state + .daemon_connections + .iter() + .find(|entry| entry.value().owner_id == auth.owner_id) + .map(|entry| entry.value().id); + + let daemon_id = match daemon_id { + Some(id) => id, + None => { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new( + "NO_DAEMON", + "No daemon connected. Start a daemon to sync files from repository.", + )), + ) + .into_response(); + } + }; + + // Send ReadRepoFile command to daemon + // Use the file ID as the request_id so we can match the response + let command = DaemonCommand::ReadRepoFile { + request_id: id, + contract_id, + file_path: repo_file_path, + repo_path: repo_local_path, + }; + + if let Err(e) = state.send_daemon_command(daemon_id, command).await { + tracing::error!("Failed to send ReadRepoFile command: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DAEMON_ERROR", e)), + ) + .into_response(); + } + + // Update status to indicate sync in progress + if let Err(e) = sqlx::query("UPDATE files SET repo_sync_status = 'syncing' WHERE id = $1") + .bind(id) + .execute(pool) + .await + { + tracing::warn!("Failed to update repo_sync_status: {}", e); + } + + // Return 202 Accepted - the sync happens asynchronously + ( + StatusCode::ACCEPTED, + Json(serde_json::json!({ + "message": "Sync operation started", + "fileId": id, + })), + ) + .into_response() +} diff --git a/makima/src/server/handlers/listen.rs b/makima/src/server/handlers/listen.rs index a26c208..524c48a 100644 --- a/makima/src/server/handlers/listen.rs +++ b/makima/src/server/handlers/listen.rs @@ -9,13 +9,13 @@ use tokio::sync::mpsc; use uuid::Uuid; use crate::audio::{resample_and_mixdown, TARGET_CHANNELS, TARGET_SAMPLE_RATE}; -use crate::db::models::{CreateFileRequest, TranscriptEntry, UpdateFileRequest}; +use crate::db::models::{TranscriptEntry, UpdateFileRequest}; use crate::db::repository; use crate::listen::{align_speakers, samples_per_chunk, DialogueSegment, TimestampMode}; use crate::server::messages::{ AudioEncoding, ClientMessage, ServerMessage, StartMessage, TranscriptMessage, }; -use crate::server::state::SharedState; +use crate::server::state::{MlModels, SharedState}; /// Chunk size in milliseconds for triggering transcription processing. const STREAM_CHUNK_MS: u32 = 5_000; @@ -77,6 +77,23 @@ async fn handle_socket(socket: WebSocket, state: SharedState) { } }); + // Lazy-load ML models on first Listen connection + let ml_models = match state.get_ml_models().await { + Ok(models) => models, + Err(e) => { + tracing::error!(session_id = %session_id, error = %e, "Failed to load ML models"); + let _ = response_tx + .send(ServerMessage::Error { + code: "MODEL_LOAD_ERROR".into(), + message: format!("Failed to load ML models: {}", e), + }) + .await; + drop(response_tx); + let _ = sender_task.await; + return; + } + }; + // Send ready message let _ = response_tx .send(ServerMessage::Ready { @@ -106,9 +123,13 @@ async fn handle_socket(socket: WebSocket, state: SharedState) { let mut transcript_entries: Vec<TranscriptEntry> = Vec::new(); let mut transcript_counter: u32 = 0; + // Auth state (set when Start message includes valid auth_token and contract_id) + let mut authenticated_owner_id: Option<Uuid> = None; + let mut target_contract_id: Option<Uuid> = None; + // Reset Sortformer state for new session { - let mut sortformer = state.sortformer.lock().await; + let mut sortformer = ml_models.sortformer.lock().await; sortformer.reset_state(); } @@ -132,8 +153,51 @@ async fn handle_socket(socket: WebSocket, state: SharedState) { sample_rate = start.sample_rate, channels = start.channels, encoding = ?start.encoding, + contract_id = ?start.contract_id, + has_auth = start.auth_token.is_some(), "Session started" ); + + // Validate auth and contract if provided + if let (Some(token), Some(contract_id_str)) = (&start.auth_token, &start.contract_id) { + // Parse contract ID + if let Ok(contract_id) = Uuid::parse_str(contract_id_str) { + // Validate JWT token + if let Some(ref verifier) = state.jwt_verifier { + match verifier.verify(token) { + Ok(claims) => { + authenticated_owner_id = Some(claims.sub); + target_contract_id = Some(contract_id); + tracing::info!( + session_id = %session_id, + owner_id = %claims.sub, + contract_id = %contract_id, + "Authenticated session - transcripts will be saved to contract" + ); + } + Err(e) => { + tracing::warn!( + session_id = %session_id, + error = %e, + "Invalid auth token - transcripts will not be saved" + ); + } + } + } else { + tracing::debug!( + session_id = %session_id, + "No JWT verifier configured - transcripts will not be saved" + ); + } + } else { + tracing::warn!( + session_id = %session_id, + contract_id = contract_id_str, + "Invalid contract ID format" + ); + } + } + audio_format = Some(start); audio_buffer.clear(); eou_buffer.clear(); @@ -143,9 +207,12 @@ async fn handle_socket(socket: WebSocket, state: SharedState) { last_processed_len = 0; audio_offset = 0.0; finalized_segments.clear(); + file_id = None; + authenticated_owner_id = authenticated_owner_id; // Keep from above + target_contract_id = target_contract_id; // Keep from above // Reset models for new session - let mut sortformer = state.sortformer.lock().await; + let mut sortformer = ml_models.sortformer.lock().await; sortformer.reset_state(); } Ok(ClientMessage::Stop(stop)) => { @@ -165,7 +232,7 @@ async fn handle_socket(socket: WebSocket, state: SharedState) { ); // Process remaining audio with sliding window - match process_audio_window(&audio_buffer, audio_offset, &state).await { + match process_audio_window(&audio_buffer, audio_offset, ml_models).await { Ok(segments) => { tracing::debug!( session_id = %session_id, @@ -291,7 +358,7 @@ async fn handle_socket(socket: WebSocket, state: SharedState) { while eou_buffer.len() >= EOU_CHUNK_SIZE { let chunk: Vec<f32> = eou_buffer.drain(..EOU_CHUNK_SIZE).collect(); - let mut eou = state.parakeet_eou.lock().await; + let mut eou = ml_models.parakeet_eou.lock().await; if let Ok(text) = eou.transcribe(&chunk, false) { // Detect utterance boundary (sentence-ending punctuation) if !text.is_empty() && text != last_eou_text { @@ -325,7 +392,7 @@ async fn handle_socket(socket: WebSocket, state: SharedState) { "Processing audio with sliding window" ); - match process_audio_window(&audio_buffer, audio_offset, &state).await { + match process_audio_window(&audio_buffer, audio_offset, ml_models).await { Ok(segments) => { tracing::debug!( session_id = %session_id, @@ -339,21 +406,29 @@ async fn handle_socket(socket: WebSocket, state: SharedState) { let adjusted_start = seg.start + audio_offset; let adjusted_end = seg.end + audio_offset; if adjusted_end > last_sent_end_time { - // Create file on first transcript if database is available + // Create file on first transcript if authenticated with contract if file_id.is_none() { - if let Some(ref pool) = state.db_pool { - match repository::create_file(pool, CreateFileRequest { + if let (Some(owner_id), Some(contract_id), Some(pool)) = + (authenticated_owner_id, target_contract_id, &state.db_pool) + { + let create_req = crate::db::models::CreateFileRequest { + contract_id, name: None, // Auto-generated - description: None, + description: Some("Live transcription".to_string()), transcript: vec![], location: None, - }).await { + body: vec![], + repo_file_path: None, + contract_phase: None, // Will be looked up from contract + }; + match repository::create_file_for_owner(pool, owner_id, create_req).await { Ok(file) => { file_id = Some(file.id); tracing::info!( session_id = %session_id, file_id = %file.id, - "Created file for session" + contract_id = %contract_id, + "Created file for session in contract" ); } Err(e) => { @@ -468,6 +543,7 @@ async fn handle_socket(socket: WebSocket, state: SharedState) { summary: None, body: None, version: None, // Internal update, skip version check + repo_file_path: None, }).await { Ok(_) => { tracing::info!( @@ -649,7 +725,7 @@ fn text_similarity(a: &str, b: &str) -> f32 { async fn process_audio_window( samples: &[f32], _audio_offset: f32, - state: &SharedState, + ml_models: &MlModels, ) -> Result<Vec<DialogueSegment>, Box<dyn std::error::Error + Send + Sync>> { // Apply sliding window - only process the last 30 seconds let window_start = samples.len().saturating_sub(MAX_WINDOW_SAMPLES); @@ -663,8 +739,8 @@ async fn process_audio_window( ); // Acquire model locks and run inference - let mut parakeet = state.parakeet.lock().await; - let mut sortformer = state.sortformer.lock().await; + let mut parakeet = ml_models.parakeet.lock().await; + let mut sortformer = ml_models.sortformer.lock().await; // Run streaming diarization (maintains speaker cache across calls) let diarization_segments = diff --git a/makima/src/server/handlers/mesh.rs b/makima/src/server/handlers/mesh.rs index 760740c..2d90a04 100644 --- a/makima/src/server/handlers/mesh.rs +++ b/makima/src/server/handlers/mesh.rs @@ -214,7 +214,27 @@ pub async fn create_task( }; match repository::create_task_for_owner(pool, auth.owner_id, req).await { - Ok(task) => (StatusCode::CREATED, Json(task)).into_response(), + Ok(task) => { + // Notify supervisor of new task creation if task belongs to a contract + if let Some(contract_id) = task.contract_id { + if !task.is_supervisor { + let pool = pool.clone(); + let state_clone = state.clone(); + let task_clone = task.clone(); + tokio::spawn(async move { + if let Ok(Some(supervisor)) = repository::get_contract_supervisor_task(&pool, contract_id).await { + state_clone.notify_supervisor_of_task_created( + supervisor.id, + supervisor.daemon_id, + task_clone.id, + &task_clone.name, + ).await; + } + }); + } + } + (StatusCode::CREATED, Json(task)).into_response() + } Err(e) => { tracing::error!("Failed to create task: {}", e); ( @@ -262,6 +282,26 @@ pub async fn update_task( .into_response(); }; + // Check if trying to set a supervisor task to a terminal status + if let Some(ref new_status) = req.status { + let terminal_statuses = ["done", "failed", "merged"]; + if terminal_statuses.contains(&new_status.as_str()) { + // Get the task to check if it's a supervisor + if let Ok(Some(task)) = repository::get_task_for_owner(pool, id, auth.owner_id).await { + if task.is_supervisor { + return ( + StatusCode::BAD_REQUEST, + Json(ApiError::new( + "SUPERVISOR_CANNOT_COMPLETE", + "Supervisor tasks cannot be marked as done, failed, or merged. They run for the lifetime of the contract.", + )), + ) + .into_response(); + } + } + } + } + // Track which fields are being updated for the notification let mut updated_fields = Vec::new(); if req.name.is_some() { @@ -288,6 +328,8 @@ pub async fn update_task( match repository::update_task_for_owner(pool, id, auth.owner_id, req).await { Ok(Some(task)) => { + let updated_fields_clone = updated_fields.clone(); + // Broadcast task update notification state.broadcast_task_update(TaskUpdateNotification { task_id: task.id, @@ -297,6 +339,28 @@ pub async fn update_task( updated_fields, updated_by: "user".to_string(), }); + + // Notify supervisor of status change if task belongs to a contract + if let Some(contract_id) = task.contract_id { + if !task.is_supervisor && updated_fields_clone.contains(&"status".to_string()) { + let pool = pool.clone(); + let state_clone = state.clone(); + let task_clone = task.clone(); + tokio::spawn(async move { + if let Ok(Some(supervisor)) = repository::get_contract_supervisor_task(&pool, contract_id).await { + state_clone.notify_supervisor_of_task_update( + supervisor.id, + supervisor.daemon_id, + task_clone.id, + &task_clone.name, + &task_clone.status, + &updated_fields_clone, + ).await; + } + }); + } + } + Json(task).into_response() } Ok(None) => ( @@ -556,7 +620,8 @@ pub async fn start_task( task_depth = task.depth, subtask_count = subtask_count, is_orchestrator = is_orchestrator, - "Starting task with orchestrator determination" + is_supervisor = task.is_supervisor, + "Starting task with orchestrator/supervisor determination" ); // IMPORTANT: Update database FIRST to assign daemon_id before sending command @@ -602,8 +667,18 @@ pub async fn start_task( completion_action: task.completion_action.clone(), continue_from_task_id: task.continue_from_task_id, copy_files: task.copy_files.as_ref().and_then(|v| serde_json::from_value(v.clone()).ok()), + contract_id: task.contract_id, + is_supervisor: task.is_supervisor, }; + tracing::info!( + task_id = %id, + is_supervisor = task.is_supervisor, + is_orchestrator = is_orchestrator, + daemon_id = %target_daemon_id, + "Sending SpawnTask command to daemon" + ); + if let Err(e) = state.send_daemon_command(target_daemon_id, command).await { tracing::error!("Failed to send SpawnTask command: {}", e); // Rollback: clear daemon_id and reset status since command failed @@ -884,8 +959,11 @@ pub async fn send_message( } }; - // Check if task is running - if task.status != "running" { + // Check if task is running (except for AUTH_CODE messages and supervisor tasks) + // Supervisor tasks can receive messages even when not running - daemon will respawn Claude + let is_auth_code = req.message.starts_with("AUTH_CODE:"); + let is_supervisor = task.is_supervisor; + if task.status != "running" && !is_auth_code && !is_supervisor { return ( StatusCode::BAD_REQUEST, Json(ApiError::new( @@ -900,8 +978,27 @@ pub async fn send_message( } // Find the daemon running this task + // For supervisors, if no daemon is assigned, find any available daemon for this owner let target_daemon_id = if let Some(daemon_id) = task.daemon_id { daemon_id + } else if is_supervisor { + // Supervisor without daemon - find one + match state.daemon_connections + .iter() + .find(|d| d.value().owner_id == auth.owner_id) + { + Some(entry) => entry.value().id, + None => { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new( + "NO_DAEMON", + "No daemon available. Please start a daemon.", + )), + ) + .into_response(); + } + } } else { return ( StatusCode::SERVICE_UNAVAILABLE, diff --git a/makima/src/server/handlers/mesh_chat.rs b/makima/src/server/handlers/mesh_chat.rs index 5d6d2ee..3f650bc 100644 --- a/makima/src/server/handlers/mesh_chat.rs +++ b/makima/src/server/handlers/mesh_chat.rs @@ -930,6 +930,46 @@ async fn handle_mesh_request( merge_mode, priority, } => { + // Subtasks inherit contract_id from parent task + let contract_id = if let Some(parent_id) = parent_task_id { + match repository::get_task(pool, parent_id).await { + Ok(Some(parent_task)) => { + match parent_task.contract_id { + Some(cid) => cid, + None => { + return MeshRequestResult { + success: false, + message: "Parent task has no contract_id".to_string(), + data: None, + }; + } + } + } + Ok(None) => { + return MeshRequestResult { + success: false, + message: format!("Parent task {} not found", parent_id), + data: None, + }; + } + Err(e) => { + return MeshRequestResult { + success: false, + message: format!("Failed to look up parent task: {}", e), + data: None, + }; + } + } + } else { + // Root tasks created via LLM chat require a contract_id + // TODO: Add contract_id to create_task tool definition + return MeshRequestResult { + success: false, + message: "Cannot create root task without contract_id. Use parent_task_id to create subtasks.".to_string(), + data: None, + }; + }; + // Check if repository_url matches a daemon's working directory (for this owner) let is_daemon_working_dir = repository_url.as_ref().map(|url| { state.daemon_connections.iter().any(|entry| { @@ -962,6 +1002,7 @@ async fn handle_mesh_request( }; let create_req = CreateTaskRequest { + contract_id, name: name.clone(), description: None, plan, @@ -975,6 +1016,8 @@ async fn handle_mesh_request( completion_action, continue_from_task_id: None, copy_files: None, + is_supervisor: false, + checkpoint_sha: None, }; match repository::create_task_for_owner(pool, owner_id, create_req).await { @@ -1074,6 +1117,8 @@ async fn handle_mesh_request( completion_action: task.completion_action.clone(), continue_from_task_id: task.continue_from_task_id, copy_files: task.copy_files.as_ref().and_then(|v| serde_json::from_value(v.clone()).ok()), + contract_id: task.contract_id, + is_supervisor: task.is_supervisor, }; match state.send_daemon_command(target_daemon_id, command).await { @@ -1610,6 +1655,9 @@ async fn handle_mesh_request( crate::db::models::BodyElement::Image { src, alt, caption } => { json!({ "type": "image", "src": src, "alt": alt, "caption": caption }) } + crate::db::models::BodyElement::Markdown { content } => { + json!({ "type": "markdown", "content": content }) + } } }) .collect(); @@ -1640,6 +1688,9 @@ async fn handle_mesh_request( }).collect(); Some(list_text.join("\n")) } + crate::db::models::BodyElement::Markdown { content } => { + Some(content.clone()) + } _ => None, } }) @@ -1976,6 +2027,79 @@ async fn handle_mesh_request( }, } } + + // Supervisor-only tools - these should be handled via the supervisor.sh script, + // not through the mesh chat. Return an informative error. + MeshToolRequest::GetAllContractTasks { contract_id } => { + MeshRequestResult { + success: false, + message: format!( + "get_all_contract_tasks is a supervisor-only tool. Use supervisor.sh to access this functionality. Contract: {}", + contract_id + ), + data: None, + } + } + MeshToolRequest::WaitForTaskCompletion { task_id, timeout_seconds } => { + MeshRequestResult { + success: false, + message: format!( + "wait_for_task_completion is a supervisor-only tool. Use supervisor.sh to access this functionality. Task: {}, Timeout: {}s", + task_id, timeout_seconds + ), + data: None, + } + } + MeshToolRequest::ReadTaskWorktree { task_id, file_path } => { + MeshRequestResult { + success: false, + message: format!( + "read_task_worktree is a supervisor-only tool. Use supervisor.sh to access this functionality. Task: {}, Path: {}", + task_id, file_path + ), + data: None, + } + } + MeshToolRequest::SpawnTask { name, plan, parent_task_id, checkpoint_sha, .. } => { + MeshRequestResult { + success: false, + message: format!( + "spawn_task is a supervisor-only tool. Only the contract supervisor can spawn new tasks. Task name: {}", + name + ), + data: None, + } + } + MeshToolRequest::CreateCheckpoint { task_id, message } => { + MeshRequestResult { + success: false, + message: format!( + "create_checkpoint is a supervisor-only tool. Use supervisor.sh to access this functionality. Task: {}, Message: {}", + task_id, message + ), + data: None, + } + } + MeshToolRequest::ListTaskCheckpoints { task_id } => { + MeshRequestResult { + success: false, + message: format!( + "list_task_checkpoints is a supervisor-only tool. Use supervisor.sh to access this functionality. Task: {}", + task_id + ), + data: None, + } + } + MeshToolRequest::GetTaskTree { task_id } => { + MeshRequestResult { + success: false, + message: format!( + "get_task_tree is a supervisor-only tool. Use supervisor.sh to access this functionality. Task: {}", + task_id + ), + data: None, + } + } } } diff --git a/makima/src/server/handlers/mesh_daemon.rs b/makima/src/server/handlers/mesh_daemon.rs index 644d0bc..178e5e1 100644 --- a/makima/src/server/handlers/mesh_daemon.rs +++ b/makima/src/server/handlers/mesh_daemon.rs @@ -301,6 +301,17 @@ pub enum DaemonMessage { #[serde(rename = "taskId")] task_id: Uuid, }, + /// Authentication required - OAuth token expired, provides login URL + AuthenticationRequired { + /// Task ID that triggered the auth error (if any) + #[serde(rename = "taskId")] + task_id: Option<Uuid>, + /// OAuth login URL for remote authentication + #[serde(rename = "loginUrl")] + login_url: String, + /// Hostname of the daemon requiring auth + hostname: Option<String>, + }, /// Response to RetryCompletionAction command CompletionActionResult { #[serde(rename = "taskId")] @@ -343,6 +354,21 @@ pub enum DaemonMessage { #[serde(rename = "targetDir")] target_dir: String, }, + /// Response to ReadRepoFile command + RepoFileContent { + /// Request ID from the original command + #[serde(rename = "requestId")] + request_id: Uuid, + /// Path to the file that was read + #[serde(rename = "filePath")] + file_path: String, + /// File content (None if error occurred) + content: Option<String>, + /// Whether the operation succeeded + success: bool, + /// Error message if operation failed + error: Option<String>, + }, } /// Validated daemon authentication result. @@ -509,6 +535,31 @@ async fn handle_daemon_connection(socket: WebSocket, state: SharedState, auth_re "Daemon registered" ); + // Register daemon in database + if let Some(ref pool) = state.db_pool { + match repository::register_daemon( + pool, + owner_id, + &connection_id, + Some(&hostname), + Some(&machine_id), + max_concurrent_tasks as i32, + ).await { + Ok(db_daemon) => { + tracing::debug!( + daemon_id = %db_daemon.id, + "Daemon registered in database" + ); + } + Err(e) => { + tracing::error!( + error = %e, + "Failed to register daemon in database" + ); + } + } + } + // Register daemon in state with owner_id state.register_daemon( connection_id.clone(), @@ -718,6 +769,24 @@ async fn handle_daemon_connection(socket: WebSocket, state: SharedState, auth_re ], updated_by: "daemon".into(), }); + + // Notify supervisor if this task belongs to a contract + if let Some(contract_id) = updated_task.contract_id { + // Don't notify for supervisor tasks (they don't report to themselves) + if !updated_task.is_supervisor { + if let Ok(Some(supervisor)) = repository::get_contract_supervisor_task(&pool, contract_id).await { + state.notify_supervisor_of_task_completion( + supervisor.id, + supervisor.daemon_id, + updated_task.id, + &updated_task.name, + &updated_task.status, + updated_task.progress_summary.as_deref(), + updated_task.error_message.as_deref(), + ).await; + } + } + } } Ok(None) => { tracing::warn!( @@ -763,6 +832,50 @@ async fn handle_daemon_connection(socket: WebSocket, state: SharedState, auth_re ); state.revoke_tool_key(task_id); } + Ok(DaemonMessage::AuthenticationRequired { task_id, login_url, hostname }) => { + tracing::warn!( + task_id = ?task_id, + login_url = %login_url, + hostname = ?hostname, + "Daemon requires authentication - OAuth token expired" + ); + + // Broadcast as task output with auth_required type so UI can display the login link + let content = format!( + "🔐 Authentication required on daemon{}. Click to login: {}", + hostname.as_ref().map(|h| format!(" ({})", h)).unwrap_or_default(), + login_url + ); + + // Broadcast to task subscribers if we have a task_id + if let Some(tid) = task_id { + tracing::info!(task_id = %tid, "Broadcasting auth_required to task subscribers"); + state.broadcast_task_output(TaskOutputNotification { + task_id: tid, + owner_id: Some(owner_id), + message_type: "auth_required".to_string(), + content: "Authentication required".to_string(), // Constant for dedup + tool_name: None, + tool_input: Some(serde_json::json!({ + "loginUrl": login_url, + "hostname": hostname, + "taskId": tid.to_string(), + })), + is_error: Some(true), + cost_usd: None, + duration_ms: None, + is_partial: false, + }); + } else { + tracing::warn!("No task_id for auth_required - cannot broadcast to specific task"); + } + + // Also log the full URL for manual use + tracing::info!( + login_url = %login_url, + "OAuth login URL available - user should open this in browser" + ); + } Ok(DaemonMessage::DaemonDirectories { working_directory, home_directory, worktrees_directory }) => { tracing::info!( daemon_id = %daemon_uuid, @@ -874,6 +987,92 @@ async fn handle_daemon_connection(socket: WebSocket, state: SharedState, auth_re is_partial: false, }); } + Ok(DaemonMessage::RepoFileContent { + request_id, + file_path, + content, + success, + error, + }) => { + tracing::info!( + request_id = %request_id, + file_path = %file_path, + success = success, + content_len = content.as_ref().map(|c| c.len()), + error = ?error, + "Repo file content received from daemon" + ); + + // The request_id is the file_id we want to update + if success { + if let (Some(pool), Some(content)) = (&state.db_pool, content) { + // Convert markdown to body elements + let body = crate::llm::markdown_to_body(&content); + + // Update file in database + let update_req = crate::db::models::UpdateFileRequest { + name: None, + description: None, + transcript: None, + summary: None, + body: Some(body), + version: None, + repo_file_path: None, + }; + + match repository::update_file_for_owner(pool, request_id, owner_id, update_req).await { + Ok(Some(_file)) => { + tracing::info!( + file_id = %request_id, + "File synced from repository successfully" + ); + + // Update repo_sync_status to 'synced' and set repo_synced_at + if let Err(e) = sqlx::query( + "UPDATE files SET repo_sync_status = 'synced', repo_synced_at = NOW() WHERE id = $1" + ) + .bind(request_id) + .execute(pool) + .await + { + tracing::warn!( + file_id = %request_id, + error = %e, + "Failed to update repo sync status" + ); + } + + // Broadcast file update notification + state.broadcast_file_update(crate::server::state::FileUpdateNotification { + file_id: request_id, + version: 0, // Will be updated by next fetch + updated_fields: vec!["body".to_string(), "repo_sync_status".to_string()], + updated_by: "daemon".to_string(), + }); + } + Ok(None) => { + tracing::warn!( + file_id = %request_id, + "File not found when syncing from repository" + ); + } + Err(e) => { + tracing::error!( + file_id = %request_id, + error = %e, + "Failed to update file from repository content" + ); + } + } + } + } else { + tracing::warn!( + file_id = %request_id, + error = ?error, + "Daemon failed to read repo file" + ); + } + } Err(e) => { tracing::warn!("Failed to parse daemon message: {}", e); } @@ -913,10 +1112,20 @@ async fn handle_daemon_connection(socket: WebSocket, state: SharedState, auth_re // Cleanup on disconnect state.unregister_daemon(&connection_id); - // Clear daemon_id from any tasks that were running on this daemon + // Delete daemon from database and clear tasks if let Some(ref pool) = state.db_pool { let pool = pool.clone(); + let conn_id = connection_id.clone(); tokio::spawn(async move { + // Delete daemon from database + if let Err(e) = repository::delete_daemon_by_connection(&pool, &conn_id).await { + tracing::error!( + connection_id = %conn_id, + error = %e, + "Failed to delete daemon from database" + ); + } + // Find tasks assigned to this daemon that are still active if let Err(e) = clear_daemon_from_tasks(&pool, daemon_uuid).await { tracing::error!( diff --git a/makima/src/server/handlers/mesh_supervisor.rs b/makima/src/server/handlers/mesh_supervisor.rs new file mode 100644 index 0000000..ac59130 --- /dev/null +++ b/makima/src/server/handlers/mesh_supervisor.rs @@ -0,0 +1,1153 @@ +//! HTTP handlers for supervisor-specific mesh operations. +//! +//! These endpoints are used by supervisor tasks (via supervisor.sh) to orchestrate +//! contract work: spawning tasks, waiting for completion, reading worktree files, etc. + +use axum::{ + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + response::IntoResponse, + Json, +}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::db::models::{CreateTaskRequest, Task, TaskSummary}; +use crate::db::repository; +use crate::server::handlers::mesh::{extract_auth, AuthSource}; +use crate::server::messages::ApiError; +use crate::server::state::{DaemonCommand, SharedState}; + +// ============================================================================= +// Request/Response Types +// ============================================================================= + +/// Request to spawn a new task from supervisor. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct SpawnTaskRequest { + pub name: String, + pub plan: String, + pub contract_id: Uuid, + pub parent_task_id: Option<Uuid>, + pub checkpoint_sha: Option<String>, + /// Repository URL for the task (supervisor should provide this) + pub repository_url: Option<String>, +} + +/// Request to wait for task completion. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct WaitForTaskRequest { + #[serde(default = "default_timeout")] + pub timeout_seconds: i32, +} + +fn default_timeout() -> i32 { + 300 +} + +/// Request to read a file from task worktree. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ReadWorktreeFileRequest { + pub file_path: String, +} + +/// Request to create a checkpoint. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateCheckpointRequest { + pub message: String, +} + +/// Response for task tree. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct TaskTreeResponse { + pub tasks: Vec<TaskSummary>, + pub supervisor_task_id: Option<Uuid>, + pub total_count: usize, +} + +/// Response for wait operation. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct WaitResponse { + pub task_id: Uuid, + pub status: String, + pub completed: bool, + pub output_summary: Option<String>, +} + +/// Response for read file operation. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ReadFileResponse { + pub task_id: Uuid, + pub file_path: String, + pub content: String, + pub exists: bool, +} + +/// Response for checkpoint operations. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CheckpointResponse { + pub task_id: Uuid, + pub checkpoint_number: i32, + pub commit_sha: String, + pub message: String, +} + +/// Task checkpoint info. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct TaskCheckpoint { + pub id: Uuid, + pub task_id: Uuid, + pub checkpoint_number: i32, + pub commit_sha: String, + pub branch_name: String, + pub message: String, + pub files_changed: Option<serde_json::Value>, + pub lines_added: i32, + pub lines_removed: i32, + pub created_at: chrono::DateTime<chrono::Utc>, +} + +/// Response for list checkpoints. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CheckpointListResponse { + pub task_id: Uuid, + pub checkpoints: Vec<TaskCheckpoint>, +} + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/// Verify the request comes from a supervisor task and extract ownership info. +async fn verify_supervisor_auth( + state: &SharedState, + headers: &HeaderMap, + contract_id: Option<Uuid>, +) -> Result<(Uuid, Uuid), (StatusCode, Json<ApiError>)> { + let auth = extract_auth(state, headers); + + let task_id = match auth { + AuthSource::ToolKey(task_id) => task_id, + _ => { + return Err(( + StatusCode::UNAUTHORIZED, + Json(ApiError::new("UNAUTHORIZED", "Supervisor endpoints require tool key auth")), + )); + } + }; + + // Get the task to verify it's a supervisor and get owner_id + let pool = state.db_pool.as_ref().ok_or_else(|| { + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + })?; + + let task = repository::get_task(pool, task_id) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to get supervisor task"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", "Failed to verify supervisor")), + ) + })? + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Task not found")), + ) + })?; + + // Verify task is a supervisor + if !task.is_supervisor { + return Err(( + StatusCode::FORBIDDEN, + Json(ApiError::new("NOT_SUPERVISOR", "Only supervisor tasks can use these endpoints")), + )); + } + + // If contract_id provided, verify the supervisor belongs to that contract + if let Some(cid) = contract_id { + if task.contract_id != Some(cid) { + return Err(( + StatusCode::FORBIDDEN, + Json(ApiError::new("CONTRACT_MISMATCH", "Supervisor does not belong to this contract")), + )); + } + } + + Ok((task_id, task.owner_id)) +} + +// ============================================================================= +// Contract Task Handlers +// ============================================================================= + +/// List all tasks in a contract's tree. +#[utoipa::path( + get, + path = "/api/v1/mesh/supervisor/contracts/{contract_id}/tasks", + params( + ("contract_id" = Uuid, Path, description = "Contract ID") + ), + responses( + (status = 200, description = "List of tasks in contract", body = TaskTreeResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - not a supervisor"), + (status = 500, description = "Internal server error"), + ), + tag = "Mesh Supervisor" +)] +pub async fn list_contract_tasks( + State(state): State<SharedState>, + Path(contract_id): Path<Uuid>, + headers: HeaderMap, +) -> impl IntoResponse { + let (_supervisor_id, owner_id) = match verify_supervisor_auth(&state, &headers, Some(contract_id)).await { + Ok(ids) => ids, + Err(e) => return e.into_response(), + }; + + let pool = state.db_pool.as_ref().unwrap(); + + // Get all tasks for this contract + match repository::list_tasks_by_contract(pool, contract_id, owner_id).await { + Ok(tasks) => { + let supervisor_task_id = tasks.iter().find(|t| t.is_supervisor).map(|t| t.id); + let summaries: Vec<TaskSummary> = tasks.into_iter().map(TaskSummary::from).collect(); + let total_count = summaries.len(); + + ( + StatusCode::OK, + Json(TaskTreeResponse { + tasks: summaries, + supervisor_task_id, + total_count, + }), + ).into_response() + } + Err(e) => { + tracing::error!(error = %e, "Failed to list contract tasks"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", "Failed to list tasks")), + ).into_response() + } + } +} + +/// Get full task tree structure for a contract. +#[utoipa::path( + get, + path = "/api/v1/mesh/supervisor/contracts/{contract_id}/tree", + params( + ("contract_id" = Uuid, Path, description = "Contract ID") + ), + responses( + (status = 200, description = "Task tree structure", body = TaskTreeResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - not a supervisor"), + (status = 500, description = "Internal server error"), + ), + tag = "Mesh Supervisor" +)] +pub async fn get_contract_tree( + State(state): State<SharedState>, + Path(contract_id): Path<Uuid>, + headers: HeaderMap, +) -> impl IntoResponse { + // Same as list_contract_tasks for now - can add tree structure later + list_contract_tasks(State(state), Path(contract_id), headers).await +} + +// ============================================================================= +// Task Spawn Handler +// ============================================================================= + +/// Spawn a new task (supervisor only). +#[utoipa::path( + post, + path = "/api/v1/mesh/supervisor/tasks", + request_body = SpawnTaskRequest, + responses( + (status = 201, description = "Task created", body = Task), + (status = 400, description = "Invalid request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - not a supervisor"), + (status = 500, description = "Internal server error"), + ), + tag = "Mesh Supervisor" +)] +pub async fn spawn_task( + State(state): State<SharedState>, + headers: HeaderMap, + Json(request): Json<SpawnTaskRequest>, +) -> impl IntoResponse { + let (supervisor_id, owner_id) = match verify_supervisor_auth(&state, &headers, Some(request.contract_id)).await { + Ok(ids) => ids, + Err(e) => return e.into_response(), + }; + + let pool = state.db_pool.as_ref().unwrap(); + + // Verify contract exists + let _contract = match repository::get_contract_for_owner(pool, request.contract_id, owner_id).await { + Ok(Some(c)) => c, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ).into_response(); + } + Err(e) => { + tracing::error!(error = %e, "Failed to get contract"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", "Failed to get contract")), + ).into_response(); + } + }; + + // Get repository URL from the contract's primary repository + let repo_url = match repository::list_contract_repositories(pool, request.contract_id).await { + Ok(repos) => { + // Prefer primary repo, fallback to first repo + repos.iter() + .find(|r| r.is_primary) + .or(repos.first()) + .and_then(|r| r.repository_url.clone()) + } + Err(e) => { + tracing::warn!(error = %e, "Failed to get contract repositories, continuing without repo URL"); + None + } + }; + + // Supervisor can override with explicit repository_url + let repo_url = request.repository_url.clone().or(repo_url); + + // Create task request + let create_req = CreateTaskRequest { + name: request.name.clone(), + description: None, + plan: request.plan.clone(), + repository_url: repo_url.clone(), + contract_id: request.contract_id, + parent_task_id: request.parent_task_id, + is_supervisor: false, + checkpoint_sha: request.checkpoint_sha.clone(), + merge_mode: Some("manual".to_string()), + priority: 0, + base_branch: None, + target_branch: None, + target_repo_path: None, + completion_action: None, + continue_from_task_id: None, + copy_files: None, + }; + + // Create task in DB + let task = match repository::create_task_for_owner(pool, owner_id, create_req).await { + Ok(t) => t, + Err(e) => { + tracing::error!(error = %e, "Failed to create task"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", "Failed to create task")), + ).into_response(); + } + }; + + tracing::info!( + supervisor_id = %supervisor_id, + task_id = %task.id, + task_name = %task.name, + "Supervisor spawned new task" + ); + + // Start task on a daemon + // Find a daemon that belongs to this owner + for entry in state.daemon_connections.iter() { + let daemon = entry.value(); + if daemon.owner_id == owner_id { + // Send spawn command to first available daemon + let cmd = DaemonCommand::SpawnTask { + task_id: task.id, + task_name: task.name.clone(), + plan: task.plan.clone(), + repo_url: repo_url.clone(), + base_branch: task.base_branch.clone(), + target_branch: task.target_branch.clone(), + parent_task_id: task.parent_task_id, + depth: task.depth, + is_orchestrator: false, + target_repo_path: task.target_repo_path.clone(), + completion_action: task.completion_action.clone(), + continue_from_task_id: task.continue_from_task_id, + copy_files: task.copy_files.as_ref().and_then(|v| serde_json::from_value(v.clone()).ok()), + contract_id: task.contract_id, + is_supervisor: false, + }; + + if let Err(e) = state.send_daemon_command(daemon.id, cmd).await { + tracing::warn!(error = %e, daemon_id = %daemon.id, "Failed to send spawn command"); + } else { + tracing::info!(task_id = %task.id, daemon_id = %daemon.id, repo_url = ?repo_url, "Task spawn command sent"); + } + break; + } + } + + (StatusCode::CREATED, Json(task)).into_response() +} + +// ============================================================================= +// Wait for Task Handler +// ============================================================================= + +/// Wait for a task to complete. +#[utoipa::path( + post, + path = "/api/v1/mesh/supervisor/tasks/{task_id}/wait", + params( + ("task_id" = Uuid, Path, description = "Task ID to wait for") + ), + request_body = WaitForTaskRequest, + responses( + (status = 200, description = "Task completed or timed out", body = WaitResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - not a supervisor"), + (status = 404, description = "Task not found"), + (status = 500, description = "Internal server error"), + ), + tag = "Mesh Supervisor" +)] +pub async fn wait_for_task( + State(state): State<SharedState>, + Path(task_id): Path<Uuid>, + headers: HeaderMap, + Json(request): Json<WaitForTaskRequest>, +) -> impl IntoResponse { + let (_supervisor_id, owner_id) = match verify_supervisor_auth(&state, &headers, None).await { + Ok(ids) => ids, + Err(e) => return e.into_response(), + }; + + let pool = state.db_pool.as_ref().unwrap(); + + // Verify task belongs to same owner + let task = match repository::get_task_for_owner(pool, task_id, owner_id).await { + Ok(Some(t)) => t, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Task not found")), + ).into_response(); + } + Err(e) => { + tracing::error!(error = %e, "Failed to get task"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", "Failed to get task")), + ).into_response(); + } + }; + + // Check if already done + if task.status == "done" || task.status == "failed" || task.status == "merged" { + return ( + StatusCode::OK, + Json(WaitResponse { + task_id, + status: task.status, + completed: true, + output_summary: None, + }), + ).into_response(); + } + + // Subscribe to task completions + let mut rx = state.task_completions.subscribe(); + let timeout = tokio::time::Duration::from_secs(request.timeout_seconds as u64); + + // Wait for completion or timeout + let result = tokio::time::timeout(timeout, async { + loop { + match rx.recv().await { + Ok(notification) => { + if notification.task_id == task_id { + return Some(notification); + } + } + Err(_) => { + // Channel closed or lagged - check DB directly + if let Ok(Some(t)) = repository::get_task(pool, task_id).await { + if t.status == "done" || t.status == "failed" || t.status == "merged" { + return Some(crate::server::state::TaskCompletionNotification { + task_id: t.id, + owner_id: Some(t.owner_id), + contract_id: t.contract_id, + parent_task_id: t.parent_task_id, + status: t.status, + output_summary: None, + worktree_path: None, + error_message: t.error_message, + }); + } + } + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + } + } + } + }).await; + + match result { + Ok(Some(notification)) => { + ( + StatusCode::OK, + Json(WaitResponse { + task_id, + status: notification.status, + completed: true, + output_summary: notification.output_summary, + }), + ).into_response() + } + Ok(None) | Err(_) => { + // Timeout - check final status + let final_status = repository::get_task(pool, task_id) + .await + .ok() + .flatten() + .map(|t| t.status) + .unwrap_or_else(|| "unknown".to_string()); + + ( + StatusCode::OK, + Json(WaitResponse { + task_id, + status: final_status.clone(), + completed: final_status == "done" || final_status == "failed" || final_status == "merged", + output_summary: None, + }), + ).into_response() + } + } +} + +// ============================================================================= +// Read Worktree File Handler +// ============================================================================= + +/// Read a file from a task's worktree. +#[utoipa::path( + post, + path = "/api/v1/mesh/supervisor/tasks/{task_id}/read-file", + params( + ("task_id" = Uuid, Path, description = "Task ID") + ), + request_body = ReadWorktreeFileRequest, + responses( + (status = 200, description = "File content", body = ReadFileResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - not a supervisor"), + (status = 404, description = "Task not found"), + (status = 500, description = "Internal server error"), + ), + tag = "Mesh Supervisor" +)] +pub async fn read_worktree_file( + State(state): State<SharedState>, + Path(task_id): Path<Uuid>, + headers: HeaderMap, + Json(request): Json<ReadWorktreeFileRequest>, +) -> impl IntoResponse { + let (_supervisor_id, owner_id) = match verify_supervisor_auth(&state, &headers, None).await { + Ok(ids) => ids, + Err(e) => return e.into_response(), + }; + + let pool = state.db_pool.as_ref().unwrap(); + + // Get task to verify ownership + let task = match repository::get_task_for_owner(pool, task_id, owner_id).await { + Ok(Some(t)) => t, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Task not found")), + ).into_response(); + } + Err(e) => { + tracing::error!(error = %e, "Failed to get task"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", "Failed to get task")), + ).into_response(); + } + }; + + // TODO: Implement file reading via worktree path + // For now, return not implemented - supervisor should use local file access via worktree + let _ = (task, request); + + ( + StatusCode::NOT_IMPLEMENTED, + Json(ApiError::new( + "NOT_IMPLEMENTED", + "Worktree file reading via API not yet implemented. Use local filesystem access via worktree path.", + )), + ).into_response() +} + +// ============================================================================= +// Checkpoint Handlers +// ============================================================================= + +/// Create a git checkpoint for a task. +#[utoipa::path( + post, + path = "/api/v1/mesh/tasks/{task_id}/checkpoint", + params( + ("task_id" = Uuid, Path, description = "Task ID") + ), + request_body = CreateCheckpointRequest, + responses( + (status = 201, description = "Checkpoint created", body = CheckpointResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Task not found"), + (status = 500, description = "Internal server error"), + ), + tag = "Mesh Supervisor" +)] +pub async fn create_checkpoint( + State(state): State<SharedState>, + Path(task_id): Path<Uuid>, + headers: HeaderMap, + Json(request): Json<CreateCheckpointRequest>, +) -> impl IntoResponse { + let auth = extract_auth(&state, &headers); + + let task_id_from_auth = match auth { + AuthSource::ToolKey(tid) => tid, + _ => { + return ( + StatusCode::UNAUTHORIZED, + Json(ApiError::new("UNAUTHORIZED", "Tool key required")), + ).into_response(); + } + }; + + // Can only create checkpoint for own task + if task_id_from_auth != task_id { + return ( + StatusCode::FORBIDDEN, + Json(ApiError::new("FORBIDDEN", "Can only create checkpoint for own task")), + ).into_response(); + } + + let pool = state.db_pool.as_ref().unwrap(); + + // Get task + let task = match repository::get_task(pool, task_id).await { + Ok(Some(t)) => t, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Task not found")), + ).into_response(); + } + Err(e) => { + tracing::error!(error = %e, "Failed to get task"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", "Failed to get task")), + ).into_response(); + } + }; + + // TODO: Implement checkpoint creation via daemon command + // For now, checkpoints should be created by the task itself via git commands + let _ = (task, request); + + ( + StatusCode::NOT_IMPLEMENTED, + Json(ApiError::new( + "NOT_IMPLEMENTED", + "Checkpoint creation via API not yet implemented. Use git commands directly in the task.", + )), + ).into_response() +} + +/// List checkpoints for a task. +#[utoipa::path( + get, + path = "/api/v1/mesh/tasks/{task_id}/checkpoints", + params( + ("task_id" = Uuid, Path, description = "Task ID") + ), + responses( + (status = 200, description = "List of checkpoints", body = CheckpointListResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Task not found"), + (status = 500, description = "Internal server error"), + ), + tag = "Mesh Supervisor" +)] +pub async fn list_checkpoints( + State(state): State<SharedState>, + Path(task_id): Path<Uuid>, + headers: HeaderMap, +) -> impl IntoResponse { + let auth = extract_auth(&state, &headers); + + let _task_id_from_auth = match auth { + AuthSource::ToolKey(tid) => tid, + _ => { + return ( + StatusCode::UNAUTHORIZED, + Json(ApiError::new("UNAUTHORIZED", "Tool key required")), + ).into_response(); + } + }; + + let pool = state.db_pool.as_ref().unwrap(); + + // Get checkpoints from DB + match repository::list_task_checkpoints(pool, task_id).await { + Ok(checkpoints) => { + let checkpoint_list: Vec<TaskCheckpoint> = checkpoints + .into_iter() + .map(|c| TaskCheckpoint { + id: c.id, + task_id: c.task_id, + checkpoint_number: c.checkpoint_number, + commit_sha: c.commit_sha, + branch_name: c.branch_name, + message: c.message, + files_changed: c.files_changed, + lines_added: c.lines_added.unwrap_or(0), + lines_removed: c.lines_removed.unwrap_or(0), + created_at: c.created_at, + }) + .collect(); + + ( + StatusCode::OK, + Json(CheckpointListResponse { + task_id, + checkpoints: checkpoint_list, + }), + ).into_response() + } + Err(e) => { + tracing::error!(error = %e, "Failed to list checkpoints"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", "Failed to list checkpoints")), + ).into_response() + } + } +} + +// ============================================================================= +// Git Operations - Request/Response Types +// ============================================================================= + +/// Request to create a new branch. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateBranchRequest { + pub branch_name: String, + pub from_ref: Option<String>, +} + +/// Response for branch creation. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateBranchResponse { + pub success: bool, + pub branch_name: String, + pub message: String, +} + +/// Request to merge task changes. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct MergeTaskRequest { + pub target_branch: Option<String>, + #[serde(default)] + pub squash: bool, +} + +/// Response for merge operation. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct MergeTaskResponse { + pub task_id: Uuid, + pub success: bool, + pub message: String, + pub commit_sha: Option<String>, + pub conflicts: Option<Vec<String>>, +} + +/// Request to create a pull request. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreatePRRequest { + pub task_id: Uuid, + pub title: String, + pub body: Option<String>, + #[serde(default = "default_base_branch")] + pub base_branch: String, +} + +fn default_base_branch() -> String { + "main".to_string() +} + +/// Response for PR creation. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreatePRResponse { + pub task_id: Uuid, + pub success: bool, + pub message: String, + pub pr_url: Option<String>, + pub pr_number: Option<i32>, +} + +/// Response for task diff. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct TaskDiffResponse { + pub task_id: Uuid, + pub success: bool, + pub diff: Option<String>, + pub error: Option<String>, +} + +// ============================================================================= +// Git Operations - Handlers +// ============================================================================= + +/// Create a new branch from supervisor's worktree. +#[utoipa::path( + post, + path = "/api/v1/mesh/supervisor/branches", + request_body = CreateBranchRequest, + responses( + (status = 201, description = "Branch created", body = CreateBranchResponse), + (status = 400, description = "Invalid request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - not a supervisor"), + (status = 500, description = "Internal server error"), + ), + tag = "Mesh Supervisor" +)] +pub async fn create_branch( + State(state): State<SharedState>, + headers: HeaderMap, + Json(request): Json<CreateBranchRequest>, +) -> impl IntoResponse { + let (supervisor_id, owner_id) = match verify_supervisor_auth(&state, &headers, None).await { + Ok(ids) => ids, + Err(e) => return e.into_response(), + }; + + // Find daemon running supervisor + let daemon_id = { + let pool = state.db_pool.as_ref().unwrap(); + match repository::get_task(pool, supervisor_id).await { + Ok(Some(task)) => task.daemon_id, + _ => None, + } + }; + + let Some(daemon_id) = daemon_id else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("NO_DAEMON", "Supervisor has no assigned daemon")), + ).into_response(); + }; + + // Send CreateBranch command to daemon + let cmd = DaemonCommand::CreateBranch { + task_id: supervisor_id, + branch_name: request.branch_name.clone(), + from_ref: request.from_ref, + }; + + if let Err(e) = state.send_daemon_command(daemon_id, cmd).await { + tracing::error!(error = %e, "Failed to send CreateBranch command"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("COMMAND_FAILED", "Failed to send command to daemon")), + ).into_response(); + } + + // Note: Real implementation would wait for daemon response + // For now, return success immediately - daemon will send response via WebSocket + ( + StatusCode::CREATED, + Json(CreateBranchResponse { + success: true, + branch_name: request.branch_name, + message: "Branch creation command sent".to_string(), + }), + ).into_response() +} + +/// Merge a task's changes to a target branch. +#[utoipa::path( + post, + path = "/api/v1/mesh/supervisor/tasks/{task_id}/merge", + params( + ("task_id" = Uuid, Path, description = "Task ID to merge") + ), + request_body = MergeTaskRequest, + responses( + (status = 200, description = "Merge initiated", body = MergeTaskResponse), + (status = 400, description = "Invalid request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - not a supervisor"), + (status = 404, description = "Task not found"), + (status = 500, description = "Internal server error"), + ), + tag = "Mesh Supervisor" +)] +pub async fn merge_task( + State(state): State<SharedState>, + Path(task_id): Path<Uuid>, + headers: HeaderMap, + Json(request): Json<MergeTaskRequest>, +) -> impl IntoResponse { + let (_supervisor_id, owner_id) = match verify_supervisor_auth(&state, &headers, None).await { + Ok(ids) => ids, + Err(e) => return e.into_response(), + }; + + let pool = state.db_pool.as_ref().unwrap(); + + // Get the target task + let task = match repository::get_task_for_owner(pool, task_id, owner_id).await { + Ok(Some(t)) => t, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Task not found")), + ).into_response(); + } + Err(e) => { + tracing::error!(error = %e, "Failed to get task"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", "Failed to get task")), + ).into_response(); + } + }; + + // Get daemon running the task + let Some(daemon_id) = task.daemon_id else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("NO_DAEMON", "Task has no assigned daemon")), + ).into_response(); + }; + + // Send MergeTaskToTarget command to daemon + let cmd = DaemonCommand::MergeTaskToTarget { + task_id, + target_branch: request.target_branch, + squash: request.squash, + }; + + if let Err(e) = state.send_daemon_command(daemon_id, cmd).await { + tracing::error!(error = %e, "Failed to send MergeTaskToTarget command"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("COMMAND_FAILED", "Failed to send command to daemon")), + ).into_response(); + } + + ( + StatusCode::OK, + Json(MergeTaskResponse { + task_id, + success: true, + message: "Merge command sent".to_string(), + commit_sha: None, + conflicts: None, + }), + ).into_response() +} + +/// Create a pull request for a task's changes. +#[utoipa::path( + post, + path = "/api/v1/mesh/supervisor/pr", + request_body = CreatePRRequest, + responses( + (status = 201, description = "PR created", body = CreatePRResponse), + (status = 400, description = "Invalid request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - not a supervisor"), + (status = 404, description = "Task not found"), + (status = 500, description = "Internal server error"), + ), + tag = "Mesh Supervisor" +)] +pub async fn create_pr( + State(state): State<SharedState>, + headers: HeaderMap, + Json(request): Json<CreatePRRequest>, +) -> impl IntoResponse { + let (_supervisor_id, owner_id) = match verify_supervisor_auth(&state, &headers, None).await { + Ok(ids) => ids, + Err(e) => return e.into_response(), + }; + + let pool = state.db_pool.as_ref().unwrap(); + + // Get the target task + let task = match repository::get_task_for_owner(pool, request.task_id, owner_id).await { + Ok(Some(t)) => t, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Task not found")), + ).into_response(); + } + Err(e) => { + tracing::error!(error = %e, "Failed to get task"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", "Failed to get task")), + ).into_response(); + } + }; + + // Get daemon running the task + let Some(daemon_id) = task.daemon_id else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("NO_DAEMON", "Task has no assigned daemon")), + ).into_response(); + }; + + // Send CreatePR command to daemon + let cmd = DaemonCommand::CreatePR { + task_id: request.task_id, + title: request.title.clone(), + body: request.body.clone(), + base_branch: request.base_branch.clone(), + }; + + if let Err(e) = state.send_daemon_command(daemon_id, cmd).await { + tracing::error!(error = %e, "Failed to send CreatePR command"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("COMMAND_FAILED", "Failed to send command to daemon")), + ).into_response(); + } + + ( + StatusCode::CREATED, + Json(CreatePRResponse { + task_id: request.task_id, + success: true, + message: "PR creation command sent".to_string(), + pr_url: None, + pr_number: None, + }), + ).into_response() +} + +/// Get the diff for a task's changes. +#[utoipa::path( + get, + path = "/api/v1/mesh/supervisor/tasks/{task_id}/diff", + params( + ("task_id" = Uuid, Path, description = "Task ID") + ), + responses( + (status = 200, description = "Task diff", body = TaskDiffResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - not a supervisor"), + (status = 404, description = "Task not found"), + (status = 500, description = "Internal server error"), + ), + tag = "Mesh Supervisor" +)] +pub async fn get_task_diff( + State(state): State<SharedState>, + Path(task_id): Path<Uuid>, + headers: HeaderMap, +) -> impl IntoResponse { + let (_supervisor_id, owner_id) = match verify_supervisor_auth(&state, &headers, None).await { + Ok(ids) => ids, + Err(e) => return e.into_response(), + }; + + let pool = state.db_pool.as_ref().unwrap(); + + // Get the target task + let task = match repository::get_task_for_owner(pool, task_id, owner_id).await { + Ok(Some(t)) => t, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Task not found")), + ).into_response(); + } + Err(e) => { + tracing::error!(error = %e, "Failed to get task"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", "Failed to get task")), + ).into_response(); + } + }; + + // Get daemon running the task + let Some(daemon_id) = task.daemon_id else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("NO_DAEMON", "Task has no assigned daemon")), + ).into_response(); + }; + + // Send GetTaskDiff command to daemon + let cmd = DaemonCommand::GetTaskDiff { task_id }; + + if let Err(e) = state.send_daemon_command(daemon_id, cmd).await { + tracing::error!(error = %e, "Failed to send GetTaskDiff command"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("COMMAND_FAILED", "Failed to send command to daemon")), + ).into_response(); + } + + ( + StatusCode::OK, + Json(TaskDiffResponse { + task_id, + success: true, + diff: None, + error: Some("Diff command sent - response will be streamed".to_string()), + }), + ).into_response() +} diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs index 8681104..8c2cb0c 100644 --- a/makima/src/server/handlers/mod.rs +++ b/makima/src/server/handlers/mod.rs @@ -2,6 +2,9 @@ pub mod api_keys; pub mod chat; +pub mod contract_chat; +pub mod contract_daemon; +pub mod contracts; pub mod file_ws; pub mod files; pub mod listen; @@ -9,6 +12,8 @@ pub mod mesh; pub mod mesh_chat; pub mod mesh_daemon; pub mod mesh_merge; +pub mod mesh_supervisor; pub mod mesh_ws; +pub mod templates; pub mod users; pub mod versions; diff --git a/makima/src/server/handlers/templates.rs b/makima/src/server/handlers/templates.rs new file mode 100644 index 0000000..868d5b4 --- /dev/null +++ b/makima/src/server/handlers/templates.rs @@ -0,0 +1,107 @@ +//! Templates API handler. + +use axum::{extract::Query, http::StatusCode, response::IntoResponse, Json}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::llm::templates; + +/// Query parameters for listing templates +#[derive(Debug, Deserialize, ToSchema)] +pub struct ListTemplatesQuery { + /// Filter by contract phase (research, specify, plan, execute, review) + pub phase: Option<String>, +} + +/// Template summary for API response +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct TemplateSummary { + /// Template identifier + pub id: String, + /// Display name + pub name: String, + /// Contract phase this template is designed for + pub phase: String, + /// Brief description + pub description: String, + /// Number of body elements in the template + pub element_count: usize, +} + +/// Response for listing templates +#[derive(Debug, Serialize, ToSchema)] +pub struct ListTemplatesResponse { + pub templates: Vec<TemplateSummary>, +} + +/// List available file templates +#[utoipa::path( + get, + path = "/api/v1/templates", + params( + ("phase" = Option<String>, Query, description = "Filter by contract phase") + ), + responses( + (status = 200, description = "Templates retrieved successfully", body = ListTemplatesResponse) + ), + tag = "templates" +)] +pub async fn list_templates( + Query(query): Query<ListTemplatesQuery>, +) -> impl IntoResponse { + let template_list = match query.phase.as_deref() { + Some(phase) => templates::templates_for_phase(phase), + None => templates::all_templates(), + }; + + let summaries: Vec<TemplateSummary> = template_list + .iter() + .map(|t| TemplateSummary { + id: t.id.clone(), + name: t.name.clone(), + phase: t.phase.clone(), + description: t.description.clone(), + element_count: t.suggested_body.len(), + }) + .collect(); + + ( + StatusCode::OK, + Json(ListTemplatesResponse { + templates: summaries, + }), + ) + .into_response() +} + +/// Get a specific template by ID +#[utoipa::path( + get, + path = "/api/v1/templates/{id}", + params( + ("id" = String, Path, description = "Template ID") + ), + responses( + (status = 200, description = "Template retrieved successfully", body = templates::FileTemplate), + (status = 404, description = "Template not found") + ), + tag = "templates" +)] +pub async fn get_template( + axum::extract::Path(id): axum::extract::Path<String>, +) -> impl IntoResponse { + let all = templates::all_templates(); + let template = all.into_iter().find(|t| t.id == id); + + match template { + Some(t) => (StatusCode::OK, Json(serde_json::json!(t))).into_response(), + None => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": format!("Template '{}' not found", id) + })), + ) + .into_response(), + } +} diff --git a/makima/src/server/messages.rs b/makima/src/server/messages.rs index 0c92447..401afb0 100644 --- a/makima/src/server/messages.rs +++ b/makima/src/server/messages.rs @@ -25,6 +25,12 @@ pub struct StartMessage { pub channels: u16, /// Audio encoding format pub encoding: AudioEncoding, + /// Optional contract ID to save transcript to (requires auth_token) + #[serde(skip_serializing_if = "Option::is_none")] + pub contract_id: Option<String>, + /// Optional auth token (JWT) for authenticated sessions + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_token: Option<String>, } /// Stop message to terminate the session. diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index a096a5c..568b287 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -18,7 +18,7 @@ use tower_http::trace::TraceLayer; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; -use crate::server::handlers::{api_keys, chat, file_ws, files, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_ws, users, versions}; +use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contracts, file_ws, files, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, templates, users, versions}; use crate::server::openapi::ApiDoc; use crate::server::state::SharedState; @@ -53,6 +53,7 @@ pub fn make_router(state: SharedState) -> Router { .delete(files::delete_file), ) .route("/files/{id}/chat", post(chat::chat_handler)) + .route("/files/{id}/sync-from-repo", post(files::sync_file_from_repo)) // Version history endpoints .route("/files/{id}/versions", get(versions::list_versions)) .route("/files/{id}/versions/{version}", get(versions::get_version)) @@ -95,6 +96,20 @@ pub fn make_router(state: SharedState) -> Router { .route("/mesh/tasks/{id}/merge/abort", post(mesh_merge::merge_abort)) .route("/mesh/tasks/{id}/merge/skip", post(mesh_merge::merge_skip)) .route("/mesh/tasks/{id}/merge/check", get(mesh_merge::merge_check)) + // Checkpoint endpoints + .route("/mesh/tasks/{id}/checkpoint", post(mesh_supervisor::create_checkpoint)) + .route("/mesh/tasks/{id}/checkpoints", get(mesh_supervisor::list_checkpoints)) + // Supervisor endpoints (for supervisor.sh) + .route("/mesh/supervisor/contracts/{contract_id}/tasks", get(mesh_supervisor::list_contract_tasks)) + .route("/mesh/supervisor/contracts/{contract_id}/tree", get(mesh_supervisor::get_contract_tree)) + .route("/mesh/supervisor/tasks", post(mesh_supervisor::spawn_task)) + .route("/mesh/supervisor/tasks/{task_id}/wait", post(mesh_supervisor::wait_for_task)) + .route("/mesh/supervisor/tasks/{task_id}/read-file", post(mesh_supervisor::read_worktree_file)) + // Supervisor git operations + .route("/mesh/supervisor/branches", post(mesh_supervisor::create_branch)) + .route("/mesh/supervisor/tasks/{task_id}/merge", post(mesh_supervisor::merge_task)) + .route("/mesh/supervisor/tasks/{task_id}/diff", get(mesh_supervisor::get_task_diff)) + .route("/mesh/supervisor/pr", post(mesh_supervisor::create_pr)) // Mesh WebSocket endpoints .route("/mesh/tasks/subscribe", get(mesh_ws::task_subscription_handler)) .route("/mesh/daemons/connect", get(mesh_daemon::daemon_handler)) @@ -113,6 +128,59 @@ pub fn make_router(state: SharedState) -> Router { ) .route("/users/me/password", axum::routing::put(users::change_password_handler)) .route("/users/me/email", axum::routing::put(users::change_email_handler)) + // Contract endpoints + .route( + "/contracts", + get(contracts::list_contracts).post(contracts::create_contract), + ) + .route( + "/contracts/{id}", + get(contracts::get_contract) + .put(contracts::update_contract) + .delete(contracts::delete_contract), + ) + .route("/contracts/{id}/phase", post(contracts::change_phase)) + .route("/contracts/{id}/events", get(contracts::get_events)) + .route("/contracts/{id}/chat", post(contract_chat::contract_chat_handler)) + .route( + "/contracts/{id}/chat/history", + get(contract_chat::get_contract_chat_history).delete(contract_chat::clear_contract_chat_history), + ) + // Contract daemon endpoints (for tasks to interact with contracts) + .route("/contracts/{id}/daemon/status", get(contract_daemon::get_contract_status)) + .route("/contracts/{id}/daemon/checklist", get(contract_daemon::get_contract_checklist)) + .route("/contracts/{id}/daemon/goals", get(contract_daemon::get_contract_goals)) + .route("/contracts/{id}/daemon/report", post(contract_daemon::post_progress_report)) + .route("/contracts/{id}/daemon/suggest-action", post(contract_daemon::get_suggest_action)) + .route("/contracts/{id}/daemon/completion-action", post(contract_daemon::get_completion_action)) + .route( + "/contracts/{id}/daemon/files", + get(contract_daemon::list_contract_files).post(contract_daemon::create_contract_file), + ) + .route( + "/contracts/{id}/daemon/files/{file_id}", + get(contract_daemon::get_contract_file).put(contract_daemon::update_contract_file), + ) + // Contract repository endpoints + .route("/contracts/{id}/repositories/remote", post(contracts::add_remote_repository)) + .route("/contracts/{id}/repositories/local", post(contracts::add_local_repository)) + .route("/contracts/{id}/repositories/managed", post(contracts::create_managed_repository)) + .route( + "/contracts/{id}/repositories/{repo_id}", + axum::routing::delete(contracts::delete_repository), + ) + .route( + "/contracts/{id}/repositories/{repo_id}/primary", + axum::routing::put(contracts::set_repository_primary), + ) + // Contract task association endpoints + .route( + "/contracts/{id}/tasks/{task_id}", + post(contracts::add_task_to_contract).delete(contracts::remove_task_from_contract), + ) + // Template endpoints + .route("/templates", get(templates::list_templates)) + .route("/templates/{id}", get(templates::get_template)) .with_state(state); let swagger = SwaggerUi::new("/swagger-ui") @@ -131,12 +199,60 @@ pub fn make_router(state: SharedState) -> Router { .layer(TraceLayer::new_for_http()) } +/// Stale daemon cleanup interval in seconds +const DAEMON_CLEANUP_INTERVAL_SECS: u64 = 60; +/// Daemon heartbeat timeout in seconds (delete daemons older than this) +const DAEMON_HEARTBEAT_TIMEOUT_SECS: i64 = 120; + /// Run the HTTP server with graceful shutdown support. /// /// # Arguments /// * `state` - Shared application state containing ML models /// * `addr` - Address to bind to (e.g., "0.0.0.0:8080") pub async fn run_server(state: SharedState, addr: &str) -> anyhow::Result<()> { + // Start background daemon cleanup task if database is available + if let Some(pool) = state.db_pool.clone() { + // Initial cleanup of any stale daemons from previous server run + match crate::db::repository::delete_stale_daemons(&pool, 0).await { + Ok(deleted) if deleted > 0 => { + tracing::info!( + deleted = deleted, + "Cleaned up stale daemons from previous server run" + ); + } + Err(e) => { + tracing::warn!(error = %e, "Failed to clean up stale daemons on startup"); + } + _ => {} + } + + // Spawn periodic cleanup task + tokio::spawn(async move { + let mut interval = tokio::time::interval( + std::time::Duration::from_secs(DAEMON_CLEANUP_INTERVAL_SECS) + ); + loop { + interval.tick().await; + match crate::db::repository::delete_stale_daemons( + &pool, + DAEMON_HEARTBEAT_TIMEOUT_SECS, + ).await { + Ok(deleted) if deleted > 0 => { + tracing::info!( + deleted = deleted, + timeout_secs = DAEMON_HEARTBEAT_TIMEOUT_SECS, + "Deleted stale daemons" + ); + } + Err(e) => { + tracing::warn!(error = %e, "Failed to delete stale daemons"); + } + _ => {} + } + } + }); + } + let app = make_router(state); let listener = tokio::net::TcpListener::bind(addr).await?; diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs index 425c466..c4f0f19 100644 --- a/makima/src/server/openapi.rs +++ b/makima/src/server/openapi.rs @@ -3,19 +3,22 @@ use utoipa::OpenApi; use crate::db::models::{ - BranchInfo, BranchListResponse, CreateFileRequest, CreateTaskRequest, Daemon, - DaemonDirectoriesResponse, DaemonDirectory, DaemonListResponse, File, FileListResponse, + AddLocalRepositoryRequest, AddRemoteRepositoryRequest, BranchInfo, BranchListResponse, + ChangePhaseRequest, Contract, ContractChatHistoryResponse, ContractChatMessageRecord, + ContractEvent, ContractListResponse, ContractRepository, ContractSummary, ContractWithRelations, + CreateContractRequest, CreateFileRequest, CreateManagedRepositoryRequest, CreateTaskRequest, + Daemon, DaemonDirectoriesResponse, DaemonDirectory, DaemonListResponse, File, FileListResponse, FileSummary, MergeCommitRequest, MergeCompleteCheckResponse, MergeResolveRequest, MergeResultResponse, MergeSkipRequest, MergeStartRequest, MergeStatusResponse, - MeshChatConversation, MeshChatHistoryResponse, MeshChatMessageRecord, SendMessageRequest, - Task, TaskEventListResponse, TaskListResponse, TaskSummary, TaskWithSubtasks, TranscriptEntry, - UpdateFileRequest, UpdateTaskRequest, + MeshChatConversation, MeshChatHistoryResponse, MeshChatMessageRecord, SendMessageRequest, Task, + TaskEventListResponse, TaskListResponse, TaskSummary, TaskWithSubtasks, TranscriptEntry, + UpdateContractRequest, UpdateFileRequest, UpdateTaskRequest, }; use crate::server::auth::{ ApiKey, ApiKeyInfoResponse, CreateApiKeyRequest, CreateApiKeyResponse, RefreshApiKeyRequest, RefreshApiKeyResponse, RevokeApiKeyResponse, }; -use crate::server::handlers::{api_keys, files, listen, mesh, mesh_chat, mesh_merge, users}; +use crate::server::handlers::{api_keys, contract_chat, contracts, files, listen, mesh, mesh_chat, mesh_merge, users}; use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage, TranscriptMessage}; #[derive(OpenApi)] @@ -33,6 +36,7 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage files::create_file, files::update_file, files::delete_file, + files::sync_file_from_repo, // Mesh endpoints mesh::list_tasks, mesh::get_task, @@ -71,6 +75,25 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage users::change_password_handler, users::change_email_handler, users::delete_account_handler, + // Contract endpoints + contracts::list_contracts, + contracts::get_contract, + contracts::create_contract, + contracts::update_contract, + contracts::delete_contract, + contracts::change_phase, + contracts::get_events, + contracts::add_remote_repository, + contracts::add_local_repository, + contracts::create_managed_repository, + contracts::delete_repository, + contracts::set_repository_primary, + contracts::add_task_to_contract, + contracts::remove_task_from_contract, + // Contract chat endpoints + contract_chat::contract_chat_handler, + contract_chat::get_contract_chat_history, + contract_chat::clear_contract_chat_history, ), components( schemas( @@ -102,6 +125,9 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage MeshChatConversation, MeshChatMessageRecord, MeshChatHistoryResponse, + // Contract chat schemas + ContractChatMessageRecord, + ContractChatHistoryResponse, // Merge schemas BranchInfo, BranchListResponse, @@ -127,12 +153,26 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage users::ChangeEmailResponse, users::DeleteAccountRequest, users::DeleteAccountResponse, + // Contract schemas + Contract, + ContractSummary, + ContractListResponse, + ContractWithRelations, + ContractRepository, + ContractEvent, + CreateContractRequest, + UpdateContractRequest, + AddRemoteRepositoryRequest, + AddLocalRepositoryRequest, + CreateManagedRepositoryRequest, + ChangePhaseRequest, ) ), tags( (name = "Listen", description = "Speech-to-text streaming endpoints"), (name = "Files", description = "Transcript file management"), (name = "Mesh", description = "Task orchestration for Claude Code instances"), + (name = "Contracts", description = "Contract management with workflow phases"), (name = "API Keys", description = "API key management for programmatic access"), (name = "Users", description = "User account management"), ) diff --git a/makima/src/server/state.rs b/makima/src/server/state.rs index e89197a..1c28544 100644 --- a/makima/src/server/state.rs +++ b/makima/src/server/state.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use dashmap::DashMap; use sqlx::PgPool; -use tokio::sync::{broadcast, mpsc, Mutex}; +use tokio::sync::{broadcast, mpsc, Mutex, OnceCell}; use uuid::Uuid; use crate::listen::{DiarizationConfig, ParakeetEOU, ParakeetTDT, Sortformer}; @@ -75,6 +75,34 @@ pub struct TaskOutputNotification { pub is_partial: bool, } +/// Notification for task completion events (for supervisor tasks to monitor). +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TaskCompletionNotification { + /// ID of the completed task + pub task_id: Uuid, + /// Owner ID for data isolation + #[serde(skip)] + pub owner_id: Option<Uuid>, + /// Contract ID if task belongs to a contract + #[serde(skip_serializing_if = "Option::is_none")] + pub contract_id: Option<Uuid>, + /// Parent task ID (to notify parent/supervisor) + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_task_id: Option<Uuid>, + /// Final status: "done", "failed", etc. + pub status: String, + /// Summary of task output/results + #[serde(skip_serializing_if = "Option::is_none")] + pub output_summary: Option<String>, + /// Path to the task's worktree (for reading files) + #[serde(skip_serializing_if = "Option::is_none")] + pub worktree_path: Option<String>, + /// Error message if task failed + #[serde(skip_serializing_if = "Option::is_none")] + pub error_message: Option<String>, +} + /// Command sent from server to daemon. #[derive(Debug, Clone, serde::Serialize)] #[serde(tag = "type", rename_all = "camelCase")] @@ -119,6 +147,12 @@ pub enum DaemonCommand { /// Files to copy from parent task's worktree #[serde(rename = "copyFiles")] copy_files: Option<Vec<String>>, + /// Contract ID if this task is associated with a contract + #[serde(rename = "contractId")] + contract_id: Option<Uuid>, + /// Whether this task is a supervisor (long-running contract orchestrator) + #[serde(rename = "isSupervisor")] + is_supervisor: bool, }, /// Pause a running task PauseTask { @@ -251,6 +285,69 @@ pub enum DaemonCommand { target_dir: String, }, + // ========================================================================= + // Contract File Commands + // ========================================================================= + + /// Read a file from a repository linked to a contract + ReadRepoFile { + /// Request ID for correlating response + #[serde(rename = "requestId")] + request_id: Uuid, + /// Contract ID (used for logging/context) + #[serde(rename = "contractId")] + contract_id: Uuid, + /// Path to the file within the repository + #[serde(rename = "filePath")] + file_path: String, + /// Full repository path on daemon's filesystem + #[serde(rename = "repoPath")] + repo_path: String, + }, + + // ========================================================================= + // Supervisor Git Commands + // ========================================================================= + + /// Create a new branch in a task's worktree + CreateBranch { + #[serde(rename = "taskId")] + task_id: Uuid, + #[serde(rename = "branchName")] + branch_name: String, + /// Optional reference to create branch from (task_id or SHA) + #[serde(rename = "fromRef")] + from_ref: Option<String>, + }, + + /// Merge a task's changes to a target branch + MergeTaskToTarget { + #[serde(rename = "taskId")] + task_id: Uuid, + /// Target branch to merge into (default: task's base branch) + #[serde(rename = "targetBranch")] + target_branch: Option<String>, + /// Whether to squash commits + squash: bool, + }, + + /// Create a pull request for a task's changes + CreatePR { + #[serde(rename = "taskId")] + task_id: Uuid, + title: String, + body: Option<String>, + /// Base branch for the PR (default: main) + #[serde(rename = "baseBranch")] + base_branch: String, + }, + + /// Get the diff for a task's changes + GetTaskDiff { + #[serde(rename = "taskId")] + task_id: Uuid, + }, + /// Error response Error { code: String, message: String }, } @@ -278,16 +375,29 @@ pub struct DaemonConnectionInfo { pub worktrees_directory: Option<String>, } -/// Shared application state containing ML models and database pool. -/// -/// Models are wrapped in `Mutex` for thread-safe mutable access during inference. -pub struct AppState { - /// Speech-to-text model (Parakeet TDT) +/// Configuration paths for ML models (used for lazy loading). +#[derive(Clone)] +pub struct ModelConfig { + pub parakeet_model_dir: String, + pub parakeet_eou_dir: String, + pub sortformer_model_path: String, +} + +/// Lazily-loaded ML models. +pub struct MlModels { pub parakeet: Mutex<ParakeetTDT>, - /// End-of-Utterance detection model for streaming pub parakeet_eou: Mutex<ParakeetEOU>, - /// Speaker diarization model (Sortformer) pub sortformer: Mutex<Sortformer>, +} + +/// Shared application state containing ML models and database pool. +/// +/// Models are lazily loaded on first use to speed up server startup. +pub struct AppState { + /// ML model configuration (paths for lazy loading) + pub model_config: Option<ModelConfig>, + /// Lazily-loaded ML models (initialized on first Listen connection) + pub ml_models: OnceCell<MlModels>, /// Optional database connection pool pub db_pool: Option<PgPool>, /// Broadcast channel for file update notifications @@ -296,6 +406,8 @@ pub struct AppState { pub task_updates: broadcast::Sender<TaskUpdateNotification>, /// Broadcast channel for task output streaming pub task_output: broadcast::Sender<TaskOutputNotification>, + /// Broadcast channel for task completion notifications (for supervisors) + pub task_completions: broadcast::Sender<TaskCompletionNotification>, /// Active daemon connections (keyed by connection_id) pub daemon_connections: DashMap<String, DaemonConnectionInfo>, /// Tool keys for orchestrator API access (key -> task_id) @@ -305,7 +417,9 @@ pub struct AppState { } impl AppState { - /// Load all ML models from the specified directories. + /// Create AppState with ML model configuration for lazy loading. + /// + /// Models are NOT loaded at startup - they will be loaded on first Listen connection. /// /// # Arguments /// * `parakeet_model_dir` - Path to the Parakeet TDT model directory @@ -315,19 +429,12 @@ impl AppState { parakeet_model_dir: &str, parakeet_eou_dir: &str, sortformer_model_path: &str, - ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> { - let parakeet = ParakeetTDT::from_pretrained(parakeet_model_dir, None)?; - let parakeet_eou = ParakeetEOU::from_pretrained(parakeet_eou_dir, None)?; - let sortformer = Sortformer::with_config( - sortformer_model_path, - None, - DiarizationConfig::callhome(), - )?; - + ) -> Self { // Create broadcast channels with buffer for 256 messages let (file_updates, _) = broadcast::channel(256); let (task_updates, _) = broadcast::channel(256); let (task_output, _) = broadcast::channel(1024); // Larger buffer for output streaming + let (task_completions, _) = broadcast::channel(256); // For supervisor task monitoring // Initialize JWT verifier from environment (optional) // Requires SUPABASE_URL and either SUPABASE_JWT_PUBLIC_KEY (RS256) or SUPABASE_JWT_SECRET (HS256) @@ -357,18 +464,61 @@ impl AppState { } }; - Ok(Self { - parakeet: Mutex::new(parakeet), - parakeet_eou: Mutex::new(parakeet_eou), - sortformer: Mutex::new(sortformer), + Self { + model_config: Some(ModelConfig { + parakeet_model_dir: parakeet_model_dir.to_string(), + parakeet_eou_dir: parakeet_eou_dir.to_string(), + sortformer_model_path: sortformer_model_path.to_string(), + }), + ml_models: OnceCell::new(), db_pool: None, file_updates, task_updates, task_output, + task_completions, daemon_connections: DashMap::new(), tool_keys: DashMap::new(), jwt_verifier, - }) + } + } + + /// Get or initialize ML models (lazy loading). + /// + /// Models are loaded on first call and cached for subsequent calls. + /// Returns None if model config is not set. + pub async fn get_ml_models(&self) -> Result<&MlModels, Box<dyn std::error::Error + Send + Sync>> { + let config = self.model_config.as_ref() + .ok_or_else(|| "ML model configuration not set")?; + + self.ml_models.get_or_try_init(|| async { + tracing::info!( + parakeet = %config.parakeet_model_dir, + eou = %config.parakeet_eou_dir, + sortformer = %config.sortformer_model_path, + "Lazy-loading ML models on first Listen connection..." + ); + + let parakeet = ParakeetTDT::from_pretrained(&config.parakeet_model_dir, None)?; + let parakeet_eou = ParakeetEOU::from_pretrained(&config.parakeet_eou_dir, None)?; + let sortformer = Sortformer::with_config( + &config.sortformer_model_path, + None, + DiarizationConfig::callhome(), + )?; + + tracing::info!("ML models loaded successfully"); + + Ok(MlModels { + parakeet: Mutex::new(parakeet), + parakeet_eou: Mutex::new(parakeet_eou), + sortformer: Mutex::new(sortformer), + }) + }).await + } + + /// Check if ML models are loaded. + pub fn are_models_loaded(&self) -> bool { + self.ml_models.initialized() } /// Set the database pool. @@ -399,6 +549,13 @@ impl AppState { let _ = self.task_output.send(notification); } + /// Broadcast a task completion notification to all subscribers. + /// + /// Used to notify supervisor tasks when their child tasks complete. + pub fn broadcast_task_completion(&self, notification: TaskCompletionNotification) { + let _ = self.task_completions.send(notification); + } + /// Register a new daemon connection. /// /// Returns the connection_id for later reference. @@ -544,6 +701,167 @@ impl AppState { self.tool_keys.retain(|_, v| *v != task_id); tracing::info!(task_id = %task_id, "Revoked tool key"); } + + // ========================================================================= + // Supervisor Notifications + // ========================================================================= + + /// Notify a contract's supervisor task about an event. + /// + /// This sends a message to the supervisor's stdin so it can react to changes + /// in tasks or contract state. + pub async fn notify_supervisor( + &self, + supervisor_task_id: Uuid, + supervisor_daemon_id: Option<Uuid>, + message: &str, + ) -> Result<(), String> { + // Only send if we have a daemon ID + let daemon_id = match supervisor_daemon_id { + Some(id) => id, + None => { + tracing::debug!( + supervisor_task_id = %supervisor_task_id, + "Supervisor has no daemon assigned, skipping notification" + ); + return Ok(()); + } + }; + + let command = DaemonCommand::SendMessage { + task_id: supervisor_task_id, + message: message.to_string(), + }; + + self.send_daemon_command(daemon_id, command).await + } + + /// Format and send a task completion notification to a supervisor. + pub async fn notify_supervisor_of_task_completion( + &self, + supervisor_task_id: Uuid, + supervisor_daemon_id: Option<Uuid>, + completed_task_id: Uuid, + completed_task_name: &str, + status: &str, + progress_summary: Option<&str>, + error_message: Option<&str>, + ) { + let mut message = format!( + "TASK_COMPLETED task_id={} name=\"{}\" status={}", + completed_task_id, completed_task_name, status + ); + + if let Some(summary) = progress_summary { + // Escape newlines in summary + let escaped = summary.replace('\n', "\\n"); + message.push_str(&format!(" summary=\"{}\"", escaped)); + } + + if let Some(err) = error_message { + let escaped = err.replace('\n', "\\n"); + message.push_str(&format!(" error=\"{}\"", escaped)); + } + + if let Err(e) = self.notify_supervisor( + supervisor_task_id, + supervisor_daemon_id, + &message, + ).await { + tracing::warn!( + supervisor_task_id = %supervisor_task_id, + completed_task_id = %completed_task_id, + "Failed to notify supervisor of task completion: {}", + e + ); + } + } + + /// Format and send a task status change notification to a supervisor. + pub async fn notify_supervisor_of_task_update( + &self, + supervisor_task_id: Uuid, + supervisor_daemon_id: Option<Uuid>, + updated_task_id: Uuid, + updated_task_name: &str, + new_status: &str, + updated_fields: &[String], + ) { + let message = format!( + "TASK_UPDATED task_id={} name=\"{}\" status={} fields={}", + updated_task_id, + updated_task_name, + new_status, + updated_fields.join(",") + ); + + if let Err(e) = self.notify_supervisor( + supervisor_task_id, + supervisor_daemon_id, + &message, + ).await { + tracing::warn!( + supervisor_task_id = %supervisor_task_id, + updated_task_id = %updated_task_id, + "Failed to notify supervisor of task update: {}", + e + ); + } + } + + /// Format and send a contract phase change notification to a supervisor. + pub async fn notify_supervisor_of_phase_change( + &self, + supervisor_task_id: Uuid, + supervisor_daemon_id: Option<Uuid>, + contract_id: Uuid, + new_phase: &str, + ) { + let message = format!( + "PHASE_CHANGED contract_id={} phase={}", + contract_id, new_phase + ); + + if let Err(e) = self.notify_supervisor( + supervisor_task_id, + supervisor_daemon_id, + &message, + ).await { + tracing::warn!( + supervisor_task_id = %supervisor_task_id, + contract_id = %contract_id, + "Failed to notify supervisor of phase change: {}", + e + ); + } + } + + /// Format and send a new task created notification to a supervisor. + pub async fn notify_supervisor_of_task_created( + &self, + supervisor_task_id: Uuid, + supervisor_daemon_id: Option<Uuid>, + new_task_id: Uuid, + new_task_name: &str, + ) { + let message = format!( + "TASK_CREATED task_id={} name=\"{}\"", + new_task_id, new_task_name + ); + + if let Err(e) = self.notify_supervisor( + supervisor_task_id, + supervisor_daemon_id, + &message, + ).await { + tracing::warn!( + supervisor_task_id = %supervisor_task_id, + new_task_id = %new_task_id, + "Failed to notify supervisor of task creation: {}", + e + ); + } + } } /// Type alias for the shared application state. |
