diff options
Diffstat (limited to 'makima/src')
| -rw-r--r-- | makima/src/db/models.rs | 43 | ||||
| -rw-r--r-- | makima/src/db/repository.rs | 313 | ||||
| -rw-r--r-- | makima/src/orchestration/directive.rs | 151 | ||||
| -rw-r--r-- | makima/src/server/handlers/directive_documents.rs | 572 | ||||
| -rw-r--r-- | makima/src/server/handlers/mod.rs | 1 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 25 | ||||
| -rw-r--r-- | makima/src/server/openapi.rs | 19 |
7 files changed, 1115 insertions, 9 deletions
diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index 44af939..18f3435 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -539,6 +539,13 @@ pub struct Task { /// Directive step this task executes #[serde(skip_serializing_if = "Option::is_none")] pub directive_step_id: Option<Uuid>, + /// Directive document this task is an artifact of. Set when the + /// orchestrator (or any task-creation path) knows which document + /// triggered the work — so when that document ships, the sidebar can + /// move its tasks alongside it under shipped/. Nullable for legacy / + /// non-directive tasks. + #[serde(skip_serializing_if = "Option::is_none")] + pub directive_document_id: Option<Uuid>, } impl Task { @@ -2778,6 +2785,11 @@ pub struct DirectiveStep { pub started_at: Option<DateTime<Utc>>, pub completed_at: Option<DateTime<Utc>>, pub created_at: DateTime<Utc>, + /// Directive document this step belongs to. When the document ships, its + /// steps move with it under shipped/. Nullable for legacy steps and for + /// directives that don't yet have a document. + #[serde(skip_serializing_if = "Option::is_none")] + pub directive_document_id: Option<Uuid>, } /// Directive with its steps for detail view. @@ -2902,6 +2914,11 @@ pub struct CreateDirectiveStepRequest { /// Valid values: "simple", "specification", "execute" #[serde(default)] pub contract_type: Option<String>, + /// Optional: attach this step to a specific directive document. When + /// omitted, the repository falls back to the directive's most-recently + /// updated active document (if any). + #[serde(default)] + pub directive_document_id: Option<Uuid>, } /// Request to update a directive step. @@ -2918,6 +2935,32 @@ pub struct UpdateDirectiveStepRequest { } // ============================================================================= +// Directive Document Types +// ============================================================================= + +/// A directive document — one of N markdown documents owned by a directive. +/// The user calls these "directive contracts". Each document has its own +/// lifecycle (draft → active → shipped → archived) and may be attached to a +/// PR. Multiple documents can be active under the same directive at once. +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DirectiveDocument { + pub id: Uuid, + pub directive_id: Uuid, + pub title: String, + pub body: String, + /// Status: draft, active, shipped, archived + pub status: String, + pub pr_url: Option<String>, + pub pr_branch: Option<String>, + pub shipped_at: Option<DateTime<Utc>>, + pub archived_at: Option<DateTime<Utc>>, + pub version: i32, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, +} + +// ============================================================================= // Order Types // ============================================================================= diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index f91bfaa..5d8ba82 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -11,7 +11,7 @@ use super::models::{ ContractTypeTemplateRecord, ConversationMessage, ConversationSnapshot, CreateContractRequest, CreateFileRequest, CreateTaskRequest, CreateTemplateRequest, Daemon, DaemonTaskAssignment, DaemonWithCapacity, - DeliverableDefinition, Directive, DirectiveStep, DirectiveSummary, + DeliverableDefinition, Directive, DirectiveDocument, DirectiveStep, DirectiveSummary, CreateDirectiveRequest, CreateDirectiveStepRequest, DirectiveGoalHistory, UpdateDirectiveRequest, UpdateDirectiveStepRequest, CreateOrderRequest, Order, UpdateOrderRequest, @@ -1105,6 +1105,21 @@ pub async fn create_task_for_owner( let copy_files_json = req.copy_files.as_ref().map(|f| serde_json::to_value(f).unwrap_or_default()); + // Resolve the directive_document_id. Tasks plumbed through this builder + // currently have no way to specify a document explicitly (we don't want + // to widen `CreateTaskRequest` for this — every call site would have to + // change). Instead, when the task is directive-driven (directive_id is + // set) we attach it to that directive's most recently-updated active + // document so the task lands under that document's tasks/ subfolder in + // the sidebar. Resolution failures are non-fatal — the task still gets + // created with directive_document_id = NULL, matching legacy behaviour. + let directive_document_id = match req.directive_id { + Some(directive_id) => resolve_active_document_for_directive(pool, directive_id) + .await + .unwrap_or(None), + None => None, + }; + sqlx::query_as::<_, Task>( r#" INSERT INTO tasks ( @@ -1112,9 +1127,9 @@ pub async fn create_task_for_owner( is_supervisor, repository_url, base_branch, target_branch, merge_mode, target_repo_path, completion_action, continue_from_task_id, copy_files, branched_from_task_id, conversation_state, supervisor_worktree_task_id, - directive_id, directive_step_id + directive_id, directive_step_id, directive_document_id ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23) RETURNING * "#, ) @@ -1140,10 +1155,46 @@ pub async fn create_task_for_owner( .bind(&req.supervisor_worktree_task_id) .bind(&req.directive_id) .bind(&req.directive_step_id) + .bind(&directive_document_id) .fetch_one(pool) .await } +/// Pick the directive's "current" document for tasks/steps to attach to. +/// +/// Selection rule, in order of preference: +/// 1. The most recently `updated_at` document with `status = 'active'`. +/// 2. If no active doc exists, the most recently `updated_at` document +/// with `status = 'draft'` — covers the case where a fresh draft was +/// auto-created post-ship and the orchestrator is now spawning tasks +/// against it before the user has even touched it. +/// 3. None — directive has no documents at all. +/// +/// Returning `Ok(None)` is fine and expected (e.g., directives that pre-date +/// the document model on a fresh DB, or directives whose only doc is shipped +/// + no fresh draft exists yet). The task/step is then stored with +/// `directive_document_id = NULL`, which the sidebar already tolerates. +async fn resolve_active_document_for_directive( + pool: &PgPool, + directive_id: Uuid, +) -> Result<Option<Uuid>, sqlx::Error> { + let row: Option<(Uuid,)> = sqlx::query_as( + r#" + SELECT id FROM directive_documents + WHERE directive_id = $1 + AND status IN ('active', 'draft') + ORDER BY + CASE status WHEN 'active' THEN 0 WHEN 'draft' THEN 1 ELSE 2 END, + updated_at DESC + LIMIT 1 + "#, + ) + .bind(directive_id) + .fetch_optional(pool) + .await?; + Ok(row.map(|r| r.0)) +} + /// Get a task by ID, scoped to owner. pub async fn get_task_for_owner( pool: &PgPool, @@ -5570,10 +5621,26 @@ pub async fn create_directive_step( let generation = req.generation.unwrap_or(1); let order_id = req.order_id; let contract_type = req.contract_type.clone(); + + // Resolve the document this step belongs to. If the caller supplied one, + // honour it; otherwise pick the directive's most recently-updated + // active (or draft) document. Steps that can't be matched to any + // document fall through with NULL — the sidebar treats those as + // directive-level orphans. + let directive_document_id = match req.directive_document_id { + Some(id) => Some(id), + None => resolve_active_document_for_directive(pool, directive_id) + .await + .unwrap_or(None), + }; + let step = sqlx::query_as::<_, DirectiveStep>( r#" - INSERT INTO directive_steps (directive_id, name, description, task_plan, depends_on, order_index, generation, contract_type) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + INSERT INTO directive_steps ( + directive_id, name, description, task_plan, depends_on, + order_index, generation, contract_type, directive_document_id + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING * "#, ) @@ -5585,6 +5652,7 @@ pub async fn create_directive_step( .bind(req.order_index) .bind(generation) .bind(&contract_type) + .bind(&directive_document_id) .fetch_one(pool) .await?; @@ -5714,6 +5782,241 @@ pub async fn clear_pending_directive_steps( } // ============================================================================= +// Directive Document CRUD +// ============================================================================= + +/// List all documents under a directive, ordered by creation time. +pub async fn list_directive_documents( + pool: &PgPool, + directive_id: Uuid, +) -> Result<Vec<DirectiveDocument>, sqlx::Error> { + sqlx::query_as::<_, DirectiveDocument>( + r#" + SELECT * FROM directive_documents + WHERE directive_id = $1 + ORDER BY created_at + "#, + ) + .bind(directive_id) + .fetch_all(pool) + .await +} + +/// Get a single directive document by ID. +pub async fn get_directive_document( + pool: &PgPool, + document_id: Uuid, +) -> Result<Option<DirectiveDocument>, sqlx::Error> { + sqlx::query_as::<_, DirectiveDocument>( + r#"SELECT * FROM directive_documents WHERE id = $1"#, + ) + .bind(document_id) + .fetch_optional(pool) + .await +} + +/// Create a new directive document. Status defaults to 'draft'. +pub async fn create_directive_document( + pool: &PgPool, + directive_id: Uuid, + title: &str, + body: &str, +) -> Result<DirectiveDocument, sqlx::Error> { + sqlx::query_as::<_, DirectiveDocument>( + r#" + INSERT INTO directive_documents (directive_id, title, body, status) + VALUES ($1, $2, $3, 'draft') + RETURNING * + "#, + ) + .bind(directive_id) + .bind(title) + .bind(body) + .fetch_one(pool) + .await +} + +/// Update a directive document's title and/or body. +/// +/// Bumps `version` and `updated_at`. If the document was previously in the +/// `shipped` state and the body actually changed, the status flips back to +/// `active` and `shipped_at` is cleared — this implements the "editing a +/// shipped contract reactivates it" behaviour. The wiring from the API / +/// handlers will be added in a later step. +pub async fn update_directive_document( + pool: &PgPool, + document_id: Uuid, + title: Option<&str>, + body: Option<&str>, +) -> Result<Option<DirectiveDocument>, sqlx::Error> { + let current = sqlx::query_as::<_, DirectiveDocument>( + r#"SELECT * FROM directive_documents WHERE id = $1"#, + ) + .bind(document_id) + .fetch_optional(pool) + .await?; + + let current = match current { + Some(c) => c, + None => return Ok(None), + }; + + let new_title = title.unwrap_or(¤t.title); + let new_body = body.unwrap_or(¤t.body); + let body_changed = new_body != current.body; + + // Reactivation rule: editing the body of a shipped doc flips it back + // to 'active' and clears shipped_at. Other status transitions remain + // untouched here and are handled by the dedicated mark/archive helpers. + let reactivate_from_shipped = + current.status == "shipped" && body_changed; + let new_status = if reactivate_from_shipped { + "active" + } else { + current.status.as_str() + }; + + let result = sqlx::query_as::<_, DirectiveDocument>( + r#" + UPDATE directive_documents + SET title = $2, + body = $3, + status = $4, + shipped_at = CASE WHEN $5 THEN NULL ELSE shipped_at END, + version = version + 1, + updated_at = NOW() + WHERE id = $1 + RETURNING * + "#, + ) + .bind(document_id) + .bind(new_title) + .bind(new_body) + .bind(new_status) + .bind(reactivate_from_shipped) + .fetch_optional(pool) + .await?; + + Ok(result) +} + +/// Mark a directive document as shipped (PR raised). Sets pr_url, optional +/// pr_branch, status = 'shipped', shipped_at = NOW(), and bumps version. +pub async fn mark_directive_document_shipped( + pool: &PgPool, + document_id: Uuid, + pr_url: &str, + pr_branch: Option<&str>, +) -> Result<Option<DirectiveDocument>, sqlx::Error> { + sqlx::query_as::<_, DirectiveDocument>( + r#" + UPDATE directive_documents + SET status = 'shipped', + pr_url = $2, + pr_branch = COALESCE($3, pr_branch), + shipped_at = NOW(), + version = version + 1, + updated_at = NOW() + WHERE id = $1 + RETURNING * + "#, + ) + .bind(document_id) + .bind(pr_url) + .bind(pr_branch) + .fetch_optional(pool) + .await +} + +/// Archive a directive document. Sets status = 'archived' and stamps +/// archived_at = NOW(). Idempotent — archiving an already-archived doc +/// re-stamps archived_at and bumps version. +pub async fn archive_directive_document( + pool: &PgPool, + document_id: Uuid, +) -> Result<Option<DirectiveDocument>, sqlx::Error> { + sqlx::query_as::<_, DirectiveDocument>( + r#" + UPDATE directive_documents + SET status = 'archived', + archived_at = NOW(), + version = version + 1, + updated_at = NOW() + WHERE id = $1 + RETURNING * + "#, + ) + .bind(document_id) + .fetch_optional(pool) + .await +} + +/// Count the number of currently-active documents under a directive. +/// "Active" here means status = 'active' (not draft, shipped, or archived). +pub async fn count_active_directive_documents( + pool: &PgPool, + directive_id: Uuid, +) -> Result<i64, sqlx::Error> { + let row: (i64,) = sqlx::query_as( + r#" + SELECT COUNT(*)::BIGINT + FROM directive_documents + WHERE directive_id = $1 AND status = 'active' + "#, + ) + .bind(directive_id) + .fetch_one(pool) + .await?; + Ok(row.0) +} + +/// List all tasks attached to a specific directive document. +/// +/// This powers the per-document `tasks/` subfolder in the sidebar — when a +/// document ships, its tasks visually move with it under shipped/. Includes +/// both step-execution tasks (those with directive_step_id set) and +/// "ephemeral" / orchestrator-style tasks (those without a step). +/// +/// Hidden tasks are filtered out so dismissed tasks don't reappear in the +/// document's task list. +pub async fn list_directive_document_tasks( + pool: &PgPool, + document_id: Uuid, +) -> Result<Vec<Task>, sqlx::Error> { + sqlx::query_as::<_, Task>( + r#" + SELECT * + FROM tasks + WHERE directive_document_id = $1 + AND COALESCE(hidden, false) = false + ORDER BY created_at DESC + "#, + ) + .bind(document_id) + .fetch_all(pool) + .await +} + +/// List directive_steps attached to a specific document, ordered the same +/// way the directive page orders them (by order_index, then created_at). +pub async fn list_directive_document_steps( + pool: &PgPool, + document_id: Uuid, +) -> Result<Vec<DirectiveStep>, sqlx::Error> { + sqlx::query_as::<_, DirectiveStep>( + r#" + SELECT * + FROM directive_steps + WHERE directive_document_id = $1 + ORDER BY order_index, created_at + "#, + ) + .bind(document_id) + .fetch_all(pool) + .await +} + +// ============================================================================= // Directive DAG Progression // ============================================================================= diff --git a/makima/src/orchestration/directive.rs b/makima/src/orchestration/directive.rs index 80d8172..7897c2c 100644 --- a/makima/src/orchestration/directive.rs +++ b/makima/src/orchestration/directive.rs @@ -910,7 +910,7 @@ impl DirectiveOrchestrator { "Extracted PR URL from completion task output" ); let update = crate::db::models::UpdateDirectiveRequest { - pr_url: Some(url), + pr_url: Some(url.clone()), ..Default::default() }; let _ = repository::update_directive_for_owner( @@ -920,6 +920,36 @@ impl DirectiveOrchestrator { update, ) .await; + + // Lifecycle hook: mark the currently-active directive + // document as shipped (records pr_url + pr_branch and + // flips status to 'shipped'), then auto-create a fresh + // empty draft document under the same directive so the + // user has a clean slate for the next batch of work. + // + // Per the goal: "automatically create a new empty draft + // contract" once the previous contract ships. + // + // Idempotency: this branch is gated by + // `check.pr_url.is_none()` — `pr_url` is set on the + // directive in the same tick above, so a retry of the + // orchestrator will see `check.pr_url.is_some()` and + // skip the hook entirely. Inside the helper we also + // refuse to ship an already-shipped doc as a belt-and- + // braces guard. + if let Err(hook_err) = ship_active_document_for_directive( + &self.pool, + check.directive_id, + &url, + ) + .await + { + tracing::warn!( + directive_id = %check.directive_id, + error = %hook_err, + "Failed to ship active directive document — continuing" + ); + } } Ok(None) => { if check.task_name.starts_with("Verify PR:") { @@ -1157,6 +1187,125 @@ pub async fn remove_already_merged_steps( Ok(removed) } +/// Ship the currently-active directive document for `directive_id` and seed a +/// fresh empty draft document underneath the same directive. +/// +/// Called from the PR-raise completion path the moment the orchestrator +/// extracts a `pr_url` from the completion task's output (see Part 2 of +/// `phase_completion`). +/// +/// Selection rule: we only auto-ship when there is **exactly one** document +/// in `status = 'active'`. If there are zero or more than one, we log a TODO +/// and bail out — multi-document selection is the next step's UI work, where +/// the user explicitly chooses which contract a PR ships. +/// +/// On success we also auto-create a fresh empty draft document under the +/// same directive (per the goal's "automatically create a new empty draft +/// contract" requirement). The draft sits in `status = 'draft'` until the +/// user starts editing it. +/// +/// Idempotency: callers gate on `directive.pr_url.is_none()` so a retried +/// orchestrator tick won't re-fire this. As an extra guard we refuse to +/// ship a doc that is already in `shipped` state. +pub async fn ship_active_document_for_directive( + pool: &PgPool, + directive_id: Uuid, + pr_url: &str, +) -> Result<(), anyhow::Error> { + // Fetch the directive so we can pick up its pr_branch (set earlier in the + // completion flow) for the document record. + let directive = match repository::get_directive(pool, directive_id).await? { + Some(d) => d, + None => { + tracing::warn!( + directive_id = %directive_id, + "ship_active_document: directive not found" + ); + return Ok(()); + } + }; + let pr_branch = directive.pr_branch.as_deref(); + + let docs = repository::list_directive_documents(pool, directive_id).await?; + let active: Vec<_> = docs + .iter() + .filter(|d| d.status == "active") + .collect(); + + match active.len() { + 0 => { + tracing::info!( + directive_id = %directive_id, + "ship_active_document: no active document — nothing to ship" + ); + return Ok(()); + } + 1 => {} + n => { + // TODO(next-step): the new sidebar UI lets users pick which + // active document a PR ships. Until that lands we conservatively + // skip — better to leave docs unshipped than to ship the wrong + // one. + tracing::warn!( + directive_id = %directive_id, + active_count = n, + "ship_active_document: multiple active documents — leaving unshipped (UI pick coming)" + ); + return Ok(()); + } + } + + let target = active[0]; + + // Belt-and-braces idempotency: refuse to re-ship. + if target.status == "shipped" { + tracing::info!( + directive_id = %directive_id, + document_id = %target.id, + "ship_active_document: document already shipped — skipping" + ); + return Ok(()); + } + + let shipped = repository::mark_directive_document_shipped( + pool, + target.id, + pr_url, + pr_branch, + ) + .await?; + if let Some(s) = shipped { + tracing::info!( + directive_id = %directive_id, + document_id = %s.id, + pr_url = %pr_url, + "Marked directive document as shipped" + ); + } + + // Auto-create the fresh empty draft contract that succeeds the just- + // shipped one. Per the directive goal: "automatically create a new + // empty draft contract". + match repository::create_directive_document(pool, directive_id, "", "").await { + Ok(draft) => { + tracing::info!( + directive_id = %directive_id, + document_id = %draft.id, + "Created fresh empty draft directive document after shipping" + ); + } + Err(e) => { + tracing::warn!( + directive_id = %directive_id, + error = %e, + "Failed to auto-create draft directive document after shipping — continuing" + ); + } + } + + Ok(()) +} + /// Trigger a completion task (PR creation/update) for a directive. /// /// This is the public entry point used by both the orchestrator tick and the diff --git a/makima/src/server/handlers/directive_documents.rs b/makima/src/server/handlers/directive_documents.rs new file mode 100644 index 0000000..48f314f --- /dev/null +++ b/makima/src/server/handlers/directive_documents.rs @@ -0,0 +1,572 @@ +//! HTTP handlers for directive_documents. +//! +//! A directive_document (the user-facing name is "directive contract") is one +//! of N markdown documents owned by a directive. Each document has its own +//! lifecycle (draft → active → shipped → archived) and may be attached to a +//! PR. Multiple documents can be active under the same directive at once; +//! the next step's UI work will let users pick between them. +//! +//! Auth/owner-scoping conventions mirror `handlers::directives`: every handler +//! looks the parent directive up via `get_directive_for_owner` to enforce the +//! caller actually owns the directive before any document operation. + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::db::models::{DirectiveStep, Task}; +use crate::db::repository; +use crate::server::auth::Authenticated; +use crate::server::messages::ApiError; +use crate::server::state::SharedState; + +// ============================================================================= +// Request payloads +// ============================================================================= + +/// Body for `POST /api/v1/directives/{directive_id}/documents`. +#[derive(Debug, Default, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateDirectiveDocumentRequest { + /// Optional document title. Defaults to empty string. + pub title: Option<String>, + /// Optional document body (markdown). Defaults to empty string. + pub body: Option<String>, +} + +/// Body for `PATCH /api/v1/directive-documents/{document_id}`. +#[derive(Debug, Default, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateDirectiveDocumentRequest { + pub title: Option<String>, + pub body: Option<String>, +} + +/// Body for `POST /api/v1/directive-documents/{document_id}/ship`. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ShipDirectiveDocumentRequest { + pub pr_url: String, + pub pr_branch: Option<String>, +} + +// ============================================================================= +// Helpers +// ============================================================================= + +/// Load a directive document and verify the caller owns the parent directive. +/// +/// Returns: +/// * `Ok(Some(doc))` — caller owns the parent directive and the document exists. +/// * `Ok(None)` — document does not exist OR caller does not own the directive. +/// Both cases are folded into 404 to avoid leaking existence to non-owners. +async fn load_owned_document( + pool: &sqlx::PgPool, + owner_id: Uuid, + document_id: Uuid, +) -> Result<Option<crate::db::models::DirectiveDocument>, sqlx::Error> { + let doc = match repository::get_directive_document(pool, document_id).await? { + Some(d) => d, + None => return Ok(None), + }; + match repository::get_directive_for_owner(pool, owner_id, doc.directive_id).await? { + Some(_) => Ok(Some(doc)), + None => Ok(None), + } +} + +// ============================================================================= +// Directive-scoped endpoints (parent = /directives/{directive_id}) +// ============================================================================= + +/// List all documents under a directive. +#[utoipa::path( + get, + path = "/api/v1/directives/{directive_id}/documents", + params(("directive_id" = Uuid, Path, description = "Directive ID")), + responses( + (status = 200, description = "List of directive documents", body = Vec<crate::db::models::DirectiveDocument>), + (status = 404, description = "Directive not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directive Documents" +)] +pub async fn list_documents( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(directive_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 directive ownership before exposing any documents. + 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(); + } + } + + match repository::list_directive_documents(pool, directive_id).await { + Ok(docs) => Json(docs).into_response(), + Err(e) => { + tracing::error!("Failed to list directive documents: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("LIST_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Create a new directive document. The new document starts in `draft` status. +#[utoipa::path( + post, + path = "/api/v1/directives/{directive_id}/documents", + params(("directive_id" = Uuid, Path, description = "Directive ID")), + request_body = CreateDirectiveDocumentRequest, + responses( + (status = 201, description = "Document created", body = crate::db::models::DirectiveDocument), + (status = 404, description = "Directive not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directive Documents" +)] +pub async fn create_document( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(directive_id): Path<Uuid>, + Json(req): Json<CreateDirectiveDocumentRequest>, +) -> 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 title = req.title.as_deref().unwrap_or(""); + let body = req.body.as_deref().unwrap_or(""); + + match repository::create_directive_document(pool, directive_id, title, body).await { + Ok(doc) => (StatusCode::CREATED, Json(doc)).into_response(), + Err(e) => { + tracing::error!("Failed to create directive document: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("CREATE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= +// Document-scoped endpoints (parent = /directive-documents/{document_id}) +// ============================================================================= + +/// Get a single directive document by ID. +#[utoipa::path( + get, + path = "/api/v1/directive-documents/{document_id}", + params(("document_id" = Uuid, Path, description = "Directive document ID")), + responses( + (status = 200, description = "Directive document", body = crate::db::models::DirectiveDocument), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directive Documents" +)] +pub async fn get_document( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(document_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 load_owned_document(pool, auth.owner_id, document_id).await { + Ok(Some(doc)) => Json(doc).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive document not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to get directive document: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Update a directive document's title and/or body. +/// +/// Calls `repository::update_directive_document`, which already encodes the +/// "editing a shipped contract reactivates it" behaviour: if the doc was in +/// `shipped` status and the body actually changed, the underlying SQL flips +/// status back to `active` and clears `shipped_at`. The handler simply +/// returns the updated row — the reactivation is automatic. +#[utoipa::path( + patch, + path = "/api/v1/directive-documents/{document_id}", + params(("document_id" = Uuid, Path, description = "Directive document ID")), + request_body = UpdateDirectiveDocumentRequest, + responses( + (status = 200, description = "Document updated", body = crate::db::models::DirectiveDocument), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directive Documents" +)] +pub async fn update_document( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(document_id): Path<Uuid>, + Json(req): Json<UpdateDirectiveDocumentRequest>, +) -> 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 the caller owns the parent directive before mutating. + match load_owned_document(pool, auth.owner_id, document_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive document 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_document( + pool, + document_id, + req.title.as_deref(), + req.body.as_deref(), + ) + .await + { + Ok(Some(doc)) => Json(doc).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive document not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to update directive document: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("UPDATE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Mark a directive document as shipped — records pr_url and optional +/// pr_branch, sets status='shipped', and stamps shipped_at. +#[utoipa::path( + post, + path = "/api/v1/directive-documents/{document_id}/ship", + params(("document_id" = Uuid, Path, description = "Directive document ID")), + request_body = ShipDirectiveDocumentRequest, + responses( + (status = 200, description = "Document shipped", body = crate::db::models::DirectiveDocument), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directive Documents" +)] +pub async fn ship_document( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(document_id): Path<Uuid>, + Json(req): Json<ShipDirectiveDocumentRequest>, +) -> 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 the caller owns the parent directive. + match load_owned_document(pool, auth.owner_id, document_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive document not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + match repository::mark_directive_document_shipped( + pool, + document_id, + &req.pr_url, + req.pr_branch.as_deref(), + ) + .await + { + Ok(Some(doc)) => Json(doc).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive document not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to ship directive document: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("SHIP_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Archive a directive document. +#[utoipa::path( + post, + path = "/api/v1/directive-documents/{document_id}/archive", + params(("document_id" = Uuid, Path, description = "Directive document ID")), + responses( + (status = 200, description = "Document archived", body = crate::db::models::DirectiveDocument), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directive Documents" +)] +pub async fn archive_document( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(document_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 the caller owns the parent directive. + match load_owned_document(pool, auth.owner_id, document_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive document not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + match repository::archive_directive_document(pool, document_id).await { + Ok(Some(doc)) => Json(doc).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive document not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to archive directive document: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("ARCHIVE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= +// Document tasks endpoint — returns the steps + ephemeral tasks attached to +// a single document. Used by the sidebar to render a per-document tasks/ +// subfolder beside the document row, and to keep that subfolder pinned to +// the document when it ships (because the rows are FK-linked to the doc, +// not to the directive). +// ============================================================================= + +/// Response body for `GET /api/v1/directive-documents/{document_id}/tasks`. +/// +/// We return BOTH steps and tasks. Steps are the planned units of work in the +/// directive's DAG; tasks are the actual execution records (orchestrator, +/// completion, ephemeral). The sidebar renders both under the document's +/// tasks/ subfolder so the user sees the full task history of a document at +/// a glance. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DocumentTasksResponse { + pub steps: Vec<DirectiveStep>, + pub tasks: Vec<Task>, +} + +/// List the steps and ephemeral tasks attached to a directive document. +/// +/// This drives the sidebar's per-document `tasks/` subfolder. The tasks here +/// are *artifacts* of the document — they were created while that document +/// was the "active" doc — so when the document ships, its tasks visually +/// move with it under the shipped/ subfolder. +/// +/// Auth: same scope check as the other document endpoints — caller must own +/// the parent directive. +#[utoipa::path( + get, + path = "/api/v1/directive-documents/{document_id}/tasks", + params(("document_id" = Uuid, Path, description = "Directive document ID")), + responses( + (status = 200, description = "Steps and tasks attached to the document", body = DocumentTasksResponse), + (status = 404, description = "Document not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directive Documents" +)] +pub async fn list_document_tasks( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(document_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(); + }; + + // Ownership check via the existing helper — folds "doesn't exist" and + // "not yours" into 404. + match load_owned_document(pool, auth.owner_id, document_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive document not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + let steps = match repository::list_directive_document_steps(pool, document_id).await { + Ok(s) => s, + Err(e) => { + tracing::error!("Failed to list directive document steps: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("LIST_STEPS_FAILED", &e.to_string())), + ) + .into_response(); + } + }; + + let tasks = match repository::list_directive_document_tasks(pool, document_id).await { + Ok(t) => t, + Err(e) => { + tracing::error!("Failed to list directive document tasks: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("LIST_TASKS_FAILED", &e.to_string())), + ) + .into_response(); + } + }; + + Json(DocumentTasksResponse { steps, tasks }).into_response() +} diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs index c761dcc..a39a4c0 100644 --- a/makima/src/server/handlers/mod.rs +++ b/makima/src/server/handlers/mod.rs @@ -6,6 +6,7 @@ pub mod api_keys; pub mod chat; pub mod daemon_download; +pub mod directive_documents; pub mod directives; pub mod file_ws; pub mod files; diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index 59eff2e..dd79ddf 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, daemon_download, directives, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, orders, repository_history, speak, templates, users, versions}; +use crate::server::handlers::{api_keys, chat, daemon_download, directive_documents, directives, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, orders, repository_history, speak, templates, users, versions}; use crate::server::openapi::ApiDoc; use crate::server::state::SharedState; @@ -214,6 +214,29 @@ pub fn make_router(state: SharedState) -> Router { ) .route("/directives/{id}/dogs/{dog_id}/orders", get(directives::list_dog_orders)) .route("/directives/{id}/dogs/{dog_id}/pick-up-orders", post(directives::pick_up_dog_orders)) + // Directive document endpoints (multi-document directive contracts). + .route( + "/directives/{directive_id}/documents", + get(directive_documents::list_documents) + .post(directive_documents::create_document), + ) + .route( + "/directive-documents/{document_id}", + get(directive_documents::get_document) + .patch(directive_documents::update_document), + ) + .route( + "/directive-documents/{document_id}/ship", + post(directive_documents::ship_document), + ) + .route( + "/directive-documents/{document_id}/archive", + post(directive_documents::archive_document), + ) + .route( + "/directive-documents/{document_id}/tasks", + get(directive_documents::list_document_tasks), + ) // Order endpoints .route( "/orders", diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs index 51a1c0d..ad7837a 100644 --- a/makima/src/server/openapi.rs +++ b/makima/src/server/openapi.rs @@ -12,7 +12,7 @@ use crate::db::models::{ CreateContractRequest, CreateDirectiveRequest, CreateDirectiveStepRequest, CreateFileRequest, CreateManagedRepositoryRequest, CreateOrderRequest, CreateTaskRequest, Daemon, DaemonDirectoriesResponse, - DaemonDirectory, DaemonListResponse, Directive, DirectiveListResponse, + DaemonDirectory, DaemonListResponse, Directive, DirectiveDocument, DirectiveListResponse, DirectiveRevision, DirectiveStep, DirectiveSummary, DirectiveWithSteps, File, FileListResponse, FileSummary, LinkDirectiveRequest, @@ -31,7 +31,7 @@ use crate::server::auth::{ ApiKey, ApiKeyInfoResponse, CreateApiKeyRequest, CreateApiKeyResponse, RefreshApiKeyRequest, RefreshApiKeyResponse, RevokeApiKeyResponse, }; -use crate::server::handlers::{api_keys, directives, files, listen, mesh, mesh_chat, mesh_merge, orders, repository_history, users}; +use crate::server::handlers::{api_keys, directive_documents, directives, files, listen, mesh, mesh_chat, mesh_merge, orders, repository_history, users}; use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage, TranscriptMessage}; #[derive(OpenApi)] @@ -116,6 +116,14 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage directives::list_directive_tasks, directives::cleanup_directive, directives::create_pr, + // Directive document endpoints + directive_documents::list_documents, + directive_documents::create_document, + directive_documents::get_document, + directive_documents::update_document, + directive_documents::ship_document, + directive_documents::archive_document, + directive_documents::list_document_tasks, // Order endpoints orders::list_orders, orders::create_order, @@ -218,6 +226,12 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage CreateDirectiveStepRequest, UpdateDirectiveStepRequest, CleanupResponse, + // Directive document schemas + DirectiveDocument, + directive_documents::CreateDirectiveDocumentRequest, + directive_documents::UpdateDirectiveDocumentRequest, + directive_documents::ShipDirectiveDocumentRequest, + directive_documents::DocumentTasksResponse, // Order schemas Order, OrderListResponse, @@ -239,6 +253,7 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage (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 = "Directive Documents", description = "Directive contracts — multi-document markdown contracts owned by a directive"), (name = "Orders", description = "Order management — card-based issue tracking for planned work items"), (name = "Settings", description = "User settings including repository history"), ) |
