summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-15 22:55:04 +0000
committersoryu <soryu@soryu.co>2026-01-16 01:12:03 +0000
commitb69dc962cd99aa8b478b7c5facbd56bfb63eda27 (patch)
tree9922f60b0da646eaf00165d5348b25e822dfd7b0
parent6ee2e75834bff187b8c262e0798ef365bc21cd59 (diff)
downloadsoryu-b69dc962cd99aa8b478b7c5facbd56bfb63eda27.tar.gz
soryu-b69dc962cd99aa8b478b7c5facbd56bfb63eda27.zip
Add Task Contract Type for one-off adhoc tasks (#2)
-rw-r--r--makima/src/bin/makima.rs65
-rw-r--r--makima/src/db/models.rs37
-rw-r--r--makima/src/server/handlers/contracts.rs132
-rw-r--r--makima/src/server/handlers/history.rs2
-rw-r--r--makima/src/server/handlers/mesh.rs170
-rw-r--r--makima/src/server/handlers/mesh_supervisor.rs11
-rw-r--r--makima/src/server/mod.rs2
-rw-r--r--makima/src/server/openapi.rs26
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,