summaryrefslogtreecommitdiff
path: root/makima/src/db
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src/db')
-rw-r--r--makima/src/db/models.rs43
-rw-r--r--makima/src/db/repository.rs313
2 files changed, 351 insertions, 5 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
// =============================================================================