summaryrefslogtreecommitdiff
path: root/makima/src/server
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-02-09 00:11:51 +0000
committersoryu <soryu@soryu.co>2026-02-09 00:11:51 +0000
commit8c23b3ab6f7fabca01b0468911bae073aa5ced32 (patch)
treef50159aee13b13f0b55618ac09e9be1f89a41bb2 /makima/src/server
parent3662b334dfd68cfdf00ed44ae88927c2e1b2aabe (diff)
downloadsoryu-8c23b3ab6f7fabca01b0468911bae073aa5ced32.tar.gz
soryu-8c23b3ab6f7fabca01b0468911bae073aa5ced32.zip
Add new directive mechanism v3
Diffstat (limited to 'makima/src/server')
-rw-r--r--makima/src/server/handlers/contract_chat.rs29
-rw-r--r--makima/src/server/handlers/contracts.rs2
-rw-r--r--makima/src/server/handlers/directives.rs841
-rw-r--r--makima/src/server/handlers/mesh.rs8
-rw-r--r--makima/src/server/handlers/mesh_chat.rs2
-rw-r--r--makima/src/server/handlers/mesh_daemon.rs17
-rw-r--r--makima/src/server/handlers/mesh_supervisor.rs2
-rw-r--r--makima/src/server/handlers/mod.rs1
-rw-r--r--makima/src/server/handlers/transcript_analysis.rs4
-rw-r--r--makima/src/server/mod.rs26
-rw-r--r--makima/src/server/openapi.rs39
11 files changed, 945 insertions, 26 deletions
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"),
)
)]