diff options
| author | soryu <soryu@soryu.co> | 2026-01-15 22:55:04 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-16 01:12:03 +0000 |
| commit | b69dc962cd99aa8b478b7c5facbd56bfb63eda27 (patch) | |
| tree | 9922f60b0da646eaf00165d5348b25e822dfd7b0 | |
| parent | 6ee2e75834bff187b8c262e0798ef365bc21cd59 (diff) | |
| download | soryu-b69dc962cd99aa8b478b7c5facbd56bfb63eda27.tar.gz soryu-b69dc962cd99aa8b478b7c5facbd56bfb63eda27.zip | |
Add Task Contract Type for one-off adhoc tasks (#2)
| -rw-r--r-- | makima/src/bin/makima.rs | 65 | ||||
| -rw-r--r-- | makima/src/db/models.rs | 37 | ||||
| -rw-r--r-- | makima/src/server/handlers/contracts.rs | 132 | ||||
| -rw-r--r-- | makima/src/server/handlers/history.rs | 2 | ||||
| -rw-r--r-- | makima/src/server/handlers/mesh.rs | 170 | ||||
| -rw-r--r-- | makima/src/server/handlers/mesh_supervisor.rs | 11 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 2 | ||||
| -rw-r--r-- | makima/src/server/openapi.rs | 26 |
8 files changed, 368 insertions, 77 deletions
diff --git a/makima/src/bin/makima.rs b/makima/src/bin/makima.rs index f430701..9c9ac77 100644 --- a/makima/src/bin/makima.rs +++ b/makima/src/bin/makima.rs @@ -364,6 +364,71 @@ async fn run_supervisor( let result = client.supervisor_get_task_output(args.target_task_id).await?; println!("{}", serde_json::to_string(&result.0)?); } + SupervisorCommand::TaskHistory(args) => { + eprintln!( + "Task history for {} (limit: {:?}, format: {})", + args.task_id, args.limit, args.format + ); + eprintln!("CLI integration not yet implemented. Use the API directly:"); + eprintln!(" GET /api/v1/mesh/tasks/{}/conversation", args.task_id); + } + SupervisorCommand::TaskCheckpoints(args) => { + eprintln!( + "Task checkpoints for {} (with_diff: {})", + args.task_id, args.with_diff + ); + eprintln!("CLI integration not yet implemented. Use the API directly:"); + eprintln!(" GET /api/v1/mesh/tasks/{}/checkpoints", args.task_id); + } + SupervisorCommand::Resume(args) => { + eprintln!( + "Resume supervisor for contract {} (mode: {}, checkpoint: {:?})", + args.common.contract_id, args.mode, args.checkpoint + ); + eprintln!("CLI integration not yet implemented. Use the API directly:"); + eprintln!( + " POST /api/v1/contracts/{}/supervisor/resume", + args.common.contract_id + ); + } + SupervisorCommand::TaskResumeFrom(args) => { + eprintln!( + "Resume task {} from checkpoint {} with plan: {}", + args.task_id, args.checkpoint, args.plan + ); + eprintln!("CLI integration not yet implemented. Use the API directly:"); + eprintln!( + " POST /api/v1/mesh/tasks/{}/checkpoints/{}/resume", + args.task_id, args.checkpoint + ); + } + SupervisorCommand::TaskRewind(args) => { + eprintln!( + "Rewind task {} to checkpoint {} (preserve: {}, branch: {:?})", + args.task_id, args.checkpoint, args.preserve, args.branch_name + ); + eprintln!("CLI integration not yet implemented. Use the API directly:"); + eprintln!(" POST /api/v1/mesh/tasks/{}/rewind", args.task_id); + } + SupervisorCommand::TaskFork(args) => { + eprintln!( + "Fork task {} from checkpoint {} as '{}' with plan: {}", + args.task_id, args.checkpoint, args.name, args.plan + ); + eprintln!("CLI integration not yet implemented. Use the API directly:"); + eprintln!(" POST /api/v1/mesh/tasks/{}/fork", args.task_id); + } + SupervisorCommand::RewindConversation(args) => { + eprintln!( + "Rewind conversation for contract {} (by: {:?}, to: {:?}, rewind_code: {})", + args.common.contract_id, args.by_messages, args.to_message, args.rewind_code + ); + eprintln!("CLI integration not yet implemented. Use the API directly:"); + eprintln!( + " POST /api/v1/contracts/{}/supervisor/conversation/rewind", + args.common.contract_id + ); + } } Ok(()) diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index 4419580..d792f2c 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -991,6 +991,9 @@ pub struct MergeCompleteCheckResponse { // Contract Types // ============================================================================= +/// Contract type constant for task (adhoc) contracts +pub const CONTRACT_TYPE_TASK: &str = "task"; + /// Contract type determines the workflow and required documents #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "lowercase")] @@ -1006,6 +1009,11 @@ pub enum ContractType { /// - Execute: implement according to specs /// - Review: verify against specifications Specification, + /// Task type for adhoc one-off tasks + /// - Single "execute" phase + /// - No supervisor created + /// - Auto-archives on completion + Task, } impl Default for ContractType { @@ -1019,6 +1027,7 @@ impl std::fmt::Display for ContractType { match self { ContractType::Simple => write!(f, "simple"), ContractType::Specification => write!(f, "specification"), + ContractType::Task => write!(f, "task"), } } } @@ -1030,6 +1039,7 @@ impl std::str::FromStr for ContractType { match s.to_lowercase().as_str() { "simple" => Ok(ContractType::Simple), "specification" => Ok(ContractType::Specification), + "task" => Ok(ContractType::Task), _ => Err(format!("Unknown contract type: {}", s)), } } @@ -1792,3 +1802,30 @@ pub struct ForkPoint { pub checkpoint: Option<TaskCheckpoint>, pub timestamp: DateTime<Utc>, } + +// ============================================================================= +// Adhoc Task Types (for one-off tasks without supervisor overhead) +// ============================================================================= + +/// Request payload for creating an adhoc (one-off) task. +/// Creates a minimal "task" type contract with a single task, no supervisor. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AdhocTaskRequest { + /// Name/description of the task + pub name: String, + /// The plan/instructions for the task + pub plan: String, + /// Repository URL (optional) + pub repository_url: Option<String>, + /// Base branch to work from + pub base_branch: Option<String>, +} + +/// Response for adhoc task creation +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AdhocTaskResponse { + pub contract: ContractSummary, + pub task: Task, +} diff --git a/makima/src/server/handlers/contracts.rs b/makima/src/server/handlers/contracts.rs index 09f78e6..afca3d7 100644 --- a/makima/src/server/handlers/contracts.rs +++ b/makima/src/server/handlers/contracts.rs @@ -259,75 +259,85 @@ pub async fn create_contract( match repository::create_contract_for_owner(pool, auth.owner_id, req.clone()).await { Ok(contract) => { - // Create supervisor task for this contract - let supervisor_name = format!("{} Supervisor", contract.name); - let supervisor_plan = format!( - "You are the supervisor for contract '{}'. Your goal is to drive this contract to completion.\n\n{}", - contract.name, - contract.description.as_deref().unwrap_or("No description provided.") - ); - - // Get repository info from contract if available - let repo_url = { - // Try to get the first repository associated with this contract - match repository::list_contract_repositories(pool, contract.id).await { - Ok(repos) if !repos.is_empty() => { - let repo = &repos[0]; - repo.repository_url.clone() + // Only create supervisor task for non-task type contracts + // Task type contracts are lightweight adhoc tasks that don't need supervisors + if contract.contract_type != crate::db::models::CONTRACT_TYPE_TASK { + // Create supervisor task for this contract + let supervisor_name = format!("{} Supervisor", contract.name); + let supervisor_plan = format!( + "You are the supervisor for contract '{}'. Your goal is to drive this contract to completion.\n\n{}", + contract.name, + contract.description.as_deref().unwrap_or("No description provided.") + ); + + // Get repository info from contract if available + let repo_url = { + // Try to get the first repository associated with this contract + match repository::list_contract_repositories(pool, contract.id).await { + Ok(repos) if !repos.is_empty() => { + let repo = &repos[0]; + repo.repository_url.clone() + } + _ => None, } - _ => None, - } - }; - - let supervisor_req = crate::db::models::CreateTaskRequest { - name: supervisor_name, - description: None, - plan: supervisor_plan, - repository_url: repo_url, - base_branch: None, - target_branch: None, - parent_task_id: None, - contract_id: contract.id, - target_repo_path: None, - completion_action: None, - continue_from_task_id: None, - copy_files: None, - is_supervisor: true, - checkpoint_sha: None, - priority: 0, - merge_mode: None, - }; - - match repository::create_task_for_owner(pool, auth.owner_id, supervisor_req).await { - Ok(supervisor_task) => { - tracing::info!( - contract_id = %contract.id, - supervisor_task_id = %supervisor_task.id, - is_supervisor = supervisor_task.is_supervisor, - "Created supervisor task for contract" - ); + }; + + let supervisor_req = crate::db::models::CreateTaskRequest { + name: supervisor_name, + description: None, + plan: supervisor_plan, + repository_url: repo_url, + base_branch: None, + target_branch: None, + parent_task_id: None, + contract_id: contract.id, + target_repo_path: None, + completion_action: None, + continue_from_task_id: None, + copy_files: None, + is_supervisor: true, + checkpoint_sha: None, + priority: 0, + merge_mode: None, + }; + + match repository::create_task_for_owner(pool, auth.owner_id, supervisor_req).await { + Ok(supervisor_task) => { + tracing::info!( + contract_id = %contract.id, + supervisor_task_id = %supervisor_task.id, + is_supervisor = supervisor_task.is_supervisor, + "Created supervisor task for contract" + ); - // Update contract with supervisor_task_id - let update_req = crate::db::models::UpdateContractRequest { - supervisor_task_id: Some(supervisor_task.id), - version: Some(contract.version), - ..Default::default() - }; - if let Err(e) = repository::update_contract_for_owner(pool, contract.id, auth.owner_id, update_req).await { + // Update contract with supervisor_task_id + let update_req = crate::db::models::UpdateContractRequest { + supervisor_task_id: Some(supervisor_task.id), + version: Some(contract.version), + ..Default::default() + }; + if let Err(e) = repository::update_contract_for_owner(pool, contract.id, auth.owner_id, update_req).await { + tracing::warn!( + contract_id = %contract.id, + error = %e, + "Failed to link supervisor task to contract" + ); + } + } + Err(e) => { tracing::warn!( contract_id = %contract.id, error = %e, - "Failed to link supervisor task to contract" + "Failed to create supervisor task for contract" ); } } - Err(e) => { - tracing::warn!( - contract_id = %contract.id, - error = %e, - "Failed to create supervisor task for contract" - ); - } + } else { + tracing::info!( + contract_id = %contract.id, + contract_type = %contract.contract_type, + "Skipping supervisor creation for task-type contract" + ); } // Get the summary version with counts diff --git a/makima/src/server/handlers/history.rs b/makima/src/server/handlers/history.rs index b3dec97..572eebd 100644 --- a/makima/src/server/handlers/history.rs +++ b/makima/src/server/handlers/history.rs @@ -231,7 +231,7 @@ pub async fn get_supervisor_conversation( .unwrap_or_default(); // Get spawned tasks - let tasks = match repository::list_contract_tasks(pool, contract_id).await { + let tasks = match repository::list_tasks_by_contract(pool, contract_id, auth.owner_id).await { Ok(t) => t, Err(e) => { tracing::warn!("Failed to get tasks for contract {}: {}", contract_id, e); diff --git a/makima/src/server/handlers/mesh.rs b/makima/src/server/handlers/mesh.rs index b5ade53..3cd38b5 100644 --- a/makima/src/server/handlers/mesh.rs +++ b/makima/src/server/handlers/mesh.rs @@ -9,9 +9,10 @@ use axum::{ use uuid::Uuid; use crate::db::models::{ + AdhocTaskRequest, AdhocTaskResponse, ContractSummary, CreateContractRequest, CreateTaskRequest, DaemonDirectory, DaemonDirectoriesResponse, DaemonListResponse, SendMessageRequest, Task, TaskEventListResponse, TaskListResponse, TaskOutputEntry, - TaskOutputResponse, TaskWithSubtasks, UpdateTaskRequest, + TaskOutputResponse, TaskWithSubtasks, UpdateTaskRequest, CONTRACT_TYPE_TASK, }; use crate::db::repository::{self, RepositoryError}; use crate::server::auth::Authenticated; @@ -359,6 +360,40 @@ pub async fn update_task( } }); } + + // Auto-archive task-type contracts when task reaches terminal status + let terminal_statuses = ["done", "failed"]; + if terminal_statuses.contains(&task.status.as_str()) { + let pool = pool.clone(); + let owner_id = auth.owner_id; + let task_id = task.id; + tokio::spawn(async move { + if let Ok(Some(contract)) = repository::get_contract_for_owner(&pool, contract_id, owner_id).await { + if contract.contract_type == CONTRACT_TYPE_TASK { + // Archive the contract + let update_req = crate::db::models::UpdateContractRequest { + status: Some("archived".to_string()), + version: Some(contract.version), + ..Default::default() + }; + if let Err(e) = repository::update_contract_for_owner(&pool, contract_id, owner_id, update_req).await { + tracing::warn!( + contract_id = %contract_id, + task_id = %task_id, + error = %e, + "Failed to auto-archive task-type contract" + ); + } else { + tracing::info!( + contract_id = %contract_id, + task_id = %task_id, + "Auto-archived task-type contract on task completion" + ); + } + } + } + }); + } } Json(task).into_response() @@ -3123,3 +3158,136 @@ pub async fn branch_from_checkpoint( ) .into_response() } + +// ============================================================================= +// Adhoc Task Endpoint +// ============================================================================= + +/// Create an adhoc (one-off) task without supervisor overhead. +/// +/// This creates a minimal "task" type contract with a single task. +/// The contract auto-archives when the task completes. +#[utoipa::path( + post, + path = "/api/v1/tasks/adhoc", + request_body = AdhocTaskRequest, + responses( + (status = 201, description = "Adhoc task created", body = AdhocTaskResponse), + (status = 400, description = "Invalid request", body = ApiError), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Mesh" +)] +pub async fn create_adhoc_task( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Json(req): Json<AdhocTaskRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Generate a short unique name for the contract + let contract_name = format!("task-{}", &Uuid::new_v4().to_string()[..8]); + + // 1. Create a minimal "task" type contract + let contract_req = CreateContractRequest { + name: contract_name, + description: Some(req.name.clone()), + contract_type: Some(CONTRACT_TYPE_TASK.to_string()), + initial_phase: Some("execute".to_string()), // Skip planning + autonomous_loop: Some(false), + }; + + let contract = match repository::create_contract_for_owner(pool, auth.owner_id, contract_req).await { + Ok(c) => c, + Err(e) => { + tracing::error!("Failed to create adhoc task contract: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + }; + + tracing::info!( + contract_id = %contract.id, + contract_type = %contract.contract_type, + "Created task-type contract for adhoc task" + ); + + // 2. Create the actual task (no supervisor) + let task_req = CreateTaskRequest { + contract_id: contract.id, + name: req.name, + description: None, + plan: req.plan, + is_supervisor: false, + parent_task_id: None, + priority: 0, + repository_url: req.repository_url, + base_branch: req.base_branch, + target_branch: None, + merge_mode: None, + target_repo_path: None, + completion_action: None, + continue_from_task_id: None, + copy_files: None, + checkpoint_sha: None, + }; + + let task = match repository::create_task_for_owner(pool, auth.owner_id, task_req).await { + Ok(t) => t, + Err(e) => { + tracing::error!("Failed to create adhoc task: {}", e); + // Clean up the contract we just created + let _ = repository::delete_contract_for_owner(pool, contract.id, auth.owner_id).await; + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + }; + + tracing::info!( + task_id = %task.id, + contract_id = %contract.id, + "Created adhoc task" + ); + + // Build the contract summary for the response + let contract_summary = ContractSummary { + id: contract.id, + name: contract.name, + description: contract.description, + contract_type: contract.contract_type, + phase: contract.phase, + status: contract.status, + file_count: 0, + task_count: 1, + repository_count: 0, + version: contract.version, + created_at: contract.created_at, + }; + + ( + StatusCode::CREATED, + Json(AdhocTaskResponse { + contract: contract_summary, + task, + }), + ) + .into_response() +} diff --git a/makima/src/server/handlers/mesh_supervisor.rs b/makima/src/server/handlers/mesh_supervisor.rs index b45dda5..3fc7dd7 100644 --- a/makima/src/server/handlers/mesh_supervisor.rs +++ b/makima/src/server/handlers/mesh_supervisor.rs @@ -1839,9 +1839,14 @@ pub async fn rewind_conversation( // Determine how many messages to keep let new_count = if let Some(by_count) = req.by_message_count { (original_count - by_count).max(0) - } else if let Some(to_index) = req.to_message_index { - // Keep messages up to and including the specified index - (to_index + 1).min(original_count).max(0) + } else if let Some(ref to_id) = req.to_message_id { + // Find message by ID and keep up to and including it + let index = conversation + .iter() + .position(|msg| msg.get("id").and_then(|v| v.as_str()) == Some(to_id.as_str())) + .map(|i| i as i32) + .unwrap_or(original_count - 1); + (index + 1).min(original_count).max(0) } else { // Default to removing last message (original_count - 1).max(0) diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index cf63f71..e244a08 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -63,6 +63,8 @@ pub fn make_router(state: SharedState) -> Router { .route("/files/{id}/versions/{version}", get(versions::get_version)) .route("/files/{id}/versions/restore", post(versions::restore_version)) // Mesh/task orchestration endpoints + // Adhoc task endpoint (creates task-type contract + task in one call) + .route("/tasks/adhoc", post(mesh::create_adhoc_task)) .route( "/mesh/tasks", get(mesh::list_tasks).post(mesh::create_task), diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs index afa114b..47e3456 100644 --- a/makima/src/server/openapi.rs +++ b/makima/src/server/openapi.rs @@ -3,17 +3,18 @@ use utoipa::OpenApi; use crate::db::models::{ - AddLocalRepositoryRequest, AddRemoteRepositoryRequest, BranchInfo, BranchListResponse, - ChangePhaseRequest, Contract, ContractChatHistoryResponse, ContractChatMessageRecord, - ContractEvent, ContractListResponse, ContractRepository, ContractSummary, ContractWithRelations, - CreateContractRequest, CreateFileRequest, CreateManagedRepositoryRequest, CreateTaskRequest, - Daemon, DaemonDirectoriesResponse, DaemonDirectory, DaemonListResponse, File, FileListResponse, - FileSummary, MergeCommitRequest, MergeCompleteCheckResponse, MergeResolveRequest, - MergeResultResponse, MergeSkipRequest, MergeStartRequest, MergeStatusResponse, - MeshChatConversation, MeshChatHistoryResponse, MeshChatMessageRecord, - RepositoryHistoryEntry, RepositoryHistoryListResponse, RepositorySuggestionsQuery, - SendMessageRequest, Task, TaskEventListResponse, TaskListResponse, TaskSummary, - TaskWithSubtasks, TranscriptEntry, UpdateContractRequest, UpdateFileRequest, UpdateTaskRequest, + AddLocalRepositoryRequest, AddRemoteRepositoryRequest, AdhocTaskRequest, AdhocTaskResponse, + BranchInfo, BranchListResponse, ChangePhaseRequest, Contract, ContractChatHistoryResponse, + ContractChatMessageRecord, ContractEvent, ContractListResponse, ContractRepository, + ContractSummary, ContractWithRelations, CreateContractRequest, CreateFileRequest, + CreateManagedRepositoryRequest, CreateTaskRequest, Daemon, DaemonDirectoriesResponse, + DaemonDirectory, DaemonListResponse, File, FileListResponse, FileSummary, MergeCommitRequest, + MergeCompleteCheckResponse, MergeResolveRequest, MergeResultResponse, MergeSkipRequest, + MergeStartRequest, MergeStatusResponse, MeshChatConversation, MeshChatHistoryResponse, + MeshChatMessageRecord, RepositoryHistoryEntry, RepositoryHistoryListResponse, + RepositorySuggestionsQuery, SendMessageRequest, Task, TaskEventListResponse, TaskListResponse, + TaskSummary, TaskWithSubtasks, TranscriptEntry, UpdateContractRequest, UpdateFileRequest, + UpdateTaskRequest, }; use crate::server::auth::{ ApiKey, ApiKeyInfoResponse, CreateApiKeyRequest, CreateApiKeyResponse, @@ -42,6 +43,7 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage mesh::list_tasks, mesh::get_task, mesh::create_task, + mesh::create_adhoc_task, mesh::update_task, mesh::delete_task, mesh::list_subtasks, @@ -122,6 +124,8 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage CreateTaskRequest, UpdateTaskRequest, SendMessageRequest, + AdhocTaskRequest, + AdhocTaskResponse, TaskEventListResponse, Daemon, DaemonListResponse, |
