diff options
Diffstat (limited to 'makima/src')
34 files changed, 315 insertions, 12453 deletions
diff --git a/makima/src/bin/makima.rs b/makima/src/bin/makima.rs index 7b8cdb6..bf8c35f 100644 --- a/makima/src/bin/makima.rs +++ b/makima/src/bin/makima.rs @@ -6,9 +6,8 @@ use std::sync::Arc; use makima::daemon::api::ApiClient; use makima::daemon::cli::{ - Cli, CliConfig, Commands, ConfigCommand, DirectiveCommand, ViewArgs, + Cli, CliConfig, Commands, ConfigCommand, DirectiveCommand, }; -use makima::daemon::tui::{self, Action, App, ListItem, ViewType, TuiWsClient, WsEvent, OutputLine, OutputMessageType, WsConnectionState, RepositorySuggestion}; use makima::daemon::config::{DaemonConfig, RepoEntry}; use makima::daemon::db::LocalDb; use makima::daemon::error::DaemonError; @@ -27,7 +26,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { Commands::Server(args) => run_server(args).await, Commands::Daemon(args) => run_daemon(args).await, Commands::Directive(cmd) => run_directive(cmd).await, - Commands::View(args) => run_view(args).await, Commands::Config(cmd) => run_config(cmd).await, } } @@ -625,51 +623,6 @@ async fn run_directive_verify( } /// Run the TUI view command. -async fn run_view(args: ViewArgs) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { - // Load CLI config for defaults - let config = CliConfig::load(); - - // Get API URL and key, preferring CLI args > env vars > config file - // Filter out empty strings - let api_url = args.api_url - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| config.get_api_url()); - let api_key = match args.api_key.filter(|s| !s.is_empty()) { - Some(key) => key, - None => config.get_api_key().ok_or_else(|| { - eprintln!("Error: No API key provided."); - eprintln!(); - eprintln!("Set your API key using one of these methods:"); - eprintln!(" 1. Run: makima config set-key YOUR_API_KEY"); - eprintln!(" 2. Set environment variable: export MAKIMA_API_KEY=YOUR_API_KEY"); - eprintln!(" 3. Pass via CLI: makima view --api-key YOUR_API_KEY"); - "No API key configured" - })?, - }; - - // Create API client - let client = ApiClient::new(api_url.clone(), api_key.clone())?; - - // Start WebSocket client for task output streaming - let ws_client = TuiWsClient::start(api_url, api_key); - - // Start at contracts view - let mut app = App::new(ViewType::Contracts); - - // Set initial search query if provided - if let Some(ref query) = args.query { - app.search_query = query.clone(); - } - - // Load initial contracts - let items = load_contracts(&client).await?; - app.set_items(items); - - // Run TUI with navigation support - let result = run_tui_with_navigation(app, client, ws_client).await; - - result -} /// Run config commands. async fn run_config(cmd: ConfigCommand) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { @@ -715,497 +668,6 @@ async fn run_config(cmd: ConfigCommand) -> Result<(), Box<dyn std::error::Error } /// Load contracts from API -async fn load_contracts(client: &ApiClient) -> Result<Vec<ListItem>, Box<dyn std::error::Error + Send + Sync>> { - let result = client.list_contracts().await?; - let items = result.0.get("contracts") - .and_then(|v| v.as_array()) - .map(|arr| arr.iter().filter_map(ListItem::from_contract).collect()) - .unwrap_or_default(); - Ok(items) -} - -/// Load tasks for a contract from API -async fn load_tasks(client: &ApiClient, contract_id: uuid::Uuid) -> Result<Vec<ListItem>, Box<dyn std::error::Error + Send + Sync>> { - // Use get_contract which returns tasks as part of the response (works with regular API key auth) - let result = client.get_contract(contract_id).await?; - let mut items: Vec<ListItem> = result.0.get("tasks") - .and_then(|v| v.as_array()) - .map(|arr| arr.iter().filter_map(ListItem::from_task).collect()) - .unwrap_or_default(); - - // Sort tasks: supervisor first, then by status (running first), then by name - items.sort_by(|a, b| { - // Check if task is supervisor (role field in extra data) - let a_is_supervisor = a.extra.get("role") - .and_then(|v| v.as_str()) - .map(|s| s == "supervisor") - .unwrap_or(false); - let b_is_supervisor = b.extra.get("role") - .and_then(|v| v.as_str()) - .map(|s| s == "supervisor") - .unwrap_or(false); - - // Supervisor first - match (a_is_supervisor, b_is_supervisor) { - (true, false) => std::cmp::Ordering::Less, - (false, true) => std::cmp::Ordering::Greater, - _ => { - // Then by status: running/working tasks first - let status_order = |s: Option<&String>| -> i32 { - match s.map(|x| x.as_str()) { - Some("running") | Some("working") => 0, - Some("pending") | Some("queued") => 1, - Some("completed") | Some("done") => 2, - Some("failed") | Some("error") => 3, - _ => 4, - } - }; - let a_order = status_order(a.status.as_ref()); - let b_order = status_order(b.status.as_ref()); - - match a_order.cmp(&b_order) { - std::cmp::Ordering::Equal => a.name.cmp(&b.name), - other => other, - } - } - } - }); - - Ok(items) -} - -/// Run the TUI with navigation support for drill-down views -async fn run_tui_with_navigation( - mut app: App, - client: ApiClient, - ws_client: TuiWsClient, -) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { - use crossterm::{ - event::{DisableMouseCapture, EnableMouseCapture}, - execute, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, - }; - use ratatui::backend::CrosstermBackend; - use std::io; - - // Setup terminal - enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = ratatui::Terminal::new(backend)?; - - let result = run_tui_loop(&mut terminal, &mut app, &client, &ws_client).await; - - // Cleanup WebSocket - ws_client.shutdown(); - - // Cleanup terminal - disable_raw_mode()?; - execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - )?; - terminal.show_cursor()?; - - match result { - Ok(Some(path)) => { - // Output the path for shell integration - tui::print_path(&path); - } - Ok(None) => { - // Normal exit - } - Err(e) => { - eprintln!("TUI error: {}", e); - std::process::exit(1); - } - } - - Ok(()) -} - -/// Main TUI event loop with async data loading -async fn run_tui_loop( - terminal: &mut ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>, - app: &mut App, - client: &ApiClient, - ws_client: &TuiWsClient, -) -> Result<Option<String>, Box<dyn std::error::Error + Send + Sync>> { - use crossterm::event::{self, Event}; - use std::time::Duration; - - // Track currently subscribed task for cleanup - let mut subscribed_task_id: Option<uuid::Uuid> = None; - - loop { - terminal.draw(|f| tui::ui::render(f, app))?; - - // Process WebSocket events (non-blocking) - while let Some(ws_event) = ws_client.try_recv() { - handle_ws_event(app, ws_event); - } - - // Poll for keyboard events with short timeout (50ms for responsive WS handling) - if event::poll(Duration::from_millis(50))? { - if let Event::Key(key) = event::read()? { - let action = tui::event::handle_key_event(app, key); - match action { - Action::Quit => break, - Action::OutputPath(path) => return Ok(Some(path)), - Action::None => {} - _ => { - let result = app.handle_action(action); - match result { - Action::OutputPath(path) => return Ok(Some(path)), - Action::LoadTasks { contract_id, contract_name: _ } => { - // Unsubscribe from any previous task - if let Some(task_id) = subscribed_task_id.take() { - ws_client.unsubscribe(task_id); - } - // Load tasks for the selected contract - match load_tasks(client, contract_id).await { - Ok(items) => { - app.set_items(items); - } - Err(e) => { - app.status_message = Some(format!("Failed to load tasks: {}", e)); - } - } - } - Action::LoadTaskOutput { task_id, task_name: _ } => { - // Clear previous output - app.output_buffer.clear(); - app.ws_state = WsConnectionState::Connecting; - - // Unsubscribe from previous task if any - if let Some(old_task_id) = subscribed_task_id.take() { - ws_client.unsubscribe(old_task_id); - } - - // Load task output history first - app.status_message = Some("Loading output history...".to_string()); - match client.get_task_output(task_id).await { - Ok(result) => { - // Parse the entries array from response - if let Some(entries) = result.0.get("entries").and_then(|v| v.as_array()) { - for entry in entries { - if let Some(line) = parse_output_entry(entry) { - app.output_buffer.add_line(line); - } - } - let count = entries.len(); - app.status_message = Some(format!("Loaded {} history entries, streaming live...", count)); - } - } - Err(e) => { - app.status_message = Some(format!("Failed to load history: {}", e)); - } - } - - // Subscribe to new task output for live updates - ws_client.subscribe(task_id); - subscribed_task_id = Some(task_id); - } - Action::PerformDelete { id, item_type } => { - // Perform the delete API call - let delete_result = match item_type { - ViewType::Contracts => { - client.delete_contract(id).await - } - ViewType::Tasks => { - client.delete_task(id).await - } - ViewType::TaskOutput => { - // Can't delete from output view - Ok(()) - } - }; - - match delete_result { - Ok(()) => { - app.status_message = Some("Deleted successfully".to_string()); - // Remove item from list - app.items.retain(|item| item.id != id); - app.update_filtered_items(); - } - Err(e) => { - app.status_message = Some(format!("Delete failed: {}", e)); - } - } - } - Action::PerformUpdate { id, item_type, name, description } => { - // Perform the update API call - let update_result = match item_type { - ViewType::Contracts => { - client.update_contract(id, Some(name.clone()), Some(description.clone())).await.map(|_| ()) - } - ViewType::Tasks => { - // For tasks, description is the plan - client.update_task(id, Some(name.clone()), Some(description.clone())).await.map(|_| ()) - } - ViewType::TaskOutput => { - // Can't edit from output view - Ok(()) - } - }; - - match update_result { - Ok(()) => { - app.status_message = Some("Updated successfully".to_string()); - // Update item in list - for item in &mut app.items { - if item.id == id { - item.name = name.clone(); - item.description = Some(description.clone()); - break; - } - } - app.update_filtered_items(); - } - Err(e) => { - app.status_message = Some(format!("Update failed: {}", e)); - } - } - } - Action::Refresh => { - // Reload data for current view - match app.view_type { - ViewType::Contracts => { - // Unsubscribe from task when going back to contracts - if let Some(task_id) = subscribed_task_id.take() { - ws_client.unsubscribe(task_id); - } - match load_contracts(client).await { - Ok(items) => app.set_items(items), - Err(e) => app.status_message = Some(format!("Refresh failed: {}", e)), - } - } - ViewType::Tasks => { - // Unsubscribe from task when going back to tasks - if let Some(task_id) = subscribed_task_id.take() { - ws_client.unsubscribe(task_id); - } - if let Some(contract_id) = app.contract_id { - match load_tasks(client, contract_id).await { - Ok(items) => app.set_items(items), - Err(e) => app.status_message = Some(format!("Refresh failed: {}", e)), - } - } - } - ViewType::TaskOutput => { - // Re-subscribe to the task output - if let Some(task_id) = app.task_id { - app.output_buffer.clear(); - app.ws_state = WsConnectionState::Connecting; - ws_client.subscribe(task_id); - subscribed_task_id = Some(task_id); - app.status_message = Some("Reconnecting...".to_string()); - } - } - } - } - Action::GoBack => { - // Unsubscribe when going back from output view - if let Some(task_id) = subscribed_task_id.take() { - ws_client.unsubscribe(task_id); - app.ws_state = WsConnectionState::Disconnected; - } - } - Action::PerformCreateContract { name: _, description: _, contract_type: _, repository_url: _ } => { - // Contracts removed in Phase 5 — directives are - // the only way to organise multi-task work now. - // The TUI's contract create form is dead code - // pending a wider TUI refresh. - app.status_message = Some( - "Contracts have been removed. Use directives instead.".to_string() - ); - } - Action::LoadRepoSuggestions => { - // Load repository suggestions for the create form - app.status_message = Some("Loading recent repositories...".to_string()); - // Force a redraw to show the status - terminal.draw(|f| tui::ui::render(f, app)).ok(); - - // Fetch all repository types (remote and local) - match client.get_repository_suggestions(None, Some(10)).await { - Ok(result) => { - // Parse suggestions from API response - let suggestions: Vec<RepositorySuggestion> = result.0 - .get("entries") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter().filter_map(|entry| { - let name = entry.get("name") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let repository_url = entry.get("repositoryUrl") - .or_else(|| entry.get("repository_url")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let local_path = entry.get("localPath") - .or_else(|| entry.get("local_path")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let source_type = entry.get("sourceType") - .or_else(|| entry.get("source_type")) - .and_then(|v| v.as_str()) - .unwrap_or("remote") - .to_string(); - let use_count = entry.get("useCount") - .or_else(|| entry.get("use_count")) - .and_then(|v| v.as_i64()) - .unwrap_or(0) as i32; - - // Only include if we have a URL or path - if repository_url.is_some() || local_path.is_some() { - Some(RepositorySuggestion { - name, - repository_url, - local_path, - source_type, - use_count, - }) - } else { - None - } - }).collect() - }) - .unwrap_or_default(); - - let count = suggestions.len(); - app.create_state.set_suggestions(suggestions); - if count > 0 { - app.status_message = Some(format!("Found {} recent repositories", count)); - } else { - app.status_message = Some("No recent repositories found".to_string()); - } - } - Err(e) => { - app.status_message = Some(format!("Could not load suggestions: {}", e)); - app.create_state.suggestions_loaded = true; - } - } - } - _ => {} - } - } - } - } - } - - if app.should_quit { - break; - } - } - - Ok(None) -} - -/// Extract a repository name from a URL. -/// E.g., "https://github.com/owner/repo.git" -> "owner/repo" -fn extract_repo_name(url: &str) -> String { - // Remove .git suffix if present - let url = url.trim_end_matches(".git"); - - // Try to extract owner/repo from common Git hosting URLs - if let Some(path) = url.strip_prefix("https://github.com/") - .or_else(|| url.strip_prefix("https://gitlab.com/")) - .or_else(|| url.strip_prefix("https://bitbucket.org/")) - .or_else(|| url.strip_prefix("git@github.com:")) - .or_else(|| url.strip_prefix("git@gitlab.com:")) - .or_else(|| url.strip_prefix("git@bitbucket.org:")) - { - // Return owner/repo - return path.to_string(); - } - - // Fallback: try to get the last path segment - if let Some(last_segment) = url.rsplit('/').next() { - if !last_segment.is_empty() { - return last_segment.to_string(); - } - } - - // Last resort: use the full URL as the name - url.to_string() -} - -/// Parse an output entry from the API response into an OutputLine -fn parse_output_entry(entry: &serde_json::Value) -> Option<OutputLine> { - let message_type = entry.get("messageType") - .and_then(|v| v.as_str()) - .unwrap_or("raw"); - let content = entry.get("content") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let tool_name = entry.get("toolName") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let is_error = entry.get("isError") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let cost_usd = entry.get("costUsd") - .and_then(|v| v.as_f64()); - let duration_ms = entry.get("durationMs") - .and_then(|v| v.as_u64()); - - Some(OutputLine { - message_type: OutputMessageType::from_str(message_type), - content, - tool_name, - is_error, - cost_usd, - duration_ms, - }) -} - -/// Handle a WebSocket event and update app state -fn handle_ws_event(app: &mut App, event: WsEvent) { - match event { - WsEvent::Connected => { - app.ws_state = WsConnectionState::Connected; - app.status_message = Some("Connected".to_string()); - } - WsEvent::Disconnected => { - app.ws_state = WsConnectionState::Disconnected; - app.status_message = Some("Disconnected".to_string()); - } - WsEvent::Reconnecting { attempt } => { - app.ws_state = WsConnectionState::Reconnecting; - app.status_message = Some(format!("Reconnecting (attempt {})...", attempt)); - } - WsEvent::Subscribed { task_id: _ } => { - app.ws_state = WsConnectionState::Connected; - app.status_message = Some("Subscribed to task output".to_string()); - } - WsEvent::Unsubscribed { task_id: _ } => { - // No status update needed - } - WsEvent::TaskOutput(output) => { - // Convert WebSocket event to OutputLine - let line = OutputLine { - message_type: OutputMessageType::from_str(&output.message_type), - content: output.content, - tool_name: output.tool_name, - is_error: output.is_error.unwrap_or(false), - cost_usd: output.cost_usd, - duration_ms: output.duration_ms, - }; - app.output_buffer.add_line(line); - // Clear status message once we're receiving output - if app.status_message.as_ref().map(|s| s.contains("Subscribed")).unwrap_or(false) { - app.status_message = None; - } - } - WsEvent::Error { message } => { - app.status_message = Some(format!("WS Error: {}", message)); - } - } -} fn init_logging(level: &str, format: &str) { let filter = EnvFilter::try_from_default_env() diff --git a/makima/src/daemon/cli/mod.rs b/makima/src/daemon/cli/mod.rs index acad9ad..077a37e 100644 --- a/makima/src/daemon/cli/mod.rs +++ b/makima/src/daemon/cli/mod.rs @@ -4,7 +4,6 @@ pub mod config; pub mod daemon; pub mod directive; pub mod server; -pub mod view; use clap::{Parser, Subcommand}; @@ -12,7 +11,6 @@ pub use config::CliConfig; pub use daemon::DaemonArgs; pub use directive::DirectiveArgs; pub use server::ServerArgs; -pub use view::ViewArgs; /// Makima - unified CLI for server, daemon, and task management. #[derive(Parser, Debug)] @@ -35,9 +33,6 @@ pub enum Commands { #[command(subcommand)] Directive(DirectiveCommand), - /// Interactive TUI browser for directives and tasks - View(ViewArgs), - /// Configure CLI settings (API key, server URL) /// /// Saves configuration to ~/.makima/config.toml for use by CLI commands. diff --git a/makima/src/daemon/cli/view.rs b/makima/src/daemon/cli/view.rs deleted file mode 100644 index b9fa82f..0000000 --- a/makima/src/daemon/cli/view.rs +++ /dev/null @@ -1,93 +0,0 @@ -//! View subcommand - interactive TUI browser for contracts and tasks. -//! -//! The `makima view` command provides an interactive Terminal User Interface (TUI) -//! for browsing and managing makima contracts and their tasks. It features -//! drill-down navigation, fuzzy search filtering, and real-time task output streaming. -//! -//! # Usage -//! -//! ```bash -//! # Browse contracts interactively -//! makima view -//! -//! # Browse with an initial search query -//! makima view "my project" -//! -//! # Change directory to selected task's worktree -//! cd $(makima view) -//! ``` -//! -//! # Keyboard Shortcuts -//! -//! | Key | Action | -//! |---------------|-------------------------------| -//! | `↑` / `k` | Move selection up | -//! | `↓` / `j` | Move selection down | -//! | `Enter` / `l` | Drill into item | -//! | `Esc` / `h` | Go back to previous view | -//! | `e` | Edit item (inline) | -//! | `d` | Delete item (with confirm) | -//! | `/` | Focus search input | -//! | `Space` | Show details in preview pane | -//! | `q` | Quit | -//! | `c` | Navigate to worktree (cd) | -//! | `r` | Refresh data | -//! -//! # Navigation -//! -//! - **Contracts view**: Lists all contracts. Press Enter to see tasks. -//! - **Tasks view**: Shows tasks for a contract. Press Enter to view output. -//! - **Output view**: Streams real-time task output with tool call formatting. -//! -//! # Features -//! -//! - **Drill-down Navigation**: Contracts → Tasks → Task Output -//! - **Fuzzy Search**: Type to filter items in real-time -//! - **Real-time Streaming**: View live task output via WebSocket -//! - **Preview Pane**: See item details without leaving the list - -use clap::Args; - -/// Interactive TUI browser for contracts and tasks. -/// -/// Provides a fuzzy-searchable interface for browsing contracts, -/// viewing their tasks, and streaming real-time task output. -/// -/// # Examples -/// -/// Browse contracts: -/// ```bash -/// makima view -/// ``` -/// -/// Browse with initial search: -/// ```bash -/// makima view "auth" -/// ``` -#[derive(Args, Debug, Clone)] -pub struct ViewArgs { - /// API URL for the makima server - /// - /// If not provided, uses MAKIMA_API_URL env var or ~/.makima/config.toml - #[arg(long, env = "MAKIMA_API_URL")] - pub api_url: Option<String>, - - /// API key for authentication - /// - /// If not provided, uses MAKIMA_API_KEY env var or ~/.makima/config.toml - #[arg(long, env = "MAKIMA_API_KEY")] - pub api_key: Option<String>, - - /// Initial search query - /// - /// Pre-populates the search field with this query when the TUI opens. - #[arg(index = 1)] - pub query: Option<String>, - - /// Disable the preview pane - /// - /// Shows only the item list without the side preview panel. - /// Useful for smaller terminal windows. - #[arg(long)] - pub no_preview: bool, -} diff --git a/makima/src/daemon/mod.rs b/makima/src/daemon/mod.rs index e15608b..014b6d7 100644 --- a/makima/src/daemon/mod.rs +++ b/makima/src/daemon/mod.rs @@ -3,9 +3,11 @@ //! 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 -//! - `makima view` - Interactive TUI browser for tasks, contracts, and files +//! - `makima directive` - Directive command group (ask, create-order, etc.) +//! +//! The legacy `makima supervisor` / `makima contract` / `makima view` +//! command groups were removed alongside the legacy contracts + +//! supervisor task-grouping system. pub mod api; pub mod cli; @@ -19,10 +21,9 @@ pub mod skills; pub mod storage; pub mod task; pub mod temp; -pub mod tui; pub mod worktree; pub mod ws; -pub use cli::{Cli, Commands, ViewArgs}; +pub use cli::{Cli, Commands}; pub use config::DaemonConfig; pub use error::{DaemonError, Result}; diff --git a/makima/src/daemon/tui/app.rs b/makima/src/daemon/tui/app.rs deleted file mode 100644 index cb0e8f3..0000000 --- a/makima/src/daemon/tui/app.rs +++ /dev/null @@ -1,1219 +0,0 @@ -//! TUI application state and logic. - -use std::collections::VecDeque; - -use fuzzy_matcher::skim::SkimMatcherV2; -use fuzzy_matcher::FuzzyMatcher; -use serde_json::Value; -use uuid::Uuid; - -/// Maximum number of output lines to keep in buffer -const MAX_OUTPUT_LINES: usize = 10000; - -/// Available views/resource types -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ViewType { - /// List of contracts - Contracts, - /// Tasks for a specific contract - Tasks, - /// Task output streaming view - TaskOutput, -} - -impl ViewType { - pub fn as_str(&self) -> &'static str { - match self { - ViewType::Contracts => "contracts", - ViewType::Tasks => "tasks", - ViewType::TaskOutput => "output", - } - } -} - -/// A saved view state for navigation stack -#[derive(Debug, Clone)] -pub struct ViewState { - /// The type of view - pub view_type: ViewType, - /// Contract ID (for Tasks view) - pub contract_id: Option<Uuid>, - /// Contract name (for breadcrumb display) - pub contract_name: Option<String>, - /// Task ID (for TaskOutput view) - pub task_id: Option<Uuid>, - /// Task name (for breadcrumb display) - pub task_name: Option<String>, - /// Selected index at time of navigation - pub selected_index: usize, - /// Scroll offset at time of navigation - pub scroll_offset: usize, -} - -/// Input mode for the TUI -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum InputMode { - /// Normal navigation mode - Normal, - /// Fuzzy search mode - Search, - /// Confirmation dialog (e.g., for delete) - Confirm, - /// Edit mode - editing name - EditName, - /// Edit mode - editing description/plan - EditDescription, - /// Create contract - editing name - CreateName, - /// Create contract - editing description - CreateDescription, -} - -/// Create contract form field -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CreateFormField { - Name, - Description, - ContractType, - Repository, -} - -/// Repository suggestion from history -#[derive(Debug, Clone)] -pub struct RepositorySuggestion { - pub name: String, - pub repository_url: Option<String>, - pub local_path: Option<String>, - pub source_type: String, - pub use_count: i32, -} - -/// State for create contract form -#[derive(Debug, Clone, Default)] -pub struct CreateContractState { - /// Contract name - pub name: String, - /// Contract description - pub description: String, - /// Contract type: "simple" or "specification" - pub contract_type: String, - /// Repository URL (optional) - pub repository_url: String, - /// Currently focused field - pub focused_field: usize, - /// Cursor position in current text field - pub cursor: usize, - /// Available repository suggestions - pub repo_suggestions: Vec<RepositorySuggestion>, - /// Selected suggestion index (for repository field) - pub selected_suggestion: usize, - /// Whether suggestions popup is visible - pub show_suggestions: bool, - /// Whether suggestions have been loaded - pub suggestions_loaded: bool, -} - -impl CreateContractState { - pub fn new() -> Self { - Self { - name: String::new(), - description: String::new(), - contract_type: "simple".to_string(), - repository_url: String::new(), - focused_field: 0, - cursor: 0, - repo_suggestions: Vec::new(), - selected_suggestion: 0, - show_suggestions: false, - suggestions_loaded: false, - } - } - - /// Set repository suggestions - pub fn set_suggestions(&mut self, suggestions: Vec<RepositorySuggestion>) { - self.repo_suggestions = suggestions; - self.selected_suggestion = 0; - self.show_suggestions = !self.repo_suggestions.is_empty(); - self.suggestions_loaded = true; - } - - /// Apply the selected suggestion to the form - pub fn apply_selected_suggestion(&mut self) { - if let Some(suggestion) = self.repo_suggestions.get(self.selected_suggestion) { - // Apply the suggestion - if let Some(ref url) = suggestion.repository_url { - self.repository_url = url.clone(); - } else if let Some(ref path) = suggestion.local_path { - self.repository_url = path.clone(); - } - self.cursor = self.repository_url.len(); - self.show_suggestions = false; - } - } - - /// Navigate to next suggestion - pub fn next_suggestion(&mut self) { - if !self.repo_suggestions.is_empty() { - self.selected_suggestion = (self.selected_suggestion + 1) % self.repo_suggestions.len(); - } - } - - /// Navigate to previous suggestion - pub fn prev_suggestion(&mut self) { - if !self.repo_suggestions.is_empty() { - self.selected_suggestion = if self.selected_suggestion == 0 { - self.repo_suggestions.len() - 1 - } else { - self.selected_suggestion - 1 - }; - } - } - - /// Get the field at the given index - pub fn field_at(&self, index: usize) -> CreateFormField { - match index { - 0 => CreateFormField::Name, - 1 => CreateFormField::Description, - 2 => CreateFormField::ContractType, - 3 => CreateFormField::Repository, - _ => CreateFormField::Name, - } - } - - /// Get the current field - pub fn current_field(&self) -> CreateFormField { - self.field_at(self.focused_field) - } - - /// Get mutable reference to the current text field value - pub fn current_value_mut(&mut self) -> Option<&mut String> { - match self.current_field() { - CreateFormField::Name => Some(&mut self.name), - CreateFormField::Description => Some(&mut self.description), - CreateFormField::Repository => Some(&mut self.repository_url), - CreateFormField::ContractType => None, // Not a text field - } - } - - /// Get the current text field value - pub fn current_value(&self) -> Option<&str> { - match self.current_field() { - CreateFormField::Name => Some(&self.name), - CreateFormField::Description => Some(&self.description), - CreateFormField::Repository => Some(&self.repository_url), - CreateFormField::ContractType => None, - } - } - - /// Move to next field - pub fn next_field(&mut self) { - self.focused_field = (self.focused_field + 1) % 4; - self.cursor = self.current_value().map(|v| v.len()).unwrap_or(0); - // Don't hide suggestions - they stay visible - } - - /// Move to previous field - pub fn prev_field(&mut self) { - self.focused_field = if self.focused_field == 0 { 3 } else { self.focused_field - 1 }; - self.cursor = self.current_value().map(|v| v.len()).unwrap_or(0); - // Don't hide suggestions - they stay visible - } - - /// Toggle contract type - pub fn toggle_contract_type(&mut self) { - self.contract_type = if self.contract_type == "simple" { - "specification".to_string() - } else { - "simple".to_string() - }; - } - - /// Insert character at cursor - pub fn insert_char(&mut self, c: char) { - let cursor = self.cursor; - match self.current_field() { - CreateFormField::Name => { - self.name.insert(cursor, c); - self.cursor += 1; - } - CreateFormField::Description => { - self.description.insert(cursor, c); - self.cursor += 1; - } - CreateFormField::Repository => { - self.repository_url.insert(cursor, c); - self.cursor += 1; - } - CreateFormField::ContractType => {} - } - } - - /// Delete character before cursor - pub fn backspace(&mut self) { - if self.cursor > 0 { - let cursor = self.cursor - 1; - match self.current_field() { - CreateFormField::Name => { - self.name.remove(cursor); - self.cursor = cursor; - } - CreateFormField::Description => { - self.description.remove(cursor); - self.cursor = cursor; - } - CreateFormField::Repository => { - self.repository_url.remove(cursor); - self.cursor = cursor; - } - CreateFormField::ContractType => {} - } - } - } - - /// Check if form is valid (name is required) - pub fn is_valid(&self) -> bool { - !self.name.trim().is_empty() - } -} - -/// Edit state for inline editing -#[derive(Debug, Clone, Default)] -pub struct EditState { - /// ID of the item being edited - pub item_id: Option<Uuid>, - /// Original name - pub original_name: String, - /// Original description - pub original_description: String, - /// Current name value - pub name: String, - /// Current description value - pub description: String, - /// Cursor position in current field - pub cursor: usize, -} - -/// Output line type for rendering -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum OutputMessageType { - /// Assistant text response - Assistant, - /// Tool being called - ToolUse, - /// Result from tool - ToolResult, - /// Final result/summary - Result, - /// System message - System, - /// Error message - Error, - /// Raw/unformatted output - Raw, -} - -impl OutputMessageType { - pub fn from_str(s: &str) -> Self { - match s.to_lowercase().as_str() { - "assistant" => Self::Assistant, - "tool_use" => Self::ToolUse, - "tool_result" => Self::ToolResult, - "result" => Self::Result, - "system" => Self::System, - "error" => Self::Error, - _ => Self::Raw, - } - } -} - -/// A single line of task output -#[derive(Debug, Clone)] -pub struct OutputLine { - /// The type of message - pub message_type: OutputMessageType, - /// The content text - pub content: String, - /// Tool name (for tool_use messages) - pub tool_name: Option<String>, - /// Whether this is an error (for tool_result) - pub is_error: bool, - /// Cost in USD (for result messages) - pub cost_usd: Option<f64>, - /// Duration in ms (for result messages) - pub duration_ms: Option<u64>, -} - -/// Output buffer for task output view -#[derive(Debug, Clone, Default)] -pub struct OutputBuffer { - /// Lines of output - pub lines: VecDeque<OutputLine>, - /// Current scroll offset (0 = bottom, auto-scroll) - pub scroll_offset: usize, - /// Auto-scroll enabled - pub auto_scroll: bool, -} - -impl OutputBuffer { - pub fn new() -> Self { - Self { - lines: VecDeque::new(), - scroll_offset: 0, - auto_scroll: true, - } - } - - pub fn add_line(&mut self, line: OutputLine) { - self.lines.push_back(line); - // Trim to max size - while self.lines.len() > MAX_OUTPUT_LINES { - self.lines.pop_front(); - } - // Auto-scroll to bottom - if self.auto_scroll { - self.scroll_offset = 0; - } - } - - pub fn clear(&mut self) { - self.lines.clear(); - self.scroll_offset = 0; - self.auto_scroll = true; - } - - pub fn scroll_up(&mut self, amount: usize) { - self.scroll_offset = self.scroll_offset.saturating_add(amount); - self.auto_scroll = false; - } - - pub fn scroll_down(&mut self, amount: usize) { - self.scroll_offset = self.scroll_offset.saturating_sub(amount); - if self.scroll_offset == 0 { - self.auto_scroll = true; - } - } - - pub fn scroll_to_bottom(&mut self) { - self.scroll_offset = 0; - self.auto_scroll = true; - } -} - -/// WebSocket connection state -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum WsConnectionState { - Disconnected, - Connecting, - Connected, - Reconnecting, -} - -/// Actions that can be performed -#[derive(Debug, Clone, PartialEq)] -pub enum Action { - /// Do nothing - None, - /// Move selection up - Up, - /// Move selection down - Down, - /// Select current item (show details in preview) - Select, - /// Drill down into selected item (contracts -> tasks, tasks -> output) - DrillDown, - /// Go back to previous view - GoBack, - /// Edit the selected item (inline editing) - Edit, - /// Delete the selected item - Delete, - /// Navigate to worktree (output path and exit) - Navigate, - /// Confirm pending action - ConfirmYes, - /// Cancel pending action - ConfirmNo, - /// Enter search mode - EnterSearch, - /// Exit search mode - ExitSearch, - /// Add character to search - SearchChar(char), - /// Backspace in search - SearchBackspace, - /// Clear search - ClearSearch, - /// Quit the application - Quit, - /// Output a path to stdout and exit (for cd integration) - OutputPath(String), - /// Launch editor with path - LaunchEditor(String), - /// Refresh data - Refresh, - /// Request to load tasks for a contract (internal) - LoadTasks { contract_id: Uuid, contract_name: String }, - /// Request to load task output (internal) - LoadTaskOutput { task_id: Uuid, task_name: String }, - /// Request to delete an item (internal) - PerformDelete { id: Uuid, item_type: ViewType }, - /// Add character in edit mode - EditChar(char), - /// Backspace in edit mode - EditBackspace, - /// Switch to next edit field (Tab) - EditNextField, - /// Save edit changes - EditSave, - /// Cancel edit - EditCancel, - /// Request to perform update (internal) - PerformUpdate { id: Uuid, item_type: ViewType, name: String, description: String }, - /// Scroll output up - ScrollUp, - /// Scroll output down - ScrollDown, - /// Scroll to bottom of output - ScrollToBottom, - /// Open create contract form - NewContract, - /// Add character in create form - CreateChar(char), - /// Backspace in create form - CreateBackspace, - /// Move to next field in create form - CreateNextField, - /// Move to previous field in create form - CreatePrevField, - /// Toggle value (for contract type) - CreateToggle, - /// Submit create form - CreateSubmit, - /// Cancel create form - CreateCancel, - /// Request to create contract (internal) - PerformCreateContract { - name: String, - description: String, - contract_type: String, - repository_url: Option<String>, - }, - /// Request to load repository suggestions (internal) - LoadRepoSuggestions, - /// Navigate to next suggestion in create form - CreateNextSuggestion, - /// Navigate to previous suggestion in create form - CreatePrevSuggestion, - /// Apply selected suggestion in create form - CreateApplySuggestion, -} - -/// A displayable item in the TUI -#[derive(Debug, Clone)] -pub struct ListItem { - pub id: Uuid, - pub name: String, - pub status: Option<String>, - pub description: Option<String>, - /// Extra data for actions (e.g., worktree path) - pub extra: Value, -} - -impl ListItem { - pub fn from_task(value: &Value) -> Option<Self> { - let id = value.get("id") - .and_then(|v| v.as_str()) - .and_then(|s| Uuid::parse_str(s).ok())?; - - let name = value.get("name") - .and_then(|v| v.as_str()) - .unwrap_or("Unnamed") - .to_string(); - - let status = value.get("status") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - let description = value.get("plan") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - Some(Self { - id, - name, - status, - description, - extra: value.clone(), - }) - } - - pub fn from_contract(value: &Value) -> Option<Self> { - let id = value.get("id") - .and_then(|v| v.as_str()) - .and_then(|s| Uuid::parse_str(s).ok())?; - - let name = value.get("name") - .and_then(|v| v.as_str()) - .unwrap_or("Unnamed") - .to_string(); - - let status = value.get("phase") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - let description = value.get("description") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - Some(Self { - id, - name, - status, - description, - extra: value.clone(), - }) - } - - pub fn from_file(value: &Value) -> Option<Self> { - let id = value.get("id") - .and_then(|v| v.as_str()) - .and_then(|s| Uuid::parse_str(s).ok())?; - - let name = value.get("name") - .and_then(|v| v.as_str()) - .unwrap_or("Unnamed") - .to_string(); - - let description = value.get("template_name") - .and_then(|v| v.as_str()) - .map(|s| format!("Template: {}", s)); - - Some(Self { - id, - name, - status: None, - description, - extra: value.clone(), - }) - } - - /// Get the worktree path from task extra data - pub fn get_worktree_path(&self) -> Option<String> { - // Try various field names that might contain the worktree path - self.extra.get("worktreePath") - .or_else(|| self.extra.get("worktree_path")) - .or_else(|| self.extra.get("workdir")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - } - - /// Build a detailed view string for display - pub fn build_detail_view(&self, view_type: ViewType) -> String { - let mut lines = Vec::new(); - - match view_type { - ViewType::Contracts => { - lines.push(format!("╭─ Contract Details ─────────────────────")); - lines.push(format!("│ Name: {}", self.name)); - lines.push(format!("│ ID: {}", self.id)); - if let Some(ref status) = self.status { - lines.push(format!("│ Phase: {}", status)); - } - if let Some(ref desc) = self.description { - lines.push(format!("│ Description: {}", desc)); - } - // Show task count if available - if let Some(count) = self.extra.get("taskCount").and_then(|v| v.as_i64()) { - lines.push(format!("│ Tasks: {}", count)); - } - if let Some(count) = self.extra.get("fileCount").and_then(|v| v.as_i64()) { - lines.push(format!("│ Files: {}", count)); - } - lines.push(format!("│")); - lines.push(format!("│ Press Enter to view tasks")); - lines.push(format!("╰────────────────────────────────────────")); - } - ViewType::Tasks => { - lines.push(format!("╭─ Task Details ─────────────────────────")); - lines.push(format!("│ Name: {}", self.name)); - lines.push(format!("│ ID: {}", self.id)); - if let Some(ref status) = self.status { - lines.push(format!("│ Status: {}", status)); - } - if let Some(ref desc) = self.description { - lines.push(format!("│ Plan: {}", desc)); - } - if let Some(path) = self.get_worktree_path() { - lines.push(format!("│ Worktree: {}", path)); - } - // Add progress if available - if let Some(progress) = self.extra.get("progressSummary").and_then(|v| v.as_str()) { - lines.push(format!("│ Progress: {}", progress)); - } - if let Some(error) = self.extra.get("errorMessage").and_then(|v| v.as_str()) { - lines.push(format!("│ Error: {}", error)); - } - lines.push(format!("│")); - lines.push(format!("│ Press Enter to view output")); - lines.push(format!("╰────────────────────────────────────────")); - } - ViewType::TaskOutput => { - // Output view doesn't use this preview pane - lines.push(format!("╭─ Task Output ──────────────────────────")); - lines.push(format!("│ Streaming task output...")); - lines.push(format!("╰────────────────────────────────────────")); - } - } - - lines.join("\n") - } -} - -/// TUI Application state -pub struct App { - /// Current view type - pub view_type: ViewType, - /// Navigation stack for drill-down views - pub view_stack: Vec<ViewState>, - /// Current contract ID (when viewing tasks) - pub contract_id: Option<Uuid>, - /// Current contract name (for breadcrumb) - pub contract_name: Option<String>, - /// Current task ID (when viewing output) - pub task_id: Option<Uuid>, - /// Current task name (for breadcrumb) - pub task_name: Option<String>, - /// All items (unfiltered) - pub items: Vec<ListItem>, - /// Filtered items (based on search) - pub filtered_items: Vec<ListItem>, - /// Currently selected index in filtered list - pub selected_index: usize, - /// Current input mode - pub input_mode: InputMode, - /// Search query - pub search_query: String, - /// Fuzzy matcher - matcher: SkimMatcherV2, - /// Preview content (for selected item) - pub preview_content: String, - /// Whether preview is visible - pub preview_visible: bool, - /// Pending delete item (for confirmation) - pub pending_delete: Option<Uuid>, - /// Edit state for inline editing - pub edit_state: EditState, - /// Create contract form state - pub create_state: CreateContractState, - /// Status message - pub status_message: Option<String>, - /// Whether the app should quit - pub should_quit: bool, - /// Action to return when exiting (for OutputPath, LaunchEditor) - pub exit_action: Option<Action>, - /// Output buffer for task output view - pub output_buffer: OutputBuffer, - /// WebSocket connection state - pub ws_state: WsConnectionState, -} - -impl App { - pub fn new(view_type: ViewType) -> Self { - Self { - view_type, - view_stack: Vec::new(), - contract_id: None, - contract_name: None, - task_id: None, - task_name: None, - items: Vec::new(), - filtered_items: Vec::new(), - selected_index: 0, - input_mode: InputMode::Normal, - search_query: String::new(), - matcher: SkimMatcherV2::default(), - preview_content: String::new(), - preview_visible: false, - pending_delete: None, - edit_state: EditState::default(), - create_state: CreateContractState::new(), - status_message: None, - should_quit: false, - exit_action: None, - output_buffer: OutputBuffer::new(), - ws_state: WsConnectionState::Disconnected, - } - } - - /// Push current state to navigation stack and prepare for new view - pub fn push_view(&mut self, new_view: ViewType) { - // Save current state - let state = ViewState { - view_type: self.view_type, - contract_id: self.contract_id, - contract_name: self.contract_name.clone(), - task_id: self.task_id, - task_name: self.task_name.clone(), - selected_index: self.selected_index, - scroll_offset: 0, // TODO: track scroll offset if needed - }; - self.view_stack.push(state); - - // Switch to new view - self.view_type = new_view; - self.items.clear(); - self.filtered_items.clear(); - self.selected_index = 0; - self.search_query.clear(); - self.preview_content.clear(); - self.preview_visible = false; - } - - /// Pop from navigation stack and restore previous view state - pub fn pop_view(&mut self) -> bool { - if let Some(state) = self.view_stack.pop() { - self.view_type = state.view_type; - self.contract_id = state.contract_id; - self.contract_name = state.contract_name; - self.task_id = state.task_id; - self.task_name = state.task_name; - self.selected_index = state.selected_index; - self.items.clear(); - self.filtered_items.clear(); - self.search_query.clear(); - self.preview_content.clear(); - self.preview_visible = false; - true - } else { - false - } - } - - /// Check if we can go back - pub fn can_go_back(&self) -> bool { - !self.view_stack.is_empty() - } - - /// Get breadcrumb path for current view - pub fn get_breadcrumb(&self) -> String { - let mut parts = vec!["Contracts".to_string()]; - - if self.view_type == ViewType::Tasks || self.view_type == ViewType::TaskOutput { - if let Some(ref name) = self.contract_name { - parts.push(name.clone()); - } - parts.push("Tasks".to_string()); - } - - if self.view_type == ViewType::TaskOutput { - if let Some(ref name) = self.task_name { - parts.push(name.clone()); - } - parts.push("Output".to_string()); - } - - parts.join(" > ") - } - - /// Set items and update filtered list - pub fn set_items(&mut self, items: Vec<ListItem>) { - self.items = items; - self.update_filtered_items(); - } - - /// Update filtered items based on search query - pub fn update_filtered_items(&mut self) { - if self.search_query.is_empty() { - self.filtered_items = self.items.clone(); - } else { - let mut scored: Vec<_> = self.items - .iter() - .filter_map(|item| { - let score = self.matcher.fuzzy_match(&item.name, &self.search_query)?; - Some((score, item.clone())) - }) - .collect(); - - // Sort by score (highest first) - scored.sort_by(|a, b| b.0.cmp(&a.0)); - self.filtered_items = scored.into_iter().map(|(_, item)| item).collect(); - } - - // Reset selection if out of bounds - if self.selected_index >= self.filtered_items.len() { - self.selected_index = self.filtered_items.len().saturating_sub(1); - } - } - - /// Get currently selected item - pub fn selected_item(&self) -> Option<&ListItem> { - self.filtered_items.get(self.selected_index) - } - - /// Handle an action and return the resulting action - pub fn handle_action(&mut self, action: Action) -> Action { - match action { - Action::Up => { - if self.selected_index > 0 { - self.selected_index -= 1; - } - Action::None - } - Action::Down => { - if self.selected_index < self.filtered_items.len().saturating_sub(1) { - self.selected_index += 1; - } - Action::None - } - Action::Select => { - // Build detailed view for selected item - if let Some(item) = self.selected_item() { - self.preview_content = item.build_detail_view(self.view_type); - self.preview_visible = true; - } - Action::None - } - Action::DrillDown => { - // Drill down into selected item - match self.view_type { - ViewType::Contracts => { - // From contracts, drill into tasks - if let Some(item) = self.selected_item() { - let contract_id = item.id; - let contract_name = item.name.clone(); - // Push view and set state before returning - self.push_view(ViewType::Tasks); - self.contract_id = Some(contract_id); - self.contract_name = Some(contract_name.clone()); - return Action::LoadTasks { contract_id, contract_name }; - } - } - ViewType::Tasks => { - // From tasks, drill into task output - if let Some(item) = self.selected_item() { - let task_id = item.id; - let task_name = item.name.clone(); - // Push view and set state before returning - self.push_view(ViewType::TaskOutput); - self.task_id = Some(task_id); - self.task_name = Some(task_name.clone()); - return Action::LoadTaskOutput { task_id, task_name }; - } - } - ViewType::TaskOutput => { - // No further drill-down from output view - } - } - Action::None - } - Action::GoBack => { - if self.can_go_back() { - self.pop_view(); - // Signal to caller to refresh data for the restored view - Action::Refresh - } else { - // At root level, quit - self.should_quit = true; - Action::Quit - } - } - Action::Edit => { - // Enter edit mode for selected item - if let Some(item) = self.selected_item() { - let name = item.name.clone(); - let description = item.description.clone().unwrap_or_default(); - self.edit_state = EditState { - item_id: Some(item.id), - original_name: name.clone(), - original_description: description.clone(), - name, - description, - cursor: 0, - }; - self.edit_state.cursor = self.edit_state.name.len(); - self.input_mode = InputMode::EditName; - } - Action::None - } - Action::EditChar(c) => { - match self.input_mode { - InputMode::EditName => { - self.edit_state.name.insert(self.edit_state.cursor, c); - self.edit_state.cursor += 1; - } - InputMode::EditDescription => { - self.edit_state.description.insert(self.edit_state.cursor, c); - self.edit_state.cursor += 1; - } - _ => {} - } - Action::None - } - Action::EditBackspace => { - match self.input_mode { - InputMode::EditName => { - if self.edit_state.cursor > 0 { - self.edit_state.cursor -= 1; - self.edit_state.name.remove(self.edit_state.cursor); - } - } - InputMode::EditDescription => { - if self.edit_state.cursor > 0 { - self.edit_state.cursor -= 1; - self.edit_state.description.remove(self.edit_state.cursor); - } - } - _ => {} - } - Action::None - } - Action::EditNextField => { - match self.input_mode { - InputMode::EditName => { - self.input_mode = InputMode::EditDescription; - self.edit_state.cursor = self.edit_state.description.len(); - } - InputMode::EditDescription => { - self.input_mode = InputMode::EditName; - self.edit_state.cursor = self.edit_state.name.len(); - } - _ => {} - } - Action::None - } - Action::EditSave => { - if let Some(id) = self.edit_state.item_id { - let name = self.edit_state.name.clone(); - let description = self.edit_state.description.clone(); - self.input_mode = InputMode::Normal; - // Return action to perform the update - return Action::PerformUpdate { - id, - item_type: self.view_type, - name, - description, - }; - } - self.input_mode = InputMode::Normal; - Action::None - } - Action::EditCancel => { - self.edit_state = EditState::default(); - self.input_mode = InputMode::Normal; - self.status_message = Some("Edit cancelled".to_string()); - Action::None - } - Action::Delete => { - // First press: enter confirm mode - if let Some(item) = self.selected_item() { - let id = item.id; - let name = item.name.clone(); - self.pending_delete = Some(id); - self.input_mode = InputMode::Confirm; - self.status_message = Some(format!("Delete '{}'? (y/n)", name)); - } - Action::None - } - Action::Navigate => { - // Get worktree path and output it - if let Some(item) = self.selected_item() { - if let Some(path) = item.get_worktree_path() { - self.should_quit = true; - self.exit_action = Some(Action::OutputPath(path.clone())); - return Action::OutputPath(path); - } else { - self.status_message = Some("No worktree path for this item".to_string()); - } - } - Action::None - } - Action::ConfirmYes => { - if self.input_mode == InputMode::Confirm { - if let Some(delete_id) = self.pending_delete.take() { - self.input_mode = InputMode::Normal; - // Return action to perform the delete - return Action::PerformDelete { - id: delete_id, - item_type: self.view_type, - }; - } - self.input_mode = InputMode::Normal; - } - Action::None - } - Action::ConfirmNo => { - if self.input_mode == InputMode::Confirm { - self.pending_delete = None; - self.input_mode = InputMode::Normal; - self.status_message = Some("Delete cancelled".to_string()); - } - Action::None - } - Action::EnterSearch => { - self.input_mode = InputMode::Search; - Action::None - } - Action::ExitSearch => { - self.input_mode = InputMode::Normal; - Action::None - } - Action::SearchChar(c) => { - self.search_query.push(c); - self.update_filtered_items(); - Action::None - } - Action::SearchBackspace => { - self.search_query.pop(); - self.update_filtered_items(); - Action::None - } - Action::ClearSearch => { - self.search_query.clear(); - self.update_filtered_items(); - Action::None - } - Action::Quit => { - self.should_quit = true; - Action::Quit - } - Action::Refresh => { - // Signal to caller to refresh data - Action::Refresh - } - Action::OutputPath(path) => { - self.should_quit = true; - self.exit_action = Some(Action::OutputPath(path.clone())); - Action::OutputPath(path) - } - Action::LaunchEditor(path) => { - self.should_quit = true; - self.exit_action = Some(Action::LaunchEditor(path.clone())); - Action::LaunchEditor(path) - } - Action::LoadTasks { contract_id, contract_name } => { - // Pass through to caller for data loading (view already pushed by DrillDown) - Action::LoadTasks { contract_id, contract_name } - } - Action::LoadTaskOutput { task_id, task_name } => { - // Pass through to caller for data loading (view already pushed by DrillDown) - Action::LoadTaskOutput { task_id, task_name } - } - Action::PerformDelete { id, item_type } => { - // Pass through to caller for API call - Action::PerformDelete { id, item_type } - } - Action::PerformUpdate { id, item_type, name, description } => { - // Pass through to caller for API call - Action::PerformUpdate { id, item_type, name, description } - } - Action::ScrollUp => { - if self.view_type == ViewType::TaskOutput { - self.output_buffer.scroll_up(5); - } - Action::None - } - Action::ScrollDown => { - if self.view_type == ViewType::TaskOutput { - self.output_buffer.scroll_down(5); - } - Action::None - } - Action::ScrollToBottom => { - if self.view_type == ViewType::TaskOutput { - self.output_buffer.scroll_to_bottom(); - } - Action::None - } - Action::NewContract => { - // Only allow creating contracts from contracts view - if self.view_type == ViewType::Contracts { - self.create_state = CreateContractState::new(); - self.input_mode = InputMode::CreateName; - // Request to load repository suggestions - return Action::LoadRepoSuggestions; - } - Action::None - } - Action::CreateChar(c) => { - self.create_state.insert_char(c); - Action::None - } - Action::CreateBackspace => { - self.create_state.backspace(); - Action::None - } - Action::CreateNextField => { - self.create_state.next_field(); - Action::None - } - Action::CreatePrevField => { - self.create_state.prev_field(); - Action::None - } - Action::CreateToggle => { - if self.create_state.current_field() == CreateFormField::ContractType { - self.create_state.toggle_contract_type(); - } - Action::None - } - Action::CreateSubmit => { - if self.create_state.is_valid() { - let name = self.create_state.name.clone(); - let description = self.create_state.description.clone(); - let contract_type = self.create_state.contract_type.clone(); - let repository_url = if self.create_state.repository_url.is_empty() { - None - } else { - Some(self.create_state.repository_url.clone()) - }; - self.input_mode = InputMode::Normal; - return Action::PerformCreateContract { - name, - description, - contract_type, - repository_url, - }; - } else { - self.status_message = Some("Name is required".to_string()); - } - Action::None - } - Action::CreateCancel => { - self.create_state = CreateContractState::new(); - self.input_mode = InputMode::Normal; - self.status_message = Some("Create cancelled".to_string()); - Action::None - } - Action::PerformCreateContract { name, description, contract_type, repository_url } => { - // Pass through to caller for API call - Action::PerformCreateContract { name, description, contract_type, repository_url } - } - Action::LoadRepoSuggestions => { - // Pass through to caller for API call - Action::LoadRepoSuggestions - } - Action::CreateNextSuggestion => { - self.create_state.next_suggestion(); - Action::None - } - Action::CreatePrevSuggestion => { - self.create_state.prev_suggestion(); - Action::None - } - Action::CreateApplySuggestion => { - self.create_state.apply_selected_suggestion(); - Action::None - } - Action::None => Action::None, - } - } - - /// Get the name of the item being deleted (for confirmation dialog) - pub fn get_pending_delete_name(&self) -> Option<String> { - self.pending_delete.and_then(|id| { - self.filtered_items.iter() - .find(|item| item.id == id) - .map(|item| item.name.clone()) - }) - } -} diff --git a/makima/src/daemon/tui/event.rs b/makima/src/daemon/tui/event.rs deleted file mode 100644 index d5ca569..0000000 --- a/makima/src/daemon/tui/event.rs +++ /dev/null @@ -1,269 +0,0 @@ -//! TUI event handling. - -use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; -use std::time::Duration; - -use super::app::{Action, App, CreateFormField, InputMode, ViewType}; - -/// Poll for events with timeout -pub fn poll_event(timeout: Duration) -> std::io::Result<Option<Event>> { - if event::poll(timeout)? { - Ok(Some(event::read()?)) - } else { - Ok(None) - } -} - -/// Handle a key event and return the resulting action -pub fn handle_key_event(app: &App, key: KeyEvent) -> Action { - // Special handling for TaskOutput view - if app.view_type == ViewType::TaskOutput && app.input_mode == InputMode::Normal { - return handle_output_mode(key); - } - - match app.input_mode { - InputMode::Normal => handle_normal_mode(app, key), - InputMode::Search => handle_search_mode(key), - InputMode::Confirm => handle_confirm_mode(key), - InputMode::EditName | InputMode::EditDescription => handle_edit_mode(key), - InputMode::CreateName | InputMode::CreateDescription => handle_create_mode(app, key), - } -} - -/// Handle key events in normal navigation mode -fn handle_normal_mode(app: &App, key: KeyEvent) -> Action { - // Check for Ctrl+C first - if key.modifiers.contains(KeyModifiers::CONTROL) { - match key.code { - KeyCode::Char('c') => return Action::Quit, - _ => {} - } - } - - match key.code { - // Navigation - KeyCode::Up | KeyCode::Char('k') => Action::Up, - KeyCode::Down | KeyCode::Char('j') => Action::Down, - - // Drill-down into selected item (Enter or l for vim-style) - KeyCode::Enter | KeyCode::Char('l') => Action::DrillDown, - - // Go back (Backspace, h for vim-style, or Esc) - KeyCode::Backspace | KeyCode::Char('h') => Action::GoBack, - - // Other actions - KeyCode::Char('e') => Action::Edit, - KeyCode::Char('d') => Action::Delete, - KeyCode::Char('c') => Action::Navigate, // cd to worktree - - // New contract (only in contracts view) - KeyCode::Char('n') if app.view_type == ViewType::Contracts => Action::NewContract, - - // Preview toggle (space to show details in preview pane) - KeyCode::Char(' ') => Action::Select, - - // Search - KeyCode::Char('/') => Action::EnterSearch, - - // Refresh - KeyCode::Char('r') => Action::Refresh, - - // Quit (only q, Esc now goes back) - KeyCode::Char('q') => Action::Quit, - KeyCode::Esc => Action::GoBack, - - _ => Action::None, - } -} - -/// Handle key events in search mode -fn handle_search_mode(key: KeyEvent) -> Action { - // Check for Ctrl+C first - if key.modifiers.contains(KeyModifiers::CONTROL) { - match key.code { - KeyCode::Char('c') => return Action::Quit, - KeyCode::Char('u') => return Action::ClearSearch, - _ => {} - } - } - - match key.code { - // Exit search mode - KeyCode::Esc => Action::ExitSearch, - KeyCode::Enter => Action::ExitSearch, - - // Text input - KeyCode::Char(c) => Action::SearchChar(c), - KeyCode::Backspace => Action::SearchBackspace, - - // Navigation while searching - KeyCode::Up => Action::Up, - KeyCode::Down => Action::Down, - - _ => Action::None, - } -} - -/// Handle key events in confirmation mode -fn handle_confirm_mode(key: KeyEvent) -> Action { - // Check for Ctrl+C first - if key.modifiers.contains(KeyModifiers::CONTROL) { - if let KeyCode::Char('c') = key.code { - return Action::Quit; - } - } - - match key.code { - // Confirm - KeyCode::Char('y') | KeyCode::Char('Y') => Action::ConfirmYes, - - // Cancel - KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => Action::ConfirmNo, - - _ => Action::None, - } -} - -/// Handle key events in task output view mode -fn handle_output_mode(key: KeyEvent) -> Action { - // Check for Ctrl+C first - if key.modifiers.contains(KeyModifiers::CONTROL) { - if let KeyCode::Char('c') = key.code { - return Action::Quit; - } - } - - match key.code { - // Scroll - KeyCode::Up | KeyCode::Char('k') => Action::ScrollUp, - KeyCode::Down | KeyCode::Char('j') => Action::ScrollDown, - KeyCode::PageUp => Action::ScrollUp, - KeyCode::PageDown => Action::ScrollDown, - - // Scroll to bottom - KeyCode::Char('G') | KeyCode::End => Action::ScrollToBottom, - - // Go back (Backspace, h for vim-style, q, or Esc) - KeyCode::Backspace | KeyCode::Char('h') | KeyCode::Esc => Action::GoBack, - KeyCode::Char('q') => Action::GoBack, - - // Refresh (re-connect WebSocket) - KeyCode::Char('r') => Action::Refresh, - - // Navigate to worktree - KeyCode::Char('c') => Action::Navigate, - - _ => Action::None, - } -} - -/// Handle key events in edit mode -fn handle_edit_mode(key: KeyEvent) -> Action { - // Check for Ctrl+C first - if key.modifiers.contains(KeyModifiers::CONTROL) { - if let KeyCode::Char('c') = key.code { - return Action::Quit; - } - } - - match key.code { - // Save - KeyCode::Enter => Action::EditSave, - - // Cancel - KeyCode::Esc => Action::EditCancel, - - // Switch fields - KeyCode::Tab => Action::EditNextField, - - // Text input - KeyCode::Char(c) => Action::EditChar(c), - KeyCode::Backspace => Action::EditBackspace, - - _ => Action::None, - } -} - -/// Handle key events in create contract mode -fn handle_create_mode(app: &App, key: KeyEvent) -> Action { - // Check for Ctrl+C first - if key.modifiers.contains(KeyModifiers::CONTROL) { - if let KeyCode::Char('c') = key.code { - return Action::Quit; - } - } - - let current_field = app.create_state.current_field(); - let has_suggestions = app.create_state.show_suggestions - && !app.create_state.repo_suggestions.is_empty(); - - // Allow Ctrl+N/Ctrl+P to navigate suggestions from any field - if has_suggestions && key.modifiers.contains(KeyModifiers::CONTROL) { - match key.code { - KeyCode::Char('n') => return Action::CreateNextSuggestion, - KeyCode::Char('p') => return Action::CreatePrevSuggestion, - _ => {} - } - } - - // Special handling when on Repository field with suggestions visible - let on_repo_field = current_field == CreateFormField::Repository; - if has_suggestions && on_repo_field { - match key.code { - // Up/Down navigate suggestions when on repo field - KeyCode::Up => return Action::CreatePrevSuggestion, - KeyCode::Down => return Action::CreateNextSuggestion, - // Enter applies suggestion instead of submitting form - KeyCode::Enter => return Action::CreateApplySuggestion, - _ => {} - } - } - - match key.code { - // Submit form - KeyCode::Enter => { - // If on contract type field, toggle instead of submit - if current_field == CreateFormField::ContractType { - Action::CreateToggle - } else { - Action::CreateSubmit - } - } - - // Cancel - KeyCode::Esc => Action::CreateCancel, - - // Navigate between fields - KeyCode::Tab => Action::CreateNextField, - KeyCode::BackTab => Action::CreatePrevField, - KeyCode::Up => Action::CreatePrevField, - KeyCode::Down => Action::CreateNextField, - - // Toggle for contract type field - KeyCode::Char(' ') if current_field == CreateFormField::ContractType => Action::CreateToggle, - KeyCode::Left if current_field == CreateFormField::ContractType => Action::CreateToggle, - KeyCode::Right if current_field == CreateFormField::ContractType => Action::CreateToggle, - - // Text input (for text fields) - KeyCode::Char(c) if current_field != CreateFormField::ContractType => Action::CreateChar(c), - KeyCode::Backspace if current_field != CreateFormField::ContractType => Action::CreateBackspace, - - _ => Action::None, - } -} - -/// Get help text for current mode -pub fn get_help_text(mode: InputMode) -> &'static str { - match mode { - InputMode::Normal => "j/k: nav | Enter: open | Esc/h: back | e: edit | d: del | n: new | /: search | q: quit", - InputMode::Search => "Type to search | Enter/Esc: exit search | Up/Down: navigate", - InputMode::Confirm => "y: confirm | n/Esc: cancel", - InputMode::EditName | InputMode::EditDescription => "Type to edit | Tab: switch field | Enter: save | Esc: cancel", - InputMode::CreateName | InputMode::CreateDescription => "Type to edit | Tab/↑↓: switch field | Enter: create | Esc: cancel", - } -} - -/// Get help text for output view -pub fn get_output_help_text() -> &'static str { - "j/k: scroll | G: bottom | c: cd | q/Esc: back | r: refresh" -} diff --git a/makima/src/daemon/tui/fuzzy.rs b/makima/src/daemon/tui/fuzzy.rs deleted file mode 100644 index 44c27ad..0000000 --- a/makima/src/daemon/tui/fuzzy.rs +++ /dev/null @@ -1,217 +0,0 @@ -//! Fuzzy matching wrapper for search functionality. -//! -//! This module provides a wrapper around the `fuzzy-matcher` crate's -//! `SkimMatcherV2` algorithm, offering: -//! -//! - Single-term fuzzy matching with score and matched indices -//! - Multi-term search (space-separated patterns) -//! - Recency-adjusted scoring for time-aware results -//! - Case-insensitive matching by default -//! -//! # Examples -//! -//! ``` -//! use makima::daemon::tui::fuzzy::FuzzyMatcher; -//! -//! let matcher = FuzzyMatcher::new(); -//! -//! // Single pattern matching -//! if let Some((score, indices)) = matcher.fuzzy_match("hello world", "hlo") { -//! println!("Score: {}, Matched positions: {:?}", score, indices); -//! } -//! -//! // Multi-term search -//! if let Some(score) = matcher.fuzzy_match_all("fix authentication bug", "fix bug") { -//! println!("All terms matched with score: {}", score); -//! } -//! ``` - -use fuzzy_matcher::skim::SkimMatcherV2; -use fuzzy_matcher::FuzzyMatcher as FuzzyMatcherTrait; - -/// Fuzzy matcher wrapper providing search functionality. -/// -/// Wraps the `SkimMatcherV2` algorithm which provides: -/// - Smart case matching (case-insensitive unless pattern has uppercase) -/// - Word boundary bonuses -/// - Consecutive character bonuses -pub struct FuzzyMatcher { - matcher: SkimMatcherV2, -} - -impl FuzzyMatcher { - /// Create a new fuzzy matcher with default settings. - pub fn new() -> Self { - Self { - matcher: SkimMatcherV2::default(), - } - } - - /// Match a pattern against a string, returning score and matched indices. - /// - /// Returns `Some((score, indices))` if the pattern matches, where: - /// - `score` is a relevance score (higher is better) - /// - `indices` are the positions of matched characters in the text - /// - /// Returns `None` if the pattern doesn't match the text. - /// - /// # Arguments - /// - /// * `text` - The text to search in - /// * `pattern` - The pattern to search for - pub fn fuzzy_match(&self, text: &str, pattern: &str) -> Option<(i64, Vec<usize>)> { - self.matcher.fuzzy_indices(text, pattern) - } - - /// Match multiple patterns (space-separated) against a string. - /// - /// All patterns must match for the function to return a score. - /// The returned score is the sum of individual pattern scores. - /// - /// # Arguments - /// - /// * `text` - The text to search in - /// * `patterns` - Space-separated patterns (e.g., "fix bug" matches both "fix" and "bug") - /// - /// # Returns - /// - /// `Some(total_score)` if all patterns match, `None` otherwise. - pub fn fuzzy_match_all(&self, text: &str, patterns: &str) -> Option<i64> { - let patterns: Vec<&str> = patterns.split_whitespace().collect(); - - if patterns.is_empty() { - return Some(0); - } - - let mut total_score = 0i64; - - for pattern in patterns { - if let Some((score, _)) = self.matcher.fuzzy_indices(text, pattern) { - total_score += score; - } else { - return None; - } - } - - Some(total_score) - } - - /// Calculate a recency-adjusted score for time-aware sorting. - /// - /// Items with lower indices (more recent) receive a bonus to their score, - /// making them rank higher in search results. - /// - /// # Arguments - /// - /// * `base_score` - The original fuzzy match score - /// * `index` - The item's position in the list (0 = most recent) - /// * `total_items` - Total number of items in the list - /// - /// # Returns - /// - /// An adjusted score that factors in recency. - pub fn recency_adjusted_score(base_score: i64, index: usize, total_items: usize) -> i64 { - if total_items == 0 { - return base_score; - } - - // Recency bonus: items at the beginning get up to 20% bonus - // Formula: bonus = base_score * 0.2 * (1 - index/total_items) - let recency_factor = 1.0 - (index as f64 / total_items as f64); - let bonus = (base_score as f64 * 0.2 * recency_factor) as i64; - - base_score + bonus - } -} - -impl Default for FuzzyMatcher { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_fuzzy_match_exact() { - let matcher = FuzzyMatcher::new(); - let result = matcher.fuzzy_match("hello world", "hello"); - assert!(result.is_some()); - } - - #[test] - fn test_fuzzy_match_partial() { - let matcher = FuzzyMatcher::new(); - let result = matcher.fuzzy_match("authentication", "auth"); - assert!(result.is_some()); - } - - #[test] - fn test_fuzzy_match_no_match() { - let matcher = FuzzyMatcher::new(); - let result = matcher.fuzzy_match("hello", "xyz"); - assert!(result.is_none()); - } - - #[test] - fn test_multi_term_search() { - let matcher = FuzzyMatcher::new(); - let result = matcher.fuzzy_match_all("fix authentication bug", "fix bug"); - assert!(result.is_some()); - } - - #[test] - fn test_case_insensitive() { - let matcher = FuzzyMatcher::new(); - let result = matcher.fuzzy_match("Hello World", "hello"); - assert!(result.is_some()); - } - - #[test] - fn test_recency_bonus() { - // Earlier items (lower index) should get higher recency bonus - let score1 = FuzzyMatcher::recency_adjusted_score(100, 0, 50); - let score2 = FuzzyMatcher::recency_adjusted_score(100, 10, 50); - assert!(score1 > score2); - } - - #[test] - fn test_fuzzy_match_returns_indices() { - let matcher = FuzzyMatcher::new(); - let result = matcher.fuzzy_match("hello world", "hlo"); - assert!(result.is_some()); - let (_, indices) = result.unwrap(); - // Should have matched 3 characters - assert_eq!(indices.len(), 3); - } - - #[test] - fn test_multi_term_empty_pattern() { - let matcher = FuzzyMatcher::new(); - let result = matcher.fuzzy_match_all("hello world", ""); - assert!(result.is_some()); - assert_eq!(result.unwrap(), 0); - } - - #[test] - fn test_multi_term_partial_match_fails() { - let matcher = FuzzyMatcher::new(); - // "xyz" doesn't match, so the whole search should fail - let result = matcher.fuzzy_match_all("fix authentication bug", "fix xyz"); - assert!(result.is_none()); - } - - #[test] - fn test_recency_bonus_edge_cases() { - // Zero total items should return base score - let score = FuzzyMatcher::recency_adjusted_score(100, 0, 0); - assert_eq!(score, 100); - - // Last item should get minimal bonus - let score_last = FuzzyMatcher::recency_adjusted_score(100, 49, 50); - let score_first = FuzzyMatcher::recency_adjusted_score(100, 0, 50); - assert!(score_first > score_last); - } -} diff --git a/makima/src/daemon/tui/mod.rs b/makima/src/daemon/tui/mod.rs deleted file mode 100644 index e52b12a..0000000 --- a/makima/src/daemon/tui/mod.rs +++ /dev/null @@ -1,98 +0,0 @@ -//! TUI module for interactive browsing. -//! -//! This module provides an interactive Terminal User Interface (TUI) for -//! browsing and managing tasks, contracts, and files in the makima system. -//! -//! # Features -//! -//! - **Fuzzy Search**: Real-time filtering with the SkimMatcherV2 algorithm -//! - **Keyboard Navigation**: Vim-style keybindings (j/k) and arrow keys -//! - **Preview Pane**: Side-by-side view of item details -//! - **Multiple Views**: Browse tasks, contracts, or files - -pub mod app; -pub mod event; -pub mod fuzzy; -pub mod ui; -pub mod ws_client; - -pub use app::{App, ListItem, ViewType, ViewState, InputMode, Action, OutputBuffer, OutputLine, OutputMessageType, WsConnectionState, CreateContractState, CreateFormField, RepositorySuggestion}; -pub use ws_client::{TuiWsClient, WsCommand, WsEvent, TaskOutputEvent}; -pub use fuzzy::FuzzyMatcher; - -use std::io; -use crossterm::{ - event::{DisableMouseCapture, EnableMouseCapture}, - execute, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, -}; -use ratatui::prelude::*; -use ratatui::backend::CrosstermBackend; - -pub type Terminal = ratatui::Terminal<CrosstermBackend<io::Stdout>>; - -/// Run the TUI application -pub fn run(mut app: App) -> Result<Option<String>, Box<dyn std::error::Error>> { - // Setup terminal - enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = ratatui::Terminal::new(backend)?; - - // Run the main loop - let result = run_app(&mut terminal, &mut app); - - // Cleanup terminal - disable_raw_mode()?; - execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - )?; - terminal.show_cursor()?; - - result -} - -fn run_app( - terminal: &mut Terminal, - app: &mut App, -) -> Result<Option<String>, Box<dyn std::error::Error>> { - use crossterm::event::Event; - use std::time::Duration; - - loop { - terminal.draw(|f| ui::render(f, app))?; - - // Poll for events with 100ms timeout - if let Some(evt) = event::poll_event(Duration::from_millis(100))? { - if let Event::Key(key) = evt { - let action = event::handle_key_event(app, key); - match action { - Action::Quit => break, - Action::OutputPath(path) => return Ok(Some(path)), - Action::None => {} - _ => { - let result = app.handle_action(action); - // Check if handle_action returned a special action - if let Action::OutputPath(path) = result { - return Ok(Some(path)); - } - } - } - } - } - - if app.should_quit { - break; - } - } - - Ok(None) -} - -/// Print a path to stdout (for cd integration) -pub fn print_path(path: &str) { - println!("{}", path); -} diff --git a/makima/src/daemon/tui/ui.rs b/makima/src/daemon/tui/ui.rs deleted file mode 100644 index 2a5a6ce..0000000 --- a/makima/src/daemon/tui/ui.rs +++ /dev/null @@ -1,695 +0,0 @@ -//! TUI rendering. - -use ratatui::{ - layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span, Text}, - widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}, - Frame, -}; - -use super::app::{App, CreateFormField, InputMode, ViewType, OutputMessageType, WsConnectionState}; -use super::event::{get_help_text, get_output_help_text}; - -/// Main render function -pub fn render(frame: &mut Frame, app: &App) { - // Create main layout - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // Header - Constraint::Min(10), // Main content - Constraint::Length(3), // Status/Help - ]) - .split(frame.area()); - - render_header(frame, app, chunks[0]); - render_main_content(frame, app, chunks[1]); - render_footer(frame, app, chunks[2]); - - // Render confirmation dialog if in confirm mode - if app.input_mode == InputMode::Confirm { - render_confirm_dialog(frame, app); - } - - // Render edit dialog if in edit mode - if matches!(app.input_mode, InputMode::EditName | InputMode::EditDescription) { - render_edit_dialog(frame, app); - } - - // Render create contract dialog if in create mode - if matches!(app.input_mode, InputMode::CreateName | InputMode::CreateDescription) { - render_create_dialog(frame, app); - } -} - -/// Render header with breadcrumb and search bar -fn render_header(frame: &mut Frame, app: &App, area: Rect) { - let breadcrumb = app.get_breadcrumb(); - - let header_text = if app.input_mode == InputMode::Search || !app.search_query.is_empty() { - format!("{} [Search: {}]", breadcrumb, app.search_query) - } else { - format!("{} ({} items)", breadcrumb, app.filtered_items.len()) - }; - - let header = Paragraph::new(header_text) - .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) - .block(Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(if app.input_mode == InputMode::Search { - Color::Yellow - } else { - Color::White - }))); - - frame.render_widget(header, area); -} - -/// Render main content (list + optional preview) -fn render_main_content(frame: &mut Frame, app: &App, area: Rect) { - // TaskOutput view has its own rendering - if app.view_type == ViewType::TaskOutput { - render_output_view(frame, app, area); - return; - } - - if app.preview_visible && !app.preview_content.is_empty() { - // Split horizontally: list on left, preview on right - let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(50), - Constraint::Percentage(50), - ]) - .split(area); - - render_list(frame, app, chunks[0]); - render_preview(frame, app, chunks[1]); - } else { - render_list(frame, app, area); - } -} - -/// Render the item list -fn render_list(frame: &mut Frame, app: &App, area: Rect) { - let items: Vec<ListItem> = app.filtered_items - .iter() - .enumerate() - .map(|(i, item)| { - let is_selected = i == app.selected_index; - - // Build the display line - let status_str = item.status - .as_ref() - .map(|s| format!(" [{}]", s)) - .unwrap_or_default(); - - let content = format!("{}{}", item.name, status_str); - - let style = if is_selected { - Style::default() - .fg(Color::Black) - .bg(Color::Cyan) - .add_modifier(Modifier::BOLD) - } else { - let status_color = item.status.as_ref().map(|s| { - match s.to_lowercase().as_str() { - "running" | "active" => Color::Green, - "pending" | "waiting" => Color::Yellow, - "completed" | "done" => Color::Blue, - "failed" | "error" => Color::Red, - _ => Color::White, - } - }).unwrap_or(Color::White); - - Style::default().fg(status_color) - }; - - ListItem::new(Line::from(vec![ - Span::styled(content, style), - ])) - }) - .collect(); - - let list = List::new(items) - .block(Block::default() - .borders(Borders::ALL) - .title(format!(" {} ", app.view_type.as_str()))); - - frame.render_widget(list, area); -} - -/// Render the preview panel -fn render_preview(frame: &mut Frame, app: &App, area: Rect) { - let preview = Paragraph::new(Text::raw(&app.preview_content)) - .wrap(Wrap { trim: false }) - .block(Block::default() - .borders(Borders::ALL) - .title(" Preview ")); - - frame.render_widget(preview, area); -} - -/// Render footer with help text and status -fn render_footer(frame: &mut Frame, app: &App, area: Rect) { - // Use output-specific help text when in output view - let help_text = if app.view_type == ViewType::TaskOutput { - get_output_help_text() - } else { - get_help_text(app.input_mode) - }; - - // Build status text with WS connection state for output view - let ws_status = if app.view_type == ViewType::TaskOutput { - match app.ws_state { - WsConnectionState::Connected => " [WS: Connected]", - WsConnectionState::Connecting => " [WS: Connecting...]", - WsConnectionState::Reconnecting => " [WS: Reconnecting...]", - WsConnectionState::Disconnected => " [WS: Disconnected]", - } - } else { - "" - }; - - let status_text = app.status_message - .as_ref() - .map(|s| format!(" | {}", s)) - .unwrap_or_default(); - - let footer_text = format!("{}{}{}", help_text, ws_status, status_text); - - let footer = Paragraph::new(footer_text) - .style(Style::default().fg(Color::DarkGray)) - .block(Block::default().borders(Borders::ALL)); - - frame.render_widget(footer, area); -} - -/// Render confirmation dialog as a centered popup -fn render_confirm_dialog(frame: &mut Frame, app: &App) { - let item_name = app.get_pending_delete_name() - .unwrap_or_else(|| "this item".to_string()); - - // Calculate popup size and position - let area = frame.area(); - let popup_width = 50.min(area.width.saturating_sub(4)); - let popup_height = 7; - - let popup_x = (area.width.saturating_sub(popup_width)) / 2; - let popup_y = (area.height.saturating_sub(popup_height)) / 2; - - let popup_area = Rect { - x: popup_x, - y: popup_y, - width: popup_width, - height: popup_height, - }; - - // Clear the area behind the popup - frame.render_widget(Clear, popup_area); - - // Build popup content - let text = vec![ - Line::from(""), - Line::from(Span::styled( - "Delete Confirmation", - Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), - )), - Line::from(""), - Line::from(format!("Delete '{}'?", item_name)), - Line::from(""), - Line::from(vec![ - Span::styled("y", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), - Span::raw(": confirm "), - Span::styled("n", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), - Span::raw(": cancel"), - ]), - ]; - - let popup = Paragraph::new(text) - .alignment(Alignment::Center) - .block(Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Red)) - .title(" Confirm ")); - - frame.render_widget(popup, popup_area); -} - -/// Render edit dialog as a centered popup -fn render_edit_dialog(frame: &mut Frame, app: &App) { - // Calculate popup size and position - make it wider - let area = frame.area(); - let popup_width = 80.min(area.width.saturating_sub(4)); - let popup_height = 14; - - let popup_x = (area.width.saturating_sub(popup_width)) / 2; - let popup_y = (area.height.saturating_sub(popup_height)) / 2; - - let popup_area = Rect { - x: popup_x, - y: popup_y, - width: popup_width, - height: popup_height, - }; - - // Clear the area behind the popup - frame.render_widget(Clear, popup_area); - - // Determine which field is active - let editing_name = app.input_mode == InputMode::EditName; - - // Calculate max display width (popup width - borders - label) - let max_field_width = (popup_width as usize).saturating_sub(16); - - // Build the name field with cursor and truncation - let name_display = if editing_name { - let cursor_pos = app.edit_state.cursor.min(app.edit_state.name.len()); - let (before, after) = app.edit_state.name.split_at(cursor_pos); - let display = format!("{}|{}", before, after); - // Show end of string if cursor is past visible area - if display.len() > max_field_width { - let start = display.len().saturating_sub(max_field_width); - format!("...{}", &display[start..]) - } else { - display - } - } else { - if app.edit_state.name.len() > max_field_width { - format!("{}...", &app.edit_state.name[..max_field_width.saturating_sub(3)]) - } else { - app.edit_state.name.clone() - } - }; - - // Build the description field with cursor and truncation - let desc_display = if !editing_name { - let cursor_pos = app.edit_state.cursor.min(app.edit_state.description.len()); - let (before, after) = app.edit_state.description.split_at(cursor_pos); - let display = format!("{}|{}", before, after); - // Show end of string if cursor is past visible area - if display.len() > max_field_width { - let start = display.len().saturating_sub(max_field_width); - format!("...{}", &display[start..]) - } else { - display - } - } else { - if app.edit_state.description.len() > max_field_width { - format!("{}...", &app.edit_state.description[..max_field_width.saturating_sub(3)]) - } else { - app.edit_state.description.clone() - } - }; - - // Determine field label based on view type - let desc_label = match app.view_type { - ViewType::Tasks => "Plan", - _ => "Desc", - }; - - // Style for active vs inactive fields - let active_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD); - let inactive_style = Style::default().fg(Color::White); - let label_style = Style::default().fg(Color::DarkGray); - - // Build popup content - use left alignment for fields - let text = vec![ - Line::from(""), - Line::from(Span::styled( - " Edit Item", - Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), - )), - Line::from(""), - // Name field - Line::from(vec![ - Span::styled(" Name: ", label_style), - Span::styled( - name_display, - if editing_name { active_style } else { inactive_style }, - ), - ]), - Line::from(""), - // Description field - Line::from(vec![ - Span::styled(format!(" {}: ", desc_label), label_style), - Span::styled( - desc_display, - if !editing_name { active_style } else { inactive_style }, - ), - ]), - Line::from(""), - Line::from(""), - Line::from(vec![ - Span::styled(" Tab", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), - Span::raw(": switch "), - Span::styled("Enter", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), - Span::raw(": save "), - Span::styled("Esc", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), - Span::raw(": cancel"), - ]), - ]; - - let popup = Paragraph::new(text) - .block(Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)) - .title(" Edit ")); - - frame.render_widget(popup, popup_area); -} - -/// Render the create contract dialog -fn render_create_dialog(frame: &mut Frame, app: &App) { - // Calculate popup size and position - make it taller if suggestions are shown - let area = frame.area(); - let state = &app.create_state; - let current_field = state.current_field(); - // Show suggestions whenever we have them (like the frontend does) - let show_suggestions = state.show_suggestions && !state.repo_suggestions.is_empty(); - - let popup_width = 70.min(area.width.saturating_sub(4)); - let base_height = 20; - let suggestion_height = if show_suggestions { - (state.repo_suggestions.len().min(5) + 2) as u16 - } else { - 0 - }; - let popup_height = base_height + suggestion_height; - - let popup_x = (area.width.saturating_sub(popup_width)) / 2; - let popup_y = (area.height.saturating_sub(popup_height)) / 2; - - let popup_area = Rect { - x: popup_x, - y: popup_y, - width: popup_width, - height: popup_height, - }; - - // Clear the area behind the popup - frame.render_widget(Clear, popup_area); - - let max_field_width = (popup_width as usize).saturating_sub(18); - - // Styles - let active_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD); - let inactive_style = Style::default().fg(Color::White); - let label_style = Style::default().fg(Color::DarkGray); - let hint_style = Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC); - let suggestion_style = Style::default().fg(Color::White); - let selected_suggestion_style = Style::default().fg(Color::Black).bg(Color::Cyan); - - // Helper to build text field with cursor - let build_field = |value: &str, cursor: usize, is_active: bool| -> String { - if is_active { - let cursor_pos = cursor.min(value.len()); - let (before, after) = value.split_at(cursor_pos); - let display = format!("{}|{}", before, after); - if display.len() > max_field_width { - let start = display.len().saturating_sub(max_field_width); - format!("...{}", &display[start..]) - } else { - display - } - } else { - if value.len() > max_field_width { - format!("{}...", &value[..max_field_width.saturating_sub(3)]) - } else if value.is_empty() { - "(empty)".to_string() - } else { - value.to_string() - } - } - }; - - // Build field displays - let name_display = build_field(&state.name, state.cursor, current_field == CreateFormField::Name); - let desc_display = build_field(&state.description, state.cursor, current_field == CreateFormField::Description); - let repo_display = build_field(&state.repository_url, state.cursor, current_field == CreateFormField::Repository); - - // Contract type selector - let type_display = if state.contract_type == "simple" { - vec![ - Span::styled("[●] ", Style::default().fg(Color::Green)), - Span::raw("Simple "), - Span::styled("[ ] ", Style::default().fg(Color::DarkGray)), - Span::styled("Specification", Style::default().fg(Color::DarkGray)), - ] - } else { - vec![ - Span::styled("[ ] ", Style::default().fg(Color::DarkGray)), - Span::styled("Simple ", Style::default().fg(Color::DarkGray)), - Span::styled("[●] ", Style::default().fg(Color::Green)), - Span::raw("Specification"), - ] - }; - - let mut text = vec![ - Line::from(""), - Line::from(Span::styled( - " New Contract", - Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), - )), - Line::from(""), - // Name field (required) - Line::from(vec![ - Span::styled(" Name*: ", label_style), - Span::styled( - name_display, - if current_field == CreateFormField::Name { active_style } else { inactive_style }, - ), - ]), - Line::from(Span::styled(" Contract name (required)", hint_style)), - Line::from(""), - // Description field - Line::from(vec![ - Span::styled(" Description: ", label_style), - Span::styled( - desc_display, - if current_field == CreateFormField::Description { active_style } else { inactive_style }, - ), - ]), - Line::from(Span::styled(" Brief description of the work", hint_style)), - Line::from(""), - // Contract type selector - Line::from(vec![ - Span::styled(" Type: ", label_style), - ].into_iter().chain( - if current_field == CreateFormField::ContractType { - type_display.into_iter().map(|s| s).collect::<Vec<_>>() - } else { - type_display.into_iter().map(|mut s| { - s.style = s.style.fg(Color::DarkGray); - s - }).collect() - } - ).collect::<Vec<_>>()), - Line::from(Span::styled(" Simple: Plan→Execute | Spec: Research→Specify→Plan→Execute→Review", hint_style)), - Line::from(""), - // Repository URL field - Line::from(vec![ - Span::styled(" Repository: ", label_style), - Span::styled( - repo_display, - if current_field == CreateFormField::Repository { active_style } else { inactive_style }, - ), - ]), - Line::from(Span::styled(" Git repository URL (optional)", hint_style)), - ]; - - // Add suggestions section - if show_suggestions { - text.push(Line::from("")); - text.push(Line::from(Span::styled( - " Recent repositories (↑/↓ to select, Enter to apply):", - Style::default().fg(Color::Cyan), - ))); - - for (i, suggestion) in state.repo_suggestions.iter().take(5).enumerate() { - let is_selected = i == state.selected_suggestion; - let url_or_path = suggestion.repository_url.as_ref() - .or(suggestion.local_path.as_ref()) - .map(|s| s.as_str()) - .unwrap_or(""); - - // Truncate if too long - let display_url = if url_or_path.len() > max_field_width - 10 { - format!("...{}", &url_or_path[url_or_path.len().saturating_sub(max_field_width - 13)..]) - } else { - url_or_path.to_string() - }; - - let prefix = if is_selected { " → " } else { " " }; - let count_suffix = format!(" ({}×)", suggestion.use_count); - - text.push(Line::from(vec![ - Span::styled( - format!("{}{}{}", prefix, display_url, count_suffix), - if is_selected { selected_suggestion_style } else { suggestion_style }, - ), - ])); - } - } else if state.suggestions_loaded && state.repo_suggestions.is_empty() { - // Show message when suggestions loaded but empty - text.push(Line::from("")); - text.push(Line::from(Span::styled( - " (No recent repositories - add repos to contracts to see suggestions here)", - hint_style, - ))); - } - - text.push(Line::from("")); - - // Help line - show different help when suggestions are visible - if show_suggestions { - text.push(Line::from(vec![ - Span::styled(" ↑/↓", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), - Span::raw(": select "), - Span::styled("Enter", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), - Span::raw(": apply "), - Span::styled("Tab", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), - Span::raw(": next field "), - Span::styled("Esc", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), - Span::raw(": cancel"), - ])); - } else { - text.push(Line::from(vec![ - Span::styled(" Tab/↑↓", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), - Span::raw(": switch "), - Span::styled("Enter", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), - Span::raw(": create "), - Span::styled("Space", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), - Span::raw(": toggle type "), - Span::styled("Esc", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), - Span::raw(": cancel"), - ])); - } - - let popup = Paragraph::new(text) - .block(Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)) - .title(" Create Contract ")); - - frame.render_widget(popup, popup_area); -} - -/// Render the task output view -fn render_output_view(frame: &mut Frame, app: &App, area: Rect) { - let buffer = &app.output_buffer; - - // Calculate visible area (subtract 2 for borders) - let visible_height = area.height.saturating_sub(2) as usize; - - // Build lines to display - let total_lines = buffer.lines.len(); - let start_idx = if total_lines > visible_height { - total_lines - .saturating_sub(visible_height) - .saturating_sub(buffer.scroll_offset) - } else { - 0 - }; - - let lines: Vec<Line> = buffer.lines - .iter() - .skip(start_idx) - .take(visible_height) - .map(|line| render_output_line(line)) - .collect(); - - // Build title with scroll indicator - let scroll_indicator = if buffer.auto_scroll { - "[auto-scroll]".to_string() - } else if buffer.scroll_offset > 0 { - format!("[+{}]", buffer.scroll_offset) - } else { - String::new() - }; - - let title = format!(" Task Output {} ", scroll_indicator); - - let paragraph = Paragraph::new(lines) - .block(Block::default() - .borders(Borders::ALL) - .title(title) - .border_style(Style::default().fg(match app.ws_state { - WsConnectionState::Connected => Color::Green, - WsConnectionState::Connecting | WsConnectionState::Reconnecting => Color::Yellow, - WsConnectionState::Disconnected => Color::Red, - }))); - - frame.render_widget(paragraph, area); -} - -/// Render a single output line with appropriate styling -fn render_output_line(line: &super::app::OutputLine) -> Line<'static> { - match line.message_type { - OutputMessageType::Assistant => { - // Blue left indicator for assistant messages - Line::from(vec![ - Span::styled("│ ", Style::default().fg(Color::Blue)), - Span::styled(line.content.clone(), Style::default().fg(Color::White)), - ]) - } - OutputMessageType::ToolUse => { - // Yellow asterisk for tool calls - let tool_name = line.tool_name.clone().unwrap_or_else(|| "tool".to_string()); - Line::from(vec![ - Span::styled("* ", Style::default().fg(Color::Yellow)), - Span::styled(format!("[{}] ", tool_name), Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), - Span::styled(line.content.clone(), Style::default().fg(Color::DarkGray)), - ]) - } - OutputMessageType::ToolResult => { - // Green/red indicator for tool results - let indicator = if line.is_error { "✗ " } else { " + " }; - let color = if line.is_error { Color::Red } else { Color::Green }; - Line::from(vec![ - Span::styled(indicator, Style::default().fg(color)), - Span::styled(line.content.clone(), Style::default().fg(Color::Gray)), - ]) - } - OutputMessageType::Result => { - // Green checkmark for final results - let mut spans = vec![ - Span::styled("✓ ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), - Span::styled(line.content.clone(), Style::default().fg(Color::Green)), - ]; - // Add cost/duration if available - if let Some(cost) = line.cost_usd { - spans.push(Span::styled( - format!(" [${:.4}]", cost), - Style::default().fg(Color::DarkGray), - )); - } - if let Some(ms) = line.duration_ms { - spans.push(Span::styled( - format!(" [{}ms]", ms), - Style::default().fg(Color::DarkGray), - )); - } - Line::from(spans) - } - OutputMessageType::System => { - // Dim gray for system messages - Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled(line.content.clone(), Style::default().fg(Color::DarkGray)), - ]) - } - OutputMessageType::Error => { - // Red for errors - Line::from(vec![ - Span::styled("! ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), - Span::styled(line.content.clone(), Style::default().fg(Color::Red)), - ]) - } - OutputMessageType::Raw => { - // Plain text - Line::from(line.content.clone()) - } - } -} diff --git a/makima/src/daemon/tui/views/contracts.rs b/makima/src/daemon/tui/views/contracts.rs deleted file mode 100644 index 73b7c33..0000000 --- a/makima/src/daemon/tui/views/contracts.rs +++ /dev/null @@ -1,32 +0,0 @@ -//! Contracts view implementation. - -use uuid::Uuid; - -use crate::daemon::api::ApiClient; -use crate::daemon::tui::app::ListItem; - -/// Load contracts from API -pub async fn load_contracts( - client: &ApiClient, -) -> Result<Vec<ListItem>, Box<dyn std::error::Error>> { - let result = client.list_contracts().await?; - - // Response is { "contracts": [...], "total": N } - let contracts = result - .0 - .get("contracts") - .and_then(|v| v.as_array()) - .map(|arr| arr.iter().filter_map(ListItem::from_contract).collect()) - .unwrap_or_default(); - - Ok(contracts) -} - -/// Get full contract details for preview -pub async fn get_contract_preview( - _client: &ApiClient, - _contract_id: Uuid, -) -> Result<String, Box<dyn std::error::Error>> { - // TODO: Implement contract preview - Ok("Contract preview not yet implemented".to_string()) -} diff --git a/makima/src/daemon/tui/views/files.rs b/makima/src/daemon/tui/views/files.rs deleted file mode 100644 index e21a989..0000000 --- a/makima/src/daemon/tui/views/files.rs +++ /dev/null @@ -1,90 +0,0 @@ -//! Files view implementation. - -use uuid::Uuid; - -use crate::daemon::api::ApiClient; -use crate::daemon::tui::app::ListItem; - -/// Load files from API -pub async fn load_files( - client: &ApiClient, - contract_id: Uuid, -) -> Result<Vec<ListItem>, Box<dyn std::error::Error>> { - let result = client.contract_files(contract_id).await?; - - // Parse JSON response into ListItem - let files: Vec<serde_json::Value> = serde_json::from_value(result.0)?; - - let items = files - .into_iter() - .filter_map(|f| { - let id_str = f.get("id")?.as_str()?; - let id = Uuid::parse_str(id_str).ok()?; - - Some(ListItem { - id, - name: f - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("Unnamed") - .to_string(), - status: None, - description: f - .get("description") - .and_then(|v| v.as_str()) - .map(String::from), - updated_at: f - .get("updatedAt") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_string(), - extra: f, - }) - }) - .collect(); - - Ok(items) -} - -/// Get full file details for preview -pub async fn get_file_preview( - client: &ApiClient, - contract_id: Uuid, - file_id: Uuid, -) -> Result<String, Box<dyn std::error::Error>> { - let result = client.contract_file(contract_id, file_id).await?; - let file: serde_json::Value = result.0; - - let name = file - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("Unknown"); - let description = file - .get("description") - .and_then(|v| v.as_str()) - .unwrap_or("-"); - - // Try to get body content - let body_preview = if let Some(body) = file.get("body") { - if let Some(body_array) = body.as_array() { - body_array - .iter() - .filter_map(|item| { - let text = item.get("text").and_then(|v| v.as_str())?; - Some(text.to_string()) - }) - .take(5) - .collect::<Vec<_>>() - .join("\n") - } else { - "-".to_string() - } - } else { - "-".to_string() - }; - - Ok(format!( - "Name: {}\nDescription: {}\n\nContent:\n{}", - name, description, body_preview - )) -} diff --git a/makima/src/daemon/tui/views/mod.rs b/makima/src/daemon/tui/views/mod.rs deleted file mode 100644 index 699b6df..0000000 --- a/makima/src/daemon/tui/views/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod contracts; -pub mod files; -pub mod tasks; diff --git a/makima/src/daemon/tui/views/tasks.rs b/makima/src/daemon/tui/views/tasks.rs deleted file mode 100644 index fd52b11..0000000 --- a/makima/src/daemon/tui/views/tasks.rs +++ /dev/null @@ -1,71 +0,0 @@ -//! Tasks view implementation. - -use uuid::Uuid; - -use crate::daemon::api::ApiClient; -use crate::daemon::tui::app::ListItem; - -/// Load tasks from API -pub async fn load_tasks( - client: &ApiClient, - contract_id: Option<Uuid>, -) -> Result<Vec<ListItem>, Box<dyn std::error::Error>> { - let Some(contract_id) = contract_id else { - // TODO: Implement listing all tasks across contracts - return Ok(Vec::new()); - }; - - let result = client.supervisor_tasks(contract_id).await?; - - // Parse JSON response into ListItem - let tasks: Vec<serde_json::Value> = serde_json::from_value(result.0)?; - - let items = tasks - .into_iter() - .filter_map(|t| { - let id_str = t.get("id")?.as_str()?; - let id = Uuid::parse_str(id_str).ok()?; - - Some(ListItem { - id, - name: t - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("Unnamed") - .to_string(), - status: t.get("status").and_then(|v| v.as_str()).map(String::from), - description: t - .get("progressSummary") - .and_then(|v| v.as_str()) - .map(String::from), - updated_at: t - .get("updatedAt") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_string(), - extra: t, - }) - }) - .collect(); - - Ok(items) -} - -/// Get full task details for preview -pub async fn get_task_preview( - client: &ApiClient, - task_id: Uuid, -) -> Result<String, Box<dyn std::error::Error>> { - let result = client.supervisor_get_task(task_id).await?; - let task: serde_json::Value = result.0; - - Ok(format!( - "Name: {}\nStatus: {}\nPlan: {}\n\nProgress:\n{}", - task.get("name").and_then(|v| v.as_str()).unwrap_or("-"), - task.get("status").and_then(|v| v.as_str()).unwrap_or("-"), - task.get("plan").and_then(|v| v.as_str()).unwrap_or("-"), - task.get("progressSummary") - .and_then(|v| v.as_str()) - .unwrap_or("-"), - )) -} diff --git a/makima/src/daemon/tui/widgets/list_view.rs b/makima/src/daemon/tui/widgets/list_view.rs deleted file mode 100644 index ff8269a..0000000 --- a/makima/src/daemon/tui/widgets/list_view.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! List view widget with fuzzy match highlighting. - -use std::collections::HashSet; - -use ratatui::{ - prelude::*, - widgets::{Block, Borders, List, ListItem, ListState}, -}; - -use crate::daemon::tui::app::{App, ViewMode}; - -/// Style for matched characters in search results -const MATCH_HIGHLIGHT_COLOR: Color = Color::Yellow; -const MATCH_HIGHLIGHT_MODIFIER: Modifier = Modifier::BOLD; - -/// Build a Line with highlighted characters based on matched indices -fn build_highlighted_name(name: &str, matched_indices: &[usize]) -> Vec<Span<'static>> { - if matched_indices.is_empty() { - return vec![Span::raw(name.to_string())]; - } - - let matched_set: HashSet<usize> = matched_indices.iter().cloned().collect(); - let mut spans = Vec::new(); - let mut current_run = String::new(); - let mut is_highlighted = false; - - for (byte_idx, ch) in name.char_indices() { - let should_highlight = matched_set.contains(&byte_idx); - - if should_highlight != is_highlighted { - // Flush current run - if !current_run.is_empty() { - if is_highlighted { - spans.push(Span::styled( - current_run.clone(), - Style::default() - .fg(MATCH_HIGHLIGHT_COLOR) - .add_modifier(MATCH_HIGHLIGHT_MODIFIER), - )); - } else { - spans.push(Span::raw(current_run.clone())); - } - current_run.clear(); - } - is_highlighted = should_highlight; - } - - current_run.push(ch); - } - - // Flush remaining - if !current_run.is_empty() { - if is_highlighted { - spans.push(Span::styled( - current_run, - Style::default() - .fg(MATCH_HIGHLIGHT_COLOR) - .add_modifier(MATCH_HIGHLIGHT_MODIFIER), - )); - } else { - spans.push(Span::raw(current_run)); - } - } - - spans -} - -/// Get status icon and color for an item -fn get_status_display(status: Option<&str>) -> (&'static str, Color) { - match status { - Some("running") => ("▸", Color::Green), - Some("done") => ("✓", Color::Blue), - Some("failed") => ("✗", Color::Red), - Some("pending") => ("○", Color::Yellow), - Some("paused") => ("⏸", Color::Cyan), - _ => (" ", Color::Gray), - } -} - -pub fn render(f: &mut Frame, area: Rect, app: &mut App) { - let items: Vec<ListItem> = app - .filtered_items - .iter() - .map(|filtered_item| { - let item = &app.items[filtered_item.index]; - let (status_icon, status_color) = get_status_display(item.status.as_deref()); - - // Build spans with highlighted matched characters - let mut spans = vec![Span::styled( - format!("{} ", status_icon), - Style::default().fg(status_color), - )]; - - // Add name with match highlighting - spans.extend(build_highlighted_name(&item.name, &filtered_item.matched_indices)); - - ListItem::new(Line::from(spans)) - }) - .collect(); - - let view_label = match app.view_mode { - ViewMode::Tasks => "Tasks", - ViewMode::Contracts => "Contracts", - ViewMode::Files => "Files", - }; - - let title = format!( - " {} ({}{}) ", - view_label, - app.filtered_items.len(), - if app.filtered_items.len() != app.items.len() { - format!("/{}", app.items.len()) - } else { - String::new() - } - ); - - let list = List::new(items) - .block(Block::default().title(title).borders(Borders::ALL)) - .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) - .highlight_symbol("> "); - - let mut state = ListState::default(); - state.select(Some(app.selected_index)); - - f.render_stateful_widget(list, area, &mut state); -} diff --git a/makima/src/daemon/tui/widgets/mod.rs b/makima/src/daemon/tui/widgets/mod.rs deleted file mode 100644 index ddea546..0000000 --- a/makima/src/daemon/tui/widgets/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod list_view; -pub mod preview_pane; -pub mod search_input; -pub mod status_bar; diff --git a/makima/src/daemon/tui/widgets/preview_pane.rs b/makima/src/daemon/tui/widgets/preview_pane.rs deleted file mode 100644 index 84095d0..0000000 --- a/makima/src/daemon/tui/widgets/preview_pane.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! Preview pane widget. - -use ratatui::{ - prelude::*, - widgets::{Block, Borders, Paragraph, Wrap}, -}; - -use crate::daemon::tui::app::App; - -pub fn render(f: &mut Frame, area: Rect, app: &App) { - let content = app - .preview_content - .as_deref() - .unwrap_or("No preview available"); - - let preview = Paragraph::new(content) - .block(Block::default().title(" Preview ").borders(Borders::ALL)) - .wrap(Wrap { trim: true }); - - f.render_widget(preview, area); -} diff --git a/makima/src/daemon/tui/widgets/search_input.rs b/makima/src/daemon/tui/widgets/search_input.rs deleted file mode 100644 index 311b4f0..0000000 --- a/makima/src/daemon/tui/widgets/search_input.rs +++ /dev/null @@ -1,82 +0,0 @@ -//! Search input widget with match count and visual feedback. - -use ratatui::{ - prelude::*, - widgets::{Block, Borders, Paragraph}, -}; - -use crate::daemon::tui::app::{App, InputMode, ViewMode}; - -/// Color for the search bar when there are no matches -const NO_MATCH_COLOR: Color = Color::Red; -/// Color for the search bar when actively searching -const SEARCH_ACTIVE_COLOR: Color = Color::Yellow; - -pub fn render(f: &mut Frame, area: Rect, app: &App) { - let view_label = match app.view_mode { - ViewMode::Tasks => "Tasks", - ViewMode::Contracts => "Contracts", - ViewMode::Files => "Files", - }; - - let (matched, total) = app.match_count(); - let has_no_matches = app.has_no_matches(); - let is_searching = matches!(app.input_mode, InputMode::Search); - let has_query = !app.search_query.is_empty(); - - // Determine border style based on state - let border_style = if has_no_matches { - Style::default().fg(NO_MATCH_COLOR) - } else if is_searching { - Style::default().fg(SEARCH_ACTIVE_COLOR) - } else { - Style::default() - }; - - // Build the search input content - let search_text = if app.search_query.is_empty() { - if is_searching { - " Type to search...".to_string() - } else { - " Press / to search".to_string() - } - } else { - format!(" {}", app.search_query) - }; - - // Build the title with match count - let title = if has_query { - if has_no_matches { - format!(" 🔍 Search [{}] - No matches ", view_label) - } else { - format!(" 🔍 Search [{}] - {}/{} matches ", view_label, matched, total) - } - } else { - format!(" 🔍 Search [{}] ", view_label) - }; - - // Create input text with appropriate style - let text_style = if app.search_query.is_empty() && !is_searching { - Style::default().fg(Color::DarkGray) - } else if has_no_matches { - Style::default().fg(NO_MATCH_COLOR) - } else { - Style::default() - }; - - let input = Paragraph::new(Span::styled(search_text, text_style)).block( - Block::default() - .title(title) - .borders(Borders::ALL) - .border_style(border_style), - ); - - f.render_widget(input, area); - - // Show cursor in search mode - if is_searching { - // Calculate cursor position based on actual search query length - let cursor_x = area.x + app.search_query.len() as u16 + 2; - f.set_cursor_position(Position::new(cursor_x, area.y + 1)); - } -} diff --git a/makima/src/daemon/tui/widgets/status_bar.rs b/makima/src/daemon/tui/widgets/status_bar.rs deleted file mode 100644 index 3357c58..0000000 --- a/makima/src/daemon/tui/widgets/status_bar.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! Status bar widget. - -use ratatui::{prelude::*, widgets::Paragraph}; - -use crate::daemon::tui::app::{App, InputMode}; - -pub fn render(f: &mut Frame, area: Rect, app: &App) { - let keybindings = match app.input_mode { - InputMode::Normal => { - "↑↓:Navigate Enter:View e:Edit d:Delete Tab:Preview /:Search q:Quit" - } - InputMode::Search => "Type to search Enter:Select Esc:Cancel", - InputMode::Confirm => "y:Confirm n:Cancel", - }; - - let status = Paragraph::new(keybindings).style(Style::default().bg(Color::DarkGray)); - - f.render_widget(status, area); -} diff --git a/makima/src/daemon/tui/ws_client.rs b/makima/src/daemon/tui/ws_client.rs deleted file mode 100644 index 3462467..0000000 --- a/makima/src/daemon/tui/ws_client.rs +++ /dev/null @@ -1,353 +0,0 @@ -//! TUI WebSocket client for task output streaming. -//! -//! Uses a dedicated async thread to handle WebSocket communication, -//! bridging async/sync worlds via channels. - -use std::sync::mpsc as std_mpsc; -use std::thread; -use std::time::Duration; - -use serde::{Deserialize, Serialize}; -use tokio::runtime::Runtime; -use tokio::sync::mpsc as tokio_mpsc; -use uuid::Uuid; - -/// Commands sent from TUI to WebSocket client -#[derive(Debug, Clone)] -pub enum WsCommand { - /// Subscribe to task output - Subscribe { task_id: Uuid }, - /// Unsubscribe from task output - Unsubscribe { task_id: Uuid }, - /// Shutdown the WebSocket client - Shutdown, -} - -/// Events sent from WebSocket client to TUI -#[derive(Debug, Clone)] -pub enum WsEvent { - /// WebSocket connected - Connected, - /// WebSocket disconnected - Disconnected, - /// WebSocket reconnecting - Reconnecting { attempt: u32 }, - /// Subscription confirmed - Subscribed { task_id: Uuid }, - /// Unsubscription confirmed - Unsubscribed { task_id: Uuid }, - /// Task output received - TaskOutput(TaskOutputEvent), - /// Error occurred - Error { message: String }, -} - -/// Task output event from server -#[derive(Debug, Clone)] -pub struct TaskOutputEvent { - pub task_id: Uuid, - pub message_type: String, - pub content: String, - pub tool_name: Option<String>, - pub tool_input: Option<serde_json::Value>, - pub is_error: Option<bool>, - pub cost_usd: Option<f64>, - pub duration_ms: Option<u64>, - pub is_partial: bool, -} - -/// Messages sent to the WebSocket server -#[derive(Debug, Clone, Serialize)] -#[serde(tag = "type", rename_all = "camelCase")] -enum ClientMessage { - SubscribeOutput { - #[serde(rename = "taskId")] - task_id: Uuid, - }, - UnsubscribeOutput { - #[serde(rename = "taskId")] - task_id: Uuid, - }, -} - -/// Messages received from the WebSocket server -#[derive(Debug, Clone, Deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] -enum ServerMessage { - OutputSubscribed { - #[serde(rename = "taskId")] - task_id: Uuid, - }, - OutputUnsubscribed { - #[serde(rename = "taskId")] - task_id: Uuid, - }, - TaskOutput { - #[serde(rename = "taskId")] - task_id: Uuid, - #[serde(rename = "messageType")] - message_type: String, - content: String, - #[serde(rename = "toolName")] - tool_name: Option<String>, - #[serde(rename = "toolInput")] - tool_input: Option<serde_json::Value>, - #[serde(rename = "isError")] - is_error: Option<bool>, - #[serde(rename = "costUsd")] - cost_usd: Option<f64>, - #[serde(rename = "durationMs")] - duration_ms: Option<u64>, - #[serde(rename = "isPartial")] - is_partial: bool, - }, - Error { - code: String, - message: String, - }, - // Other message types we don't care about - #[serde(other)] - Other, -} - -/// TUI WebSocket client handle -pub struct TuiWsClient { - /// Command sender to WebSocket thread - command_tx: tokio_mpsc::Sender<WsCommand>, - /// Event receiver from WebSocket thread - event_rx: std_mpsc::Receiver<WsEvent>, -} - -impl TuiWsClient { - /// Start a new WebSocket client in a dedicated thread - pub fn start(api_url: String, api_key: String) -> Self { - let (command_tx, command_rx) = tokio_mpsc::channel(32); - let (event_tx, event_rx) = std_mpsc::channel(); - - // Spawn as daemon thread so it doesn't block process exit - thread::Builder::new() - .name("ws-client".to_string()) - .spawn(move || { - let rt = match Runtime::new() { - Ok(rt) => rt, - Err(e) => { - let _ = event_tx.send(WsEvent::Error { - message: format!("Failed to create tokio runtime: {}", e), - }); - return; - } - }; - rt.block_on(run_ws_client(api_url, api_key, command_rx, event_tx)); - }) - .ok(); - - Self { - command_tx, - event_rx, - } - } - - /// Send a command to the WebSocket client (non-blocking) - pub fn send(&self, command: WsCommand) { - // Use try_send to avoid blocking on shutdown - let _ = self.command_tx.try_send(command); - } - - /// Subscribe to task output - pub fn subscribe(&self, task_id: Uuid) { - self.send(WsCommand::Subscribe { task_id }); - } - - /// Unsubscribe from task output - pub fn unsubscribe(&self, task_id: Uuid) { - self.send(WsCommand::Unsubscribe { task_id }); - } - - /// Shutdown the WebSocket client - pub fn shutdown(&self) { - self.send(WsCommand::Shutdown); - } - - /// Try to receive an event (non-blocking) - pub fn try_recv(&self) -> Option<WsEvent> { - self.event_rx.try_recv().ok() - } - - /// Receive an event with timeout - pub fn recv_timeout(&self, timeout: Duration) -> Option<WsEvent> { - self.event_rx.recv_timeout(timeout).ok() - } -} - -impl Drop for TuiWsClient { - fn drop(&mut self) { - // Try to send shutdown command, but don't wait - let _ = self.command_tx.try_send(WsCommand::Shutdown); - } -} - -/// WebSocket client main loop -async fn run_ws_client( - api_url: String, - api_key: String, - mut command_rx: tokio_mpsc::Receiver<WsCommand>, - event_tx: std_mpsc::Sender<WsEvent>, -) { - use futures::{SinkExt, StreamExt}; - use tokio_tungstenite::{connect_async, tungstenite::client::IntoClientRequest, tungstenite::Message}; - - // Build WebSocket URL from HTTP URL - let ws_url = api_url - .replace("https://", "wss://") - .replace("http://", "ws://"); - let ws_url = format!("{}/api/v1/mesh/tasks/subscribe", ws_url); - - let mut reconnect_attempt = 0u32; - let max_reconnect_delay = Duration::from_secs(30); - let initial_delay = Duration::from_secs(1); - - loop { - // Build request with API key header - let mut request = match ws_url.clone().into_client_request() { - Ok(r) => r, - Err(e) => { - let _ = event_tx.send(WsEvent::Error { - message: format!("Invalid URL: {}", e), - }); - return; - } - }; - - // Send both headers - server will try tool key first, then API key - if let Ok(header_value) = api_key.parse() { - request.headers_mut().insert("x-makima-tool-key", header_value); - } - if let Ok(header_value) = api_key.parse() { - request.headers_mut().insert("x-makima-api-key", header_value); - } - - if reconnect_attempt > 0 { - let _ = event_tx.send(WsEvent::Reconnecting { - attempt: reconnect_attempt, - }); - - // Exponential backoff - let delay = std::cmp::min( - initial_delay * 2u32.saturating_pow(reconnect_attempt - 1), - max_reconnect_delay, - ); - tokio::time::sleep(delay).await; - } - - // Try to connect - let (ws_stream, _) = match connect_async(request).await { - Ok(result) => { - reconnect_attempt = 0; - let _ = event_tx.send(WsEvent::Connected); - result - } - Err(e) => { - reconnect_attempt += 1; - let _ = event_tx.send(WsEvent::Error { - message: format!("Connection failed: {}", e), - }); - continue; - } - }; - - let (mut write, mut read) = ws_stream.split(); - - // Main message loop - loop { - tokio::select! { - // Handle commands from TUI - cmd = command_rx.recv() => { - match cmd { - Some(WsCommand::Subscribe { task_id }) => { - let msg = ClientMessage::SubscribeOutput { task_id }; - if let Ok(json) = serde_json::to_string(&msg) { - let _ = write.send(Message::Text(json)).await; - } - } - Some(WsCommand::Unsubscribe { task_id }) => { - let msg = ClientMessage::UnsubscribeOutput { task_id }; - if let Ok(json) = serde_json::to_string(&msg) { - let _ = write.send(Message::Text(json)).await; - } - } - Some(WsCommand::Shutdown) | None => { - let _ = write.close().await; - return; - } - } - } - - // Handle messages from server - msg = read.next() => { - match msg { - Some(Ok(Message::Text(text))) => { - if let Ok(server_msg) = serde_json::from_str::<ServerMessage>(&text) { - match server_msg { - ServerMessage::OutputSubscribed { task_id } => { - let _ = event_tx.send(WsEvent::Subscribed { task_id }); - } - ServerMessage::OutputUnsubscribed { task_id } => { - let _ = event_tx.send(WsEvent::Unsubscribed { task_id }); - } - ServerMessage::TaskOutput { - task_id, - message_type, - content, - tool_name, - tool_input, - is_error, - cost_usd, - duration_ms, - is_partial, - } => { - let _ = event_tx.send(WsEvent::TaskOutput(TaskOutputEvent { - task_id, - message_type, - content, - tool_name, - tool_input, - is_error, - cost_usd, - duration_ms, - is_partial, - })); - } - ServerMessage::Error { code, message } => { - let _ = event_tx.send(WsEvent::Error { - message: format!("{}: {}", code, message), - }); - } - ServerMessage::Other => { - // Ignore other message types - } - } - } - } - Some(Ok(Message::Ping(data))) => { - let _ = write.send(Message::Pong(data)).await; - } - Some(Ok(Message::Close(_))) | None => { - let _ = event_tx.send(WsEvent::Disconnected); - reconnect_attempt += 1; - break; // Reconnect - } - Some(Err(e)) => { - let _ = event_tx.send(WsEvent::Error { - message: format!("WebSocket error: {}", e), - }); - let _ = event_tx.send(WsEvent::Disconnected); - reconnect_attempt += 1; - break; // Reconnect - } - _ => {} - } - } - } - } - } -} diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index 3fb9667..bfb8bf3 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -111,10 +111,6 @@ 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)] @@ -141,8 +137,6 @@ 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 @@ -157,8 +151,6 @@ pub struct CreateFileRequest { 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. @@ -194,12 +186,6 @@ 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, @@ -224,9 +210,6 @@ 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(), @@ -425,8 +408,6 @@ 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 (no longer constrained) pub depth: i32, @@ -436,11 +417,6 @@ 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>, @@ -565,14 +541,6 @@ 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>, - /// Contract status (joined from contracts table): 'active', 'completed', 'archived' - pub contract_status: Option<String>, pub parent_task_id: Option<Uuid>, /// Depth in task hierarchy: 0=orchestrator (top-level), 1=subtask (max) pub depth: i32, @@ -582,9 +550,6 @@ 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, /// Whether this task is hidden from the UI (user dismissed it) #[serde(default)] pub hidden: bool, @@ -597,10 +562,6 @@ 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 - contract_status: None, // Not available from Task directly parent_task_id: task.parent_task_id, depth: task.depth, name: task.name, @@ -609,7 +570,6 @@ impl From<Task> for TaskSummary { progress_summary: task.progress_summary, subtask_count: 0, // Would need separate query version: task.version, - is_supervisor: task.is_supervisor, hidden: task.hidden, created_at: task.created_at, updated_at: task.updated_at, @@ -629,8 +589,6 @@ pub struct TaskListResponse { #[derive(Debug, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct CreateTaskRequest { - /// Contract this task belongs to (optional for branched/anonymous tasks) - pub contract_id: Option<Uuid>, /// Name of the task pub name: String, /// Optional description @@ -639,9 +597,6 @@ 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, @@ -668,9 +623,6 @@ pub struct CreateTaskRequest { pub branched_from_task_id: Option<Uuid>, /// Conversation history to initialize the task with (JSON array of messages) pub conversation_history: Option<serde_json::Value>, - /// Task ID whose worktree this task shares. When set, this task reuses the supervisor's - /// worktree instead of creating its own, and should NOT have its worktree deleted during cleanup. - pub supervisor_worktree_task_id: Option<Uuid>, /// Directive this task belongs to (for directive-driven tasks) pub directive_id: Option<Uuid>, /// Directive step this task executes @@ -935,87 +887,8 @@ pub struct TaskOutputResponse { pub task_id: Uuid, } -// ============================================================================= -// Mesh Chat History Types -// ============================================================================= - -/// Mesh chat conversation for persisting history -#[derive(Debug, Clone, FromRow, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct MeshChatConversation { - pub 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 mesh chat conversation -#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct MeshChatMessageRecord { - pub id: Uuid, - pub conversation_id: Uuid, - pub role: String, - pub content: String, - pub context_type: String, - pub context_task_id: Option<Uuid>, - /// 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 chat history endpoint -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct MeshChatHistoryResponse { - pub conversation_id: Uuid, - pub messages: Vec<MeshChatMessageRecord>, -} - -// ============================================================================= -// 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>, -} +// (MeshChat* + ContractChat* types removed alongside their dead +// tables/handlers — see migration 20260517000000.) // ============================================================================= // Merge API Types @@ -1120,772 +993,6 @@ pub struct MergeCompleteCheckResponse { pub skipped_count: u32, } -// ============================================================================= -// Contract Type Templates (User-defined) -// ============================================================================= - -/// A phase definition within a contract template -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct PhaseDefinition { - /// Phase identifier (e.g., "research", "plan", "execute") - pub id: String, - /// Display name for the phase - pub name: String, - /// Order in the workflow (0-indexed) - pub order: i32, -} - -/// A deliverable definition within a phase -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct DeliverableDefinition { - /// Deliverable identifier (e.g., "plan-document", "pull-request") - pub id: String, - /// Display name for the deliverable - pub name: String, - /// Priority: "required", "recommended", or "optional" - #[serde(default = "default_priority")] - pub priority: String, -} - -fn default_priority() -> String { - "required".to_string() -} - -/// Phase configuration stored on a contract (copied from template at creation) -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct PhaseConfig { - /// Ordered list of phases in the workflow - pub phases: Vec<PhaseDefinition>, - /// Default starting phase - pub default_phase: String, - /// Deliverables per phase: { "phase_id": [deliverables] } - #[serde(default)] - pub deliverables: std::collections::HashMap<String, Vec<DeliverableDefinition>>, -} - -/// Contract type template record from the database -#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ContractTypeTemplateRecord { - pub id: Uuid, - pub owner_id: Uuid, - pub name: String, - pub description: Option<String>, - #[sqlx(json)] - pub phases: Vec<PhaseDefinition>, - pub default_phase: String, - #[sqlx(json)] - pub deliverables: Option<std::collections::HashMap<String, Vec<DeliverableDefinition>>>, - pub version: i32, - pub created_at: DateTime<Utc>, - pub updated_at: DateTime<Utc>, -} - -/// Request to create a new contract type template -#[derive(Debug, Clone, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct CreateTemplateRequest { - pub name: String, - pub description: Option<String>, - pub phases: Vec<PhaseDefinition>, - pub default_phase: String, - pub deliverables: Option<std::collections::HashMap<String, Vec<DeliverableDefinition>>>, -} - -/// Request to update a contract type template -#[derive(Debug, Clone, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct UpdateTemplateRequest { - pub name: Option<String>, - pub description: Option<String>, - pub phases: Option<Vec<PhaseDefinition>>, - pub default_phase: Option<String>, - pub deliverables: Option<std::collections::HashMap<String, Vec<DeliverableDefinition>>>, - /// Version for optimistic locking - pub version: Option<i32>, -} - -/// Summary of a contract type template for list views -#[derive(Debug, Clone, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ContractTypeTemplateSummary { - pub id: Uuid, - pub name: String, - pub description: Option<String>, - pub phases: Vec<PhaseDefinition>, - pub default_phase: String, - pub is_builtin: bool, - pub version: i32, - pub created_at: DateTime<Utc>, -} - -// ============================================================================= -// Contract Types -// ============================================================================= - -/// Contract type determines the workflow and required documents -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "lowercase")] -pub enum ContractType { - /// Simple Plan -> Execute workflow (default) - /// - Plan phase: requires a "Plan" document - /// - Execute phase: requires a "PR" document - Simple, - /// Specification-based development with TDD - /// - Research: requires "Research Notes" document - /// - Specify: requires "Requirements Document" - /// - Plan: requires "Plan" document - /// - Execute: requires "PR" document - /// - Review: requires "Release Notes" document - Specification, - /// Execute-only workflow with no deliverables - /// - Only has "execute" phase - /// - NO deliverables at all - just execute tasks directly - Execute, -} - -impl Default for ContractType { - fn default() -> Self { - ContractType::Simple - } -} - -impl std::fmt::Display for ContractType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ContractType::Simple => write!(f, "simple"), - ContractType::Specification => write!(f, "specification"), - ContractType::Execute => write!(f, "execute"), - } - } -} - -impl std::str::FromStr for ContractType { - type Err = String; - - fn from_str(s: &str) -> Result<Self, Self::Err> { - match s.to_lowercase().as_str() { - "simple" => Ok(ContractType::Simple), - "specification" => Ok(ContractType::Specification), - "execute" => Ok(ContractType::Execute), - _ => Err(format!("Unknown contract type: {}", s)), - } - } -} - -/// 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>, - /// Contract type: "simple" or "specification" - pub contract_type: 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>, - /// Whether tasks for this contract should run in autonomous loop mode. - /// When enabled, tasks will automatically restart with --continue if they exit - /// without a COMPLETION_GATE indicating ready: true. - #[serde(default)] - pub autonomous_loop: bool, - /// Whether to wait for user confirmation before progressing to the next phase. - /// When enabled, the supervisor will pause and ask the user to review and approve - /// phase outputs (like plans, requirements, etc.) before continuing. - #[serde(default)] - pub phase_guard: bool, - /// Completed deliverables per phase. - /// Structure: { "plan": ["plan-document"], "execute": ["pull-request"] } - #[sqlx(json)] - #[serde(default)] - pub completed_deliverables: serde_json::Value, - /// Whether this contract operates in local-only mode. - /// When enabled, automatic completion actions (branch, merge, pr) are skipped, - /// allowing users to manually handle code changes via patch files or other means. - #[serde(default)] - pub local_only: bool, - /// Whether to auto-merge to target branch locally when local_only mode is enabled. - /// When both local_only and auto_merge_local are true, completed task changes will be - /// automatically merged to the master/main branch locally (without pushing or creating PRs). - #[serde(default)] - pub auto_merge_local: bool, - /// Phase configuration copied from template at contract creation (raw JSON). - /// When present, this overrides the built-in contract type phases. - /// Use `get_phase_config()` to get the parsed PhaseConfig. - #[serde(skip_serializing_if = "Option::is_none")] - pub phase_config: Option<serde_json::Value>, - pub version: i32, - pub created_at: DateTime<Utc>, - pub updated_at: DateTime<Utc>, -} - -impl Contract { - /// Parse contract_type string to ContractType enum - pub fn contract_type_enum(&self) -> Result<ContractType, String> { - self.contract_type.parse() - } - - /// 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() - } - - /// Get valid phase IDs for this contract (as strings) - pub fn valid_phase_ids(&self) -> Vec<String> { - // Check phase_config first (for custom templates) - if let Some(config) = self.get_phase_config() { - let mut phases: Vec<_> = config.phases.iter().collect(); - phases.sort_by_key(|p| p.order); - return phases.iter().map(|p| p.id.clone()).collect(); - } - - // Fall back to built-in contract types - match self.contract_type.as_str() { - "simple" => vec!["plan".to_string(), "execute".to_string()], - "specification" => vec![ - "research".to_string(), - "specify".to_string(), - "plan".to_string(), - "execute".to_string(), - "review".to_string(), - ], - "execute" => vec!["execute".to_string()], - _ => vec!["plan".to_string(), "execute".to_string()], - } - } - - /// Get valid phases for this contract type (as ContractPhase enums) - /// Note: For custom templates with non-standard phases, this only returns - /// phases that map to the ContractPhase enum. - pub fn valid_phases(&self) -> Vec<ContractPhase> { - self.valid_phase_ids() - .iter() - .filter_map(|id| id.parse::<ContractPhase>().ok()) - .collect() - } - - /// Get the initial phase ID for this contract type (as string) - pub fn initial_phase_id(&self) -> String { - // Check phase_config first (for custom templates) - if let Some(config) = self.get_phase_config() { - return config.default_phase.clone(); - } - - // Fall back to built-in contract types - match self.contract_type.as_str() { - "specification" => "research".to_string(), - "execute" => "execute".to_string(), - _ => "plan".to_string(), - } - } - - /// Get the initial phase for this contract type (as ContractPhase enum) - pub fn initial_phase(&self) -> ContractPhase { - self.initial_phase_id() - .parse() - .unwrap_or(ContractPhase::Plan) - } - - /// Get the terminal phase ID for this contract type (as string) - pub fn terminal_phase_id(&self) -> String { - // Check phase_config first (for custom templates) - if let Some(config) = self.get_phase_config() { - // Last phase in sorted order is the terminal phase - let mut phases: Vec<_> = config.phases.iter().collect(); - phases.sort_by_key(|p| p.order); - if let Some(last) = phases.last() { - return last.id.clone(); - } - } - - // Fall back to built-in contract types - match self.contract_type.as_str() { - "specification" => "review".to_string(), - _ => "execute".to_string(), - } - } - - /// Get the terminal phase for this contract type (phase where contract can be completed) - pub fn terminal_phase(&self) -> ContractPhase { - self.terminal_phase_id() - .parse() - .unwrap_or(ContractPhase::Execute) - } - - /// Check if a phase ID is valid for this contract - pub fn is_valid_phase(&self, phase_id: &str) -> bool { - self.valid_phase_ids().contains(&phase_id.to_string()) - } - - /// Get the phase configuration for custom templates - pub fn get_phase_config(&self) -> Option<PhaseConfig> { - self.phase_config - .as_ref() - .and_then(|v| serde_json::from_value(v.clone()).ok()) - } - - /// Get completed deliverable IDs for a specific phase - pub fn get_completed_deliverables(&self, phase: &str) -> Vec<String> { - self.completed_deliverables - .get(phase) - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default() - } - - /// Check if a specific deliverable is marked as complete for a phase - pub fn is_deliverable_complete(&self, phase: &str, deliverable_id: &str) -> bool { - self.get_completed_deliverables(phase) - .contains(&deliverable_id.to_string()) - } -} - -/// 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>, - /// Contract type: "simple" or "specification" - pub contract_type: String, - pub phase: String, - pub status: String, - /// Supervisor task ID for contract orchestration - pub supervisor_task_id: Option<Uuid>, - /// When true, tasks do not auto-execute completion actions and work stays in worktrees. - #[serde(default)] - pub local_only: bool, - /// When true with local_only, automatically merge completed tasks to target branch locally. - #[serde(default)] - pub auto_merge_local: bool, - 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>, - /// Contract type: "simple" (default), "specification", "execute", or a custom template name. - /// For built-in types: - /// - simple: Plan -> Execute workflow - /// - specification: Research -> Specify -> Plan -> Execute -> Review - /// - execute: Execute only - /// For custom templates, use the template name or provide template_id. - #[serde(default)] - pub contract_type: Option<String>, - /// UUID of a custom template to use. If provided, this takes precedence over contract_type. - /// The template's phase configuration will be copied to the contract. - #[serde(default)] - pub template_id: Option<Uuid>, - /// Initial phase to start in (defaults based on contract_type or template) - /// - simple: defaults to "plan" - /// - specification: defaults to "research" - #[serde(default)] - pub initial_phase: Option<String>, - /// Enable autonomous loop mode for tasks in this contract. - /// When enabled, tasks automatically restart with --continue if they exit - /// without a COMPLETION_GATE indicating ready: true. - #[serde(default)] - pub autonomous_loop: Option<bool>, - /// Enable phase guard mode for this contract. - /// When enabled, the supervisor will pause and ask the user to review and approve - /// phase outputs before progressing to the next phase. - #[serde(default)] - pub phase_guard: Option<bool>, - /// Enable local-only mode for this contract. - /// When enabled, automatic completion actions (branch, merge, pr) are skipped, - /// allowing users to manually handle code changes via patch files or other means. - #[serde(default)] - pub local_only: Option<bool>, - /// Enable auto-merge to target branch locally when local_only mode is enabled. - /// When both local_only and auto_merge_local are true, completed task changes will be - /// automatically merged to the master/main branch locally (without pushing or creating PRs). - #[serde(default)] - pub auto_merge_local: Option<bool>, -} - -/// 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>, - /// Enable or disable autonomous loop mode for tasks in this contract. - #[serde(default)] - pub autonomous_loop: Option<bool>, - /// Enable or disable phase guard mode for this contract. - /// When enabled, the supervisor will pause and ask the user to review and approve - /// phase outputs before progressing to the next phase. - #[serde(default)] - pub phase_guard: Option<bool>, - /// Enable or disable local-only mode for this contract. - /// When enabled, automatic completion actions (branch, merge, pr) are skipped, - /// allowing users to manually handle code changes via patch files or other means. - #[serde(default)] - pub local_only: Option<bool>, - /// Enable or disable auto-merge to target branch locally when local_only mode is enabled. - /// When both local_only and auto_merge_local are true, completed task changes will be - /// automatically merged to the master/main branch locally (without pushing or creating PRs). - #[serde(default)] - pub auto_merge_local: Option<bool>, - /// 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, - /// If phase_guard is enabled, this must be true to confirm the transition. - /// If not provided or false, returns phase deliverables for review. - #[serde(default)] - pub confirmed: Option<bool>, - /// User feedback for changes (used when not confirming) - #[serde(skip_serializing_if = "Option::is_none")] - pub feedback: Option<String>, - /// Expected version for optimistic locking. If provided, the phase change - /// will only succeed if the current contract version matches. - #[serde(skip_serializing_if = "Option::is_none")] - pub expected_version: Option<i32>, -} - -/// Result of a phase change operation, supporting explicit conflict detection. -#[derive(Debug, Clone)] -pub enum PhaseChangeResult { - /// Phase change succeeded, returning the updated contract - Success(Contract), - /// Version conflict: the contract was modified concurrently - VersionConflict { - /// The version the client expected - expected: i32, - /// The actual current version in the database - actual: i32, - /// The current phase of the contract - current_phase: String, - }, - /// Validation failed (e.g., invalid phase transition) - ValidationFailed { - /// Human-readable reason for the failure - reason: String, - /// List of missing requirements for the phase transition - missing_requirements: Vec<String>, - }, - /// The caller is not authorized to change this contract's phase - Unauthorized, - /// The contract was not found - NotFound, -} - -/// Response for phase transition when phase_guard is enabled -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct PhaseTransitionRequest { - /// Current contract phase - pub current_phase: String, - /// Requested next phase - pub next_phase: String, - /// Summary of phase deliverables/outputs - pub deliverables_summary: String, - /// List of files created in this phase - pub phase_files: Vec<PhaseFileInfo>, - /// List of completed tasks in this phase - pub phase_tasks: Vec<PhaseTaskInfo>, - /// Whether user confirmation is required - pub requires_confirmation: bool, - /// Unique ID for tracking this transition request - pub transition_id: String, -} - -/// File info for phase transition review -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct PhaseFileInfo { - pub id: Uuid, - pub name: String, - pub description: Option<String>, -} - -/// Task info for phase transition review -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct PhaseTaskInfo { - pub id: Uuid, - pub name: String, - pub status: 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) @@ -2173,11 +1280,9 @@ pub struct ConversationSnapshot { pub struct HistoryEvent { pub id: Uuid, pub owner_id: Uuid, - pub contract_id: Option<Uuid>, pub task_id: Option<Uuid>, pub event_type: String, pub event_subtype: Option<String>, - pub phase: Option<String>, #[sqlx(json)] pub event_data: serde_json::Value, pub created_at: DateTime<Utc>, @@ -2221,7 +1326,6 @@ pub struct ToolCallInfo { #[derive(Debug, Deserialize, ToSchema, Default)] #[serde(rename_all = "camelCase")] pub struct HistoryQueryFilters { - pub phase: Option<String>, pub event_types: Option<Vec<String>>, #[serde(default, deserialize_with = "flexible_datetime::deserialize")] pub from: Option<DateTime<Utc>>, @@ -2766,11 +1870,6 @@ pub struct DirectiveStep { /// Status: pending, ready, running, completed, failed, skipped pub status: String, pub task_id: Option<Uuid>, - /// Optional contract ID for contract-backed execution. - pub contract_id: Option<Uuid>, - /// Optional contract type (e.g. "simple", "specification", "execute"). - /// When set, the orchestrator creates a contract instead of a standalone task. - pub contract_type: Option<String>, pub order_index: i32, pub generation: i32, pub started_at: Option<DateTime<Utc>>, diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index ee4b561..d453f99 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -6,21 +6,17 @@ use sqlx::PgPool; use uuid::Uuid; use super::models::{ - CheckpointPatch, CheckpointPatchInfo, Contract, ContractChatConversation, - ContractChatMessageRecord, ContractEvent, ContractRepository, ContractSummary, - ContractTypeTemplateRecord, ConversationMessage, ConversationSnapshot, - CreateContractRequest, CreateFileRequest, CreateTaskRequest, - CreateTemplateRequest, Daemon, DaemonTaskAssignment, DaemonWithCapacity, - DeliverableDefinition, Directive, DirectiveDocument, DirectiveStep, DirectiveSummary, + CheckpointPatch, CheckpointPatchInfo, ConversationMessage, ConversationSnapshot, + CreateFileRequest, CreateTaskRequest, + Daemon, DaemonTaskAssignment, DaemonWithCapacity, + Directive, DirectiveDocument, DirectiveStep, DirectiveSummary, CreateDirectiveRequest, CreateDirectiveStepRequest, UpdateDirectiveRequest, UpdateDirectiveStepRequest, CreateOrderRequest, Order, UpdateOrderRequest, CreateDirectiveOrderGroupRequest, DirectiveOrderGroup, UpdateDirectiveOrderGroupRequest, File, FileSummary, FileVersion, HistoryEvent, HistoryQueryFilters, - MeshChatConversation, MeshChatMessageRecord, PhaseChangeResult, PhaseConfig, - PhaseDefinition, SupervisorHeartbeatRecord, SupervisorState, - Task, TaskCheckpoint, TaskEvent, TaskSummary, UpdateContractRequest, - UpdateFileRequest, UpdateTaskRequest, UpdateTemplateRequest, + Task, TaskCheckpoint, TaskEvent, TaskSummary, + UpdateFileRequest, UpdateTaskRequest, }; /// Repository error types. @@ -89,7 +85,7 @@ pub async fn create_file(pool: &PgPool, req: InternalCreateFileRequest) -> Resul r#" INSERT INTO files (name, description, transcript, location, summary, body) VALUES ($1, $2, $3, $4, NULL, $5) - 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 + RETURNING id, owner_id, name, description, transcript, location, summary, body, version, repo_file_path, repo_synced_at, repo_sync_status, created_at, updated_at "#, ) .bind(&name) @@ -105,7 +101,7 @@ pub async fn create_file(pool: &PgPool, req: InternalCreateFileRequest) -> Resul pub async fn get_file(pool: &PgPool, id: Uuid) -> Result<Option<File>, sqlx::Error> { 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 + SELECT id, owner_id, 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 "#, @@ -119,7 +115,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, contract_id, contract_phase, name, description, transcript, location, summary, body, version, repo_file_path, repo_synced_at, repo_sync_status, created_at, updated_at + SELECT id, owner_id, 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 "#, @@ -171,7 +167,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, contract_id, contract_phase, name, description, transcript, location, summary, body, version, repo_file_path, repo_synced_at, repo_sync_status, created_at, updated_at + RETURNING id, owner_id, name, description, transcript, location, summary, body, version, repo_file_path, repo_synced_at, repo_sync_status, created_at, updated_at "#, ) .bind(id) @@ -190,7 +186,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, contract_id, contract_phase, name, description, transcript, location, summary, body, version, repo_file_path, repo_synced_at, repo_sync_status, created_at, updated_at + RETURNING id, owner_id, name, description, transcript, location, summary, body, version, repo_file_path, repo_synced_at, repo_sync_status, created_at, updated_at "#, ) .bind(id) @@ -246,7 +242,6 @@ 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, @@ -254,32 +249,16 @@ 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(); - // 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, 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 + INSERT INTO files (owner_id, name, description, transcript, location, summary, body, repo_file_path) + VALUES ($1, $2, $3, $4, $5, NULL, $6, $7) + RETURNING id, owner_id, 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) @@ -298,7 +277,7 @@ pub async fn get_file_for_owner( ) -> Result<Option<File>, sqlx::Error> { 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 + SELECT id, owner_id, 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 "#, @@ -313,7 +292,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, contract_id, contract_phase, name, description, transcript, location, summary, body, version, repo_file_path, repo_synced_at, repo_sync_status, created_at, updated_at + SELECT id, owner_id, 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 @@ -324,13 +303,10 @@ 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 +/// Database row type for file summary #[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)] @@ -342,7 +318,7 @@ struct FileSummaryRow { updated_at: chrono::DateTime<chrono::Utc>, } -/// List file summaries for an owner with contract info (joined). +/// List file summaries for an owner. pub async fn list_file_summaries_for_owner( pool: &PgPool, owner_id: Uuid, @@ -350,11 +326,9 @@ pub async fn list_file_summaries_for_owner( 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.id, 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 "#, @@ -373,9 +347,6 @@ pub async fn list_file_summaries_for_owner( .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(), @@ -429,7 +400,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, contract_id, contract_phase, name, description, transcript, location, summary, body, version, repo_file_path, repo_synced_at, repo_sync_status, created_at, updated_at + RETURNING id, owner_id, name, description, transcript, location, summary, body, version, repo_file_path, repo_synced_at, repo_sync_status, created_at, updated_at "#, ) .bind(id) @@ -449,7 +420,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, contract_id, contract_phase, name, description, transcript, location, summary, body, version, repo_file_path, repo_synced_at, repo_sync_status, created_at, updated_at + RETURNING id, owner_id, name, description, transcript, location, summary, body, version, repo_file_path, repo_synced_at, repo_sync_status, created_at, updated_at "#, ) .bind(id) @@ -657,34 +628,24 @@ pub async fn count_file_versions(pool: &PgPool, file_id: Uuid) -> Result<i64, sq /// 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, contract_id, repo_url, base_branch, target_branch, merge_mode, target_repo_path, completion_action) = + // Calculate depth + inherit settings from parent if applicable. + let (depth, 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 settings let parent = get_task(pool, parent_id).await? .ok_or_else(|| sqlx::Error::RowNotFound)?; let new_depth = parent.depth + 1; - - // Subtasks inherit contract_id from parent (or use request contract_id if parent has none) - let contract_id = parent.contract_id.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); let target_branch = req.target_branch.clone().or(parent.target_branch); 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 supervisor integrates subtask work from their worktrees. let completion_action = req.completion_action.clone(); - (new_depth, contract_id, repo_url, base_branch, target_branch, merge_mode, target_repo_path, completion_action) + (new_depth, repo_url, base_branch, target_branch, merge_mode, target_repo_path, completion_action) } else { - // Top-level task: depth 0, use contract_id from request (may be None for branched tasks) ( 0, - req.contract_id, req.repository_url.clone(), req.base_branch.clone(), req.target_branch.clone(), @@ -699,23 +660,21 @@ pub async fn create_task(pool: &PgPool, req: CreateTaskRequest) -> Result<Task, sqlx::query_as::<_, Task>( r#" INSERT INTO tasks ( - contract_id, parent_task_id, depth, name, description, plan, priority, - is_supervisor, repository_url, base_branch, target_branch, merge_mode, + parent_task_id, depth, name, description, plan, priority, + repository_url, base_branch, target_branch, merge_mode, target_repo_path, completion_action, continue_from_task_id, copy_files, - branched_from_task_id, conversation_state, supervisor_worktree_task_id + branched_from_task_id, conversation_state ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) + 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) @@ -726,7 +685,6 @@ pub async fn create_task(pool: &PgPool, req: CreateTaskRequest) -> Result<Task, .bind(©_files_json) .bind(&req.branched_from_task_id) .bind(&req.conversation_history) - .bind(&req.supervisor_worktree_task_id) .fetch_one(pool) .await } @@ -751,14 +709,12 @@ pub async fn list_tasks(pool: &PgPool) -> 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, - c.status as contract_status, + t.id, 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, COALESCE(t.hidden, false) as hidden, t.created_at, t.updated_at + t.version, COALESCE(t.hidden, false) as hidden, 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 COALESCE(t.hidden, false) = false ORDER BY t.priority DESC, t.created_at DESC "#, @@ -772,14 +728,12 @@ pub async fn list_subtasks(pool: &PgPool, parent_id: Uuid) -> Result<Vec<TaskSum sqlx::query_as::<_, TaskSummary>( r#" SELECT - t.id, t.contract_id, c.name as contract_name, c.phase as contract_phase, - c.status as contract_status, + t.id, 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, COALESCE(t.hidden, false) as hidden, t.created_at, t.updated_at + t.version, COALESCE(t.hidden, false) as hidden, 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 "#, @@ -790,73 +744,6 @@ pub async fn list_subtasks(pool: &PgPool, parent_id: Uuid) -> Result<Vec<TaskSum } /// 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 -} - -/// Get pending tasks for a contract (non-supervisor tasks only). -/// Includes tasks that were interrupted (retry candidates). -/// Prioritizes interrupted tasks and excludes those that exceeded max_retries. -pub async fn get_pending_tasks_for_contract( - pool: &PgPool, - contract_id: Uuid, - owner_id: Uuid, -) -> Result<Vec<Task>, sqlx::Error> { - sqlx::query_as::<_, Task>( - r#" - SELECT t.* FROM tasks t - WHERE t.contract_id = $1 AND t.owner_id = $2 - AND t.status = 'pending' - AND t.retry_count < t.max_retries - AND t.is_supervisor = false - ORDER BY - t.interrupted_at DESC NULLS LAST, - t.priority DESC, - t.created_at ASC - "#, - ) - .bind(contract_id) - .bind(owner_id) - .fetch_all(pool) - .await -} - -/// Get all contracts that have pending tasks awaiting retry. -/// Returns tuples of (contract_id, owner_id) for contracts with retryable tasks. -pub async fn get_all_pending_task_contracts( - pool: &PgPool, -) -> Result<Vec<(Uuid, Uuid)>, sqlx::Error> { - sqlx::query_as::<_, (Uuid, Uuid)>( - r#" - SELECT DISTINCT t.contract_id, t.owner_id - FROM tasks t - WHERE t.contract_id IS NOT NULL - AND t.status = 'pending' - AND t.retry_count < t.max_retries - AND t.is_supervisor = false - ORDER BY t.owner_id, t.contract_id - "#, - ) - .fetch_all(pool) - .await -} - -/// Mark a task as pending for retry after daemon failure. -/// Increments retry count and adds the failed daemon to exclusion list. pub async fn mark_task_for_retry( pool: &PgPool, task_id: Uuid, @@ -1061,16 +948,13 @@ pub async fn create_task_for_owner( owner_id: Uuid, req: CreateTaskRequest, ) -> Result<Task, sqlx::Error> { - // Calculate depth and inherit settings from parent if applicable - let (depth, contract_id, repo_url, base_branch, target_branch, merge_mode, target_repo_path, completion_action) = + // Calculate depth + inherit settings from parent if applicable. + let (depth, 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 settings (must belong to same owner) let parent = get_task_for_owner(pool, parent_id, owner_id).await? .ok_or_else(|| sqlx::Error::RowNotFound)?; let new_depth = parent.depth + 1; - - // Validate max depth 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.", @@ -1078,25 +962,17 @@ pub async fn create_task_for_owner( ))); } - // Subtasks inherit contract_id from parent (or use request contract_id if parent has none) - let contract_id = parent.contract_id.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); let target_branch = req.target_branch.clone().or(parent.target_branch); 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. let completion_action = req.completion_action.clone(); - (new_depth, contract_id, repo_url, base_branch, target_branch, merge_mode, target_repo_path, completion_action) + (new_depth, repo_url, base_branch, target_branch, merge_mode, target_repo_path, completion_action) } else { - // Top-level task: depth 0, use contract_id from request (may be None for branched tasks) ( 0, - req.contract_id, req.repository_url.clone(), req.base_branch.clone(), req.target_branch.clone(), @@ -1108,14 +984,11 @@ pub async fn create_task_for_owner( let copy_files_json = req.copy_files.as_ref().map(|f| serde_json::to_value(f).unwrap_or_default()); - // Resolve the directive_document_id. Tasks plumbed through this builder - // currently have no way to specify a document explicitly (we don't want - // to widen `CreateTaskRequest` for this — every call site would have to - // change). Instead, when the task is directive-driven (directive_id is - // set) we attach it to that directive's most recently-updated active - // document so the task lands under that document's tasks/ subfolder in - // the sidebar. Resolution failures are non-fatal — the task still gets - // created with directive_document_id = NULL, matching legacy behaviour. + // Resolve directive_document_id from the directive's currently- + // active contract row (directive_documents table) so the task + // lands under the right tasks/ subfolder in the sidebar. Failures + // are non-fatal — the task is created with NULL document_id and + // the sidebar tolerates that. let directive_document_id = match req.directive_id { Some(directive_id) => resolve_active_document_for_directive(pool, directive_id) .await @@ -1126,25 +999,23 @@ pub async fn create_task_for_owner( sqlx::query_as::<_, Task>( r#" INSERT INTO tasks ( - owner_id, contract_id, parent_task_id, depth, name, description, plan, priority, - is_supervisor, repository_url, base_branch, target_branch, merge_mode, + owner_id, parent_task_id, depth, name, description, plan, priority, + repository_url, base_branch, target_branch, merge_mode, target_repo_path, completion_action, continue_from_task_id, copy_files, - branched_from_task_id, conversation_state, supervisor_worktree_task_id, + branched_from_task_id, conversation_state, directive_id, directive_step_id, directive_document_id ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) 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) @@ -1155,7 +1026,6 @@ pub async fn create_task_for_owner( .bind(©_files_json) .bind(&req.branched_from_task_id) .bind(&req.conversation_history) - .bind(&req.supervisor_worktree_task_id) .bind(&req.directive_id) .bind(&req.directive_step_id) .bind(&directive_document_id) @@ -1226,14 +1096,12 @@ pub async fn list_tasks_for_owner( sqlx::query_as::<_, TaskSummary>( r#" SELECT - t.id, t.contract_id, c.name as contract_name, c.phase as contract_phase, - c.status as contract_status, + t.id, 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, COALESCE(t.hidden, false) as hidden, t.created_at, t.updated_at + t.version, COALESCE(t.hidden, false) as hidden, 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 AND COALESCE(t.hidden, false) = false ORDER BY t.priority DESC, t.created_at DESC "#, @@ -1335,14 +1203,12 @@ pub async fn list_ephemeral_directive_tasks_for_owner( sqlx::query_as::<_, TaskSummary>( r#" SELECT - t.id, t.contract_id, c.name as contract_name, c.phase as contract_phase, - c.status as contract_status, + t.id, 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, COALESCE(t.hidden, false) as hidden, t.created_at, t.updated_at + t.version, COALESCE(t.hidden, false) as hidden, 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.directive_id = $2 AND t.directive_step_id IS NULL @@ -1369,14 +1235,12 @@ pub async fn list_tmp_tasks_for_owner( sqlx::query_as::<_, TaskSummary>( r#" SELECT - t.id, t.contract_id, c.name as contract_name, c.phase as contract_phase, - c.status as contract_status, + t.id, 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, COALESCE(t.hidden, false) as hidden, t.created_at, t.updated_at + t.version, COALESCE(t.hidden, false) as hidden, 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.directive_id = $2 AND t.parent_task_id IS NULL @@ -1399,14 +1263,12 @@ pub async fn list_subtasks_for_owner( sqlx::query_as::<_, TaskSummary>( r#" SELECT - t.id, t.contract_id, c.name as contract_name, c.phase as contract_phase, - c.status as contract_status, + t.id, 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, COALESCE(t.hidden, false) as hidden, t.created_at, t.updated_at + t.version, COALESCE(t.hidden, false) as hidden, 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 "#, @@ -1920,14 +1782,12 @@ pub async fn list_sibling_tasks( sqlx::query_as::<_, TaskSummary>( r#" SELECT - t.id, t.contract_id, c.name as contract_name, c.phase as contract_phase, - c.status as contract_status, + t.id, 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, COALESCE(t.hidden, false) as hidden, t.created_at, t.updated_at + t.version, COALESCE(t.hidden, false) as hidden, 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 "#, @@ -1942,14 +1802,12 @@ pub async fn list_sibling_tasks( sqlx::query_as::<_, TaskSummary>( r#" SELECT - t.id, t.contract_id, c.name as contract_name, c.phase as contract_phase, - c.status as contract_status, + t.id, 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, COALESCE(t.hidden, false) as hidden, t.created_at, t.updated_at + t.version, COALESCE(t.hidden, false) as hidden, 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 "#, @@ -2121,1287 +1979,28 @@ pub async fn complete_task( Ok(task) } -// ============================================================================= -// Mesh Chat History Functions -// ============================================================================= - -/// Get or create the active conversation for an owner. -pub async fn get_or_create_active_conversation( - pool: &PgPool, - owner_id: Uuid, -) -> Result<MeshChatConversation, sqlx::Error> { - // Try to get existing active conversation for this owner - let existing = sqlx::query_as::<_, MeshChatConversation>( - r#" - SELECT * - FROM mesh_chat_conversations - WHERE is_active = true AND owner_id = $1 - LIMIT 1 - "#, - ) - .bind(owner_id) - .fetch_optional(pool) - .await?; - - if let Some(conv) = existing { - return Ok(conv); - } - - // Create new conversation - sqlx::query_as::<_, MeshChatConversation>( - r#" - INSERT INTO mesh_chat_conversations (owner_id, is_active) - VALUES ($1, true) - RETURNING * - "#, - ) - .bind(owner_id) - .fetch_one(pool) - .await -} - -/// List messages for a conversation. -pub async fn list_chat_messages( - pool: &PgPool, - conversation_id: Uuid, - limit: Option<i32>, -) -> Result<Vec<MeshChatMessageRecord>, sqlx::Error> { - let limit = limit.unwrap_or(100); - sqlx::query_as::<_, MeshChatMessageRecord>( - r#" - SELECT * - FROM mesh_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 conversation. -#[allow(clippy::too_many_arguments)] -pub async fn add_chat_message( - pool: &PgPool, - conversation_id: Uuid, - role: &str, - content: &str, - context_type: &str, - context_task_id: Option<Uuid>, - tool_calls: Option<serde_json::Value>, - pending_questions: Option<serde_json::Value>, -) -> Result<MeshChatMessageRecord, sqlx::Error> { - sqlx::query_as::<_, MeshChatMessageRecord>( - r#" - INSERT INTO mesh_chat_messages - (conversation_id, role, content, context_type, context_task_id, tool_calls, pending_questions) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING * - "#, - ) - .bind(conversation_id) - .bind(role) - .bind(content) - .bind(context_type) - .bind(context_task_id) - .bind(tool_calls) - .bind(pending_questions) - .fetch_one(pool) - .await -} - -/// Clear conversation (archive existing and create new). -pub async fn clear_conversation(pool: &PgPool, owner_id: Uuid) -> Result<MeshChatConversation, sqlx::Error> { - // Mark existing as inactive for this owner - sqlx::query( - r#" - UPDATE mesh_chat_conversations - SET is_active = false, updated_at = NOW() - WHERE is_active = true AND owner_id = $1 - "#, - ) - .bind(owner_id) - .execute(pool) - .await?; - - // 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 Type Template Functions (Owner-Scoped) -// ============================================================================= - -/// Create a new contract type template for a specific owner. -pub async fn create_template_for_owner( - pool: &PgPool, - owner_id: Uuid, - req: CreateTemplateRequest, -) -> Result<ContractTypeTemplateRecord, sqlx::Error> { - sqlx::query_as::<_, ContractTypeTemplateRecord>( - r#" - INSERT INTO contract_type_templates (owner_id, name, description, phases, default_phase, deliverables) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING * - "#, - ) - .bind(owner_id) - .bind(&req.name) - .bind(&req.description) - .bind(serde_json::to_value(&req.phases).unwrap_or_default()) - .bind(&req.default_phase) - .bind(match &req.deliverables { - Some(d) => serde_json::to_value(d).ok(), - None => None, - }) - .fetch_one(pool) - .await -} - -/// Get a contract type template by ID, scoped to owner. -pub async fn get_template_for_owner( - pool: &PgPool, - id: Uuid, - owner_id: Uuid, -) -> Result<Option<ContractTypeTemplateRecord>, sqlx::Error> { - sqlx::query_as::<_, ContractTypeTemplateRecord>( - r#" - SELECT * - FROM contract_type_templates - WHERE id = $1 AND owner_id = $2 - "#, - ) - .bind(id) - .bind(owner_id) - .fetch_optional(pool) - .await -} - -/// Get a contract type template by ID (internal use, no owner scoping). -pub async fn get_template_by_id( - pool: &PgPool, - id: Uuid, -) -> Result<Option<ContractTypeTemplateRecord>, sqlx::Error> { - sqlx::query_as::<_, ContractTypeTemplateRecord>( - r#" - SELECT * - FROM contract_type_templates - WHERE id = $1 - "#, - ) - .bind(id) - .fetch_optional(pool) - .await -} - -/// List all contract type templates for an owner, ordered by name. -pub async fn list_templates_for_owner( - pool: &PgPool, - owner_id: Uuid, -) -> Result<Vec<ContractTypeTemplateRecord>, sqlx::Error> { - sqlx::query_as::<_, ContractTypeTemplateRecord>( - r#" - SELECT * - FROM contract_type_templates - WHERE owner_id = $1 - ORDER BY name ASC - "#, - ) - .bind(owner_id) - .fetch_all(pool) - .await -} - -/// Update a contract type template for an owner. -pub async fn update_template_for_owner( - pool: &PgPool, - id: Uuid, - owner_id: Uuid, - req: UpdateTemplateRequest, -) -> Result<Option<ContractTypeTemplateRecord>, RepositoryError> { - // Build dynamic update query - let mut query = String::from("UPDATE contract_type_templates SET updated_at = NOW()"); - let mut param_idx = 3; // $1 = id, $2 = owner_id - - if req.name.is_some() { - query.push_str(&format!(", name = ${}", param_idx)); - param_idx += 1; - } - if req.description.is_some() { - query.push_str(&format!(", description = ${}", param_idx)); - param_idx += 1; - } - if req.phases.is_some() { - query.push_str(&format!(", phases = ${}", param_idx)); - param_idx += 1; - } - if req.default_phase.is_some() { - query.push_str(&format!(", default_phase = ${}", param_idx)); - param_idx += 1; - } - if req.deliverables.is_some() { - query.push_str(&format!(", deliverables = ${}", param_idx)); - param_idx += 1; - } - - // Optimistic locking - if req.version.is_some() { - query.push_str(&format!(", version = version + 1 WHERE id = $1 AND owner_id = $2 AND version = ${}", param_idx)); - } else { - query.push_str(", version = version + 1 WHERE id = $1 AND owner_id = $2"); - } - query.push_str(" RETURNING *"); - - let mut sql_query = sqlx::query_as::<_, ContractTypeTemplateRecord>(&query); - sql_query = sql_query.bind(id).bind(owner_id); - - if let Some(ref name) = req.name { - sql_query = sql_query.bind(name); - } - if let Some(ref description) = req.description { - sql_query = sql_query.bind(description); - } - if let Some(ref phases) = req.phases { - sql_query = sql_query.bind(serde_json::to_value(phases).unwrap_or_default()); - } - if let Some(ref default_phase) = req.default_phase { - sql_query = sql_query.bind(default_phase); - } - if let Some(ref deliverables) = req.deliverables { - sql_query = sql_query.bind(serde_json::to_value(deliverables).unwrap_or_default()); - } - if let Some(version) = req.version { - sql_query = sql_query.bind(version); - } - - match sql_query.fetch_optional(pool).await { - Ok(result) => { - if result.is_none() && req.version.is_some() { - // Check if it's a version conflict - if let Some(current) = get_template_for_owner(pool, id, owner_id).await? { - return Err(RepositoryError::VersionConflict { - expected: req.version.unwrap(), - actual: current.version, - }); - } - } - Ok(result) - } - Err(e) => Err(RepositoryError::Database(e)), - } -} - -/// Delete a contract type template for an owner. -pub async fn delete_template_for_owner( - pool: &PgPool, - id: Uuid, - owner_id: Uuid, -) -> Result<bool, sqlx::Error> { - let result = sqlx::query( - r#" - DELETE FROM contract_type_templates - WHERE id = $1 AND owner_id = $2 - "#, - ) - .bind(id) - .bind(owner_id) - .execute(pool) - .await?; - - Ok(result.rows_affected() > 0) -} - -/// Helper function to build PhaseConfig from a template. -pub fn build_phase_config_from_template(template: &ContractTypeTemplateRecord) -> PhaseConfig { - PhaseConfig { - phases: template.phases.clone(), - default_phase: template.default_phase.clone(), - deliverables: template.deliverables.clone().unwrap_or_default(), - } -} - -/// Helper function to build PhaseConfig for built-in contract types. -pub fn build_phase_config_for_builtin(contract_type: &str) -> PhaseConfig { - match contract_type { - "simple" => PhaseConfig { - phases: vec![ - PhaseDefinition { id: "plan".to_string(), name: "Plan".to_string(), order: 0 }, - PhaseDefinition { id: "execute".to_string(), name: "Execute".to_string(), order: 1 }, - ], - default_phase: "plan".to_string(), - deliverables: [ - ("plan".to_string(), vec![DeliverableDefinition { - id: "plan-document".to_string(), - name: "Plan".to_string(), - priority: "required".to_string(), - }]), - ("execute".to_string(), vec![DeliverableDefinition { - id: "pull-request".to_string(), - name: "Pull Request".to_string(), - priority: "required".to_string(), - }]), - ].into_iter().collect(), - }, - "specification" => PhaseConfig { - phases: vec![ - PhaseDefinition { id: "research".to_string(), name: "Research".to_string(), order: 0 }, - PhaseDefinition { id: "specify".to_string(), name: "Specify".to_string(), order: 1 }, - PhaseDefinition { id: "plan".to_string(), name: "Plan".to_string(), order: 2 }, - PhaseDefinition { id: "execute".to_string(), name: "Execute".to_string(), order: 3 }, - PhaseDefinition { id: "review".to_string(), name: "Review".to_string(), order: 4 }, - ], - default_phase: "research".to_string(), - deliverables: [ - ("research".to_string(), vec![DeliverableDefinition { - id: "research-notes".to_string(), - name: "Research Notes".to_string(), - priority: "required".to_string(), - }]), - ("specify".to_string(), vec![DeliverableDefinition { - id: "requirements-document".to_string(), - name: "Requirements Document".to_string(), - priority: "required".to_string(), - }]), - ("plan".to_string(), vec![DeliverableDefinition { - id: "plan-document".to_string(), - name: "Plan".to_string(), - priority: "required".to_string(), - }]), - ("execute".to_string(), vec![DeliverableDefinition { - id: "pull-request".to_string(), - name: "Pull Request".to_string(), - priority: "required".to_string(), - }]), - ("review".to_string(), vec![DeliverableDefinition { - id: "release-notes".to_string(), - name: "Release Notes".to_string(), - priority: "required".to_string(), - }]), - ].into_iter().collect(), - }, - "execute" | _ => PhaseConfig { - phases: vec![ - PhaseDefinition { id: "execute".to_string(), name: "Execute".to_string(), order: 0 }, - ], - default_phase: "execute".to_string(), - deliverables: std::collections::HashMap::new(), - }, - } -} - -// ============================================================================= -// Contract Functions (Owner-Scoped) -// ============================================================================= - -/// Create a new contract for a specific owner. -/// Supports both built-in contract types (simple, specification, execute) and custom templates. -pub async fn create_contract_for_owner( - pool: &PgPool, - owner_id: Uuid, - req: CreateContractRequest, -) -> Result<Contract, sqlx::Error> { - // Determine phase configuration based on template_id or contract_type - let (phase_config, contract_type_str, default_phase): (PhaseConfig, String, String) = - if let Some(template_id) = req.template_id { - // Look up the custom template - let template = get_template_by_id(pool, template_id) - .await? - .ok_or_else(|| { - sqlx::Error::Protocol(format!("Template not found: {}", template_id)) - })?; - - let config = build_phase_config_from_template(&template); - let default = config.default_phase.clone(); - // For custom templates, store the template name as the contract_type - (config, template.name.clone(), default) - } else { - // Use built-in contract type - let contract_type = req.contract_type.as_deref().unwrap_or("simple"); - - // Validate contract type - let valid_types = ["simple", "specification", "execute"]; - if !valid_types.contains(&contract_type) { - return Err(sqlx::Error::Protocol(format!( - "Invalid contract_type '{}'. Must be one of: {} or provide a template_id", - contract_type, - valid_types.join(", ") - ))); - } - - let config = build_phase_config_for_builtin(contract_type); - let default = config.default_phase.clone(); - (config, contract_type.to_string(), default) - }; - - // Get valid phase IDs from the configuration - let valid_phase_ids: Vec<String> = phase_config.phases.iter().map(|p| p.id.clone()).collect(); - - // Use provided initial_phase or default based on contract type/template - let phase = req.initial_phase.as_deref().unwrap_or(&default_phase); - - // Validate the phase is valid for this contract type/template - if !valid_phase_ids.contains(&phase.to_string()) { - return Err(sqlx::Error::Protocol(format!( - "Invalid initial_phase '{}' for contract type '{}'. Must be one of: {}", - phase, - contract_type_str, - valid_phase_ids.join(", ") - ))); - } - - let autonomous_loop = req.autonomous_loop.unwrap_or(false); - let phase_guard = req.phase_guard.unwrap_or(false); - let local_only = req.local_only.unwrap_or(false); - let auto_merge_local = req.auto_merge_local.unwrap_or(false); - - // Serialize phase_config to JSON - let phase_config_json = serde_json::to_value(&phase_config).ok(); - - sqlx::query_as::<_, Contract>( - r#" - INSERT INTO contracts (owner_id, name, description, contract_type, phase, autonomous_loop, phase_guard, local_only, auto_merge_local, phase_config) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - RETURNING * - "#, - ) - .bind(owner_id) - .bind(&req.name) - .bind(&req.description) - .bind(&contract_type_str) - .bind(phase) - .bind(autonomous_loop) - .bind(phase_guard) - .bind(local_only) - .bind(auto_merge_local) - .bind(phase_config_json) - .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.contract_type, c.phase, c.status, - c.supervisor_task_id, c.local_only, c.auto_merge_local, 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.contract_type, c.phase, c.status, - c.supervisor_task_id, c.local_only, c.auto_merge_local, 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 autonomous_loop = req.autonomous_loop.unwrap_or(existing.autonomous_loop); - let phase_guard = req.phase_guard.unwrap_or(existing.phase_guard); - let local_only = req.local_only.unwrap_or(existing.local_only); - let auto_merge_local = req.auto_merge_local.unwrap_or(existing.auto_merge_local); - - 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, autonomous_loop = $8, phase_guard = $9, local_only = $10, auto_merge_local = $11, version = version + 1, updated_at = NOW() - WHERE id = $1 AND owner_id = $2 AND version = $12 - RETURNING * - "#, - ) - .bind(id) - .bind(owner_id) - .bind(&name) - .bind(&description) - .bind(&phase) - .bind(&status) - .bind(supervisor_task_id) - .bind(autonomous_loop) - .bind(phase_guard) - .bind(local_only) - .bind(auto_merge_local) - .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, autonomous_loop = $8, phase_guard = $9, local_only = $10, auto_merge_local = $11, 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) - .bind(autonomous_loop) - .bind(phase_guard) - .bind(local_only) - .bind(auto_merge_local) - .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. -/// -/// This is the simple version without version checking. Use `change_contract_phase_with_version` -/// for explicit version conflict detection. -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) -} - -/// Change contract phase with explicit version checking for conflict detection. -/// -/// Uses `SELECT ... FOR UPDATE` to lock the row and prevent race conditions. -/// Returns `PhaseChangeResult::VersionConflict` if the expected version doesn't match. -pub async fn change_contract_phase_with_version( - pool: &PgPool, - id: Uuid, - owner_id: Uuid, - new_phase: &str, - expected_version: Option<i32>, -) -> Result<PhaseChangeResult, sqlx::Error> { - // Start a transaction to ensure atomicity with row locking - let mut tx = pool.begin().await?; - - // Lock the row with SELECT FOR UPDATE and get current state - let existing: Option<Contract> = sqlx::query_as::<_, Contract>( - r#" - SELECT * - FROM contracts - WHERE id = $1 AND owner_id = $2 - FOR UPDATE - "#, - ) - .bind(id) - .bind(owner_id) - .fetch_optional(&mut *tx) - .await?; - - let Some(existing) = existing else { - tx.rollback().await?; - return Ok(PhaseChangeResult::NotFound); - }; - - // Check version if provided (optimistic locking) - if let Some(expected) = expected_version { - if existing.version != expected { - tx.rollback().await?; - return Ok(PhaseChangeResult::VersionConflict { - expected, - actual: existing.version, - current_phase: existing.phase, - }); - } - } - - // Validate the phase transition is allowed - let valid_phases = existing.valid_phase_ids(); - if !valid_phases.contains(&new_phase.to_string()) { - tx.rollback().await?; - return Ok(PhaseChangeResult::ValidationFailed { - reason: format!( - "Invalid phase '{}' for contract type '{}'", - new_phase, existing.contract_type - ), - missing_requirements: vec![format!( - "Phase must be one of: {}", - valid_phases.join(", ") - )], - }); - } - - let previous_phase = existing.phase.clone(); - - // Update phase with version increment - 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_one(&mut *tx) - .await?; - - // Record event - 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(&mut *tx) - .await?; - - // Commit the transaction - tx.commit().await?; - - Ok(PhaseChangeResult::Success(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>( +/// 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#" - 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 - "#, + 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 ) - .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, - c.status as contract_status, - 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, COALESCE(t.hidden, false) as hidden, 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 -} - -/// Minimal task info for worktree cleanup operations. -#[derive(Debug, Clone, sqlx::FromRow)] -pub struct TaskWorktreeInfo { - pub id: Uuid, - pub daemon_id: Option<Uuid>, - pub overlay_path: Option<String>, - /// If set, this task shares the worktree of the specified supervisor task. - /// Should NOT have its worktree deleted during cleanup. - pub supervisor_worktree_task_id: Option<Uuid>, -} - -/// List tasks in a contract with their daemon/worktree info. -/// Used for cleaning up worktrees when a contract is completed or deleted. -pub async fn list_contract_tasks_with_worktree_info( - pool: &PgPool, - contract_id: Uuid, -) -> Result<Vec<TaskWorktreeInfo>, sqlx::Error> { - sqlx::query_as::<_, TaskWorktreeInfo>( - r#" - SELECT id, daemon_id, overlay_path, supervisor_worktree_task_id - FROM tasks - WHERE contract_id = $1 AND (daemon_id IS NOT NULL OR overlay_path IS NOT NULL) - "#, - ) - .bind(contract_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 + SELECT * FROM task_tree + ORDER BY depth, created_at "#, ) - .bind(contract_id) + .bind(root_task_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 // ============================================================================ @@ -3501,713 +2100,6 @@ pub async fn list_task_checkpoints( } // ============================================================================ -// 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 -} - -/// Update supervisor state with detailed activity tracking. -/// Called at key save points: LLM response, task spawn, question asked, phase change. -pub async fn update_supervisor_detailed_state( - pool: &PgPool, - contract_id: Uuid, - state: &str, - current_activity: Option<&str>, - progress: i32, - error_message: Option<&str>, -) -> Result<SupervisorState, sqlx::Error> { - sqlx::query_as::<_, SupervisorState>( - r#" - UPDATE supervisor_states - SET state = $1, - current_activity = $2, - progress = $3, - error_message = $4, - last_activity = NOW(), - updated_at = NOW() - WHERE contract_id = $5 - RETURNING * - "#, - ) - .bind(state) - .bind(current_activity) - .bind(progress) - .bind(error_message) - .bind(contract_id) - .fetch_one(pool) - .await -} - -/// Add a spawned task ID to the supervisor's list. -pub async fn add_supervisor_spawned_task( - pool: &PgPool, - contract_id: Uuid, - task_id: Uuid, -) -> Result<SupervisorState, sqlx::Error> { - sqlx::query_as::<_, SupervisorState>( - r#" - UPDATE supervisor_states - SET spawned_task_ids = array_append(spawned_task_ids, $1), - last_activity = NOW(), - updated_at = NOW() - WHERE contract_id = $2 - RETURNING * - "#, - ) - .bind(task_id) - .bind(contract_id) - .fetch_one(pool) - .await -} - -/// Add a pending question to the supervisor state. -pub async fn add_supervisor_pending_question( - pool: &PgPool, - contract_id: Uuid, - question: serde_json::Value, -) -> Result<SupervisorState, sqlx::Error> { - sqlx::query_as::<_, SupervisorState>( - r#" - UPDATE supervisor_states - SET pending_questions = pending_questions || $1::jsonb, - state = 'waiting_for_user', - last_activity = NOW(), - updated_at = NOW() - WHERE contract_id = $2 - RETURNING * - "#, - ) - .bind(question) - .bind(contract_id) - .fetch_one(pool) - .await -} - -/// Remove a pending question by ID. -pub async fn remove_supervisor_pending_question( - pool: &PgPool, - contract_id: Uuid, - question_id: Uuid, -) -> Result<SupervisorState, sqlx::Error> { - sqlx::query_as::<_, SupervisorState>( - r#" - UPDATE supervisor_states - SET pending_questions = ( - SELECT COALESCE(jsonb_agg(elem), '[]'::jsonb) - FROM jsonb_array_elements(pending_questions) elem - WHERE (elem->>'id')::uuid != $1 - ), - last_activity = NOW(), - updated_at = NOW() - WHERE contract_id = $2 - RETURNING * - "#, - ) - .bind(question_id) - .bind(contract_id) - .fetch_one(pool) - .await -} - -/// Comprehensive state save - used at major save points. -pub async fn save_supervisor_state_full( - pool: &PgPool, - contract_id: Uuid, - task_id: Uuid, - conversation_history: serde_json::Value, - pending_task_ids: &[Uuid], - phase: &str, - state: &str, - current_activity: Option<&str>, - progress: i32, - error_message: Option<&str>, - spawned_task_ids: &[Uuid], - pending_questions: serde_json::Value, -) -> Result<SupervisorState, sqlx::Error> { - sqlx::query_as::<_, SupervisorState>( - r#" - INSERT INTO supervisor_states ( - contract_id, task_id, conversation_history, pending_task_ids, phase, - state, current_activity, progress, error_message, spawned_task_ids, - pending_questions, last_activity - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 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, - state = EXCLUDED.state, - current_activity = EXCLUDED.current_activity, - progress = EXCLUDED.progress, - error_message = EXCLUDED.error_message, - spawned_task_ids = EXCLUDED.spawned_task_ids, - pending_questions = EXCLUDED.pending_questions, - last_activity = NOW(), - updated_at = NOW() - RETURNING * - "#, - ) - .bind(contract_id) - .bind(task_id) - .bind(conversation_history) - .bind(pending_task_ids) - .bind(phase) - .bind(state) - .bind(current_activity) - .bind(progress) - .bind(error_message) - .bind(spawned_task_ids) - .bind(pending_questions) - .fetch_one(pool) - .await -} - -/// Mark supervisor as restored from a crash/interruption. -pub async fn mark_supervisor_restored( - pool: &PgPool, - contract_id: Uuid, - restoration_source: &str, -) -> Result<SupervisorState, sqlx::Error> { - sqlx::query_as::<_, SupervisorState>( - r#" - UPDATE supervisor_states - SET restoration_count = restoration_count + 1, - last_restored_at = NOW(), - restoration_source = $1, - state = 'initializing', - error_message = NULL, - last_activity = NOW(), - updated_at = NOW() - WHERE contract_id = $2 - RETURNING * - "#, - ) - .bind(restoration_source) - .bind(contract_id) - .fetch_one(pool) - .await -} - -/// Get supervisors with pending questions (for re-delivery after restoration). -pub async fn get_supervisors_with_pending_questions( - pool: &PgPool, - owner_id: Uuid, -) -> Result<Vec<SupervisorState>, sqlx::Error> { - sqlx::query_as::<_, SupervisorState>( - r#" - SELECT ss.* - FROM supervisor_states ss - JOIN contracts c ON c.id = ss.contract_id - WHERE c.owner_id = $1 - AND ss.pending_questions != '[]'::jsonb - AND c.status = 'active' - ORDER BY ss.last_activity DESC - "#, - ) - .bind(owner_id) - .fetch_all(pool) - .await -} - -/// Get supervisor state with full details for restoration. -/// Includes validation info. -pub async fn get_supervisor_state_for_restoration( - pool: &PgPool, - contract_id: Uuid, -) -> Result<Option<SupervisorState>, sqlx::Error> { - sqlx::query_as::<_, SupervisorState>( - r#" - SELECT * FROM supervisor_states WHERE contract_id = $1 - "#, - ) - .bind(contract_id) - .fetch_optional(pool) - .await -} - -/// Validate spawned tasks are in expected states. -/// Returns map of task_id -> (status, updated_at). -pub async fn validate_spawned_tasks( - pool: &PgPool, - task_ids: &[Uuid], -) -> Result<std::collections::HashMap<Uuid, (String, chrono::DateTime<Utc>)>, sqlx::Error> { - use sqlx::Row; - - let rows = sqlx::query( - r#" - SELECT id, status, updated_at - FROM tasks - WHERE id = ANY($1) - "#, - ) - .bind(task_ids) - .fetch_all(pool) - .await?; - - let mut result = std::collections::HashMap::new(); - for row in rows { - let id: Uuid = row.get("id"); - let status: String = row.get("status"); - let updated_at: chrono::DateTime<Utc> = row.get("updated_at"); - result.insert(id, (status, updated_at)); - } - Ok(result) -} - -/// Update supervisor state when phase changes. -pub async fn update_supervisor_phase( - pool: &PgPool, - contract_id: Uuid, - new_phase: &str, -) -> Result<SupervisorState, sqlx::Error> { - sqlx::query_as::<_, SupervisorState>( - r#" - UPDATE supervisor_states - SET phase = $1, - state = 'working', - current_activity = 'Phase changed to ' || $1, - last_activity = NOW(), - updated_at = NOW() - WHERE contract_id = $2 - RETURNING * - "#, - ) - .bind(new_phase) - .bind(contract_id) - .fetch_one(pool) - .await -} - -/// Update supervisor state on heartbeat (lightweight update). -pub async fn update_supervisor_heartbeat_state( - pool: &PgPool, - contract_id: Uuid, - state: &str, - current_activity: Option<&str>, - progress: i32, - pending_task_ids: &[Uuid], -) -> Result<(), sqlx::Error> { - sqlx::query( - r#" - UPDATE supervisor_states - SET state = $1, - current_activity = $2, - progress = $3, - pending_task_ids = $4, - last_activity = NOW(), - updated_at = NOW() - WHERE contract_id = $5 - "#, - ) - .bind(state) - .bind(current_activity) - .bind(progress) - .bind(pending_task_ids) - .bind(contract_id) - .execute(pool) - .await?; - Ok(()) -} - -// ============================================================================ -// Supervisor Heartbeats -// ============================================================================ - -/// Record a supervisor heartbeat. -/// This creates a historical record for monitoring and dead supervisor detection. -pub async fn create_supervisor_heartbeat( - pool: &PgPool, - supervisor_task_id: Uuid, - contract_id: Uuid, - state: &str, - phase: &str, - current_activity: Option<&str>, - progress: i32, - pending_task_ids: &[Uuid], -) -> Result<SupervisorHeartbeatRecord, sqlx::Error> { - sqlx::query_as::<_, SupervisorHeartbeatRecord>( - r#" - INSERT INTO supervisor_heartbeats ( - supervisor_task_id, contract_id, state, phase, current_activity, progress, pending_task_ids, timestamp - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) - RETURNING * - "#, - ) - .bind(supervisor_task_id) - .bind(contract_id) - .bind(state) - .bind(phase) - .bind(current_activity) - .bind(progress) - .bind(pending_task_ids) - .fetch_one(pool) - .await -} - -/// Get the latest heartbeat for a supervisor task. -pub async fn get_latest_supervisor_heartbeat( - pool: &PgPool, - supervisor_task_id: Uuid, -) -> Result<Option<SupervisorHeartbeatRecord>, sqlx::Error> { - sqlx::query_as::<_, SupervisorHeartbeatRecord>( - r#" - SELECT * FROM supervisor_heartbeats - WHERE supervisor_task_id = $1 - ORDER BY timestamp DESC - LIMIT 1 - "#, - ) - .bind(supervisor_task_id) - .fetch_optional(pool) - .await -} - -/// Get recent heartbeats for a supervisor task. -pub async fn get_supervisor_heartbeats( - pool: &PgPool, - supervisor_task_id: Uuid, - limit: i64, -) -> Result<Vec<SupervisorHeartbeatRecord>, sqlx::Error> { - sqlx::query_as::<_, SupervisorHeartbeatRecord>( - r#" - SELECT * FROM supervisor_heartbeats - WHERE supervisor_task_id = $1 - ORDER BY timestamp DESC - LIMIT $2 - "#, - ) - .bind(supervisor_task_id) - .bind(limit) - .fetch_all(pool) - .await -} - -/// Get recent heartbeats for a contract. -pub async fn get_contract_supervisor_heartbeats( - pool: &PgPool, - contract_id: Uuid, - limit: i64, -) -> Result<Vec<SupervisorHeartbeatRecord>, sqlx::Error> { - sqlx::query_as::<_, SupervisorHeartbeatRecord>( - r#" - SELECT * FROM supervisor_heartbeats - WHERE contract_id = $1 - ORDER BY timestamp DESC - LIMIT $2 - "#, - ) - .bind(contract_id) - .bind(limit) - .fetch_all(pool) - .await -} - -/// Delete old heartbeats beyond the TTL (24 hours by default). -/// Returns the number of deleted records. -pub async fn cleanup_old_heartbeats( - pool: &PgPool, - ttl_hours: i64, -) -> Result<u64, sqlx::Error> { - let result = sqlx::query( - r#" - DELETE FROM supervisor_heartbeats - WHERE timestamp < NOW() - ($1 || ' hours')::INTERVAL - "#, - ) - .bind(ttl_hours.to_string()) - .execute(pool) - .await?; - - Ok(result.rows_affected()) -} - -/// Find supervisors that have not sent a heartbeat within the timeout period. -/// Returns list of (supervisor_task_id, contract_id, last_heartbeat_timestamp). -pub async fn find_stale_supervisors( - pool: &PgPool, - timeout_seconds: i64, -) -> Result<Vec<(Uuid, Uuid, chrono::DateTime<Utc>)>, sqlx::Error> { - let rows = sqlx::query( - r#" - WITH latest_heartbeats AS ( - SELECT DISTINCT ON (supervisor_task_id) - supervisor_task_id, - contract_id, - timestamp - FROM supervisor_heartbeats - ORDER BY supervisor_task_id, timestamp DESC - ) - SELECT - lh.supervisor_task_id, - lh.contract_id, - lh.timestamp - FROM latest_heartbeats lh - JOIN tasks t ON t.id = lh.supervisor_task_id - WHERE t.status = 'running' - AND lh.timestamp < NOW() - ($1 || ' seconds')::INTERVAL - "#, - ) - .bind(timeout_seconds.to_string()) - .fetch_all(pool) - .await?; - - let mut result = Vec::new(); - for row in rows { - use sqlx::Row; - let supervisor_task_id: Uuid = row.get("supervisor_task_id"); - let contract_id: Uuid = row.get("contract_id"); - let timestamp: chrono::DateTime<Utc> = row.get("timestamp"); - result.push((supervisor_task_id, contract_id, timestamp)); - } - Ok(result) -} - -// ============================================================================ -// 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 -} - -/// Mark a deliverable as complete for a specific phase. -/// Uses JSONB operations to append the deliverable_id to the phase's array. -pub async fn mark_deliverable_complete( - pool: &PgPool, - contract_id: Uuid, - phase: &str, - deliverable_id: &str, -) -> Result<Contract, sqlx::Error> { - // Use jsonb_set to add the deliverable to the phase's array - // If the phase key doesn't exist, create an empty array first - // COALESCE handles the case where the phase array doesn't exist yet - sqlx::query_as::<_, Contract>( - r#" - UPDATE contracts - SET completed_deliverables = jsonb_set( - completed_deliverables, - ARRAY[$2::text], - COALESCE(completed_deliverables->$2, '[]'::jsonb) || to_jsonb($3::text), - true - ), - updated_at = NOW() - WHERE id = $1 - AND NOT (COALESCE(completed_deliverables->$2, '[]'::jsonb) ? $3) - RETURNING * - "#, - ) - .bind(contract_id) - .bind(phase) - .bind(deliverable_id) - .fetch_one(pool) - .await -} - -/// Clear all completed deliverables for a specific phase. -/// Used when phase changes or deliverables need to be reset. -pub async fn clear_phase_deliverables( - pool: &PgPool, - contract_id: Uuid, - phase: &str, -) -> Result<Contract, sqlx::Error> { - sqlx::query_as::<_, Contract>( - r#" - UPDATE contracts - SET completed_deliverables = completed_deliverables - $2, - updated_at = NOW() - WHERE id = $1 - RETURNING * - "#, - ) - .bind(contract_id) - .bind(phase) - .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 // ============================================================================ @@ -4578,107 +2470,27 @@ pub async fn cleanup_old_snapshots( pub async fn record_history_event( pool: &PgPool, owner_id: Uuid, - contract_id: Option<Uuid>, task_id: Option<Uuid>, event_type: &str, event_subtype: Option<&str>, - phase: Option<&str>, event_data: serde_json::Value, ) -> Result<HistoryEvent, sqlx::Error> { sqlx::query_as::<_, HistoryEvent>( r#" - INSERT INTO history_events (owner_id, contract_id, task_id, event_type, event_subtype, phase, event_data) - VALUES ($1, $2, $3, $4, $5, $6, $7) + INSERT INTO history_events (owner_id, task_id, event_type, event_subtype, event_data) + VALUES ($1, $2, $3, $4, $5) RETURNING * "# ) .bind(owner_id) - .bind(contract_id) .bind(task_id) .bind(event_type) .bind(event_subtype) - .bind(phase) .bind(event_data) .fetch_one(pool) .await } -/// Get contract history timeline -pub async fn get_contract_history( - pool: &PgPool, - contract_id: Uuid, - owner_id: Uuid, - filters: &HistoryQueryFilters, -) -> Result<(Vec<HistoryEvent>, i64), sqlx::Error> { - let limit = filters.limit.unwrap_or(100); - - let mut query = String::from( - "SELECT * FROM history_events WHERE contract_id = $1 AND owner_id = $2" - ); - let mut count_query = String::from( - "SELECT COUNT(*) FROM history_events WHERE contract_id = $1 AND owner_id = $2" - ); - - let mut param_count = 2; - - if filters.phase.is_some() { - param_count += 1; - query.push_str(&format!(" AND phase = ${}" , param_count)); - count_query.push_str(&format!(" AND phase = ${}", param_count)); - } - - if filters.from.is_some() { - param_count += 1; - query.push_str(&format!(" AND created_at >= ${}", param_count)); - count_query.push_str(&format!(" AND created_at >= ${}", param_count)); - } - - if filters.to.is_some() { - param_count += 1; - query.push_str(&format!(" AND created_at <= ${}", param_count)); - count_query.push_str(&format!(" AND created_at <= ${}", param_count)); - } - - query.push_str(" ORDER BY created_at DESC"); - query.push_str(&format!(" LIMIT {}", limit)); - - // Build and execute the query dynamically - let mut q = sqlx::query_as::<_, HistoryEvent>(&query) - .bind(contract_id) - .bind(owner_id); - - if let Some(ref phase) = filters.phase { - q = q.bind(phase); - } - if let Some(ref from) = filters.from { - q = q.bind(from); - } - if let Some(ref to) = filters.to { - q = q.bind(to); - } - - let events = q.fetch_all(pool).await?; - - // Get total count - let mut cq = sqlx::query_scalar::<_, i64>(&count_query) - .bind(contract_id) - .bind(owner_id); - - if let Some(ref phase) = filters.phase { - cq = cq.bind(phase); - } - if let Some(ref from) = filters.from { - cq = cq.bind(from); - } - if let Some(ref to) = filters.to { - cq = cq.bind(to); - } - - let count = cq.fetch_one(pool).await?; - - Ok((events, count)) -} - /// Get task history pub async fn get_task_history( pool: &PgPool, @@ -4825,13 +2637,6 @@ pub async fn get_task_conversation( Ok(messages) } -/// Get supervisor conversation (from supervisor_states) -pub async fn get_supervisor_conversation_full( - pool: &PgPool, - contract_id: Uuid, -) -> Result<Option<SupervisorState>, sqlx::Error> { - get_supervisor_state(pool, contract_id).await -} // ============================================================================= // Anonymous Task Cleanup Functions @@ -4969,156 +2774,6 @@ pub async fn delete_checkpoint_patches_for_task( Ok(result.rows_affected() as i64) } -// ============================================================================= -// Red Team Notifications -// ============================================================================= -// ============================================================================= -// Supervisor Status API Helpers -// ============================================================================= - -/// Get supervisor status for a contract. -/// Returns combined information from supervisor_states and tasks tables. -pub async fn get_supervisor_status( - pool: &PgPool, - contract_id: Uuid, - owner_id: Uuid, -) -> Result<Option<SupervisorStatusInfo>, sqlx::Error> { - // Query to get supervisor status by joining supervisor_states with tasks - sqlx::query_as::<_, SupervisorStatusInfo>( - r#" - SELECT - ss.task_id, - COALESCE(t.status, 'unknown') as supervisor_state, - ss.phase, - t.progress_summary as current_activity, - ss.pending_task_ids, - ss.last_activity as last_heartbeat, - t.status as task_status, - t.daemon_id IS NOT NULL as is_running - FROM supervisor_states ss - JOIN tasks t ON t.id = ss.task_id - WHERE ss.contract_id = $1 - AND t.owner_id = $2 - "#, - ) - .bind(contract_id) - .bind(owner_id) - .fetch_optional(pool) - .await -} - -/// Internal struct to hold supervisor status query result -#[derive(Debug, Clone, sqlx::FromRow)] -pub struct SupervisorStatusInfo { - pub task_id: Uuid, - pub supervisor_state: String, - pub phase: String, - pub current_activity: Option<String>, - #[sqlx(try_from = "Vec<Uuid>")] - pub pending_task_ids: Vec<Uuid>, - pub last_heartbeat: chrono::DateTime<chrono::Utc>, - pub task_status: String, - pub is_running: bool, -} - -/// Get supervisor activity history from history_events table. -/// This provides a timeline of supervisor activities that can serve as "heartbeats". -pub async fn get_supervisor_activity_history( - pool: &PgPool, - contract_id: Uuid, - limit: i32, - offset: i32, -) -> Result<Vec<SupervisorActivityEntry>, sqlx::Error> { - sqlx::query_as::<_, SupervisorActivityEntry>( - r#" - SELECT - created_at as timestamp, - COALESCE(event_subtype, 'activity') as state, - event_data->>'activity' as activity, - (event_data->>'progress')::INTEGER as progress, - COALESCE(phase, 'unknown') as phase, - CASE - WHEN event_data->'pending_task_ids' IS NOT NULL - THEN ARRAY(SELECT jsonb_array_elements_text(event_data->'pending_task_ids'))::UUID[] - ELSE ARRAY[]::UUID[] - END as pending_task_ids - FROM history_events - WHERE contract_id = $1 - AND event_type IN ('supervisor', 'phase', 'task') - ORDER BY created_at DESC - LIMIT $2 OFFSET $3 - "#, - ) - .bind(contract_id) - .bind(limit) - .bind(offset) - .fetch_all(pool) - .await -} - -/// Internal struct to hold supervisor activity entry -#[derive(Debug, Clone, sqlx::FromRow)] -pub struct SupervisorActivityEntry { - pub timestamp: chrono::DateTime<chrono::Utc>, - pub state: String, - pub activity: Option<String>, - pub progress: Option<i32>, - pub phase: String, - #[sqlx(try_from = "Vec<Uuid>")] - pub pending_task_ids: Vec<Uuid>, -} - -/// Count total supervisor activity history entries for a contract. -pub async fn count_supervisor_activity_history( - pool: &PgPool, - contract_id: Uuid, -) -> Result<i64, sqlx::Error> { - let result: (i64,) = sqlx::query_as( - r#" - SELECT COUNT(*) - FROM history_events - WHERE contract_id = $1 - AND event_type IN ('supervisor', 'phase', 'task') - "#, - ) - .bind(contract_id) - .fetch_one(pool) - .await?; - Ok(result.0) -} - -/// Update supervisor state last_activity timestamp. -/// This acts as a "sync" operation to refresh the supervisor's heartbeat. -pub async fn sync_supervisor_state( - pool: &PgPool, - contract_id: Uuid, -) -> Result<Option<SupervisorState>, sqlx::Error> { - sqlx::query_as::<_, SupervisorState>( - r#" - UPDATE supervisor_states - SET last_activity = NOW(), - updated_at = NOW() - WHERE contract_id = $1 - RETURNING * - "#, - ) - .bind(contract_id) - .fetch_optional(pool) - .await -} - -// ============================================================================= -// Helper Functions -// ============================================================================= - -/// Helper to truncate string to max length -fn truncate_string(s: &str, max_len: usize) -> String { - if s.len() <= max_len { - s.to_string() - } else { - format!("{}...", &s[..max_len - 3]) - } -} // ============================================================================= // Directive CRUD @@ -7031,37 +4686,6 @@ pub async fn get_running_steps_with_tasks( .await } -/// A running step backed by a contract, joined with the contract's current status. -#[derive(Debug, Clone, sqlx::FromRow)] -pub struct RunningStepWithContract { - pub step_id: Uuid, - pub directive_id: Uuid, - pub contract_id: Uuid, - pub contract_status: String, - pub contract_phase: String, -} - -/// Get running steps that are backed by contracts (for contract-based monitoring). -pub async fn get_running_steps_with_contracts( - pool: &PgPool, -) -> Result<Vec<RunningStepWithContract>, sqlx::Error> { - sqlx::query_as::<_, RunningStepWithContract>( - r#" - SELECT - ds.id AS step_id, - ds.directive_id, - ds.contract_id AS "contract_id!", - c.status AS contract_status, - c.phase AS contract_phase - FROM directive_steps ds - JOIN contracts c ON c.id = ds.contract_id - WHERE ds.status = 'running' - AND ds.contract_id IS NOT NULL - "#, - ) - .fetch_all(pool) - .await -} /// An orchestrator task to check (directive with pending planning task). #[derive(Debug, Clone, sqlx::FromRow)] @@ -7221,25 +4845,6 @@ pub async fn link_task_to_step( Ok(()) } -/// Link a contract to a directive step. -pub async fn link_contract_to_step( - pool: &PgPool, - step_id: Uuid, - contract_id: Uuid, -) -> Result<(), sqlx::Error> { - sqlx::query( - r#" - UPDATE directive_steps - SET contract_id = $1 - WHERE id = $2 - "#, - ) - .bind(contract_id) - .bind(step_id) - .execute(pool) - .await?; - Ok(()) -} /// Set a step to 'running' status (after its task has been dispatched). pub async fn set_step_running( diff --git a/makima/src/orchestration/directive.rs b/makima/src/orchestration/directive.rs index 7f90bcd..3d00a8f 100644 --- a/makima/src/orchestration/directive.rs +++ b/makima/src/orchestration/directive.rs @@ -11,7 +11,7 @@ use uuid::Uuid; use base64::Engine; -use crate::db::models::{CreateContractRequest, CreateTaskRequest, UpdateContractRequest, UpdateTaskRequest}; +use crate::db::models::{CreateTaskRequest, UpdateTaskRequest}; use crate::db::repository; use crate::server::state::{DaemonCommand, SharedState}; @@ -276,70 +276,6 @@ impl DirectiveOrchestrator { /// Phase 3: Monitor running steps and orchestrator tasks. async fn phase_monitoring(&self) -> Result<(), anyhow::Error> { - // Check contract-backed running steps first - let contract_steps = repository::get_running_steps_with_contracts(&self.pool).await?; - - for step in contract_steps { - if let Err(e) = async { - match step.contract_status.as_str() { - "completed" | "archived" => { - tracing::info!( - step_id = %step.step_id, - directive_id = %step.directive_id, - contract_id = %step.contract_id, - contract_status = %step.contract_status, - "Contract-backed step contract completed — updating step to completed" - ); - let update = crate::db::models::UpdateDirectiveStepRequest { - status: Some("completed".to_string()), - ..Default::default() - }; - repository::update_directive_step(&self.pool, step.step_id, update).await?; - - // Mark linked orders as done - if let Ok(linked_orders) = repository::get_orders_by_step_id(&self.pool, step.step_id).await { - for order in linked_orders { - if order.status != "done" && order.status != "archived" { - let order_update = crate::db::models::UpdateOrderRequest { - status: Some("done".to_string()), - ..Default::default() - }; - let _ = repository::update_order(&self.pool, order.owner_id, order.id, order_update).await; - } - } - } - - repository::advance_directive_ready_steps(&self.pool, step.directive_id) - .await?; - repository::check_directive_idle(&self.pool, step.directive_id).await?; - } - "active" => { - // Contract still active — check if the supervisor has failed - // by looking at whether there are any failed tasks with no active tasks remaining - tracing::debug!( - step_id = %step.step_id, - contract_id = %step.contract_id, - contract_phase = %step.contract_phase, - "Contract-backed step still active — monitoring" - ); - } - _ => { - // Unknown status — log and skip - tracing::debug!( - step_id = %step.step_id, - contract_id = %step.contract_id, - contract_status = %step.contract_status, - "Contract-backed step in unexpected status" - ); - } - } - Ok::<(), anyhow::Error>(()) - }.await { - tracing::warn!(step_id = %step.step_id, error = %e, "Error processing contract-backed step — continuing"); - } - } - - // Check task-backed running steps (excludes contract-backed steps) let running = repository::get_running_steps_with_tasks(&self.pool).await?; for step in running { @@ -549,12 +485,10 @@ impl DirectiveOrchestrator { base_branch: Option<&str>, ) -> Result<(), anyhow::Error> { let req = CreateTaskRequest { - contract_id: None, name, description: Some("Directive planning task".to_string()), plan, parent_task_id: None, - is_supervisor: false, priority: 0, repository_url: repo_url.map(|s| s.to_string()), base_branch: base_branch.map(|s| s.to_string()), @@ -567,7 +501,6 @@ impl DirectiveOrchestrator { checkpoint_sha: None, branched_from_task_id: None, conversation_history: None, - supervisor_worktree_task_id: None, directive_id: Some(directive_id), directive_step_id: None, }; @@ -607,12 +540,10 @@ impl DirectiveOrchestrator { continue_from_task_id: Option<Uuid>, ) -> Result<(), anyhow::Error> { let req = CreateTaskRequest { - contract_id: None, name, description: Some("Directive step execution task".to_string()), plan, parent_task_id: None, - is_supervisor: false, priority: 0, repository_url: repo_url.map(|s| s.to_string()), base_branch: base_branch.map(|s| s.to_string()), @@ -625,7 +556,6 @@ impl DirectiveOrchestrator { checkpoint_sha: None, branched_from_task_id: None, conversation_history: None, - supervisor_worktree_task_id: None, directive_id: Some(directive_id), directive_step_id: Some(step_id), }; @@ -704,8 +634,6 @@ impl DirectiveOrchestrator { completion_action: updated_task.completion_action.clone(), continue_from_task_id: updated_task.continue_from_task_id, copy_files: None, - contract_id: None, - is_supervisor: false, autonomous_loop: false, resume_session: false, conversation_history: None, @@ -713,7 +641,6 @@ impl DirectiveOrchestrator { patch_base_sha, local_only: false, auto_merge_local: false, - supervisor_worktree_task_id: None, directive_id: updated_task.directive_id, }; @@ -1107,12 +1034,10 @@ impl DirectiveOrchestrator { base_branch: Option<&str>, ) -> Result<Uuid, anyhow::Error> { let req = CreateTaskRequest { - contract_id: None, name, description: Some("Directive PR completion task".to_string()), plan, parent_task_id: None, - is_supervisor: false, priority: 0, repository_url: repo_url.map(|s| s.to_string()), base_branch: base_branch.map(|s| s.to_string()), @@ -1125,7 +1050,6 @@ impl DirectiveOrchestrator { checkpoint_sha: None, branched_from_task_id: None, conversation_history: None, - supervisor_worktree_task_id: None, directive_id: Some(directive_id), directive_step_id: None, }; @@ -1374,12 +1298,10 @@ pub async fn trigger_completion_task( // Create the completion task FIRST so we have a real task ID for the FK let req = CreateTaskRequest { - contract_id: None, name: task_name, description: Some("Directive PR completion task".to_string()), plan: prompt, parent_task_id: None, - is_supervisor: false, priority: 0, repository_url: directive.repository_url.clone(), base_branch: directive.base_branch.clone(), @@ -1392,7 +1314,6 @@ pub async fn trigger_completion_task( checkpoint_sha: None, branched_from_task_id: None, conversation_history: None, - supervisor_worktree_task_id: None, directive_id: Some(directive_id), directive_step_id: None, }; @@ -1454,8 +1375,6 @@ pub async fn trigger_completion_task( completion_action: updated_task.completion_action.clone(), continue_from_task_id: updated_task.continue_from_task_id, copy_files: None, - contract_id: None, - is_supervisor: false, autonomous_loop: false, resume_session: false, conversation_history: None, @@ -1463,7 +1382,6 @@ pub async fn trigger_completion_task( patch_base_sha, local_only: false, auto_merge_local: false, - supervisor_worktree_task_id: None, directive_id: updated_task.directive_id, }; diff --git a/makima/src/server/handlers/directives.rs b/makima/src/server/handlers/directives.rs index 35a46a0..410b2b3 100644 --- a/makima/src/server/handlers/directives.rs +++ b/makima/src/server/handlers/directives.rs @@ -1019,12 +1019,10 @@ pub async fn cleanup_directive( // Create the cleanup task (following pick_up_orders pattern) let req = CreateTaskRequest { - contract_id: None, name: format!("Cleanup: {}", directive.title), description: Some("Directive cleanup — verify merged branches and remove merged steps".to_string()), plan: prompt, parent_task_id: None, - is_supervisor: false, priority: 0, repository_url: directive.repository_url.clone(), base_branch: directive.base_branch.clone(), @@ -1037,7 +1035,6 @@ pub async fn cleanup_directive( checkpoint_sha: None, branched_from_task_id: None, conversation_history: None, - supervisor_worktree_task_id: None, directive_id: Some(directive.id), directive_step_id: None, }; @@ -1330,12 +1327,10 @@ pub async fn pick_up_orders( // Create the planning task let req = CreateTaskRequest { - contract_id: None, name: format!("Pick up orders: {}", directive.title), description: Some("Directive order pickup planning task".to_string()), plan, parent_task_id: None, - is_supervisor: false, priority: 0, repository_url: directive.repository_url.clone(), base_branch: directive.base_branch.clone(), @@ -1348,7 +1343,6 @@ pub async fn pick_up_orders( checkpoint_sha: None, branched_from_task_id: None, conversation_history: None, - supervisor_worktree_task_id: None, directive_id: Some(directive.id), directive_step_id: None, }; @@ -1907,12 +1901,10 @@ pub async fn pick_up_dog_orders( // Create the planning task let req = CreateTaskRequest { - contract_id: None, name: format!("Pick up DOG orders: {}", directive.title), description: Some("Directive order group pickup planning task".to_string()), plan, parent_task_id: None, - is_supervisor: false, priority: 0, repository_url: directive.repository_url.clone(), base_branch: directive.base_branch.clone(), @@ -1925,7 +1917,6 @@ pub async fn pick_up_dog_orders( checkpoint_sha: None, branched_from_task_id: None, conversation_history: None, - supervisor_worktree_task_id: None, directive_id: Some(directive.id), directive_step_id: None, }; @@ -2209,12 +2200,10 @@ pub async fn create_directive_task( let base_branch = req.base_branch.or_else(|| directive.base_branch.clone()); let create_req = CreateTaskRequest { - contract_id: None, name: req.name, description: None, plan: req.plan, parent_task_id: None, - is_supervisor: false, priority: 0, repository_url: repo_url, base_branch, @@ -2227,7 +2216,6 @@ pub async fn create_directive_task( checkpoint_sha: None, branched_from_task_id: None, conversation_history: None, - supervisor_worktree_task_id: None, directive_id: Some(directive.id), // No directive_step_id — this is what makes the task "ephemeral": // it lives under the directive folder but isn't part of the DAG. diff --git a/makima/src/server/handlers/files.rs b/makima/src/server/handlers/files.rs index 711be41..023b9ff 100644 --- a/makima/src/server/handlers/files.rs +++ b/makima/src/server/handlers/files.rs @@ -145,26 +145,7 @@ 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 - } - + // Legacy contract scope removed; files are owner-scoped only now. match repository::create_file_for_owner(pool, auth.owner_id, req).await { Ok(file) => (StatusCode::CREATED, Json(file)).into_response(), Err(e) => { @@ -336,189 +317,3 @@ 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/history.rs b/makima/src/server/handlers/history.rs index bee6b02..46be7ac 100644 --- a/makima/src/server/handlers/history.rs +++ b/makima/src/server/handlers/history.rs @@ -10,10 +10,7 @@ use uuid::Uuid; use crate::{ db::{ - models::{ - flexible_datetime, ContractHistoryResponse, ConversationMessage, HistoryQueryFilters, - SupervisorConversationResponse, TaskConversationResponse, TaskReference, - }, + models::{flexible_datetime, ConversationMessage, HistoryQueryFilters, TaskConversationResponse}, repository, }, server::{auth::Authenticated, messages::ApiError, state::SharedState}, @@ -32,7 +29,6 @@ pub struct TaskConversationParams { #[derive(Debug, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct TimelineQueryFilters { - pub contract_id: Option<Uuid>, pub task_id: Option<Uuid>, pub include_subtasks: Option<bool>, #[serde(default, deserialize_with = "flexible_datetime::deserialize")] @@ -42,231 +38,6 @@ pub struct TimelineQueryFilters { pub limit: Option<i32>, } -/// GET /api/v1/contracts/{id}/history -/// Returns contract history timeline with filtering and pagination -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}/history", - params( - ("id" = Uuid, Path, description = "Contract ID"), - ("phase" = Option<String>, Query, description = "Filter by phase"), - ("event_types" = Option<String>, Query, description = "Filter by event types (comma-separated)"), - ("from" = Option<String>, Query, description = "Start date filter"), - ("to" = Option<String>, Query, description = "End date filter"), - ("limit" = Option<i32>, Query, description = "Limit results"), - ), - responses( - (status = 200, description = "Contract history", body = ContractHistoryResponse), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 403, description = "Forbidden", 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 = "History" -)] -pub async fn get_contract_history( - State(state): State<SharedState>, - Path(contract_id): Path<Uuid>, - Query(filters): Query<HistoryQueryFilters>, - 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(); - }; - - // Verify contract exists and user has access - let contract = match repository::get_contract_for_owner(pool, contract_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 {}: {}", contract_id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Get history events - match repository::get_contract_history(pool, contract.id, auth.owner_id, &filters).await { - Ok((events, total_count)) => { - Json(ContractHistoryResponse { - contract_id, - entries: events, - total_count, - cursor: None, // TODO: implement cursor pagination - }) - .into_response() - } - Err(e) => { - tracing::error!("Failed to get contract history: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// GET /api/v1/contracts/{id}/supervisor/conversation -/// Returns full supervisor conversation with spawned task references -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}/supervisor/conversation", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 200, description = "Supervisor conversation", body = SupervisorConversationResponse), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 403, description = "Forbidden", body = ApiError), - (status = 404, description = "Supervisor 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 = "History" -)] -pub async fn get_supervisor_conversation( - State(state): State<SharedState>, - Path(contract_id): Path<Uuid>, - 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(); - }; - - // Get contract for phase info and ownership check - let contract = match repository::get_contract_for_owner(pool, contract_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 {}: {}", contract_id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Get the supervisor state - let supervisor_state = match repository::get_supervisor_state(pool, contract_id).await { - Ok(Some(s)) => s, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Supervisor not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get supervisor state for {}: {}", contract_id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Parse conversation history from JSONB - let messages: Vec<ConversationMessage> = supervisor_state - .conversation_history - .as_array() - .map(|arr| { - arr.iter() - .enumerate() - .map(|(i, v)| ConversationMessage { - id: i.to_string(), - role: v - .get("role") - .and_then(|r| r.as_str()) - .unwrap_or("user") - .to_string(), - content: v - .get("content") - .and_then(|c| c.as_str()) - .unwrap_or("") - .to_string(), - timestamp: supervisor_state.last_activity, - tool_calls: None, - tool_name: None, - tool_input: None, - tool_result: None, - is_error: None, - token_count: None, - cost_usd: None, - }) - .collect() - }) - .unwrap_or_default(); - - // Get spawned tasks - let tasks = match repository::list_tasks_by_contract(pool, contract_id, auth.owner_id).await { - Ok(t) => t, - Err(e) => { - tracing::warn!("Failed to get tasks for contract {}: {}", contract_id, e); - Vec::new() - } - }; - - let spawned_tasks: Vec<TaskReference> = tasks - .into_iter() - .filter(|t| !t.is_supervisor) - .map(|t| TaskReference { - task_id: t.id, - task_name: t.name, - status: t.status, - created_at: t.created_at, - completed_at: t.completed_at, - }) - .collect(); - - Json(SupervisorConversationResponse { - contract_id, - supervisor_task_id: supervisor_state.task_id, - phase: contract.phase, - last_activity: supervisor_state.last_activity, - pending_task_ids: supervisor_state.pending_task_ids, - messages, - spawned_tasks, - }) - .into_response() -} - -/// GET /api/v1/mesh/tasks/{id}/conversation -/// Returns task conversation history #[utoipa::path( get, path = "/api/v1/mesh/tasks/{id}/conversation", @@ -364,28 +135,16 @@ pub async fn get_task_conversation( } /// GET /api/v1/timeline -/// Returns unified timeline for authenticated user +/// Returns unified task-history timeline for the authenticated user. #[utoipa::path( get, path = "/api/v1/timeline", - params( - ("contract_id" = Option<Uuid>, Query, description = "Filter by contract"), - ("task_id" = Option<Uuid>, Query, description = "Filter by task"), - ("include_subtasks" = Option<bool>, Query, description = "Include subtask events"), - ("from" = Option<String>, Query, description = "Start date filter"), - ("to" = Option<String>, Query, description = "End date filter"), - ("limit" = Option<i32>, Query, description = "Limit results"), - ), responses( - (status = 200, description = "Timeline events", body = ContractHistoryResponse), + (status = 200, description = "Timeline events"), (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" = []) ), + security(("bearer_auth" = []), ("api_key" = [])), tag = "History" )] pub async fn get_timeline( @@ -402,7 +161,6 @@ pub async fn get_timeline( }; let history_filters = HistoryQueryFilters { - phase: None, event_types: None, from: filters.from, to: filters.to, @@ -410,24 +168,18 @@ pub async fn get_timeline( cursor: None, }; - let result = if let Some(contract_id) = filters.contract_id { - repository::get_contract_history(pool, contract_id, auth.owner_id, &history_filters).await - } else if let Some(task_id) = filters.task_id { + let result = if let Some(task_id) = filters.task_id { repository::get_task_history(pool, task_id, auth.owner_id, &history_filters).await } else { repository::get_timeline(pool, auth.owner_id, &history_filters).await }; match result { - Ok((events, total_count)) => { - Json(ContractHistoryResponse { - contract_id: filters.contract_id.unwrap_or_default(), - entries: events, - total_count, - cursor: None, - }) - .into_response() - } + Ok((events, total_count)) => Json(serde_json::json!({ + "entries": events, + "totalCount": total_count, + })) + .into_response(), Err(e) => { tracing::error!("Failed to get timeline: {}", e); ( diff --git a/makima/src/server/handlers/listen.rs b/makima/src/server/handlers/listen.rs deleted file mode 100644 index e1bc30e..0000000 --- a/makima/src/server/handlers/listen.rs +++ /dev/null @@ -1,783 +0,0 @@ -//! WebSocket handler for streaming speech-to-text with sliding window optimization. - -use axum::{ - extract::{ws::Message, ws::WebSocket, State, WebSocketUpgrade}, - response::Response, -}; -use futures::{SinkExt, StreamExt}; -use tokio::sync::mpsc; -use uuid::Uuid; - -use crate::audio::{resample_and_mixdown, TARGET_CHANNELS, TARGET_SAMPLE_RATE}; -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::{MlModels, SharedState}; - -/// Chunk size in milliseconds for triggering transcription processing. -const STREAM_CHUNK_MS: u32 = 5_000; - -/// Maximum window size in seconds for sliding window processing. -const MAX_WINDOW_SECONDS: f32 = 30.0; - -/// Maximum window size in samples at 16kHz. -const MAX_WINDOW_SAMPLES: usize = (MAX_WINDOW_SECONDS as usize) * (TARGET_SAMPLE_RATE as usize); - -/// EOU chunk size in samples (160ms at 16kHz). -const EOU_CHUNK_SIZE: usize = 2560; - -/// Context overlap in seconds to keep when trimming finalized audio. -const CONTEXT_OVERLAP_SECONDS: f32 = 2.0; - -/// WebSocket upgrade handler for STT streaming. -/// -/// This endpoint accepts WebSocket connections for real-time speech-to-text -/// transcription with speaker diarization. -#[utoipa::path( - get, - path = "/api/v1/listen", - responses( - (status = 101, description = "WebSocket connection established"), - ), - tag = "Listen" -)] -pub async fn websocket_handler( - ws: WebSocketUpgrade, - State(state): State<SharedState>, -) -> Response { - ws.on_upgrade(|socket| handle_socket(socket, state)) -} - -async fn handle_socket(socket: WebSocket, state: SharedState) { - let session_id = Uuid::new_v4().to_string(); - tracing::info!(session_id = %session_id, "New WebSocket connection"); - - // Split socket for concurrent read/write - let (mut sender, mut receiver) = socket.split(); - - // Channel for sending responses back to client - let (response_tx, mut response_rx) = mpsc::channel::<ServerMessage>(32); - - // Spawn task to forward responses to WebSocket - let sender_task = tokio::spawn(async move { - while let Some(msg) = response_rx.recv().await { - let json = match serde_json::to_string(&msg) { - Ok(j) => j, - Err(e) => { - tracing::error!("Failed to serialize message: {}", e); - continue; - } - }; - if sender.send(Message::Text(json.into())).await.is_err() { - break; - } - } - }); - - // 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 { - session_id: session_id.clone(), - }) - .await; - - // Audio format state - let mut audio_format: Option<StartMessage> = None; - - // Main audio buffer for transcription (accumulates resampled 16kHz mono audio) - let mut audio_buffer: Vec<f32> = Vec::new(); - - // EOU detection buffer (resampled audio for utterance detection) - let mut eou_buffer: Vec<f32> = Vec::new(); - let mut last_eou_text: String = String::new(); - let mut utterance_ended: bool = false; - - // Tracking state - let mut last_sent_end_time: f32 = 0.0; - let mut last_processed_len: usize = 0; - let mut audio_offset: f32 = 0.0; // Time offset from trimmed audio - let mut finalized_segments: Vec<DialogueSegment> = Vec::new(); - - // File persistence state - let mut file_id: Option<Uuid> = None; - 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 = ml_models.sortformer.lock().await; - sortformer.reset_state(); - } - - // Process incoming messages - while let Some(msg_result) = receiver.next().await { - let msg = match msg_result { - Ok(m) => m, - Err(e) => { - tracing::error!("WebSocket error: {}", e); - break; - } - }; - - match msg { - Message::Text(text) => { - // Parse JSON control messages - match serde_json::from_str::<ClientMessage>(&text) { - Ok(ClientMessage::Start(start)) => { - tracing::info!( - session_id = %session_id, - 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(); - last_eou_text.clear(); - utterance_ended = false; - last_sent_end_time = 0.0; - 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 = ml_models.sortformer.lock().await; - sortformer.reset_state(); - } - Ok(ClientMessage::Stop(stop)) => { - tracing::info!( - session_id = %session_id, - reason = ?stop.reason, - audio_buffer_len = audio_buffer.len(), - "Session stopped by client" - ); - - if audio_format.is_some() { - if !audio_buffer.is_empty() { - tracing::debug!( - session_id = %session_id, - samples = audio_buffer.len(), - "Processing final audio buffer" - ); - - // Process remaining audio with sliding window - match process_audio_window(&audio_buffer, audio_offset, ml_models).await { - Ok(segments) => { - tracing::debug!( - session_id = %session_id, - total_segments = segments.len(), - finalized_count = finalized_segments.len(), - last_sent_end = last_sent_end_time, - "Final transcription complete" - ); - - // Combine finalized segments with new segments - let mut all_segments = finalized_segments.clone(); - - // Add segments from current window that weren't finalized - for seg in &segments { - // Adjust timestamps with offset - let adjusted_seg = DialogueSegment { - speaker: seg.speaker.clone(), - start: seg.start + audio_offset, - end: seg.end + audio_offset, - text: seg.text.clone(), - }; - - // Only add if not already finalized - if !finalized_segments.iter().any(|f| - (f.start - adjusted_seg.start).abs() < 0.1 && - f.text == adjusted_seg.text - ) { - all_segments.push(adjusted_seg); - } - } - - // Sort by start time - all_segments.sort_by(|a, b| a.start.partial_cmp(&b.start).unwrap()); - - // Send any NEW segments as interim first - for seg in &all_segments { - if seg.end > last_sent_end_time { - let _ = response_tx - .send(ServerMessage::Transcript(TranscriptMessage { - speaker: seg.speaker.clone(), - start: seg.start, - end: seg.end, - text: seg.text.clone(), - is_final: false, - })) - .await; - } - } - - // Send ALL segments as final - for seg in &all_segments { - let _ = response_tx - .send(ServerMessage::Transcript(TranscriptMessage { - speaker: seg.speaker.clone(), - start: seg.start, - end: seg.end, - text: seg.text.clone(), - is_final: true, - })) - .await; - } - } - Err(e) => { - tracing::error!( - session_id = %session_id, - error = %e, - "Final transcription failed" - ); - let _ = response_tx - .send(ServerMessage::Error { - code: "TRANSCRIPTION_ERROR".into(), - message: e.to_string(), - }) - .await; - } - } - } - } - - let _ = response_tx - .send(ServerMessage::Stopped { - reason: stop.reason.unwrap_or_else(|| "client_requested".into()), - }) - .await; - break; - } - Err(e) => { - tracing::warn!(session_id = %session_id, error = %e, "Failed to parse message"); - let _ = response_tx - .send(ServerMessage::Error { - code: "PARSE_ERROR".into(), - message: format!("Failed to parse message: {}", e), - }) - .await; - } - } - } - Message::Binary(data) => { - let Some(ref format) = audio_format else { - let _ = response_tx - .send(ServerMessage::Error { - code: "NO_FORMAT".into(), - message: "Received audio before start message".into(), - }) - .await; - continue; - }; - - // Decode binary audio data to f32 samples - let samples = decode_audio_chunk(&data, format); - - // Resample to 16kHz mono for all processing - let resampled = if format.sample_rate != TARGET_SAMPLE_RATE || format.channels != TARGET_CHANNELS { - resample_and_mixdown(&samples, format.sample_rate, format.channels) - } else { - samples - }; - - audio_buffer.extend(&resampled); - eou_buffer.extend(&resampled); - - // Process EOU detection in 160ms chunks - while eou_buffer.len() >= EOU_CHUNK_SIZE { - let chunk: Vec<f32> = eou_buffer.drain(..EOU_CHUNK_SIZE).collect(); - - 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 { - if last_eou_text.ends_with('.') - || last_eou_text.ends_with('?') - || last_eou_text.ends_with('!') - { - utterance_ended = true; - tracing::debug!( - session_id = %session_id, - "Utterance boundary detected via EOU" - ); - } - last_eou_text = text; - } - } - } - - // Calculate if we should process (utterance ended OR enough new audio) - let chunk_samples = samples_per_chunk(TARGET_SAMPLE_RATE, STREAM_CHUNK_MS); - let new_audio_len = audio_buffer.len() - last_processed_len; - let should_process = utterance_ended || new_audio_len >= chunk_samples; - - if should_process { - tracing::debug!( - session_id = %session_id, - total_samples = audio_buffer.len(), - new_samples = new_audio_len, - utterance_ended = utterance_ended, - audio_offset = audio_offset, - "Processing audio with sliding window" - ); - - match process_audio_window(&audio_buffer, audio_offset, ml_models).await { - Ok(segments) => { - tracing::debug!( - session_id = %session_id, - total_segments = segments.len(), - last_sent_end = last_sent_end_time, - "Transcription produced segments" - ); - - // Send segments with adjusted timestamps - for seg in &segments { - 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 authenticated with contract - if file_id.is_none() { - 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: Some("Live transcription".to_string()), - transcript: vec![], - location: None, - 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, - contract_id = %contract_id, - "Created file for session in contract" - ); - } - Err(e) => { - tracing::warn!( - session_id = %session_id, - error = %e, - "Failed to create file for session" - ); - } - } - } - } - - // Track transcript entry - transcript_counter += 1; - transcript_entries.push(TranscriptEntry { - id: format!("{}-{}", session_id, transcript_counter), - speaker: seg.speaker.clone(), - start: adjusted_start, - end: adjusted_end, - text: seg.text.clone(), - is_final: false, - }); - - let _ = response_tx - .send(ServerMessage::Transcript(TranscriptMessage { - speaker: seg.speaker.clone(), - start: adjusted_start, - end: adjusted_end, - text: seg.text.clone(), - is_final: false, - })) - .await; - last_sent_end_time = adjusted_end; - } - } - - // If utterance ended, finalize and trim - if utterance_ended && segments.len() > 1 { - // Finalize all but the last segment - let to_finalize = &segments[..segments.len() - 1]; - for seg in to_finalize { - finalized_segments.push(DialogueSegment { - speaker: seg.speaker.clone(), - start: seg.start + audio_offset, - end: seg.end + audio_offset, - text: seg.text.clone(), - }); - } - - // Trim audio buffer - if let Some(last_finalized) = to_finalize.last() { - let trim_to_time = (last_finalized.end - CONTEXT_OVERLAP_SECONDS).max(0.0); - let trim_samples = (trim_to_time * TARGET_SAMPLE_RATE as f32) as usize; - - if trim_samples > 0 && trim_samples < audio_buffer.len() { - audio_buffer.drain(..trim_samples); - audio_offset += trim_to_time; - tracing::debug!( - session_id = %session_id, - trimmed_samples = trim_samples, - new_offset = audio_offset, - remaining_samples = audio_buffer.len(), - "Trimmed audio buffer after finalization" - ); - } - } - } - - last_processed_len = audio_buffer.len(); - utterance_ended = false; - } - Err(e) => { - tracing::error!(session_id = %session_id, error = %e, "Transcription error"); - let _ = response_tx - .send(ServerMessage::Error { - code: "TRANSCRIPTION_ERROR".into(), - message: e.to_string(), - }) - .await; - } - } - } - } - Message::Close(_) => { - tracing::info!(session_id = %session_id, "WebSocket closed by client"); - break; - } - _ => {} - } - } - - // Save final transcript to file if we have one - if let Some(fid) = file_id { - if let Some(ref pool) = state.db_pool { - // Deduplicate transcript entries before saving - let deduplicated = deduplicate_transcripts(&transcript_entries); - - // Mark all entries as final - let final_entries: Vec<TranscriptEntry> = deduplicated - .into_iter() - .map(|mut entry| { - entry.is_final = true; - entry - }) - .collect(); - - match repository::update_file(pool, fid, UpdateFileRequest { - name: None, - description: None, - transcript: Some(final_entries.clone()), - summary: None, - body: None, - version: None, // Internal update, skip version check - repo_file_path: None, - }).await { - Ok(_) => { - tracing::info!( - session_id = %session_id, - file_id = %fid, - original_count = transcript_entries.len(), - deduplicated_count = final_entries.len(), - "Saved final transcript to file" - ); - - // Send TranscriptSaved message to client - if let Some(contract_id) = target_contract_id { - let _ = response_tx - .send(ServerMessage::TranscriptSaved { - file_id: fid.to_string(), - contract_id: contract_id.to_string(), - }) - .await; - } - } - Err(e) => { - tracing::error!( - session_id = %session_id, - file_id = %fid, - error = %e, - "Failed to save final transcript to file" - ); - } - } - } - } - - // Cleanup - drop(response_tx); - let _ = sender_task.await; - tracing::info!(session_id = %session_id, "WebSocket connection closed"); -} - -/// Decode binary audio chunk to f32 samples based on encoding format. -fn decode_audio_chunk(data: &[u8], format: &StartMessage) -> Vec<f32> { - match format.encoding { - AudioEncoding::Pcm32f => data - .chunks_exact(4) - .map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])) - .collect(), - AudioEncoding::Pcm16 | AudioEncoding::Raw => data - .chunks_exact(2) - .map(|chunk| { - let sample = i16::from_le_bytes([chunk[0], chunk[1]]); - sample as f32 / 32768.0 - }) - .collect(), - } -} - -/// Deduplicate transcript entries by removing entries with similar times and text. -/// -/// Entries are considered duplicates if any of these are true: -/// - Start times are within 1.5 seconds AND text is similar (same, substring, or high overlap) -/// - Time ranges overlap significantly AND text is similar -/// - Text is identical regardless of timing -fn deduplicate_transcripts(entries: &[TranscriptEntry]) -> Vec<TranscriptEntry> { - if entries.is_empty() { - return vec![]; - } - - // Sort by start time - let mut sorted: Vec<TranscriptEntry> = entries.to_vec(); - sorted.sort_by(|a, b| a.start.partial_cmp(&b.start).unwrap_or(std::cmp::Ordering::Equal)); - - let mut result: Vec<TranscriptEntry> = Vec::new(); - - for entry in sorted { - // Normalize text for comparison - let entry_text_normalized = normalize_text(&entry.text); - - // Check if this entry is a duplicate of any existing entry - let duplicate_idx = result.iter().position(|existing| { - let existing_text_normalized = normalize_text(&existing.text); - - // Check if same speaker - let same_speaker = existing.speaker == entry.speaker; - - // Check if start times are identical or very close - let start_identical = (existing.start - entry.start).abs() < 0.1; - let start_close = (existing.start - entry.start).abs() < 1.5; - - // Check if time ranges overlap - let time_overlap = existing.start < entry.end && entry.start < existing.end; - - // Check various text similarity conditions - let text_identical = existing_text_normalized == entry_text_normalized; - let text_contained = existing_text_normalized.contains(&entry_text_normalized) - || entry_text_normalized.contains(&existing_text_normalized); - let text_similar = text_similarity(&existing_text_normalized, &entry_text_normalized) > 0.7; - - // Duplicate conditions: - // 1. Same speaker + identical start time (different end times = same segment refined) - // 2. Same speaker + close start + similar text - // 3. Same speaker + overlapping time + similar text - // 4. Identical text (likely a re-transcription) - (same_speaker && start_identical) - || (same_speaker && start_close && (text_identical || text_contained || text_similar)) - || (same_speaker && time_overlap && (text_identical || text_contained)) - || text_identical - }); - - match duplicate_idx { - Some(idx) => { - // If the new entry has longer text, update the existing one - if entry.text.len() > result[idx].text.len() { - result[idx].text = entry.text.clone(); - result[idx].end = result[idx].end.max(entry.end); - } else { - // Extend end time if needed - result[idx].end = result[idx].end.max(entry.end); - } - } - None => { - result.push(entry); - } - } - } - - // Second pass: merge adjacent segments with same speaker and similar text - let mut merged: Vec<TranscriptEntry> = Vec::new(); - for entry in result { - if let Some(last) = merged.last_mut() { - // Check if this should be merged with the previous entry - let same_speaker = last.speaker == entry.speaker; - let adjacent = (entry.start - last.end).abs() < 0.5; - let text_overlap = normalize_text(&last.text).contains(&normalize_text(&entry.text)) - || normalize_text(&entry.text).contains(&normalize_text(&last.text)); - - if same_speaker && adjacent && text_overlap { - // Merge: keep longer text, extend time range - if entry.text.len() > last.text.len() { - last.text = entry.text; - } - last.end = last.end.max(entry.end); - continue; - } - } - merged.push(entry); - } - - // Reassign IDs to be sequential - for (i, entry) in merged.iter_mut().enumerate() { - let parts: Vec<&str> = entry.id.split('-').collect(); - if let Some(session_prefix) = parts.first() { - entry.id = format!("{}-{}", session_prefix, i + 1); - } - } - - merged -} - -/// Normalize text for comparison by lowercasing and collapsing whitespace. -fn normalize_text(text: &str) -> String { - text.to_lowercase() - .split_whitespace() - .collect::<Vec<_>>() - .join(" ") -} - -/// Calculate text similarity as a ratio of shared words. -fn text_similarity(a: &str, b: &str) -> f32 { - if a.is_empty() || b.is_empty() { - return 0.0; - } - - let words_a: std::collections::HashSet<&str> = a.split_whitespace().collect(); - let words_b: std::collections::HashSet<&str> = b.split_whitespace().collect(); - - let intersection = words_a.intersection(&words_b).count(); - let union = words_a.union(&words_b).count(); - - if union == 0 { - 0.0 - } else { - intersection as f32 / union as f32 - } -} - -/// Process audio using sliding window through STT and streaming diarization models. -/// -/// Only processes the last MAX_WINDOW_SECONDS of audio to maintain constant -/// processing time regardless of total audio length. -async fn process_audio_window( - samples: &[f32], - _audio_offset: f32, - 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); - let window = &samples[window_start..]; - - tracing::trace!( - total_samples = samples.len(), - window_samples = window.len(), - window_start = window_start, - "Using sliding window for processing" - ); - - // Acquire model locks and run inference - 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 = - sortformer.diarize_streaming(window.to_vec(), TARGET_SAMPLE_RATE, TARGET_CHANNELS)?; - - // Run transcription - let transcription = parakeet.transcribe_samples( - window.to_vec(), - TARGET_SAMPLE_RATE, - TARGET_CHANNELS, - Some(TimestampMode::Sentences), - )?; - - // Align speakers with transcription - let aligned = align_speakers(&transcription.tokens, &diarization_segments); - - // Adjust timestamps for window offset within the buffer - let window_offset = window_start as f32 / TARGET_SAMPLE_RATE as f32; - let adjusted: Vec<DialogueSegment> = aligned - .into_iter() - .map(|seg| DialogueSegment { - speaker: seg.speaker, - start: seg.start + window_offset, - end: seg.end + window_offset, - text: seg.text, - }) - .collect(); - - Ok(adjusted) -} diff --git a/makima/src/server/handlers/mesh.rs b/makima/src/server/handlers/mesh.rs index be5387e..6ba4c8b 100644 --- a/makima/src/server/handlers/mesh.rs +++ b/makima/src/server/handlers/mesh.rs @@ -274,35 +274,16 @@ pub async fn create_task( let _ = repository::record_history_event( pool, auth.owner_id, - task.contract_id, Some(task.id), "task", Some("created"), - None, serde_json::json!({ "name": &task.name, - "isSupervisor": task.is_supervisor, }), ).await; - // 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; - } - }); - } - } + // Supervisor notification on task creation removed alongside + // legacy contracts. (StatusCode::CREATED, Json(task)).into_response() } Err(e) => { @@ -352,26 +333,6 @@ 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() { @@ -410,26 +371,9 @@ pub async fn update_task( 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; - } - }); - } - } + // Supervisor notification on task update removed alongside + // legacy contracts. + let _ = updated_fields_clone; Json(task).into_response() } @@ -657,15 +601,10 @@ pub async fn start_task( .into_response(); } - // Get local_only and auto_merge_local flags from contract if task has one - let (local_only, auto_merge_local) = if let Some(contract_id) = task.contract_id { - match repository::get_contract_for_owner(pool, contract_id, auth.owner_id).await { - Ok(Some(contract)) => (contract.local_only, contract.auto_merge_local), - _ => (false, false), - } - } else { - (false, false) - }; + // local_only / auto_merge_local used to come from the parent contract. + // With legacy contracts removed they default to false; the directive + // lifecycle handles its own completion now. + let (local_only, auto_merge_local) = (false, false); // Get list of daemons that have previously failed this task let mut exclude_daemon_ids: Vec<Uuid> = task.failed_daemon_ids.clone().unwrap_or_default(); @@ -708,8 +647,7 @@ pub async fn start_task( task_depth = task.depth, subtask_count = subtask_count, is_orchestrator = is_orchestrator, - is_supervisor = task.is_supervisor, - "Starting task with orchestrator/supervisor determination" + "Starting task" ); // IMPORTANT: Update database FIRST to assign daemon_id before sending command @@ -755,8 +693,6 @@ 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, autonomous_loop: false, resume_session: false, conversation_history: None, @@ -764,13 +700,11 @@ pub async fn start_task( patch_base_sha: None, local_only, auto_merge_local, - supervisor_worktree_task_id: None, // Not spawned by supervisor directive_id: task.directive_id, }; tracing::info!( task_id = %id, - is_supervisor = task.is_supervisor, is_orchestrator = is_orchestrator, daemon_id = %target_daemon_id, "Sending SpawnTask command to daemon" @@ -811,8 +745,6 @@ 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, autonomous_loop: false, resume_session: false, conversation_history: None, @@ -820,7 +752,6 @@ pub async fn start_task( patch_base_sha: None, local_only, auto_merge_local, - supervisor_worktree_task_id: None, // Not spawned by supervisor directive_id: task.directive_id, }; @@ -1128,11 +1059,10 @@ pub async fn send_message( // Check if task is in a state that can receive messages // Allow "running" and "starting" (to handle race between status update and message send) - // Also allow AUTH_CODE messages and supervisor tasks regardless of status + // Also allow AUTH_CODE messages regardless of status let is_auth_code = req.message.starts_with("AUTH_CODE:"); - let is_supervisor = task.is_supervisor; let can_receive_message = task.status == "running" || task.status == "starting"; - if !can_receive_message && !is_auth_code && !is_supervisor { + if !can_receive_message && !is_auth_code { return ( StatusCode::BAD_REQUEST, Json(ApiError::new( @@ -1147,27 +1077,8 @@ 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, @@ -1206,15 +1117,7 @@ pub async fn send_message( }; if let Ok(Some(updated_task)) = repository::update_task_for_owner(pool, id, auth.owner_id, update_req).await { - // Get local_only and auto_merge_local from contract if task has one - let (local_only, auto_merge_local) = if let Some(contract_id) = updated_task.contract_id { - match repository::get_contract_for_owner(pool, contract_id, auth.owner_id).await { - Ok(Some(contract)) => (contract.local_only, contract.auto_merge_local), - _ => (false, false), - } - } else { - (false, false) - }; + let (local_only, auto_merge_local) = (false, false); // Send spawn command to new daemon let spawn_cmd = DaemonCommand::SpawnTask { @@ -1231,8 +1134,6 @@ pub async fn send_message( completion_action: updated_task.completion_action.clone(), continue_from_task_id: updated_task.continue_from_task_id, copy_files: updated_task.copy_files.as_ref().and_then(|v| serde_json::from_value(v.clone()).ok()), - contract_id: updated_task.contract_id, - is_supervisor: updated_task.is_supervisor, autonomous_loop: false, resume_session: false, conversation_history: None, @@ -1240,7 +1141,6 @@ pub async fn send_message( patch_base_sha: None, local_only, auto_merge_local, - supervisor_worktree_task_id: None, // Not spawned by supervisor directive_id: updated_task.directive_id, }; @@ -2433,13 +2333,11 @@ pub async fn commit_worktree( // Task Patches // ============================================================================= -/// Query parameters for listing task patches +/// Query parameters for listing task patches (legacy contract scope +/// removed; query is currently empty). #[derive(Debug, serde::Deserialize)] #[serde(rename_all = "camelCase")] -pub struct ListPatchesQuery { - /// Contract ID to scope the patches - pub contract_id: Uuid, -} +pub struct ListPatchesQuery {} /// Patch summary for API response #[derive(Debug, serde::Serialize, utoipa::ToSchema)] @@ -2453,8 +2351,6 @@ pub struct PatchSummary { pub description: Option<String>, /// Task ID pub task_id: Uuid, - /// Contract ID - pub contract_id: Uuid, /// Number of files in the patch pub files_count: i32, /// Total lines added (estimated from patch size) @@ -2523,14 +2419,8 @@ pub async fn list_task_patches( } }; - // Verify task belongs to the specified contract - if task.contract_id != Some(query.contract_id) { - return ( - StatusCode::BAD_REQUEST, - Json(ApiError::new("INVALID_CONTRACT", "Task does not belong to the specified contract")), - ) - .into_response(); - } + // Legacy contract verification removed; checkpoint patches are + // accessible to any owner of the task. // Get checkpoint patches for this task let patches = match repository::list_checkpoint_patches(pool, id).await { @@ -2586,7 +2476,6 @@ pub async fn list_task_patches( name, description, task_id: p.task_id, - contract_id: query.contract_id, files_count: p.files_count, lines_added, lines_removed, @@ -3040,12 +2929,10 @@ pub async fn reassign_task( // Create a NEW task with the conversation context let create_req = CreateTaskRequest { - contract_id: task.contract_id, name: format!("{} (resumed)", task.name), description: task.description.clone(), plan: updated_plan.clone(), parent_task_id: task.parent_task_id, - is_supervisor: task.is_supervisor, priority: task.priority, repository_url: task.repository_url.clone(), base_branch: task.base_branch.clone(), @@ -3058,7 +2945,6 @@ pub async fn reassign_task( checkpoint_sha: task.last_checkpoint_sha.clone(), branched_from_task_id: None, conversation_history: None, - supervisor_worktree_task_id: None, // Not spawned by supervisor directive_id: None, directive_step_id: None, }; @@ -3126,15 +3012,8 @@ pub async fn reassign_task( } }; - // Get local_only and auto_merge_local from contract if task has one - let (local_only, auto_merge_local) = if let Some(contract_id) = task.contract_id { - match repository::get_contract_for_owner(pool, contract_id, auth.owner_id).await { - Ok(Some(contract)) => (contract.local_only, contract.auto_merge_local), - _ => (false, false), - } - } else { - (false, false) - }; + // Legacy contract scope removed; defaults to false. + let (local_only, auto_merge_local) = (false, false); // Send SpawnTask command to daemon for the new task let command = DaemonCommand::SpawnTask { @@ -3151,8 +3030,6 @@ pub async fn reassign_task( completion_action: task.completion_action.clone(), continue_from_task_id: Some(id), // Continue from old task's worktree copy_files: None, - contract_id: task.contract_id, - is_supervisor: task.is_supervisor, autonomous_loop: false, resume_session: false, conversation_history: None, @@ -3160,7 +3037,6 @@ pub async fn reassign_task( patch_base_sha, local_only, auto_merge_local, - supervisor_worktree_task_id: None, // Not spawned by supervisor directive_id: task.directive_id, }; @@ -3190,56 +3066,10 @@ pub async fn reassign_task( // Don't fail the request, the new task is already running } - // Notify the contract's supervisor about the reassignment (if applicable) - if let Some(contract_id) = task.contract_id { - if let Ok(Some(contract)) = repository::get_contract_for_owner(pool, contract_id, auth.owner_id).await { - if let Some(supervisor_task_id) = contract.supervisor_task_id { - // Don't notify if we're reassigning the supervisor itself - if supervisor_task_id != old_task_id { - // Find the supervisor's daemon and send a message - if let Ok(Some(supervisor_task)) = repository::get_task_for_owner(pool, supervisor_task_id, auth.owner_id).await { - if supervisor_task.status == "running" { - if let Some(supervisor_daemon_id) = supervisor_task.daemon_id { - // Find the daemon by its UUID - if let Some(daemon_entry) = state.daemon_connections.iter().find(|d| d.value().id == supervisor_daemon_id) { - let notification_msg = format!( - "\n\n[SYSTEM NOTIFICATION] Task '{}' (ID: {}) was reassigned due to daemon disconnect. \ - A new task '{}' (ID: {}) has been created to continue the work. \ - The new task has {} context entries from the previous conversation.\n\n", - task.name, - old_task_id, - final_task.name, - new_task.id, - context_entries - ); - - let notify_cmd = DaemonCommand::SendMessage { - task_id: supervisor_task_id, - message: notification_msg, - }; - - if let Err(e) = state.send_daemon_command(daemon_entry.value().id, notify_cmd).await { - tracing::warn!( - supervisor_id = %supervisor_task_id, - error = %e, - "Failed to notify supervisor about task reassignment" - ); - } else { - tracing::info!( - supervisor_id = %supervisor_task_id, - old_task_id = %old_task_id, - new_task_id = %new_task.id, - "Notified supervisor about task reassignment" - ); - } - } - } - } - } - } - } - } - } + // Supervisor reassignment notification removed alongside legacy + // contracts. The directive reconciler picks up reassigned tasks on + // its next tick. + let _ = context_entries; // Broadcast task update for the new task state.broadcast_task_update(TaskUpdateNotification { @@ -3467,15 +3297,8 @@ pub async fn continue_task( }; let is_orchestrator = task.depth == 0 && subtask_count > 0; - // Get local_only and auto_merge_local from contract if task has one - let (local_only, auto_merge_local) = if let Some(contract_id) = task.contract_id { - match repository::get_contract_for_owner(pool, contract_id, auth.owner_id).await { - Ok(Some(contract)) => (contract.local_only, contract.auto_merge_local), - _ => (false, false), - } - } else { - (false, false) - }; + // Legacy contract scope removed; defaults to false. + let (local_only, auto_merge_local) = (false, false); // Send SpawnTask command to daemon let command = DaemonCommand::SpawnTask { @@ -3492,8 +3315,6 @@ pub async fn continue_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, autonomous_loop: false, resume_session: false, conversation_history: None, @@ -3501,7 +3322,6 @@ pub async fn continue_task( patch_base_sha: None, local_only, auto_merge_local, - supervisor_worktree_task_id: None, // Not spawned by supervisor directive_id: task.directive_id, }; @@ -3509,7 +3329,6 @@ pub async fn continue_task( task_id = %id, daemon_id = %target_daemon_id, context_entries = context_entries, - is_supervisor = task.is_supervisor, "Continuing task with conversation context" ); @@ -3820,12 +3639,10 @@ pub async fn fork_task( // Create the new forked task let create_req = CreateTaskRequest { - contract_id: task.contract_id, name: req.new_task_name.clone(), description: task.description.clone(), plan: req.new_task_plan.clone(), parent_task_id: None, // Forked tasks are independent - is_supervisor: false, priority: task.priority, repository_url: task.repository_url.clone(), base_branch: task.base_branch.clone(), @@ -3838,7 +3655,6 @@ pub async fn fork_task( checkpoint_sha: Some(checkpoint.commit_sha.clone()), branched_from_task_id: None, conversation_history: None, - supervisor_worktree_task_id: None, // Not spawned by supervisor directive_id: None, directive_step_id: None, }; @@ -3980,12 +3796,10 @@ pub async fn resume_from_checkpoint( }); let create_req = CreateTaskRequest { - contract_id: task.contract_id, name: task_name, description: task.description.clone(), plan: req.plan, parent_task_id: None, - is_supervisor: false, priority: task.priority, repository_url: task.repository_url.clone(), base_branch: task.base_branch.clone(), @@ -3998,7 +3812,6 @@ pub async fn resume_from_checkpoint( checkpoint_sha: Some(checkpoint.commit_sha.clone()), branched_from_task_id: None, conversation_history: None, - supervisor_worktree_task_id: None, // Not spawned by supervisor directive_id: None, directive_step_id: None, }; @@ -4318,12 +4131,10 @@ pub async fn branch_task( // Create the branched task (anonymous - no contract_id) let create_req = CreateTaskRequest { - contract_id: None, // Anonymous task name: task_name, description: Some(format!("Branched from task: {}", source_task.name)), plan: req.message, parent_task_id: None, - is_supervisor: false, priority: source_task.priority, repository_url: source_task.repository_url.clone(), base_branch: source_task.base_branch.clone(), @@ -4336,7 +4147,6 @@ pub async fn branch_task( checkpoint_sha: None, branched_from_task_id: Some(source_task_id), conversation_history, - supervisor_worktree_task_id: None, // Not spawned by supervisor directive_id: None, directive_step_id: None, }; @@ -4357,11 +4167,9 @@ pub async fn branch_task( let _ = repository::record_history_event( pool, auth.owner_id, - None, // No contract for anonymous tasks Some(task.id), "task", Some("branched"), - None, serde_json::json!({ "name": &task.name, "sourceTaskId": source_task_id, @@ -4425,8 +4233,6 @@ pub async fn branch_task( completion_action: updated_task.completion_action.clone(), continue_from_task_id: updated_task.continue_from_task_id, copy_files: None, - contract_id: None, - is_supervisor: false, autonomous_loop: false, resume_session: message_count > 0, // Resume if we have conversation history conversation_history: updated_task.conversation_state.clone(), @@ -4434,7 +4240,6 @@ pub async fn branch_task( patch_base_sha, local_only: false, // No contract, so not local_only auto_merge_local: false, // No contract, so no auto_merge_local - supervisor_worktree_task_id: None, // Not spawned by supervisor directive_id: None, }; diff --git a/makima/src/server/handlers/mesh_daemon.rs b/makima/src/server/handlers/mesh_daemon.rs index 19d2166..9900385 100644 --- a/makima/src/server/handlers/mesh_daemon.rs +++ b/makima/src/server/handlers/mesh_daemon.rs @@ -262,27 +262,6 @@ pub enum DaemonMessage { #[serde(rename = "activeTasks")] active_tasks: Vec<Uuid>, }, - /// Enhanced supervisor heartbeat with detailed state - SupervisorHeartbeat { - #[serde(rename = "taskId")] - task_id: Uuid, - #[serde(rename = "contractId")] - contract_id: Uuid, - /// Supervisor state: initializing, idle, working, waiting_for_user, waiting_for_tasks, blocked, completed, failed, interrupted - state: String, - /// Current contract phase - phase: String, - /// Description of current activity - #[serde(rename = "currentActivity")] - current_activity: Option<String>, - /// Progress percentage (0-100) - progress: u8, - /// Task IDs the supervisor is waiting on - #[serde(rename = "pendingTaskIds")] - pending_task_ids: Vec<Uuid>, - /// Timestamp of this heartbeat - timestamp: DateTime<Utc>, - }, /// Task output streaming (stdout/stderr from Claude Code) TaskOutput { #[serde(rename = "taskId")] @@ -618,96 +597,6 @@ struct DaemonAuthResult { /// Automatically create a PR when all non-supervisor tasks for a contract are done. /// Only applies to remote-repo contracts in the "execute" phase. /// Fires as a best-effort operation — errors are logged but not propagated. -async fn auto_create_pr_if_ready( - pool: &sqlx::PgPool, - state: &SharedState, - contract_id: Uuid, - owner_id: Uuid, -) { - // 1. Load contract — must be remote (not local_only) and in execute phase - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - _ => return, - }; - if contract.local_only || contract.phase != "execute" { - return; - } - - // 2. Load non-supervisor tasks — all must be done - let tasks = match repository::list_tasks_by_contract(pool, contract_id, owner_id).await { - Ok(t) => t, - _ => return, - }; - let non_supervisor_tasks: Vec<_> = tasks.iter().filter(|t| !t.is_supervisor).collect(); - if non_supervisor_tasks.is_empty() || !non_supervisor_tasks.iter().all(|t| t.status == "done") { - return; - } - - // 3. Check pull-request deliverable not already complete - let completed_deliverables = contract.get_completed_deliverables(&contract.phase); - if completed_deliverables.contains(&"pull-request".to_string()) { - return; - } - - // 4. Check at least one repository has a remote URL - let repos = match repository::list_contract_repositories(pool, contract_id).await { - Ok(r) => r, - _ => return, - }; - if !repos.iter().any(|r| r.repository_url.is_some()) { - return; - } - - // 5. Load supervisor task - let supervisor = match repository::get_contract_supervisor_task(pool, contract_id).await { - Ok(Some(s)) => s, - _ => return, - }; - - // Need supervisor's daemon_id to send command - let daemon_id = match supervisor.daemon_id { - Some(id) => id, - None => return, - }; - - // 6. Construct branch name - let sanitized_name: String = supervisor - .name - .chars() - .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }) - .collect::<String>() - .to_lowercase(); - let short_id = &supervisor.id.to_string()[..8]; - let branch = format!("makima/{}-{}", sanitized_name, short_id); - - // 7. Send CreatePR command to supervisor's daemon - let command = DaemonCommand::CreatePR { - task_id: supervisor.id, - title: contract.name.clone(), - body: contract.description.clone(), - base_branch: supervisor.base_branch.clone(), - branch, - }; - - match state.send_daemon_command(daemon_id, command).await { - Ok(()) => { - tracing::info!( - contract_id = %contract_id, - supervisor_id = %supervisor.id, - "Auto-PR: sent CreatePR command to supervisor daemon" - ); - } - Err(e) => { - tracing::warn!( - contract_id = %contract_id, - error = %e, - "Auto-PR: failed to send CreatePR command" - ); - } - } -} - -/// Validate an API key and return (user_id, owner_id). async fn validate_daemon_api_key(pool: &sqlx::PgPool, key: &str) -> Result<DaemonAuthResult, String> { let key_hash = hash_api_key(key); @@ -983,83 +872,6 @@ async fn handle_daemon_connection(socket: WebSocket, state: SharedState, auth_re }); } } - Ok(DaemonMessage::SupervisorHeartbeat { - task_id, - contract_id, - state: supervisor_state, - phase, - current_activity, - progress, - pending_task_ids, - timestamp: _, - }) => { - tracing::debug!( - task_id = %task_id, - contract_id = %contract_id, - state = %supervisor_state, - phase = %phase, - progress = progress, - "Supervisor heartbeat received" - ); - - // Store heartbeat in database and update supervisor state (Task 3.3) - if let Some(ref pool) = state.db_pool { - let pool = pool.clone(); - let pending_ids = pending_task_ids.clone(); - let activity = current_activity.clone(); - let state_str = supervisor_state.clone(); - let phase_str = phase.clone(); - tokio::spawn(async move { - // Store the heartbeat record - if let Err(e) = repository::create_supervisor_heartbeat( - &pool, - task_id, - contract_id, - &state_str, - &phase_str, - activity.as_deref(), - progress as i32, - &pending_ids, - ).await { - tracing::warn!( - task_id = %task_id, - contract_id = %contract_id, - error = %e, - "Failed to store supervisor heartbeat" - ); - } - - // Update supervisor_states table (lightweight heartbeat state update - Task 3.3) - if let Err(e) = repository::update_supervisor_heartbeat_state( - &pool, - contract_id, - &state_str, - activity.as_deref(), - progress as i32, - &pending_ids, - ).await { - tracing::debug!( - contract_id = %contract_id, - error = %e, - "Failed to update supervisor state from heartbeat (may not exist yet)" - ); - } - - // Also update the daemon heartbeat - if let Ok(Some(task)) = repository::get_task(&pool, task_id).await { - if let Some(daemon_id) = task.daemon_id { - if let Err(e) = repository::update_daemon_heartbeat(&pool, daemon_id).await { - tracing::warn!( - daemon_id = %daemon_id, - error = %e, - "Failed to update daemon heartbeat from supervisor" - ); - } - } - } - }); - } - } Ok(DaemonMessage::TaskOutput { task_id, output, is_partial }) => { // Parse the output line and broadcast structured data if let Some(notification) = parse_claude_output(task_id, owner_id, &output, is_partial) { @@ -1120,136 +932,16 @@ async fn handle_daemon_connection(socket: WebSocket, state: SharedState, auth_re updated_by: "daemon".into(), }); - // Initialize or restore supervisor_state when supervisor task starts running (Task 3.4) - if updated_task.is_supervisor && new_status_owned == "running" { - if let Some(contract_id) = updated_task.contract_id { - // Check if supervisor state already exists (restoration scenario) - match repository::get_supervisor_state(&pool, contract_id).await { - Ok(Some(existing_state)) => { - // State exists - this is a restoration - tracing::info!( - task_id = %task_id, - contract_id = %contract_id, - existing_state = %existing_state.state, - restoration_count = existing_state.restoration_count, - "Supervisor starting with existing state - restoration in progress" - ); - - // Mark as restored (increments restoration_count) - match repository::mark_supervisor_restored( - &pool, - contract_id, - "daemon_restart", - ).await { - Ok(restored_state) => { - tracing::info!( - task_id = %task_id, - contract_id = %contract_id, - restoration_count = restored_state.restoration_count, - "Supervisor restoration marked" - ); - - // Check for pending questions to re-deliver - if let Ok(questions) = serde_json::from_value::<Vec<crate::db::models::PendingQuestion>>( - restored_state.pending_questions.clone() - ) { - if !questions.is_empty() { - tracing::info!( - contract_id = %contract_id, - question_count = questions.len(), - "Pending questions found for re-delivery" - ); - // Questions will be re-delivered by the supervisor when it restores - } - } - } - Err(e) => { - tracing::warn!( - task_id = %task_id, - contract_id = %contract_id, - error = %e, - "Failed to mark supervisor as restored" - ); - } - } - } - Ok(None) => { - // No existing state - fresh start - // Get contract to get its phase - match repository::get_contract_for_owner( - &pool, - contract_id, - updated_task.owner_id, - ).await { - Ok(Some(contract)) => { - match repository::upsert_supervisor_state( - &pool, - contract_id, - task_id, - serde_json::json!([]), // Empty conversation - &[], // No pending tasks - &contract.phase, - ).await { - Ok(_) => { - tracing::info!( - task_id = %task_id, - contract_id = %contract_id, - phase = %contract.phase, - "Initialized fresh supervisor state" - ); - } - Err(e) => { - tracing::warn!( - task_id = %task_id, - contract_id = %contract_id, - error = %e, - "Failed to initialize supervisor state" - ); - } - } - } - Ok(None) => { - tracing::warn!( - task_id = %task_id, - contract_id = %contract_id, - "Contract not found when initializing supervisor state" - ); - } - Err(e) => { - tracing::warn!( - task_id = %task_id, - contract_id = %contract_id, - error = %e, - "Failed to get contract for supervisor state" - ); - } - } - } - Err(e) => { - tracing::warn!( - task_id = %task_id, - contract_id = %contract_id, - error = %e, - "Failed to check existing supervisor state" - ); - } - } - } - } - // Record history event when task starts running if new_status_owned == "running" { let _ = repository::record_history_event( &pool, updated_task.owner_id, - updated_task.contract_id, Some(task_id), "task", Some("started"), - None, serde_json::json!({ "name": &updated_task.name, - "isSupervisor": updated_task.is_supervisor, }), ).await; } @@ -1329,51 +1021,19 @@ 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 { - // action_directive used to come from - // compute_action_directive (now removed alongside the - // LLM module). Passing None preserves the existing - // supervisor protocol; the auto-PR path below still - // fires when every task is done. - 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(), - None, - ).await; - } - } - } - - // Auto-create PR if all tasks are done and repo is remote - if updated_task.status == "done" { - if let Some(contract_id) = updated_task.contract_id { - let pool_c = pool.clone(); - let state_c = state.clone(); - tokio::spawn(async move { - auto_create_pr_if_ready(&pool_c, &state_c, contract_id, owner_id).await; - }); - } - } + // Supervisor notification + auto-PR removed alongside + // legacy contracts. Directive completion is handled + // by the directive reconciler. + let _ = owner_id; // Record history event for task completion let subtype = if updated_task.status == "done" { "completed" } else { "failed" }; let _ = repository::record_history_event( &pool, updated_task.owner_id, - updated_task.contract_id, Some(task_id), "task", Some(subtype), - None, serde_json::json!({ "name": &updated_task.name, "status": &updated_task.status, @@ -1962,16 +1622,13 @@ async fn handle_daemon_connection(socket: WebSocket, state: SharedState, auth_re }); // Record history event for checkpoint - // Get task to get contract_id if let Ok(Some(task)) = repository::get_task(pool, task_id).await { let _ = repository::record_history_event( pool, task.owner_id, - task.contract_id, Some(task_id), "checkpoint", Some("created"), - None, serde_json::json!({ "checkpointNumber": checkpoint.checkpoint_number, "commitSha": &sha, @@ -2103,28 +1760,6 @@ async fn handle_daemon_connection(socket: WebSocket, state: SharedState, auth_re conflicts: conflicts.clone(), }); - // On successful merge, notify supervisor to check if all merges complete - if success { - if let Some(pool) = state.db_pool.as_ref() { - if let Ok(Some(task)) = repository::get_task(pool, task_id).await { - if let Some(contract_id) = task.contract_id { - if let Ok(Some(supervisor)) = repository::get_contract_supervisor_task(pool, contract_id).await { - let prompt = format!( - "[INFO] Merge completed: {}\n\ - Check if all tasks are merged with `makima supervisor tasks`.\n\ - If ready, create PR with `makima supervisor pr`.", - message - ); - let _ = state.notify_supervisor( - supervisor.id, - supervisor.daemon_id, - &prompt, - ).await; - } - } - } - } - } } Ok(DaemonMessage::PRCreated { task_id, @@ -2150,52 +1785,6 @@ async fn handle_daemon_connection(socket: WebSocket, state: SharedState, auth_re pr_number, }); - // Notify supervisor of PR result (both success and failure) - if let Some(pool) = state.db_pool.as_ref() { - if let Ok(Some(task)) = repository::get_task(pool, task_id).await { - if let Some(contract_id) = task.contract_id { - if let Ok(Some(supervisor)) = repository::get_contract_supervisor_task(pool, contract_id).await { - let prompt = if success { - // Get contract to determine next action - let next_action = if let Ok(Some(contract)) = repository::get_contract_for_owner(pool, contract_id, task.owner_id).await { - match (contract.contract_type.as_str(), contract.phase.as_str()) { - ("simple", "execute") => { - "Mark contract complete with `makima supervisor complete`".to_string() - } - ("specification", "execute") => { - "Advance to review phase with `makima supervisor advance-phase review`".to_string() - } - _ => "Check contract status with `makima supervisor status`".to_string() - } - } else { - "Check contract status with `makima supervisor status`".to_string() - }; - - format!( - "[ACTION REQUIRED] PR created successfully!\n\ - PR: {}\n\n\ - Next step: {}", - pr_url.as_deref().unwrap_or(&message), - next_action - ) - } else { - format!( - "[ERROR] PR creation failed for task {}:\n\ - {}\n\n\ - Please fix the issue and retry with `makima supervisor pr`.", - task_id, - message - ) - }; - let _ = state.notify_supervisor( - supervisor.id, - supervisor.daemon_id, - &prompt, - ).await; - } - } - } - } } Ok(DaemonMessage::GitConfigInherited { success, diff --git a/makima/src/server/handlers/mesh_supervisor.rs b/makima/src/server/handlers/mesh_supervisor.rs index ebde52b..4a9a00b 100644 --- a/makima/src/server/handlers/mesh_supervisor.rs +++ b/makima/src/server/handlers/mesh_supervisor.rs @@ -1,7 +1,13 @@ -//! HTTP handlers for supervisor-specific mesh operations. +//! Question + order backchannel for directive-spawned tasks. //! -//! These endpoints are used by supervisor tasks (via supervisor.sh) to orchestrate -//! contract work: spawning tasks, waiting for completion, reading worktree files, etc. +//! Originally a much larger handler that orchestrated contract-supervisor +//! task trees (spawn / wait / merge / PR / etc.). Legacy contracts and +//! supervisor tasks have been removed; what remains is the in-memory +//! question machinery (`makima directive ask`) and order creation +//! (`makima directive create-order`). +//! +//! Module name is kept as `mesh_supervisor` for route-path stability — +//! the CLI client still hits `/api/v1/mesh/supervisor/...` endpoints. use axum::{ extract::{Path, State}, @@ -9,238 +15,38 @@ use axum::{ response::IntoResponse, Json, }; -use base64::Engine; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; -use crate::db::models::{CreateOrderRequest, CreateTaskRequest, PendingQuestion, Task, TaskSummary, UpdateTaskRequest}; +use crate::db::models::CreateOrderRequest; use crate::db::repository; -use sqlx::PgPool; use crate::server::auth::Authenticated; use crate::server::handlers::mesh::{extract_auth, AuthSource}; use crate::server::messages::ApiError; -use crate::server::state::{DaemonCommand, SharedState, TaskOutputNotification, TaskUpdateNotification}; - -// ============================================================================= -// 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 (optional - if not provided, will be looked up from contract). - 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 ask a question and wait for user feedback. -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct AskQuestionRequest { - /// The question to ask the user - pub question: String, - /// Optional choices (if empty, free-form text response) - #[serde(default)] - pub choices: Vec<String>, - /// Optional context about what this relates to - pub context: Option<String>, - /// How long to wait for a response (seconds) - #[serde(default = "default_question_timeout")] - pub timeout_seconds: i32, - /// When true, the request will block indefinitely until user responds (no timeout) - #[serde(default)] - pub phaseguard: bool, - /// When true, allow selecting multiple choices (response will be comma-separated) - #[serde(default)] - pub multi_select: bool, - /// When true, return immediately without waiting for response - #[serde(default)] - pub non_blocking: bool, - /// Question type: general, phase_confirmation, or contract_complete - #[serde(default = "default_question_type")] - pub question_type: String, -} - -fn default_question_type() -> String { - "general".to_string() -} - -fn default_question_timeout() -> i32 { - 3600 // 1 hour default -} - -/// Response from asking a question. -#[derive(Debug, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct AskQuestionResponse { - /// The question ID for tracking - pub question_id: Uuid, - /// The user's response (None if timed out) - pub response: Option<String>, - /// Whether the question timed out - pub timed_out: bool, - /// Whether the question is still pending (server-side timeout reached but question not removed). - /// The client should poll the poll endpoint to continue waiting. - #[serde(default)] - pub still_pending: bool, -} - -/// Request to answer a supervisor question. -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct AnswerQuestionRequest { - /// The user's response - pub response: String, -} - -/// Response to answering a question. -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct AnswerQuestionResponse { - /// Whether the answer was accepted - pub success: bool, -} - -/// Pending question summary. -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct PendingQuestionSummary { - pub question_id: Uuid, - pub task_id: Uuid, - pub contract_id: Uuid, - /// Directive this question relates to (if from a directive task) - #[serde(skip_serializing_if = "Option::is_none")] - pub directive_id: Option<Uuid>, - pub question: String, - pub choices: Vec<String>, - pub context: Option<String>, - pub created_at: chrono::DateTime<chrono::Utc>, - /// Whether multiple choices can be selected - #[serde(default)] - pub multi_select: bool, - /// Question type: general, phase_confirmation, or contract_complete - #[serde(default)] - pub question_type: 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>, -} +use crate::server::state::SharedState; // ============================================================================= -// Helper Functions +// Auth helper // ============================================================================= -/// Verify the request comes from a supervisor task and extract ownership info. -async fn verify_supervisor_auth( +/// Verify the request comes from a directive task (tool-key auth) and +/// return the calling task id + owner id. +async fn verify_task_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")), + Json(ApiError::new("UNAUTHORIZED", "These 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, @@ -251,10 +57,10 @@ async fn verify_supervisor_auth( let task = repository::get_task(pool, task_id) .await .map_err(|e| { - tracing::error!(error = %e, "Failed to get supervisor task"); + tracing::error!(error = %e, "Failed to load task"); ( StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", "Failed to verify supervisor")), + Json(ApiError::new("DB_ERROR", "Failed to load task")), ) })? .ok_or_else(|| { @@ -264,1411 +70,113 @@ async fn verify_supervisor_auth( ) })?; - // Verify task is a supervisor or a directive task - if !task.is_supervisor && task.directive_id.is_none() { + // Only directive-attached tasks may use this backchannel. + if task.directive_id.is_none() { return Err(( StatusCode::FORBIDDEN, - Json(ApiError::new("NOT_SUPERVISOR", "Only supervisor or directive tasks can use these endpoints")), + Json(ApiError::new( + "NOT_DIRECTIVE_TASK", + "Only directive-attached 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)) } -/// Try to start a pending task on an available daemon. -/// Returns Ok(Some(task)) if a task was started, Ok(None) if no tasks could be started. -/// For retried tasks, excludes daemons that previously failed the task and includes -/// checkpoint patch data for worktree recovery. -pub async fn try_start_pending_task( - state: &SharedState, - contract_id: Uuid, - owner_id: Uuid, -) -> Result<Option<Task>, String> { - let pool = state.db_pool.as_ref().ok_or("Database not configured")?; - - // Get pending tasks for this contract (includes interrupted tasks awaiting retry) - let pending_tasks = repository::get_pending_tasks_for_contract(pool, contract_id, owner_id) - .await - .map_err(|e| format!("Failed to get pending tasks: {}", e))?; - - if pending_tasks.is_empty() { - return Ok(None); - } - - // Get contract to check local_only flag - let contract = repository::get_contract_for_owner(pool, contract_id, owner_id) - .await - .map_err(|e| format!("Failed to get contract: {}", e))? - .ok_or_else(|| "Contract not found".to_string())?; - - // Try each pending task until we find one we can start - for task in &pending_tasks { - // Get excluded daemon IDs for this task (daemons that have already failed it) - let exclude_ids: Vec<Uuid> = task.failed_daemon_ids.clone().unwrap_or_default(); - - // Get available daemons excluding failed ones for this task - let daemons = repository::get_available_daemons_excluding(pool, owner_id, &exclude_ids) - .await - .map_err(|e| format!("Failed to get available daemons: {}", e))?; - - // Find a daemon with capacity - let available_daemon = daemons.iter().find(|d| { - d.current_task_count < d.max_concurrent_tasks - && state.daemon_connections.contains_key(&d.connection_id) - }); - - let daemon = match available_daemon { - Some(d) => d, - None => continue, // Try next task - }; - - // Get repo URL from task or contract - let repo_url = if let Some(url) = &task.repository_url { - Some(url.clone()) - } else { - match repository::list_contract_repositories(pool, contract_id).await { - Ok(repos) => repos - .iter() - .find(|r| r.is_primary) - .or(repos.first()) - .and_then(|r| r.repository_url.clone().or_else(|| r.local_path.clone())), - Err(_) => None, - } - }; - - // Update task with daemon assignment - let update_req = UpdateTaskRequest { - status: Some("starting".to_string()), - daemon_id: Some(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) => continue, // Task was modified concurrently, try next - Err(e) => { - tracing::warn!(task_id = %task.id, error = %e, "Failed to update task for daemon assignment"); - continue; // Try next task - } - }; - - // For retried tasks, fetch checkpoint patch for worktree recovery - let (patch_data, patch_base_sha) = if task.retry_count > 0 { - // This is a retry - try to restore from checkpoint - match repository::get_latest_checkpoint_patch(pool, task.id).await { - Ok(Some(patch)) => { - tracing::info!( - task_id = %task.id, - retry_count = task.retry_count, - patch_size = patch.patch_size_bytes, - base_sha = %patch.base_commit_sha, - "Including checkpoint patch for task retry recovery" - ); - let encoded = base64::engine::general_purpose::STANDARD.encode(&patch.patch_data); - (Some(encoded), Some(patch.base_commit_sha)) - } - Ok(None) => { - tracing::debug!(task_id = %task.id, "No checkpoint patch found for retry"); - (None, None) - } - Err(e) => { - tracing::warn!(task_id = %task.id, error = %e, "Failed to fetch checkpoint patch for retry"); - (None, None) - } - } - } else { - (None, None) - }; - - // Send spawn command - let cmd = DaemonCommand::SpawnTask { - task_id: updated_task.id, - task_name: updated_task.name.clone(), - plan: updated_task.plan.clone(), - repo_url, - base_branch: updated_task.base_branch.clone(), - target_branch: updated_task.target_branch.clone(), - parent_task_id: updated_task.parent_task_id, - depth: updated_task.depth, - is_orchestrator: false, - target_repo_path: updated_task.target_repo_path.clone(), - completion_action: updated_task.completion_action.clone(), - continue_from_task_id: updated_task.continue_from_task_id, - copy_files: updated_task.copy_files.as_ref().and_then(|v| serde_json::from_value(v.clone()).ok()), - contract_id: updated_task.contract_id, - is_supervisor: updated_task.is_supervisor, - autonomous_loop: updated_task.is_supervisor, - resume_session: task.retry_count > 0, // Use --continue for retried tasks - conversation_history: None, - patch_data, - patch_base_sha, - local_only: contract.local_only, - auto_merge_local: contract.auto_merge_local, - // For retried tasks, use their own worktree (they already have state from previous attempt) - supervisor_worktree_task_id: None, - directive_id: updated_task.directive_id, - }; - - if let Err(e) = state.send_daemon_command(daemon.id, cmd).await { - tracing::warn!(error = %e, daemon_id = %daemon.id, task_id = %task.id, "Failed to send spawn command"); - // Rollback - let rollback_req = 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; - continue; // Try next task - } - - tracing::info!(task_id = %task.id, daemon_id = %daemon.id, "Started pending task from wait loop"); - return Ok(Some(updated_task)); - } - - // No tasks could be started - Ok(None) -} - -// ============================================================================= -// 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 and get local_only flag - 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 - either from request or from contract's repositories - let repo_url = if let Some(url) = request.repository_url.clone() { - if !url.trim().is_empty() { - Some(url) - } else { - None - } - } else { - None - }; - - // If no repo URL provided, look it up from the contract - let repo_url = match repo_url { - Some(url) => Some(url), - None => { - match repository::list_contract_repositories(pool, request.contract_id).await { - Ok(repos) => { - // Prefer primary repo, fallback to first repo - let repo = repos.iter() - .find(|r| r.is_primary) - .or(repos.first()); - - // Use repository_url if set, otherwise use local_path - repo.and_then(|r| { - r.repository_url.clone() - .or_else(|| r.local_path.clone()) - }) - } - Err(e) => { - tracing::warn!(error = %e, "Failed to get contract repositories"); - None - } - } - } - }; - - // Validate that we have a repo URL - if repo_url.is_none() { - return ( - StatusCode::BAD_REQUEST, - Json(ApiError::new("MISSING_REPO_URL", "No repository URL found. Either provide one or ensure the contract has repositories configured.")), - ).into_response(); - } - - // Create task request - // All tasks share the supervisor's worktree - let supervisor_worktree_task_id = Some(supervisor_id); - - let create_req = CreateTaskRequest { - name: request.name.clone(), - description: None, - plan: request.plan.clone(), - repository_url: repo_url.clone(), - contract_id: Some(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, - branched_from_task_id: None, - conversation_history: None, - supervisor_worktree_task_id, - directive_id: None, - directive_step_id: 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" - ); - - // Record history event for task spawned by supervisor - let _ = repository::record_history_event( - pool, - owner_id, - task.contract_id, - Some(task.id), - "task", - Some("spawned"), - None, - serde_json::json!({ - "name": &task.name, - "spawnedBy": supervisor_id.to_string(), - }), - ).await; - - // Broadcast task creation notification to WebSocket subscribers - state.broadcast_task_update(TaskUpdateNotification { - task_id: task.id, - owner_id: Some(owner_id), - version: task.version, - status: task.status.clone(), - updated_fields: vec!["created".to_string()], - updated_by: "supervisor".to_string(), - }); - - // Start task on a daemon - // Find a daemon that belongs to this owner - let mut updated_task = task; - for entry in state.daemon_connections.iter() { - let daemon = entry.value(); - if daemon.owner_id == owner_id { - // IMPORTANT: Update database FIRST to assign daemon_id before sending command - // This prevents race conditions where the task starts but daemon_id is not set - let update_req = UpdateTaskRequest { - status: Some("starting".to_string()), - daemon_id: Some(daemon.id), - version: Some(updated_task.version), - ..Default::default() - }; - - match repository::update_task_for_owner(pool, updated_task.id, owner_id, update_req).await { - Ok(Some(t)) => { - updated_task = t; - } - Ok(None) => { - tracing::warn!(task_id = %updated_task.id, "Task not found when updating daemon_id"); - break; - } - Err(e) => { - tracing::error!(task_id = %updated_task.id, error = %e, "Failed to update task with daemon_id"); - break; - } - } - - // Send spawn command to daemon - let cmd = DaemonCommand::SpawnTask { - task_id: updated_task.id, - task_name: updated_task.name.clone(), - plan: updated_task.plan.clone(), - repo_url: repo_url.clone(), - base_branch: updated_task.base_branch.clone(), - target_branch: updated_task.target_branch.clone(), - parent_task_id: updated_task.parent_task_id, - depth: updated_task.depth, - is_orchestrator: false, - target_repo_path: updated_task.target_repo_path.clone(), - completion_action: updated_task.completion_action.clone(), - continue_from_task_id: updated_task.continue_from_task_id, - copy_files: updated_task.copy_files.as_ref().and_then(|v| serde_json::from_value(v.clone()).ok()), - contract_id: updated_task.contract_id, - is_supervisor: false, - autonomous_loop: false, - resume_session: false, - conversation_history: None, - patch_data: None, - patch_base_sha: None, - local_only: contract.local_only, - auto_merge_local: contract.auto_merge_local, - // All tasks share the supervisor's worktree - supervisor_worktree_task_id: Some(supervisor_id), - directive_id: updated_task.directive_id, - }; - - 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"); - // Rollback: clear daemon_id and reset status since command failed - let rollback_req = UpdateTaskRequest { - status: Some("pending".to_string()), - clear_daemon_id: true, - ..Default::default() - }; - let _ = repository::update_task_for_owner(pool, updated_task.id, owner_id, rollback_req).await; - } else { - tracing::info!(task_id = %updated_task.id, daemon_id = %daemon.id, repo_url = ?repo_url, "Task spawn command sent"); - - // Save state: task spawn is a key save point (Task 3.3) - save_state_on_task_spawn(pool, request.contract_id, updated_task.id).await; - - // Broadcast task status update notification to WebSocket subscribers - state.broadcast_task_update(TaskUpdateNotification { - task_id: updated_task.id, - owner_id: Some(owner_id), - version: updated_task.version, - status: "starting".to_string(), - updated_fields: vec!["status".to_string(), "daemon_id".to_string()], - updated_by: "supervisor".to_string(), - }); - - } - break; - } - } - - (StatusCode::CREATED, Json(updated_task)).into_response() -} - // ============================================================================= -// Wait for Task Handler +// Question types // ============================================================================= -/// 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(); - } - - // Get contract_id for pending task scheduling - let contract_id = task.contract_id; - - // 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, periodically trying to start pending tasks - let result = tokio::time::timeout(timeout, async { - let mut pending_check_interval = tokio::time::interval(tokio::time::Duration::from_secs(5)); - pending_check_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - - loop { - tokio::select! { - // Check for task completion notifications - recv_result = rx.recv() => { - match recv_result { - 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, - }); - } - } - } - } - } - // Periodically try to start pending tasks - _ = pending_check_interval.tick() => { - if let Some(cid) = contract_id { - match try_start_pending_task(&state, cid, owner_id).await { - Ok(Some(started_task)) => { - tracing::debug!( - task_id = %started_task.id, - task_name = %started_task.name, - "Started pending task while waiting" - ); - } - Ok(None) => { - // No pending tasks or no capacity - that's fine - } - Err(e) => { - tracing::warn!(error = %e, "Error trying to start pending task"); - } - } - } - } - } - } - }).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 = 202, description = "Checkpoint creation accepted", body = CheckpointResponse), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden - can only create checkpoint for own task"), - (status = 404, description = "Task not found"), - (status = 500, description = "Internal server error"), - (status = 503, description = "Task has no assigned daemon"), - ), - 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 and daemon_id - 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(); - } - }; - - 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 CreateCheckpoint command to daemon - let cmd = DaemonCommand::CreateCheckpoint { - task_id, - message: request.message.clone(), - }; - - if let Err(e) = state.send_daemon_command(daemon_id, cmd).await { - tracing::error!(error = %e, "Failed to send CreateCheckpoint command"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("COMMAND_FAILED", "Failed to send command to daemon")), - ).into_response(); - } - - // Return accepted - the checkpoint result will be delivered via WebSocket - // and stored in the database by the daemon message handler - ( - StatusCode::ACCEPTED, - Json(CheckpointResponse { - task_id, - checkpoint_number: 0, // Will be assigned by DB on actual creation - commit_sha: "pending".to_string(), - message: request.message, - }), - ).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>, +pub struct AskQuestionRequest { + pub question: String, + #[serde(default)] + pub choices: Vec<String>, + pub context: Option<String>, + #[serde(default = "default_question_timeout")] + pub timeout_seconds: i32, + /// When true the request blocks until the user responds (no + /// timeout) — the CLI reconnects via the poll endpoint if the + /// server-side timeout is reached. + #[serde(default)] + pub phaseguard: bool, + #[serde(default)] + pub multi_select: bool, + /// Return immediately without waiting for a response. + #[serde(default)] + pub non_blocking: bool, + /// Question type: general, phase_confirmation, contract_complete. + #[serde(default = "default_question_type")] + pub question_type: 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, +fn default_question_type() -> String { + "general".to_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, +fn default_question_timeout() -> i32 { + 3600 } -/// Response for merge operation. -#[derive(Debug, Serialize, ToSchema)] +#[derive(Debug, Serialize, Deserialize, 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>>, +pub struct AskQuestionResponse { + pub question_id: Uuid, + pub response: Option<String>, + pub timed_out: bool, + /// Server-side timeout was reached but the question is still + /// pending. CLI should re-poll via `/poll`. + #[serde(default)] + pub still_pending: bool, } -/// Request to create a pull request. #[derive(Debug, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct CreatePRRequest { - pub branch: String, - pub title: String, - pub body: Option<String>, +pub struct AnswerQuestionRequest { + pub response: String, } -/// Response for PR creation. #[derive(Debug, Serialize, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct CreatePRResponse { - pub task_id: Uuid, +pub struct AnswerQuestionResponse { 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 struct PendingQuestionSummary { + pub question_id: Uuid, 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(); - }; - - // Subscribe to merge results BEFORE sending the command - let mut rx = state.merge_results.subscribe(); - - // 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(); - } - - // Wait for the merge result with a timeout (60 seconds should be plenty for a merge) - let timeout = tokio::time::Duration::from_secs(60); - let result = tokio::time::timeout(timeout, async { - loop { - match rx.recv().await { - Ok(notification) => { - if notification.task_id == task_id { - return Some(notification); - } - // Not our task, keep waiting - } - Err(_) => { - // Channel closed or lagged - return None; - } - } - } - }).await; - - match result { - Ok(Some(notification)) => { - ( - StatusCode::OK, - Json(MergeTaskResponse { - task_id, - success: notification.success, - message: notification.message, - commit_sha: notification.commit_sha, - conflicts: notification.conflicts, - }), - ).into_response() - } - Ok(None) | Err(_) => { - // Timeout or channel error - return error status - ( - StatusCode::GATEWAY_TIMEOUT, - Json(MergeTaskResponse { - task_id, - success: false, - message: "Merge operation timed out waiting for daemon response".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 supervisor's own task to find daemon and base_branch - let task = match repository::get_task(pool, supervisor_id).await { - Ok(Some(t)) => t, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Supervisor task not found")), - ).into_response(); - } - Err(e) => { - tracing::error!(error = %e, "Failed to get supervisor task"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", "Failed to get supervisor task")), - ).into_response(); - } - }; - - // Get daemon running the supervisor - let Some(daemon_id) = task.daemon_id else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("NO_DAEMON", "Supervisor has no assigned daemon")), - ).into_response(); - }; - - // Subscribe to PR results BEFORE sending the command - let mut rx = state.pr_results.subscribe(); - - // Send CreatePR command to daemon using the supervisor's task ID - // (the branch is in the supervisor's worktree) - // Pass base_branch from task if available, otherwise daemon will auto-detect - let cmd = DaemonCommand::CreatePR { - task_id: supervisor_id, - title: request.title.clone(), - body: request.body.clone(), - base_branch: task.base_branch.clone(), - branch: request.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(); - } - - // Wait for the PR result with a timeout (60 seconds should be plenty for PR creation) - let timeout = tokio::time::Duration::from_secs(60); - let result = tokio::time::timeout(timeout, async { - loop { - match rx.recv().await { - Ok(notification) => { - if notification.task_id == supervisor_id { - return Some(notification); - } - // Not our task, keep waiting - } - Err(_) => { - // Channel closed or lagged - return None; - } - } - } - }).await; - - match result { - Ok(Some(notification)) => { - let status = if notification.success { - StatusCode::CREATED - } else { - StatusCode::INTERNAL_SERVER_ERROR - }; - ( - status, - Json(CreatePRResponse { - task_id: supervisor_id, - success: notification.success, - message: notification.message, - pr_url: notification.pr_url, - pr_number: notification.pr_number, - }), - ).into_response() - } - Ok(None) | Err(_) => { - // Timeout or channel error - return error status - ( - StatusCode::GATEWAY_TIMEOUT, - Json(CreatePRResponse { - task_id: supervisor_id, - success: false, - message: "PR creation timed out waiting for daemon response".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() + #[serde(skip_serializing_if = "Option::is_none")] + pub directive_id: Option<Uuid>, + pub question: String, + pub choices: Vec<String>, + pub context: Option<String>, + pub created_at: chrono::DateTime<chrono::Utc>, + #[serde(default)] + pub multi_select: bool, + #[serde(default)] + pub question_type: String, } // ============================================================================= -// Supervisor Question Handlers +// Question handlers // ============================================================================= -/// Ask a question and wait for user feedback. -/// -/// The supervisor calls this to ask a question. The endpoint will poll until -/// either the user responds or the timeout is reached. +/// Ask the user a question from a directive task. Blocks until the user +/// answers, the timeout fires, or `non_blocking` returns immediately. #[utoipa::path( post, path = "/api/v1/mesh/supervisor/questions", request_body = AskQuestionRequest, responses( - (status = 200, description = "Question answered", body = AskQuestionResponse), - (status = 408, description = "Question timed out", body = AskQuestionResponse), + (status = 200, description = "Question asked", body = AskQuestionResponse), (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden - not a supervisor"), - (status = 500, description = "Internal server error"), - ), - security( - ("tool_key" = []) + (status = 403, description = "Not a directive task"), ), + security(("tool_key" = [])), tag = "Mesh Supervisor" )] pub async fn ask_question( @@ -1676,67 +184,49 @@ pub async fn ask_question( headers: HeaderMap, Json(request): Json<AskQuestionRequest>, ) -> impl IntoResponse { - let (supervisor_id, owner_id) = match verify_supervisor_auth(&state, &headers, None).await { + let (task_id, owner_id) = match verify_task_auth(&state, &headers).await { Ok(ids) => ids, Err(e) => return e.into_response(), }; let pool = state.db_pool.as_ref().unwrap(); - // Get the supervisor task to find its contract - let supervisor = match repository::get_task_for_owner(pool, supervisor_id, owner_id).await { + // Pull the directive_id off the calling task so subscribers can + // route the question to the right directive view. + 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", "Supervisor task not found")), - ).into_response(); + Json(ApiError::new("NOT_FOUND", "Task not found")), + ) + .into_response(); } Err(e) => { - tracing::error!(error = %e, "Failed to get supervisor task"); + tracing::error!(error = %e, "Failed to fetch task"); return ( StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", "Failed to get supervisor task")), - ).into_response(); + Json(ApiError::new("DB_ERROR", "Failed to fetch task")), + ) + .into_response(); } }; - // Determine context: contract or directive - let contract_id = supervisor.contract_id; - let directive_id = supervisor.directive_id; + let directive_id = task.directive_id; - if contract_id.is_none() && directive_id.is_none() { - return ( - StatusCode::BAD_REQUEST, - Json(ApiError::new("NO_CONTEXT", "Supervisor has no associated contract or directive")), - ).into_response(); - } - - let is_directive_context = directive_id.is_some() && contract_id.is_none(); - - // For directive context, check reconcile_mode to determine behavior - let directive_reconcile_mode: String = if let Some(did) = directive_id { - if is_directive_context { - match repository::get_directive_for_owner(pool, owner_id, did).await { - Ok(Some(d)) => d.reconcile_mode.clone(), - Ok(None) => "auto".to_string(), - Err(e) => { - tracing::warn!(error = %e, "Failed to get directive for reconcile_mode check"); - "auto".to_string() - } - } - } else { - "auto".to_string() - } - } else { - "auto".to_string() + // Reconcile mode controls block-vs-timeout behaviour on directive + // tasks: semi-auto / manual block indefinitely (effectively + // phaseguard); auto times out after 30s. + let reconcile_mode: String = match directive_id { + Some(did) => match repository::get_directive_for_owner(pool, owner_id, did).await { + Ok(Some(d)) => d.reconcile_mode.clone(), + _ => "auto".to_string(), + }, + None => "auto".to_string(), }; - // Add the question (use Uuid::nil() for contract_id in directive-only context) - let effective_contract_id = contract_id.unwrap_or(Uuid::nil()); - let question_id = state.add_supervisor_question_with_directive( - supervisor_id, - effective_contract_id, + let question_id = state.add_supervisor_question( + task_id, directive_id, owner_id, request.question.clone(), @@ -1746,60 +236,6 @@ pub async fn ask_question( request.question_type.clone(), ); - // Save state: question asked is a key save point (Task 3.3) - // Only for contract context — directive tasks don't use supervisor_states table - if let Some(cid) = contract_id { - let pending_question = PendingQuestion { - id: question_id, - question: request.question.clone(), - choices: request.choices.clone(), - context: request.context.clone(), - question_type: request.question_type.clone(), - asked_at: chrono::Utc::now(), - }; - save_state_on_question_asked(pool, cid, pending_question).await; - } - - // Broadcast question as task output entry for the task's chat - let question_data = serde_json::json!({ - "question_id": question_id.to_string(), - "choices": request.choices, - "context": request.context, - "multi_select": request.multi_select, - "question_type": request.question_type, - }); - state.broadcast_task_output(TaskOutputNotification { - task_id: supervisor_id, - owner_id: Some(owner_id), - message_type: "supervisor_question".to_string(), - content: request.question.clone(), - tool_name: None, - tool_input: Some(question_data.clone()), - is_error: None, - cost_usd: None, - duration_ms: None, - is_partial: false, - }); - - // Persist to database so it appears when reloading the page - // Use event_type "output" with messageType "supervisor_question" to match TaskOutputEntry format - if let Some(pool) = state.db_pool.as_ref() { - let event_data = serde_json::json!({ - "messageType": "supervisor_question", - "content": request.question, - "toolInput": question_data, - }); - let _ = repository::create_task_event( - pool, - supervisor_id, - "output", - None, - None, - Some(event_data), - ).await; - } - - // If non_blocking mode, return immediately if request.non_blocking { return ( StatusCode::OK, @@ -1809,41 +245,28 @@ pub async fn ask_question( timed_out: false, still_pending: false, }), - ).into_response(); + ) + .into_response(); } - // Determine if we should block indefinitely (phaseguard or directive reconcile mode) - let use_phaseguard = request.phaseguard || (is_directive_context && (directive_reconcile_mode == "semi-auto" || directive_reconcile_mode == "manual")); - - // Poll for response with timeout - // - Phaseguard: block indefinitely until user responds - // - Directive tasks without reconcile mode: 30s default timeout - // - Contract tasks: use requested timeout_seconds + // Determine block behaviour. + let use_phaseguard = + request.phaseguard || reconcile_mode == "semi-auto" || reconcile_mode == "manual"; let timeout_secs = if use_phaseguard { - // Cap at 5 minutes per HTTP request (well under Claude Code's 10-min limit). - // The CLI will automatically reconnect via the poll endpoint. 300 - } else if is_directive_context && directive_reconcile_mode == "auto" { + } else if reconcile_mode == "auto" { 30 } else { request.timeout_seconds.max(1) as u64 }; + let timeout_duration = std::time::Duration::from_secs(timeout_secs); let start = std::time::Instant::now(); let poll_interval = std::time::Duration::from_millis(500); loop { - // Check if response has been submitted if let Some(response) = state.get_question_response(question_id) { - // Clean up the response state.cleanup_question_response(question_id); - - // Clear pending question from supervisor state (Task 3.3) - // Skip for directive context — no supervisor_states for directives - if let Some(cid) = contract_id { - clear_pending_question(pool, cid, question_id).await; - } - return ( StatusCode::OK, Json(AskQuestionResponse { @@ -1852,14 +275,12 @@ pub async fn ask_question( timed_out: false, still_pending: false, }), - ).into_response(); + ) + .into_response(); } - // Check timeout if start.elapsed() >= timeout_duration { if use_phaseguard { - // Phaseguard/reconcile: DON'T remove the pending question. - // Return still_pending so the CLI can reconnect via the poll endpoint. return ( StatusCode::OK, Json(AskQuestionResponse { @@ -1868,18 +289,10 @@ pub async fn ask_question( timed_out: false, still_pending: true, }), - ).into_response(); + ) + .into_response(); } - - // Non-phaseguard: remove the pending question on timeout state.remove_pending_question(question_id); - - // Clear pending question from supervisor state on timeout (Task 3.3) - // Skip for directive context — no supervisor_states for directives - if let Some(cid) = contract_id { - clear_pending_question(pool, cid, question_id).await; - } - return ( StatusCode::REQUEST_TIMEOUT, Json(AskQuestionResponse { @@ -1888,34 +301,25 @@ pub async fn ask_question( timed_out: true, still_pending: false, }), - ).into_response(); + ) + .into_response(); } - // Wait before polling again tokio::time::sleep(poll_interval).await; } } -/// Poll for a question response by question_id. -/// -/// Used by the CLI to reconnect after a still_pending response from ask_question. -/// Blocks for up to 5 minutes polling every 500ms. Returns still_pending if timeout -/// is reached without a response, allowing the CLI to reconnect again. +/// Re-poll a question by id. Used by the CLI to reconnect after +/// `still_pending` from `ask_question`. Blocks up to 5 minutes. #[utoipa::path( get, path = "/api/v1/mesh/supervisor/questions/{question_id}/poll", - params( - ("question_id" = Uuid, Path, description = "The question ID to poll for"), - ), + params(("question_id" = Uuid, Path, description = "Question id")), responses( - (status = 200, description = "Question answered or still pending", body = AskQuestionResponse), - (status = 404, description = "Question not found"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden - not a supervisor"), - ), - security( - ("tool_key" = []) + (status = 200, description = "Answered or still pending", body = AskQuestionResponse), + (status = 404, description = "Not found"), ), + security(("tool_key" = [])), tag = "Mesh Supervisor" )] pub async fn poll_question( @@ -1923,23 +327,16 @@ pub async fn poll_question( headers: HeaderMap, Path(question_id): Path<Uuid>, ) -> impl IntoResponse { - let (supervisor_id, owner_id) = match verify_supervisor_auth(&state, &headers, None).await { - Ok(ids) => ids, - Err(e) => return e.into_response(), - }; + if verify_task_auth(&state, &headers).await.is_err() { + return ( + StatusCode::UNAUTHORIZED, + Json(ApiError::new("UNAUTHORIZED", "Tool key required")), + ) + .into_response(); + } - // Check if a response is already available if let Some(response) = state.get_question_response(question_id) { state.cleanup_question_response(question_id); - - // Clear pending question from supervisor state for contract context - let pool = state.db_pool.as_ref().unwrap(); - if let Ok(Some(task)) = repository::get_task_for_owner(pool, supervisor_id, owner_id).await { - if let Some(cid) = task.contract_id { - clear_pending_question(pool, cid, question_id).await; - } - } - return ( StatusCode::OK, Json(AskQuestionResponse { @@ -1948,35 +345,25 @@ pub async fn poll_question( timed_out: false, still_pending: false, }), - ).into_response(); + ) + .into_response(); } - // Check if the question exists at all (pending or response) - if !state.has_pending_question(question_id) { + if state.get_pending_question(question_id).is_none() { return ( StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Question not found or already answered")), - ).into_response(); + Json(ApiError::new("NOT_FOUND", "Question not found")), + ) + .into_response(); } - // Block for up to 5 minutes polling every 500ms - let timeout_duration = std::time::Duration::from_secs(300); + let timeout = std::time::Duration::from_secs(300); let start = std::time::Instant::now(); let poll_interval = std::time::Duration::from_millis(500); loop { - // Check if response has been submitted if let Some(response) = state.get_question_response(question_id) { state.cleanup_question_response(question_id); - - // Clear pending question from supervisor state for contract context - let pool = state.db_pool.as_ref().unwrap(); - if let Ok(Some(task)) = repository::get_task_for_owner(pool, supervisor_id, owner_id).await { - if let Some(cid) = task.contract_id { - clear_pending_question(pool, cid, question_id).await; - } - } - return ( StatusCode::OK, Json(AskQuestionResponse { @@ -1985,19 +372,10 @@ pub async fn poll_question( timed_out: false, still_pending: false, }), - ).into_response(); - } - - // Check if the question was removed (e.g., task deleted) - if !state.has_pending_question(question_id) { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Question no longer exists")), - ).into_response(); + ) + .into_response(); } - - // Check timeout - if start.elapsed() >= timeout_duration { + if start.elapsed() >= timeout { return ( StatusCode::OK, Json(AskQuestionResponse { @@ -2006,27 +384,21 @@ pub async fn poll_question( timed_out: false, still_pending: true, }), - ).into_response(); + ) + .into_response(); } - - // Wait before polling again tokio::time::sleep(poll_interval).await; } } -/// Get all pending questions for the current user. +/// List currently-pending questions for the caller. #[utoipa::path( get, path = "/api/v1/mesh/questions", responses( - (status = 200, description = "List of pending questions", body = Vec<PendingQuestionSummary>), - (status = 401, description = "Unauthorized"), - (status = 500, description = "Internal server error"), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) + (status = 200, description = "Pending questions", body = Vec<PendingQuestionSummary>), ), + security(("bearer_auth" = []), ("api_key" = [])), tag = "Mesh" )] pub async fn list_pending_questions( @@ -2039,7 +411,6 @@ pub async fn list_pending_questions( .map(|q| PendingQuestionSummary { question_id: q.question_id, task_id: q.task_id, - contract_id: q.contract_id, directive_id: q.directive_id, question: q.question, choices: q.choices, @@ -2053,1051 +424,59 @@ pub async fn list_pending_questions( Json(questions).into_response() } -/// Answer a pending supervisor question. +/// Answer a pending question. #[utoipa::path( post, path = "/api/v1/mesh/questions/{question_id}/answer", - params( - ("question_id" = Uuid, Path, description = "Question ID") - ), + params(("question_id" = Uuid, Path, description = "Question id")), request_body = AnswerQuestionRequest, responses( - (status = 200, description = "Question answered", body = AnswerQuestionResponse), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Question not found"), - (status = 500, description = "Internal server error"), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) + (status = 200, description = "Answered", body = AnswerQuestionResponse), + (status = 404, description = "Not found"), ), + security(("bearer_auth" = []), ("api_key" = [])), tag = "Mesh" )] pub async fn answer_question( State(state): State<SharedState>, Authenticated(auth): Authenticated, Path(question_id): Path<Uuid>, - Json(request): Json<AnswerQuestionRequest>, + Json(req): Json<AnswerQuestionRequest>, ) -> impl IntoResponse { - // Verify the question exists and belongs to this owner + // Ownership check: only the owner of the question can answer it. let question = match state.get_pending_question(question_id) { - Some(q) if q.owner_id == auth.owner_id => q, - Some(_) => { - return ( - StatusCode::FORBIDDEN, - Json(ApiError::new("FORBIDDEN", "Question belongs to another user")), - ).into_response(); - } - None => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Question not found or already answered")), - ).into_response(); - } - }; - - // Submit the response - let success = state.submit_question_response(question_id, request.response.clone()); - - if success { - tracing::info!( - question_id = %question_id, - task_id = %question.task_id, - "User answered supervisor question" - ); - - // Send the response to the task as a message - // This will auto-resume the task if it was paused (phaseguard) - let pool = state.db_pool.as_ref().unwrap(); - if let Ok(Some(task)) = repository::get_task_for_owner(pool, question.task_id, auth.owner_id).await { - if let Some(daemon_id) = task.daemon_id { - // Format the response message - let response_msg = format!( - "\n[User Response to Question]\nQuestion: {}\nAnswer: {}\n", - question.question, - request.response - ); - let cmd = DaemonCommand::SendMessage { - task_id: question.task_id, - message: response_msg, - }; - if let Err(e) = state.send_daemon_command(daemon_id, cmd).await { - tracing::warn!( - task_id = %question.task_id, - error = %e, - "Failed to send response message to task" - ); - } else { - tracing::info!( - task_id = %question.task_id, - "Sent response message to task (will auto-resume if paused)" - ); - } - } - } - } - - Json(AnswerQuestionResponse { success }).into_response() -} - -// ============================================================================= -// Supervisor Resume and Conversation Rewind -// ============================================================================= - -/// Response for supervisor resume -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ResumeSupervisorResponse { - pub supervisor_task_id: Uuid, - pub daemon_id: Option<Uuid>, - pub resumed_from: ResumedFromInfo, - pub status: String, - /// Restoration context (Task 3.4) - pub restoration: Option<RestorationInfo>, -} - -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ResumedFromInfo { - pub phase: String, - pub last_activity: chrono::DateTime<chrono::Utc>, - pub message_count: i32, -} - -/// Information about supervisor restoration (Task 3.4) -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct RestorationInfo { - /// Previous state before restoration - pub previous_state: String, - /// How many times this supervisor has been restored - pub restoration_count: i32, - /// Number of pending questions to re-deliver - pub pending_questions_count: usize, - /// Number of tasks being waited on - pub waiting_tasks_count: usize, - /// Number of tasks spawned before crash - pub spawned_tasks_count: usize, - /// Any warnings from state validation - pub warnings: Vec<String>, -} - -/// Resume interrupted supervisor with specified mode. -/// -/// POST /api/v1/contracts/{id}/supervisor/resume -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/supervisor/resume", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - request_body = crate::db::models::ResumeSupervisorRequest, - responses( - (status = 200, description = "Supervisor resumed", body = ResumeSupervisorResponse), - (status = 400, description = "Invalid request", body = ApiError), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract or supervisor not found", body = ApiError), - (status = 409, description = "Supervisor is already running", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Mesh Supervisor" -)] -pub async fn resume_supervisor( - State(state): State<SharedState>, - Path(contract_id): Path<Uuid>, - auth: crate::server::auth::Authenticated, - Json(req): Json<crate::db::models::ResumeSupervisorRequest>, -) -> impl IntoResponse { - let crate::server::auth::Authenticated(auth_info) = auth; - - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Get contract and verify ownership - let contract = match repository::get_contract_for_owner(pool, contract_id, auth_info.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 {}: {}", contract_id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Get existing supervisor state - let supervisor_state = match repository::get_supervisor_state(pool, contract_id).await { - Ok(Some(s)) => s, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new( - "NO_SUPERVISOR_STATE", - "No supervisor state found - supervisor may not have been started", - )), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get supervisor state: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Get supervisor task - let supervisor_task = match repository::get_task_for_owner(pool, supervisor_state.task_id, auth_info.owner_id).await { - Ok(Some(t)) => t, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Supervisor task not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get supervisor task: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Check if already running - but only if daemon is actually connected - // (daemon disconnect handler may not have updated status yet) - if supervisor_task.status == "running" { - let daemon_connected = supervisor_task - .daemon_id - .map(|d| state.is_daemon_connected(d)) - .unwrap_or(false); - - if daemon_connected { - return ( - StatusCode::CONFLICT, - Json(ApiError::new("ALREADY_RUNNING", "Supervisor is already running")), - ) - .into_response(); - } - // Daemon not connected - allow resume (treat as interrupted) - tracing::info!( - supervisor_task_id = %supervisor_task.id, - daemon_id = ?supervisor_task.daemon_id, - "Supervisor status is 'running' but daemon is not connected, allowing resume" - ); - } - - // Calculate message count from conversation history - let message_count = supervisor_state - .conversation_history - .as_array() - .map(|arr| arr.len() as i32) - .unwrap_or(0); - - // Find a connected daemon for this owner - let target_daemon_id = match state.find_alternative_daemon(auth_info.owner_id, &[]) { - Some(id) => id, + Some(q) => q, None => { return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new( - "NO_DAEMON", - "No daemons connected for your account. Cannot resume supervisor.", - )), - ) - .into_response(); - } - }; - - // Track response values (may be updated by resume modes) - let mut response_daemon_id = supervisor_task.daemon_id; - let mut response_status = "pending".to_string(); - - // Based on resume mode, handle differently - match req.resume_mode.as_str() { - "continue" => { - // Update task status to starting and assign daemon - if let Err(e) = sqlx::query("UPDATE tasks SET status = 'starting', daemon_id = $1 WHERE id = $2") - .bind(target_daemon_id) - .bind(supervisor_state.task_id) - .execute(pool) - .await - { - tracing::error!("Failed to update task for resume: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - - // Fetch latest checkpoint patch for worktree recovery - let (patch_data, patch_base_sha) = match repository::get_latest_checkpoint_patch(pool, supervisor_state.task_id).await { - Ok(Some(patch)) => { - tracing::info!( - task_id = %supervisor_state.task_id, - patch_size = patch.patch_size_bytes, - base_sha = %patch.base_commit_sha, - "Including checkpoint patch for worktree recovery" - ); - // Encode patch as base64 for JSON transport - let encoded = base64::engine::general_purpose::STANDARD.encode(&patch.patch_data); - (Some(encoded), Some(patch.base_commit_sha)) - } - Ok(None) => { - tracing::debug!(task_id = %supervisor_state.task_id, "No checkpoint patch found"); - (None, None) - } - Err(e) => { - tracing::warn!(task_id = %supervisor_state.task_id, error = %e, "Failed to fetch checkpoint patch"); - (None, None) - } - }; - - // Send SpawnTask with resume_session=true to use Claude's --continue - // Include conversation_history as fallback if worktree doesn't exist on target daemon - let command = DaemonCommand::SpawnTask { - task_id: supervisor_state.task_id, - task_name: supervisor_task.name.clone(), - plan: supervisor_task.plan.clone(), - repo_url: supervisor_task.repository_url.clone(), - base_branch: supervisor_task.base_branch.clone(), - target_branch: supervisor_task.target_branch.clone(), - parent_task_id: supervisor_task.parent_task_id, - depth: supervisor_task.depth, - is_orchestrator: false, - target_repo_path: supervisor_task.target_repo_path.clone(), - completion_action: supervisor_task.completion_action.clone(), - continue_from_task_id: supervisor_task.continue_from_task_id, - copy_files: supervisor_task.copy_files.as_ref().and_then(|v| serde_json::from_value(v.clone()).ok()), - contract_id: supervisor_task.contract_id, - is_supervisor: true, - autonomous_loop: false, - resume_session: true, // Use --continue to preserve conversation - conversation_history: Some(supervisor_state.conversation_history.clone()), // Fallback if worktree missing - patch_data, - patch_base_sha, - local_only: contract.local_only, - auto_merge_local: contract.auto_merge_local, - supervisor_worktree_task_id: None, // Supervisor uses its own worktree - directive_id: supervisor_task.directive_id, - }; - - if let Err(e) = state.send_daemon_command(target_daemon_id, command).await { - // Rollback status on failure - let _ = sqlx::query("UPDATE tasks SET status = 'interrupted', daemon_id = NULL WHERE id = $1") - .bind(supervisor_state.task_id) - .execute(pool) - .await; - tracing::error!("Failed to send SpawnTask to daemon: {}", e); - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DAEMON_ERROR", format!("Failed to send to daemon: {}", e))), - ) - .into_response(); - } - - tracing::info!( - contract_id = %contract_id, - supervisor_task_id = %supervisor_state.task_id, - daemon_id = %target_daemon_id, - message_count = message_count, - "Supervisor resumed with --continue (resume_session=true)" - ); - - // Update response values for successful resume - response_daemon_id = Some(target_daemon_id); - response_status = "starting".to_string(); - } - "restart_phase" => { - // Clear conversation but keep phase progress - if let Err(e) = repository::update_supervisor_conversation( - pool, - contract_id, - serde_json::json!([]), - ) - .await - { - tracing::error!("Failed to clear conversation: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - - if let Err(e) = sqlx::query("UPDATE tasks SET status = 'pending' WHERE id = $1") - .bind(supervisor_state.task_id) - .execute(pool) - .await - { - tracing::error!("Failed to update task status: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - "from_checkpoint" => { - // This would require more complex handling with checkpoint system - return ( - StatusCode::BAD_REQUEST, - Json(ApiError::new( - "NOT_IMPLEMENTED", - "from_checkpoint mode not yet implemented", - )), - ) - .into_response(); - } - _ => { - return ( - StatusCode::BAD_REQUEST, - Json(ApiError::new( - "INVALID_RESUME_MODE", - "Invalid resume_mode. Use: continue, restart_phase, or from_checkpoint", - )), - ) - .into_response(); - } - } - - tracing::info!( - contract_id = %contract_id, - supervisor_task_id = %supervisor_state.task_id, - resume_mode = %req.resume_mode, - message_count = message_count, - "Supervisor resume requested" - ); - - // Build restoration info (Task 3.4) - let pending_questions: Vec<PendingQuestion> = serde_json::from_value( - supervisor_state.pending_questions.clone() - ).unwrap_or_default(); - - let restoration_info = RestorationInfo { - previous_state: supervisor_state.state.clone(), - restoration_count: supervisor_state.restoration_count, - pending_questions_count: pending_questions.len(), - waiting_tasks_count: supervisor_state.pending_task_ids.len(), - spawned_tasks_count: supervisor_state.spawned_task_ids.len(), - warnings: vec![], // Could add validation warnings here - }; - - // Re-deliver pending questions if any (Task 3.4) - if !pending_questions.is_empty() { - redeliver_pending_questions( - &state, - supervisor_state.task_id, - contract_id, - auth_info.owner_id, - &pending_questions, - ).await; - } - - Json(ResumeSupervisorResponse { - supervisor_task_id: supervisor_state.task_id, - daemon_id: response_daemon_id, - resumed_from: ResumedFromInfo { - phase: contract.phase, - last_activity: supervisor_state.last_activity, - message_count, - }, - status: response_status, - restoration: Some(restoration_info), - }) - .into_response() -} - -/// Response for conversation rewind -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct RewindConversationResponse { - pub contract_id: Uuid, - pub messages_removed: i32, - pub new_message_count: i32, - pub code_rewound: bool, -} - -/// Rewind supervisor conversation to specified point. -/// -/// POST /api/v1/contracts/{id}/supervisor/conversation/rewind -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/supervisor/conversation/rewind", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - request_body = crate::db::models::RewindConversationRequest, - responses( - (status = 200, description = "Conversation rewound", body = RewindConversationResponse), - (status = 400, description = "Invalid request", body = ApiError), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract or supervisor 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 = "Mesh Supervisor" -)] -pub async fn rewind_conversation( - State(state): State<SharedState>, - Path(contract_id): Path<Uuid>, - auth: crate::server::auth::Authenticated, - Json(req): Json<crate::db::models::RewindConversationRequest>, -) -> impl IntoResponse { - let crate::server::auth::Authenticated(auth_info) = auth; - - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Get contract and verify ownership - let _contract = match repository::get_contract_for_owner(pool, contract_id, auth_info.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 {}: {}", contract_id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), + Json(ApiError::new("NOT_FOUND", "Question not found")), ) .into_response(); } }; - - // Get supervisor state - let supervisor_state = match repository::get_supervisor_state(pool, contract_id).await { - Ok(Some(s)) => s, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Supervisor state not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get supervisor state: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - let conversation = supervisor_state - .conversation_history - .as_array() - .cloned() - .unwrap_or_default(); - - let original_count = conversation.len() as i32; - - // Determine how many messages to keep - let new_count = if let Some(by_count) = req.by_message_count { - (original_count - by_count).max(0) - } else if let Some(ref to_id) = req.to_message_id { - // Find message by ID and keep up to and including it - let index = conversation - .iter() - .position(|msg| msg.get("id").and_then(|v| v.as_str()) == Some(to_id.as_str())) - .map(|i| i as i32) - .unwrap_or(original_count - 1); - (index + 1).min(original_count).max(0) - } else { - // Default to removing last message - (original_count - 1).max(0) - }; - - // Truncate conversation - let new_conversation: Vec<serde_json::Value> = conversation - .into_iter() - .take(new_count as usize) - .collect(); - - // Update the conversation - if let Err(e) = repository::update_supervisor_conversation( - pool, - contract_id, - serde_json::Value::Array(new_conversation), - ) - .await - { - tracing::error!("Failed to update conversation: {}", e); + if question.owner_id != auth.owner_id { return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), + StatusCode::FORBIDDEN, + Json(ApiError::new("FORBIDDEN", "Not your question")), ) .into_response(); } - tracing::info!( - contract_id = %contract_id, - original_count = original_count, - new_count = new_count, - messages_removed = original_count - new_count, - "Conversation rewound" - ); - - Json(RewindConversationResponse { - contract_id, - messages_removed: original_count - new_count, - new_message_count: new_count, - code_rewound: req.rewind_code.unwrap_or(false), // TODO: implement code rewind - }) - .into_response() -} - -// ============================================================================= -// Supervisor State Persistence Helpers (Task 3.3) -// ============================================================================= - -use crate::db::models::{ - SupervisorRestorationContext, SupervisorStateEnum, - StateValidationResult, StateRecoveryAction, -}; - -/// Save supervisor state on task spawn. -/// This is called when a supervisor spawns a new task. -pub async fn save_state_on_task_spawn( - pool: &PgPool, - contract_id: Uuid, - spawned_task_id: Uuid, -) { - if let Err(e) = repository::add_supervisor_spawned_task(pool, contract_id, spawned_task_id).await { - tracing::warn!( - contract_id = %contract_id, - spawned_task_id = %spawned_task_id, - error = %e, - "Failed to save spawned task to supervisor state" - ); + if state.submit_question_response(question_id, req.response) { + Json(AnswerQuestionResponse { success: true }).into_response() } else { - tracing::debug!( - contract_id = %contract_id, - spawned_task_id = %spawned_task_id, - "Saved spawned task to supervisor state" - ); - } - - // Also update state to working - if let Err(e) = repository::update_supervisor_detailed_state( - pool, - contract_id, - "working", - Some(&format!("Spawned task {}", spawned_task_id)), - 0, // Progress resets when spawning new work - None, - ).await { - tracing::warn!(contract_id = %contract_id, error = %e, "Failed to update supervisor state on task spawn"); - } -} - -/// Save supervisor state on question asked. -/// This is called when a supervisor asks a question and is waiting for user input. -pub async fn save_state_on_question_asked( - pool: &PgPool, - contract_id: Uuid, - question: PendingQuestion, -) { - let question_json = match serde_json::to_value(&[&question]) { - Ok(v) => v, - Err(e) => { - tracing::warn!(contract_id = %contract_id, error = %e, "Failed to serialize pending question"); - return; - } - }; - - if let Err(e) = repository::add_supervisor_pending_question(pool, contract_id, question_json).await { - tracing::warn!( - contract_id = %contract_id, - question_id = %question.id, - error = %e, - "Failed to save pending question to supervisor state" - ); - } else { - tracing::debug!( - contract_id = %contract_id, - question_id = %question.id, - "Saved pending question to supervisor state" - ); - } -} - -/// Clear pending question after it's answered. -pub async fn clear_pending_question( - pool: &PgPool, - contract_id: Uuid, - question_id: Uuid, -) { - if let Err(e) = repository::remove_supervisor_pending_question(pool, contract_id, question_id).await { - tracing::warn!( - contract_id = %contract_id, - question_id = %question_id, - error = %e, - "Failed to remove pending question from supervisor state" - ); - } - - // Update state back to working (if no more pending questions) - match repository::get_supervisor_state(pool, contract_id).await { - Ok(Some(state)) => { - let questions: Vec<PendingQuestion> = serde_json::from_value(state.pending_questions.clone()) - .unwrap_or_default(); - if questions.is_empty() { - let _ = repository::update_supervisor_detailed_state( - pool, - contract_id, - "working", - Some("Resumed after user response"), - state.progress, - None, - ).await; - } - } - Ok(None) => {} - Err(e) => { - tracing::warn!(contract_id = %contract_id, error = %e, "Failed to check supervisor state after clearing question"); - } - } -} - -/// Save supervisor state on phase change. -pub async fn save_state_on_phase_change( - pool: &PgPool, - contract_id: Uuid, - new_phase: &str, -) { - if let Err(e) = repository::update_supervisor_phase(pool, contract_id, new_phase).await { - tracing::warn!( - contract_id = %contract_id, - new_phase = %new_phase, - error = %e, - "Failed to update supervisor state on phase change" - ); - } else { - tracing::info!( - contract_id = %contract_id, - new_phase = %new_phase, - "Updated supervisor state on phase change" - ); - } -} - -// ============================================================================= -// Supervisor Restoration Protocol (Task 3.4) -// ============================================================================= - -/// Validate supervisor state consistency before restoration. -/// Checks that spawned tasks and pending questions are in expected states. -pub async fn validate_supervisor_state( - pool: &PgPool, - state: &crate::db::models::SupervisorState, -) -> StateValidationResult { - let mut issues = Vec::new(); - - // Validate spawned tasks - if !state.spawned_task_ids.is_empty() { - match repository::validate_spawned_tasks(pool, &state.spawned_task_ids).await { - Ok(task_statuses) => { - for task_id in &state.spawned_task_ids { - if !task_statuses.contains_key(task_id) { - issues.push(format!("Spawned task {} not found in database", task_id)); - } - } - } - Err(e) => { - issues.push(format!("Failed to validate spawned tasks: {}", e)); - } - } - } - - // Validate pending questions - let pending_questions: Vec<PendingQuestion> = serde_json::from_value(state.pending_questions.clone()) - .unwrap_or_default(); - - // Check if questions are not too old (e.g., more than 24 hours) - for question in &pending_questions { - let age = chrono::Utc::now() - question.asked_at; - if age.num_hours() > 24 { - issues.push(format!( - "Pending question {} is {} hours old, may be stale", - question.id, age.num_hours() - )); - } - } - - // Validate conversation history - if let Some(history) = state.conversation_history.as_array() { - if history.is_empty() && state.restoration_count > 0 { - issues.push("Conversation history is empty after previous restoration".to_string()); - } - } - - // Determine recovery action - let recovery_action = if issues.is_empty() { - StateRecoveryAction::Proceed - } else if issues.iter().any(|i| i.contains("not found")) { - // Missing tasks suggest corruption - use checkpoint - StateRecoveryAction::UseCheckpoint - } else if issues.len() > 3 { - // Many issues suggest manual intervention needed - StateRecoveryAction::ManualIntervention - } else { - // Minor issues - proceed with warnings - StateRecoveryAction::Proceed - }; - - StateValidationResult { - is_valid: issues.is_empty(), - issues, - recovery_action, - } -} - -/// Restore supervisor from saved state after daemon crash or task reassignment. -/// Returns restoration context to send to the supervisor. -pub async fn restore_supervisor( - pool: &PgPool, - contract_id: Uuid, - restoration_source: &str, -) -> Result<SupervisorRestorationContext, String> { - // Step 1: Load supervisor state - let state = match repository::get_supervisor_state_for_restoration(pool, contract_id).await { - Ok(Some(s)) => s, - Ok(None) => { - tracing::warn!( - contract_id = %contract_id, - "No supervisor state found for restoration - starting fresh" - ); - return Ok(SupervisorRestorationContext { - success: true, - previous_state: SupervisorStateEnum::Initializing, - conversation_history: serde_json::json!([]), - pending_questions: vec![], - waiting_task_ids: vec![], - spawned_task_ids: vec![], - restoration_count: 0, - restoration_context_message: "No previous state found. Starting fresh.".to_string(), - warnings: vec!["No previous supervisor state found".to_string()], - }); - } - Err(e) => { - return Err(format!("Failed to load supervisor state: {}", e)); - } - }; - - // Step 2: Parse previous state - let previous_state: SupervisorStateEnum = state.state.parse().unwrap_or(SupervisorStateEnum::Interrupted); - - // Step 3: Validate state consistency - let validation = validate_supervisor_state(pool, &state).await; - let mut warnings = validation.issues.clone(); - - // Step 4: Handle based on validation result - let (conversation_history, pending_questions, restoration_message) = match validation.recovery_action { - StateRecoveryAction::Proceed => { - // State is valid, use it - let questions: Vec<PendingQuestion> = serde_json::from_value(state.pending_questions.clone()) - .unwrap_or_default(); - - let message = format!( - "Restored from {} state. {} pending questions, {} spawned tasks, {} waiting tasks.", - state.state, - questions.len(), - state.spawned_task_ids.len(), - state.pending_task_ids.len() - ); - - (state.conversation_history.clone(), questions, message) - } - StateRecoveryAction::UseCheckpoint => { - // State is corrupted, try to use checkpoint - warnings.push("State validation failed, attempting checkpoint recovery".to_string()); - - // TODO: Implement checkpoint-based recovery - // For now, start with empty questions but preserve conversation - let message = "Restored from last checkpoint due to state inconsistency.".to_string(); - (state.conversation_history.clone(), vec![], message) - } - StateRecoveryAction::StartFresh => { - warnings.push("Starting fresh due to unrecoverable state".to_string()); - let message = "Starting fresh due to unrecoverable state corruption.".to_string(); - (serde_json::json!([]), vec![], message) - } - StateRecoveryAction::ManualIntervention => { - warnings.push("Manual intervention may be required".to_string()); - // Still try to restore but with warning - let questions: Vec<PendingQuestion> = serde_json::from_value(state.pending_questions.clone()) - .unwrap_or_default(); - let message = "Restored with warnings - manual intervention may be required.".to_string(); - (state.conversation_history.clone(), questions, message) - } - }; - - // Step 5: Mark supervisor as restored - let new_state = match repository::mark_supervisor_restored(pool, contract_id, restoration_source).await { - Ok(s) => s, - Err(e) => { - return Err(format!("Failed to mark supervisor as restored: {}", e)); - } - }; - - // Step 6: Build restoration context - let context = SupervisorRestorationContext { - success: true, - previous_state, - conversation_history, - pending_questions, - waiting_task_ids: state.pending_task_ids.clone(), - spawned_task_ids: state.spawned_task_ids.clone(), - restoration_count: new_state.restoration_count, - restoration_context_message: restoration_message, - warnings, - }; - - tracing::info!( - contract_id = %contract_id, - restoration_source = %restoration_source, - restoration_count = new_state.restoration_count, - pending_questions_count = context.pending_questions.len(), - waiting_tasks_count = context.waiting_task_ids.len(), - spawned_tasks_count = context.spawned_task_ids.len(), - "Supervisor restoration completed" - ); - - Ok(context) -} - -/// Re-deliver pending questions to the user after restoration. -/// This ensures questions asked before crash are shown to the user again. -pub async fn redeliver_pending_questions( - state: &SharedState, - supervisor_id: Uuid, - contract_id: Uuid, - owner_id: Uuid, - questions: &[PendingQuestion], -) { - for question in questions { - // Add to in-memory question state - state.add_supervisor_question( - supervisor_id, - contract_id, - owner_id, - question.question.clone(), - question.choices.clone(), - question.context.clone(), - false, // Assume single select for restored questions - question.question_type.clone(), - ); - - // Broadcast to WebSocket clients - let question_data = serde_json::json!({ - "question_id": question.id.to_string(), - "choices": question.choices, - "context": question.context, - "question_type": question.question_type, - "is_restored": true, - "originally_asked_at": question.asked_at.to_rfc3339(), - }); - - state.broadcast_task_output(TaskOutputNotification { - task_id: supervisor_id, - owner_id: Some(owner_id), - message_type: "supervisor_question".to_string(), - content: question.question.clone(), - tool_name: None, - tool_input: Some(question_data), - is_error: None, - cost_usd: None, - duration_ms: None, - is_partial: false, - }); - - tracing::info!( - supervisor_id = %supervisor_id, - question_id = %question.id, - "Re-delivered pending question after restoration" - ); - } -} - -/// Generate restoration context message for Claude. -/// This message is injected into the conversation to inform Claude about the restoration. -pub fn generate_restoration_context_message(context: &SupervisorRestorationContext) -> String { - let mut message = String::new(); - - message.push_str("=== SUPERVISOR RESTORATION NOTICE ===\n\n"); - message.push_str(&format!("This supervisor has been restored after interruption. {}\n\n", context.restoration_context_message)); - message.push_str(&format!("Restoration count: {}\n", context.restoration_count)); - - if !context.pending_questions.is_empty() { - message.push_str(&format!("\nPending questions ({}): These have been re-delivered to the user.\n", context.pending_questions.len())); - for q in &context.pending_questions { - message.push_str(&format!(" - {}: {}\n", q.id, q.question)); - } - } - - if !context.waiting_task_ids.is_empty() { - message.push_str(&format!("\nWaiting on {} task(s) to complete. Check their status before continuing.\n", context.waiting_task_ids.len())); - } - - if !context.spawned_task_ids.is_empty() { - message.push_str(&format!("\n{} task(s) were spawned before interruption. Their status may need verification.\n", context.spawned_task_ids.len())); - } - - if !context.warnings.is_empty() { - message.push_str("\nWarnings:\n"); - for warning in &context.warnings { - message.push_str(&format!(" - {}\n", warning)); - } + ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Question not found")), + ) + .into_response() } - - message.push_str("\n=== END RESTORATION NOTICE ===\n"); - - message } // ============================================================================= -// Order Creation from Directive Tasks +// Order creation (from directive tasks) // ============================================================================= -/// Request to create an order from a directive task. #[derive(Debug, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct CreateOrderForTaskRequest { @@ -3117,30 +496,25 @@ pub struct CreateOrderForTaskRequest { fn default_order_priority() -> String { "medium".to_string() } - fn default_order_type() -> String { "spike".to_string() } - fn default_order_labels() -> serde_json::Value { serde_json::json!([]) } -/// Create an order for future work from a directive task. -/// -/// Only spike and chore order types are allowed. The order is automatically -/// linked to the directive associated with the calling task. +/// Create a follow-up order from a directive task (spike/chore only). #[utoipa::path( post, path = "/api/v1/mesh/supervisor/orders", request_body = CreateOrderForTaskRequest, responses( (status = 201, description = "Order created"), - (status = 400, description = "Invalid order type"), + (status = 400, description = "Invalid order type or no directive context"), (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden - not a supervisor/directive task"), - (status = 500, description = "Internal server error"), + (status = 403, description = "Not a directive task"), ), + security(("tool_key" = [])), tag = "Mesh Supervisor" )] pub async fn create_order_for_task( @@ -3148,14 +522,11 @@ pub async fn create_order_for_task( headers: HeaderMap, Json(request): Json<CreateOrderForTaskRequest>, ) -> impl IntoResponse { - let (task_id, owner_id) = match verify_supervisor_auth(&state, &headers, None).await { + let (task_id, owner_id) = match verify_task_auth(&state, &headers).await { Ok(ids) => ids, Err(e) => return e.into_response(), }; - let pool = state.db_pool.as_ref().unwrap(); - - // Validate order_type is spike or chore if request.order_type != "spike" && request.order_type != "chore" { return ( StatusCode::BAD_REQUEST, @@ -3167,7 +538,8 @@ pub async fn create_order_for_task( .into_response(); } - // Get the task to find its directive_id + let pool = state.db_pool.as_ref().unwrap(); + let task = match repository::get_task(pool, task_id).await { Ok(Some(t)) => t, Ok(None) => { @@ -3178,10 +550,10 @@ pub async fn create_order_for_task( .into_response(); } Err(e) => { - tracing::error!(error = %e, "Failed to get task"); + tracing::error!(error = %e, "Failed to fetch task"); return ( StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", "Failed to get task")), + Json(ApiError::new("DB_ERROR", "Failed to fetch task")), ) .into_response(); } @@ -3192,27 +564,21 @@ pub async fn create_order_for_task( None => { return ( StatusCode::BAD_REQUEST, - Json(ApiError::new( - "NO_DIRECTIVE", - "Task is not associated with a directive", - )), + Json(ApiError::new("NO_DIRECTIVE", "Task is not directive-attached")), ) .into_response(); } }; - // Determine repository_url: use request value, or fall back to directive's repository_url let repository_url = if request.repository_url.is_some() { request.repository_url } else { - // Look up directive for its repository_url match repository::get_directive_for_owner(pool, owner_id, directive_id).await { Ok(Some(d)) => d.repository_url, _ => None, } }; - // Create the order let order_req = CreateOrderRequest { title: request.title, description: request.description, diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs index 5737360..312fdc7 100644 --- a/makima/src/server/handlers/mod.rs +++ b/makima/src/server/handlers/mod.rs @@ -14,7 +14,6 @@ pub mod directives; pub mod file_ws; pub mod files; pub mod history; -pub mod listen; pub mod mesh; pub mod orders; pub mod mesh_daemon; diff --git a/makima/src/server/messages.rs b/makima/src/server/messages.rs index cecb622..5fa8c24 100644 --- a/makima/src/server/messages.rs +++ b/makima/src/server/messages.rs @@ -25,9 +25,6 @@ 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>, @@ -77,8 +74,6 @@ pub enum ServerMessage { TranscriptSaved { /// The ID of the file where the transcript was saved file_id: String, - /// The ID of the contract the file belongs to - contract_id: String, }, /// Error occurred during processing Error { code: String, message: String }, diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index bd48a8f..62ad1c7 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, daemon_download, directive_documents, directives, file_ws, files, history, listen, mesh, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, orders, repository_history, speak, users, versions}; +use crate::server::handlers::{api_keys, daemon_download, directive_documents, directives, file_ws, files, history, mesh, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, orders, repository_history, speak, users, versions}; use crate::server::openapi::ApiDoc; use crate::server::state::SharedState; @@ -43,7 +43,6 @@ async fn health_check() -> impl IntoResponse { pub fn make_router(state: SharedState) -> Router { // API v1 routes let api_v1 = Router::new() - .route("/listen", get(listen::websocket_handler)) .route("/speak", get(speak::websocket_handler)) // Listen/transcript-analysis endpoints removed in Phase 5 with the // contracts subsystem. @@ -55,7 +54,6 @@ pub fn make_router(state: SharedState) -> Router { .put(files::update_file) .delete(files::delete_file), ) - .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)) @@ -103,9 +101,7 @@ 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)) + // Task conversation history. .route("/mesh/tasks/{id}/conversation", get(history::get_task_conversation)) // Resume and rewind endpoints .route("/mesh/tasks/{id}/rewind", post(mesh::rewind_task)) @@ -114,20 +110,11 @@ pub fn make_router(state: SharedState) -> Router { .route("/mesh/tasks/{id}/checkpoints/{cid}/branch", post(mesh::branch_from_checkpoint)) // Task branching endpoint .route("/mesh/tasks/{id}/branch", post(mesh::branch_task)) - // 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)) - // Supervisor order creation endpoint + // Directive backchannel — used by `makima directive ask` and + // `makima directive create-order`. The /supervisor/ path is + // kept for CLI client backwards compat (see mesh_supervisor.rs + // module docstring). .route("/mesh/supervisor/orders", post(mesh_supervisor::create_order_for_task)) - // Supervisor question endpoints .route("/mesh/supervisor/questions", post(mesh_supervisor::ask_question)) .route("/mesh/supervisor/questions/{question_id}/poll", get(mesh_supervisor::poll_question)) .route("/mesh/questions", get(mesh_supervisor::list_pending_questions)) @@ -315,9 +302,6 @@ const ANONYMOUS_TASK_MAX_AGE_DAYS: i32 = 7; /// Interval for checkpoint patch cleanup (hourly) const CHECKPOINT_PATCH_CLEANUP_INTERVAL_SECS: u64 = 3600; -// Retry orchestrator checks for pending tasks every 30 seconds -const RETRY_ORCHESTRATOR_INTERVAL_SECS: u64 = 30; - /// Run the HTTP server with graceful shutdown support. /// /// # Arguments @@ -455,63 +439,9 @@ pub async fn run_server(state: SharedState, addr: &str) -> anyhow::Result<()> { } }); - // Clone state and pool for retry orchestrator - let retry_pool = pool.clone(); - let retry_state = state.clone(); - - // Spawn retry orchestrator - periodically retries pending tasks on available daemons - tokio::spawn(async move { - let mut interval = tokio::time::interval( - std::time::Duration::from_secs(RETRY_ORCHESTRATOR_INTERVAL_SECS) - ); - loop { - interval.tick().await; - - // Get all contracts with pending tasks awaiting retry - match crate::db::repository::get_all_pending_task_contracts(&retry_pool).await { - Ok(contract_owners) => { - for (contract_id, owner_id) in contract_owners { - // Try to start a pending task for this contract - match handlers::mesh_supervisor::try_start_pending_task( - &retry_state, - contract_id, - owner_id, - ).await { - Ok(Some(task)) => { - tracing::info!( - task_id = %task.id, - contract_id = %contract_id, - retry_count = task.retry_count, - "Retry orchestrator started pending task" - ); - } - Ok(None) => { - // No tasks could be started (no available daemons, etc.) - } - Err(e) => { - tracing::warn!( - contract_id = %contract_id, - error = %e, - "Retry orchestrator failed to start pending task" - ); - } - } - } - } - Err(e) => { - tracing::warn!( - error = %e, - "Retry orchestrator failed to query pending task contracts" - ); - } - } - } - }); - - tracing::info!( - "Retry orchestrator started (interval: {}s)", - RETRY_ORCHESTRATOR_INTERVAL_SECS - ); + // Retry orchestrator (contract-keyed) removed alongside legacy + // contracts — the directive system has its own reconciler that + // handles pending directive tasks. // Spawn directive orchestrator - automates directive lifecycle let directive_pool = pool.clone(); diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs index 13ba787..51ce01d 100644 --- a/makima/src/server/openapi.rs +++ b/makima/src/server/openapi.rs @@ -3,35 +3,30 @@ use utoipa::OpenApi; use crate::db::models::{ - AddLocalRepositoryRequest, AddRemoteRepositoryRequest, BranchInfo, BranchListResponse, BranchTaskRequest, BranchTaskResponse, - ChangePhaseRequest, - Contract, ContractEvent, - ContractListResponse, ContractRepository, ContractSummary, ContractWithRelations, CleanupResponse, - CreateContractRequest, CreateDirectiveRequest, CreateDirectiveStepRequest, CreateFileRequest, - CreateManagedRepositoryRequest, CreateOrderRequest, CreateTaskRequest, + CreateDirectiveRequest, CreateDirectiveStepRequest, CreateFileRequest, + CreateOrderRequest, CreateTaskRequest, Daemon, DaemonDirectoriesResponse, DaemonDirectory, DaemonListResponse, Directive, DirectiveDocument, DirectiveListResponse, DirectiveRevision, DirectiveStep, DirectiveSummary, DirectiveWithSteps, File, FileListResponse, FileSummary, LinkDirectiveRequest, MergeCommitRequest, MergeCompleteCheckResponse, MergeResolveRequest, MergeResultResponse, - MergeSkipRequest, MergeStartRequest, MergeStatusResponse, MeshChatConversation, - MeshChatHistoryResponse, MeshChatMessageRecord, + MergeSkipRequest, MergeStartRequest, MergeStatusResponse, Order, OrderListResponse, OrderListQuery, RepositoryHistoryEntry, RepositoryHistoryListResponse, RepositorySuggestionsQuery, SendMessageRequest, Task, TaskEventListResponse, TaskListResponse, TaskSummary, TaskWithSubtasks, TranscriptEntry, - UpdateContractRequest, UpdateDirectiveRequest, UpdateDirectiveStepRequest, + UpdateDirectiveRequest, UpdateDirectiveStepRequest, UpdateFileRequest, UpdateOrderRequest, UpdateTaskRequest, }; use crate::server::auth::{ ApiKey, ApiKeyInfoResponse, CreateApiKeyRequest, CreateApiKeyResponse, RefreshApiKeyRequest, RefreshApiKeyResponse, RevokeApiKeyResponse, }; -use crate::server::handlers::{api_keys, directive_documents, directives, files, listen, mesh, mesh_merge, orders, repository_history, users}; +use crate::server::handlers::{api_keys, directive_documents, directives, files, mesh, mesh_merge, orders, repository_history, users}; use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage, TranscriptMessage}; #[derive(OpenApi)] @@ -43,13 +38,11 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage license(name = "MIT"), ), paths( - listen::websocket_handler, files::list_files, files::get_file, files::create_file, files::update_file, files::delete_file, - files::sync_file_from_repo, // Mesh endpoints mesh::list_tasks, mesh::get_task, @@ -170,10 +163,6 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage DaemonDirectoriesResponse, DaemonDirectory, mesh::TaskPatchDataResponse, - MeshChatConversation, - MeshChatMessageRecord, - MeshChatHistoryResponse, - // Contract chat / discuss schemas removed in Phase 5. // Merge schemas BranchInfo, BranchListResponse, @@ -201,19 +190,6 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage users::DeleteAccountResponse, users::UserSettingsResponse, users::UpdateUserSettingsRequest, - // Contract schemas - Contract, - ContractSummary, - ContractListResponse, - ContractWithRelations, - ContractRepository, - ContractEvent, - CreateContractRequest, - UpdateContractRequest, - AddRemoteRepositoryRequest, - AddLocalRepositoryRequest, - CreateManagedRepositoryRequest, - ChangePhaseRequest, // Directive schemas Directive, DirectiveStep, diff --git a/makima/src/server/state.rs b/makima/src/server/state.rs index e267da1..9e06b4c 100644 --- a/makima/src/server/state.rs +++ b/makima/src/server/state.rs @@ -140,10 +140,8 @@ pub struct PrResultNotification { pub struct SupervisorQuestionNotification { /// Unique ID for this question pub question_id: Uuid, - /// Supervisor task that asked the question + /// Task that asked the question pub task_id: Uuid, - /// Contract this question relates to (Uuid::nil() for directive context) - pub contract_id: Uuid, /// Directive this question relates to (if from a directive task) #[serde(skip_serializing_if = "Option::is_none")] pub directive_id: Option<Uuid>, @@ -172,7 +170,6 @@ pub struct SupervisorQuestionNotification { pub struct PendingSupervisorQuestion { pub question_id: Uuid, pub task_id: Uuid, - pub contract_id: Uuid, /// Directive this question relates to (if from a directive task) pub directive_id: Option<Uuid>, pub owner_id: Uuid, @@ -285,12 +282,6 @@ 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, /// Whether to run in autonomous loop mode #[serde(rename = "autonomousLoop", default)] autonomous_loop: bool, @@ -306,15 +297,12 @@ pub enum DaemonCommand { /// Commit SHA to apply the patch on top of #[serde(rename = "patchBaseSha", default, skip_serializing_if = "Option::is_none")] patch_base_sha: Option<String>, - /// Whether the contract is in local-only mode (skips automatic completion actions) + /// Whether to skip automatic completion actions (local-only mode). #[serde(rename = "localOnly", default)] local_only: bool, /// Whether to auto-merge to target branch locally when local_only mode is enabled #[serde(rename = "autoMergeLocal", default)] auto_merge_local: bool, - /// Task ID to share worktree with (supervisor's task ID). If Some, use that task's worktree instead of creating a new one. - #[serde(rename = "supervisorWorktreeTaskId", default, skip_serializing_if = "Option::is_none")] - supervisor_worktree_task_id: Option<Uuid>, /// Directive ID if this task is associated with a directive #[serde(rename = "directiveId", default, skip_serializing_if = "Option::is_none")] directive_id: Option<Uuid>, @@ -906,29 +894,12 @@ impl AppState { let _ = self.pr_results.send(notification); } - /// Add a pending supervisor question and broadcast it. + /// Add a pending question and broadcast it. Questions live in + /// memory only; they're a back-channel for directive tasks to + /// pause for clarification (used by `makima directive ask`). pub fn add_supervisor_question( &self, task_id: Uuid, - contract_id: Uuid, - owner_id: Uuid, - question: String, - choices: Vec<String>, - context: Option<String>, - multi_select: bool, - question_type: String, - ) -> Uuid { - self.add_supervisor_question_with_directive( - task_id, contract_id, None, owner_id, - question, choices, context, multi_select, question_type, - ) - } - - /// Add a pending supervisor question with optional directive context and broadcast it. - pub fn add_supervisor_question_with_directive( - &self, - task_id: Uuid, - contract_id: Uuid, directive_id: Option<Uuid>, owner_id: Uuid, question: String, @@ -940,13 +911,11 @@ impl AppState { let question_id = Uuid::new_v4(); let now = chrono::Utc::now(); - // Store the pending question self.pending_questions.insert( question_id, PendingSupervisorQuestion { question_id, task_id, - contract_id, directive_id, owner_id, question: question.clone(), @@ -958,11 +927,9 @@ impl AppState { }, ); - // Broadcast to subscribers self.broadcast_supervisor_question(SupervisorQuestionNotification { question_id, task_id, - contract_id, directive_id, owner_id: Some(owner_id), question, @@ -976,10 +943,9 @@ impl AppState { tracing::info!( question_id = %question_id, task_id = %task_id, - contract_id = %contract_id, directive_id = ?directive_id, question_type = %question_type, - "Supervisor question added" + "Question added" ); question_id @@ -1029,7 +995,6 @@ impl AppState { self.broadcast_supervisor_question(SupervisorQuestionNotification { question_id, task_id: question.1.task_id, - contract_id: question.1.contract_id, directive_id: question.1.directive_id, owner_id: Some(question.1.owner_id), question: question.1.question, @@ -1093,38 +1058,6 @@ impl AppState { count } - /// Remove all pending questions for a specific contract. - /// - /// This should be called when a contract is deleted to clean up orphaned questions. - /// Returns the number of questions removed. - pub fn remove_pending_questions_for_contract(&self, contract_id: Uuid) -> usize { - // Collect question IDs to remove - let question_ids: Vec<Uuid> = self - .pending_questions - .iter() - .filter(|entry| entry.value().contract_id == contract_id) - .map(|entry| entry.value().question_id) - .collect(); - - let count = question_ids.len(); - - // Remove pending questions and their responses - for question_id in question_ids { - self.pending_questions.remove(&question_id); - self.question_responses.remove(&question_id); - } - - if count > 0 { - tracing::info!( - contract_id = %contract_id, - count = count, - "Cleaned up pending questions for deleted contract" - ); - } - - count - } - /// Register a new daemon connection. /// /// Returns the connection_id for later reference. @@ -1329,176 +1262,6 @@ impl AppState { .map(|entry| entry.value().clone()) } - // ========================================================================= - // 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. - /// - /// If `action_directive` is provided, it will be appended to the message - /// as an [ACTION REQUIRED] block to prompt the supervisor to take action. - 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>, - action_directive: 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)); - } - - // Append action directive if provided - if let Some(directive) = action_directive { - message.push_str("\n\n"); - message.push_str(directive); - } - - 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. |
