diff options
| author | soryu <soryu@soryu.co> | 2026-05-01 23:56:51 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-05-01 23:56:51 +0100 |
| commit | e11759447b1ac00becfb1e979e488f7f9c9cf478 (patch) | |
| tree | f8a58368de3f6dda3f2f5c1af34e869a0e714205 /makima/src | |
| parent | 80085c7cfa9d679ed3e3fd54a7d55fa8ab1addef (diff) | |
| download | soryu-e11759447b1ac00becfb1e979e488f7f9c9cf478.tar.gz soryu-e11759447b1ac00becfb1e979e488f7f9c9cf478.zip | |
chore(cleanup): Phase 5 contracts removal + tmp directive + 30-day expiry + scroll fix (#118)
Sweeping cleanup across the surface and the wire. Net: -14k LOC of legacy
contracts code, plus the tmp/scroll/UX fixes the user asked for.
## Sidebar/editor independent scroll
Replace `height: calc(100vh - 80px)` (which assumed an 80px masthead and
quietly clipped or pushed the whole page below the fold when the masthead
was taller) with `h-screen + overflow-hidden` on the page root and proper
`flex-1 min-h-0` sizing on `<main>`. Sidebar and editor pane now manage
their own scroll independently; the page itself never scrolls.
Same fix in /tmp/:taskId.
## tmp directive — real backing for orphans/ephemerals
New migration `20260501100000_tmp_directive_and_clear_orphans.sql`:
* Adds `directives.is_tmp` BOOLEAN NOT NULL DEFAULT false.
* Partial unique index `(owner_id) WHERE is_tmp` — at most ONE tmp
directive per owner.
* Hard-deletes every existing orphan task (`directive_id IS NULL`).
Per the user spec: "ALSO there are TOO MANY old tasks in tmp, we
need to remove all of them as well."
New repository helpers:
* `get_or_create_tmp_directive(pool, owner_id) -> Directive`
INSERT ON CONFLICT DO NOTHING + fallback SELECT, race-safe.
* `list_all_tmp_directives` — drives the expiry sweep.
* `delete_expired_tmp_tasks(tmp_directive_id) -> u64`.
* `list_tmp_tasks_for_owner` (replaces `list_orphan_tasks_for_owner`).
`mesh::create_task`: every top-level task must have a directive. If a
caller doesn't supply `directive_id` and isn't a subtask, attach to the
caller's tmp directive (auto-creating it on first use).
`list_directives_for_owner` filters out `is_tmp=true` so the scratchpad
directive doesn't pollute the contract list — surfaced via the sidebar's
`tmp/` folder instead.
## 30-day expiry on tmp tasks
New `phase_tmp_expiry` in the directive reconciler. Throttled to once per
hour: enumerates every tmp directive, calls `delete_expired_tmp_tasks`,
logs the count. The actual delete is `WHERE created_at < NOW() - INTERVAL
'30 days'` and is fast on the existing index. Subtasks die via FK cascade.
## Phase 5 — contracts removed
### Frontend
Deleted entire `/contracts` surface:
* routes: `contracts.tsx`, `contract-file.tsx`
* components/contracts: ContractList, ContractDetail, ContractCliInput,
ContractContextMenu, CommandModePanel, PhaseBadge, PhaseHint,
PhaseDeliverablesPanel, PhaseProgressBar, QuickActionButtons,
RepositoryPanel, TaskDerivationPreview
* (Kept `PhaseConfirmationModal` — used outside the contracts surface
by `TaskOutput` and `PhaseConfirmationNotification`.)
* Routes deregistered from `main.tsx`; nav entry removed from
`NavStrip`.
### Backend handlers
Deleted: `contracts.rs` (2.4k LOC), `contract_chat.rs` (3.2k LOC),
`contract_daemon.rs` (~940 LOC), `contract_discuss.rs` (~590 LOC),
`transcript_analysis.rs` (~690 LOC). All `/api/v1/contracts/*` routes
deregistered. OpenAPI entries dropped. Module declarations removed from
`server/handlers/mod.rs`.
### CLI
Removed `makima contract` and `makima supervisor` subcommands. Deleted
`daemon/cli/contract.rs` and `daemon/cli/supervisor.rs`. Bin dispatch
trimmed (~377 LOC).
### Orchestrator
Removed the contract-spawn path from `phase_execution`
(`spawn_step_contract` and its caller). `directive_steps.contract_type`
now logs a warning and falls through to standalone-task spawn. Column
itself stays — old data still reads, just no longer triggers a
contract+supervisor spawn.
### TUI
`Action::PerformCreateContract` is now a no-op that surfaces a status
message: "Contracts have been removed. Use directives instead." The TUI
form is dead code pending a wider refresh.
## Out of scope (deliberately left)
* Contracts DB tables (`contracts`, `contract_repositories`,
`contract_chat_history`, `contract_events`, `contract_templates`) are
retained for historical data + because some peripheral code still
joins to them in TaskSummary queries.
* `mesh_supervisor` handlers are retained — they aren't only used by
contracts (some mesh-level supervisor behaviour persists), and the
cross-cutting cleanup is bigger than this PR.
* `directive_steps.contract_type` column itself isn't dropped; just no
longer functional.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'makima/src')
| -rw-r--r-- | makima/src/bin/makima.rs | 454 | ||||
| -rw-r--r-- | makima/src/daemon/cli/contract.rs | 87 | ||||
| -rw-r--r-- | makima/src/daemon/cli/mod.rs | 140 | ||||
| -rw-r--r-- | makima/src/daemon/cli/supervisor.rs | 448 | ||||
| -rw-r--r-- | makima/src/daemon/mod.rs | 2 | ||||
| -rw-r--r-- | makima/src/db/models.rs | 6 | ||||
| -rw-r--r-- | makima/src/db/repository.rs | 99 | ||||
| -rw-r--r-- | makima/src/orchestration/directive.rs | 229 | ||||
| -rw-r--r-- | makima/src/server/handlers/contract_chat.rs | 3183 | ||||
| -rw-r--r-- | makima/src/server/handlers/contract_daemon.rs | 936 | ||||
| -rw-r--r-- | makima/src/server/handlers/contract_discuss.rs | 592 | ||||
| -rw-r--r-- | makima/src/server/handlers/contracts.rs | 2376 | ||||
| -rw-r--r-- | makima/src/server/handlers/mesh.rs | 34 | ||||
| -rw-r--r-- | makima/src/server/handlers/mod.rs | 8 | ||||
| -rw-r--r-- | makima/src/server/handlers/transcript_analysis.rs | 690 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 73 | ||||
| -rw-r--r-- | makima/src/server/openapi.rs | 34 |
17 files changed, 220 insertions, 9171 deletions
diff --git a/makima/src/bin/makima.rs b/makima/src/bin/makima.rs index df3e8e7..338d8f9 100644 --- a/makima/src/bin/makima.rs +++ b/makima/src/bin/makima.rs @@ -4,10 +4,9 @@ use std::io::{self, Read}; use std::path::Path; use std::sync::Arc; -use makima::daemon::api::{ApiClient, CreateContractRequest}; +use makima::daemon::api::ApiClient; use makima::daemon::cli::{ - Cli, CliConfig, Commands, ConfigCommand, ContractCommand, - DirectiveCommand, SupervisorCommand, ViewArgs, + Cli, CliConfig, Commands, ConfigCommand, DirectiveCommand, ViewArgs, }; use makima::daemon::tui::{self, Action, App, ListItem, ViewType, TuiWsClient, WsEvent, OutputLine, OutputMessageType, WsConnectionState, RepositorySuggestion}; use makima::daemon::config::{DaemonConfig, RepoEntry}; @@ -27,8 +26,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { match cli.command { Commands::Server(args) => run_server(args).await, Commands::Daemon(args) => run_daemon(args).await, - Commands::Supervisor(cmd) => run_supervisor(cmd).await, - Commands::Contract(cmd) => run_contract(cmd).await, Commands::Directive(cmd) => run_directive(cmd).await, Commands::View(args) => run_view(args).await, Commands::Config(cmd) => run_config(cmd).await, @@ -309,383 +306,6 @@ async fn run_daemon( Ok(()) } -/// Run supervisor commands. -async fn run_supervisor( - cmd: SupervisorCommand, -) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { - use makima::daemon::api::supervisor::*; - - match cmd { - SupervisorCommand::Tasks(args) => { - let client = ApiClient::new(args.api_url, args.api_key)?; - let result = client.supervisor_tasks(args.contract_id).await?; - println!("{}", serde_json::to_string(&result.0)?); - } - SupervisorCommand::Tree(args) => { - let client = ApiClient::new(args.api_url, args.api_key)?; - let result = client.supervisor_tree(args.contract_id).await?; - println!("{}", serde_json::to_string(&result.0)?); - } - SupervisorCommand::Spawn(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - eprintln!("Creating task: {}...", args.name); - let req = SpawnTaskRequest { - name: args.name, - plan: args.plan, - contract_id: args.common.contract_id, - parent_task_id: args.parent, - checkpoint_sha: args.checkpoint, - }; - let result = client.supervisor_spawn(req).await?; - println!("{}", serde_json::to_string(&result.0)?); - } - SupervisorCommand::Wait(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - eprintln!( - "Waiting for task {} (timeout: {}s, poll interval: {}s)...", - args.task_id, args.timeout, args.poll_interval - ); - - let start_time = std::time::Instant::now(); - let timeout_duration = std::time::Duration::from_secs(args.timeout as u64); - let poll_interval = std::time::Duration::from_secs(args.poll_interval); - let server_wait_timeout = 30i32; // Short timeout for server-side wait - - loop { - // Check if we've exceeded the total timeout - let remaining = timeout_duration.saturating_sub(start_time.elapsed()); - if remaining.is_zero() { - eprintln!("Timeout reached after {}s", args.timeout); - let result = client.supervisor_get_task(args.task_id).await?; - println!("{}", serde_json::to_string(&result.0)?); - break; - } - - // Try server-side wait with short timeout - let wait_timeout = std::cmp::min(server_wait_timeout, remaining.as_secs() as i32); - - match client.supervisor_wait(args.task_id, wait_timeout).await { - Ok(result) => { - if let Some(completed) = result.0.get("completed").and_then(|c| c.as_bool()) { - if completed { - println!("{}", serde_json::to_string(&result.0)?); - break; - } - } - // Not completed yet, continue loop - eprintln!("Task still running (elapsed: {:?})", start_time.elapsed()); - } - Err(e) => { - eprintln!("Warning: Server wait failed: {}. Falling back to polling...", e); - // Fall back to simple status poll - if let Ok(result) = client.supervisor_get_task(args.task_id).await { - if let Some(status) = result.0.get("status").and_then(|s| s.as_str()) { - if status == "done" || status == "failed" || status == "merged" { - let wait_response = serde_json::json!({ - "taskId": args.task_id, - "status": status, - "completed": true, - "outputSummary": result.0.get("progressSummary") - }); - println!("{}", serde_json::to_string(&wait_response)?); - break; - } - } - } - } - } - - // Small delay before retrying - tokio::time::sleep(poll_interval).await; - } - } - SupervisorCommand::ReadFile(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - let result = client - .supervisor_read_file(args.task_id, &args.file_path) - .await?; - println!("{}", serde_json::to_string(&result.0)?); - } - SupervisorCommand::Branch(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - eprintln!("Creating branch: {}...", args.name); - let result = client.supervisor_branch(&args.name, args.from).await?; - println!("{}", serde_json::to_string(&result.0)?); - } - SupervisorCommand::Merge(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - eprintln!("Merging task {}...", args.task_id); - let result = client - .supervisor_merge(args.task_id, args.to, args.squash) - .await?; - println!("{}", serde_json::to_string(&result.0)?); - } - SupervisorCommand::Pr(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - eprintln!("Creating PR for branch {}...", args.branch); - let body = args.body.as_deref().unwrap_or(""); - let result = client - .supervisor_pr(&args.branch, &args.title, body) - .await?; - println!("{}", serde_json::to_string(&result.0)?); - } - SupervisorCommand::Diff(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - let result = client.supervisor_diff(args.task_id).await?; - println!("{}", serde_json::to_string(&result.0)?); - } - SupervisorCommand::Checkpoint(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - let task_id = args - .common - .self_task_id - .ok_or("MAKIMA_TASK_ID is required for checkpoint")?; - let result = client - .supervisor_checkpoint(task_id, &args.message) - .await?; - println!("{}", serde_json::to_string(&result.0)?); - } - SupervisorCommand::Checkpoints(args) => { - let client = ApiClient::new(args.api_url, args.api_key)?; - let task_id = args.self_task_id.ok_or("MAKIMA_TASK_ID is required")?; - let result = client.supervisor_checkpoints(task_id).await?; - println!("{}", serde_json::to_string(&result.0)?); - } - SupervisorCommand::Status(args) => { - let client = ApiClient::new(args.api_url, args.api_key)?; - let result = client.supervisor_status(args.contract_id).await?; - println!("{}", serde_json::to_string(&result.0)?); - } - SupervisorCommand::Ask(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - eprintln!("Asking user: {}...", args.question); - let choices = args - .choices - .map(|c| c.split(',').map(|s| s.trim().to_string()).collect()) - .unwrap_or_default(); - let result = client - .supervisor_ask(&args.question, choices, args.context, args.timeout, args.phaseguard, args.multi_select, args.non_blocking, args.question_type) - .await?; - println!("{}", serde_json::to_string(&result.0)?); - } - SupervisorCommand::AdvancePhase(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - if args.confirmed { - eprintln!("Advancing contract to phase: {} (confirmed)...", args.phase); - } else { - eprintln!("Requesting phase advance to: {} (use --confirmed to proceed)...", args.phase); - } - let result = client - .supervisor_advance_phase(args.common.contract_id, &args.phase, args.confirmed) - .await?; - println!("{}", serde_json::to_string(&result.0)?); - } - SupervisorCommand::Task(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - let result = client.supervisor_get_task(args.target_task_id).await?; - println!("{}", serde_json::to_string(&result.0)?); - } - SupervisorCommand::Output(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - let result = client.supervisor_get_task_output(args.target_task_id).await?; - println!("{}", serde_json::to_string(&result.0)?); - } - SupervisorCommand::TaskHistory(args) => { - eprintln!( - "Task history for {} (limit: {:?}, format: {})", - args.task_id, args.limit, args.format - ); - eprintln!("CLI integration not yet implemented. Use the API directly:"); - eprintln!(" GET /api/v1/mesh/tasks/{}/conversation", args.task_id); - } - SupervisorCommand::TaskCheckpoints(args) => { - eprintln!( - "Task checkpoints for {} (with_diff: {})", - args.task_id, args.with_diff - ); - eprintln!("CLI integration not yet implemented. Use the API directly:"); - eprintln!(" GET /api/v1/mesh/tasks/{}/checkpoints", args.task_id); - } - SupervisorCommand::Resume(args) => { - eprintln!( - "Resume supervisor for contract {} (mode: {}, checkpoint: {:?})", - args.common.contract_id, args.mode, args.checkpoint - ); - eprintln!("CLI integration not yet implemented. Use the API directly:"); - eprintln!( - " POST /api/v1/contracts/{}/supervisor/resume", - args.common.contract_id - ); - } - SupervisorCommand::TaskResumeFrom(args) => { - eprintln!( - "Resume task {} from checkpoint {} with plan: {}", - args.task_id, args.checkpoint, args.plan - ); - eprintln!("CLI integration not yet implemented. Use the API directly:"); - eprintln!( - " POST /api/v1/mesh/tasks/{}/checkpoints/{}/resume", - args.task_id, args.checkpoint - ); - } - SupervisorCommand::TaskRewind(args) => { - eprintln!( - "Rewind task {} to checkpoint {} (preserve: {}, branch: {:?})", - args.task_id, args.checkpoint, args.preserve, args.branch_name - ); - eprintln!("CLI integration not yet implemented. Use the API directly:"); - eprintln!(" POST /api/v1/mesh/tasks/{}/rewind", args.task_id); - } - SupervisorCommand::TaskFork(args) => { - eprintln!( - "Fork task {} from checkpoint {} as '{}' with plan: {}", - args.task_id, args.checkpoint, args.name, args.plan - ); - eprintln!("CLI integration not yet implemented. Use the API directly:"); - eprintln!(" POST /api/v1/mesh/tasks/{}/fork", args.task_id); - } - SupervisorCommand::RewindConversation(args) => { - eprintln!( - "Rewind conversation for contract {} (by: {:?}, to: {:?}, rewind_code: {})", - args.common.contract_id, args.by_messages, args.to_message, args.rewind_code - ); - eprintln!("CLI integration not yet implemented. Use the API directly:"); - eprintln!( - " POST /api/v1/contracts/{}/supervisor/conversation/rewind", - args.common.contract_id - ); - } - SupervisorCommand::Complete(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - eprintln!("Marking contract {} as complete...", args.common.contract_id); - match client.supervisor_complete(args.common.contract_id).await { - Ok(_) => { - println!(r#"{{"success": true, "message": "Contract marked as complete"}}"#); - } - Err(e) => { - eprintln!("Error: {}", e); - println!(r#"{{"success": false, "error": "{}"}}"#, e); - std::process::exit(1); - } - } - } - SupervisorCommand::ResumeContract(args) => { - let client = ApiClient::new(args.api_url, args.api_key)?; - eprintln!("Resuming contract {}...", args.contract_id); - let result = client.supervisor_resume_contract(args.contract_id).await?; - println!("{}", serde_json::to_string(&serde_json::json!({ - "success": true, - "message": "Contract resumed", - "contract": result.0 - }))?); - } - SupervisorCommand::MarkDeliverable(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - eprintln!( - "Marking deliverable '{}' as complete for contract {}...", - args.deliverable_id, args.common.contract_id - ); - let result = client - .supervisor_mark_deliverable( - args.common.contract_id, - &args.deliverable_id, - args.phase.as_deref(), - ) - .await?; - println!("{}", serde_json::to_string(&result.0)?); - } - } - - Ok(()) -} - -/// Run contract commands. -async fn run_contract( - cmd: ContractCommand, -) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { - match cmd { - ContractCommand::Status(args) => { - let client = ApiClient::new(args.api_url, args.api_key)?; - let result = client.contract_status(args.contract_id).await?; - println!("{}", serde_json::to_string(&result.0)?); - } - ContractCommand::Checklist(args) => { - let client = ApiClient::new(args.api_url, args.api_key)?; - let result = client.contract_checklist(args.contract_id).await?; - println!("{}", serde_json::to_string(&result.0)?); - } - ContractCommand::Goals(args) => { - let client = ApiClient::new(args.api_url, args.api_key)?; - let result = client.contract_goals(args.contract_id).await?; - println!("{}", serde_json::to_string(&result.0)?); - } - ContractCommand::Files(args) => { - let client = ApiClient::new(args.api_url, args.api_key)?; - let result = client.contract_files(args.contract_id).await?; - println!("{}", serde_json::to_string(&result.0)?); - } - ContractCommand::File(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - let result = client - .contract_file(args.common.contract_id, args.file_id) - .await?; - println!("{}", serde_json::to_string(&result.0)?); - } - ContractCommand::Report(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - let result = client - .contract_report(args.common.contract_id, &args.message, args.common.task_id) - .await?; - println!("{}", serde_json::to_string(&result.0)?); - } - ContractCommand::SuggestAction(args) => { - let client = ApiClient::new(args.api_url, args.api_key)?; - let result = client.contract_suggest_action(args.contract_id).await?; - println!("{}", serde_json::to_string(&result.0)?); - } - ContractCommand::CompletionAction(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - let files = args.files.map(|f| { - f.split(',') - .map(|s| s.trim().to_string()) - .collect::<Vec<_>>() - }); - let result = client - .contract_completion_action( - args.common.contract_id, - args.common.task_id, - files, - args.lines_added, - args.lines_removed, - args.code, - ) - .await?; - println!("{}", serde_json::to_string(&result.0)?); - } - ContractCommand::UpdateFile(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - // Read content from stdin - let mut content = String::new(); - io::stdin().read_to_string(&mut content)?; - let result = client - .contract_update_file(args.common.contract_id, args.file_id, &content) - .await?; - println!("{}", serde_json::to_string(&result.0)?); - } - ContractCommand::CreateFile(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - // Read content from stdin - let mut content = String::new(); - io::stdin().read_to_string(&mut content)?; - let result = client - .contract_create_file(args.common.contract_id, &args.name, &content) - .await?; - println!("{}", serde_json::to_string(&result.0)?); - } - } - - Ok(()) -} /// Run directive commands. async fn run_directive( @@ -1380,68 +1000,14 @@ async fn run_tui_loop( app.ws_state = WsConnectionState::Disconnected; } } - Action::PerformCreateContract { name, description, contract_type, repository_url } => { - // Create the contract via API - let req = CreateContractRequest { - name: name.clone(), - description: if description.is_empty() { None } else { Some(description) }, - contract_type: Some(contract_type), - initial_phase: None, - autonomous_loop: None, - phase_guard: None, - local_only: None, - auto_merge_local: None, - }; - - match client.create_contract(req).await { - Ok(result) => { - let contract_name = result.0.get("name") - .and_then(|v| v.as_str()) - .unwrap_or(&name) - .to_string(); - let contract_id = result.0.get("id") - .and_then(|v| v.as_str()) - .and_then(|s| uuid::Uuid::parse_str(s).ok()); - - // Add repository if provided - if let (Some(repo_url), Some(cid)) = (repository_url.as_ref(), contract_id) { - if !repo_url.is_empty() { - // Extract repo name from URL (e.g., "owner/repo" from GitHub URL) - let repo_name = extract_repo_name(repo_url); - match client.add_remote_repository(cid, &repo_name, repo_url, true).await { - Ok(_) => { - app.status_message = Some(format!( - "Created contract '{}' with repository", - contract_name - )); - } - Err(e) => { - app.status_message = Some(format!( - "Created contract but failed to add repository: {}", - e - )); - } - } - } else { - app.status_message = Some(format!("Created contract: {}", contract_name)); - } - } else { - app.status_message = Some(format!("Created contract: {}", contract_name)); - } - - // Refresh the contracts list - match load_contracts(client).await { - Ok(items) => app.set_items(items), - Err(e) => { - let msg = app.status_message.take().unwrap_or_default(); - app.status_message = Some(format!("{} (refresh failed: {})", msg, e)); - } - } - } - Err(e) => { - app.status_message = Some(format!("Create failed: {}", e)); - } - } + 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 diff --git a/makima/src/daemon/cli/contract.rs b/makima/src/daemon/cli/contract.rs deleted file mode 100644 index a443b85..0000000 --- a/makima/src/daemon/cli/contract.rs +++ /dev/null @@ -1,87 +0,0 @@ -//! Contract subcommand - task-contract interaction commands. - -use clap::Args; -use uuid::Uuid; - -/// Common arguments for contract commands. -#[derive(Args, Debug, Clone)] -pub struct ContractArgs { - /// API URL - #[arg(long, env = "MAKIMA_API_URL", default_value = "https://api.makima.jp", global = true)] - pub api_url: String, - - /// API key for authentication - #[arg(long, env = "MAKIMA_API_KEY", global = true)] - pub api_key: String, - - /// Current task ID (optional) - #[arg(long, env = "MAKIMA_TASK_ID", global = true)] - pub task_id: Option<Uuid>, - - /// Contract ID - #[arg(long, env = "MAKIMA_CONTRACT_ID", global = true)] - pub contract_id: Uuid, -} - -/// Arguments for file command (get specific file). -#[derive(Args, Debug)] -pub struct FileArgs { - #[command(flatten)] - pub common: ContractArgs, - - /// File ID to retrieve - pub file_id: Uuid, -} - -/// Arguments for report command. -#[derive(Args, Debug)] -pub struct ReportArgs { - #[command(flatten)] - pub common: ContractArgs, - - /// Progress message - pub message: String, -} - -/// Arguments for completion-action command. -#[derive(Args, Debug)] -pub struct CompletionActionArgs { - #[command(flatten)] - pub common: ContractArgs, - - /// Comma-separated list of modified files - #[arg(long)] - pub files: Option<String>, - - /// Number of lines added - #[arg(long, default_value = "0")] - pub lines_added: i32, - - /// Number of lines removed - #[arg(long, default_value = "0")] - pub lines_removed: i32, - - /// Whether there are code changes - #[arg(long)] - pub code: bool, -} - -/// Arguments for update-file command. -#[derive(Args, Debug)] -pub struct UpdateFileArgs { - #[command(flatten)] - pub common: ContractArgs, - - /// File ID to update - pub file_id: Uuid, -} - -/// Arguments for create-file command. -#[derive(Args, Debug)] -pub struct CreateFileArgs { - #[command(flatten)] - pub common: ContractArgs, - - /// Name of the new file - pub name: String, -} diff --git a/makima/src/daemon/cli/mod.rs b/makima/src/daemon/cli/mod.rs index 7affc55..b01c161 100644 --- a/makima/src/daemon/cli/mod.rs +++ b/makima/src/daemon/cli/mod.rs @@ -1,21 +1,17 @@ //! Command-line interface for the makima CLI. pub mod config; -pub mod contract; pub mod daemon; pub mod directive; pub mod server; -pub mod supervisor; pub mod view; use clap::{Parser, Subcommand}; pub use config::CliConfig; -pub use contract::ContractArgs; pub use daemon::DaemonArgs; pub use directive::DirectiveArgs; pub use server::ServerArgs; -pub use supervisor::SupervisorArgs; pub use view::ViewArgs; /// Makima - unified CLI for server, daemon, and task management. @@ -35,28 +31,11 @@ pub enum Commands { /// Run the daemon (connect to server, manage tasks) Daemon(DaemonArgs), - /// Supervisor commands for contract orchestration - #[command(subcommand)] - Supervisor(SupervisorCommand), - - /// Contract commands for task-contract interaction - #[command(subcommand)] - Contract(ContractCommand), - /// Directive commands for DAG-based project management #[command(subcommand)] Directive(DirectiveCommand), - /// Interactive TUI browser for contracts and tasks - /// - /// Provides a drill-down interface for browsing contracts, viewing their - /// tasks, and streaming real-time task output. - /// - /// Keyboard shortcuts: - /// ↑/k: Move up ↓/j: Move down Enter/l: Drill in - /// Esc/h: Go back /: Search q: Quit - /// e: Edit d: Delete c: cd to worktree - /// n: New contract + /// Interactive TUI browser for directives and tasks View(ViewArgs), /// Configure CLI settings (API key, server URL) @@ -86,121 +65,8 @@ pub enum ConfigCommand { Path, } -/// Supervisor subcommands for contract orchestration. -#[derive(Subcommand, Debug)] -pub enum SupervisorCommand { - /// List all tasks in the contract - Tasks(SupervisorArgs), - - /// Get the task tree structure - Tree(SupervisorArgs), - - /// Create and start a new task - Spawn(supervisor::SpawnArgs), - - /// Wait for a task to complete - Wait(supervisor::WaitArgs), - - /// Read a file from a task's worktree - ReadFile(supervisor::ReadFileArgs), - - /// Create a git branch - Branch(supervisor::BranchArgs), - - /// Merge a task's changes to a branch - Merge(supervisor::MergeArgs), - - /// Create a pull request - Pr(supervisor::PrArgs), - - /// View task diff - Diff(supervisor::DiffArgs), - - /// Create a checkpoint - Checkpoint(supervisor::CheckpointArgs), - - /// List checkpoints - Checkpoints(SupervisorArgs), - - /// Get contract status - Status(SupervisorArgs), - - /// Advance the contract to the next phase - AdvancePhase(supervisor::AdvancePhaseArgs), - - /// Ask a question and wait for user feedback - Ask(supervisor::AskArgs), - - /// Get individual task details - Task(supervisor::GetTaskArgs), - - /// Get task output/claude log - Output(supervisor::GetTaskOutputArgs), - - /// View task conversation history - TaskHistory(supervisor::TaskHistoryArgs), - - /// List task checkpoints (with optional diff) - TaskCheckpoints(supervisor::TaskCheckpointsArgs), - - /// Resume supervisor after interruption - Resume(supervisor::ResumeArgs), - - /// Resume task from checkpoint - TaskResumeFrom(supervisor::TaskResumeFromArgs), - - /// Rewind task code to checkpoint - TaskRewind(supervisor::TaskRewindArgs), - - /// Fork task from historical point - TaskFork(supervisor::TaskForkArgs), - - /// Rewind supervisor conversation - RewindConversation(supervisor::ConversationRewindArgs), - - /// Mark the contract as complete and stop the supervisor - Complete(supervisor::CompleteArgs), - - /// Resume a completed contract (reactivate it) - ResumeContract(supervisor::ResumeContractArgs), - - /// Mark a deliverable as complete - MarkDeliverable(supervisor::MarkDeliverableArgs), -} - -/// Contract subcommands for task-contract interaction. -#[derive(Subcommand, Debug)] -pub enum ContractCommand { - /// Get contract status - Status(ContractArgs), - - /// Get the phase checklist - Checklist(ContractArgs), - - /// Get contract goals - Goals(ContractArgs), - - /// List contract files - Files(ContractArgs), - - /// Get a specific file's content - File(contract::FileArgs), - - /// Report progress on the contract - Report(contract::ReportArgs), - - /// Get suggested next action - SuggestAction(ContractArgs), - - /// Get completion recommendation - CompletionAction(contract::CompletionActionArgs), - - /// Update a file (reads content from stdin) - UpdateFile(contract::UpdateFileArgs), - - /// Create a new file (reads content from stdin) - CreateFile(contract::CreateFileArgs), -} +// SupervisorCommand and ContractCommand removed in Phase 5 — contracts +// subsystem is gone. See cli/contract.rs and cli/supervisor.rs deletion. /// Directive subcommands for DAG-based project management. #[derive(Subcommand, Debug)] diff --git a/makima/src/daemon/cli/supervisor.rs b/makima/src/daemon/cli/supervisor.rs deleted file mode 100644 index 82d3900..0000000 --- a/makima/src/daemon/cli/supervisor.rs +++ /dev/null @@ -1,448 +0,0 @@ -//! Supervisor subcommand - contract orchestration commands. - -use clap::Args; -use uuid::Uuid; - -/// Common arguments for supervisor commands. -#[derive(Args, Debug, Clone)] -pub struct SupervisorArgs { - /// API URL - #[arg(long, env = "MAKIMA_API_URL", default_value = "https://api.makima.jp")] - pub api_url: String, - - /// API key for authentication - #[arg(long, env = "MAKIMA_API_KEY")] - pub api_key: String, - - /// Current task ID (optional) - the supervisor's own task ID - #[arg(long, env = "MAKIMA_TASK_ID")] - pub self_task_id: Option<Uuid>, - - /// Contract ID - #[arg(long, env = "MAKIMA_CONTRACT_ID")] - pub contract_id: Uuid, -} - -/// Arguments for spawn command. -#[derive(Args, Debug)] -pub struct SpawnArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// Name of the task - #[arg(index = 1)] - pub name: String, - - /// Plan/description for the task - #[arg(index = 2)] - pub plan: String, - - /// Parent task ID to branch from - #[arg(long)] - pub parent: Option<Uuid>, - - /// Checkpoint SHA to start from - #[arg(long)] - pub checkpoint: Option<String>, - - /// Repository URL (local path or remote URL). If not provided, will try to detect from current directory. - #[arg(long)] - pub repo: Option<String>, -} - -/// Arguments for wait command. -#[derive(Args, Debug)] -pub struct WaitArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// Task ID to wait for - #[arg(index = 1)] - pub task_id: Uuid, - - /// Timeout in seconds (total wait time) - #[arg(index = 2, default_value = "300")] - pub timeout: i32, - - /// Polling interval in seconds (how often to check task status via client-side polling) - #[arg(long, default_value = "5")] - pub poll_interval: u64, -} - -/// Arguments for read-file command. -#[derive(Args, Debug)] -pub struct ReadFileArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// Task ID to read from - #[arg(index = 1)] - pub task_id: Uuid, - - /// File path to read - #[arg(index = 2)] - pub file_path: String, -} - -/// Arguments for branch command. -#[derive(Args, Debug)] -pub struct BranchArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// Branch name to create - #[arg(index = 1)] - pub name: String, - - /// Reference (task ID or SHA) to branch from - #[arg(long)] - pub from: Option<String>, -} - -/// Arguments for merge command. -#[derive(Args, Debug)] -pub struct MergeArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// Task ID to merge - #[arg(index = 1)] - pub task_id: Uuid, - - /// Target branch to merge into - #[arg(long)] - pub to: Option<String>, - - /// Squash commits on merge - #[arg(long)] - pub squash: bool, -} - -/// Arguments for pr command. -#[derive(Args, Debug)] -pub struct PrArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// Branch name to create PR from (e.g., "makima/feature-name") - #[arg(index = 1)] - pub branch: String, - - /// PR title - #[arg(long)] - pub title: String, - - /// PR body/description - #[arg(long)] - pub body: Option<String>, -} - -/// Arguments for diff command. -#[derive(Args, Debug)] -pub struct DiffArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// Task ID to get diff for - #[arg(index = 1)] - pub task_id: Uuid, -} - -/// Arguments for checkpoint command. -#[derive(Args, Debug)] -pub struct CheckpointArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// Checkpoint message - #[arg(index = 1)] - pub message: String, -} - -/// Arguments for ask command (ask user a question). -#[derive(Args, Debug)] -pub struct AskArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// The question to ask - #[arg(index = 1)] - pub question: String, - - /// Optional choices (comma-separated) - #[arg(long)] - pub choices: Option<String>, - - /// Context about what this relates to - #[arg(long)] - pub context: Option<String>, - - /// Timeout in seconds (default: 3600 = 1 hour) - #[arg(long, default_value = "3600")] - pub timeout: i32, - - /// Block indefinitely until user responds (no timeout) - #[arg(long, default_value = "false")] - pub phaseguard: bool, - - /// Allow selecting multiple choices (response will be comma-separated) - #[arg(long, default_value = "false")] - pub multi_select: bool, - - /// Non-blocking mode - returns immediately without waiting for response - #[arg(long, default_value = "false")] - pub non_blocking: bool, - - /// Question type (general, phase_confirmation, contract_complete) - #[arg(long, default_value = "general")] - pub question_type: String, -} - -/// Arguments for status command (get contract status including phase). -#[derive(Args, Debug)] -pub struct StatusArgs { - #[command(flatten)] - pub common: SupervisorArgs, -} - -/// Arguments for advance-phase command. -#[derive(Args, Debug)] -pub struct AdvancePhaseArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// The phase to advance to (specify, plan, execute, review) - #[arg(index = 1)] - pub phase: String, - - /// Confirm the phase transition (required when phase_guard is enabled). - /// Without this flag, the command will return deliverables for review. - #[arg(long, short = 'y')] - pub confirmed: bool, -} - -/// Arguments for mark-deliverable command. -#[derive(Args, Debug)] -pub struct MarkDeliverableArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// The deliverable ID to mark as complete (e.g., 'plan-document', 'pull-request', 'research-notes') - #[arg(index = 1)] - pub deliverable_id: String, - - /// Phase the deliverable belongs to. Defaults to current contract phase if not specified. - #[arg(long)] - pub phase: Option<String>, -} - -/// Arguments for task command (get individual task details). -#[derive(Args, Debug)] -pub struct GetTaskArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// Task ID to get details for - #[arg(index = 1, id = "target_task_id")] - pub target_task_id: Uuid, -} - -/// Arguments for output command (get task output/claude log). -#[derive(Args, Debug)] -pub struct GetTaskOutputArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// Task ID to get output for - #[arg(index = 1, id = "target_task_id")] - pub target_task_id: Uuid, -} - -// ============================================================================ -// History Command Args -// ============================================================================ - -/// Arguments for task-history command. -#[derive(Args, Debug)] -pub struct TaskHistoryArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// Task ID to view history for - #[arg(index = 1)] - pub task_id: Uuid, - - /// Include tool calls in output - #[arg(long, default_value = "true")] - pub tool_calls: bool, - - /// Maximum messages to return - #[arg(long)] - pub limit: Option<i32>, - - /// Output format (table, json, chat) - #[arg(long, default_value = "chat")] - pub format: String, -} - -/// Arguments for task-checkpoints command (with optional diff). -#[derive(Args, Debug)] -pub struct TaskCheckpointsArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// Task ID to list checkpoints for - #[arg(index = 1)] - pub task_id: Uuid, - - /// Include diff summary - #[arg(long)] - pub with_diff: bool, -} - -// ============================================================================ -// Resume Command Args -// ============================================================================ - -/// Arguments for resume command. -#[derive(Args, Debug)] -pub struct ResumeArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// Resume mode: continue, restart_phase, from_checkpoint - #[arg(long, default_value = "continue")] - pub mode: String, - - /// Checkpoint ID (required for from_checkpoint mode) - #[arg(long)] - pub checkpoint: Option<Uuid>, - - /// Additional context to inject - #[arg(long)] - pub context: Option<String>, -} - -/// Arguments for task-resume-from command. -#[derive(Args, Debug)] -pub struct TaskResumeFromArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// Source task ID - #[arg(index = 1)] - pub task_id: Uuid, - - /// Checkpoint number to resume from - #[arg(long)] - pub checkpoint: i32, - - /// Plan for the new task - #[arg(long)] - pub plan: String, - - /// Name for the new task - #[arg(long)] - pub name: Option<String>, -} - -// ============================================================================ -// Rewind Command Args -// ============================================================================ - -/// Arguments for task-rewind command. -#[derive(Args, Debug)] -pub struct TaskRewindArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// Task ID to rewind - #[arg(index = 1)] - pub task_id: Uuid, - - /// Checkpoint number to rewind to - #[arg(long)] - pub checkpoint: i32, - - /// Preserve mode: discard, create_branch, stash - #[arg(long, default_value = "create_branch")] - pub preserve: String, - - /// Branch name (for create_branch mode) - #[arg(long)] - pub branch_name: Option<String>, -} - -/// Arguments for task-fork command. -#[derive(Args, Debug)] -pub struct TaskForkArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// Source task ID - #[arg(index = 1)] - pub task_id: Uuid, - - /// Checkpoint number to fork from - #[arg(long)] - pub checkpoint: i32, - - /// Name for the new task - #[arg(long)] - pub name: String, - - /// Plan for the new task - #[arg(long)] - pub plan: String, - - /// Include conversation history - #[arg(long, default_value = "true")] - pub include_conversation: bool, -} - -/// Arguments for rewind-conversation command. -#[derive(Args, Debug)] -pub struct ConversationRewindArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// Number of messages to rewind - #[arg(long)] - pub by_messages: Option<i32>, - - /// Message ID to rewind to - #[arg(long)] - pub to_message: Option<String>, - - /// Also rewind code to matching checkpoint - #[arg(long)] - pub rewind_code: bool, -} - -/// Arguments for complete command (mark contract as complete). -#[derive(Args, Debug)] -pub struct CompleteArgs { - #[command(flatten)] - pub common: SupervisorArgs, -} - -// ============================================================================ -// Resume Contract Command Args -// ============================================================================ - -/// Arguments for resume-contract command (reactivate a completed contract). -#[derive(Args, Debug)] -pub struct ResumeContractArgs { - /// API URL - #[arg(long, env = "MAKIMA_API_URL", default_value = "https://api.makima.jp")] - pub api_url: String, - - /// API key for authentication - #[arg(long, env = "MAKIMA_API_KEY")] - pub api_key: String, - - /// Contract ID to resume - #[arg(index = 1)] - pub contract_id: Uuid, -} - diff --git a/makima/src/daemon/mod.rs b/makima/src/daemon/mod.rs index 13f0862..e15608b 100644 --- a/makima/src/daemon/mod.rs +++ b/makima/src/daemon/mod.rs @@ -23,6 +23,6 @@ pub mod tui; pub mod worktree; pub mod ws; -pub use cli::{Cli, Commands, ContractCommand, SupervisorCommand, ViewArgs}; +pub use cli::{Cli, Commands, ViewArgs}; pub use config::DaemonConfig; pub use error::{DaemonError, Result}; diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index 1fe6e35..44af939 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -2721,6 +2721,12 @@ pub struct Directive { pub version: i32, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>, + /// True for the per-owner scratchpad directive. Auto-created on first + /// orphan-task creation. Hidden from the directive list; surfaced to + /// users via the sidebar's `tmp/` folder. Tasks attached to a tmp + /// directive are auto-deleted after 30 days. + #[serde(default)] + pub is_tmp: bool, } /// A historical record of a directive goal change. diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index b41c74c..f91bfaa 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -1189,6 +1189,86 @@ pub async fn list_tasks_for_owner( .await } +// ============================================================================= +// Tmp directive — per-owner scratchpad +// ============================================================================= + +/// Get the owner's tmp directive, creating it on the fly if absent. Idempotent +/// thanks to the partial unique index on (owner_id) WHERE is_tmp. +/// +/// We try an INSERT first with ON CONFLICT DO NOTHING; if a row was inserted +/// it's returned, otherwise we fall back to a SELECT for the row some other +/// request just created (or one that already existed). +pub async fn get_or_create_tmp_directive( + pool: &PgPool, + owner_id: Uuid, +) -> Result<Directive, sqlx::Error> { + // Try insert first. RETURNING fires only if a row was actually written; + // if the partial unique index trips (a tmp directive already exists) + // we get None and fall through to the SELECT. + let inserted = sqlx::query_as::<_, Directive>( + r#" + INSERT INTO directives + (owner_id, title, goal, status, reconcile_mode, is_tmp) + VALUES + ($1, 'tmp', '', 'idle', 'auto', true) + ON CONFLICT DO NOTHING + RETURNING * + "#, + ) + .bind(owner_id) + .fetch_optional(pool) + .await?; + + if let Some(d) = inserted { + return Ok(d); + } + + // Pre-existing or just-created-by-someone-else: fetch. + sqlx::query_as::<_, Directive>( + r#"SELECT * FROM directives WHERE owner_id = $1 AND is_tmp = true LIMIT 1"#, + ) + .bind(owner_id) + .fetch_one(pool) + .await +} + +/// Find every tmp directive (across owners). Used by the 30-day expiry +/// sweep — we need to know which directives are scratchpads so we know +/// which tasks to age out. +pub async fn list_all_tmp_directives( + pool: &PgPool, +) -> Result<Vec<Directive>, sqlx::Error> { + sqlx::query_as::<_, Directive>( + r#"SELECT * FROM directives WHERE is_tmp = true"#, + ) + .fetch_all(pool) + .await +} + +/// Delete tasks attached to a tmp directive that are older than 30 days. +/// Returns the number of rows deleted (informational; we log it). +/// +/// We only sweep top-level tasks (parent_task_id IS NULL) — subtasks die +/// when their parent dies via the FK cascade. +pub async fn delete_expired_tmp_tasks( + pool: &PgPool, + tmp_directive_id: Uuid, +) -> Result<u64, sqlx::Error> { + let result = sqlx::query( + r#" + DELETE FROM tasks + WHERE directive_id = $1 + AND parent_task_id IS NULL + AND created_at < NOW() - INTERVAL '30 days' + "#, + ) + .bind(tmp_directive_id) + .execute(pool) + .await?; + Ok(result.rows_affected()) +} + /// List ephemeral tasks attached to a directive — tasks with `directive_id` /// set but no `directive_step_id`. These are the "spinoff" tasks the user /// created via the directive folder context menu, distinct from @@ -1223,14 +1303,15 @@ pub async fn list_ephemeral_directive_tasks_for_owner( .await } -/// List "orphan" top-level tasks for an owner — tasks that are NOT attached -/// to a directive and NOT a subtask of another task. These surface in the -/// document-mode sidebar under a top-level `tmp/` folder. Hidden tasks -/// excluded. -pub async fn list_orphan_tasks_for_owner( +/// List top-level tasks attached to the owner's tmp directive. These are +/// the scratchpad / orphan tasks surfaced under the sidebar's `tmp/` +/// folder. Auto-creates the tmp directive if it doesn't exist yet so the +/// caller never has to handle "no tmp directive". +pub async fn list_tmp_tasks_for_owner( pool: &PgPool, owner_id: Uuid, ) -> Result<Vec<TaskSummary>, sqlx::Error> { + let tmp = get_or_create_tmp_directive(pool, owner_id).await?; sqlx::query_as::<_, TaskSummary>( r#" SELECT @@ -1243,13 +1324,14 @@ pub async fn list_orphan_tasks_for_owner( 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 - AND t.directive_id IS NULL AND COALESCE(t.hidden, false) = false ORDER BY t.priority DESC, t.created_at DESC "#, ) .bind(owner_id) + .bind(tmp.id) .fetch_all(pool) .await } @@ -5066,7 +5148,9 @@ pub async fn get_directive_with_steps_for_owner( } } -/// List all directives for an owner with step counts. +/// List all directives for an owner with step counts. Excludes the per-owner +/// tmp directive (the scratchpad surface; surfaced via the sidebar's +/// dedicated `tmp/` folder, not the regular directive list). pub async fn list_directives_for_owner( pool: &PgPool, owner_id: Uuid, @@ -5093,6 +5177,7 @@ pub async fn list_directives_for_owner( WHERE directive_id = d.id ) s ON true WHERE d.owner_id = $1 + AND d.is_tmp = false ORDER BY d.created_at DESC "#, ) diff --git a/makima/src/orchestration/directive.rs b/makima/src/orchestration/directive.rs index 1e004bf..80d8172 100644 --- a/makima/src/orchestration/directive.rs +++ b/makima/src/orchestration/directive.rs @@ -18,11 +18,22 @@ use crate::server::state::{DaemonCommand, SharedState}; pub struct DirectiveOrchestrator { pool: PgPool, state: SharedState, + /// Last time we ran the tmp-task expiry sweep. Throttled to once an + /// hour so the deletion query doesn't run on every 15-second tick. + last_tmp_sweep: std::time::Instant, } impl DirectiveOrchestrator { pub fn new(pool: PgPool, state: SharedState) -> Self { - Self { pool, state } + Self { + pool, + state, + // Initialise to 1 hour ago so the first tick after startup runs + // the sweep immediately — clears any tasks that aged out while + // the server was down. + last_tmp_sweep: std::time::Instant::now() + - std::time::Duration::from_secs(3600), + } } /// Run one orchestration tick — called every 15s. @@ -42,6 +53,14 @@ impl DirectiveOrchestrator { if let Err(e) = self.phase_completion().await { tracing::warn!(error = %e, "Directive phase_completion failed"); } + // Throttled to hourly — the actual delete is cheap (indexed + // partial scan) but we don't want to log a sweep every 15s. + if self.last_tmp_sweep.elapsed() >= std::time::Duration::from_secs(3600) { + self.last_tmp_sweep = std::time::Instant::now(); + if let Err(e) = self.phase_tmp_expiry().await { + tracing::warn!(error = %e, "Directive phase_tmp_expiry failed"); + } + } Ok(()) } @@ -100,40 +119,18 @@ impl DirectiveOrchestrator { let steps = repository::get_ready_steps_for_dispatch(&self.pool).await?; for step in steps { - // If the step has a contract_type, create a contract instead of a standalone task + // contract_type used to spawn a heavyweight contract+supervisor + // for a step. The contracts subsystem has been removed (Phase 5); + // we now treat any contract-backed step as a plain standalone + // task. The column itself is left in place for one more release + // so old data still reads cleanly, but it has no effect. if step.contract_type.is_some() { - tracing::info!( + tracing::warn!( step_id = %step.step_id, directive_id = %step.directive_id, - step_name = %step.step_name, contract_type = ?step.contract_type, - "Spawning contract for contract-backed step" + "Step has legacy contract_type; falling back to standalone task spawn" ); - - match self - .spawn_step_contract( - step.step_id, - step.directive_id, - step.owner_id, - &step.step_name, - step.step_description.as_deref(), - step.task_plan.as_deref(), - step.contract_type.as_deref().unwrap_or("simple"), - step.repository_url.as_deref(), - step.base_branch.as_deref(), - ) - .await - { - Ok(()) => {} - Err(e) => { - tracing::warn!( - step_id = %step.step_id, - error = %e, - "Failed to spawn contract for step" - ); - } - } - continue; } tracing::info!( @@ -647,141 +644,9 @@ impl DirectiveOrchestrator { Ok(()) } - /// Spawn a contract for a contract-backed step. - /// Creates a contract, adds the directive's repository to it, links it to the step, - /// creates a supervisor task, and marks the step as running. - async fn spawn_step_contract( - &self, - step_id: Uuid, - directive_id: Uuid, - owner_id: Uuid, - step_name: &str, - step_description: Option<&str>, - task_plan: Option<&str>, - contract_type: &str, - repo_url: Option<&str>, - base_branch: Option<&str>, - ) -> Result<(), anyhow::Error> { - // Build contract description from step info - let description = match (step_description, task_plan) { - (Some(desc), Some(plan)) => Some(format!("{}\n\n{}", desc, plan)), - (Some(desc), None) => Some(desc.to_string()), - (None, Some(plan)) => Some(plan.to_string()), - (None, None) => None, - }; - - // Create the contract - let contract_req = CreateContractRequest { - name: step_name.to_string(), - description, - contract_type: Some(contract_type.to_string()), - template_id: None, - initial_phase: None, - autonomous_loop: Some(true), - phase_guard: None, - local_only: None, - auto_merge_local: None, - }; - - let contract = repository::create_contract_for_owner(&self.pool, owner_id, contract_req).await?; - - tracing::info!( - step_id = %step_id, - contract_id = %contract.id, - contract_type = %contract.contract_type, - "Created contract for directive step" - ); - - // Link the contract to the step - repository::link_contract_to_step(&self.pool, step_id, contract.id).await?; - - // Add the directive's repository to the contract (if available) - if let Some(url) = repo_url { - if let Err(e) = repository::add_remote_repository( - &self.pool, - contract.id, - step_name, - url, - true, // is_primary - ) - .await - { - tracing::warn!( - contract_id = %contract.id, - error = %e, - "Failed to add repository to contract — continuing without it" - ); - } - } - - // Create supervisor task for the contract (following the pattern from contract handlers) - let supervisor_name = format!("{} Supervisor", step_name); - let supervisor_plan = format!( - "You are the supervisor for contract '{}'. Your goal is to drive this contract to completion.\n\n{}", - step_name, - contract.description.as_deref().unwrap_or("No description provided.") - ); - - let supervisor_req = CreateTaskRequest { - name: supervisor_name.clone(), - description: None, - plan: supervisor_plan.clone(), - repository_url: repo_url.map(|s| s.to_string()), - base_branch: base_branch.map(|s| s.to_string()), - target_branch: None, - parent_task_id: None, - contract_id: Some(contract.id), - target_repo_path: None, - completion_action: None, - continue_from_task_id: None, - copy_files: None, - is_supervisor: true, - checkpoint_sha: None, - priority: 0, - merge_mode: None, - branched_from_task_id: None, - conversation_history: None, - supervisor_worktree_task_id: None, - directive_id: Some(directive_id), - directive_step_id: Some(step_id), - }; - - let supervisor_task = repository::create_task_for_owner(&self.pool, owner_id, supervisor_req).await?; - - tracing::info!( - contract_id = %contract.id, - supervisor_task_id = %supervisor_task.id, - "Created supervisor task for contract-backed step" - ); - - // Link supervisor task to contract - let update_req = UpdateContractRequest { - supervisor_task_id: Some(supervisor_task.id), - version: Some(contract.version), - ..Default::default() - }; - if let Err(e) = repository::update_contract_for_owner(&self.pool, contract.id, owner_id, update_req).await { - tracing::warn!( - contract_id = %contract.id, - error = %e, - "Failed to link supervisor task to contract" - ); - } - - // Try to dispatch the supervisor task to a daemon - if self - .try_dispatch_task(supervisor_task.id, owner_id, &supervisor_task.name, &supervisor_task.plan, supervisor_task.version) - .await - { - repository::set_step_running(&self.pool, step_id).await?; - } else { - // Even if dispatch fails, mark step as running since contract is created. - // The supervisor task will be retried by the pending task retry logic. - repository::set_step_running(&self.pool, step_id).await?; - } - - Ok(()) - } + // spawn_step_contract was removed in Phase 5 — the contracts subsystem + // is gone. Step rows with `contract_type` set are now silently treated + // as standalone tasks (see the warn! in phase_execution). /// Try to dispatch a task to an available daemon. Returns true if dispatched. async fn try_dispatch_task( @@ -877,6 +742,40 @@ impl DirectiveOrchestrator { false } + /// Hourly sweep — delete top-level tasks attached to any tmp directive + /// that are older than 30 days. Per-owner; no global cap. Subtasks die + /// via the FK cascade. + async fn phase_tmp_expiry(&self) -> Result<(), anyhow::Error> { + let tmps = repository::list_all_tmp_directives(&self.pool).await?; + let mut total_deleted: u64 = 0; + for d in tmps { + match repository::delete_expired_tmp_tasks(&self.pool, d.id).await { + Ok(n) => { + if n > 0 { + tracing::info!( + directive_id = %d.id, + owner_id = %d.owner_id, + deleted = n, + "Expired tmp tasks deleted (>30 days old)" + ); + total_deleted += n; + } + } + Err(e) => { + tracing::warn!( + directive_id = %d.id, + error = %e, + "Failed to expire tmp tasks for owner" + ); + } + } + } + if total_deleted > 0 { + tracing::info!(total = total_deleted, "Tmp expiry sweep completed"); + } + Ok(()) + } + /// Phase 5: Completion — spawn PR-creation tasks for idle directives. async fn phase_completion(&self) -> Result<(), anyhow::Error> { // Part 1: Spawn completion tasks for idle directives diff --git a/makima/src/server/handlers/contract_chat.rs b/makima/src/server/handlers/contract_chat.rs deleted file mode 100644 index 5d8ab3e..0000000 --- a/makima/src/server/handlers/contract_chat.rs +++ /dev/null @@ -1,3183 +0,0 @@ -//! Chat endpoint for LLM-powered contract management. -//! -//! This handler provides an agentic loop for managing contracts: creating tasks, -//! adding files, managing repositories, and handling phase transitions. - -use axum::{ - extract::{Path, State}, - http::StatusCode, - response::IntoResponse, - Json, -}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use utoipa::ToSchema; -use uuid::Uuid; - -use crate::db::{ - models::{ - ContractChatHistoryResponse, ContractWithRelations, CreateTaskRequest, UpdateFileRequest, - }, - repository, -}; -use crate::llm::{ - analyze_task_output, body_to_markdown, format_checklist_markdown, - format_parsed_tasks, parse_tasks_from_breakdown, - claude::{self, ClaudeClient, ClaudeError, ClaudeModel}, - groq::{GroqClient, GroqError, Message, ToolCallResponse}, - parse_contract_tool_call, ContractToolRequest, - LlmModel, TaskInfo, ToolCall, ToolResult, UserQuestion, CONTRACT_TOOLS, - format_transcript_for_analysis, calculate_speaker_stats, - build_analysis_prompt, parse_analysis_response, -}; -use crate::server::auth::Authenticated; -use crate::server::state::{DaemonCommand, SharedState}; - -/// Maximum number of tool-calling rounds to prevent infinite loops -const MAX_TOOL_ROUNDS: usize = 30; - -#[derive(Debug, Clone, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ContractChatHistoryMessage { - /// Role: "user" or "assistant" - pub role: String, - /// Message content - pub content: String, -} - -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ContractChatRequest { - /// The user's message/instruction - pub message: String, - /// Optional model selection: "claude-sonnet" (default), "claude-opus", or "groq" - #[serde(default)] - pub model: Option<String>, - /// Optional conversation history for context continuity - #[serde(default)] - pub history: Option<Vec<ContractChatHistoryMessage>>, -} - -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ContractChatResponse { - /// The LLM's response message - pub response: String, - /// Tool calls that were executed - pub tool_calls: Vec<ContractToolCallInfo>, - /// Questions pending user answers (pauses conversation) - #[serde(skip_serializing_if = "Option::is_none")] - pub pending_questions: Option<Vec<UserQuestion>>, -} - -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ContractToolCallInfo { - pub name: String, - pub result: ToolResult, -} - -/// Enum to hold LLM clients -enum LlmClient { - Groq(GroqClient), - Claude(ClaudeClient), -} - -/// Unified result from LLM call -struct LlmResult { - content: Option<String>, - tool_calls: Vec<ToolCall>, - raw_tool_calls: Vec<ToolCallResponse>, - finish_reason: String, -} - -/// Helper to get contract with all relations -async fn get_contract_with_relations( - pool: &sqlx::PgPool, - contract_id: Uuid, - owner_id: Uuid, -) -> Result<Option<ContractWithRelations>, sqlx::Error> { - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await? { - Some(c) => c, - None => return Ok(None), - }; - - let repositories = repository::list_contract_repositories(pool, contract_id) - .await - .unwrap_or_default(); - - let files = repository::list_files_in_contract(pool, contract_id, owner_id) - .await - .unwrap_or_default(); - - let tasks = repository::list_tasks_in_contract(pool, contract_id, owner_id) - .await - .unwrap_or_default(); - - Ok(Some(ContractWithRelations { - contract, - repositories, - files, - tasks, - })) -} - -/// Chat with a contract using LLM tool calling for management -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/chat", - request_body = ContractChatRequest, - responses( - (status = 200, description = "Chat completed successfully", body = ContractChatResponse), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Contract not found"), - (status = 500, description = "Internal server error") - ), - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn contract_chat_handler( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(contract_id): Path<Uuid>, - Json(request): Json<ContractChatRequest>, -) -> impl IntoResponse { - // Check if database is configured - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "Database not configured" })), - ) - .into_response(); - }; - - // Get the contract (scoped by owner) - let contract = match get_contract_with_relations(pool, contract_id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({ "error": "Contract not found" })), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Database error: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Database error: {}", e) })), - ) - .into_response(); - } - }; - - // Parse model selection (default to Claude Sonnet) - let model = request - .model - .as_ref() - .and_then(|m| LlmModel::from_str(m)) - .unwrap_or(LlmModel::ClaudeSonnet); - - tracing::info!("Contract chat using LLM model: {:?}", model); - - // Initialize the appropriate LLM client - let llm_client = match model { - LlmModel::ClaudeSonnet => match ClaudeClient::from_env(ClaudeModel::Sonnet) { - Ok(client) => LlmClient::Claude(client), - Err(ClaudeError::MissingApiKey) => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Claude client error: {}", e) })), - ) - .into_response(); - } - }, - LlmModel::ClaudeOpus => match ClaudeClient::from_env(ClaudeModel::Opus) { - Ok(client) => LlmClient::Claude(client), - Err(ClaudeError::MissingApiKey) => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Claude client error: {}", e) })), - ) - .into_response(); - } - }, - LlmModel::GroqKimi => match GroqClient::from_env() { - Ok(client) => LlmClient::Groq(client), - Err(GroqError::MissingApiKey) => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "GROQ_API_KEY not configured" })), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Groq client error: {}", e) })), - ) - .into_response(); - } - }, - }; - - // Build contract context - let contract_context = build_contract_context(&contract); - - // Build system prompt for contract management - let system_prompt = format!( - r#"You are an intelligent contract management agent. You guide users through the contract lifecycle from research to completion, helping them organize work, create documentation, set up repositories, and execute tasks. - -## Your Capabilities -You have access to tools for: -- **Query**: get_contract_status, list_contract_files, list_contract_tasks, list_contract_repositories, read_file -- **File Management**: create_file_from_template, create_empty_file, list_available_templates -- **Task Management**: create_contract_task, delegate_content_generation, start_task -- **Phase Management**: get_phase_info, suggest_phase_transition, advance_phase -- **Repository Management**: list_daemon_directories, add_repository, set_primary_repository -- **Interactive**: ask_user - -## Content Generation Deferral -When asked to write substantial content, fill templates, or generate documentation: -- **Use delegate_content_generation** to create a task for the content generation -- This delegates the work to a task agent that can do more thorough research and writing - -**Use delegation for:** -- Filling in template content with real data -- Writing documentation based on requirements -- Generating user stories or specifications -- Creating detailed design documents -- Any substantial writing that requires research or analysis - -**Direct actions (no delegation needed):** -- Listing files/tasks/repos -- Reading files -- Phase transitions -- Creating empty files or templates -- Simple queries and status checks -- Asking user questions - -## Contract Lifecycle Phases - -### 1. RESEARCH Phase -**Purpose**: Gather information and understand the problem space -**Key Activities**: -- Conduct user research and interviews -- Analyze competitors and existing solutions -- Document findings and insights -- Identify opportunities and constraints -**Suggested Actions**: -- Create a "Research Notes" document to capture findings -- Create a "Competitor Analysis" document -- When research is complete, suggest transitioning to Specify phase - -### 2. SPECIFY Phase -**Purpose**: Define what needs to be built -**Key Activities**: -- Write clear requirements -- Create user stories with acceptance criteria -- Define scope and constraints -- Document technical constraints -**Suggested Actions**: -- Create a "Requirements" document -- Create "User Stories" with acceptance criteria -- When specifications are clear, suggest transitioning to Plan phase - -### 3. PLAN Phase -**Purpose**: Design the solution and break down the work -**Key Activities**: -- Design system architecture -- Create technical specifications -- Break work into implementable tasks -- Set up repositories for development -**Suggested Actions**: -- Create an "Architecture" document -- Create a "Task Breakdown" document -- **IMPORTANT**: Help set up a repository if not already configured -- When planning is complete and a repository is set, suggest transitioning to Execute phase - -### 4. EXECUTE Phase -**Purpose**: Implement the solution -**Key Activities**: -- Create and run tasks to implement features -- Write and run tests -- Track progress -- Document implementation decisions -**Suggested Actions**: -- Create tasks based on the task breakdown -- Monitor task progress and help resolve blockers -- When all tasks are complete, suggest transitioning to Review phase - -### 5. REVIEW Phase -**Purpose**: Validate and document the completed work -**Key Activities**: -- Review completed work -- Create release notes -- Conduct retrospective -- Document learnings -**Suggested Actions**: -- Create a "Release Notes" document -- Create a "Retrospective" document -- Help mark the contract as complete when review is done - -## Current Contract -{contract_context} - -## Proactive Guidance - -### Repository Setup (Critical for Plan/Execute phases) -When the user wants to add a local repository or set up for execution: -1. **First call list_daemon_directories** to get available paths from connected agents -2. Present the suggested directories to the user -3. Ask which path they want to use, or let them specify a custom path -4. Then call add_repository with the chosen path - -Example flow: -``` -User: "Set up a repository for this contract" -You: Call list_daemon_directories first -You: "I found these directories from your connected agent: - - /Users/alice/projects (Working Directory) - - /Users/alice/.makima/home (Makima Home) - Which would you like to use, or provide a custom path?" -``` - -### Phase Transitions -- Phases progress in order: research -> specify -> plan -> execute -> review -- You can ONLY advance forward one step at a time to the NEXT phase -- ALWAYS use suggest_phase_transition FIRST to get the exact nextPhase value -- Then use advance_phase with that exact nextPhase value -- Example: If currentPhase is "specify", nextPhase will be "plan" - use advance_phase with new_phase="plan" -- NEVER suggest advancing to the same phase the contract is already in - -### New Users -When a new contract is created or the user seems unsure: -1. Explain the current phase and what should be done -2. Suggest creating appropriate documents -3. Guide them toward the next milestone - -## Agentic Behavior Guidelines - -### 1. Understand Before Acting -- For complex requests, first gather information about the contract's current state -- Use get_contract_status or list_contract_files to understand what exists -- Consider the current phase when suggesting actions - -### 2. Phase-Appropriate Suggestions -- Suggest templates and actions appropriate for the current phase -- When creating files, prefer templates that match the contract's phase -- Advise when the contract might be ready for the next phase - -### 3. Help Plan Work -- When asked to plan work, read existing files to understand context -- Suggest creating tasks based on requirements or plans in files -- Offer to create task breakdowns from design documents - -### 4. Repository Management -- When adding local repositories, ALWAYS use list_daemon_directories first to get suggestions -- This provides the user with valid paths from their connected agents -- Don't ask users to manually type paths when suggestions are available - -### 5. Task Creation and Execution -- When creating tasks, derive plans from existing contract files when possible -- Use the contract's primary repository for tasks by default -- Create clear, actionable task plans -- After creating a task, you can use **start_task** to immediately begin execution -- A daemon must be connected for start_task to work - -### 6. Be Proactive but Efficient -- Guide users through the contract flow -- Don't over-analyze simple requests -- Use the minimum number of tool calls needed -- Provide clear summaries of actions taken - -## Important Notes -- This contract's ID is: {contract_id} -- All operations are scoped to this contract -- When creating tasks or files, they are automatically associated with this contract"#, - contract_context = contract_context, - contract_id = contract_id - ); - - // Run the agentic loop - run_contract_agentic_loop( - pool, - &state, - &llm_client, - system_prompt, - &request, - contract_id, - auth.owner_id, - ) - .await -} - -fn build_contract_context(contract: &crate::db::models::ContractWithRelations) -> String { - let c = &contract.contract; - let mut context = format!( - "Name: {}\nID: {}\nPhase: {}\nStatus: {}\nContract Type: {}\nAutonomous Loop: {}\n", - c.name, c.id, c.phase, c.status, c.contract_type, c.autonomous_loop - ); - - if let Some(ref desc) = c.description { - context.push_str(&format!("Description: {}\n", desc)); - } - - // Get completed deliverables for the current phase - let completed_deliverables = c.get_completed_deliverables(&c.phase); - - // Build task infos for checklist - let task_infos: Vec<TaskInfo> = contract.tasks.iter().map(|t| TaskInfo { - name: t.name.clone(), - status: t.status.clone(), - }).collect(); - - let has_repository = !contract.repositories.is_empty(); - let phase_checklist = crate::llm::get_phase_checklist_for_type(&c.phase, &completed_deliverables, &task_infos, has_repository, &c.contract_type); - - // Add phase checklist to context - context.push_str("\n"); - context.push_str(&format_checklist_markdown(&phase_checklist)); - - // Add deliverable check result for phase transition readiness - let deliverable_check = crate::llm::check_deliverables_met( - &c.phase, - &c.contract_type, - &completed_deliverables, - &task_infos, - has_repository, - ); - - // Add deliverable prompt guidance - context.push_str(&crate::llm::generate_deliverable_prompt_guidance( - &c.phase, - &c.contract_type, - &deliverable_check, - )); - - // Files summary - context.push_str(&format!("\n### Files ({} total)\n", contract.files.len())); - if !contract.files.is_empty() { - for file in contract.files.iter().take(5) { - let phase_label = file.contract_phase.as_deref().unwrap_or("none"); - context.push_str(&format!("- {} [{}] (ID: {})\n", file.name, phase_label, file.id)); - } - if contract.files.len() > 5 { - context.push_str(&format!("... and {} more\n", contract.files.len() - 5)); - } - } - - // Tasks summary - context.push_str(&format!("\n### Tasks ({} total)\n", contract.tasks.len())); - if !contract.tasks.is_empty() { - let pending = contract.tasks.iter().filter(|t| t.status == "pending").count(); - let running = contract.tasks.iter().filter(|t| t.status == "running").count(); - let done = contract.tasks.iter().filter(|t| t.status == "done").count(); - context.push_str(&format!("{} pending, {} running, {} done\n", pending, running, done)); - for task in contract.tasks.iter().take(5) { - context.push_str(&format!("- {} ({}) - ID: {}\n", task.name, task.status, task.id)); - } - if contract.tasks.len() > 5 { - context.push_str(&format!("... and {} more\n", contract.tasks.len() - 5)); - } - } - - // Repositories summary - context.push_str(&format!("\n### Repositories ({} total)\n", contract.repositories.len())); - if !contract.repositories.is_empty() { - for repo in &contract.repositories { - let primary = if repo.is_primary { " (primary)" } else { "" }; - let url_or_path = repo.repository_url.as_deref() - .or(repo.local_path.as_deref()) - .unwrap_or("managed"); - context.push_str(&format!("- {}: {}{}\n", repo.name, url_or_path, primary)); - } - } - - context -} - -/// Summarize older conversation history to reduce token usage -async fn summarize_conversation_history( - llm_client: &LlmClient, - messages: &[&crate::db::models::ContractChatMessageRecord], -) -> String { - // Build conversation text for summarization - let mut conversation_text = String::new(); - for msg in messages { - let role_label = if msg.role == "user" { "User" } else { "Assistant" }; - // Limit each message to avoid overwhelming the summarizer - let content = if msg.content.len() > 500 { - format!("{}...", &msg.content[..500]) - } else { - msg.content.clone() - }; - conversation_text.push_str(&format!("{}: {}\n", role_label, content)); - } - - // Limit total text to summarize - if conversation_text.len() > 8000 { - conversation_text = format!("{}...", &conversation_text[..8000]); - } - - let summary_prompt = format!( - "Summarize this conversation history in 2-3 sentences, focusing on key decisions, actions taken, and current state:\n\n{}", - conversation_text - ); - - // Use a simple chat call without tools for summarization - let summary = match llm_client { - LlmClient::Claude(client) => { - let claude_messages = vec![claude::Message { - role: "user".to_string(), - content: claude::MessageContent::Text(summary_prompt.clone()), - }]; - match client.chat_with_tools(claude_messages, &[]).await { - Ok(response) => response.content.unwrap_or_default(), - Err(e) => { - tracing::warn!("Failed to summarize conversation: {}", e); - "Previous conversation covered contract management tasks.".to_string() - } - } - } - LlmClient::Groq(client) => { - let groq_messages = vec![Message { - role: "user".to_string(), - content: Some(summary_prompt.clone()), - tool_calls: None, - tool_call_id: None, - }]; - match client.chat_with_tools(groq_messages, &[]).await { - Ok(response) => response.content.unwrap_or_default(), - Err(e) => { - tracing::warn!("Failed to summarize conversation: {}", e); - "Previous conversation covered contract management tasks.".to_string() - } - } - } - }; - - // Limit summary length - if summary.len() > 500 { - format!("{}...", &summary[..500]) - } else { - summary - } -} - -/// Run the agentic loop for contract chat -async fn run_contract_agentic_loop( - pool: &sqlx::PgPool, - state: &SharedState, - llm_client: &LlmClient, - system_prompt: String, - request: &ContractChatRequest, - contract_id: Uuid, - owner_id: Uuid, -) -> axum::response::Response { - // Get or create the conversation for persistent history - let conversation = match repository::get_or_create_contract_conversation(pool, contract_id, owner_id).await { - Ok(conv) => conv, - Err(e) => { - tracing::error!("Failed to get/create contract conversation: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Failed to initialize conversation: {}", e) })), - ) - .into_response(); - } - }; - - // Load ALL existing messages from database - let saved_messages = match repository::list_contract_chat_messages(pool, conversation.id, None).await { - Ok(msgs) => msgs, - Err(e) => { - tracing::warn!("Failed to load contract chat history: {}", e); - Vec::new() - } - }; - - // Build initial messages - let mut messages = vec![Message { - role: "system".to_string(), - content: Some(system_prompt), - tool_calls: None, - tool_call_id: None, - }]; - - // Add saved conversation history, summarizing older messages if needed - // to stay under rate limits (~25k chars ≈ ~6k tokens for history) - const MAX_HISTORY_CHARS: usize = 25000; - const RECENT_MESSAGES_TO_KEEP: usize = 6; // Keep last 3 turns intact - - // Filter to user/assistant messages only - let history_messages: Vec<_> = saved_messages - .iter() - .filter(|m| m.role == "user" || m.role == "assistant") - .collect(); - - // Calculate total character count - let total_chars: usize = history_messages.iter().map(|m| m.content.len()).sum(); - - if total_chars > MAX_HISTORY_CHARS && history_messages.len() > RECENT_MESSAGES_TO_KEEP { - // Need to summarize older messages - let split_point = history_messages.len().saturating_sub(RECENT_MESSAGES_TO_KEEP); - let older_messages = &history_messages[..split_point]; - let recent_messages = &history_messages[split_point..]; - - // Generate summary of older conversation - let summary = summarize_conversation_history(&llm_client, older_messages).await; - - // Add summary as context - messages.push(Message { - role: "user".to_string(), - content: Some(format!("[Previous conversation summary: {}]", summary)), - tool_calls: None, - tool_call_id: None, - }); - messages.push(Message { - role: "assistant".to_string(), - content: Some("I understand the previous context. Let's continue.".to_string()), - tool_calls: None, - tool_call_id: None, - }); - - // Add recent messages in full - for saved_msg in recent_messages { - messages.push(Message { - role: saved_msg.role.clone(), - content: Some(saved_msg.content.clone()), - tool_calls: None, - tool_call_id: None, - }); - } - - tracing::info!( - total_messages = history_messages.len(), - summarized = older_messages.len(), - kept_recent = recent_messages.len(), - "Summarized older conversation history" - ); - } else { - // Add all messages directly - for saved_msg in history_messages { - messages.push(Message { - role: saved_msg.role.clone(), - content: Some(saved_msg.content.clone()), - tool_calls: None, - tool_call_id: None, - }); - } - } - - // Add current user message - messages.push(Message { - role: "user".to_string(), - content: Some(request.message.clone()), - tool_calls: None, - tool_call_id: None, - }); - - // Save the user message to database - if let Err(e) = repository::add_contract_chat_message( - pool, - conversation.id, - "user", - &request.message, - None, - None, - ).await { - tracing::warn!("Failed to save user message to contract chat history: {}", e); - } - - // State for tracking - let mut all_tool_call_infos: Vec<ContractToolCallInfo> = Vec::new(); - let mut final_response: Option<String> = None; - let mut consecutive_failures = 0; - const MAX_CONSECUTIVE_FAILURES: usize = 3; - let mut pending_questions: Option<Vec<UserQuestion>> = None; - - // Multi-turn agentic tool calling loop - for round in 0..MAX_TOOL_ROUNDS { - tracing::info!( - round = round, - total_tool_calls = all_tool_call_infos.len(), - "Contract agentic loop iteration" - ); - - // Check consecutive failures - if consecutive_failures >= MAX_CONSECUTIVE_FAILURES { - tracing::warn!( - "Breaking contract loop due to {} consecutive failures", - consecutive_failures - ); - final_response = Some( - "I encountered multiple consecutive errors and stopped. \ - Please check the contract state and try again." - .to_string(), - ); - break; - } - - // Call the appropriate LLM API - let result = match llm_client { - LlmClient::Groq(groq) => { - match groq.chat_with_tools(messages.clone(), &CONTRACT_TOOLS).await { - Ok(r) => LlmResult { - content: r.content, - tool_calls: r.tool_calls, - raw_tool_calls: r.raw_tool_calls, - finish_reason: r.finish_reason, - }, - Err(e) => { - tracing::error!("Groq API error: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("LLM API error: {}", e) })), - ) - .into_response(); - } - } - } - LlmClient::Claude(claude_client) => { - let claude_messages = claude::groq_messages_to_claude(&messages); - match claude_client - .chat_with_tools(claude_messages, &CONTRACT_TOOLS) - .await - { - Ok(r) => { - let raw_tool_calls: Vec<ToolCallResponse> = r - .tool_calls - .iter() - .map(|tc| ToolCallResponse { - id: tc.id.clone(), - call_type: "function".to_string(), - function: crate::llm::groq::FunctionCall { - name: tc.name.clone(), - arguments: tc.arguments.to_string(), - }, - }) - .collect(); - - LlmResult { - content: r.content, - tool_calls: r.tool_calls, - raw_tool_calls, - finish_reason: r.stop_reason, - } - } - Err(e) => { - tracing::error!("Claude API error: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("LLM API error: {}", e) })), - ) - .into_response(); - } - } - } - }; - - // Check if there are tool calls to execute - if result.tool_calls.is_empty() { - final_response = result.content; - break; - } - - // Add assistant message with tool calls to conversation - messages.push(Message { - role: "assistant".to_string(), - content: result.content.clone(), - tool_calls: Some(result.raw_tool_calls.clone()), - tool_call_id: None, - }); - - // Execute each tool call - for (i, tool_call) in result.tool_calls.iter().enumerate() { - tracing::info!(tool = %tool_call.name, round = round, "Executing contract tool call"); - - // Parse the tool call - let mut execution_result = parse_contract_tool_call(tool_call); - - // Handle async contract tool requests - if let Some(contract_request) = execution_result.request.take() { - let async_result = - handle_contract_request(pool, &state.daemon_connections, contract_request, contract_id, owner_id).await; - execution_result.success = async_result.success; - execution_result.message = async_result.message; - execution_result.data = async_result.data; - } - - // Track consecutive failures - if execution_result.success { - consecutive_failures = 0; - } else { - consecutive_failures += 1; - tracing::warn!( - tool = %tool_call.name, - consecutive_failures = consecutive_failures, - "Contract tool call failed" - ); - } - - // Check for pending user questions - if let Some(questions) = execution_result.pending_questions { - tracing::info!( - question_count = questions.len(), - "Contract LLM requesting user input" - ); - pending_questions = Some(questions); - all_tool_call_infos.push(ContractToolCallInfo { - name: tool_call.name.clone(), - result: ToolResult { - success: execution_result.success, - message: execution_result.message.clone(), - }, - }); - break; - } - - // Build tool result message - let result_content = if let Some(data) = &execution_result.data { - json!({ - "success": execution_result.success, - "message": execution_result.message, - "data": data - }) - .to_string() - } else { - json!({ - "success": execution_result.success, - "message": execution_result.message - }) - .to_string() - }; - - // Add tool result message - let tool_call_id = match llm_client { - LlmClient::Groq(_) => result.raw_tool_calls[i].id.clone(), - LlmClient::Claude(_) => tool_call.id.clone(), - }; - - messages.push(Message { - role: "tool".to_string(), - content: Some(result_content), - tool_calls: None, - tool_call_id: Some(tool_call_id), - }); - - // Track for response - all_tool_call_infos.push(ContractToolCallInfo { - name: tool_call.name.clone(), - result: ToolResult { - success: execution_result.success, - message: execution_result.message, - }, - }); - } - - // If user questions are pending, pause - if pending_questions.is_some() { - final_response = result.content; - break; - } - - // If finish reason indicates completion, exit loop - let finish_lower = result.finish_reason.to_lowercase(); - if finish_lower == "stop" || finish_lower == "end_turn" { - final_response = result.content; - break; - } - } - - // Build response - let response_text = final_response.unwrap_or_else(|| { - if all_tool_call_infos.is_empty() { - "I couldn't understand your request. Please try rephrasing.".to_string() - } else { - format!( - "Done! Executed {} tool{}.", - all_tool_call_infos.len(), - if all_tool_call_infos.len() == 1 { "" } else { "s" } - ) - } - }); - - // Save assistant response to database - let tool_calls_json = if all_tool_call_infos.is_empty() { - None - } else { - serde_json::to_value(&all_tool_call_infos).ok() - }; - - let pending_questions_json = pending_questions.as_ref().and_then(|q| serde_json::to_value(q).ok()); - - if let Err(e) = repository::add_contract_chat_message( - pool, - conversation.id, - "assistant", - &response_text, - tool_calls_json, - pending_questions_json, - ).await { - tracing::warn!("Failed to save assistant response to contract chat history: {}", e); - } - - ( - StatusCode::OK, - Json(ContractChatResponse { - response: response_text, - tool_calls: all_tool_call_infos, - pending_questions, - }), - ) - .into_response() -} - -/// Result from handling an async contract tool request -struct ContractRequestResult { - success: bool, - message: String, - data: Option<serde_json::Value>, -} - -/// Handle async contract tool requests that require database access -async fn handle_contract_request( - pool: &sqlx::PgPool, - daemon_connections: &dashmap::DashMap<String, crate::server::state::DaemonConnectionInfo>, - request: ContractToolRequest, - contract_id: Uuid, - owner_id: Uuid, -) -> ContractRequestResult { - match request { - ContractToolRequest::ListDaemonDirectories => { - let mut directories = Vec::new(); - - // Iterate over connected daemons belonging to this owner - for entry in daemon_connections.iter() { - let daemon = entry.value(); - - // Only include daemons belonging to this owner - if daemon.owner_id != owner_id { - continue; - } - - // Add working directory if available - if let Some(ref working_dir) = daemon.working_directory { - directories.push(json!({ - "path": working_dir, - "label": "Working Directory", - "type": "working", - "hostname": daemon.hostname, - })); - } - - // Add home directory if available - if let Some(ref home_dir) = daemon.home_directory { - directories.push(json!({ - "path": home_dir, - "label": "Makima Home", - "type": "home", - "hostname": daemon.hostname, - })); - } - } - - if directories.is_empty() { - ContractRequestResult { - success: true, - message: "No daemon directories available. Connect a daemon to get directory suggestions.".to_string(), - data: Some(json!({ "directories": [] })), - } - } else { - ContractRequestResult { - success: true, - message: format!("Found {} suggested directories from connected daemons", directories.len()), - data: Some(json!({ "directories": directories })), - } - } - } - - ContractToolRequest::GetContractStatus => { - match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(cwr)) => { - let c = &cwr.contract; - ContractRequestResult { - success: true, - message: format!( - "Contract '{}' is in '{}' phase with status '{}'", - c.name, c.phase, c.status - ), - data: Some(json!({ - "name": c.name, - "phase": c.phase, - "status": c.status, - "description": c.description, - "fileCount": cwr.files.len(), - "taskCount": cwr.tasks.len(), - "repositoryCount": cwr.repositories.len(), - })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - ContractToolRequest::ListContractFiles => { - match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(cwr)) => { - let files: Vec<serde_json::Value> = cwr - .files - .iter() - .map(|f| { - json!({ - "fileId": f.id, - "name": f.name, - "description": f.description, - "phase": f.contract_phase, - }) - }) - .collect(); - - ContractRequestResult { - success: true, - message: format!("Found {} files", files.len()), - data: Some(json!({ "files": files })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - ContractToolRequest::ListContractTasks => { - match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(cwr)) => { - let tasks: Vec<serde_json::Value> = cwr - .tasks - .iter() - .map(|t| { - json!({ - "taskId": t.id, - "name": t.name, - "status": t.status, - "priority": t.priority, - }) - }) - .collect(); - - ContractRequestResult { - success: true, - message: format!("Found {} tasks", tasks.len()), - data: Some(json!({ "tasks": tasks })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - ContractToolRequest::ListContractRepositories => { - match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(cwr)) => { - let repos: Vec<serde_json::Value> = cwr - .repositories - .iter() - .map(|r| { - json!({ - "repositoryId": r.id, - "name": r.name, - "repositoryUrl": r.repository_url, - "localPath": r.local_path, - "isPrimary": r.is_primary, - }) - }) - .collect(); - - ContractRequestResult { - success: true, - message: format!("Found {} repositories", repos.len()), - data: Some(json!({ "repositories": repos })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - ContractToolRequest::ReadFile { file_id } => { - match repository::get_file_for_owner(pool, file_id, owner_id).await { - Ok(Some(file)) => { - // Verify file belongs to this contract - if file.contract_id != Some(contract_id) { - return ContractRequestResult { - success: false, - message: "File does not belong to this contract".to_string(), - data: None, - }; - } - - // Convert body to markdown for LLM consumption - let markdown = body_to_markdown(&file.body); - - ContractRequestResult { - success: true, - message: format!("Read file '{}'", file.name), - data: Some(json!({ - "fileId": file.id, - "name": file.name, - "description": file.description, - "summary": file.summary, - "plainText": markdown, - "phase": file.contract_phase, - })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "File not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - ContractToolRequest::CreateEmptyFile { name, description } => { - // Verify contract exists and get current phase - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - } - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - } - } - }; - - // Create the file with current contract phase - let create_req = crate::db::models::CreateFileRequest { - contract_id, - name: Some(name.clone()), - description, - body: Vec::new(), - transcript: Vec::new(), - location: None, - repo_file_path: None, - contract_phase: Some(contract.phase.clone()), - }; - - match repository::create_file_for_owner(pool, owner_id, create_req).await { - Ok(file) => ContractRequestResult { - success: true, - message: format!("Created empty file '{}'", name), - data: Some(json!({ - "fileId": file.id, - "name": file.name, - })), - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to create file: {}", e), - data: None, - }, - } - } - - ContractToolRequest::MarkDeliverableComplete { - deliverable_id, - phase, - } => { - // Get the contract to determine current phase and contract type - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - } - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - } - } - }; - - // Use specified phase or default to current contract phase - let target_phase = phase.unwrap_or_else(|| contract.phase.clone()); - - // Validate the deliverable ID exists for this phase/contract type - let phase_deliverables = crate::llm::get_phase_deliverables_for_type(&target_phase, &contract.contract_type); - let deliverable_exists = phase_deliverables.deliverables.iter().any(|d| d.id == deliverable_id); - - if !deliverable_exists { - let valid_ids: Vec<&str> = phase_deliverables.deliverables.iter().map(|d| d.id.as_str()).collect(); - return ContractRequestResult { - success: false, - message: format!( - "Invalid deliverable_id '{}' for {} phase. Valid IDs: {:?}", - deliverable_id, target_phase, valid_ids - ), - data: None, - }; - } - - // Check if already completed - if contract.is_deliverable_complete(&target_phase, &deliverable_id) { - return ContractRequestResult { - success: true, - message: format!("Deliverable '{}' is already marked complete for {} phase", deliverable_id, target_phase), - data: Some(json!({ - "deliverableId": deliverable_id, - "phase": target_phase, - "alreadyComplete": true, - })), - }; - } - - // Mark the deliverable as complete - match repository::mark_deliverable_complete(pool, contract_id, &target_phase, &deliverable_id).await { - Ok(updated_contract) => { - let completed = updated_contract.get_completed_deliverables(&target_phase); - ContractRequestResult { - success: true, - message: format!("Marked deliverable '{}' as complete for {} phase", deliverable_id, target_phase), - data: Some(json!({ - "deliverableId": deliverable_id, - "phase": target_phase, - "completedDeliverables": completed, - })), - } - } - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to mark deliverable complete: {}", e), - data: None, - }, - } - } - - ContractToolRequest::CreateContractTask { - name, - plan, - repository_url, - base_branch, - } => { - // Get primary repository if not specified - let repo_url = if repository_url.is_some() { - repository_url - } else { - // Find primary repository - match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(contract)) => { - contract - .repositories - .iter() - .find(|r| r.is_primary) - .and_then(|r| r.repository_url.clone().or(r.local_path.clone())) - } - _ => None, - } - }; - - let create_req = CreateTaskRequest { - contract_id: Some(contract_id), - name: name.clone(), - description: None, - plan, - parent_task_id: None, - repository_url: repo_url, - base_branch, - target_branch: None, - merge_mode: None, - priority: 0, - target_repo_path: None, - completion_action: None, - continue_from_task_id: None, - copy_files: None, - is_supervisor: false, - checkpoint_sha: None, - branched_from_task_id: None, - conversation_history: None, - supervisor_worktree_task_id: None, // Not spawned by supervisor - directive_id: None, - directive_step_id: None, - }; - - match repository::create_task_for_owner(pool, owner_id, create_req).await { - Ok(task) => ContractRequestResult { - success: true, - message: format!("Created task '{}' in contract", name), - data: Some(json!({ - "taskId": task.id, - "name": task.name, - "status": task.status, - })), - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to create task: {}", e), - data: None, - }, - } - } - - ContractToolRequest::DelegateContentGeneration { - file_id, - instruction, - context, - } => { - // Build a task plan that includes the content generation instruction - let mut plan = format!( - "Content Generation Task\n\n\ - ## Instruction\n{}\n\n", - instruction - ); - - if let Some(ctx) = context { - plan.push_str(&format!("## Context\n{}\n\n", ctx)); - } - - // If file_id is provided, get file details and include them - let (file_name, file_info) = if let Some(fid) = file_id { - match repository::get_file_for_owner(pool, fid, owner_id).await { - Ok(Some(file)) => { - let info = format!( - "## Target File\n\ - - File ID: {}\n\ - - Name: {}\n\ - - Description: {}\n\n\ - The generated content should be structured to update this file.\n", - fid, - file.name, - file.description.as_deref().unwrap_or("(no description)") - ); - (Some(file.name.clone()), Some(info)) - } - _ => (None, None), - } - } else { - (None, None) - }; - - if let Some(info) = file_info { - plan.push_str(&info); - } - - // Get primary repository - let repo_url = match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(contract)) => contract - .repositories - .iter() - .find(|r| r.is_primary) - .and_then(|r| r.repository_url.clone().or(r.local_path.clone())), - _ => None, - }; - - let task_name = format!( - "Generate content{}", - file_name.map(|n| format!(": {}", n)).unwrap_or_default() - ); - - let create_req = CreateTaskRequest { - contract_id: Some(contract_id), - name: task_name.clone(), - description: Some(instruction.clone()), - plan, - parent_task_id: None, - repository_url: repo_url, - base_branch: None, - target_branch: None, - merge_mode: None, - priority: 0, - target_repo_path: None, - completion_action: None, - continue_from_task_id: None, - copy_files: None, - is_supervisor: false, - checkpoint_sha: None, - branched_from_task_id: None, - conversation_history: None, - supervisor_worktree_task_id: None, // Not spawned by supervisor - directive_id: None, - directive_step_id: None, - }; - - match repository::create_task_for_owner(pool, owner_id, create_req).await { - Ok(task) => ContractRequestResult { - success: true, - message: format!( - "Created content generation task '{}'. Start the task to generate the content.", - task_name - ), - data: Some(json!({ - "taskId": task.id, - "name": task.name, - "status": task.status, - "targetFileId": file_id, - })), - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to create content generation task: {}", e), - data: None, - }, - } - } - - ContractToolRequest::StartTask { task_id } => { - // Get the task - let task = match repository::get_task_for_owner(pool, task_id, owner_id).await { - Ok(Some(t)) => t, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "Task not found".to_string(), - data: None, - } - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Failed to get task: {}", e), - data: None, - } - } - }; - - // Check if task can be started - let startable_statuses = ["pending", "failed", "interrupted", "done", "merged"]; - if !startable_statuses.contains(&task.status.as_str()) { - return ContractRequestResult { - success: false, - message: format!("Task cannot be started from status: {}", task.status), - data: None, - }; - } - - // Find a connected daemon for this owner - let daemon_entry = daemon_connections - .iter() - .find(|d| d.value().owner_id == owner_id); - - let (target_daemon_id, command_sender) = match daemon_entry { - Some(entry) => { - let daemon = entry.value(); - (daemon.id, daemon.command_sender.clone()) - } - None => { - return ContractRequestResult { - success: false, - message: "No daemon connected. Start a daemon to run tasks.".to_string(), - data: None, - }; - } - }; - - // Check if this is an orchestrator - let subtask_count = match repository::list_subtasks_for_owner(pool, task_id, owner_id).await { - Ok(subtasks) => subtasks.len(), - Err(_) => 0, - }; - let is_orchestrator = task.depth == 0 && subtask_count > 0; - - // Update task status to 'starting' and assign daemon_id - let update_req = crate::db::models::UpdateTaskRequest { - status: Some("starting".to_string()), - daemon_id: Some(target_daemon_id), - version: Some(task.version), - ..Default::default() - }; - - let _updated_task = match repository::update_task_for_owner(pool, task_id, owner_id, update_req).await { - Ok(Some(t)) => t, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "Task not found".to_string(), - data: None, - }; - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Failed to update task: {}", e), - data: None, - }; - } - }; - - // 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, owner_id).await { - Ok(Some(contract)) => (contract.local_only, contract.auto_merge_local), - _ => (false, false), - } - } else { - (false, false) - }; - - // Send SpawnTask command to daemon - let command = DaemonCommand::SpawnTask { - task_id, - task_name: task.name.clone(), - plan: task.plan.clone(), - repo_url: task.repository_url.clone(), - base_branch: task.base_branch.clone(), - target_branch: task.target_branch.clone(), - parent_task_id: task.parent_task_id, - depth: task.depth, - is_orchestrator, - target_repo_path: task.target_repo_path.clone(), - completion_action: task.completion_action.clone(), - continue_from_task_id: task.continue_from_task_id, - copy_files: task.copy_files.as_ref().and_then(|v| serde_json::from_value(v.clone()).ok()), - contract_id: task.contract_id, - is_supervisor: task.is_supervisor, - autonomous_loop: false, - resume_session: false, - conversation_history: None, - patch_data: None, - patch_base_sha: None, - local_only, - auto_merge_local, - supervisor_worktree_task_id: None, // Not spawned by supervisor - directive_id: task.directive_id, - }; - - if let Err(e) = command_sender.send(command).await { - // Rollback: reset status since command failed - let rollback_req = crate::db::models::UpdateTaskRequest { - status: Some("pending".to_string()), - clear_daemon_id: true, - ..Default::default() - }; - let _ = repository::update_task_for_owner(pool, task_id, owner_id, rollback_req).await; - return ContractRequestResult { - success: false, - message: format!("Failed to send task to daemon: {}", e), - data: None, - }; - } - - // Note: TaskUpdateNotification broadcast is handled by the mesh handler when daemon reports status - ContractRequestResult { - success: true, - message: format!("Started task '{}'. The task is now running on a connected daemon.", task.name), - data: Some(json!({ - "taskId": task_id, - "name": task.name, - "status": "starting", - })), - } - } - - ContractToolRequest::GetPhaseInfo => { - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - } - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - } - } - }; - - let phase_info = get_phase_description(&contract.phase); - let phase_deliverables = crate::llm::get_phase_deliverables_for_type(&contract.phase, &contract.contract_type); - let deliverable_names: Vec<String> = phase_deliverables.deliverables.iter().map(|d| d.name.clone()).collect(); - - ContractRequestResult { - success: true, - message: format!("Contract is in '{}' phase", contract.phase), - data: Some(json!({ - "phase": contract.phase, - "description": phase_info.0, - "activities": phase_info.1, - "deliverables": deliverable_names, - "guidance": phase_deliverables.guidance, - "nextPhase": get_next_phase(&contract.phase), - })), - } - } - - ContractToolRequest::SuggestPhaseTransition => { - let contract = match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - } - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - } - } - }; - - let analysis = analyze_phase_readiness(&contract); - - ContractRequestResult { - success: true, - message: analysis.summary.clone(), - data: Some(json!({ - "currentPhase": contract.contract.phase, - "nextPhase": get_next_phase(&contract.contract.phase), - "ready": analysis.ready, - "summary": analysis.summary, - "reasons": analysis.reasons, - "suggestions": analysis.suggestions, - })), - } - } - - ContractToolRequest::AdvancePhase { new_phase, confirmed, feedback } => { - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - } - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - } - } - }; - - // Validate phase transition - let current_phase = &contract.phase; - let valid_next = get_next_phase(current_phase); - - if valid_next.as_deref() != Some(&new_phase) { - return ContractRequestResult { - success: false, - message: format!( - "Cannot transition from '{}' to '{}'. Next valid phase is: {:?}", - current_phase, new_phase, valid_next - ), - data: None, - }; - } - - // Check if deliverables are met before allowing transition - let cwr = match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) | Err(_) => { - // Fall through - we'll just skip the deliverables check - return ContractRequestResult { - success: false, - message: "Failed to load contract for deliverables check".to_string(), - data: None, - }; - } - }; - - // Get completed deliverables for the current phase - let completed_deliverables = cwr.contract.get_completed_deliverables(current_phase); - - let task_infos: Vec<TaskInfo> = cwr.tasks.iter().map(|t| TaskInfo { - name: t.name.clone(), - status: t.status.clone(), - }).collect(); - - let has_repository = !cwr.repositories.is_empty(); - - let check_result = crate::llm::check_deliverables_met( - current_phase, - &contract.contract_type, - &completed_deliverables, - &task_infos, - has_repository, - ); - - // Block transition if deliverables are not met - if !check_result.deliverables_met { - return ContractRequestResult { - success: false, - message: format!( - "Cannot advance to '{}' phase: deliverables not met. {}", - new_phase, check_result.summary - ), - data: Some(json!({ - "status": "deliverables_not_met", - "currentPhase": current_phase, - "requestedPhase": new_phase, - "deliverablesMet": false, - "requiredDeliverables": check_result.required_deliverables, - "missing": check_result.missing, - "action": "Complete the missing deliverables before advancing to the next phase" - })), - }; - } - - // Check if phase_guard is enabled - if contract.phase_guard { - // If user provided feedback, return it for the task to address - if let Some(ref user_feedback) = feedback { - return ContractRequestResult { - success: true, - message: format!( - "Phase transition to '{}' requires changes. User feedback: {}", - new_phase, user_feedback - ), - data: Some(json!({ - "status": "changes_requested", - "currentPhase": current_phase, - "requestedPhase": new_phase, - "feedback": user_feedback, - "action": "Address the user feedback and try again when ready" - })), - }; - } - - // If not confirmed, return requires_confirmation with phase deliverables - // This applies to ALL callers (including supervisors) - phase_guard enforcement at API level - if !confirmed { - // Get files created in this phase - let phase_files = match repository::list_files_in_contract(pool, contract_id, owner_id).await { - Ok(files) => files - .into_iter() - .filter(|f| f.contract_phase.as_deref() == Some(current_phase)) - .map(|f| json!({ - "id": f.id, - "name": f.name, - "description": f.description - })) - .collect::<Vec<_>>(), - Err(_) => Vec::new(), - }; - - // Get tasks completed in this contract - let phase_tasks = match repository::list_tasks_in_contract(pool, contract_id, owner_id).await { - Ok(tasks) => tasks - .into_iter() - .filter(|t| t.status == "done" || t.status == "completed") - .map(|t| json!({ - "id": t.id, - "name": t.name, - "status": t.status - })) - .collect::<Vec<_>>(), - Err(_) => Vec::new(), - }; - - // Get phase deliverables with completion status - let phase_deliverables = crate::llm::get_phase_deliverables_for_type(current_phase, &contract.contract_type); - let completed_deliverables = contract.get_completed_deliverables(current_phase); - - let deliverables: Vec<serde_json::Value> = phase_deliverables - .deliverables - .iter() - .map(|d| json!({ - "id": d.id, - "name": d.name, - "completed": completed_deliverables.contains(&d.id) - })) - .collect(); - - // Build deliverables summary - let deliverables_summary = format!( - "Phase '{}' deliverables: {} files created, {} tasks completed.", - current_phase, - phase_files.len(), - phase_tasks.len() - ); - - let transition_id = uuid::Uuid::new_v4().to_string(); - - return ContractRequestResult { - success: true, - message: format!( - "Phase transition to '{}' requires user confirmation. Review the deliverables and call advance_phase again with confirmed=true to proceed, or provide feedback to request changes.", - new_phase - ), - data: Some(json!({ - "status": "requires_confirmation", - "transitionId": transition_id, - "currentPhase": current_phase, - "nextPhase": new_phase, - "deliverablesSummary": deliverables_summary, - "deliverables": deliverables, - "phaseFiles": phase_files, - "phaseTasks": phase_tasks, - "requiresConfirmation": true, - "message": "Phase guard is enabled. User confirmation required.", - "instructions": "To proceed: call advance_phase with confirmed=true. To request changes: call advance_phase with feedback='your feedback here'" - })), - }; - } - } - - // Update phase (either phase_guard is disabled, or user confirmed) - match repository::change_contract_phase_for_owner(pool, contract_id, owner_id, &new_phase).await { - Ok(Some(updated)) => { - // Get deliverables for the new phase (using contract type) - let phase_deliverables = crate::llm::get_phase_deliverables_for_type(&new_phase, &contract.contract_type); - - // Build deliverables list - let deliverables_list: Vec<serde_json::Value> = phase_deliverables - .deliverables - .iter() - .map(|d| json!({ - "id": d.id, - "name": d.name, - "priority": format!("{:?}", d.priority).to_lowercase(), - "description": d.description, - })) - .collect(); - - ContractRequestResult { - success: true, - message: format!( - "Advanced contract from '{}' to '{}' phase. {}", - current_phase, new_phase, phase_deliverables.guidance - ), - data: Some(json!({ - "status": "advanced", - "previousPhase": current_phase, - "newPhase": updated.phase, - "phaseGuidance": phase_deliverables.guidance, - "deliverables": deliverables_list, - "requiresRepository": phase_deliverables.requires_repository, - "requiresTasks": phase_deliverables.requires_tasks, - })), - } - }, - Ok(None) => ContractRequestResult { - success: false, - message: "Failed to update phase".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to update phase: {}", e), - data: None, - }, - } - } - - ContractToolRequest::AddRepository { - repo_type, - name, - url, - is_primary, - } => { - let add_result = match repo_type.as_str() { - "remote" => { - let url = url.unwrap_or_default(); - repository::add_remote_repository( - pool, - contract_id, - &name, - &url, - is_primary, - ) - .await - } - "local" => { - let path = url.unwrap_or_default(); - repository::add_local_repository( - pool, - contract_id, - &name, - &path, - is_primary, - ) - .await - } - "managed" => { - repository::create_managed_repository(pool, contract_id, &name, is_primary) - .await - } - _ => { - return ContractRequestResult { - success: false, - message: format!("Invalid repository type: {}", repo_type), - data: None, - } - } - }; - - match add_result { - Ok(repo) => ContractRequestResult { - success: true, - message: format!("Added {} repository '{}'", repo_type, name), - data: Some(json!({ - "repositoryId": repo.id, - "name": repo.name, - "isPrimary": repo.is_primary, - })), - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to add repository: {}", e), - data: None, - }, - } - } - - ContractToolRequest::SetPrimaryRepository { repository_id } => { - match repository::set_repository_primary(pool, repository_id, contract_id).await { - Ok(true) => ContractRequestResult { - success: true, - message: "Set repository as primary".to_string(), - data: Some(json!({ - "repositoryId": repository_id, - })), - }, - Ok(false) => ContractRequestResult { - success: false, - message: "Repository not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to set primary repository: {}", e), - data: None, - }, - } - } - - // ============================================================================= - // Phase Guidance Handlers - // ============================================================================= - - ContractToolRequest::GetPhaseChecklist => { - match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(cwr)) => { - let completed_deliverables = cwr.contract.get_completed_deliverables(&cwr.contract.phase); - - let task_infos: Vec<TaskInfo> = cwr.tasks.iter().map(|t| TaskInfo { - name: t.name.clone(), - status: t.status.clone(), - }).collect(); - - let has_repository = !cwr.repositories.is_empty(); - let checklist = crate::llm::get_phase_checklist_for_type(&cwr.contract.phase, &completed_deliverables, &task_infos, has_repository, &cwr.contract.contract_type); - - ContractRequestResult { - success: true, - message: checklist.summary.clone(), - data: Some(json!({ - "phase": checklist.phase, - "completionPercentage": checklist.completion_percentage, - "deliverables": checklist.deliverables, - "hasRepository": checklist.has_repository, - "repositoryRequired": checklist.repository_required, - "taskStats": checklist.task_stats, - "suggestions": checklist.suggestions, - "summary": checklist.summary, - })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - ContractToolRequest::CheckDeliverablesMet => { - match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(cwr)) => { - let completed_deliverables = cwr.contract.get_completed_deliverables(&cwr.contract.phase); - - let task_infos: Vec<TaskInfo> = cwr.tasks.iter().map(|t| TaskInfo { - name: t.name.clone(), - status: t.status.clone(), - }).collect(); - - let has_repository = !cwr.repositories.is_empty(); - - let check_result = crate::llm::check_deliverables_met( - &cwr.contract.phase, - &cwr.contract.contract_type, - &completed_deliverables, - &task_infos, - has_repository, - ); - - // Check if we should auto-progress - let auto_progress = crate::llm::should_auto_progress( - &cwr.contract.phase, - &cwr.contract.contract_type, - &completed_deliverables, - &task_infos, - has_repository, - cwr.contract.autonomous_loop, - ); - - ContractRequestResult { - success: true, - message: check_result.summary.clone(), - data: Some(json!({ - "deliverablesMet": check_result.deliverables_met, - "readyToAdvance": check_result.ready_to_advance, - "phase": check_result.phase, - "nextPhase": check_result.next_phase, - "requiredDeliverables": check_result.required_deliverables, - "missing": check_result.missing, - "summary": check_result.summary, - "autoProgressRecommended": check_result.auto_progress_recommended, - "autoProgress": { - "shouldProgress": auto_progress.should_progress, - "nextPhase": auto_progress.next_phase, - "reason": auto_progress.reason, - "action": format!("{:?}", auto_progress.action), - }, - "autonomousLoop": cwr.contract.autonomous_loop, - })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - // ============================================================================= - // Task Derivation Handlers - // ============================================================================= - - ContractToolRequest::DeriveTasksFromFile { file_id } => { - // First get the file - match repository::get_file_for_owner(pool, file_id, owner_id).await { - Ok(Some(file)) => { - // Verify file belongs to this contract - if file.contract_id != Some(contract_id) { - return ContractRequestResult { - success: false, - message: "File does not belong to this contract".to_string(), - data: None, - }; - } - - // Convert body to markdown for task parsing - let markdown = body_to_markdown(&file.body); - - // Parse tasks from the content - let parse_result = parse_tasks_from_breakdown(&markdown); - - ContractRequestResult { - success: true, - message: format!("Found {} tasks in file '{}'", parse_result.total, file.name), - data: Some(json!({ - "fileId": file_id, - "fileName": file.name, - "tasks": parse_result.tasks, - "groups": parse_result.groups, - "total": parse_result.total, - "warnings": parse_result.warnings, - "formatted": format_parsed_tasks(&parse_result), - })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "File not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - ContractToolRequest::CreateChainedTasks { tasks } => { - // Get primary repository for tasks - let repo_url = match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(contract)) => { - contract - .repositories - .iter() - .find(|r| r.is_primary) - .and_then(|r| r.repository_url.clone().or(r.local_path.clone())) - } - _ => None, - }; - - let mut created_tasks = Vec::new(); - let mut previous_task_id: Option<Uuid> = None; - - for task_def in &tasks { - let create_req = CreateTaskRequest { - contract_id: Some(contract_id), - name: task_def.name.clone(), - description: None, - plan: task_def.plan.clone(), - parent_task_id: None, - repository_url: repo_url.clone(), - base_branch: None, - target_branch: None, - merge_mode: None, - priority: 0, - target_repo_path: None, - completion_action: None, - continue_from_task_id: previous_task_id, - copy_files: None, - is_supervisor: false, - checkpoint_sha: None, - branched_from_task_id: None, - conversation_history: None, - supervisor_worktree_task_id: None, // Not spawned by supervisor - directive_id: None, - directive_step_id: None, - }; - - match repository::create_task_for_owner(pool, owner_id, create_req).await { - Ok(task) => { - created_tasks.push(json!({ - "taskId": task.id, - "name": task.name, - "status": task.status, - "chainedFrom": previous_task_id, - })); - previous_task_id = Some(task.id); - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Failed to create task '{}': {}", task_def.name, e), - data: Some(json!({ - "createdSoFar": created_tasks, - })), - }; - } - } - } - - ContractRequestResult { - success: true, - message: format!("Created {} chained tasks", created_tasks.len()), - data: Some(json!({ - "tasks": created_tasks, - "total": created_tasks.len(), - })), - } - } - - // ============================================================================= - // Task Completion Processing Handlers - // ============================================================================= - - ContractToolRequest::ProcessTaskCompletion { task_id } => { - // Get the task - match repository::get_task_for_owner(pool, task_id, owner_id).await { - Ok(Some(task)) => { - // Verify task belongs to this contract - if task.contract_id != Some(contract_id) { - return ContractRequestResult { - success: false, - message: "Task does not belong to this contract".to_string(), - data: None, - }; - } - - // Get contract for context - let contract = get_contract_with_relations(pool, contract_id, owner_id).await.ok().flatten(); - - let total_tasks = contract.as_ref().map(|c| c.tasks.len()).unwrap_or(0); - let completed_tasks = contract.as_ref() - .map(|c| c.tasks.iter().filter(|t| t.status == "done").count()) - .unwrap_or(0); - - // Note: Finding next chained task would require querying full Task objects - // Since TaskSummary doesn't have continue_from_task_id, we skip this for now - let next_task: Option<(Uuid, String)> = None; - - // Find Dev Notes file if exists - let dev_notes = if let Some(ref c) = contract { - c.files.iter() - .find(|f| f.name.to_lowercase().contains("dev") && f.name.to_lowercase().contains("notes")) - .map(|f| (f.id, f.name.clone())) - } else { - None - }; - - let contract_phase = contract.as_ref() - .map(|c| c.contract.phase.clone()) - .unwrap_or_else(|| "execute".to_string()); - - // Analyze the task output - let analysis = analyze_task_output( - task_id, - &task.name, - task.last_output.as_deref(), - task.progress_summary.as_deref(), - &contract_phase, - total_tasks, - completed_tasks, - next_task, - dev_notes, - ); - - ContractRequestResult { - success: true, - message: format!("Analyzed completion of task '{}'", task.name), - data: Some(json!({ - "taskId": task_id, - "taskName": task.name, - "taskStatus": task.status, - "summary": analysis.summary, - "filesAffected": analysis.files_affected, - "nextSteps": analysis.next_steps, - "phaseImpact": analysis.phase_impact, - })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "Task not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - ContractToolRequest::UpdateFileFromTask { file_id, task_id, section_title } => { - // Get the task - let task = match repository::get_task_for_owner(pool, task_id, owner_id).await { - Ok(Some(t)) => t, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "Task not found".to_string(), - data: None, - }; - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }; - } - }; - - // Get the file - let file = match repository::get_file_for_owner(pool, file_id, owner_id).await { - Ok(Some(f)) => f, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "File not found".to_string(), - data: None, - }; - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }; - } - }; - - // Verify file belongs to this contract - if file.contract_id != Some(contract_id) { - return ContractRequestResult { - success: false, - message: "File does not belong to this contract".to_string(), - data: None, - }; - } - - // Build the section to add - let title = section_title.unwrap_or_else(|| format!("Task: {}", task.name)); - let result_text = task.last_output.as_deref().unwrap_or("Task completed"); - - // Create new body elements to append - let mut new_body = file.body.clone(); - new_body.push(crate::db::models::BodyElement::Heading { - level: 2, - text: title, - }); - new_body.push(crate::db::models::BodyElement::Paragraph { - text: format!("Status: {}", task.status), - }); - new_body.push(crate::db::models::BodyElement::Paragraph { - text: result_text.to_string(), - }); - - // Update the file using UpdateFileRequest - let update_req = UpdateFileRequest { - name: None, - description: None, - transcript: None, - summary: None, - body: Some(new_body), - version: None, // Don't require version for this update - repo_file_path: None, - }; - - match repository::update_file_for_owner(pool, file_id, owner_id, update_req).await { - Ok(Some(updated_file)) => { - ContractRequestResult { - success: true, - message: format!("Updated file '{}' with task summary", file.name), - data: Some(json!({ - "fileId": file_id, - "fileName": updated_file.name, - "taskId": task_id, - "taskName": task.name, - })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "Failed to update file".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - // ============================================================================= - // Transcript Analysis Handlers - // ============================================================================= - - ContractToolRequest::AnalyzeTranscript { file_id } => { - // Get the file - let file = match repository::get_file_for_owner(pool, file_id, owner_id).await { - Ok(Some(f)) => f, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "File not found".to_string(), - data: None, - }; - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }; - } - }; - - if file.transcript.is_empty() { - return ContractRequestResult { - success: false, - message: "File has no transcript to analyze".to_string(), - data: None, - }; - } - - // Format and analyze - let transcript_text = format_transcript_for_analysis(&file.transcript); - let speaker_stats = calculate_speaker_stats(&file.transcript); - let prompt = build_analysis_prompt(&transcript_text); - - // Call Claude for analysis - let client = match ClaudeClient::from_env(ClaudeModel::Sonnet) { - Ok(c) => c, - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Failed to create Claude client: {}", e), - data: None, - }; - } - }; - - let claude_messages = vec![claude::Message { - role: "user".to_string(), - content: claude::MessageContent::Text(prompt), - }]; - - match client.chat_with_tools(claude_messages, &[]).await { - Ok(result) => { - let response_content = result.content.unwrap_or_default(); - match parse_analysis_response(&response_content, speaker_stats) { - Ok(analysis) => { - ContractRequestResult { - success: true, - message: format!( - "Analysis complete: {} requirements, {} decisions, {} action items", - analysis.requirements.len(), - analysis.decisions.len(), - analysis.action_items.len() - ), - data: Some(json!({ - "analysis": analysis - })), - } - } - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to parse analysis: {}", e), - data: None, - } - } - } - Err(e) => ContractRequestResult { - success: false, - message: format!("Claude API error: {}", e), - data: None, - } - } - } - - ContractToolRequest::CreateContractFromTranscript { - file_id, name, description, include_requirements, include_decisions, include_action_items - } => { - // Get file - let file = match repository::get_file_for_owner(pool, file_id, owner_id).await { - Ok(Some(f)) => f, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "File not found".to_string(), - data: None, - }; - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }; - } - }; - - if file.transcript.is_empty() { - return ContractRequestResult { - success: false, - message: "File has no transcript".to_string(), - data: None, - }; - } - - // Analyze transcript - let transcript_text = format_transcript_for_analysis(&file.transcript); - let speaker_stats = calculate_speaker_stats(&file.transcript); - let prompt = build_analysis_prompt(&transcript_text); - - let client = match ClaudeClient::from_env(ClaudeModel::Sonnet) { - Ok(c) => c, - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Failed to create Claude client: {}", e), - data: None, - }; - } - }; - - let claude_messages = vec![claude::Message { - role: "user".to_string(), - content: claude::MessageContent::Text(prompt), - }]; - - let analysis = match client.chat_with_tools(claude_messages, &[]).await { - Ok(result) => { - let response_content = result.content.unwrap_or_default(); - match parse_analysis_response(&response_content, speaker_stats) { - Ok(a) => a, - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Failed to parse analysis: {}", e), - data: None, - }; - } - } - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Claude API error: {}", e), - data: None, - }; - } - }; - - // Create contract - let contract_name = name - .or(analysis.suggested_contract_name.clone()) - .unwrap_or_else(|| format!("Contract from {}", file.name)); - let contract_description = description.or(analysis.suggested_description.clone()); - - let contract_req = crate::db::models::CreateContractRequest { - name: contract_name.clone(), - description: contract_description, - contract_type: Some("specification".to_string()), - initial_phase: Some("research".to_string()), - autonomous_loop: None, - phase_guard: None, - local_only: None, - auto_merge_local: None, - template_id: None, - }; - - let contract = match repository::create_contract_for_owner(pool, owner_id, contract_req).await { - Ok(c) => c, - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Failed to create contract: {}", e), - data: None, - }; - } - }; - - let mut files_created = 0; - let mut tasks_created = 0; - - // Create requirements file if requested and there are requirements - if include_requirements && !analysis.requirements.is_empty() { - let requirements_items: Vec<String> = analysis.requirements - .iter() - .map(|req| format!("[{}] {}", req.speaker, req.text)) - .collect(); - - let body: Vec<crate::db::models::BodyElement> = vec![ - crate::db::models::BodyElement::Heading { - level: 1, - text: "Requirements".to_string(), - }, - crate::db::models::BodyElement::Paragraph { - text: format!("Extracted {} requirements from transcript analysis.", analysis.requirements.len()), - }, - crate::db::models::BodyElement::Heading { - level: 2, - text: "Extracted Requirements".to_string(), - }, - crate::db::models::BodyElement::List { - ordered: false, - items: requirements_items, - }, - ]; - - let create_req = crate::db::models::CreateFileRequest { - contract_id: contract.id, - name: Some("Requirements".to_string()), - description: Some("Requirements extracted from transcript analysis".to_string()), - body, - transcript: Vec::new(), - location: None, - repo_file_path: None, - contract_phase: Some("specify".to_string()), - }; - - if repository::create_file_for_owner(pool, owner_id, create_req).await.is_ok() { - files_created += 1; - } - } - - // Create decisions file if requested and there are decisions - if include_decisions && !analysis.decisions.is_empty() { - let decisions_items: Vec<String> = analysis.decisions - .iter() - .map(|dec| format!("[{}] {}", dec.speaker, dec.text)) - .collect(); - - let body: Vec<crate::db::models::BodyElement> = vec![ - crate::db::models::BodyElement::Heading { - level: 1, - text: "Decisions".to_string(), - }, - crate::db::models::BodyElement::Paragraph { - text: format!("Extracted {} decisions from transcript analysis.", analysis.decisions.len()), - }, - crate::db::models::BodyElement::Heading { - level: 2, - text: "Recorded Decisions".to_string(), - }, - crate::db::models::BodyElement::List { - ordered: false, - items: decisions_items, - }, - ]; - - let create_req = crate::db::models::CreateFileRequest { - contract_id: contract.id, - name: Some("Decisions".to_string()), - description: Some("Decisions extracted from transcript analysis".to_string()), - body, - transcript: Vec::new(), - location: None, - repo_file_path: None, - contract_phase: Some("research".to_string()), - }; - - if repository::create_file_for_owner(pool, owner_id, create_req).await.is_ok() { - files_created += 1; - } - } - - // Create tasks from action items if requested - if include_action_items && !analysis.action_items.is_empty() { - for item in &analysis.action_items { - let task_req = CreateTaskRequest { - contract_id: Some(contract.id), - name: item.text.chars().take(100).collect(), - description: Some(format!("Action item from: {}", item.speaker)), - plan: item.text.clone(), - parent_task_id: None, - repository_url: None, - base_branch: None, - target_branch: None, - merge_mode: None, - priority: match item.priority.as_deref() { - Some("high") => 10, - Some("medium") => 5, - _ => 0, - }, - target_repo_path: None, - completion_action: None, - continue_from_task_id: None, - copy_files: None, - is_supervisor: false, - checkpoint_sha: None, - branched_from_task_id: None, - conversation_history: None, - supervisor_worktree_task_id: None, // Not spawned by supervisor - directive_id: None, - directive_step_id: None, - }; - - if repository::create_task_for_owner(pool, owner_id, task_req).await.is_ok() { - tasks_created += 1; - } - } - } - - ContractRequestResult { - success: true, - message: format!( - "Created contract '{}' with {} files and {} tasks from transcript analysis", - contract_name, files_created, tasks_created - ), - data: Some(json!({ - "contractId": contract.id, - "contractName": contract_name, - "filesCreated": files_created, - "tasksCreated": tasks_created, - "analysis": { - "requirementsCount": analysis.requirements.len(), - "decisionsCount": analysis.decisions.len(), - "actionItemsCount": analysis.action_items.len() - } - })), - } - } - - - } -} - -/// Get description and activities for a phase -fn get_phase_description(phase: &str) -> (String, Vec<String>) { - match phase { - "research" => ( - "Gather information, analyze competitors, and understand user needs".to_string(), - vec![ - "Conduct user research".to_string(), - "Analyze competitors".to_string(), - "Document findings".to_string(), - "Identify opportunities".to_string(), - ], - ), - "specify" => ( - "Define requirements, user stories, and acceptance criteria".to_string(), - vec![ - "Write requirements".to_string(), - "Create user stories".to_string(), - "Define acceptance criteria".to_string(), - "Document constraints".to_string(), - ], - ), - "plan" => ( - "Design architecture, create task breakdowns, and technical designs".to_string(), - vec![ - "Design system architecture".to_string(), - "Create technical specifications".to_string(), - "Break down into tasks".to_string(), - "Plan implementation order".to_string(), - ], - ), - "execute" => ( - "Implement features, write code, and run tasks".to_string(), - vec![ - "Implement features".to_string(), - "Write tests".to_string(), - "Track progress".to_string(), - "Document implementation details".to_string(), - ], - ), - "review" => ( - "Review work, create release notes, and conduct retrospectives".to_string(), - vec![ - "Review code and features".to_string(), - "Create release notes".to_string(), - "Conduct retrospective".to_string(), - "Document learnings".to_string(), - ], - ), - _ => ( - "Unknown phase".to_string(), - vec![], - ), - } -} - -/// Get the next phase in the lifecycle -fn get_next_phase(current: &str) -> Option<String> { - match current { - "research" => Some("specify".to_string()), - "specify" => Some("plan".to_string()), - "plan" => Some("execute".to_string()), - "execute" => Some("review".to_string()), - "review" => None, // Final phase - _ => None, - } -} - -/// Phase readiness analysis result -struct PhaseReadinessAnalysis { - ready: bool, - summary: String, - reasons: Vec<String>, - suggestions: Vec<String>, -} - -/// Analyze if the contract is ready to transition to the next phase -fn analyze_phase_readiness(contract: &crate::db::models::ContractWithRelations) -> PhaseReadinessAnalysis { - let mut reasons = Vec::new(); - let mut suggestions = Vec::new(); - - match contract.contract.phase.as_str() { - "research" => { - // Check for research files - let research_files = contract.files.iter() - .filter(|f| f.contract_phase.as_deref() == Some("research")) - .count(); - - if research_files == 0 { - reasons.push("No research documents created yet".to_string()); - suggestions.push("Create research notes or competitor analysis documents".to_string()); - } else { - reasons.push(format!("{} research document(s) created", research_files)); - } - - let ready = research_files > 0; - PhaseReadinessAnalysis { - ready, - summary: if ready { - "Research phase has documentation. Consider transitioning to Specify phase.".to_string() - } else { - "Research phase needs more documentation before transitioning.".to_string() - }, - reasons, - suggestions, - } - } - "specify" => { - let spec_files = contract.files.iter() - .filter(|f| f.contract_phase.as_deref() == Some("specify")) - .count(); - - if spec_files == 0 { - reasons.push("No specification documents created yet".to_string()); - suggestions.push("Create requirements or user stories documents".to_string()); - } else { - reasons.push(format!("{} specification document(s) created", spec_files)); - } - - let ready = spec_files > 0; - PhaseReadinessAnalysis { - ready, - summary: if ready { - "Specification phase has documentation. Consider transitioning to Plan phase.".to_string() - } else { - "Specification phase needs requirements or user stories.".to_string() - }, - reasons, - suggestions, - } - } - "plan" => { - let plan_files = contract.files.iter() - .filter(|f| f.contract_phase.as_deref() == Some("plan")) - .count(); - - let has_repos = !contract.repositories.is_empty(); - - if plan_files == 0 { - reasons.push("No planning documents created yet".to_string()); - suggestions.push("Create architecture or task breakdown documents".to_string()); - } else { - reasons.push(format!("{} planning document(s) created", plan_files)); - } - - if !has_repos { - reasons.push("No repositories configured".to_string()); - suggestions.push("Add a repository for task execution".to_string()); - } else { - reasons.push(format!("{} repository(ies) configured", contract.repositories.len())); - } - - let ready = plan_files > 0 && has_repos; - PhaseReadinessAnalysis { - ready, - summary: if ready { - "Planning phase complete with documents and repositories. Ready for Execute phase.".to_string() - } else { - "Planning phase needs documentation and/or repository configuration.".to_string() - }, - reasons, - suggestions, - } - } - "execute" => { - let total_tasks = contract.tasks.len(); - let done_tasks = contract.tasks.iter().filter(|t| t.status == "done").count(); - let running_tasks = contract.tasks.iter().filter(|t| t.status == "running").count(); - - if total_tasks == 0 { - reasons.push("No tasks created yet".to_string()); - suggestions.push("Create tasks to implement the planned work".to_string()); - } else { - reasons.push(format!("{} of {} tasks completed", done_tasks, total_tasks)); - } - - if running_tasks > 0 { - reasons.push(format!("{} task(s) still running", running_tasks)); - suggestions.push("Wait for running tasks to complete".to_string()); - } - - let ready = total_tasks > 0 && done_tasks == total_tasks; - - // For simple contracts, execute is the terminal phase - suggest completion - if ready && contract.contract.contract_type == "simple" { - suggestions.push("Mark the contract as completed".to_string()); - } - - PhaseReadinessAnalysis { - ready, - summary: if ready { - if contract.contract.contract_type == "simple" { - "All tasks completed. Contract can be marked as completed.".to_string() - } else { - "All tasks completed. Ready for Review phase.".to_string() - } - } else if total_tasks == 0 { - "No tasks created yet. Create and complete tasks before reviewing.".to_string() - } else { - format!("{}/{} tasks complete. Finish remaining tasks before review.", done_tasks, total_tasks) - }, - reasons, - suggestions, - } - } - "review" => { - let review_files = contract.files.iter() - .filter(|f| f.contract_phase.as_deref() == Some("review")) - .count(); - - if review_files == 0 { - suggestions.push("Create review checklist or release notes".to_string()); - } else { - // Review documentation exists - suggest completion - suggestions.push("Mark the contract as completed".to_string()); - } - - PhaseReadinessAnalysis { - ready: review_files > 0, - summary: if review_files > 0 { - "Review documentation complete. Contract can be marked as completed.".to_string() - } else { - "Review phase needs documentation before completion.".to_string() - }, - reasons: vec!["Review is the final phase".to_string()], - suggestions, - } - } - _ => PhaseReadinessAnalysis { - ready: false, - summary: "Unknown phase".to_string(), - reasons: vec!["Phase not recognized".to_string()], - suggestions: vec![], - }, - } -} - -// ============================================================================= -// Contract Chat History Endpoints -// ============================================================================= - -/// Get contract chat history -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}/chat/history", - responses( - (status = 200, description = "Chat history retrieved successfully", body = ContractChatHistoryResponse), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Contract not found"), - (status = 500, description = "Internal server error") - ), - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn get_contract_chat_history( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(contract_id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "Database not configured" })), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, contract_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({ "error": "Contract not found" })), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Database error: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Database error: {}", e) })), - ) - .into_response(); - } - } - - // Get or create conversation - let conversation = match repository::get_or_create_contract_conversation(pool, contract_id, auth.owner_id).await { - Ok(conv) => conv, - Err(e) => { - tracing::error!("Failed to get contract conversation: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Failed to get conversation: {}", e) })), - ) - .into_response(); - } - }; - - // Get messages - let messages = match repository::list_contract_chat_messages(pool, conversation.id, Some(100)).await { - Ok(msgs) => msgs, - Err(e) => { - tracing::error!("Failed to list contract chat messages: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Failed to list messages: {}", e) })), - ) - .into_response(); - } - }; - - ( - StatusCode::OK, - Json(ContractChatHistoryResponse { - contract_id, - conversation_id: conversation.id, - messages, - }), - ) - .into_response() -} - -/// Clear contract chat history (creates a new conversation) -#[utoipa::path( - delete, - path = "/api/v1/contracts/{id}/chat/history", - responses( - (status = 200, description = "Chat history cleared successfully"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Contract not found"), - (status = 500, description = "Internal server error") - ), - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn clear_contract_chat_history( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(contract_id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "Database not configured" })), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, contract_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({ "error": "Contract not found" })), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Database error: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Database error: {}", e) })), - ) - .into_response(); - } - } - - // Clear conversation (archives existing and creates new) - match repository::clear_contract_conversation(pool, contract_id, auth.owner_id).await { - Ok(new_conversation) => { - ( - StatusCode::OK, - Json(json!({ - "message": "Chat history cleared", - "newConversationId": new_conversation.id - })), - ) - .into_response() - } - Err(e) => { - tracing::error!("Failed to clear contract conversation: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Failed to clear history: {}", e) })), - ) - .into_response() - } - } -} diff --git a/makima/src/server/handlers/contract_daemon.rs b/makima/src/server/handlers/contract_daemon.rs deleted file mode 100644 index 5f56f06..0000000 --- a/makima/src/server/handlers/contract_daemon.rs +++ /dev/null @@ -1,936 +0,0 @@ -//! HTTP handlers for daemon-to-contract interaction. -//! -//! These endpoints allow tasks running in daemons to interact with their -//! associated contracts via the contract.sh script. Authentication is via -//! tool keys registered by the daemon when starting a task. - -use axum::{ - extract::{Path, State}, - http::StatusCode, - response::IntoResponse, - Json, -}; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; -use uuid::Uuid; - -use crate::db::{models::FileSummary, repository}; -use crate::llm::phase_guidance::{self, PhaseChecklist, TaskInfo}; -use crate::server::auth::Authenticated; -use crate::server::messages::ApiError; -use crate::server::state::SharedState; - -// ============================================================================= -// Request/Response Types -// ============================================================================= - -/// Contract status response for daemon. -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ContractStatusResponse { - pub id: Uuid, - pub name: String, - pub phase: String, - pub status: String, - pub description: Option<String>, -} - -/// Contract goals response. -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ContractGoalsResponse { - /// Description serves as goals for the contract - pub description: Option<String>, - pub phase: String, - pub phase_guidance: String, -} - -/// Progress report request from daemon. -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ProgressReportRequest { - pub message: String, - #[serde(default)] - pub task_id: Option<Uuid>, -} - -/// Suggested action from server. -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct SuggestedActionResponse { - pub action: String, - pub description: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub data: Option<serde_json::Value>, -} - -/// Completion action request. -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct CompletionActionRequest { - #[serde(default)] - pub task_id: Option<Uuid>, - #[serde(default)] - pub files_modified: Vec<String>, - #[serde(default)] - pub lines_added: i32, - #[serde(default)] - pub lines_removed: i32, - #[serde(default)] - pub has_code_changes: bool, -} - -/// Recommended completion action. -#[derive(Debug, Clone, Serialize, ToSchema)] -#[serde(rename_all = "lowercase")] -pub enum CompletionAction { - Branch, - Merge, - Pr, - None, -} - -impl std::fmt::Display for CompletionAction { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - CompletionAction::Branch => write!(f, "branch"), - CompletionAction::Merge => write!(f, "merge"), - CompletionAction::Pr => write!(f, "pr"), - CompletionAction::None => write!(f, "none"), - } - } -} - -/// Completion action response. -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct CompletionActionResponse { - pub action: String, - pub reason: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub branch_name: Option<String>, -} - -/// Create file request from daemon. -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct CreateFileRequest { - pub name: String, - pub content: String, - #[serde(default)] - pub template_id: Option<String>, -} - -/// Update file request from daemon. -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct DaemonUpdateFileRequest { - /// Content to update in the file (as markdown body element) - pub content: String, -} - -// ============================================================================= -// Handlers -// ============================================================================= - -/// Get contract status for daemon. -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}/daemon/status", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 200, description = "Contract status", body = ContractStatusResponse), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - ), - security( - ("tool_key" = []), - ("api_key" = []) - ), - tag = "Contract Daemon" -)] -pub async fn get_contract_status( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(contract)) => Json(ContractStatusResponse { - id: contract.id, - name: contract.name, - phase: contract.phase, - status: contract.status, - description: contract.description, - }) - .into_response(), - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Get phase deliverables checklist. -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}/daemon/checklist", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 200, description = "Phase checklist", body = PhaseChecklist), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - ), - security( - ("tool_key" = []), - ("api_key" = []) - ), - tag = "Contract Daemon" -)] -pub async fn get_contract_checklist( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Get contract - let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Get completed deliverables for the current phase - let completed_deliverables = contract.get_completed_deliverables(&contract.phase); - - // Get tasks for this contract - let tasks = match repository::list_tasks_in_contract(pool, id, auth.owner_id).await { - Ok(t) => t - .into_iter() - .map(|t| TaskInfo { - name: t.name, - status: t.status, - }) - .collect::<Vec<_>>(), - Err(e) => { - tracing::warn!("Failed to get tasks for contract {}: {}", id, e); - Vec::new() - } - }; - - // Check if repository is configured - let has_repository = match repository::list_contract_repositories(pool, id).await { - Ok(repos) => !repos.is_empty(), - Err(_) => false, - }; - - let checklist = phase_guidance::get_phase_checklist_for_type(&contract.phase, &completed_deliverables, &tasks, has_repository, &contract.contract_type); - - Json(checklist).into_response() -} - -/// Get contract goals. -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}/daemon/goals", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 200, description = "Contract goals", body = ContractGoalsResponse), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - ), - security( - ("tool_key" = []), - ("api_key" = []) - ), - tag = "Contract Daemon" -)] -pub async fn get_contract_goals( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(contract)) => { - let deliverables = phase_guidance::get_phase_deliverables_for_type(&contract.phase, &contract.contract_type); - Json(ContractGoalsResponse { - description: contract.description, - phase: contract.phase, - phase_guidance: deliverables.guidance, - }) - .into_response() - } - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Post progress report to contract. -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/daemon/report", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - request_body = ProgressReportRequest, - responses( - (status = 200, description = "Report received"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - ), - security( - ("tool_key" = []), - ("api_key" = []) - ), - tag = "Contract Daemon" -)] -pub async fn post_progress_report( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<ProgressReportRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - // Log the report as a contract event - let event_type = "progress_report"; - let payload = serde_json::json!({ - "message": req.message, - "task_id": req.task_id, - }); - - if let Err(e) = repository::record_contract_event(pool, id, event_type, Some(payload)).await { - tracing::warn!("Failed to create contract event: {}", e); - } - - Json(serde_json::json!({"status": "received"})).into_response() -} - -/// Get suggested action based on contract state. -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/daemon/suggest-action", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 200, description = "Suggested action", body = SuggestedActionResponse), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - ), - security( - ("tool_key" = []), - ("api_key" = []) - ), - tag = "Contract Daemon" -)] -pub async fn get_suggest_action( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Get contract - let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Get completed deliverables and tasks for checklist - let completed_deliverables = contract.get_completed_deliverables(&contract.phase); - - let tasks = repository::list_tasks_in_contract(pool, id, auth.owner_id) - .await - .unwrap_or_default() - .into_iter() - .map(|t| TaskInfo { - name: t.name, - status: t.status, - }) - .collect::<Vec<_>>(); - - let has_repository = repository::list_contract_repositories(pool, id) - .await - .map(|r| !r.is_empty()) - .unwrap_or(false); - - let checklist = phase_guidance::get_phase_checklist_for_type(&contract.phase, &completed_deliverables, &tasks, has_repository, &contract.contract_type); - - // Determine suggested action based on checklist - let (action, description) = if !checklist.suggestions.is_empty() { - ("follow_suggestion", checklist.suggestions.first().unwrap().clone()) - } else if checklist.completion_percentage >= 100 { - ("advance_phase", format!("Phase {} is complete, consider advancing to next phase", contract.phase)) - } else { - ("continue", format!("Continue working on {} phase ({}% complete)", contract.phase, checklist.completion_percentage)) - }; - - Json(SuggestedActionResponse { - action: action.to_string(), - description, - data: None, - }) - .into_response() -} - -/// Get recommended completion action. -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/daemon/completion-action", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - request_body = CompletionActionRequest, - responses( - (status = 200, description = "Recommended completion action", body = CompletionActionResponse), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - ), - security( - ("tool_key" = []), - ("api_key" = []) - ), - tag = "Contract Daemon" -)] -pub async fn get_completion_action( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<CompletionActionRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Get contract - let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Determine completion action based on phase and changes - let has_changes = !req.files_modified.is_empty() || req.lines_added > 0 || req.lines_removed > 0; - let has_significant_changes = req.lines_added + req.lines_removed > 50; - - let (action, reason) = match contract.phase.as_str() { - "research" | "specify" => { - if has_changes { - (CompletionAction::Merge, "Early phase changes can be merged directly".to_string()) - } else { - (CompletionAction::None, "No changes to commit".to_string()) - } - } - "plan" => { - if has_significant_changes { - (CompletionAction::Pr, "Significant planning changes require review".to_string()) - } else if has_changes { - (CompletionAction::Merge, "Minor planning changes can be merged".to_string()) - } else { - (CompletionAction::None, "No changes to commit".to_string()) - } - } - "execute" => { - if req.has_code_changes { - (CompletionAction::Pr, "Code changes in execute phase require review".to_string()) - } else if has_changes { - (CompletionAction::Branch, "Documentation changes can be branched".to_string()) - } else { - (CompletionAction::None, "No changes to commit".to_string()) - } - } - "review" => { - if has_changes { - (CompletionAction::Pr, "Review phase changes should be reviewed".to_string()) - } else { - (CompletionAction::None, "No changes to commit".to_string()) - } - } - _ => (CompletionAction::None, "Unknown phase".to_string()), - }; - - // Generate branch name based on contract - let branch_name = if matches!(action, CompletionAction::Branch | CompletionAction::Pr) { - let slug = contract.name.to_lowercase().replace(' ', "-"); - Some(format!("contract/{}", slug)) - } else { - None - }; - - Json(CompletionActionResponse { - action: action.to_string(), - reason, - branch_name, - }) - .into_response() -} - -/// List contract files for daemon. -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}/daemon/files", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 200, description = "List of contract files", body = Vec<FileSummary>), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - ), - security( - ("tool_key" = []), - ("api_key" = []) - ), - tag = "Contract Daemon" -)] -pub async fn list_contract_files( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::list_files_in_contract(pool, id, auth.owner_id).await { - Ok(files) => Json(files).into_response(), - Err(e) => { - tracing::error!("Failed to list files for contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Get a specific contract file. -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}/daemon/files/{file_id}", - params( - ("id" = Uuid, Path, description = "Contract ID"), - ("file_id" = Uuid, Path, description = "File ID") - ), - responses( - (status = 200, description = "File content"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract or file not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - ), - security( - ("tool_key" = []), - ("api_key" = []) - ), - tag = "Contract Daemon" -)] -pub async fn get_contract_file( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path((id, file_id)): Path<(Uuid, Uuid)>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - // Get file and verify it belongs to this contract - match repository::get_file_for_owner(pool, file_id, auth.owner_id).await { - Ok(Some(file)) => { - if file.contract_id != Some(id) { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "File not found in this contract")), - ) - .into_response(); - } - Json(file).into_response() - } - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "File not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to get file {}: {}", file_id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Update a contract file. -#[utoipa::path( - put, - path = "/api/v1/contracts/{id}/daemon/files/{file_id}", - params( - ("id" = Uuid, Path, description = "Contract ID"), - ("file_id" = Uuid, Path, description = "File ID") - ), - request_body = DaemonUpdateFileRequest, - responses( - (status = 200, description = "File updated"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract or file not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - ), - security( - ("tool_key" = []), - ("api_key" = []) - ), - tag = "Contract Daemon" -)] -pub async fn update_contract_file( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path((id, file_id)): Path<(Uuid, Uuid)>, - Json(req): Json<DaemonUpdateFileRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - // Get file and verify it belongs to this contract - let file = match repository::get_file_for_owner(pool, file_id, auth.owner_id).await { - Ok(Some(f)) => f, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "File not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get file {}: {}", file_id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - if file.contract_id != Some(id) { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "File not found in this contract")), - ) - .into_response(); - } - - // Update the file with content parsed as markdown - let body = crate::llm::markdown_to_body(&req.content); - let update_req = crate::db::models::UpdateFileRequest { - name: None, - description: None, - transcript: None, - summary: None, - body: Some(body), - version: None, - repo_file_path: None, - }; - - match repository::update_file_for_owner(pool, file_id, auth.owner_id, update_req).await { - Ok(Some(updated)) => Json(updated).into_response(), - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "File not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to update file {}: {}", file_id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", format!("{}", e))), - ) - .into_response() - } - } -} - -/// Create a new contract file. -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/daemon/files", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - request_body = CreateFileRequest, - responses( - (status = 201, description = "File created"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - ), - security( - ("tool_key" = []), - ("api_key" = []) - ), - tag = "Contract Daemon" -)] -pub async fn create_contract_file( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<CreateFileRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Create the file with content parsed as markdown - let body = crate::llm::markdown_to_body(&req.content); - let create_req = crate::db::models::CreateFileRequest { - contract_id: id, - name: Some(req.name), - description: None, - transcript: vec![], - location: None, - body, - repo_file_path: None, - contract_phase: None, // Will be looked up from contract's current phase - }; - - match repository::create_file_for_owner(pool, auth.owner_id, create_req).await { - Ok(file) => (StatusCode::CREATED, Json(file)).into_response(), - Err(e) => { - tracing::error!("Failed to create file for contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} diff --git a/makima/src/server/handlers/contract_discuss.rs b/makima/src/server/handlers/contract_discuss.rs deleted file mode 100644 index 1f98f53..0000000 --- a/makima/src/server/handlers/contract_discuss.rs +++ /dev/null @@ -1,592 +0,0 @@ -//! Discussion endpoint for LLM-powered contract creation. -//! -//! This handler provides an ephemeral conversation with Makima to help users -//! define and create contracts through natural dialogue. - -use axum::{ - extract::State, - http::StatusCode, - response::IntoResponse, - Json, -}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use utoipa::ToSchema; -use uuid::Uuid; - -use crate::db::{models::CreateContractRequest, repository}; -use crate::llm::{ - claude::{self, ClaudeClient, ClaudeError, ClaudeModel}, - groq::{GroqClient, GroqError, Message, ToolCallResponse}, - discuss_tools::{parse_discuss_tool_call, DiscussToolRequest, DISCUSS_TOOLS}, - LlmModel, ToolCall, ToolResult, UserQuestion, -}; -use crate::server::auth::Authenticated; -use crate::server::state::SharedState; - -/// Maximum number of tool-calling rounds to prevent infinite loops -const MAX_TOOL_ROUNDS: usize = 10; - -/// System prompt for Makima character in contract discussions -const DISCUSS_SYSTEM_PROMPT: &str = r#" -You are Makima, an AI assistant on the makima.jp platform. You help users define and create contracts for their projects through natural conversation. - -## Your Personality -- Professional yet personable -- Focused on understanding the user's actual needs -- Ask clarifying questions when requirements are vague -- Guide the conversation toward actionable outcomes -- Comfortable making recommendations based on experience - -## Your Goal -Help the user flesh out their project idea into a well-defined contract. A contract on makima.jp includes: -- A clear name and description -- The right contract type (simple, specification, or execute) -- Understanding of the scope and requirements - -## Contract Types -- **simple**: Quick tasks with minimal planning (plan -> execute phases only) -- **specification**: Full lifecycle projects (research -> specify -> plan -> execute -> review) -- **execute**: Direct implementation when requirements are already clear (execute phase only) - -## Guidelines -1. **Start by understanding**: Ask about what they want to build -2. **Clarify scope**: Is this a quick fix, a new feature, or a full project? -3. **Gather requirements**: What are the must-haves vs nice-to-haves? -4. **Identify context**: Is there existing code? Which repository? -5. **Recommend type**: Suggest the appropriate contract type -6. **Confirm and create**: When the user is satisfied, create the contract - -## When to Create the Contract -Create the contract when: -- You have a clear understanding of what the user wants -- The user has confirmed they're ready to proceed -- You've gathered enough information for a meaningful contract - -Do NOT create the contract if: -- The user is still exploring ideas -- Key information is missing -- The user hasn't indicated readiness - -{transcript_context} -"#; - -/// Chat message in history -#[derive(Debug, Clone, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ChatMessage { - /// Role: "user" or "assistant" - pub role: String, - /// Message content - pub content: String, -} - -/// Request to discuss a potential contract -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct DiscussContractRequest { - /// The user's message - pub message: String, - /// Optional model selection (default: claude-sonnet) - #[serde(default)] - pub model: Option<String>, - /// Conversation history for context continuity - #[serde(default)] - pub history: Option<Vec<ChatMessage>>, - /// Optional transcript context from current session - #[serde(default)] - pub transcript_context: Option<String>, -} - -/// Response from the discussion endpoint -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct DiscussContractResponse { - /// Makima's response message - pub response: String, - /// Tool calls that were executed (e.g., create_contract) - pub tool_calls: Vec<ToolCallInfo>, - /// If a contract was created, its details - #[serde(skip_serializing_if = "Option::is_none")] - pub created_contract: Option<CreatedContractInfo>, - /// Pending questions (if LLM needs clarification) - #[serde(skip_serializing_if = "Option::is_none")] - pub pending_questions: Option<Vec<UserQuestion>>, -} - -/// Information about a tool call that was executed -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ToolCallInfo { - pub name: String, - pub result: ToolResult, -} - -/// Information about a created contract -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct CreatedContractInfo { - pub id: String, - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option<String>, - pub contract_type: String, - pub initial_phase: String, -} - -/// Enum to hold LLM clients -enum LlmClient { - Groq(GroqClient), - Claude(ClaudeClient), -} - -/// Unified result from LLM call -struct LlmResult { - content: Option<String>, - tool_calls: Vec<ToolCall>, - raw_tool_calls: Vec<ToolCallResponse>, - finish_reason: String, -} - -/// Discuss a potential contract with Makima -#[utoipa::path( - post, - path = "/api/v1/contracts/discuss", - request_body = DiscussContractRequest, - responses( - (status = 200, description = "Discussion completed successfully", body = DiscussContractResponse), - (status = 401, description = "Unauthorized"), - (status = 500, description = "Internal server error") - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn discuss_contract_handler( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Json(request): Json<DiscussContractRequest>, -) -> impl IntoResponse { - // Check if database is configured - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "Database not configured" })), - ) - .into_response(); - }; - - // Parse model selection (default to Claude Sonnet) - let model = request - .model - .as_ref() - .and_then(|m| LlmModel::from_str(m)) - .unwrap_or(LlmModel::ClaudeSonnet); - - tracing::info!("Contract discussion using LLM model: {:?}", model); - - // Initialize the appropriate LLM client - let llm_client = match model { - LlmModel::ClaudeSonnet => match ClaudeClient::from_env(ClaudeModel::Sonnet) { - Ok(client) => LlmClient::Claude(client), - Err(ClaudeError::MissingApiKey) => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Claude client error: {}", e) })), - ) - .into_response(); - } - }, - LlmModel::ClaudeOpus => match ClaudeClient::from_env(ClaudeModel::Opus) { - Ok(client) => LlmClient::Claude(client), - Err(ClaudeError::MissingApiKey) => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Claude client error: {}", e) })), - ) - .into_response(); - } - }, - LlmModel::GroqKimi => match GroqClient::from_env() { - Ok(client) => LlmClient::Groq(client), - Err(GroqError::MissingApiKey) => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "GROQ_API_KEY not configured" })), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Groq client error: {}", e) })), - ) - .into_response(); - } - }, - }; - - // Build system prompt with optional transcript context - let transcript_section = match &request.transcript_context { - Some(ctx) => format!( - "\n## Current Session Context\nThe user has been recording a session. Here's the transcript:\n\n{}\n", - ctx - ), - None => String::new(), - }; - - let system_prompt = DISCUSS_SYSTEM_PROMPT.replace("{transcript_context}", &transcript_section); - - // Run the discussion agentic loop - run_discuss_agentic_loop( - pool, - &llm_client, - system_prompt, - &request, - auth.owner_id, - ) - .await -} - -/// Run the agentic loop for contract discussion -async fn run_discuss_agentic_loop( - pool: &sqlx::PgPool, - llm_client: &LlmClient, - system_prompt: String, - request: &DiscussContractRequest, - owner_id: Uuid, -) -> axum::response::Response { - // Build initial messages - let mut messages = vec![Message { - role: "system".to_string(), - content: Some(system_prompt), - tool_calls: None, - tool_call_id: None, - }]; - - // Add conversation history if provided - if let Some(history) = &request.history { - for msg in history { - messages.push(Message { - role: msg.role.clone(), - content: Some(msg.content.clone()), - tool_calls: None, - tool_call_id: None, - }); - } - } - - // Add current user message - messages.push(Message { - role: "user".to_string(), - content: Some(request.message.clone()), - tool_calls: None, - tool_call_id: None, - }); - - // State for tracking - let mut all_tool_call_infos: Vec<ToolCallInfo> = Vec::new(); - let mut final_response: Option<String> = None; - let mut created_contract: Option<CreatedContractInfo> = None; - let mut pending_questions: Option<Vec<UserQuestion>> = None; - - // Multi-turn agentic tool calling loop - for round in 0..MAX_TOOL_ROUNDS { - tracing::info!( - round = round, - total_tool_calls = all_tool_call_infos.len(), - "Contract discussion loop iteration" - ); - - // Call the appropriate LLM API - let result = match llm_client { - LlmClient::Groq(groq) => { - match groq.chat_with_tools(messages.clone(), &DISCUSS_TOOLS).await { - Ok(r) => LlmResult { - content: r.content, - tool_calls: r.tool_calls, - raw_tool_calls: r.raw_tool_calls, - finish_reason: r.finish_reason, - }, - Err(e) => { - tracing::error!("Groq API error: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("LLM API error: {}", e) })), - ) - .into_response(); - } - } - } - LlmClient::Claude(claude_client) => { - let claude_messages = claude::groq_messages_to_claude(&messages); - match claude_client - .chat_with_tools(claude_messages, &DISCUSS_TOOLS) - .await - { - Ok(r) => { - let raw_tool_calls: Vec<ToolCallResponse> = r - .tool_calls - .iter() - .map(|tc| ToolCallResponse { - id: tc.id.clone(), - call_type: "function".to_string(), - function: crate::llm::groq::FunctionCall { - name: tc.name.clone(), - arguments: tc.arguments.to_string(), - }, - }) - .collect(); - - LlmResult { - content: r.content, - tool_calls: r.tool_calls, - raw_tool_calls, - finish_reason: r.stop_reason, - } - } - Err(e) => { - tracing::error!("Claude API error: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("LLM API error: {}", e) })), - ) - .into_response(); - } - } - } - }; - - // Check if there are tool calls to execute - if result.tool_calls.is_empty() { - final_response = result.content; - break; - } - - // Add assistant message with tool calls to conversation - messages.push(Message { - role: "assistant".to_string(), - content: result.content.clone(), - tool_calls: Some(result.raw_tool_calls.clone()), - tool_call_id: None, - }); - - // Execute each tool call - for (i, tool_call) in result.tool_calls.iter().enumerate() { - tracing::info!(tool = %tool_call.name, round = round, "Executing discussion tool call"); - - // Parse the tool call - let mut execution_result = parse_discuss_tool_call(tool_call); - - // Handle async discussion tool requests - if let Some(discuss_request) = execution_result.request.take() { - let async_result = - handle_discuss_request(pool, discuss_request, owner_id).await; - execution_result.success = async_result.success; - execution_result.message = async_result.message; - execution_result.data = async_result.data; - - // Check if a contract was created - if let Some(ref data) = execution_result.data { - if let Some(contract_info) = data.get("createdContract") { - created_contract = Some(CreatedContractInfo { - id: contract_info["id"].as_str().unwrap_or("").to_string(), - name: contract_info["name"].as_str().unwrap_or("").to_string(), - description: contract_info["description"].as_str().map(|s| s.to_string()), - contract_type: contract_info["contractType"].as_str().unwrap_or("").to_string(), - initial_phase: contract_info["initialPhase"].as_str().unwrap_or("").to_string(), - }); - } - } - } - - // Check for pending user questions - if let Some(questions) = execution_result.pending_questions { - tracing::info!( - question_count = questions.len(), - "Discussion LLM requesting user input" - ); - pending_questions = Some(questions); - all_tool_call_infos.push(ToolCallInfo { - name: tool_call.name.clone(), - result: ToolResult { - success: execution_result.success, - message: execution_result.message.clone(), - }, - }); - break; - } - - // Build tool result message - let result_content = if let Some(data) = &execution_result.data { - json!({ - "success": execution_result.success, - "message": execution_result.message, - "data": data - }) - .to_string() - } else { - json!({ - "success": execution_result.success, - "message": execution_result.message - }) - .to_string() - }; - - // Add tool result message - let tool_call_id = match llm_client { - LlmClient::Groq(_) => result.raw_tool_calls[i].id.clone(), - LlmClient::Claude(_) => tool_call.id.clone(), - }; - - messages.push(Message { - role: "tool".to_string(), - content: Some(result_content), - tool_calls: None, - tool_call_id: Some(tool_call_id), - }); - - // Track for response - all_tool_call_infos.push(ToolCallInfo { - name: tool_call.name.clone(), - result: ToolResult { - success: execution_result.success, - message: execution_result.message, - }, - }); - } - - // If user questions are pending, pause - if pending_questions.is_some() { - final_response = result.content; - break; - } - - // If finish reason indicates completion, exit loop - let finish_lower = result.finish_reason.to_lowercase(); - if finish_lower == "stop" || finish_lower == "end_turn" { - final_response = result.content; - break; - } - } - - // Build response - let response_text = final_response.unwrap_or_else(|| { - if all_tool_call_infos.is_empty() { - "I couldn't understand your request. Please try rephrasing.".to_string() - } else { - "Done!".to_string() - } - }); - - ( - StatusCode::OK, - Json(DiscussContractResponse { - response: response_text, - tool_calls: all_tool_call_infos, - created_contract, - pending_questions, - }), - ) - .into_response() -} - -/// Result from handling an async discussion tool request -struct DiscussRequestResult { - success: bool, - message: String, - data: Option<serde_json::Value>, -} - -/// Handle async discussion tool requests that require database access -async fn handle_discuss_request( - pool: &sqlx::PgPool, - request: DiscussToolRequest, - owner_id: Uuid, -) -> DiscussRequestResult { - match request { - DiscussToolRequest::CreateContract { - name, - description, - contract_type, - repository_url, - local_only, - } => { - // Create the contract request - let create_req = CreateContractRequest { - name: name.clone(), - description: Some(description.clone()), - contract_type: Some(contract_type.clone()), - template_id: None, - initial_phase: None, - autonomous_loop: None, - phase_guard: None, - local_only: Some(local_only), - auto_merge_local: None, - }; - - match repository::create_contract_for_owner(pool, owner_id, create_req).await { - Ok(contract) => { - // If repository URL was provided, try to add it - if let Some(repo_url) = repository_url { - // Try to add as remote repository - let add_result = repository::add_remote_repository( - pool, - contract.id, - &format!("{} Repository", name), - &repo_url, - true, // is_primary - ) - .await; - - if let Err(e) = add_result { - tracing::warn!( - "Failed to add repository to contract {}: {}", - contract.id, - e - ); - } - } - - DiscussRequestResult { - success: true, - message: format!("Contract '{}' created successfully!", contract.name), - data: Some(json!({ - "createdContract": { - "id": contract.id.to_string(), - "name": contract.name, - "description": contract.description, - "contractType": contract.contract_type, - "initialPhase": contract.phase, - } - })), - } - } - Err(e) => { - tracing::error!("Failed to create contract: {}", e); - DiscussRequestResult { - success: false, - message: format!("Failed to create contract: {}", e), - data: None, - } - } - } - } - } -} diff --git a/makima/src/server/handlers/contracts.rs b/makima/src/server/handlers/contracts.rs deleted file mode 100644 index bdd4d40..0000000 --- a/makima/src/server/handlers/contracts.rs +++ /dev/null @@ -1,2376 +0,0 @@ -//! HTTP handlers for contract CRUD operations. - -use axum::{ - extract::{Path, State}, - http::StatusCode, - response::IntoResponse, - Json, -}; -use serde::Deserialize; -use utoipa::ToSchema; -use uuid::Uuid; - -use crate::db::models::{ - AddLocalRepositoryRequest, AddRemoteRepositoryRequest, ChangePhaseRequest, - ContractListResponse, ContractRepository, ContractSummary, ContractWithRelations, - CreateContractRequest, CreateManagedRepositoryRequest, PhaseChangeResult, - UpdateContractRequest, UpdateTaskRequest, -}; -use crate::db::repository::{self, RepositoryError}; -use crate::llm::PhaseDeliverables; -use crate::server::auth::Authenticated; -use crate::server::messages::ApiError; -use crate::server::state::SharedState; - -// ============================================================================= -// Deliverable Validation -// ============================================================================= - -/// Error type for deliverable validation failures -#[derive(Debug, Clone)] -pub struct DeliverableValidationError { - /// The error message with details about valid deliverables - pub message: String, -} - -impl DeliverableValidationError { - pub fn new(message: impl Into<String>) -> Self { - Self { - message: message.into(), - } - } -} - -impl std::fmt::Display for DeliverableValidationError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.message) - } -} - -impl std::error::Error for DeliverableValidationError {} - -/// Validates that a deliverable ID is valid for the given phase deliverables. -/// -/// # Arguments -/// * `deliverable_id` - The deliverable ID to validate -/// * `phase_deliverables` - The phase deliverables configuration to validate against -/// -/// # Returns -/// * `Ok(())` if the deliverable is valid -/// * `Err(DeliverableValidationError)` if the deliverable is not valid -pub fn validate_deliverable( - deliverable_id: &str, - phase_deliverables: &PhaseDeliverables, -) -> Result<(), DeliverableValidationError> { - let valid_deliverable = phase_deliverables - .deliverables - .iter() - .any(|d| d.id == deliverable_id); - - if valid_deliverable { - Ok(()) - } else { - let valid_ids: Vec<&str> = phase_deliverables - .deliverables - .iter() - .map(|d| d.id.as_str()) - .collect(); - - Err(DeliverableValidationError::new(format!( - "Invalid deliverable '{}' for {} phase. Valid IDs: [{}]", - deliverable_id, - phase_deliverables.phase, - valid_ids.join(", ") - ))) - } -} - -// ============================================================================= -// Supervisor Repository Update Helper -// ============================================================================= - -/// Helper function to update the supervisor task with repository info when a primary repo is added. -/// This ensures the supervisor has access to the repository when it starts. -async fn update_supervisor_with_repo_if_needed( - pool: &sqlx::PgPool, - contract_id: uuid::Uuid, - owner_id: uuid::Uuid, - repo: &ContractRepository, -) { - // Only update for primary repositories - if !repo.is_primary { - return; - } - - // Get the supervisor task - let supervisor = match repository::get_contract_supervisor_task(pool, contract_id).await { - Ok(Some(s)) => s, - Ok(None) => { - tracing::debug!(contract_id = %contract_id, "No supervisor task found"); - return; - } - Err(e) => { - tracing::warn!(contract_id = %contract_id, error = %e, "Failed to get supervisor task"); - return; - } - }; - - // Only update if supervisor doesn't have a repository URL yet - if supervisor.repository_url.is_some() { - tracing::debug!( - supervisor_id = %supervisor.id, - "Supervisor already has repository URL" - ); - return; - } - - // Get repository URL (for remote repos) or local path (for local repos) - let repo_url = repo.repository_url.clone().or_else(|| repo.local_path.clone()); - - if repo_url.is_none() && repo.source_type != "managed" { - tracing::debug!( - supervisor_id = %supervisor.id, - "Repository has no URL or path to assign" - ); - return; - } - - // Update supervisor task with repository info - let update_req = UpdateTaskRequest { - repository_url: repo_url, - version: Some(supervisor.version), - ..Default::default() - }; - - match repository::update_task_for_owner(pool, supervisor.id, owner_id, update_req).await { - Ok(Some(updated)) => { - tracing::info!( - supervisor_id = %updated.id, - repository_url = ?updated.repository_url, - "Updated supervisor task with repository URL" - ); - } - Ok(None) => { - tracing::warn!(supervisor_id = %supervisor.id, "Supervisor task not found during update"); - } - Err(e) => { - tracing::warn!( - supervisor_id = %supervisor.id, - error = %e, - "Failed to update supervisor with repository URL" - ); - } - } -} - -/// List all root contracts (no parent) for the authenticated user's owner. -#[utoipa::path( - get, - path = "/api/v1/contracts", - responses( - (status = 200, description = "List of root contracts", body = ContractListResponse), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn list_contracts( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - match repository::list_contracts_for_owner(pool, auth.owner_id).await { - Ok(contracts) => { - let total = contracts.len() as i64; - Json(ContractListResponse { contracts, total }).into_response() - } - Err(e) => { - tracing::error!("Failed to list contracts: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Get a contract by ID with all its relations (repositories, files, tasks). -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 200, description = "Contract details with relations", body = ContractWithRelations), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn get_contract( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Get the contract - let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Get repositories - let repositories = match repository::list_contract_repositories(pool, id).await { - Ok(r) => r, - Err(e) => { - tracing::warn!("Failed to get repositories for {}: {}", id, e); - Vec::new() - } - }; - - // Get files - let files = match repository::list_files_in_contract(pool, id, auth.owner_id).await { - Ok(f) => f, - Err(e) => { - tracing::warn!("Failed to get files for contract {}: {}", id, e); - Vec::new() - } - }; - - // Get tasks - let tasks = match repository::list_tasks_in_contract(pool, id, auth.owner_id).await { - Ok(t) => t, - Err(e) => { - tracing::warn!("Failed to get tasks for contract {}: {}", id, e); - Vec::new() - } - }; - - Json(ContractWithRelations { - contract, - repositories, - files, - tasks, - }) - .into_response() -} - -/// Create a new contract. -#[utoipa::path( - post, - path = "/api/v1/contracts", - request_body = CreateContractRequest, - responses( - (status = 201, description = "Contract created", body = ContractSummary), - (status = 400, description = "Invalid request", body = ApiError), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn create_contract( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Json(req): Json<CreateContractRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - match repository::create_contract_for_owner(pool, auth.owner_id, req.clone()).await { - Ok(contract) => { - // Create supervisor task for this contract - let supervisor_name = format!("{} Supervisor", contract.name); - let supervisor_plan = format!( - "You are the supervisor for contract '{}'. Your goal is to drive this contract to completion.\n\n{}", - contract.name, - contract.description.as_deref().unwrap_or("No description provided.") - ); - - // Get repository info from contract if available - let repo_url = { - // Try to get the first repository associated with this contract - match repository::list_contract_repositories(pool, contract.id).await { - Ok(repos) if !repos.is_empty() => { - let repo = &repos[0]; - repo.repository_url.clone() - } - _ => None, - } - }; - - let supervisor_req = crate::db::models::CreateTaskRequest { - name: supervisor_name, - description: None, - plan: supervisor_plan, - repository_url: repo_url, - base_branch: None, - target_branch: None, - parent_task_id: None, - contract_id: Some(contract.id), - target_repo_path: None, - completion_action: None, - continue_from_task_id: None, - copy_files: None, - is_supervisor: true, - checkpoint_sha: None, - priority: 0, - merge_mode: None, - branched_from_task_id: None, - conversation_history: None, - supervisor_worktree_task_id: None, // Supervisor uses its own worktree - directive_id: None, - directive_step_id: None, - }; - - match repository::create_task_for_owner(pool, auth.owner_id, supervisor_req).await { - Ok(supervisor_task) => { - tracing::info!( - contract_id = %contract.id, - supervisor_task_id = %supervisor_task.id, - is_supervisor = supervisor_task.is_supervisor, - "Created supervisor task for contract" - ); - - // Update contract with supervisor_task_id - let update_req = crate::db::models::UpdateContractRequest { - supervisor_task_id: Some(supervisor_task.id), - version: Some(contract.version), - ..Default::default() - }; - if let Err(e) = repository::update_contract_for_owner(pool, contract.id, auth.owner_id, update_req).await { - tracing::warn!( - contract_id = %contract.id, - error = %e, - "Failed to link supervisor task to contract" - ); - } - } - Err(e) => { - tracing::warn!( - contract_id = %contract.id, - error = %e, - "Failed to create supervisor task for contract" - ); - } - } - - // Record history event for contract creation - let _ = repository::record_history_event( - pool, - auth.owner_id, - Some(contract.id), - None, - "contract", - Some("created"), - Some(&contract.phase), - serde_json::json!({ - "name": &contract.name, - "type": &contract.contract_type, - "description": &contract.description, - }), - ).await; - - // Get the summary version with counts - match repository::get_contract_summary_for_owner(pool, contract.id, auth.owner_id).await - { - Ok(Some(summary)) => (StatusCode::CREATED, Json(summary)).into_response(), - Ok(None) => { - // Shouldn't happen, but return basic info if it does - ( - StatusCode::CREATED, - Json(ContractSummary { - id: contract.id, - name: contract.name, - description: contract.description, - contract_type: contract.contract_type, - phase: contract.phase, - status: contract.status, - supervisor_task_id: contract.supervisor_task_id, - local_only: contract.local_only, - auto_merge_local: contract.auto_merge_local, - file_count: 0, - task_count: 0, - repository_count: 0, - version: contract.version, - created_at: contract.created_at, - }), - ) - .into_response() - } - Err(e) => { - tracing::warn!("Failed to get contract summary: {}", e); - ( - StatusCode::CREATED, - Json(ContractSummary { - id: contract.id, - name: contract.name, - description: contract.description, - contract_type: contract.contract_type, - phase: contract.phase, - status: contract.status, - supervisor_task_id: contract.supervisor_task_id, - local_only: contract.local_only, - auto_merge_local: contract.auto_merge_local, - file_count: 0, - task_count: 0, - repository_count: 0, - version: contract.version, - created_at: contract.created_at, - }), - ) - .into_response() - } - } - } - Err(e) => { - tracing::error!("Failed to create contract: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Update a contract. -#[utoipa::path( - put, - path = "/api/v1/contracts/{id}", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - request_body = UpdateContractRequest, - responses( - (status = 200, description = "Contract updated", body = ContractSummary), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 409, description = "Version conflict", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn update_contract( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<UpdateContractRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - match repository::update_contract_for_owner(pool, id, auth.owner_id, req).await { - Ok(Some(contract)) => { - // If contract is completed, stop the supervisor task and clean up worktrees - if contract.status == "completed" { - if let Some(supervisor_task_id) = contract.supervisor_task_id { - // Get the supervisor task to find its daemon - if let Ok(Some(supervisor)) = repository::get_task_for_owner(pool, supervisor_task_id, auth.owner_id).await { - if let Some(daemon_id) = supervisor.daemon_id { - let state_clone = state.clone(); - tokio::spawn(async move { - // Gracefully interrupt the supervisor - let cmd = crate::server::state::DaemonCommand::InterruptTask { - task_id: supervisor_task_id, - graceful: true, - }; - if let Err(e) = state_clone.send_daemon_command(daemon_id, cmd).await { - tracing::warn!( - supervisor_task_id = %supervisor_task_id, - daemon_id = %daemon_id, - error = %e, - "Failed to stop supervisor task on contract completion" - ); - } else { - tracing::info!( - supervisor_task_id = %supervisor_task_id, - contract_id = %id, - "Stopped supervisor task on contract completion" - ); - } - }); - } - } - } - - // Clean up all task worktrees for this contract - let pool_clone = pool.clone(); - let state_clone = state.clone(); - let contract_id = id; - tokio::spawn(async move { - cleanup_contract_worktrees(&pool_clone, &state_clone, contract_id).await; - }); - - // Record history event for contract completion - let _ = repository::record_history_event( - pool, - auth.owner_id, - Some(contract.id), - None, - "contract", - Some("completed"), - Some(&contract.phase), - serde_json::json!({ - "name": &contract.name, - "status": &contract.status, - }), - ).await; - - } - - // Get summary with counts - match repository::get_contract_summary_for_owner(pool, contract.id, auth.owner_id).await - { - Ok(Some(summary)) => Json(summary).into_response(), - _ => Json(ContractSummary { - id: contract.id, - name: contract.name, - description: contract.description, - contract_type: contract.contract_type, - phase: contract.phase, - status: contract.status, - supervisor_task_id: contract.supervisor_task_id, - local_only: contract.local_only, - auto_merge_local: contract.auto_merge_local, - file_count: 0, - task_count: 0, - repository_count: 0, - version: contract.version, - created_at: contract.created_at, - }) - .into_response(), - } - } - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(), - Err(RepositoryError::VersionConflict { expected, actual }) => { - tracing::info!( - "Version conflict on contract {}: expected {}, actual {}", - id, - expected, - actual - ); - ( - StatusCode::CONFLICT, - Json(serde_json::json!({ - "code": "VERSION_CONFLICT", - "message": format!( - "Contract was modified. Expected version {}, actual version {}", - expected, actual - ), - "expectedVersion": expected, - "actualVersion": actual, - })), - ) - .into_response() - } - Err(RepositoryError::Database(e)) => { - tracing::error!("Failed to update contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Delete a contract. -#[utoipa::path( - delete, - path = "/api/v1/contracts/{id}", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 204, description = "Contract deleted"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn delete_contract( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // First, verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - // Clean up any pending supervisor questions for this contract - state.remove_pending_questions_for_contract(id); - - // Clean up all task worktrees BEFORE deleting the contract - // (because CASCADE delete will remove tasks from DB) - cleanup_contract_worktrees(pool, &state, id).await; - - match repository::delete_contract_for_owner(pool, id, auth.owner_id).await { - Ok(true) => StatusCode::NO_CONTENT.into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to delete contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -// ============================================================================= -// Repository Management -// ============================================================================= - -/// Add a remote repository to a contract. -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/repositories/remote", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - request_body = AddRemoteRepositoryRequest, - responses( - (status = 201, description = "Repository added", body = ContractRepository), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn add_remote_repository( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<AddRemoteRepositoryRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::add_remote_repository(pool, id, &req.name, &req.repository_url, req.is_primary) - .await - { - Ok(repo) => { - // Update supervisor task with repository info if this is a primary repo - update_supervisor_with_repo_if_needed(pool, id, auth.owner_id, &repo).await; - - // Track repository in history for future suggestions - if let Err(e) = repository::add_or_update_repository_history( - pool, - auth.owner_id, - &req.name, - Some(&req.repository_url), - None, - "remote", - ) - .await - { - // Log but don't fail the request if history tracking fails - tracing::warn!("Failed to track repository in history: {}", e); - } - - (StatusCode::CREATED, Json(repo)).into_response() - } - Err(e) => { - tracing::error!("Failed to add remote repository to contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Add a local repository to a contract. -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/repositories/local", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - request_body = AddLocalRepositoryRequest, - responses( - (status = 201, description = "Repository added", body = ContractRepository), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn add_local_repository( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<AddLocalRepositoryRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::add_local_repository(pool, id, &req.name, &req.local_path, req.is_primary) - .await - { - Ok(repo) => { - // Update supervisor task with repository info if this is a primary repo - update_supervisor_with_repo_if_needed(pool, id, auth.owner_id, &repo).await; - - // Track repository in history for future suggestions - if let Err(e) = repository::add_or_update_repository_history( - pool, - auth.owner_id, - &req.name, - None, - Some(&req.local_path), - "local", - ) - .await - { - // Log but don't fail the request if history tracking fails - tracing::warn!("Failed to track repository in history: {}", e); - } - - (StatusCode::CREATED, Json(repo)).into_response() - } - Err(e) => { - tracing::error!("Failed to add local repository to contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Create a managed repository (daemon will create it). -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/repositories/managed", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - request_body = CreateManagedRepositoryRequest, - responses( - (status = 201, description = "Repository creation requested", body = ContractRepository), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn create_managed_repository( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<CreateManagedRepositoryRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::create_managed_repository(pool, id, &req.name, req.is_primary).await { - Ok(repo) => { - // For managed repos, the daemon will create the repo and we'll update later - // For now, just mark that this is a managed repo configuration - // The helper handles the case where repo has no URL yet - update_supervisor_with_repo_if_needed(pool, id, auth.owner_id, &repo).await; - (StatusCode::CREATED, Json(repo)).into_response() - } - Err(e) => { - tracing::error!( - "Failed to create managed repository for contract {}: {}", - id, - e - ); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Delete a repository from a contract. -#[utoipa::path( - delete, - path = "/api/v1/contracts/{id}/repositories/{repo_id}", - params( - ("id" = Uuid, Path, description = "Contract ID"), - ("repo_id" = Uuid, Path, description = "Repository ID") - ), - responses( - (status = 204, description = "Repository removed"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract or repository not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn delete_repository( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path((id, repo_id)): Path<(Uuid, Uuid)>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::delete_contract_repository(pool, repo_id, id).await { - Ok(true) => StatusCode::NO_CONTENT.into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Repository not found")), - ) - .into_response(), - Err(e) => { - tracing::error!( - "Failed to delete repository {} from contract {}: {}", - repo_id, - id, - e - ); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Set a repository as primary for a contract. -#[utoipa::path( - put, - path = "/api/v1/contracts/{id}/repositories/{repo_id}/primary", - params( - ("id" = Uuid, Path, description = "Contract ID"), - ("repo_id" = Uuid, Path, description = "Repository ID") - ), - responses( - (status = 204, description = "Repository set as primary"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract or repository not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn set_repository_primary( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path((id, repo_id)): Path<(Uuid, Uuid)>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::set_repository_primary(pool, repo_id, id).await { - Ok(true) => StatusCode::NO_CONTENT.into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Repository not found")), - ) - .into_response(), - Err(e) => { - tracing::error!( - "Failed to set repository {} as primary for contract {}: {}", - repo_id, - id, - e - ); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -// ============================================================================= -// Task Association -// ============================================================================= - -/// Add a task to a contract. -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/tasks/{task_id}", - params( - ("id" = Uuid, Path, description = "Contract ID"), - ("task_id" = Uuid, Path, description = "Task ID") - ), - responses( - (status = 204, description = "Task added to contract"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract or task not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn add_task_to_contract( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path((id, task_id)): Path<(Uuid, Uuid)>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - // Verify task exists and belongs to owner - match repository::get_task_for_owner(pool, task_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Task not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get task {}: {}", task_id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::add_task_to_contract(pool, id, task_id, auth.owner_id).await { - Ok(true) => StatusCode::NO_CONTENT.into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Task not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to add task {} to contract {}: {}", task_id, id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Remove a task from a contract. -#[utoipa::path( - delete, - path = "/api/v1/contracts/{id}/tasks/{task_id}", - params( - ("id" = Uuid, Path, description = "Contract ID"), - ("task_id" = Uuid, Path, description = "Task ID") - ), - responses( - (status = 204, description = "Task removed from contract"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract or task not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn remove_task_from_contract( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path((id, task_id)): Path<(Uuid, Uuid)>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::remove_task_from_contract(pool, id, task_id, auth.owner_id).await { - Ok(true) => StatusCode::NO_CONTENT.into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Task not found in this contract")), - ) - .into_response(), - Err(e) => { - tracing::error!( - "Failed to remove task {} from contract {}: {}", - task_id, - id, - e - ); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -// ============================================================================= -// Phase Management -// ============================================================================= - -/// Change contract phase. -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/phase", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - request_body = ChangePhaseRequest, - responses( - (status = 200, description = "Phase changed", body = ContractSummary), - (status = 400, description = "Validation failed", body = ApiError), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 409, description = "Version conflict", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn change_phase( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<ChangePhaseRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // First, get the contract to check phase_guard - let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // If phase_guard is enabled and not confirmed, return phase deliverables for review - // This applies to ALL callers (including supervisors) - phase_guard enforcement at API level - if contract.phase_guard && !req.confirmed.unwrap_or(false) { - // If user provided feedback, return it - if let Some(ref feedback) = req.feedback { - return Json(serde_json::json!({ - "status": "changes_requested", - "currentPhase": contract.phase, - "requestedPhase": req.phase, - "feedback": feedback, - "message": "Feedback has been noted. Address the changes and try again." - })) - .into_response(); - } - - // Get files created in this phase - let phase_files = match repository::list_files_in_contract(pool, id, auth.owner_id).await { - Ok(files) => files - .into_iter() - .filter(|f| f.contract_phase.as_deref() == Some(&contract.phase)) - .map(|f| serde_json::json!({ - "id": f.id, - "name": f.name, - "description": f.description - })) - .collect::<Vec<_>>(), - Err(_) => Vec::new(), - }; - - // Get tasks completed in this contract - let phase_tasks = match repository::list_tasks_in_contract(pool, id, auth.owner_id).await { - Ok(tasks) => tasks - .into_iter() - .filter(|t| t.status == "done" || t.status == "completed") - .map(|t| serde_json::json!({ - "id": t.id, - "name": t.name, - "status": t.status - })) - .collect::<Vec<_>>(), - Err(_) => Vec::new(), - }; - - // Get phase deliverables with completion status - let phase_deliverables = crate::llm::get_phase_deliverables_for_type(&contract.phase, &contract.contract_type); - let completed_deliverables = contract.get_completed_deliverables(&contract.phase); - - let deliverables: Vec<serde_json::Value> = phase_deliverables - .deliverables - .iter() - .map(|d| serde_json::json!({ - "id": d.id, - "name": d.name, - "completed": completed_deliverables.contains(&d.id) - })) - .collect(); - - let deliverables_summary = format!( - "Phase '{}' deliverables: {} files created, {} tasks completed.", - contract.phase, - phase_files.len(), - phase_tasks.len() - ); - - let transition_id = uuid::Uuid::new_v4().to_string(); - - return Json(serde_json::json!({ - "status": "requires_confirmation", - "transitionId": transition_id, - "currentPhase": contract.phase, - "nextPhase": req.phase, - "deliverablesSummary": deliverables_summary, - "deliverables": deliverables, - "phaseFiles": phase_files, - "phaseTasks": phase_tasks, - "requiresConfirmation": true, - "message": "Phase guard is enabled. User confirmation required." - })) - .into_response(); - } - - // Phase guard is disabled or user confirmed - proceed with phase change - // Use the version-checking function for explicit conflict detection - match repository::change_contract_phase_with_version( - pool, - id, - auth.owner_id, - &req.phase, - req.expected_version, - ) - .await - { - Ok(PhaseChangeResult::Success(updated_contract)) => { - // Save supervisor state on phase change (Task 3.3) - // This is a key save point for restoration - let new_phase_for_state = updated_contract.phase.clone(); - let contract_id_for_state = updated_contract.id; - let pool_for_state = pool.clone(); - tokio::spawn(async move { - if let Err(e) = repository::update_supervisor_phase(&pool_for_state, contract_id_for_state, &new_phase_for_state).await { - tracing::warn!( - contract_id = %contract_id_for_state, - new_phase = %new_phase_for_state, - error = %e, - "Failed to save supervisor state on phase change" - ); - } - }); - - // Notify supervisor of phase change - if let Some(supervisor_task_id) = updated_contract.supervisor_task_id { - if let Ok(Some(supervisor)) = repository::get_task_for_owner(pool, supervisor_task_id, auth.owner_id).await { - let state_clone = state.clone(); - let contract_id = updated_contract.id; - let new_phase = updated_contract.phase.clone(); - tokio::spawn(async move { - state_clone.notify_supervisor_of_phase_change( - supervisor.id, - supervisor.daemon_id, - contract_id, - &new_phase, - ).await; - }); - } - } - - // Record history event for phase change - let _ = repository::record_history_event( - pool, - auth.owner_id, - Some(contract.id), - None, - "phase", - Some("changed"), - Some(&contract.phase), - serde_json::json!({ - "contractName": &contract.name, - "newPhase": &updated_contract.phase, - }), - ).await; - - // Get summary with counts - match repository::get_contract_summary_for_owner(pool, updated_contract.id, auth.owner_id).await - { - Ok(Some(summary)) => Json(summary).into_response(), - _ => Json(ContractSummary { - id: updated_contract.id, - name: updated_contract.name, - description: updated_contract.description, - contract_type: updated_contract.contract_type, - phase: updated_contract.phase, - status: updated_contract.status, - supervisor_task_id: updated_contract.supervisor_task_id, - local_only: updated_contract.local_only, - auto_merge_local: updated_contract.auto_merge_local, - file_count: 0, - task_count: 0, - repository_count: 0, - version: updated_contract.version, - created_at: updated_contract.created_at, - }) - .into_response(), - } - } - Ok(PhaseChangeResult::VersionConflict { expected, actual, current_phase }) => { - tracing::info!( - contract_id = %id, - expected_version = expected, - actual_version = actual, - current_phase = %current_phase, - "Phase change failed due to version conflict" - ); - ( - StatusCode::CONFLICT, - Json(serde_json::json!({ - "code": "VERSION_CONFLICT", - "message": "Phase change failed due to concurrent modification", - "details": { - "expected_version": expected, - "actual_version": actual, - "current_phase": current_phase - } - })), - ) - .into_response() - } - Ok(PhaseChangeResult::ValidationFailed { reason, missing_requirements }) => { - tracing::warn!( - contract_id = %id, - reason = %reason, - "Phase change validation failed" - ); - ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "code": "VALIDATION_FAILED", - "message": reason, - "details": { - "missing_requirements": missing_requirements - } - })), - ) - .into_response() - } - Ok(PhaseChangeResult::NotFound) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(), - Ok(PhaseChangeResult::Unauthorized) => ( - StatusCode::UNAUTHORIZED, - Json(ApiError::new("UNAUTHORIZED", "Not authorized to change this contract's phase")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to change phase for contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -// ============================================================================= -// Deliverables -// ============================================================================= - -/// Request body for marking a deliverable complete -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct MarkDeliverableRequest { - /// The deliverable ID to mark as complete (e.g., 'plan-document', 'pull-request') - pub deliverable_id: String, - /// Phase the deliverable belongs to. Defaults to current contract phase if not specified. - pub phase: Option<String>, -} - -/// Mark a deliverable as complete for a contract phase. -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/deliverables/complete", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - request_body = MarkDeliverableRequest, - responses( - (status = 200, description = "Deliverable marked complete", body = serde_json::Value), - (status = 400, description = "Invalid deliverable ID", body = ApiError), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn mark_deliverable_complete( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<MarkDeliverableRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Get contract - let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Use specified phase or default to current contract phase - let target_phase = req.phase.unwrap_or_else(|| contract.phase.clone()); - - // Validate the deliverable ID exists for this phase/contract type - // Use custom phase_config if present, otherwise fall back to built-in contract types - let phase_config = contract.get_phase_config(); - let phase_deliverables = crate::llm::get_phase_deliverables_with_config( - &target_phase, - &contract.contract_type, - phase_config.as_ref(), - ); - - // Validate deliverable exists - if let Err(validation_error) = validate_deliverable(&req.deliverable_id, &phase_deliverables) { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "code": "INVALID_DELIVERABLE", - "message": validation_error.message, - })), - ) - .into_response(); - } - - // Check if already completed - if contract.is_deliverable_complete(&target_phase, &req.deliverable_id) { - return Json(serde_json::json!({ - "success": true, - "message": format!("Deliverable '{}' is already marked complete for {} phase", req.deliverable_id, target_phase), - "deliverableId": req.deliverable_id, - "phase": target_phase, - "alreadyComplete": true, - })) - .into_response(); - } - - // Mark the deliverable as complete - match repository::mark_deliverable_complete(pool, id, &target_phase, &req.deliverable_id).await { - Ok(updated_contract) => { - let completed = updated_contract.get_completed_deliverables(&target_phase); - Json(serde_json::json!({ - "success": true, - "message": format!("Marked deliverable '{}' as complete for {} phase", req.deliverable_id, target_phase), - "deliverableId": req.deliverable_id, - "phase": target_phase, - "completedDeliverables": completed, - })) - .into_response() - } - Err(e) => { - tracing::error!("Failed to mark deliverable complete for contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -// ============================================================================= -// Events -// ============================================================================= - -/// Get contract event history. -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}/events", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 200, description = "Event history", body = Vec<crate::db::models::ContractEvent>), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn get_events( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::list_contract_events(pool, id).await { - Ok(events) => Json(events).into_response(), - Err(e) => { - tracing::error!("Failed to get events for contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -// ============================================================================= -// Internal Helper Functions -// ============================================================================= - -/// Clean up all worktrees for tasks in a contract. -/// -/// This is called when a contract is completed or deleted to remove -/// all associated task worktrees from connected daemons. -async fn cleanup_contract_worktrees( - pool: &sqlx::PgPool, - state: &SharedState, - contract_id: Uuid, -) { - tracing::info!( - contract_id = %contract_id, - "Cleaning up worktrees for contract tasks" - ); - - // Get all tasks with worktree info for this contract - let tasks = match repository::list_contract_tasks_with_worktree_info(pool, contract_id).await { - Ok(tasks) => tasks, - Err(e) => { - tracing::error!( - contract_id = %contract_id, - error = %e, - "Failed to list tasks for worktree cleanup" - ); - return; - } - }; - - if tasks.is_empty() { - tracing::debug!( - contract_id = %contract_id, - "No tasks with worktrees to clean up" - ); - return; - } - - tracing::info!( - contract_id = %contract_id, - task_count = tasks.len(), - "Found tasks with worktrees to clean up" - ); - - // Send cleanup command to each task's daemon - // Skip tasks that share a supervisor's worktree (they don't own the worktree) - for task in tasks { - // Skip tasks that reuse the supervisor's worktree - the supervisor owns it - if task.supervisor_worktree_task_id.is_some() { - tracing::debug!( - task_id = %task.id, - supervisor_worktree_task_id = ?task.supervisor_worktree_task_id, - contract_id = %contract_id, - "Task shares supervisor worktree, skipping worktree cleanup" - ); - continue; - } - - if let Some(daemon_id) = task.daemon_id { - let cmd = crate::server::state::DaemonCommand::CleanupWorktree { - task_id: task.id, - delete_branch: true, // Delete the branch when contract is done - }; - - match state.send_daemon_command(daemon_id, cmd).await { - Ok(()) => { - tracing::info!( - task_id = %task.id, - daemon_id = %daemon_id, - contract_id = %contract_id, - "Sent worktree cleanup command" - ); - } - Err(e) => { - tracing::warn!( - task_id = %task.id, - daemon_id = %daemon_id, - contract_id = %contract_id, - error = %e, - "Failed to send worktree cleanup command (daemon may be offline)" - ); - } - } - } else { - tracing::debug!( - task_id = %task.id, - contract_id = %contract_id, - "Task has no daemon assigned, skipping worktree cleanup" - ); - } - } -} - -// ============================================================================= -// Supervisor Status API -// ============================================================================= - -/// Query parameters for supervisor heartbeat history -#[derive(Debug, Deserialize)] -pub struct HeartbeatHistoryQuery { - /// Maximum number of heartbeats to return (default: 10) - pub limit: Option<i32>, - /// Offset for pagination (default: 0) - pub offset: Option<i32>, -} - -/// Get supervisor status for a contract. -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}/supervisor/status", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 200, description = "Supervisor status", body = crate::db::models::SupervisorStatusResponse), - (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 = "Contracts" -)] -pub async fn get_supervisor_status( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("CONTRACT_NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Check if contract has a supervisor - let supervisor_task_id = match contract.supervisor_task_id { - Some(task_id) => task_id, - None => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("SUPERVISOR_NOT_FOUND", "No supervisor task for this contract")), - ) - .into_response(); - } - }; - - // Get supervisor status from supervisor_states table - match repository::get_supervisor_status(pool, id, auth.owner_id).await { - Ok(Some(status_info)) => { - // Determine if supervisor is actively running - let is_running = status_info.is_running && status_info.task_status == "running"; - - let response = crate::db::models::SupervisorStatusResponse { - task_id: status_info.task_id, - state: status_info.supervisor_state, - phase: status_info.phase, - current_activity: status_info.current_activity, - progress: None, // We don't track progress percentage yet - last_heartbeat: status_info.last_heartbeat, - pending_task_ids: status_info.pending_task_ids, - is_running, - }; - Json(response).into_response() - } - Ok(None) => { - // No supervisor state record exists, but supervisor task might exist - // Try to get info from the task itself - match repository::get_task_for_owner(pool, supervisor_task_id, auth.owner_id).await { - Ok(Some(task)) => { - let is_running = task.daemon_id.is_some() && task.status == "running"; - let response = crate::db::models::SupervisorStatusResponse { - task_id: task.id, - state: task.status.clone(), - phase: contract.phase.clone(), - current_activity: task.progress_summary.clone(), - progress: None, - last_heartbeat: task.updated_at, - pending_task_ids: Vec::new(), - is_running, - }; - Json(response).into_response() - } - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("SUPERVISOR_NOT_FOUND", "Supervisor task not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to get supervisor task {}: {}", supervisor_task_id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } - } - Err(e) => { - tracing::error!("Failed to get supervisor status for contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Get supervisor heartbeat history for a contract. -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}/supervisor/heartbeats", - params( - ("id" = Uuid, Path, description = "Contract ID"), - ("limit" = Option<i32>, Query, description = "Maximum number of heartbeats to return (default: 10)"), - ("offset" = Option<i32>, Query, description = "Offset for pagination (default: 0)") - ), - responses( - (status = 200, description = "Supervisor heartbeat history", body = crate::db::models::SupervisorHeartbeatHistoryResponse), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn get_supervisor_heartbeats( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - axum::extract::Query(query): axum::extract::Query<HeartbeatHistoryQuery>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("CONTRACT_NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - let limit = query.limit.unwrap_or(10).min(100); // Cap at 100 - let offset = query.offset.unwrap_or(0); - - // Get activity history as heartbeats - let activities = match repository::get_supervisor_activity_history(pool, id, limit, offset).await { - Ok(activities) => activities, - Err(e) => { - tracing::error!("Failed to get supervisor heartbeats for contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Get total count for pagination - let total = match repository::count_supervisor_activity_history(pool, id).await { - Ok(count) => count, - Err(e) => { - tracing::warn!("Failed to count supervisor heartbeats: {}", e); - activities.len() as i64 - } - }; - - // Convert to heartbeat entries - let heartbeats: Vec<crate::db::models::SupervisorHeartbeatEntry> = activities - .into_iter() - .map(|a| crate::db::models::SupervisorHeartbeatEntry { - timestamp: a.timestamp, - state: a.state, - activity: a.activity, - progress: a.progress.map(|p| p as u8), - phase: a.phase, - pending_task_ids: a.pending_task_ids, - }) - .collect(); - - Json(crate::db::models::SupervisorHeartbeatHistoryResponse { - heartbeats, - total, - }) - .into_response() -} - -/// Sync supervisor state (refresh last_activity timestamp). -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/supervisor/sync", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 200, description = "Supervisor synced", body = crate::db::models::SupervisorSyncResponse), - (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 = "Contracts" -)] -pub async fn sync_supervisor( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("CONTRACT_NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Check if contract has a supervisor - if contract.supervisor_task_id.is_none() { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("SUPERVISOR_NOT_FOUND", "No supervisor task for this contract")), - ) - .into_response(); - } - - // Sync supervisor state (update last_activity) - match repository::sync_supervisor_state(pool, id).await { - Ok(Some(_state)) => { - // Get task status to determine current state - let task_status = if let Some(task_id) = contract.supervisor_task_id { - match repository::get_task_for_owner(pool, task_id, auth.owner_id).await { - Ok(Some(task)) => task.status, - _ => "unknown".to_string(), - } - } else { - "unknown".to_string() - }; - - Json(crate::db::models::SupervisorSyncResponse { - synced: true, - state: task_status, - message: Some("Supervisor state synced successfully".to_string()), - }) - .into_response() - } - Ok(None) => { - // No supervisor state exists, return not found - ( - StatusCode::NOT_FOUND, - Json(ApiError::new("SUPERVISOR_NOT_FOUND", "No supervisor state found for this contract")), - ) - .into_response() - } - Err(e) => { - tracing::error!("Failed to sync supervisor state for contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -// ============================================================================= -// Tests -// ============================================================================= - -#[cfg(test)] -mod tests { - use super::*; - use crate::db::models::{DeliverableDefinition, PhaseConfig, PhaseDefinition}; - use crate::llm::{get_phase_deliverables_for_type, get_phase_deliverables_with_config}; - use std::collections::HashMap; - - #[test] - fn test_validate_deliverable_valid_simple_plan() { - let phase_deliverables = get_phase_deliverables_for_type("plan", "simple"); - let result = validate_deliverable("plan-document", &phase_deliverables); - assert!(result.is_ok()); - } - - #[test] - fn test_validate_deliverable_valid_simple_execute() { - let phase_deliverables = get_phase_deliverables_for_type("execute", "simple"); - let result = validate_deliverable("pull-request", &phase_deliverables); - assert!(result.is_ok()); - } - - #[test] - fn test_validate_deliverable_invalid_id() { - let phase_deliverables = get_phase_deliverables_for_type("plan", "simple"); - let result = validate_deliverable("nonexistent-deliverable", &phase_deliverables); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err.message.contains("Invalid deliverable")); - assert!(err.message.contains("nonexistent-deliverable")); - assert!(err.message.contains("plan-document")); - } - - #[test] - fn test_validate_deliverable_specification_phases() { - // Research phase - let phase_deliverables = get_phase_deliverables_for_type("research", "specification"); - assert!(validate_deliverable("research-notes", &phase_deliverables).is_ok()); - assert!(validate_deliverable("invalid", &phase_deliverables).is_err()); - - // Specify phase - let phase_deliverables = get_phase_deliverables_for_type("specify", "specification"); - assert!(validate_deliverable("requirements-document", &phase_deliverables).is_ok()); - assert!(validate_deliverable("plan-document", &phase_deliverables).is_err()); - - // Review phase - let phase_deliverables = get_phase_deliverables_for_type("review", "specification"); - assert!(validate_deliverable("release-notes", &phase_deliverables).is_ok()); - } - - #[test] - fn test_validate_deliverable_execute_type_no_deliverables() { - // Execute-only contracts have no deliverables - let phase_deliverables = get_phase_deliverables_for_type("execute", "execute"); - // Any deliverable should fail since there are none - let result = validate_deliverable("pull-request", &phase_deliverables); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err.message.contains("Valid IDs: []")); - } - - #[test] - fn test_validate_deliverable_with_custom_phase_config() { - // Create a custom phase config - let mut deliverables = HashMap::new(); - deliverables.insert( - "design".to_string(), - vec![ - DeliverableDefinition { - id: "architecture-doc".to_string(), - name: "Architecture Document".to_string(), - priority: "required".to_string(), - }, - DeliverableDefinition { - id: "api-spec".to_string(), - name: "API Specification".to_string(), - priority: "recommended".to_string(), - }, - ], - ); - - let phase_config = PhaseConfig { - phases: vec![ - PhaseDefinition { - id: "design".to_string(), - name: "Design".to_string(), - order: 0, - }, - PhaseDefinition { - id: "build".to_string(), - name: "Build".to_string(), - order: 1, - }, - ], - default_phase: "design".to_string(), - deliverables, - }; - - // Validate against custom config - let phase_deliverables = - get_phase_deliverables_with_config("design", "custom", Some(&phase_config)); - - // Valid custom deliverables - assert!(validate_deliverable("architecture-doc", &phase_deliverables).is_ok()); - assert!(validate_deliverable("api-spec", &phase_deliverables).is_ok()); - - // Invalid deliverable for custom config - let result = validate_deliverable("plan-document", &phase_deliverables); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err.message.contains("Invalid deliverable")); - assert!(err.message.contains("plan-document")); - assert!(err.message.contains("architecture-doc")); - assert!(err.message.contains("api-spec")); - } - - #[test] - fn test_validate_deliverable_error_message_format() { - let phase_deliverables = get_phase_deliverables_for_type("plan", "simple"); - let result = validate_deliverable("xyz", &phase_deliverables); - let err = result.unwrap_err(); - - // Check error message format matches the specification - assert!(err.message.contains("Invalid deliverable 'xyz'")); - assert!(err.message.contains("plan phase")); - assert!(err.message.contains("Valid IDs:")); - assert!(err.message.contains("plan-document")); - } - - #[test] - fn test_deliverable_validation_error_display() { - let err = DeliverableValidationError::new("Test error message"); - assert_eq!(format!("{}", err), "Test error message"); - } - - #[test] - fn test_validate_deliverable_unknown_phase() { - // Unknown phase should return empty deliverables - let phase_deliverables = get_phase_deliverables_for_type("unknown", "simple"); - let result = validate_deliverable("any-id", &phase_deliverables); - assert!(result.is_err()); - } -} diff --git a/makima/src/server/handlers/mesh.rs b/makima/src/server/handlers/mesh.rs index ac5652a..63b1827 100644 --- a/makima/src/server/handlers/mesh.rs +++ b/makima/src/server/handlers/mesh.rs @@ -122,7 +122,11 @@ pub async fn list_tasks( }; let result = if query.orphan { - repository::list_orphan_tasks_for_owner(pool, auth.owner_id).await + // Backed by the per-owner tmp directive going forward — see + // `list_tmp_tasks_for_owner` for the semantics. The query parameter + // name (`?orphan=true`) is preserved for backwards compatibility + // with existing frontend callers. + repository::list_tmp_tasks_for_owner(pool, auth.owner_id).await } else { repository::list_tasks_for_owner(pool, auth.owner_id).await }; @@ -228,7 +232,7 @@ pub async fn get_task( pub async fn create_task( State(state): State<SharedState>, Authenticated(auth): Authenticated, - Json(req): Json<CreateTaskRequest>, + Json(mut req): Json<CreateTaskRequest>, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( @@ -238,6 +242,32 @@ pub async fn create_task( .into_response(); }; + // Every top-level task must live under SOME directive going forward — + // the unified directive surface is the only way users see tasks. If a + // caller doesn't supply directive_id, attach to the owner's tmp + // (scratchpad) directive, auto-creating it if needed. Subtasks + // (parent_task_id set) inherit their parent's directive linkage and + // are fine without an explicit directive_id. + if req.directive_id.is_none() && req.parent_task_id.is_none() { + match repository::get_or_create_tmp_directive(pool, auth.owner_id).await { + Ok(tmp) => { + req.directive_id = Some(tmp.id); + } + Err(e) => { + tracing::error!( + owner_id = %auth.owner_id, + error = %e, + "Failed to provision tmp directive for orphan task" + ); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("TMP_PROVISION_FAILED", &e.to_string())), + ) + .into_response(); + } + } + } + match repository::create_task_for_owner(pool, auth.owner_id, req).await { Ok(task) => { // Record history event for task creation diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs index 4bdb424..c761dcc 100644 --- a/makima/src/server/handlers/mod.rs +++ b/makima/src/server/handlers/mod.rs @@ -1,12 +1,11 @@ //! HTTP and WebSocket request handlers. +//! +//! Phase 5 removed: contract_chat, contract_daemon, contract_discuss, +//! contracts, transcript_analysis. Contracts subsystem is gone. pub mod api_keys; pub mod chat; -pub mod contract_chat; -pub mod contract_daemon; -pub mod contract_discuss; pub mod daemon_download; -pub mod contracts; pub mod directives; pub mod file_ws; pub mod files; @@ -23,6 +22,5 @@ pub mod repository_history; pub mod speak; pub mod templates; pub mod voice; -pub mod transcript_analysis; pub mod users; pub mod versions; diff --git a/makima/src/server/handlers/transcript_analysis.rs b/makima/src/server/handlers/transcript_analysis.rs deleted file mode 100644 index 9261c0c..0000000 --- a/makima/src/server/handlers/transcript_analysis.rs +++ /dev/null @@ -1,690 +0,0 @@ -//! HTTP handlers for transcript analysis and contract integration. - -use axum::{ - extract::State, - http::StatusCode, - response::IntoResponse, - Json, -}; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; -use uuid::Uuid; - -use crate::db::{models, repository}; -use crate::llm::transcript_analyzer::{ - TranscriptAnalysisResult, build_analysis_prompt, calculate_speaker_stats, - format_transcript_for_analysis, parse_analysis_response, -}; -use crate::llm::claude::{ClaudeClient, ClaudeModel, Message, MessageContent}; -use crate::server::auth::Authenticated; -use crate::server::messages::ApiError; -use crate::server::state::SharedState; - -// ============================================================================= -// Request/Response Types -// ============================================================================= - -/// Request to analyze a file's transcript -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct AnalyzeTranscriptRequest { - /// File ID containing the transcript to analyze - pub file_id: Uuid, -} - -/// Response from transcript analysis -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct AnalyzeTranscriptResponse { - pub file_id: Uuid, - pub analysis: TranscriptAnalysisResult, -} - -/// Request to create a contract from analysis -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct CreateContractFromAnalysisRequest { - /// File ID containing the analyzed transcript - pub file_id: Uuid, - /// Override the suggested name (optional) - pub name: Option<String>, - /// Override the suggested description (optional) - pub description: Option<String>, - /// Include requirements as file content (default: true) - #[serde(default = "default_true")] - pub include_requirements: bool, - /// Include decisions as file content (default: true) - #[serde(default = "default_true")] - pub include_decisions: bool, - /// Include action items as tasks (default: true) - #[serde(default = "default_true")] - pub include_action_items: bool, -} - -fn default_true() -> bool { - true -} - -/// Response from creating contract from analysis -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct CreateContractFromAnalysisResponse { - pub contract_id: Uuid, - pub contract_name: String, - pub files_created: Vec<FileCreatedInfo>, - pub tasks_created: Vec<TaskCreatedInfo>, -} - -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct FileCreatedInfo { - pub id: Uuid, - pub name: String, - pub file_type: String, -} - -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct TaskCreatedInfo { - pub id: Uuid, - pub name: String, -} - -/// Request to update an existing contract from analysis -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct UpdateContractFromAnalysisRequest { - /// File ID containing the transcript - pub file_id: Uuid, - /// Contract ID to update - pub contract_id: Uuid, - /// Add requirements to contract files - #[serde(default = "default_true")] - pub add_requirements: bool, - /// Add decisions to contract files - #[serde(default = "default_true")] - pub add_decisions: bool, - /// Create tasks from action items - #[serde(default = "default_true")] - pub create_tasks: bool, -} - -/// Response from updating contract -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct UpdateContractFromAnalysisResponse { - pub contract_id: Uuid, - pub files_updated: Vec<Uuid>, - pub tasks_created: Vec<TaskCreatedInfo>, - pub analysis_summary: String, -} - -// ============================================================================= -// Handlers -// ============================================================================= - -/// Analyze a file's transcript to extract requirements, decisions, and action items. -#[utoipa::path( - post, - path = "/api/v1/listen/analyze", - request_body = AnalyzeTranscriptRequest, - responses( - (status = 200, description = "Transcript analyzed", body = AnalyzeTranscriptResponse), - (status = 400, description = "Invalid request"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "File not found"), - (status = 500, description = "Internal server error"), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Listen" -)] -pub async fn analyze_transcript( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Json(request): Json<AnalyzeTranscriptRequest>, -) -> 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 - let file = match repository::get_file_for_owner(pool, request.file_id, auth.owner_id).await { - Ok(Some(f)) => f, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "File not found")), - ).into_response(); - } - Err(e) => { - tracing::error!(error = %e, "Failed to get file"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ).into_response(); - } - }; - - // Check if transcript is empty - if file.transcript.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(ApiError::new("NO_TRANSCRIPT", "File has no transcript to analyze")), - ).into_response(); - } - - // Analyze the transcript - match analyze_transcript_internal(&file.transcript).await { - Ok(analysis) => { - Json(AnalyzeTranscriptResponse { - file_id: request.file_id, - analysis, - }).into_response() - } - Err(e) => { - tracing::error!(error = %e, "Failed to analyze transcript"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("ANALYSIS_ERROR", e)), - ).into_response() - } - } -} - -/// Create a new contract from an analyzed transcript. -#[utoipa::path( - post, - path = "/api/v1/listen/create-contract", - request_body = CreateContractFromAnalysisRequest, - responses( - (status = 201, description = "Contract created", body = CreateContractFromAnalysisResponse), - (status = 400, description = "Invalid request"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "File not found"), - (status = 500, description = "Internal server error"), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Listen" -)] -pub async fn create_contract_from_analysis( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Json(request): Json<CreateContractFromAnalysisRequest>, -) -> 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 with transcript - let file = match repository::get_file_for_owner(pool, request.file_id, auth.owner_id).await { - Ok(Some(f)) => f, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "File not found")), - ).into_response(); - } - Err(e) => { - tracing::error!(error = %e, "Failed to get file"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ).into_response(); - } - }; - - if file.transcript.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(ApiError::new("NO_TRANSCRIPT", "File has no transcript")), - ).into_response(); - } - - // Analyze transcript - let analysis = match analyze_transcript_internal(&file.transcript).await { - Ok(a) => a, - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("ANALYSIS_ERROR", e)), - ).into_response(); - } - }; - - // Determine contract name and description - let contract_name = request.name - .or(analysis.suggested_contract_name.clone()) - .unwrap_or_else(|| format!("Contract from {}", file.name)); - let contract_description = request.description - .or(analysis.suggested_description.clone()); - - // Create the contract - let contract_req = models::CreateContractRequest { - name: contract_name.clone(), - description: contract_description, - contract_type: Some("specification".to_string()), - initial_phase: Some("research".to_string()), - autonomous_loop: None, - phase_guard: None, - local_only: None, - auto_merge_local: None, - template_id: None, - }; - - let contract = match repository::create_contract_for_owner(pool, auth.owner_id, contract_req).await { - Ok(c) => c, - Err(e) => { - tracing::error!(error = %e, "Failed to create contract"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ).into_response(); - } - }; - - let mut files_created: Vec<FileCreatedInfo> = Vec::new(); - let mut tasks_created: Vec<TaskCreatedInfo> = Vec::new(); - - // Create requirements file if we have requirements - if request.include_requirements && !analysis.requirements.is_empty() { - let body = build_requirements_body(&analysis.requirements); - let file_req = models::CreateFileRequest { - contract_id: contract.id, - name: Some("Requirements from Transcript".to_string()), - description: Some("Requirements extracted from voice transcript".to_string()), - transcript: vec![], - location: None, - body, - repo_file_path: None, - contract_phase: Some("research".to_string()), - }; - - if let Ok(f) = repository::create_file_for_owner(pool, auth.owner_id, file_req).await { - files_created.push(FileCreatedInfo { - id: f.id, - name: f.name, - file_type: "requirements".to_string(), - }); - } - } - - // Create decisions file if we have decisions - if request.include_decisions && !analysis.decisions.is_empty() { - let body = build_decisions_body(&analysis.decisions); - let file_req = models::CreateFileRequest { - contract_id: contract.id, - name: Some("Decisions from Transcript".to_string()), - description: Some("Decisions extracted from voice transcript".to_string()), - transcript: vec![], - location: None, - body, - repo_file_path: None, - contract_phase: Some("research".to_string()), - }; - - if let Ok(f) = repository::create_file_for_owner(pool, auth.owner_id, file_req).await { - files_created.push(FileCreatedInfo { - id: f.id, - name: f.name, - file_type: "decisions".to_string(), - }); - } - } - - // Create tasks from action items - if request.include_action_items && !analysis.action_items.is_empty() { - for item in &analysis.action_items { - let task_req = models::CreateTaskRequest { - contract_id: Some(contract.id), - name: truncate_for_name(&item.text, 100), - description: Some(format!("Action item from transcript (Speaker: {})", item.speaker)), - plan: item.text.clone(), - repository_url: None, - base_branch: None, - target_branch: None, - parent_task_id: None, - target_repo_path: None, - completion_action: None, - continue_from_task_id: None, - copy_files: None, - is_supervisor: false, - checkpoint_sha: None, - priority: match item.priority.as_deref() { - Some("high") => 10, - Some("medium") => 5, - _ => 0, - }, - merge_mode: None, - branched_from_task_id: None, - conversation_history: None, - supervisor_worktree_task_id: None, // Not spawned by supervisor - directive_id: None, - directive_step_id: None, - }; - - if let Ok(t) = repository::create_task_for_owner(pool, auth.owner_id, task_req).await { - tasks_created.push(TaskCreatedInfo { - id: t.id, - name: t.name, - }); - } - } - } - - ( - StatusCode::CREATED, - Json(CreateContractFromAnalysisResponse { - contract_id: contract.id, - contract_name, - files_created, - tasks_created, - }), - ).into_response() -} - -/// Update an existing contract with information from transcript analysis. -#[utoipa::path( - post, - path = "/api/v1/listen/update-contract", - request_body = UpdateContractFromAnalysisRequest, - responses( - (status = 200, description = "Contract updated", body = UpdateContractFromAnalysisResponse), - (status = 400, description = "Invalid request"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "File or contract not found"), - (status = 500, description = "Internal server error"), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Listen" -)] -pub async fn update_contract_from_analysis( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Json(request): Json<UpdateContractFromAnalysisRequest>, -) -> 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 - let file = match repository::get_file_for_owner(pool, request.file_id, auth.owner_id).await { - Ok(Some(f)) => f, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "File not found")), - ).into_response(); - } - Err(e) => { - tracing::error!(error = %e, "Failed to get file"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ).into_response(); - } - }; - - // Verify contract exists - let _contract = match repository::get_contract_for_owner(pool, request.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!(error = %e, "Failed to get contract"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ).into_response(); - } - }; - - if file.transcript.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(ApiError::new("NO_TRANSCRIPT", "File has no transcript")), - ).into_response(); - } - - // Analyze transcript - let analysis = match analyze_transcript_internal(&file.transcript).await { - Ok(a) => a, - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("ANALYSIS_ERROR", e)), - ).into_response(); - } - }; - - let mut files_updated: Vec<Uuid> = Vec::new(); - let mut tasks_created: Vec<TaskCreatedInfo> = Vec::new(); - - // Create or update requirements file - if request.add_requirements && !analysis.requirements.is_empty() { - let body = build_requirements_body(&analysis.requirements); - let file_req = models::CreateFileRequest { - contract_id: request.contract_id, - name: Some(format!("Requirements from {}", file.name)), - description: Some("Requirements extracted from voice transcript".to_string()), - transcript: vec![], - location: None, - body, - repo_file_path: None, - contract_phase: None, - }; - - if let Ok(f) = repository::create_file_for_owner(pool, auth.owner_id, file_req).await { - files_updated.push(f.id); - } - } - - // Create or update decisions file - if request.add_decisions && !analysis.decisions.is_empty() { - let body = build_decisions_body(&analysis.decisions); - let file_req = models::CreateFileRequest { - contract_id: request.contract_id, - name: Some(format!("Decisions from {}", file.name)), - description: Some("Decisions extracted from voice transcript".to_string()), - transcript: vec![], - location: None, - body, - repo_file_path: None, - contract_phase: None, - }; - - if let Ok(f) = repository::create_file_for_owner(pool, auth.owner_id, file_req).await { - files_updated.push(f.id); - } - } - - // Create tasks from action items - if request.create_tasks && !analysis.action_items.is_empty() { - for item in &analysis.action_items { - let task_req = models::CreateTaskRequest { - contract_id: Some(request.contract_id), - name: truncate_for_name(&item.text, 100), - description: Some(format!("Action item from {} (Speaker: {})", file.name, item.speaker)), - plan: item.text.clone(), - repository_url: None, - base_branch: None, - target_branch: None, - parent_task_id: None, - target_repo_path: None, - completion_action: None, - continue_from_task_id: None, - copy_files: None, - is_supervisor: false, - checkpoint_sha: None, - priority: 0, - merge_mode: None, - branched_from_task_id: None, - conversation_history: None, - supervisor_worktree_task_id: None, // Not spawned by supervisor - directive_id: None, - directive_step_id: None, - }; - - if let Ok(t) = repository::create_task_for_owner(pool, auth.owner_id, task_req).await { - tasks_created.push(TaskCreatedInfo { - id: t.id, - name: t.name, - }); - } - } - } - - let summary = format!( - "Extracted {} requirements, {} decisions, {} action items from transcript", - analysis.requirements.len(), - analysis.decisions.len(), - analysis.action_items.len() - ); - - Json(UpdateContractFromAnalysisResponse { - contract_id: request.contract_id, - files_updated, - tasks_created, - analysis_summary: summary, - }).into_response() -} - -// ============================================================================= -// Helper Functions -// ============================================================================= - -/// Analyze transcript using Claude -async fn analyze_transcript_internal( - transcript: &[models::TranscriptEntry], -) -> Result<TranscriptAnalysisResult, String> { - let transcript_text = format_transcript_for_analysis(transcript); - let speaker_stats = calculate_speaker_stats(transcript); - let prompt = build_analysis_prompt(&transcript_text); - - // Create Claude client - let client = ClaudeClient::from_env(ClaudeModel::Sonnet) - .map_err(|e| format!("Failed to create Claude client: {}", e))?; - - // Call Claude API with empty tools to make a simple chat call - let messages = vec![Message { - role: "user".to_string(), - content: MessageContent::Text(prompt), - }]; - - let result = client.chat_with_tools(messages, &[]).await - .map_err(|e| format!("Claude API error: {}", e))?; - - // Parse the response - let content = result.content.ok_or_else(|| "No response content from Claude".to_string())?; - parse_analysis_response(&content, speaker_stats) -} - -/// Build file body elements from requirements -fn build_requirements_body(requirements: &[crate::llm::transcript_analyzer::ExtractedRequirement]) -> Vec<models::BodyElement> { - let mut body = vec![ - models::BodyElement::Heading { - level: 1, - text: "Requirements".to_string(), - }, - ]; - - // Group by category if available - let mut functional = Vec::new(); - let mut technical = Vec::new(); - let mut other = Vec::new(); - - for req in requirements { - match req.category.as_deref() { - Some("functional") => functional.push(req), - Some("technical") => technical.push(req), - _ => other.push(req), - } - } - - if !functional.is_empty() { - body.push(models::BodyElement::Heading { - level: 2, - text: "Functional Requirements".to_string(), - }); - body.push(models::BodyElement::List { - ordered: false, - items: functional.iter().map(|r| format!("{} ({})", r.text, r.speaker)).collect(), - }); - } - - if !technical.is_empty() { - body.push(models::BodyElement::Heading { - level: 2, - text: "Technical Requirements".to_string(), - }); - body.push(models::BodyElement::List { - ordered: false, - items: technical.iter().map(|r| format!("{} ({})", r.text, r.speaker)).collect(), - }); - } - - if !other.is_empty() { - body.push(models::BodyElement::Heading { - level: 2, - text: "Other Requirements".to_string(), - }); - body.push(models::BodyElement::List { - ordered: false, - items: other.iter().map(|r| format!("{} ({})", r.text, r.speaker)).collect(), - }); - } - - body -} - -/// Build file body elements from decisions -fn build_decisions_body(decisions: &[crate::llm::transcript_analyzer::ExtractedDecision]) -> Vec<models::BodyElement> { - let mut body = vec![ - models::BodyElement::Heading { - level: 1, - text: "Decisions".to_string(), - }, - ]; - - let items: Vec<String> = decisions.iter().map(|d| { - let context = d.context.as_ref().map(|c| format!(" (Context: {})", c)).unwrap_or_default(); - format!("**{}**: {}{}", d.speaker, d.text, context) - }).collect(); - - body.push(models::BodyElement::List { - ordered: true, - items, - }); - - body -} - -/// Truncate text to fit as a task name -fn truncate_for_name(text: &str, max_len: usize) -> String { - if text.len() <= max_len { - text.to_string() - } else { - format!("{}...", &text[..max_len - 3]) - } -} diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index efae901..59eff2e 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -18,7 +18,7 @@ use tower_http::trace::TraceLayer; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; -use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contract_discuss, contracts, daemon_download, directives, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, orders, repository_history, speak, templates, transcript_analysis, users, versions}; +use crate::server::handlers::{api_keys, chat, daemon_download, directives, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, orders, repository_history, speak, templates, users, versions}; use crate::server::openapi::ApiDoc; use crate::server::state::SharedState; @@ -45,10 +45,8 @@ pub fn make_router(state: SharedState) -> Router { let api_v1 = Router::new() .route("/listen", get(listen::websocket_handler)) .route("/speak", get(speak::websocket_handler)) - // Listen/transcript analysis endpoints - .route("/listen/analyze", post(transcript_analysis::analyze_transcript)) - .route("/listen/create-contract", post(transcript_analysis::create_contract_from_analysis)) - .route("/listen/update-contract", post(transcript_analysis::update_contract_from_analysis)) + // Listen/transcript-analysis endpoints removed in Phase 5 with the + // contracts subsystem. .route("/files/subscribe", get(file_ws::file_subscription_handler)) .route("/files", get(files::list_files).post(files::create_file)) .route( @@ -167,68 +165,9 @@ pub fn make_router(state: SharedState) -> Router { get(users::get_user_settings_handler) .put(users::update_user_settings_handler), ) - // Contract endpoints - .route("/contracts/discuss", post(contract_discuss::discuss_contract_handler)) - .route( - "/contracts", - get(contracts::list_contracts).post(contracts::create_contract), - ) - .route( - "/contracts/{id}", - get(contracts::get_contract) - .put(contracts::update_contract) - .delete(contracts::delete_contract), - ) - .route("/contracts/{id}/phase", post(contracts::change_phase)) - .route("/contracts/{id}/deliverables/complete", post(contracts::mark_deliverable_complete)) - .route("/contracts/{id}/events", get(contracts::get_events)) - .route("/contracts/{id}/chat", post(contract_chat::contract_chat_handler)) - .route( - "/contracts/{id}/chat/history", - get(contract_chat::get_contract_chat_history).delete(contract_chat::clear_contract_chat_history), - ) - // Contract supervisor resume endpoints - .route("/contracts/{id}/supervisor/resume", post(mesh_supervisor::resume_supervisor)) - .route("/contracts/{id}/supervisor/conversation/rewind", post(mesh_supervisor::rewind_conversation)) - // Contract supervisor status endpoints - .route("/contracts/{id}/supervisor/status", get(contracts::get_supervisor_status)) - .route("/contracts/{id}/supervisor/heartbeats", get(contracts::get_supervisor_heartbeats)) - .route("/contracts/{id}/supervisor/sync", post(contracts::sync_supervisor)) - // History endpoints - .route("/contracts/{id}/history", get(history::get_contract_history)) - .route("/contracts/{id}/supervisor/conversation", get(history::get_supervisor_conversation)) - // Contract daemon endpoints (for tasks to interact with contracts) - .route("/contracts/{id}/daemon/status", get(contract_daemon::get_contract_status)) - .route("/contracts/{id}/daemon/checklist", get(contract_daemon::get_contract_checklist)) - .route("/contracts/{id}/daemon/goals", get(contract_daemon::get_contract_goals)) - .route("/contracts/{id}/daemon/report", post(contract_daemon::post_progress_report)) - .route("/contracts/{id}/daemon/suggest-action", post(contract_daemon::get_suggest_action)) - .route("/contracts/{id}/daemon/completion-action", post(contract_daemon::get_completion_action)) - .route( - "/contracts/{id}/daemon/files", - get(contract_daemon::list_contract_files).post(contract_daemon::create_contract_file), - ) - .route( - "/contracts/{id}/daemon/files/{file_id}", - get(contract_daemon::get_contract_file).put(contract_daemon::update_contract_file), - ) - // Contract repository endpoints - .route("/contracts/{id}/repositories/remote", post(contracts::add_remote_repository)) - .route("/contracts/{id}/repositories/local", post(contracts::add_local_repository)) - .route("/contracts/{id}/repositories/managed", post(contracts::create_managed_repository)) - .route( - "/contracts/{id}/repositories/{repo_id}", - axum::routing::delete(contracts::delete_repository), - ) - .route( - "/contracts/{id}/repositories/{repo_id}/primary", - axum::routing::put(contracts::set_repository_primary), - ) - // Contract task association endpoints - .route( - "/contracts/{id}/tasks/{task_id}", - post(contracts::add_task_to_contract).delete(contracts::remove_task_from_contract), - ) + // Contract endpoints removed in Phase 5. The contracts subsystem + // has been folded into directives — see Phase 5 in the unified + // surface plan. Routes are gone; handler files were deleted. // Directive endpoints .route( "/directives", diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs index 7a4b004..51a1c0d 100644 --- a/makima/src/server/openapi.rs +++ b/makima/src/server/openapi.rs @@ -31,7 +31,7 @@ use crate::server::auth::{ ApiKey, ApiKeyInfoResponse, CreateApiKeyRequest, CreateApiKeyResponse, RefreshApiKeyRequest, RefreshApiKeyResponse, RevokeApiKeyResponse, }; -use crate::server::handlers::{api_keys, contract_chat, contract_discuss, contracts, directives, files, listen, mesh, mesh_chat, mesh_merge, orders, repository_history, users}; +use crate::server::handlers::{api_keys, directives, files, listen, mesh, mesh_chat, mesh_merge, orders, repository_history, users}; use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage, TranscriptMessage}; #[derive(OpenApi)] @@ -92,27 +92,7 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage users::delete_account_handler, users::get_user_settings_handler, users::update_user_settings_handler, - // Contract endpoints - contracts::list_contracts, - contracts::get_contract, - contracts::create_contract, - contracts::update_contract, - contracts::delete_contract, - contracts::change_phase, - contracts::get_events, - contracts::add_remote_repository, - contracts::add_local_repository, - contracts::create_managed_repository, - contracts::delete_repository, - contracts::set_repository_primary, - contracts::add_task_to_contract, - contracts::remove_task_from_contract, - // Contract chat endpoints - contract_chat::contract_chat_handler, - contract_chat::get_contract_chat_history, - contract_chat::clear_contract_chat_history, - // Contract discuss endpoint - contract_discuss::discuss_contract_handler, + // Contract endpoints removed in Phase 5. // Directive endpoints directives::list_directives, directives::create_directive, @@ -182,15 +162,7 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage MeshChatConversation, MeshChatMessageRecord, MeshChatHistoryResponse, - // Contract chat schemas - ContractChatMessageRecord, - ContractChatHistoryResponse, - // Contract discuss schemas - contract_discuss::ChatMessage, - contract_discuss::DiscussContractRequest, - contract_discuss::DiscussContractResponse, - contract_discuss::ToolCallInfo, - contract_discuss::CreatedContractInfo, + // Contract chat / discuss schemas removed in Phase 5. // Merge schemas BranchInfo, BranchListResponse, |
