summaryrefslogtreecommitdiff
path: root/makima/src
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src')
-rw-r--r--makima/src/db/models.rs43
-rw-r--r--makima/src/db/repository.rs313
-rw-r--r--makima/src/orchestration/directive.rs151
-rw-r--r--makima/src/server/handlers/directive_documents.rs572
-rw-r--r--makima/src/server/handlers/mod.rs1
-rw-r--r--makima/src/server/mod.rs25
-rw-r--r--makima/src/server/openapi.rs19
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(&current.title);
+ let new_body = body.unwrap_or(&current.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"),
)