summaryrefslogtreecommitdiff
path: root/makima/src
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src')
-rw-r--r--makima/src/db/repository.rs65
-rw-r--r--makima/src/server/handlers/directives.rs161
-rw-r--r--makima/src/server/handlers/mesh.rs25
-rw-r--r--makima/src/server/mod.rs4
-rw-r--r--makima/src/server/openapi.rs3
5 files changed, 256 insertions, 2 deletions
diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs
index 27bd47e..b41c74c 100644
--- a/makima/src/db/repository.rs
+++ b/makima/src/db/repository.rs
@@ -1189,6 +1189,71 @@ pub async fn list_tasks_for_owner(
.await
}
+/// List ephemeral tasks attached to a directive — tasks with `directive_id`
+/// set but no `directive_step_id`. These are the "spinoff" tasks the user
+/// created via the directive folder context menu, distinct from
+/// step-spawned execution tasks. Hidden tasks excluded.
+pub async fn list_ephemeral_directive_tasks_for_owner(
+ pool: &PgPool,
+ owner_id: Uuid,
+ directive_id: Uuid,
+) -> Result<Vec<TaskSummary>, sqlx::Error> {
+ sqlx::query_as::<_, TaskSummary>(
+ r#"
+ SELECT
+ t.id, t.contract_id, c.name as contract_name, c.phase as contract_phase,
+ c.status as contract_status,
+ t.parent_task_id, t.depth, t.name, t.status, t.priority,
+ t.progress_summary,
+ (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count,
+ t.version, t.is_supervisor, COALESCE(t.hidden, false) as hidden, t.created_at, t.updated_at
+ FROM tasks t
+ LEFT JOIN contracts c ON t.contract_id = c.id
+ WHERE t.owner_id = $1
+ AND t.directive_id = $2
+ AND t.directive_step_id IS NULL
+ AND t.parent_task_id IS NULL
+ AND COALESCE(t.hidden, false) = false
+ ORDER BY t.created_at DESC
+ "#,
+ )
+ .bind(owner_id)
+ .bind(directive_id)
+ .fetch_all(pool)
+ .await
+}
+
+/// List "orphan" top-level tasks for an owner — tasks that are NOT attached
+/// to a directive and NOT a subtask of another task. These surface in the
+/// document-mode sidebar under a top-level `tmp/` folder. Hidden tasks
+/// excluded.
+pub async fn list_orphan_tasks_for_owner(
+ pool: &PgPool,
+ owner_id: Uuid,
+) -> Result<Vec<TaskSummary>, sqlx::Error> {
+ sqlx::query_as::<_, TaskSummary>(
+ r#"
+ SELECT
+ t.id, t.contract_id, c.name as contract_name, c.phase as contract_phase,
+ c.status as contract_status,
+ t.parent_task_id, t.depth, t.name, t.status, t.priority,
+ t.progress_summary,
+ (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count,
+ t.version, t.is_supervisor, COALESCE(t.hidden, false) as hidden, t.created_at, t.updated_at
+ FROM tasks t
+ LEFT JOIN contracts c ON t.contract_id = c.id
+ WHERE t.owner_id = $1
+ AND t.parent_task_id IS NULL
+ AND t.directive_id IS NULL
+ AND COALESCE(t.hidden, false) = false
+ ORDER BY t.priority DESC, t.created_at DESC
+ "#,
+ )
+ .bind(owner_id)
+ .fetch_all(pool)
+ .await
+}
+
/// List subtasks of a parent task, scoped to owner.
pub async fn list_subtasks_for_owner(
pool: &PgPool,
diff --git a/makima/src/server/handlers/directives.rs b/makima/src/server/handlers/directives.rs
index 7a7aff4..7b13f1c 100644
--- a/makima/src/server/handlers/directives.rs
+++ b/makima/src/server/handlers/directives.rs
@@ -12,6 +12,7 @@ use crate::db::models::{
CleanupResponse, CreateDirectiveRequest, CreateTaskRequest,
CreateDirectiveStepRequest, Directive, DirectiveListResponse,
DirectiveRevision, DirectiveStep, DirectiveWithSteps, PickUpOrdersResponse,
+ Task,
UpdateDirectiveRequest, UpdateDirectiveStepRequest, UpdateGoalRequest,
CreateDirectiveOrderGroupRequest, DirectiveOrderGroup,
DirectiveOrderGroupListResponse, UpdateDirectiveOrderGroupRequest,
@@ -2194,3 +2195,163 @@ pub async fn list_directive_revisions(
}
}
}
+
+// =============================================================================
+// Ephemeral tasks under a directive
+//
+// A directive can spin up ad-hoc one-off tasks that are NOT part of its DAG.
+// They live in the `tasks` table with `directive_id` set and
+// `directive_step_id = NULL` — the existing schema already supports this; we
+// just expose the create path through a directive-scoped endpoint and
+// inherit repo/branch from the parent directive when the caller doesn't
+// override.
+// =============================================================================
+
+/// Request body for creating an ephemeral task under a directive.
+#[derive(Debug, serde::Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CreateDirectiveTaskRequest {
+ /// Human-readable name (used for branch/commit names).
+ pub name: String,
+ /// Plan / instructions for the Claude Code session.
+ pub plan: String,
+ /// Optional override for the directive's repository.
+ pub repository_url: Option<String>,
+ /// Optional override for the directive's base branch.
+ pub base_branch: Option<String>,
+}
+
+/// List the ephemeral tasks (no directive_step_id) attached to a directive.
+#[utoipa::path(
+ get,
+ path = "/api/v1/directives/{id}/tasks",
+ params(("id" = Uuid, Path, description = "Directive ID")),
+ responses(
+ (status = 200, description = "List of ephemeral tasks", body = crate::db::models::TaskListResponse),
+ (status = 503, description = "Database not configured", body = ApiError),
+ ),
+ security(("bearer_auth" = []), ("api_key" = [])),
+ tag = "Directives"
+)]
+pub async fn list_directive_tasks(
+ 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::list_ephemeral_directive_tasks_for_owner(pool, auth.owner_id, id).await {
+ Ok(tasks) => {
+ let total = tasks.len() as i64;
+ Json(crate::db::models::TaskListResponse { tasks, total }).into_response()
+ }
+ Err(e) => {
+ tracing::error!("Failed to list ephemeral tasks: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("LIST_FAILED", &e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Create an ephemeral task attached to a directive.
+#[utoipa::path(
+ post,
+ path = "/api/v1/directives/{id}/tasks",
+ params(("id" = Uuid, Path, description = "Directive ID")),
+ request_body = CreateDirectiveTaskRequest,
+ responses(
+ (status = 201, description = "Ephemeral task created", body = Task),
+ (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_directive_task(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+ Json(req): Json<CreateDirectiveTaskRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Look up the parent directive so we can inherit its repository/base branch
+ // when the caller doesn't override them. Also gates the request on
+ // ownership: if the directive isn't visible to this owner, 404.
+ 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) => {
+ tracing::error!("Failed to load directive for ephemeral task: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", &e.to_string())),
+ )
+ .into_response();
+ }
+ };
+
+ let repo_url = req
+ .repository_url
+ .or_else(|| directive.repository_url.clone());
+ let base_branch = req.base_branch.or_else(|| directive.base_branch.clone());
+
+ let create_req = CreateTaskRequest {
+ contract_id: None,
+ name: req.name,
+ description: None,
+ plan: req.plan,
+ parent_task_id: None,
+ is_supervisor: false,
+ priority: 0,
+ repository_url: repo_url,
+ base_branch,
+ target_branch: None,
+ merge_mode: None,
+ target_repo_path: None,
+ completion_action: None,
+ continue_from_task_id: None,
+ copy_files: None,
+ checkpoint_sha: None,
+ branched_from_task_id: None,
+ conversation_history: None,
+ supervisor_worktree_task_id: None,
+ directive_id: Some(directive.id),
+ // No directive_step_id — this is what makes the task "ephemeral":
+ // it lives under the directive folder but isn't part of the DAG.
+ directive_step_id: None,
+ };
+
+ match repository::create_task_for_owner(pool, auth.owner_id, create_req).await {
+ Ok(task) => (StatusCode::CREATED, Json(task)).into_response(),
+ Err(e) => {
+ tracing::error!("Failed to create ephemeral directive task: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("CREATE_FAILED", &e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
diff --git a/makima/src/server/handlers/mesh.rs b/makima/src/server/handlers/mesh.rs
index 1a5b9c1..ac5652a 100644
--- a/makima/src/server/handlers/mesh.rs
+++ b/makima/src/server/handlers/mesh.rs
@@ -78,10 +78,24 @@ pub fn extract_auth(state: &SharedState, headers: &HeaderMap) -> AuthSource {
// Task Handlers
// =============================================================================
-/// List all tasks for the current owner.
+/// Query parameters for `list_tasks`. Currently only the `orphan` filter —
+/// when set, returns tasks with NO parent_task_id AND NO directive_id, used
+/// by the document-mode sidebar's `tmp/` folder.
+#[derive(Debug, serde::Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ListTasksQuery {
+ #[serde(default)]
+ pub orphan: bool,
+}
+
+/// List all tasks for the current owner. Pass `?orphan=true` to restrict to
+/// top-level tasks with no directive attachment.
#[utoipa::path(
get,
path = "/api/v1/mesh/tasks",
+ params(
+ ("orphan" = Option<bool>, Query, description = "Filter to tasks with no directive_id and no parent_task_id"),
+ ),
responses(
(status = 200, description = "List of tasks", body = TaskListResponse),
(status = 401, description = "Unauthorized", body = ApiError),
@@ -97,6 +111,7 @@ pub fn extract_auth(state: &SharedState, headers: &HeaderMap) -> AuthSource {
pub async fn list_tasks(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
+ axum::extract::Query(query): axum::extract::Query<ListTasksQuery>,
) -> impl IntoResponse {
let Some(ref pool) = state.db_pool else {
return (
@@ -106,7 +121,13 @@ pub async fn list_tasks(
.into_response();
};
- match repository::list_tasks_for_owner(pool, auth.owner_id).await {
+ let result = if query.orphan {
+ repository::list_orphan_tasks_for_owner(pool, auth.owner_id).await
+ } else {
+ repository::list_tasks_for_owner(pool, auth.owner_id).await
+ };
+
+ match result {
Ok(tasks) => {
let total = tasks.len() as i64;
Json(TaskListResponse { tasks, total }).into_response()
diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs
index c577904..efae901 100644
--- a/makima/src/server/mod.rs
+++ b/makima/src/server/mod.rs
@@ -255,6 +255,10 @@ pub fn make_router(state: SharedState) -> Router {
.route("/directives/{id}/goal", put(directives::update_goal))
.route("/directives/{id}/revisions", get(directives::list_directive_revisions))
.route("/directives/{id}/new-draft", post(directives::new_directive_draft))
+ .route(
+ "/directives/{id}/tasks",
+ get(directives::list_directive_tasks).post(directives::create_directive_task),
+ )
.route("/directives/{id}/cleanup", post(directives::cleanup_directive))
.route("/directives/{id}/create-pr", post(directives::create_pr))
.route("/directives/{id}/pick-up-orders", post(directives::pick_up_orders))
diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs
index e6d4547..7a4b004 100644
--- a/makima/src/server/openapi.rs
+++ b/makima/src/server/openapi.rs
@@ -132,6 +132,8 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
directives::update_goal,
directives::list_directive_revisions,
directives::new_directive_draft,
+ directives::create_directive_task,
+ directives::list_directive_tasks,
directives::cleanup_directive,
directives::create_pr,
// Order endpoints
@@ -237,6 +239,7 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
DirectiveListResponse,
DirectiveRevision,
crate::server::handlers::directives::DirectiveRevisionListResponse,
+ crate::server::handlers::directives::CreateDirectiveTaskRequest,
CreateDirectiveRequest,
UpdateDirectiveRequest,
UpdateGoalRequest,