summaryrefslogtreecommitdiff
path: root/makima
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-05-18 01:21:30 +0100
committerGitHub <noreply@github.com>2026-05-18 01:21:30 +0100
commitf240675da99bc7705e473b8f70a2628812aa4c10 (patch)
tree3ee2d24b431ccb8cd1a3013c86b34a5782a3e224 /makima
parent0d996cf7590e3e52f424859c7d6f0e68640f119e (diff)
downloadsoryu-f240675da99bc7705e473b8f70a2628812aa4c10.tar.gz
soryu-f240675da99bc7705e473b8f70a2628812aa4c10.zip
chore: drop legacy contracts + supervisor task-grouping (#136)HEADmaster
The contracts table, supervisor task type, and all their backing machinery have been inert for several PRs. The directives system reads its own active contract body for spec text, and PR #135 removed the last LLM surface that spawned supervisors. This PR wipes the dead surface in one shot — the user authorised a DB wipe, so the migration drops every legacy table with CASCADE rather than carrying forward stub rows. Net change: −12k LOC across handlers, repository, state, models, the TUI, and the listen module. What's gone: - contracts, contract_chat_*, contract_events, contract_repositories, contract_type_templates tables. - supervisor_states, supervisor_heartbeats tables. - mesh_chat_conversations, mesh_chat_messages tables. - tasks.contract_id/is_supervisor/supervisor_task_id/supervisor_worktree_task_id columns. - directive_steps.contract_id/contract_type columns. - files.contract_id/contract_phase columns. - history_events.contract_id/phase columns. - The Contract/Supervisor/MeshChat handler + model + repository surface, plus the daemon TUI views that read them. - The standalone listen.rs websocket handler (orphaned with the LLM). What stays: - mesh_supervisor handler: trimmed to just the questions + orders backchannel used by `makima directive ask` / `create-order` (kept the URL prefix for CLI client compat). - directive_documents (the user-facing "contracts" surface). - pending_questions in-memory state for the directive Ask flow. cargo check, cargo test --lib (68 passed), tsc, and vite build all clean. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.