diff options
Diffstat (limited to 'makima/src')
| -rw-r--r-- | makima/src/bin/makima.rs | 105 | ||||
| -rw-r--r-- | makima/src/daemon/api/directive.rs | 124 | ||||
| -rw-r--r-- | makima/src/daemon/api/mod.rs | 1 | ||||
| -rw-r--r-- | makima/src/daemon/cli/directive.rs | 101 | ||||
| -rw-r--r-- | makima/src/daemon/cli/mod.rs | 49 | ||||
| -rw-r--r-- | makima/src/daemon/skills/directive.md | 111 | ||||
| -rw-r--r-- | makima/src/daemon/skills/mod.rs | 4 | ||||
| -rw-r--r-- | makima/src/db/models.rs | 151 | ||||
| -rw-r--r-- | makima/src/db/repository.rs | 415 | ||||
| -rw-r--r-- | makima/src/llm/contract_tools.rs | 488 | ||||
| -rw-r--r-- | makima/src/server/handlers/contract_chat.rs | 29 | ||||
| -rw-r--r-- | makima/src/server/handlers/contracts.rs | 2 | ||||
| -rw-r--r-- | makima/src/server/handlers/directives.rs | 841 | ||||
| -rw-r--r-- | makima/src/server/handlers/mesh.rs | 8 | ||||
| -rw-r--r-- | makima/src/server/handlers/mesh_chat.rs | 2 | ||||
| -rw-r--r-- | makima/src/server/handlers/mesh_daemon.rs | 17 | ||||
| -rw-r--r-- | makima/src/server/handlers/mesh_supervisor.rs | 2 | ||||
| -rw-r--r-- | makima/src/server/handlers/mod.rs | 1 | ||||
| -rw-r--r-- | makima/src/server/handlers/transcript_analysis.rs | 4 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 26 | ||||
| -rw-r--r-- | makima/src/server/openapi.rs | 39 |
21 files changed, 2004 insertions, 516 deletions
diff --git a/makima/src/bin/makima.rs b/makima/src/bin/makima.rs index ee5895c..639c88b 100644 --- a/makima/src/bin/makima.rs +++ b/makima/src/bin/makima.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use makima::daemon::api::{ApiClient, CreateContractRequest}; use makima::daemon::cli::{ Cli, CliConfig, Commands, ConfigCommand, ContractCommand, - SupervisorCommand, ViewArgs, + DirectiveCommand, SupervisorCommand, ViewArgs, }; use makima::daemon::tui::{self, Action, App, ListItem, ViewType, TuiWsClient, WsEvent, OutputLine, OutputMessageType, WsConnectionState, RepositorySuggestion}; use makima::daemon::config::{DaemonConfig, RepoEntry}; @@ -29,6 +29,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { 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, } @@ -711,6 +712,108 @@ async fn run_contract( Ok(()) } +/// Run directive commands. +async fn run_directive( + cmd: DirectiveCommand, +) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { + use makima::daemon::api::directive::*; + + match cmd { + DirectiveCommand::List(args) => { + let client = ApiClient::new(args.api_url, args.api_key)?; + let result = client.list_directives().await?; + println!("{}", serde_json::to_string(&result.0)?); + } + DirectiveCommand::Get(args) | DirectiveCommand::Status(args) => { + let client = ApiClient::new(args.api_url, args.api_key)?; + let result = client.get_directive(args.directive_id).await?; + println!("{}", serde_json::to_string(&result.0)?); + } + DirectiveCommand::AddStep(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + let depends_on: Vec<uuid::Uuid> = args + .depends_on + .map(|d| { + d.split(',') + .filter_map(|s| uuid::Uuid::parse_str(s.trim()).ok()) + .collect() + }) + .unwrap_or_default(); + let req = CreateStepRequest { + name: args.name, + description: args.description, + task_plan: args.task_plan, + depends_on, + order_index: args.order_index, + }; + let result = client.directive_add_step(args.common.directive_id, req).await?; + println!("{}", serde_json::to_string(&result.0)?); + } + DirectiveCommand::RemoveStep(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + client.directive_remove_step(args.common.directive_id, args.step_id).await?; + println!(r#"{{"success": true}}"#); + } + DirectiveCommand::SetDeps(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + let depends_on: Vec<uuid::Uuid> = args + .depends_on + .split(',') + .filter_map(|s| uuid::Uuid::parse_str(s.trim()).ok()) + .collect(); + let result = client + .directive_set_deps(args.common.directive_id, args.step_id, depends_on) + .await?; + println!("{}", serde_json::to_string(&result.0)?); + } + DirectiveCommand::Start(args) => { + let client = ApiClient::new(args.api_url, args.api_key)?; + let result = client.directive_start(args.directive_id).await?; + println!("{}", serde_json::to_string(&result.0)?); + } + DirectiveCommand::Pause(args) => { + let client = ApiClient::new(args.api_url, args.api_key)?; + let result = client.directive_pause(args.directive_id).await?; + println!("{}", serde_json::to_string(&result.0)?); + } + DirectiveCommand::Advance(args) => { + let client = ApiClient::new(args.api_url, args.api_key)?; + let result = client.directive_advance(args.directive_id).await?; + println!("{}", serde_json::to_string(&result.0)?); + } + DirectiveCommand::CompleteStep(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + let result = client + .directive_complete_step(args.common.directive_id, args.step_id) + .await?; + println!("{}", serde_json::to_string(&result.0)?); + } + DirectiveCommand::FailStep(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + let result = client + .directive_fail_step(args.common.directive_id, args.step_id) + .await?; + println!("{}", serde_json::to_string(&result.0)?); + } + DirectiveCommand::SkipStep(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + let result = client + .directive_skip_step(args.common.directive_id, args.step_id) + .await?; + println!("{}", serde_json::to_string(&result.0)?); + } + DirectiveCommand::UpdateGoal(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + let result = client + .directive_update_goal(args.common.directive_id, &args.goal) + .await?; + println!("{}", serde_json::to_string(&result.0)?); + } + } + + Ok(()) +} + /// Run the TUI view command. async fn run_view(args: ViewArgs) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { // Load CLI config for defaults diff --git a/makima/src/daemon/api/directive.rs b/makima/src/daemon/api/directive.rs new file mode 100644 index 0000000..fbd27fe --- /dev/null +++ b/makima/src/daemon/api/directive.rs @@ -0,0 +1,124 @@ +//! Directive API methods. + +use serde::Serialize; +use uuid::Uuid; + +use super::client::{ApiClient, ApiError}; +use super::supervisor::JsonValue; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateStepRequest { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub task_plan: Option<String>, + pub depends_on: Vec<Uuid>, + pub order_index: i32, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateGoalRequest { + pub goal: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateStepDepsRequest { + pub depends_on: Vec<Uuid>, +} + +impl ApiClient { + /// List all directives. + pub async fn list_directives(&self) -> Result<JsonValue, ApiError> { + self.get("/api/v1/directives").await + } + + /// Get a directive with its steps. + pub async fn get_directive(&self, directive_id: Uuid) -> Result<JsonValue, ApiError> { + self.get(&format!("/api/v1/directives/{}", directive_id)).await + } + + /// Add a step to a directive. + pub async fn directive_add_step( + &self, + directive_id: Uuid, + req: CreateStepRequest, + ) -> Result<JsonValue, ApiError> { + self.post(&format!("/api/v1/directives/{}/steps", directive_id), &req).await + } + + /// Remove a step from a directive. + pub async fn directive_remove_step( + &self, + directive_id: Uuid, + step_id: Uuid, + ) -> Result<(), ApiError> { + self.delete(&format!("/api/v1/directives/{}/steps/{}", directive_id, step_id)).await + } + + /// Set dependencies for a step. + pub async fn directive_set_deps( + &self, + directive_id: Uuid, + step_id: Uuid, + depends_on: Vec<Uuid>, + ) -> Result<JsonValue, ApiError> { + let req = UpdateStepDepsRequest { depends_on }; + self.put(&format!("/api/v1/directives/{}/steps/{}", directive_id, step_id), &req).await + } + + /// Start a directive. + pub async fn directive_start(&self, directive_id: Uuid) -> Result<JsonValue, ApiError> { + self.post_empty(&format!("/api/v1/directives/{}/start", directive_id)).await + } + + /// Pause a directive. + pub async fn directive_pause(&self, directive_id: Uuid) -> Result<JsonValue, ApiError> { + self.post_empty(&format!("/api/v1/directives/{}/pause", directive_id)).await + } + + /// Advance the directive DAG. + pub async fn directive_advance(&self, directive_id: Uuid) -> Result<JsonValue, ApiError> { + self.post_empty(&format!("/api/v1/directives/{}/advance", directive_id)).await + } + + /// Mark a step as completed. + pub async fn directive_complete_step( + &self, + directive_id: Uuid, + step_id: Uuid, + ) -> Result<JsonValue, ApiError> { + self.post_empty(&format!("/api/v1/directives/{}/steps/{}/complete", directive_id, step_id)).await + } + + /// Mark a step as failed. + pub async fn directive_fail_step( + &self, + directive_id: Uuid, + step_id: Uuid, + ) -> Result<JsonValue, ApiError> { + self.post_empty(&format!("/api/v1/directives/{}/steps/{}/fail", directive_id, step_id)).await + } + + /// Mark a step as skipped. + pub async fn directive_skip_step( + &self, + directive_id: Uuid, + step_id: Uuid, + ) -> Result<JsonValue, ApiError> { + self.post_empty(&format!("/api/v1/directives/{}/steps/{}/skip", directive_id, step_id)).await + } + + /// Update the directive's goal. + pub async fn directive_update_goal( + &self, + directive_id: Uuid, + goal: &str, + ) -> Result<JsonValue, ApiError> { + let req = UpdateGoalRequest { goal: goal.to_string() }; + self.put(&format!("/api/v1/directives/{}/goal", directive_id), &req).await + } +} diff --git a/makima/src/daemon/api/mod.rs b/makima/src/daemon/api/mod.rs index 49d80e0..2d1efbf 100644 --- a/makima/src/daemon/api/mod.rs +++ b/makima/src/daemon/api/mod.rs @@ -2,6 +2,7 @@ pub mod client; pub mod contract; +pub mod directive; pub mod supervisor; pub use client::ApiClient; diff --git a/makima/src/daemon/cli/directive.rs b/makima/src/daemon/cli/directive.rs new file mode 100644 index 0000000..5de60ed --- /dev/null +++ b/makima/src/daemon/cli/directive.rs @@ -0,0 +1,101 @@ +//! Directive subcommand - directive management commands for orchestrator tasks. + +use clap::Args; +use uuid::Uuid; + +/// Common arguments for directive commands. +#[derive(Args, Debug, Clone)] +pub struct DirectiveArgs { + /// 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, + + /// Directive ID + #[arg(long, env = "MAKIMA_DIRECTIVE_ID", global = true)] + pub directive_id: Uuid, +} + +/// Arguments for listing directives (no directive_id required). +#[derive(Args, Debug, Clone)] +pub struct DirectiveListArgs { + /// 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, +} + +/// Arguments for add-step command. +#[derive(Args, Debug)] +pub struct AddStepArgs { + #[command(flatten)] + pub common: DirectiveArgs, + + /// Step name + pub name: String, + + /// Step description + #[arg(long)] + pub description: Option<String>, + + /// Task plan for the step + #[arg(long)] + pub task_plan: Option<String>, + + /// Comma-separated UUIDs of dependency steps + #[arg(long)] + pub depends_on: Option<String>, + + /// Order index + #[arg(long, default_value = "0")] + pub order_index: i32, +} + +/// Arguments for remove-step command. +#[derive(Args, Debug)] +pub struct RemoveStepArgs { + #[command(flatten)] + pub common: DirectiveArgs, + + /// Step ID to remove + pub step_id: Uuid, +} + +/// Arguments for set-deps command. +#[derive(Args, Debug)] +pub struct SetDepsArgs { + #[command(flatten)] + pub common: DirectiveArgs, + + /// Step ID to update + pub step_id: Uuid, + + /// Comma-separated UUIDs of dependency steps + pub depends_on: String, +} + +/// Arguments for complete-step/fail-step/skip-step commands. +#[derive(Args, Debug)] +pub struct StepActionArgs { + #[command(flatten)] + pub common: DirectiveArgs, + + /// Step ID + pub step_id: Uuid, +} + +/// Arguments for update-goal command. +#[derive(Args, Debug)] +pub struct UpdateGoalArgs { + #[command(flatten)] + pub common: DirectiveArgs, + + /// New goal text + pub goal: String, +} diff --git a/makima/src/daemon/cli/mod.rs b/makima/src/daemon/cli/mod.rs index 0805edd..faafaea 100644 --- a/makima/src/daemon/cli/mod.rs +++ b/makima/src/daemon/cli/mod.rs @@ -3,6 +3,7 @@ pub mod config; pub mod contract; pub mod daemon; +pub mod directive; pub mod server; pub mod supervisor; pub mod view; @@ -12,6 +13,7 @@ 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; @@ -41,6 +43,10 @@ pub enum Commands { #[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 @@ -196,6 +202,49 @@ pub enum ContractCommand { CreateFile(contract::CreateFileArgs), } +/// Directive subcommands for DAG-based project management. +#[derive(Subcommand, Debug)] +pub enum DirectiveCommand { + /// List all directives + List(directive::DirectiveListArgs), + + /// Get directive status with steps + Get(DirectiveArgs), + + /// Get directive status (alias for get) + Status(DirectiveArgs), + + /// Add a step to the directive + AddStep(directive::AddStepArgs), + + /// Remove a step from the directive + RemoveStep(directive::RemoveStepArgs), + + /// Set dependencies for a step + SetDeps(directive::SetDepsArgs), + + /// Start the directive (begin executing steps) + Start(DirectiveArgs), + + /// Pause the directive + Pause(DirectiveArgs), + + /// Advance the DAG (find newly-ready steps) + Advance(DirectiveArgs), + + /// Mark a step as completed + CompleteStep(directive::StepActionArgs), + + /// Mark a step as failed + FailStep(directive::StepActionArgs), + + /// Mark a step as skipped + SkipStep(directive::StepActionArgs), + + /// Update the directive's goal (triggers re-planning) + UpdateGoal(directive::UpdateGoalArgs), +} + impl Cli { /// Parse command-line arguments pub fn parse_args() -> Self { diff --git a/makima/src/daemon/skills/directive.md b/makima/src/daemon/skills/directive.md new file mode 100644 index 0000000..7c55cf8 --- /dev/null +++ b/makima/src/daemon/skills/directive.md @@ -0,0 +1,111 @@ +--- +name: makima-directive +description: Directive commands for makima DAG-based project orchestration. Use these commands to manage long-lived directives with auto-progressing steps. +--- + +# Makima Directive Skill + +You are orchestrating a **directive** — a long-lived project managed through a DAG (directed acyclic graph) of steps. Unlike contracts which are finite and phase-based, directives are **ongoing and continuous**: they stay active as the project evolves, and new features/requirements can be added at any time. + +## Key Concepts + +- **Directive**: A long-lived top-level entity with a goal, repository info, and a mutable DAG of steps +- **Steps**: Nodes in the DAG. Each step can spawn a task using the mesh infrastructure +- **Auto-progression**: When a step completes, newly-ready steps (whose dependencies are met) automatically become ready +- **Continuous evolution**: The goal can be updated at any time. When all steps complete, the directive goes `idle` (not completed) — waiting for new work +- **Statuses**: `draft` → `active` ↔ `idle` → `archived`. Directives are never "completed" — they go idle and wait + +## Commands + +### Check Status +```bash +makima directive status +``` +Returns the directive with all steps, their statuses, and dependency information. + +### Add a Step +```bash +makima directive add-step "Step Name" --description "What this step does" --task-plan "Detailed instructions for the task" --depends-on "uuid1,uuid2" --order-index 1 +``` + +### Remove a Step +```bash +makima directive remove-step <step_id> +``` + +### Set Dependencies +```bash +makima directive set-deps <step_id> "dep_uuid1,dep_uuid2" +``` + +### Start the Directive +```bash +makima directive start +``` +Sets status to `active` and advances any steps with no dependencies to `ready`. + +### Advance the DAG +```bash +makima directive advance +``` +Finds newly-ready steps (all dependencies met) and marks them ready. If all steps are in terminal states, sets the directive to `idle`. + +### Complete a Step +```bash +makima directive complete-step <step_id> +``` + +### Fail a Step +```bash +makima directive fail-step <step_id> +``` + +### Skip a Step +```bash +makima directive skip-step <step_id> +``` + +### Update the Goal +```bash +makima directive update-goal "New or expanded goal text" +``` +Updates the goal and bumps `goalUpdatedAt`. If the directive is `idle`, it reactivates to `active`. + +### Pause +```bash +makima directive pause +``` + +## Orchestration Workflow + +### Initial Setup +1. Check the directive status to understand the goal +2. Decompose the goal into steps with clear dependencies +3. Add steps using `add-step` with appropriate `--depends-on` flags +4. Start the directive with `start` +5. Steps with no dependencies will become `ready` immediately + +### Monitoring and Advancing +1. Periodically check status to see step progress +2. When tasks complete, the DAG auto-advances — newly-ready steps appear +3. Use `advance` to manually trigger DAG progression if needed +4. Mark steps as complete/failed/skipped as appropriate + +### Re-planning (When Goal Updates) +When the goal is updated (you'll see a new `goalUpdatedAt` timestamp): +1. Check the current status to see completed and in-progress steps +2. Identify what's new in the updated goal +3. Add new steps that depend on existing completed steps as appropriate +4. The DAG will auto-advance any newly-ready steps + +### Idle State +When all steps complete, the directive enters `idle` state. This is normal — it means: +- All current work is done +- The directive is waiting for new requirements +- When the user updates the goal, it reactivates automatically +- You should add new steps based on the updated goal + +## Environment Variables +- `MAKIMA_API_URL` - API server URL +- `MAKIMA_API_KEY` - Authentication key +- `MAKIMA_DIRECTIVE_ID` - Current directive ID (set automatically) diff --git a/makima/src/daemon/skills/mod.rs b/makima/src/daemon/skills/mod.rs index 0b05f3a..0c015ba 100644 --- a/makima/src/daemon/skills/mod.rs +++ b/makima/src/daemon/skills/mod.rs @@ -9,8 +9,12 @@ pub const SUPERVISOR_SKILL: &str = include_str!("supervisor.md"); /// Contract skill content - task-contract interaction commands pub const CONTRACT_SKILL: &str = include_str!("contract.md"); +/// Directive skill content - DAG-based project orchestration commands +pub const DIRECTIVE_SKILL: &str = include_str!("directive.md"); + /// All skills as (name, content) pairs for installation pub const ALL_SKILLS: &[(&str, &str)] = &[ ("makima-supervisor", SUPERVISOR_SKILL), ("makima-contract", CONTRACT_SKILL), + ("makima-directive", DIRECTIVE_SKILL), ]; diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index d0a0bd6..9159fd5 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -531,6 +531,14 @@ pub struct Task { /// Standalone completed tasks can be dismissed by the user. #[serde(default)] pub hidden: bool, + + // Directive association + /// Directive this task belongs to (for directive-driven tasks) + #[serde(skip_serializing_if = "Option::is_none")] + pub directive_id: Option<Uuid>, + /// Directive step this task executes + #[serde(skip_serializing_if = "Option::is_none")] + pub directive_step_id: Option<Uuid>, } impl Task { @@ -656,6 +664,10 @@ pub struct CreateTaskRequest { /// Task ID whose worktree this task shares. When set, this task reuses the supervisor's /// worktree instead of creating its own, and should NOT have its worktree deleted during cleanup. pub supervisor_worktree_task_id: Option<Uuid>, + /// Directive this task belongs to (for directive-driven tasks) + pub directive_id: Option<Uuid>, + /// Directive step this task executes + pub directive_step_id: Option<Uuid>, } /// Request payload for updating a task @@ -2682,3 +2694,142 @@ mod tests { } // ============================================================================= +// Directive Types +// ============================================================================= + +/// A directive — a long-lived top-level entity for managing projects via a DAG of steps. +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Directive { + pub id: Uuid, + pub owner_id: Uuid, + pub title: String, + pub goal: String, + /// Status: draft, active, idle, paused, archived + pub status: String, + pub repository_url: Option<String>, + pub local_path: Option<String>, + pub base_branch: Option<String>, + pub orchestrator_task_id: Option<Uuid>, + pub goal_updated_at: DateTime<Utc>, + pub started_at: Option<DateTime<Utc>>, + pub version: i32, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, +} + +/// A step in a directive's DAG. +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DirectiveStep { + pub id: Uuid, + pub directive_id: Uuid, + pub name: String, + pub description: Option<String>, + pub task_plan: Option<String>, + pub depends_on: Vec<Uuid>, + /// Status: pending, ready, running, completed, failed, skipped + pub status: String, + pub task_id: Option<Uuid>, + pub order_index: i32, + pub generation: i32, + pub started_at: Option<DateTime<Utc>>, + pub completed_at: Option<DateTime<Utc>>, + pub created_at: DateTime<Utc>, +} + +/// Directive with its steps for detail view. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DirectiveWithSteps { + #[serde(flatten)] + pub directive: Directive, + pub steps: Vec<DirectiveStep>, +} + +/// Summary for directive list views. +#[derive(Debug, Clone, FromRow, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DirectiveSummary { + pub id: Uuid, + pub owner_id: Uuid, + pub title: String, + pub goal: String, + pub status: String, + pub repository_url: Option<String>, + pub orchestrator_task_id: Option<Uuid>, + pub version: i32, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, + pub total_steps: i64, + pub completed_steps: i64, + pub running_steps: i64, + pub failed_steps: i64, +} + +/// List response for directives. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DirectiveListResponse { + pub directives: Vec<DirectiveSummary>, + pub total: i64, +} + +/// Request to create a new directive. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateDirectiveRequest { + pub title: String, + pub goal: String, + pub repository_url: Option<String>, + pub local_path: Option<String>, + pub base_branch: Option<String>, +} + +/// Request to update a directive. +#[derive(Debug, Default, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateDirectiveRequest { + pub title: Option<String>, + pub goal: Option<String>, + pub status: Option<String>, + pub repository_url: Option<String>, + pub local_path: Option<String>, + pub base_branch: Option<String>, + pub orchestrator_task_id: Option<Uuid>, + pub version: Option<i32>, +} + +/// Request to update a directive's goal (triggers re-planning). +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateGoalRequest { + pub goal: String, +} + +/// Request to create a directive step. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateDirectiveStepRequest { + pub name: String, + pub description: Option<String>, + pub task_plan: Option<String>, + #[serde(default)] + pub depends_on: Vec<Uuid>, + #[serde(default)] + pub order_index: i32, + pub generation: Option<i32>, +} + +/// Request to update a directive step. +#[derive(Debug, Default, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateDirectiveStepRequest { + pub name: Option<String>, + pub description: Option<String>, + pub task_plan: Option<String>, + pub depends_on: Option<Vec<Uuid>>, + pub status: Option<String>, + pub task_id: Option<Uuid>, + pub order_index: Option<i32>, +} diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index 4ed2298..f347fc7 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -11,7 +11,9 @@ use super::models::{ ContractTypeTemplateRecord, ConversationMessage, ConversationSnapshot, CreateContractRequest, CreateFileRequest, CreateTaskRequest, CreateTemplateRequest, Daemon, DaemonTaskAssignment, DaemonWithCapacity, - DeliverableDefinition, + DeliverableDefinition, Directive, DirectiveStep, DirectiveSummary, + CreateDirectiveRequest, CreateDirectiveStepRequest, UpdateDirectiveRequest, + UpdateDirectiveStepRequest, File, FileSummary, FileVersion, HistoryEvent, HistoryQueryFilters, MeshChatConversation, MeshChatMessageRecord, PhaseChangeResult, PhaseConfig, PhaseDefinition, SupervisorHeartbeatRecord, SupervisorState, @@ -4912,3 +4914,414 @@ fn truncate_string(s: &str, max_len: usize) -> String { } } +// ============================================================================= +// Directive CRUD +// ============================================================================= + +/// Create a new directive for an owner. +pub async fn create_directive_for_owner( + pool: &PgPool, + owner_id: Uuid, + req: CreateDirectiveRequest, +) -> Result<Directive, sqlx::Error> { + sqlx::query_as::<_, Directive>( + r#" + INSERT INTO directives (owner_id, title, goal, repository_url, local_path, base_branch) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING * + "#, + ) + .bind(owner_id) + .bind(&req.title) + .bind(&req.goal) + .bind(&req.repository_url) + .bind(&req.local_path) + .bind(&req.base_branch) + .fetch_one(pool) + .await +} + +/// Get a single directive for an owner. +pub async fn get_directive_for_owner( + pool: &PgPool, + owner_id: Uuid, + id: Uuid, +) -> Result<Option<Directive>, sqlx::Error> { + sqlx::query_as::<_, Directive>( + r#"SELECT * FROM directives WHERE id = $1 AND owner_id = $2"#, + ) + .bind(id) + .bind(owner_id) + .fetch_optional(pool) + .await +} + +/// Get a directive with all its steps. +pub async fn get_directive_with_steps_for_owner( + pool: &PgPool, + owner_id: Uuid, + id: Uuid, +) -> Result<Option<(Directive, Vec<DirectiveStep>)>, sqlx::Error> { + let directive = sqlx::query_as::<_, Directive>( + r#"SELECT * FROM directives WHERE id = $1 AND owner_id = $2"#, + ) + .bind(id) + .bind(owner_id) + .fetch_optional(pool) + .await?; + + match directive { + Some(d) => { + let steps = list_directive_steps(pool, d.id).await?; + Ok(Some((d, steps))) + } + None => Ok(None), + } +} + +/// List all directives for an owner with step counts. +pub async fn list_directives_for_owner( + pool: &PgPool, + owner_id: Uuid, +) -> Result<Vec<DirectiveSummary>, sqlx::Error> { + sqlx::query_as::<_, DirectiveSummary>( + r#" + SELECT + d.id, d.owner_id, d.title, d.goal, d.status, d.repository_url, + d.orchestrator_task_id, d.version, d.created_at, d.updated_at, + COALESCE((SELECT COUNT(*) FROM directive_steps WHERE directive_id = d.id), 0) as total_steps, + COALESCE((SELECT COUNT(*) FROM directive_steps WHERE directive_id = d.id AND status = 'completed'), 0) as completed_steps, + COALESCE((SELECT COUNT(*) FROM directive_steps WHERE directive_id = d.id AND status = 'running'), 0) as running_steps, + COALESCE((SELECT COUNT(*) FROM directive_steps WHERE directive_id = d.id AND status = 'failed'), 0) as failed_steps + FROM directives d + WHERE d.owner_id = $1 + ORDER BY d.created_at DESC + "#, + ) + .bind(owner_id) + .fetch_all(pool) + .await +} + +/// Update a directive with optimistic locking. +pub async fn update_directive_for_owner( + pool: &PgPool, + owner_id: Uuid, + id: Uuid, + req: UpdateDirectiveRequest, +) -> Result<Option<Directive>, RepositoryError> { + let current = sqlx::query_as::<_, Directive>( + r#"SELECT * FROM directives WHERE id = $1 AND owner_id = $2"#, + ) + .bind(id) + .bind(owner_id) + .fetch_optional(pool) + .await + .map_err(RepositoryError::Database)?; + + let current = match current { + Some(c) => c, + None => return Ok(None), + }; + + if let Some(expected_version) = req.version { + if expected_version != current.version { + return Err(RepositoryError::VersionConflict { + expected: expected_version, + actual: current.version, + }); + } + } + + let title = req.title.as_deref().unwrap_or(¤t.title); + let goal = req.goal.as_deref().unwrap_or(¤t.goal); + let status = req.status.as_deref().unwrap_or(¤t.status); + let repository_url = req.repository_url.as_deref().or(current.repository_url.as_deref()); + let local_path = req.local_path.as_deref().or(current.local_path.as_deref()); + let base_branch = req.base_branch.as_deref().or(current.base_branch.as_deref()); + let orchestrator_task_id = req.orchestrator_task_id.or(current.orchestrator_task_id); + + let result = sqlx::query_as::<_, Directive>( + r#" + UPDATE directives + SET title = $3, goal = $4, status = $5, repository_url = $6, local_path = $7, + base_branch = $8, orchestrator_task_id = $9, version = version + 1, updated_at = NOW() + WHERE id = $1 AND owner_id = $2 + RETURNING * + "#, + ) + .bind(id) + .bind(owner_id) + .bind(title) + .bind(goal) + .bind(status) + .bind(repository_url) + .bind(local_path) + .bind(base_branch) + .bind(orchestrator_task_id) + .fetch_optional(pool) + .await + .map_err(RepositoryError::Database)?; + + Ok(result) +} + +/// Delete a directive for an owner. +pub async fn delete_directive_for_owner( + pool: &PgPool, + owner_id: Uuid, + id: Uuid, +) -> Result<bool, sqlx::Error> { + let result = sqlx::query( + r#"DELETE FROM directives WHERE id = $1 AND owner_id = $2"#, + ) + .bind(id) + .bind(owner_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +// ============================================================================= +// Directive Step CRUD +// ============================================================================= + +/// List all steps for a directive, ordered by order_index. +pub async fn list_directive_steps( + pool: &PgPool, + directive_id: Uuid, +) -> Result<Vec<DirectiveStep>, sqlx::Error> { + sqlx::query_as::<_, DirectiveStep>( + r#" + SELECT * FROM directive_steps + WHERE directive_id = $1 + ORDER BY order_index, created_at + "#, + ) + .bind(directive_id) + .fetch_all(pool) + .await +} + +/// Create a single directive step. +pub async fn create_directive_step( + pool: &PgPool, + directive_id: Uuid, + req: CreateDirectiveStepRequest, +) -> Result<DirectiveStep, sqlx::Error> { + let generation = req.generation.unwrap_or(1); + sqlx::query_as::<_, DirectiveStep>( + r#" + INSERT INTO directive_steps (directive_id, name, description, task_plan, depends_on, order_index, generation) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING * + "#, + ) + .bind(directive_id) + .bind(&req.name) + .bind(&req.description) + .bind(&req.task_plan) + .bind(&req.depends_on) + .bind(req.order_index) + .bind(generation) + .fetch_one(pool) + .await +} + +/// Batch create multiple directive steps. +pub async fn batch_create_directive_steps( + pool: &PgPool, + directive_id: Uuid, + steps: Vec<CreateDirectiveStepRequest>, +) -> Result<Vec<DirectiveStep>, sqlx::Error> { + let mut results = Vec::with_capacity(steps.len()); + for req in steps { + let step = create_directive_step(pool, directive_id, req).await?; + results.push(step); + } + Ok(results) +} + +/// Update a directive step. +pub async fn update_directive_step( + pool: &PgPool, + step_id: Uuid, + req: UpdateDirectiveStepRequest, +) -> Result<Option<DirectiveStep>, sqlx::Error> { + let current = sqlx::query_as::<_, DirectiveStep>( + r#"SELECT * FROM directive_steps WHERE id = $1"#, + ) + .bind(step_id) + .fetch_optional(pool) + .await?; + + let current = match current { + Some(c) => c, + None => return Ok(None), + }; + + let name = req.name.as_deref().unwrap_or(¤t.name); + let description = req.description.as_deref().or(current.description.as_deref()); + let task_plan = req.task_plan.as_deref().or(current.task_plan.as_deref()); + let depends_on = req.depends_on.as_deref().unwrap_or(¤t.depends_on); + let status = req.status.as_deref().unwrap_or(¤t.status); + let task_id = req.task_id.or(current.task_id); + let order_index = req.order_index.unwrap_or(current.order_index); + + // Set started_at when transitioning to running + let started_at = if status == "running" && current.status != "running" { + Some(Utc::now()) + } else { + current.started_at + }; + + // Set completed_at when transitioning to terminal state + let completed_at = if matches!(status, "completed" | "failed" | "skipped") + && !matches!(current.status.as_str(), "completed" | "failed" | "skipped") + { + Some(Utc::now()) + } else { + current.completed_at + }; + + sqlx::query_as::<_, DirectiveStep>( + r#" + UPDATE directive_steps + SET name = $2, description = $3, task_plan = $4, depends_on = $5, + status = $6, task_id = $7, order_index = $8, started_at = $9, completed_at = $10 + WHERE id = $1 + RETURNING * + "#, + ) + .bind(step_id) + .bind(name) + .bind(description) + .bind(task_plan) + .bind(depends_on) + .bind(status) + .bind(task_id) + .bind(order_index) + .bind(started_at) + .bind(completed_at) + .fetch_optional(pool) + .await +} + +/// Delete a directive step. +pub async fn delete_directive_step( + pool: &PgPool, + step_id: Uuid, +) -> Result<bool, sqlx::Error> { + let result = sqlx::query(r#"DELETE FROM directive_steps WHERE id = $1"#) + .bind(step_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +// ============================================================================= +// Directive DAG Progression +// ============================================================================= + +/// Advance pending steps to ready if all their dependencies are in terminal states. +/// Returns the newly-ready steps. +pub async fn advance_directive_ready_steps( + pool: &PgPool, + directive_id: Uuid, +) -> Result<Vec<DirectiveStep>, sqlx::Error> { + sqlx::query_as::<_, DirectiveStep>( + r#" + UPDATE directive_steps SET status = 'ready' + WHERE directive_id = $1 AND status = 'pending' + AND NOT EXISTS ( + SELECT 1 FROM unnest(depends_on) AS dep_id + JOIN directive_steps ds ON ds.id = dep_id + WHERE ds.status NOT IN ('completed', 'skipped') + ) + RETURNING * + "#, + ) + .bind(directive_id) + .fetch_all(pool) + .await +} + +/// Check if all steps in a directive are in terminal states. +/// If so, set the directive to 'idle' (not completed — directives are ongoing). +/// Returns true if the directive was set to idle. +pub async fn check_directive_idle( + pool: &PgPool, + directive_id: Uuid, +) -> Result<bool, sqlx::Error> { + let result = sqlx::query( + r#" + UPDATE directives SET status = 'idle', updated_at = NOW() + WHERE id = $1 AND status = 'active' + AND NOT EXISTS ( + SELECT 1 FROM directive_steps + WHERE directive_id = $1 + AND status NOT IN ('completed', 'failed', 'skipped') + ) + AND EXISTS ( + SELECT 1 FROM directive_steps WHERE directive_id = $1 + ) + "#, + ) + .bind(directive_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// Update a directive's goal and bump goal_updated_at. Reactivates if idle. +pub async fn update_directive_goal( + pool: &PgPool, + owner_id: Uuid, + directive_id: Uuid, + goal: &str, +) -> Result<Option<Directive>, sqlx::Error> { + sqlx::query_as::<_, Directive>( + r#" + UPDATE directives + SET goal = $3, + goal_updated_at = NOW(), + status = CASE WHEN status = 'idle' THEN 'active' ELSE status END, + updated_at = NOW(), + version = version + 1 + WHERE id = $1 AND owner_id = $2 + RETURNING * + "#, + ) + .bind(directive_id) + .bind(owner_id) + .bind(goal) + .fetch_optional(pool) + .await +} + +/// Set a directive's status (used for start/pause/archive transitions). +pub async fn set_directive_status( + pool: &PgPool, + owner_id: Uuid, + directive_id: Uuid, + status: &str, +) -> Result<Option<Directive>, sqlx::Error> { + let mut query = String::from( + r#"UPDATE directives SET status = $3, updated_at = NOW(), version = version + 1"#, + ); + if status == "active" { + query.push_str(", started_at = COALESCE(started_at, NOW())"); + } + query.push_str(" WHERE id = $1 AND owner_id = $2 RETURNING *"); + + sqlx::query_as::<_, Directive>(&query) + .bind(directive_id) + .bind(owner_id) + .bind(status) + .fetch_optional(pool) + .await +} + diff --git a/makima/src/llm/contract_tools.rs b/makima/src/llm/contract_tools.rs index 7f7e849..38d1a7e 100644 --- a/makima/src/llm/contract_tools.rs +++ b/makima/src/llm/contract_tools.rs @@ -460,214 +460,6 @@ pub static CONTRACT_TOOLS: once_cell::sync::Lazy<Vec<Tool>> = once_cell::sync::L "required": ["file_id"] }), }, - // ============================================================================= - // Chain Directive Tools (for directive contracts orchestrating chains) - // ============================================================================= - Tool { - name: "create_chain_from_directive".to_string(), - description: "Create a new chain that this directive contract will orchestrate. The chain starts in 'pending' status and contract definitions can be added. Only available to directive contracts.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name for the chain" - }, - "description": { - "type": "string", - "description": "Description of what the chain accomplishes" - } - }, - "required": ["name"] - }), - }, - Tool { - name: "add_chain_contract".to_string(), - description: "Add a contract definition to the chain being orchestrated. The contract will be created when its dependencies are met.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Contract name" - }, - "description": { - "type": "string", - "description": "What this contract accomplishes" - }, - "contract_type": { - "type": "string", - "enum": ["simple", "execute", "checkpoint"], - "description": "Contract type (default: simple)" - }, - "depends_on": { - "type": "array", - "items": { "type": "string" }, - "description": "Names of contracts this depends on" - }, - "requirement_ids": { - "type": "array", - "items": { "type": "string" }, - "description": "Requirement IDs this contract addresses (for traceability)" - } - }, - "required": ["name"] - }), - }, - Tool { - name: "set_chain_dependencies".to_string(), - description: "Set which contracts depend on which other contracts in the chain.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "contract_name": { - "type": "string", - "description": "Name of contract that has dependencies" - }, - "depends_on": { - "type": "array", - "items": { "type": "string" }, - "description": "Names of contracts it depends on" - } - }, - "required": ["contract_name", "depends_on"] - }), - }, - Tool { - name: "modify_chain_contract".to_string(), - description: "Update a contract definition in the chain.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name of the contract to modify" - }, - "new_name": { - "type": "string", - "description": "New name for the contract" - }, - "description": { - "type": "string", - "description": "New description" - }, - "add_requirement_ids": { - "type": "array", - "items": { "type": "string" }, - "description": "Requirement IDs to add" - }, - "remove_requirement_ids": { - "type": "array", - "items": { "type": "string" }, - "description": "Requirement IDs to remove" - } - }, - "required": ["name"] - }), - }, - Tool { - name: "remove_chain_contract".to_string(), - description: "Remove a contract definition from the chain (only if not yet instantiated).".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name of the contract to remove" - } - }, - "required": ["name"] - }), - }, - Tool { - name: "preview_chain_dag".to_string(), - description: "Generate a visual preview of the chain DAG structure for review.".to_string(), - parameters: json!({ - "type": "object", - "properties": {} - }), - }, - Tool { - name: "validate_chain_directive".to_string(), - description: "Validate the chain specification is complete and valid (no cycles, all dependencies exist, all requirements covered).".to_string(), - parameters: json!({ - "type": "object", - "properties": {} - }), - }, - Tool { - name: "finalize_chain_directive".to_string(), - description: "Lock the directive and start chain execution. Call this after validation passes and user has approved (if phase_guard enabled).".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "auto_start": { - "type": "boolean", - "description": "Whether to immediately start the chain (default: true)" - } - } - }), - }, - Tool { - name: "get_chain_status".to_string(), - description: "Get current status of the chain being orchestrated, including contract statuses and progress.".to_string(), - parameters: json!({ - "type": "object", - "properties": {} - }), - }, - Tool { - name: "get_uncovered_requirements".to_string(), - description: "List requirements from the directive that are not yet mapped to any contract.".to_string(), - parameters: json!({ - "type": "object", - "properties": {} - }), - }, - Tool { - name: "evaluate_contract_completion".to_string(), - description: "Evaluate whether a completed chain contract meets the directive requirements. Use this after a contract completes to assess if it satisfies acceptance criteria.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "contract_id": { - "type": "string", - "description": "ID of the completed contract to evaluate" - }, - "passed": { - "type": "boolean", - "description": "Whether the evaluation passed" - }, - "feedback": { - "type": "string", - "description": "Evaluation feedback and rationale" - }, - "rework_instructions": { - "type": "string", - "description": "Instructions for rework if evaluation failed" - } - }, - "required": ["contract_id", "passed", "feedback"] - }), - }, - Tool { - name: "request_rework".to_string(), - description: "Request rework on a completed contract that didn't meet requirements. This will block chain progression and notify the contract to address issues.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "contract_id": { - "type": "string", - "description": "ID of the contract needing rework" - }, - "feedback": { - "type": "string", - "description": "Detailed feedback on what needs to be fixed" - } - }, - "required": ["contract_id", "feedback"] - }), - }, ] }); @@ -755,49 +547,6 @@ pub enum ContractToolRequest { include_action_items: bool, }, - // Chain directive tools (for directive contracts) - CreateChainFromDirective { - name: String, - description: Option<String>, - }, - AddChainContract { - name: String, - description: Option<String>, - contract_type: Option<String>, - depends_on: Option<Vec<String>>, - requirement_ids: Option<Vec<String>>, - }, - SetChainDependencies { - contract_name: String, - depends_on: Vec<String>, - }, - ModifyChainContract { - name: String, - new_name: Option<String>, - description: Option<String>, - add_requirement_ids: Option<Vec<String>>, - remove_requirement_ids: Option<Vec<String>>, - }, - RemoveChainContract { - name: String, - }, - PreviewChainDag, - ValidateChainDirective, - FinalizeChainDirective { - auto_start: bool, - }, - GetChainStatus, - GetUncoveredRequirements, - EvaluateContractCompletion { - contract_id: Uuid, - passed: bool, - feedback: String, - rework_instructions: Option<String>, - }, - RequestRework { - contract_id: Uuid, - feedback: String, - }, } /// Task definition for chained task creation @@ -869,20 +618,6 @@ pub fn parse_contract_tool_call(call: &super::tools::ToolCall) -> ContractToolEx "analyze_transcript" => parse_analyze_transcript(call), "create_contract_from_transcript" => parse_create_contract_from_transcript(call), - // Chain directive tools - "create_chain_from_directive" => parse_create_chain_from_directive(call), - "add_chain_contract" => parse_add_chain_contract(call), - "set_chain_dependencies" => parse_set_chain_dependencies(call), - "modify_chain_contract" => parse_modify_chain_contract(call), - "remove_chain_contract" => parse_remove_chain_contract(call), - "preview_chain_dag" => parse_preview_chain_dag(), - "validate_chain_directive" => parse_validate_chain_directive(), - "finalize_chain_directive" => parse_finalize_chain_directive(call), - "get_chain_status" => parse_get_chain_status(), - "get_uncovered_requirements" => parse_get_uncovered_requirements(), - "evaluate_contract_completion" => parse_evaluate_contract_completion(call), - "request_rework" => parse_request_rework(call), - _ => ContractToolExecutionResult { success: false, message: format!("Unknown contract tool: {}", call.name), @@ -1472,229 +1207,6 @@ fn parse_create_contract_from_transcript(call: &super::tools::ToolCall) -> Contr } // ============================================================================= -// Chain Directive Tool Parsing -// ============================================================================= - -fn parse_create_chain_from_directive(call: &super::tools::ToolCall) -> ContractToolExecutionResult { - let name = call.arguments.get("name").and_then(|v| v.as_str()).map(|s| s.to_string()); - let Some(name) = name else { - return error_result("Missing required parameter: name"); - }; - let description = call.arguments.get("description").and_then(|v| v.as_str()).map(|s| s.to_string()); - - ContractToolExecutionResult { - success: true, - message: "Creating chain from directive...".to_string(), - data: None, - request: Some(ContractToolRequest::CreateChainFromDirective { name, description }), - pending_questions: None, - } -} - -fn parse_add_chain_contract(call: &super::tools::ToolCall) -> ContractToolExecutionResult { - let name = call.arguments.get("name").and_then(|v| v.as_str()).map(|s| s.to_string()); - let Some(name) = name else { - return error_result("Missing required parameter: name"); - }; - - let description = call.arguments.get("description").and_then(|v| v.as_str()).map(|s| s.to_string()); - let contract_type = call.arguments.get("contract_type").and_then(|v| v.as_str()).map(|s| s.to_string()); - let depends_on = call.arguments.get("depends_on").and_then(|v| { - v.as_array().map(|arr| { - arr.iter().filter_map(|item| item.as_str().map(|s| s.to_string())).collect() - }) - }); - let requirement_ids = call.arguments.get("requirement_ids").and_then(|v| { - v.as_array().map(|arr| { - arr.iter().filter_map(|item| item.as_str().map(|s| s.to_string())).collect() - }) - }); - - ContractToolExecutionResult { - success: true, - message: format!("Adding contract '{}' to chain...", name), - data: None, - request: Some(ContractToolRequest::AddChainContract { - name, - description, - contract_type, - depends_on, - requirement_ids, - }), - pending_questions: None, - } -} - -fn parse_set_chain_dependencies(call: &super::tools::ToolCall) -> ContractToolExecutionResult { - let contract_name = call.arguments.get("contract_name").and_then(|v| v.as_str()).map(|s| s.to_string()); - let Some(contract_name) = contract_name else { - return error_result("Missing required parameter: contract_name"); - }; - - let depends_on = call.arguments.get("depends_on").and_then(|v| { - v.as_array().map(|arr| { - arr.iter().filter_map(|item| item.as_str().map(|s| s.to_string())).collect() - }) - }).unwrap_or_default(); - - ContractToolExecutionResult { - success: true, - message: format!("Setting dependencies for '{}'...", contract_name), - data: None, - request: Some(ContractToolRequest::SetChainDependencies { contract_name, depends_on }), - pending_questions: None, - } -} - -fn parse_modify_chain_contract(call: &super::tools::ToolCall) -> ContractToolExecutionResult { - let name = call.arguments.get("name").and_then(|v| v.as_str()).map(|s| s.to_string()); - let Some(name) = name else { - return error_result("Missing required parameter: name"); - }; - - let new_name = call.arguments.get("new_name").and_then(|v| v.as_str()).map(|s| s.to_string()); - let description = call.arguments.get("description").and_then(|v| v.as_str()).map(|s| s.to_string()); - let add_requirement_ids = call.arguments.get("add_requirement_ids").and_then(|v| { - v.as_array().map(|arr| { - arr.iter().filter_map(|item| item.as_str().map(|s| s.to_string())).collect() - }) - }); - let remove_requirement_ids = call.arguments.get("remove_requirement_ids").and_then(|v| { - v.as_array().map(|arr| { - arr.iter().filter_map(|item| item.as_str().map(|s| s.to_string())).collect() - }) - }); - - ContractToolExecutionResult { - success: true, - message: format!("Modifying contract '{}'...", name), - data: None, - request: Some(ContractToolRequest::ModifyChainContract { - name, - new_name, - description, - add_requirement_ids, - remove_requirement_ids, - }), - pending_questions: None, - } -} - -fn parse_remove_chain_contract(call: &super::tools::ToolCall) -> ContractToolExecutionResult { - let name = call.arguments.get("name").and_then(|v| v.as_str()).map(|s| s.to_string()); - let Some(name) = name else { - return error_result("Missing required parameter: name"); - }; - - ContractToolExecutionResult { - success: true, - message: format!("Removing contract '{}'...", name), - data: None, - request: Some(ContractToolRequest::RemoveChainContract { name }), - pending_questions: None, - } -} - -fn parse_preview_chain_dag() -> ContractToolExecutionResult { - ContractToolExecutionResult { - success: true, - message: "Generating chain DAG preview...".to_string(), - data: None, - request: Some(ContractToolRequest::PreviewChainDag), - pending_questions: None, - } -} - -fn parse_validate_chain_directive() -> ContractToolExecutionResult { - ContractToolExecutionResult { - success: true, - message: "Validating chain directive...".to_string(), - data: None, - request: Some(ContractToolRequest::ValidateChainDirective), - pending_questions: None, - } -} - -fn parse_finalize_chain_directive(call: &super::tools::ToolCall) -> ContractToolExecutionResult { - let auto_start = call.arguments.get("auto_start").and_then(|v| v.as_bool()).unwrap_or(true); - - ContractToolExecutionResult { - success: true, - message: "Finalizing chain directive...".to_string(), - data: None, - request: Some(ContractToolRequest::FinalizeChainDirective { auto_start }), - pending_questions: None, - } -} - -fn parse_get_chain_status() -> ContractToolExecutionResult { - ContractToolExecutionResult { - success: true, - message: "Getting chain status...".to_string(), - data: None, - request: Some(ContractToolRequest::GetChainStatus), - pending_questions: None, - } -} - -fn parse_get_uncovered_requirements() -> ContractToolExecutionResult { - ContractToolExecutionResult { - success: true, - message: "Getting uncovered requirements...".to_string(), - data: None, - request: Some(ContractToolRequest::GetUncoveredRequirements), - pending_questions: None, - } -} - -fn parse_evaluate_contract_completion(call: &super::tools::ToolCall) -> ContractToolExecutionResult { - let contract_id = parse_uuid_arg(call, "contract_id"); - let Some(contract_id) = contract_id else { - return error_result("Missing or invalid required parameter: contract_id"); - }; - - let passed = call.arguments.get("passed").and_then(|v| v.as_bool()).unwrap_or(false); - let feedback = call.arguments.get("feedback").and_then(|v| v.as_str()).map(|s| s.to_string()); - let Some(feedback) = feedback else { - return error_result("Missing required parameter: feedback"); - }; - let rework_instructions = call.arguments.get("rework_instructions").and_then(|v| v.as_str()).map(|s| s.to_string()); - - ContractToolExecutionResult { - success: true, - message: format!("Evaluating contract completion (passed: {})...", passed), - data: None, - request: Some(ContractToolRequest::EvaluateContractCompletion { - contract_id, - passed, - feedback, - rework_instructions, - }), - pending_questions: None, - } -} - -fn parse_request_rework(call: &super::tools::ToolCall) -> ContractToolExecutionResult { - let contract_id = parse_uuid_arg(call, "contract_id"); - let Some(contract_id) = contract_id else { - return error_result("Missing or invalid required parameter: contract_id"); - }; - - let feedback = call.arguments.get("feedback").and_then(|v| v.as_str()).map(|s| s.to_string()); - let Some(feedback) = feedback else { - return error_result("Missing required parameter: feedback"); - }; - - ContractToolExecutionResult { - success: true, - message: "Requesting rework...".to_string(), - data: None, - request: Some(ContractToolRequest::RequestRework { contract_id, feedback }), - pending_questions: None, - } -} - -// ============================================================================= // Helper Functions // ============================================================================= diff --git a/makima/src/server/handlers/contract_chat.rs b/makima/src/server/handlers/contract_chat.rs index 8153093..2c7a800 100644 --- a/makima/src/server/handlers/contract_chat.rs +++ b/makima/src/server/handlers/contract_chat.rs @@ -1368,6 +1368,8 @@ async fn handle_contract_request( 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 { @@ -1465,6 +1467,8 @@ async fn handle_contract_request( 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 { @@ -2217,6 +2221,8 @@ async fn handle_contract_request( 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 { @@ -2737,6 +2743,8 @@ async fn handle_contract_request( 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() { @@ -2766,27 +2774,6 @@ async fn handle_contract_request( } - // Chain directive tools - TEMPORARILY DISABLED - // These tools will be reimplemented using the new directive system. - // See the orchestration module for the new implementation. - ContractToolRequest::CreateChainFromDirective { .. } | - ContractToolRequest::AddChainContract { .. } | - ContractToolRequest::SetChainDependencies { .. } | - ContractToolRequest::ModifyChainContract { .. } | - ContractToolRequest::RemoveChainContract { .. } | - ContractToolRequest::PreviewChainDag | - ContractToolRequest::ValidateChainDirective | - ContractToolRequest::FinalizeChainDirective { .. } | - ContractToolRequest::GetChainStatus | - ContractToolRequest::GetUncoveredRequirements | - ContractToolRequest::EvaluateContractCompletion { .. } | - ContractToolRequest::RequestRework { .. } => { - ContractRequestResult { - success: false, - message: "Chain directive tools are temporarily disabled. The directive system is being reimplemented.".to_string(), - data: None, - } - } } } diff --git a/makima/src/server/handlers/contracts.rs b/makima/src/server/handlers/contracts.rs index dc15923..bdd4d40 100644 --- a/makima/src/server/handlers/contracts.rs +++ b/makima/src/server/handlers/contracts.rs @@ -369,6 +369,8 @@ pub async fn create_contract( 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 { diff --git a/makima/src/server/handlers/directives.rs b/makima/src/server/handlers/directives.rs new file mode 100644 index 0000000..d48ff74 --- /dev/null +++ b/makima/src/server/handlers/directives.rs @@ -0,0 +1,841 @@ +//! HTTP handlers for directive CRUD and DAG progression. + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use uuid::Uuid; + +use crate::db::models::{ + CreateDirectiveRequest, CreateDirectiveStepRequest, Directive, DirectiveListResponse, + DirectiveStep, DirectiveWithSteps, UpdateDirectiveRequest, UpdateDirectiveStepRequest, + UpdateGoalRequest, +}; +use crate::db::repository; +use crate::server::auth::Authenticated; +use crate::server::messages::ApiError; +use crate::server::state::SharedState; + +// ============================================================================= +// Directive CRUD +// ============================================================================= + +/// List all directives for the authenticated user. +#[utoipa::path( + get, + path = "/api/v1/directives", + responses( + (status = 200, description = "List of directives", body = DirectiveListResponse), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn list_directives( + 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_directives_for_owner(pool, auth.owner_id).await { + Ok(directives) => { + let total = directives.len() as i64; + Json(DirectiveListResponse { directives, total }).into_response() + } + Err(e) => { + tracing::error!("Failed to list directives: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("LIST_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Create a new directive. +#[utoipa::path( + post, + path = "/api/v1/directives", + request_body = CreateDirectiveRequest, + responses( + (status = 201, description = "Directive created", body = Directive), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn create_directive( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Json(req): Json<CreateDirectiveRequest>, +) -> 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_directive_for_owner(pool, auth.owner_id, req).await { + Ok(directive) => (StatusCode::CREATED, Json(directive)).into_response(), + Err(e) => { + tracing::error!("Failed to create directive: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("CREATE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Get a directive with all its steps. +#[utoipa::path( + get, + path = "/api/v1/directives/{id}", + params(("id" = Uuid, Path, description = "Directive ID")), + responses( + (status = 200, description = "Directive with steps", body = DirectiveWithSteps), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn get_directive( + 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_directive_with_steps_for_owner(pool, auth.owner_id, id).await { + Ok(Some((directive, steps))) => { + Json(DirectiveWithSteps { directive, steps }).into_response() + } + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to get directive: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Update a directive. +#[utoipa::path( + put, + path = "/api/v1/directives/{id}", + params(("id" = Uuid, Path, description = "Directive ID")), + request_body = UpdateDirectiveRequest, + responses( + (status = 200, description = "Directive updated", body = Directive), + (status = 404, description = "Not found", body = ApiError), + (status = 409, description = "Version conflict", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn update_directive( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<UpdateDirectiveRequest>, +) -> 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_directive_for_owner(pool, auth.owner_id, id, req).await { + Ok(Some(directive)) => Json(directive).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(), + Err(repository::RepositoryError::VersionConflict { expected, actual }) => ( + StatusCode::CONFLICT, + Json(ApiError::new( + "VERSION_CONFLICT", + &format!("Expected version {}, but current is {}", expected, actual), + )), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to update directive: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("UPDATE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Delete a directive. +#[utoipa::path( + delete, + path = "/api/v1/directives/{id}", + params(("id" = Uuid, Path, description = "Directive ID")), + responses( + (status = 204, description = "Deleted"), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn delete_directive( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::delete_directive_for_owner(pool, auth.owner_id, id).await { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to delete directive: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DELETE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= +// Step CRUD +// ============================================================================= + +/// Create a step in a directive. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/steps", + params(("id" = Uuid, Path, description = "Directive ID")), + request_body = CreateDirectiveStepRequest, + responses( + (status = 201, description = "Step created", body = DirectiveStep), + (status = 404, description = "Directive not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn create_step( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<CreateDirectiveStepRequest>, +) -> 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 directive ownership + match repository::get_directive_for_owner(pool, auth.owner_id, id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + match repository::create_directive_step(pool, id, req).await { + Ok(step) => (StatusCode::CREATED, Json(step)).into_response(), + Err(e) => { + tracing::error!("Failed to create step: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("CREATE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Batch create steps in a directive. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/steps/batch", + params(("id" = Uuid, Path, description = "Directive ID")), + request_body = Vec<CreateDirectiveStepRequest>, + responses( + (status = 201, description = "Steps created", body = Vec<DirectiveStep>), + (status = 404, description = "Directive not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn batch_create_steps( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(steps): Json<Vec<CreateDirectiveStepRequest>>, +) -> 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 directive ownership + match repository::get_directive_for_owner(pool, auth.owner_id, id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + match repository::batch_create_directive_steps(pool, id, steps).await { + Ok(created) => (StatusCode::CREATED, Json(created)).into_response(), + Err(e) => { + tracing::error!("Failed to batch create steps: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("CREATE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Update a step. +#[utoipa::path( + put, + path = "/api/v1/directives/{id}/steps/{step_id}", + params( + ("id" = Uuid, Path, description = "Directive ID"), + ("step_id" = Uuid, Path, description = "Step ID"), + ), + request_body = UpdateDirectiveStepRequest, + responses( + (status = 200, description = "Step updated", body = DirectiveStep), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn update_step( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, step_id)): Path<(Uuid, Uuid)>, + Json(req): Json<UpdateDirectiveStepRequest>, +) -> 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 directive ownership + match repository::get_directive_for_owner(pool, auth.owner_id, id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + match repository::update_directive_step(pool, step_id, req).await { + Ok(Some(step)) => Json(step).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Step not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to update step: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("UPDATE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Delete a step. +#[utoipa::path( + delete, + path = "/api/v1/directives/{id}/steps/{step_id}", + params( + ("id" = Uuid, Path, description = "Directive ID"), + ("step_id" = Uuid, Path, description = "Step ID"), + ), + responses( + (status = 204, description = "Deleted"), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn delete_step( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, step_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 directive ownership + match repository::get_directive_for_owner(pool, auth.owner_id, id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + match repository::delete_directive_step(pool, step_id).await { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Step not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to delete step: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DELETE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= +// Directive Lifecycle Actions +// ============================================================================= + +/// Start a directive: sets status=active, advances ready steps. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/start", + params(("id" = Uuid, Path, description = "Directive ID")), + responses( + (status = 200, description = "Directive started", body = DirectiveWithSteps), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn start_directive( + 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(); + }; + + // Set to active + match repository::set_directive_status(pool, auth.owner_id, id, "active").await { + Ok(Some(directive)) => { + // Advance ready steps + let _ = repository::advance_directive_ready_steps(pool, id).await; + let steps = repository::list_directive_steps(pool, id).await.unwrap_or_default(); + Json(DirectiveWithSteps { directive, steps }).into_response() + } + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to start directive: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("START_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Pause a directive. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/pause", + params(("id" = Uuid, Path, description = "Directive ID")), + responses( + (status = 200, description = "Directive paused", body = Directive), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn pause_directive( + 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::set_directive_status(pool, auth.owner_id, id, "paused").await { + Ok(Some(directive)) => Json(directive).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("PAUSE_FAILED", &e.to_string())), + ) + .into_response(), + } +} + +/// Advance a directive: find newly-ready steps. If all steps done, set idle. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/advance", + params(("id" = Uuid, Path, description = "Directive ID")), + responses( + (status = 200, description = "Advance result", body = DirectiveWithSteps), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn advance_directive( + 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 ownership + let directive = match repository::get_directive_for_owner(pool, auth.owner_id, id).await { + Ok(Some(d)) => d, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + }; + + // Advance ready steps + let _ = repository::advance_directive_ready_steps(pool, id).await; + + // Check if idle + let _ = repository::check_directive_idle(pool, id).await; + + // Return updated state + let directive = match repository::get_directive_for_owner(pool, auth.owner_id, id).await { + Ok(Some(d)) => d, + _ => directive, + }; + let steps = repository::list_directive_steps(pool, id).await.unwrap_or_default(); + Json(DirectiveWithSteps { directive, steps }).into_response() +} + +/// Mark a step as completed. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/steps/{step_id}/complete", + params( + ("id" = Uuid, Path, description = "Directive ID"), + ("step_id" = Uuid, Path, description = "Step ID"), + ), + responses( + (status = 200, description = "Step completed", body = DirectiveStep), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn complete_step( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, step_id)): Path<(Uuid, Uuid)>, +) -> impl IntoResponse { + step_status_change(state, auth, id, step_id, "completed").await +} + +/// Mark a step as failed. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/steps/{step_id}/fail", + params( + ("id" = Uuid, Path, description = "Directive ID"), + ("step_id" = Uuid, Path, description = "Step ID"), + ), + responses( + (status = 200, description = "Step failed", body = DirectiveStep), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn fail_step( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, step_id)): Path<(Uuid, Uuid)>, +) -> impl IntoResponse { + step_status_change(state, auth, id, step_id, "failed").await +} + +/// Mark a step as skipped. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/steps/{step_id}/skip", + params( + ("id" = Uuid, Path, description = "Directive ID"), + ("step_id" = Uuid, Path, description = "Step ID"), + ), + responses( + (status = 200, description = "Step skipped", body = DirectiveStep), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn skip_step( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, step_id)): Path<(Uuid, Uuid)>, +) -> impl IntoResponse { + step_status_change(state, auth, id, step_id, "skipped").await +} + +/// Helper for step status changes. +async fn step_status_change( + state: SharedState, + auth: crate::server::auth::AuthenticatedUser, + directive_id: Uuid, + step_id: Uuid, + new_status: &str, +) -> 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 directive ownership + match repository::get_directive_for_owner(pool, auth.owner_id, directive_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + let req = UpdateDirectiveStepRequest { + status: Some(new_status.to_string()), + ..Default::default() + }; + + match repository::update_directive_step(pool, step_id, req).await { + Ok(Some(step)) => { + // After step status change, advance the DAG + let _ = repository::advance_directive_ready_steps(pool, directive_id).await; + let _ = repository::check_directive_idle(pool, directive_id).await; + Json(step).into_response() + } + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Step not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to update step status: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("UPDATE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Update a directive's goal (triggers re-planning). +#[utoipa::path( + put, + path = "/api/v1/directives/{id}/goal", + params(("id" = Uuid, Path, description = "Directive ID")), + request_body = UpdateGoalRequest, + responses( + (status = 200, description = "Goal updated", body = Directive), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn update_goal( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<UpdateGoalRequest>, +) -> 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_directive_goal(pool, auth.owner_id, id, &req.goal).await { + Ok(Some(directive)) => Json(directive).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to update goal: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("UPDATE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} diff --git a/makima/src/server/handlers/mesh.rs b/makima/src/server/handlers/mesh.rs index fe9ffc0..310bec8 100644 --- a/makima/src/server/handlers/mesh.rs +++ b/makima/src/server/handlers/mesh.rs @@ -2626,6 +2626,8 @@ pub async fn reassign_task( branched_from_task_id: None, conversation_history: None, supervisor_worktree_task_id: None, // Not spawned by supervisor + directive_id: None, + directive_step_id: None, }; let new_task = match repository::create_task_for_owner(pool, auth.owner_id, create_req).await { @@ -3402,6 +3404,8 @@ pub async fn fork_task( branched_from_task_id: None, conversation_history: None, supervisor_worktree_task_id: None, // Not spawned by supervisor + directive_id: None, + directive_step_id: None, }; let new_task = match repository::create_task_for_owner(pool, auth.owner_id, create_req).await { @@ -3560,6 +3564,8 @@ pub async fn resume_from_checkpoint( branched_from_task_id: None, conversation_history: None, supervisor_worktree_task_id: None, // Not spawned by supervisor + directive_id: None, + directive_step_id: None, }; let new_task = match repository::create_task_for_owner(pool, auth.owner_id, create_req).await { @@ -3896,6 +3902,8 @@ pub async fn branch_task( branched_from_task_id: Some(source_task_id), conversation_history, supervisor_worktree_task_id: None, // Not spawned by supervisor + directive_id: None, + directive_step_id: None, }; let task = match repository::create_task_for_owner(pool, auth.owner_id, create_req).await { diff --git a/makima/src/server/handlers/mesh_chat.rs b/makima/src/server/handlers/mesh_chat.rs index a6a3a3c..cf56ab6 100644 --- a/makima/src/server/handlers/mesh_chat.rs +++ b/makima/src/server/handlers/mesh_chat.rs @@ -1021,6 +1021,8 @@ async fn handle_mesh_request( 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 { diff --git a/makima/src/server/handlers/mesh_daemon.rs b/makima/src/server/handlers/mesh_daemon.rs index 87b5e44..2ea7805 100644 --- a/makima/src/server/handlers/mesh_daemon.rs +++ b/makima/src/server/handlers/mesh_daemon.rs @@ -1303,6 +1303,23 @@ async fn handle_daemon_connection(socket: WebSocket, state: SharedState, auth_re }), ).await; + // Auto-advance directive DAG when a directive step task completes + if let Some(step_id) = updated_task.directive_step_id { + let step_status = if updated_task.status == "done" { "completed" } else { "failed" }; + let step_update = crate::db::models::UpdateDirectiveStepRequest { + status: Some(step_status.to_string()), + ..Default::default() + }; + let _ = repository::update_directive_step(&pool, step_id, step_update).await; + + if let Some(directive_id) = updated_task.directive_id { + // Advance newly-ready steps in the DAG + let _ = repository::advance_directive_ready_steps(&pool, directive_id).await; + // Check if all steps are done → set directive to idle + let _ = repository::check_directive_idle(&pool, directive_id).await; + } + } + } Ok(None) => { tracing::warn!( diff --git a/makima/src/server/handlers/mesh_supervisor.rs b/makima/src/server/handlers/mesh_supervisor.rs index 09758bb..8bf2534 100644 --- a/makima/src/server/handlers/mesh_supervisor.rs +++ b/makima/src/server/handlers/mesh_supervisor.rs @@ -629,6 +629,8 @@ pub async fn spawn_task( branched_from_task_id: None, conversation_history: None, supervisor_worktree_task_id, + directive_id: None, + directive_step_id: None, }; // Create task in DB diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs index ae370c9..29cd09f 100644 --- a/makima/src/server/handlers/mod.rs +++ b/makima/src/server/handlers/mod.rs @@ -6,6 +6,7 @@ pub mod contract_chat; pub mod contract_daemon; pub mod contract_discuss; pub mod contracts; +pub mod directives; pub mod file_ws; pub mod files; pub mod history; diff --git a/makima/src/server/handlers/transcript_analysis.rs b/makima/src/server/handlers/transcript_analysis.rs index 62c65a6..9261c0c 100644 --- a/makima/src/server/handlers/transcript_analysis.rs +++ b/makima/src/server/handlers/transcript_analysis.rs @@ -370,6 +370,8 @@ pub async fn create_contract_from_analysis( 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 { @@ -540,6 +542,8 @@ pub async fn update_contract_from_analysis( 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 { diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index b7a4156..9e1ee50 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, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, repository_history, speak, templates, transcript_analysis, users, versions}; +use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contract_discuss, contracts, directives, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, repository_history, speak, templates, transcript_analysis, users, versions}; use crate::server::openapi::ApiDoc; use crate::server::state::SharedState; @@ -212,6 +212,30 @@ pub fn make_router(state: SharedState) -> Router { "/contracts/{id}/tasks/{task_id}", post(contracts::add_task_to_contract).delete(contracts::remove_task_from_contract), ) + // Directive endpoints + .route( + "/directives", + get(directives::list_directives).post(directives::create_directive), + ) + .route( + "/directives/{id}", + get(directives::get_directive) + .put(directives::update_directive) + .delete(directives::delete_directive), + ) + .route("/directives/{id}/steps", post(directives::create_step)) + .route("/directives/{id}/steps/batch", post(directives::batch_create_steps)) + .route( + "/directives/{id}/steps/{step_id}", + put(directives::update_step).delete(directives::delete_step), + ) + .route("/directives/{id}/start", post(directives::start_directive)) + .route("/directives/{id}/pause", post(directives::pause_directive)) + .route("/directives/{id}/advance", post(directives::advance_directive)) + .route("/directives/{id}/steps/{step_id}/complete", post(directives::complete_step)) + .route("/directives/{id}/steps/{step_id}/fail", post(directives::fail_step)) + .route("/directives/{id}/steps/{step_id}/skip", post(directives::skip_step)) + .route("/directives/{id}/goal", put(directives::update_goal)) // Timeline endpoint (unified history for user) .route("/timeline", get(history::get_timeline)) // Contract type templates (built-in only) diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs index 0b6bfba..4e3b85b 100644 --- a/makima/src/server/openapi.rs +++ b/makima/src/server/openapi.rs @@ -8,9 +8,10 @@ use crate::db::models::{ ChangePhaseRequest, Contract, ContractChatHistoryResponse, ContractChatMessageRecord, ContractEvent, ContractListResponse, ContractRepository, ContractSummary, ContractWithRelations, - CreateContractRequest, CreateFileRequest, + CreateContractRequest, CreateDirectiveRequest, CreateDirectiveStepRequest, CreateFileRequest, CreateManagedRepositoryRequest, CreateTaskRequest, Daemon, DaemonDirectoriesResponse, - DaemonDirectory, DaemonListResponse, + DaemonDirectory, DaemonListResponse, Directive, DirectiveListResponse, DirectiveStep, + DirectiveSummary, DirectiveWithSteps, File, FileListResponse, FileSummary, MergeCommitRequest, MergeCompleteCheckResponse, MergeResolveRequest, MergeResultResponse, MergeSkipRequest, MergeStartRequest, MergeStatusResponse, MeshChatConversation, @@ -18,13 +19,14 @@ use crate::db::models::{ RepositoryHistoryListResponse, RepositorySuggestionsQuery, SendMessageRequest, Task, TaskEventListResponse, TaskListResponse, TaskSummary, TaskWithSubtasks, TranscriptEntry, - UpdateContractRequest, UpdateFileRequest, UpdateTaskRequest, + UpdateContractRequest, UpdateDirectiveRequest, UpdateDirectiveStepRequest, + UpdateFileRequest, UpdateGoalRequest, UpdateTaskRequest, }; use crate::server::auth::{ ApiKey, ApiKeyInfoResponse, CreateApiKeyRequest, CreateApiKeyResponse, RefreshApiKeyRequest, RefreshApiKeyResponse, RevokeApiKeyResponse, }; -use crate::server::handlers::{api_keys, contract_chat, contract_discuss, contracts, files, listen, mesh, mesh_chat, mesh_merge, repository_history, users}; +use crate::server::handlers::{api_keys, contract_chat, contract_discuss, contracts, directives, files, listen, mesh, mesh_chat, mesh_merge, repository_history, users}; use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage, TranscriptMessage}; #[derive(OpenApi)] @@ -103,6 +105,23 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage contract_chat::clear_contract_chat_history, // Contract discuss endpoint contract_discuss::discuss_contract_handler, + // Directive endpoints + directives::list_directives, + directives::create_directive, + directives::get_directive, + directives::update_directive, + directives::delete_directive, + directives::create_step, + directives::batch_create_steps, + directives::update_step, + directives::delete_step, + directives::start_directive, + directives::pause_directive, + directives::advance_directive, + directives::complete_step, + directives::fail_step, + directives::skip_step, + directives::update_goal, // Repository history/settings endpoints repository_history::list_repository_history, repository_history::get_repository_suggestions, @@ -187,6 +206,17 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage AddLocalRepositoryRequest, CreateManagedRepositoryRequest, ChangePhaseRequest, + // Directive schemas + Directive, + DirectiveStep, + DirectiveWithSteps, + DirectiveSummary, + DirectiveListResponse, + CreateDirectiveRequest, + UpdateDirectiveRequest, + UpdateGoalRequest, + CreateDirectiveStepRequest, + UpdateDirectiveStepRequest, // Repository history schemas RepositoryHistoryEntry, RepositoryHistoryListResponse, @@ -200,6 +230,7 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage (name = "Contracts", description = "Contract management with workflow phases"), (name = "API Keys", description = "API key management for programmatic access"), (name = "Users", description = "User account management"), + (name = "Directives", description = "Directive management with DAG-based step progression"), (name = "Settings", description = "User settings including repository history"), ) )] |
