summaryrefslogtreecommitdiff
path: root/makima/src/server/handlers/directive_documents.rs
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-05-08 11:29:56 +0100
committerGitHub <noreply@github.com>2026-05-08 11:29:56 +0100
commite00be74c8b575c725829677aadeb755ee81454d0 (patch)
tree6804ba099978b32f94e3436bb373c2b0bd07b84d /makima/src/server/handlers/directive_documents.rs
parentd7048aaef8ffa483c63a765d2d35ae01389e331f (diff)
downloadsoryu-e00be74c8b575c725829677aadeb755ee81454d0.tar.gz
soryu-e00be74c8b575c725829677aadeb755ee81454d0.zip
feat(directives): unified contracts surface — backbone (#128)
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) <noreply@anthropic.com>
Diffstat (limited to 'makima/src/server/handlers/directive_documents.rs')
-rw-r--r--makima/src/server/handlers/directive_documents.rs110
1 files changed, 98 insertions, 12 deletions
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()
+ }
+ }
+}