summaryrefslogtreecommitdiff
path: root/makima
diff options
context:
space:
mode:
Diffstat (limited to 'makima')
-rw-r--r--makima/migrations/20260517000000_drop_legacy_contracts_and_supervisor.sql58
-rw-r--r--makima/src/bin/makima.rs540
-rw-r--r--makima/src/daemon/cli/mod.rs5
-rw-r--r--makima/src/daemon/cli/view.rs93
-rw-r--r--makima/src/daemon/mod.rs11
-rw-r--r--makima/src/daemon/tui/app.rs1219
-rw-r--r--makima/src/daemon/tui/event.rs269
-rw-r--r--makima/src/daemon/tui/fuzzy.rs217
-rw-r--r--makima/src/daemon/tui/mod.rs98
-rw-r--r--makima/src/daemon/tui/ui.rs695
-rw-r--r--makima/src/daemon/tui/views/contracts.rs32
-rw-r--r--makima/src/daemon/tui/views/files.rs90
-rw-r--r--makima/src/daemon/tui/views/mod.rs3
-rw-r--r--makima/src/daemon/tui/views/tasks.rs71
-rw-r--r--makima/src/daemon/tui/widgets/list_view.rs127
-rw-r--r--makima/src/daemon/tui/widgets/mod.rs4
-rw-r--r--makima/src/daemon/tui/widgets/preview_pane.rs21
-rw-r--r--makima/src/daemon/tui/widgets/search_input.rs82
-rw-r--r--makima/src/daemon/tui/widgets/status_bar.rs19
-rw-r--r--makima/src/daemon/tui/ws_client.rs353
-rw-r--r--makima/src/db/models.rs905
-rw-r--r--makima/src/db/repository.rs2537
-rw-r--r--makima/src/orchestration/directive.rs84
-rw-r--r--makima/src/server/handlers/directives.rs12
-rw-r--r--makima/src/server/handlers/files.rs207
-rw-r--r--makima/src/server/handlers/history.rs268
-rw-r--r--makima/src/server/handlers/listen.rs783
-rw-r--r--makima/src/server/handlers/mesh.rs247
-rw-r--r--makima/src/server/handlers/mesh_daemon.rs419
-rw-r--r--makima/src/server/handlers/mesh_supervisor.rs2980
-rw-r--r--makima/src/server/handlers/mod.rs1
-rw-r--r--makima/src/server/messages.rs5
-rw-r--r--makima/src/server/mod.rs88
-rw-r--r--makima/src/server/openapi.rs34
-rw-r--r--makima/src/server/state.rs249
35 files changed, 373 insertions, 12453 deletions
diff --git a/makima/migrations/20260517000000_drop_legacy_contracts_and_supervisor.sql b/makima/migrations/20260517000000_drop_legacy_contracts_and_supervisor.sql
new file mode 100644
index 0000000..bc89ff8
--- /dev/null
+++ b/makima/migrations/20260517000000_drop_legacy_contracts_and_supervisor.sql
@@ -0,0 +1,58 @@
+-- Drop the legacy contracts + supervisor task-grouping system.
+--
+-- Context: contracts were the pre-directive grouping for tasks. The
+-- "supervisor" was a special task type (`is_supervisor=true`) that
+-- coordinated a tree of subtasks under one contract. Both have been
+-- inert for several PRs — no creation path remains, the LLM removal
+-- (#135) took out the last surface that spawned them, and the
+-- directives system reads its own active contract body for spec text.
+--
+-- This migration drops every remnant in one shot. The user authorised
+-- a DB wipe; CASCADE is intentional.
+--
+-- Survives:
+-- * `pending_questions` (in-memory only in server state.rs) — the
+-- directive Ask command still uses this backchannel.
+-- * `orders` (already had contract_id dropped, line 20260216).
+-- * `directive_documents` (the user-facing "contract" surface).
+
+-- ---------------------------------------------------------------------------
+-- Drop FK columns first so the parent tables can go cleanly.
+-- ---------------------------------------------------------------------------
+
+-- Tasks: legacy contract grouping + supervisor flags.
+ALTER TABLE tasks DROP COLUMN IF EXISTS contract_id CASCADE;
+ALTER TABLE tasks DROP COLUMN IF EXISTS is_supervisor CASCADE;
+ALTER TABLE tasks DROP COLUMN IF EXISTS supervisor_task_id CASCADE;
+ALTER TABLE tasks DROP COLUMN IF EXISTS supervisor_worktree_task_id CASCADE;
+
+-- directive_steps: legacy contract-backed step machinery.
+ALTER TABLE directive_steps DROP COLUMN IF EXISTS contract_id CASCADE;
+ALTER TABLE directive_steps DROP COLUMN IF EXISTS contract_type CASCADE;
+
+-- files: legacy contract + phase association.
+ALTER TABLE files DROP COLUMN IF EXISTS contract_id CASCADE;
+ALTER TABLE files DROP COLUMN IF EXISTS contract_phase CASCADE;
+
+-- history_events: drop contract scope; events stay task-level only.
+ALTER TABLE history_events DROP COLUMN IF EXISTS contract_id CASCADE;
+ALTER TABLE history_events DROP COLUMN IF EXISTS phase CASCADE;
+
+-- ---------------------------------------------------------------------------
+-- Drop dependent tables. Order is FK-safe: leaf tables first.
+-- ---------------------------------------------------------------------------
+
+DROP TABLE IF EXISTS contract_chat_messages CASCADE;
+DROP TABLE IF EXISTS contract_chat_conversations CASCADE;
+DROP TABLE IF EXISTS contract_events CASCADE;
+DROP TABLE IF EXISTS contract_repositories CASCADE;
+DROP TABLE IF EXISTS contract_type_templates CASCADE;
+
+DROP TABLE IF EXISTS supervisor_heartbeats CASCADE;
+DROP TABLE IF EXISTS supervisor_states CASCADE;
+
+DROP TABLE IF EXISTS mesh_chat_messages CASCADE;
+DROP TABLE IF EXISTS mesh_chat_conversations CASCADE;
+
+-- Final boss: the contracts table itself.
+DROP TABLE IF EXISTS contracts CASCADE;
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(&copy_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(&copy_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.