From b5811ad26a94f48ddab45251e44861f5595a7b95 Mon Sep 17 00:00:00 2001 From: soryu Date: Fri, 8 May 2026 11:29:20 +0100 Subject: feat(directives): unified contracts surface — backbone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the backbone PR for the unified directive workflow. A directive holds a sequence of contracts; each contract is a spec body whose execution drives tasks in the directive's shared worktree. Lifecycle (Lock & Start, queue scheduler, drag-reorder) lands in follow-ups. What's in this PR: - Migration adds `position` (queue order) and `merge_mode` (shared|own_pr) columns to directive_documents. The actual table rename is deferred — the legacy `contracts` table from the old contracts system still exists, and the rename collision waits for Phase 5 to drop legacy contracts. - Repository: list orders by position; create assigns next-position; update accepts merge_mode; new reorder_directive_document_position shifts siblings inside a transaction. - HTTP: endpoints aliased under /api/v1/directives/{id}/contracts and /api/v1/contracts/{id}/... with a new /contracts/{id}/reorder. - Frontend: api types renamed `DirectiveContract*` (avoiding the legacy `Contract` type collision); document-directives.tsx imports via aliases so the rest of the file is untouched. Internal struct + table names stay `DirectiveDocument` / `directive_documents` until the legacy contracts cleanup. Co-Authored-By: Claude Opus 4.7 (1M context) --- makima/src/db/models.rs | 20 ++++++-- makima/src/db/repository.rs | 117 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 128 insertions(+), 9 deletions(-) (limited to 'makima/src/db') diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index 18f3435..fcccd05 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -2938,10 +2938,16 @@ 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. +/// A directive document — the user-facing "contract" in the unified +/// directive UI. One of N specs owned by a directive. Each runs +/// sequentially in the directive's shared worktree (or, when +/// `merge_mode = 'own_pr'`, on its own branch). `position` defines +/// queue order. +/// +/// Naming note: this struct stays `DirectiveDocument` internally because +/// a legacy `Contract` struct (from the pre-directive contracts system) +/// still exists. The API and frontend expose this as "Contract"; the +/// table/struct rename lands once legacy contracts are dropped (Phase 5). #[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct DirectiveDocument { @@ -2956,6 +2962,12 @@ pub struct DirectiveDocument { pub shipped_at: Option>, pub archived_at: Option>, pub version: i32, + /// Queue position within the parent directive (0-indexed). Lower + /// numbers run earlier; only one contract is active at a time. + pub position: i32, + /// Where this contract's commits land. `shared` (default): the + /// directive's branch. `own_pr`: a contract-specific branch + PR. + pub merge_mode: String, pub created_at: DateTime, pub updated_at: DateTime, } diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index 5d8ba82..12d5e4d 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -5785,7 +5785,12 @@ pub async fn clear_pending_directive_steps( // Directive Document CRUD // ============================================================================= -/// List all documents under a directive, ordered by creation time. +/// List all contracts under a directive in queue order. +/// +/// Ordered by `position` (lower = earlier), with `created_at` as a stable +/// tie-break. Position is the queue order in the unified directive UI; +/// only one contract is active at a time, and the next-up contract is +/// the lowest-position non-shipped row. pub async fn list_directive_documents( pool: &PgPool, directive_id: Uuid, @@ -5794,7 +5799,7 @@ pub async fn list_directive_documents( r#" SELECT * FROM directive_documents WHERE directive_id = $1 - ORDER BY created_at + ORDER BY position ASC, created_at ASC "#, ) .bind(directive_id) @@ -5815,7 +5820,14 @@ pub async fn get_directive_document( .await } -/// Create a new directive document. Status defaults to 'draft'. +/// Create a new directive document (contract). Status defaults to 'draft'. +/// +/// The new row's `position` is computed server-side as +/// `MAX(position) + 1` over the directive's existing contracts, so it +/// lands at the bottom of the queue. Callers that want to insert in the +/// middle should call `reorder_directive_document_position` afterwards. +/// `merge_mode` defaults to 'shared' on creation; flip later via +/// `update_directive_document`. pub async fn create_directive_document( pool: &PgPool, directive_id: Uuid, @@ -5824,8 +5836,14 @@ pub async fn create_directive_document( ) -> Result { sqlx::query_as::<_, DirectiveDocument>( r#" - INSERT INTO directive_documents (directive_id, title, body, status) - VALUES ($1, $2, $3, 'draft') + INSERT INTO directive_documents (directive_id, title, body, status, position) + VALUES ( + $1, $2, $3, 'draft', + COALESCE( + (SELECT MAX(position) + 1 FROM directive_documents WHERE directive_id = $1), + 0 + ) + ) RETURNING * "#, ) @@ -5848,6 +5866,7 @@ pub async fn update_directive_document( document_id: Uuid, title: Option<&str>, body: Option<&str>, + merge_mode: Option<&str>, ) -> Result, sqlx::Error> { let current = sqlx::query_as::<_, DirectiveDocument>( r#"SELECT * FROM directive_documents WHERE id = $1"#, @@ -5863,6 +5882,7 @@ pub async fn update_directive_document( let new_title = title.unwrap_or(¤t.title); let new_body = body.unwrap_or(¤t.body); + let new_merge_mode = merge_mode.unwrap_or(¤t.merge_mode); let body_changed = new_body != current.body; // Reactivation rule: editing the body of a shipped doc flips it back @@ -5883,6 +5903,7 @@ pub async fn update_directive_document( body = $3, status = $4, shipped_at = CASE WHEN $5 THEN NULL ELSE shipped_at END, + merge_mode = $6, version = version + 1, updated_at = NOW() WHERE id = $1 @@ -5894,12 +5915,98 @@ pub async fn update_directive_document( .bind(new_body) .bind(new_status) .bind(reactivate_from_shipped) + .bind(new_merge_mode) .fetch_optional(pool) .await?; Ok(result) } +/// Move a contract to a new queue position within its directive. +/// +/// Implementation: a single SQL CTE that bumps siblings out of the way +/// based on whether we're moving forward (later) or backward (earlier). +/// Returns the updated contract row. +pub async fn reorder_directive_document_position( + pool: &PgPool, + document_id: Uuid, + new_position: i32, +) -> Result, sqlx::Error> { + let mut tx = pool.begin().await?; + + let current = sqlx::query_as::<_, DirectiveDocument>( + r#"SELECT * FROM directive_documents WHERE id = $1"#, + ) + .bind(document_id) + .fetch_optional(&mut *tx) + .await?; + + let current = match current { + Some(c) => c, + None => return Ok(None), + }; + + if current.position == new_position { + tx.commit().await?; + return Ok(Some(current)); + } + + // Shift siblings to make room. Moving forward (new > old) drags the + // intermediate range back by one; moving backward pushes it forward. + if new_position > current.position { + sqlx::query( + r#" + UPDATE directive_documents + SET position = position - 1 + WHERE directive_id = $1 + AND id <> $2 + AND position > $3 + AND position <= $4 + "#, + ) + .bind(current.directive_id) + .bind(document_id) + .bind(current.position) + .bind(new_position) + .execute(&mut *tx) + .await?; + } else { + sqlx::query( + r#" + UPDATE directive_documents + SET position = position + 1 + WHERE directive_id = $1 + AND id <> $2 + AND position >= $3 + AND position < $4 + "#, + ) + .bind(current.directive_id) + .bind(document_id) + .bind(new_position) + .bind(current.position) + .execute(&mut *tx) + .await?; + } + + let result = sqlx::query_as::<_, DirectiveDocument>( + r#" + UPDATE directive_documents + SET position = $2, + updated_at = NOW() + WHERE id = $1 + RETURNING * + "#, + ) + .bind(document_id) + .bind(new_position) + .fetch_optional(&mut *tx) + .await?; + + tx.commit().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( -- cgit v1.2.3