diff options
Diffstat (limited to 'makima/src/db')
| -rw-r--r-- | makima/src/db/models.rs | 43 | ||||
| -rw-r--r-- | makima/src/db/repository.rs | 313 |
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(¤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 // ============================================================================= |
