summaryrefslogtreecommitdiff
path: root/makima/src
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src')
-rw-r--r--makima/src/db/models.rs20
-rw-r--r--makima/src/db/repository.rs117
-rw-r--r--makima/src/server/handlers/directive_documents.rs110
-rw-r--r--makima/src/server/mod.rs20
-rw-r--r--makima/src/server/openapi.rs6
5 files changed, 244 insertions, 29 deletions
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<DateTime<Utc>>,
pub archived_at: Option<DateTime<Utc>>,
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<Utc>,
pub updated_at: DateTime<Utc>,
}
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<DirectiveDocument, sqlx::Error> {
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<Option<DirectiveDocument>, 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(&current.title);
let new_body = body.unwrap_or(&current.body);
+ let new_merge_mode = merge_mode.unwrap_or(&current.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<Option<DirectiveDocument>, 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(
diff --git a/makima/src/server/handlers/directive_documents.rs b/makima/src/server/handlers/directive_documents.rs
index 48f314f..ed38ee4 100644
--- a/makima/src/server/handlers/directive_documents.rs
+++ b/makima/src/server/handlers/directive_documents.rs
@@ -40,15 +40,27 @@ pub struct CreateDirectiveDocumentRequest {
pub body: Option<String>,
}
-/// Body for `PATCH /api/v1/directive-documents/{document_id}`.
+/// Body for `PATCH /api/v1/contracts/{document_id}`.
#[derive(Debug, Default, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct UpdateDirectiveDocumentRequest {
pub title: Option<String>,
pub body: Option<String>,
+ /// Per-contract merge mode. `shared` lands commits on the directive's
+ /// branch; `own_pr` carves out a contract-specific branch + PR. The
+ /// queue scheduler reads this when activating the contract.
+ pub merge_mode: Option<String>,
}
-/// Body for `POST /api/v1/directive-documents/{document_id}/ship`.
+/// Body for `POST /api/v1/contracts/{document_id}/reorder`.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ReorderDirectiveDocumentRequest {
+ /// New 0-indexed queue position within the parent directive.
+ pub position: i32,
+}
+
+/// Body for `POST /api/v1/contracts/{document_id}/ship`.
#[derive(Debug, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ShipDirectiveDocumentRequest {
@@ -88,10 +100,10 @@ async fn load_owned_document(
/// List all documents under a directive.
#[utoipa::path(
get,
- path = "/api/v1/directives/{directive_id}/documents",
+ path = "/api/v1/directives/{directive_id}/contracts",
params(("directive_id" = Uuid, Path, description = "Directive ID")),
responses(
- (status = 200, description = "List of directive documents", body = Vec<crate::db::models::DirectiveDocument>),
+ (status = 200, description = "List of contracts under the directive", body = Vec<crate::db::models::DirectiveDocument>),
(status = 404, description = "Directive not found", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
),
@@ -146,11 +158,11 @@ pub async fn list_documents(
/// Create a new directive document. The new document starts in `draft` status.
#[utoipa::path(
post,
- path = "/api/v1/directives/{directive_id}/documents",
+ path = "/api/v1/directives/{directive_id}/contracts",
params(("directive_id" = Uuid, Path, description = "Directive ID")),
request_body = CreateDirectiveDocumentRequest,
responses(
- (status = 201, description = "Document created", body = crate::db::models::DirectiveDocument),
+ (status = 201, description = "Contract created", body = crate::db::models::DirectiveDocument),
(status = 404, description = "Directive not found", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
),
@@ -213,7 +225,7 @@ pub async fn create_document(
/// Get a single directive document by ID.
#[utoipa::path(
get,
- path = "/api/v1/directive-documents/{document_id}",
+ path = "/api/v1/contracts/{document_id}",
params(("document_id" = Uuid, Path, description = "Directive document ID")),
responses(
(status = 200, description = "Directive document", body = crate::db::models::DirectiveDocument),
@@ -263,7 +275,7 @@ pub async fn get_document(
/// returns the updated row — the reactivation is automatic.
#[utoipa::path(
patch,
- path = "/api/v1/directive-documents/{document_id}",
+ path = "/api/v1/contracts/{document_id}",
params(("document_id" = Uuid, Path, description = "Directive document ID")),
request_body = UpdateDirectiveDocumentRequest,
responses(
@@ -312,6 +324,7 @@ pub async fn update_document(
document_id,
req.title.as_deref(),
req.body.as_deref(),
+ req.merge_mode.as_deref(),
)
.await
{
@@ -336,7 +349,7 @@ pub async fn update_document(
/// pr_branch, sets status='shipped', and stamps shipped_at.
#[utoipa::path(
post,
- path = "/api/v1/directive-documents/{document_id}/ship",
+ path = "/api/v1/contracts/{document_id}/ship",
params(("document_id" = Uuid, Path, description = "Directive document ID")),
request_body = ShipDirectiveDocumentRequest,
responses(
@@ -408,7 +421,7 @@ pub async fn ship_document(
/// Archive a directive document.
#[utoipa::path(
post,
- path = "/api/v1/directive-documents/{document_id}/archive",
+ path = "/api/v1/contracts/{document_id}/archive",
params(("document_id" = Uuid, Path, description = "Directive document ID")),
responses(
(status = 200, description = "Document archived", body = crate::db::models::DirectiveDocument),
@@ -476,7 +489,7 @@ pub async fn archive_document(
// not to the directive).
// =============================================================================
-/// Response body for `GET /api/v1/directive-documents/{document_id}/tasks`.
+/// Response body for `GET /api/v1/contracts/{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,
@@ -501,7 +514,7 @@ pub struct DocumentTasksResponse {
/// the parent directive.
#[utoipa::path(
get,
- path = "/api/v1/directive-documents/{document_id}/tasks",
+ path = "/api/v1/contracts/{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),
@@ -570,3 +583,76 @@ pub async fn list_document_tasks(
Json(DocumentTasksResponse { steps, tasks }).into_response()
}
+
+// =============================================================================
+// Reorder a contract within its parent directive's queue.
+//
+// Drag-to-reorder in the sidebar lands here. The `position` field on each
+// contract drives the ORDER BY in `list_directive_documents`, so the
+// repository function does the bookkeeping (shift siblings, set new
+// position) inside a single transaction. The handler only owns auth.
+// =============================================================================
+
+/// Move a contract to a new queue position within its parent directive.
+#[utoipa::path(
+ post,
+ path = "/api/v1/contracts/{document_id}/reorder",
+ params(("document_id" = Uuid, Path, description = "Contract ID")),
+ request_body = ReorderDirectiveDocumentRequest,
+ responses(
+ (status = 200, description = "Contract reordered", 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 reorder_contract(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(document_id): Path<Uuid>,
+ Json(req): Json<ReorderDirectiveDocumentRequest>,
+) -> 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(_)) => {}
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Contract not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("GET_FAILED", &e.to_string())),
+ )
+ .into_response();
+ }
+ }
+
+ match repository::reorder_directive_document_position(pool, document_id, req.position).await {
+ Ok(Some(doc)) => Json(doc).into_response(),
+ Ok(None) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Contract not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to reorder contract: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("REORDER_FAILED", &e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs
index dd79ddf..68d3dea 100644
--- a/makima/src/server/mod.rs
+++ b/makima/src/server/mod.rs
@@ -214,27 +214,35 @@ 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).
+ // Contract endpoints (the unified directive contracts surface).
+ // The handler module + DB column names are still
+ // "directive_documents" for now — the user-facing names ("contract"
+ // in URLs / structs / UI) are aligned here while the deeper rename
+ // waits for legacy contracts removal (Phase 5).
.route(
- "/directives/{directive_id}/documents",
+ "/directives/{directive_id}/contracts",
get(directive_documents::list_documents)
.post(directive_documents::create_document),
)
.route(
- "/directive-documents/{document_id}",
+ "/contracts/{document_id}",
get(directive_documents::get_document)
.patch(directive_documents::update_document),
)
.route(
- "/directive-documents/{document_id}/ship",
+ "/contracts/{document_id}/ship",
post(directive_documents::ship_document),
)
.route(
- "/directive-documents/{document_id}/archive",
+ "/contracts/{document_id}/archive",
post(directive_documents::archive_document),
)
.route(
- "/directive-documents/{document_id}/tasks",
+ "/contracts/{document_id}/reorder",
+ post(directive_documents::reorder_contract),
+ )
+ .route(
+ "/contracts/{document_id}/tasks",
get(directive_documents::list_document_tasks),
)
// Order endpoints
diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs
index ad7837a..7ddaf1b 100644
--- a/makima/src/server/openapi.rs
+++ b/makima/src/server/openapi.rs
@@ -116,13 +116,14 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
directives::list_directive_tasks,
directives::cleanup_directive,
directives::create_pr,
- // Directive document endpoints
+ // Contract (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::reorder_contract,
directive_documents::list_document_tasks,
// Order endpoints
orders::list_orders,
@@ -226,11 +227,12 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
CreateDirectiveStepRequest,
UpdateDirectiveStepRequest,
CleanupResponse,
- // Directive document schemas
+ // Contract (directive document) schemas
DirectiveDocument,
directive_documents::CreateDirectiveDocumentRequest,
directive_documents::UpdateDirectiveDocumentRequest,
directive_documents::ShipDirectiveDocumentRequest,
+ directive_documents::ReorderDirectiveDocumentRequest,
directive_documents::DocumentTasksResponse,
// Order schemas
Order,