summaryrefslogtreecommitdiff
path: root/makima
diff options
context:
space:
mode:
Diffstat (limited to 'makima')
-rw-r--r--makima/frontend/src/lib/api.ts54
-rw-r--r--makima/frontend/src/routes/document-directives.tsx270
-rw-r--r--makima/migrations/20260509000000_contract_lifecycle_states.sql19
-rw-r--r--makima/src/db/repository.rs276
-rw-r--r--makima/src/server/handlers/directive_documents.rs222
-rw-r--r--makima/src/server/handlers/files.rs5
-rw-r--r--makima/src/server/handlers/mesh.rs5
-rw-r--r--makima/src/server/handlers/versions.rs5
-rw-r--r--makima/src/server/mod.rs16
-rw-r--r--makima/src/server/openapi.rs5
10 files changed, 841 insertions, 36 deletions
diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts
index 17cf1d0..f777ba0 100644
--- a/makima/frontend/src/lib/api.ts
+++ b/makima/frontend/src/lib/api.ts
@@ -3597,7 +3597,7 @@ export async function pickUpOrders(directiveId: string): Promise<PickUpOrdersRes
// to plain `Contract`. URLs and the user-facing UI already say "contract".
// =============================================================================
-export type DirectiveContractStatus = "draft" | "active" | "shipped" | "archived";
+export type DirectiveContractStatus = "draft" | "queued" | "active" | "shipped" | "archived";
/** How a contract's commits land. `shared` (default): on the directive's
* branch — multiple contracts feed one PR. `own_pr`: a contract-specific
@@ -3700,6 +3700,58 @@ export async function reorderDirectiveContract(
return res.json();
}
+// ----- Lifecycle transitions -----------------------------------------------
+//
+// Lock & Start: draft → queued (if a sibling is active) or active.
+// Pause: active → queued; next queued sibling auto-promotes.
+// Complete: active → shipped, optionally records pr_url + pr_branch.
+// Unlock: queued or active → draft (reactivates editing).
+//
+// All four reach the same /contracts/{id}/<action> endpoint and surface
+// 400 with a plain message string when the transition is invalid.
+
+async function postContractAction(
+ contractId: string,
+ action: "start" | "pause" | "complete" | "unlock",
+ body?: object,
+): Promise<DirectiveContract> {
+ const res = await authFetch(`${API_BASE}/api/v1/contracts/${contractId}/${action}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body ?? {}),
+ });
+ if (!res.ok) {
+ let detail = res.statusText;
+ try {
+ const err = await res.json();
+ if (err?.message) detail = err.message;
+ } catch {
+ /* fall through to statusText */
+ }
+ throw new Error(`Failed to ${action} contract: ${detail}`);
+ }
+ return res.json();
+}
+
+export async function startDirectiveContract(contractId: string): Promise<DirectiveContract> {
+ return postContractAction(contractId, "start");
+}
+
+export async function pauseDirectiveContract(contractId: string): Promise<DirectiveContract> {
+ return postContractAction(contractId, "pause");
+}
+
+export async function completeDirectiveContract(
+ contractId: string,
+ opts: { prUrl?: string; prBranch?: string } = {},
+): Promise<DirectiveContract> {
+ return postContractAction(contractId, "complete", opts);
+}
+
+export async function unlockDirectiveContract(contractId: string): Promise<DirectiveContract> {
+ return postContractAction(contractId, "unlock");
+}
+
/** Steps and tasks attached to a single contract. Drives the per-contract
* `tasks/` subfolder in the sidebar — when the contract ships, its
* tasks visually move with it. */
diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx
index d589dac..044c8af 100644
--- a/makima/frontend/src/routes/document-directives.tsx
+++ b/makima/frontend/src/routes/document-directives.tsx
@@ -12,11 +12,16 @@ import {
type DirectiveStep,
type Task,
type DirectiveContractTasksResponse as ContractTasksResponse,
+ type DirectiveContractMergeMode as ContractMergeMode,
listDirectiveContracts as listContracts,
createDirectiveContract as createContract,
getDirectiveContract as getContract,
updateDirectiveContract as updateContract,
listDirectiveContractTasks as listContractTasks,
+ startDirectiveContract,
+ pauseDirectiveContract,
+ completeDirectiveContract,
+ unlockDirectiveContract,
createDirectiveTask,
startDirective,
pauseDirective,
@@ -41,10 +46,13 @@ const STATUS_DOT: Record<DirectiveStatus, string> = {
archived: "bg-[#3a4a6a]",
};
-// Per-document status palette. Active/draft documents use the same bright
-// green-ish accent as a running directive; shipped/archived use a muted blue.
+// Per-contract status palette. Active = bright green (currently driving
+// daemons); queued = amber (locked, waiting for the active slot); draft
+// = grey (editable spec); shipped = muted blue (work done); archived =
+// faint navy.
const DOC_STATUS_DOT: Record<ContractStatus, string> = {
draft: "bg-[#556677]",
+ queued: "bg-amber-400",
active: "bg-green-400",
shipped: "bg-[#75aafc]",
archived: "bg-[#3a4a6a]",
@@ -892,6 +900,226 @@ function DocumentSidebar({
}
// =============================================================================
+// Contract header — breadcrumb + status badge + lifecycle action buttons +
+// merge mode radio. Renders above the spec editor in the document path.
+//
+// Action visibility is status-driven:
+// * draft → Lock & Start
+// * queued → Unlock (back to draft); shows "queued" pill
+// * active → Pause, Complete, Unlock; shows "active" + pulsing dot
+// * shipped → reopen via spec edit (no buttons here; backend reactivates)
+// * archived → no buttons
+//
+// Merge mode (shared / own_pr) is editable while the contract is in
+// `draft` or `queued` — once active, the queue scheduler has already
+// claimed the slot, so flipping the toggle would silently change a
+// running flow's branch target. Locked rows show the value as readonly.
+// =============================================================================
+
+interface ContractHeaderProps {
+ directive: { id: string; title: string; orchestratorTaskId: string | null };
+ doc: Contract;
+ docTitle: string;
+ /** Called with the server's response after any status / merge-mode
+ * transition so the parent can refresh the editor + sidebar. */
+ onContractChanged: (updated: Contract) => void;
+}
+
+function ContractHeader({
+ directive,
+ doc,
+ docTitle,
+ onContractChanged,
+}: ContractHeaderProps) {
+ const [busy, setBusy] = useState<null | "start" | "pause" | "complete" | "unlock" | "merge_mode">(
+ null,
+ );
+ const [error, setError] = useState<string | null>(null);
+
+ const wrap = useCallback(
+ async (tag: typeof busy, op: () => Promise<Contract>) => {
+ try {
+ setBusy(tag);
+ setError(null);
+ const updated = await op();
+ onContractChanged(updated);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Unknown error");
+ } finally {
+ setBusy(null);
+ }
+ },
+ [onContractChanged],
+ );
+
+ const onStart = useCallback(
+ () => wrap("start", () => startDirectiveContract(doc.id)),
+ [doc.id, wrap],
+ );
+ const onPause = useCallback(
+ () => wrap("pause", () => pauseDirectiveContract(doc.id)),
+ [doc.id, wrap],
+ );
+ const onComplete = useCallback(
+ () => wrap("complete", () => completeDirectiveContract(doc.id)),
+ [doc.id, wrap],
+ );
+ const onUnlock = useCallback(
+ () => wrap("unlock", () => unlockDirectiveContract(doc.id)),
+ [doc.id, wrap],
+ );
+ const onMergeMode = useCallback(
+ (mode: ContractMergeMode) =>
+ wrap("merge_mode", () => updateContract(doc.id, { mergeMode: mode })),
+ [doc.id, wrap],
+ );
+
+ const editableMergeMode = doc.status === "draft" || doc.status === "queued";
+
+ return (
+ <div className="px-6 py-3 border-b border-dashed border-[rgba(117,170,252,0.2)] flex flex-col gap-2">
+ {/* Row 1: breadcrumb + status pill + orchestrator indicator */}
+ <div className="flex items-center gap-2 text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">
+ <FileIcon />
+ <span>directives /</span>
+ <span className="text-[#9bc3ff]">
+ {directive.title.trim().length > 0
+ ? directive.title
+ : directive.id.slice(0, 8)}
+ </span>
+ <span>/</span>
+ <span className="text-white">{docTitle}</span>
+ <ContractStatusPill status={doc.status} />
+ {!!directive.orchestratorTaskId && (
+ <span className="ml-auto inline-flex items-center gap-1 text-yellow-400">
+ <span className="inline-block w-1.5 h-1.5 rounded-full bg-yellow-400 animate-pulse" />
+ orchestrator running
+ </span>
+ )}
+ </div>
+
+ {/* Row 2: action buttons (status-driven) + merge mode + error */}
+ <div className="flex items-center gap-2 text-[11px] font-mono">
+ {doc.status === "draft" && (
+ <ContractActionButton onClick={onStart} disabled={busy !== null} variant="primary">
+ {busy === "start" ? "Starting…" : "Lock & Start"}
+ </ContractActionButton>
+ )}
+ {doc.status === "queued" && (
+ <ContractActionButton onClick={onUnlock} disabled={busy !== null}>
+ {busy === "unlock" ? "Unlocking…" : "Unlock"}
+ </ContractActionButton>
+ )}
+ {doc.status === "active" && (
+ <>
+ <ContractActionButton onClick={onPause} disabled={busy !== null}>
+ {busy === "pause" ? "Pausing…" : "Pause"}
+ </ContractActionButton>
+ <ContractActionButton onClick={onComplete} disabled={busy !== null} variant="primary">
+ {busy === "complete" ? "Completing…" : "Mark complete"}
+ </ContractActionButton>
+ <ContractActionButton onClick={onUnlock} disabled={busy !== null}>
+ {busy === "unlock" ? "Unlocking…" : "Unlock"}
+ </ContractActionButton>
+ </>
+ )}
+
+ {/* Merge mode radios — visible always, editable only in draft/queued */}
+ <div className="ml-auto flex items-center gap-2 text-[#7788aa]">
+ <span className="uppercase tracking-wide">merge:</span>
+ <MergeModeRadio
+ value={doc.mergeMode}
+ onChange={onMergeMode}
+ disabled={!editableMergeMode || busy !== null}
+ />
+ </div>
+ </div>
+
+ {error && (
+ <div className="text-[10px] font-mono text-red-400">{error}</div>
+ )}
+ </div>
+ );
+}
+
+function ContractStatusPill({ status }: { status: ContractStatus }) {
+ const styles: Record<ContractStatus, { label: string; cls: string }> = {
+ draft: { label: "draft", cls: "text-[#556677]" },
+ queued: { label: "queued", cls: "text-amber-400" },
+ active: { label: "active", cls: "text-green-400" },
+ shipped: { label: "shipped", cls: "text-[#75aafc]" },
+ archived: { label: "archived", cls: "text-[#7788aa]" },
+ };
+ const s = styles[status];
+ return <span className={`ml-2 normal-case ${s.cls}`}>{s.label}</span>;
+}
+
+function ContractActionButton({
+ children,
+ onClick,
+ disabled,
+ variant,
+}: {
+ children: React.ReactNode;
+ onClick: () => void;
+ disabled?: boolean;
+ variant?: "primary";
+}) {
+ const base =
+ "px-2 py-1 border border-[rgba(117,170,252,0.3)] rounded text-[10px] uppercase tracking-wide transition-colors";
+ const colors =
+ variant === "primary"
+ ? "text-green-300 hover:bg-[rgba(120,200,140,0.1)]"
+ : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)]";
+ const dim = disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer";
+ return (
+ <button
+ type="button"
+ onClick={onClick}
+ disabled={disabled}
+ className={`${base} ${colors} ${dim}`}
+ >
+ {children}
+ </button>
+ );
+}
+
+function MergeModeRadio({
+ value,
+ onChange,
+ disabled,
+}: {
+ value: ContractMergeMode;
+ onChange: (mode: ContractMergeMode) => void;
+ disabled?: boolean;
+}) {
+ const opt = (mode: ContractMergeMode, label: string) => {
+ const selected = value === mode;
+ const cls = selected
+ ? "text-white border-[rgba(117,170,252,0.6)] bg-[rgba(117,170,252,0.1)]"
+ : "text-[#7788aa] border-transparent hover:text-[#9bc3ff]";
+ const dim = disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer";
+ return (
+ <button
+ key={mode}
+ type="button"
+ onClick={() => !disabled && !selected && onChange(mode)}
+ disabled={disabled}
+ className={`px-2 py-0.5 rounded border ${cls} ${dim} text-[10px] uppercase tracking-wide`}
+ >
+ {label}
+ </button>
+ );
+ };
+ return (
+ <div className="flex items-center gap-1">
+ {opt("shared", "shared")}
+ {opt("own_pr", "own pr")}
+ </div>
+ );
+}
+
+// =============================================================================
// Editor shell — wraps DocumentEditor and handles the "no document selected"
// and loading states. Two modes:
// 1) documentId selected → fetch the Contract and edit doc.body via
@@ -1093,35 +1321,15 @@ function EditorShell({
return (
<div className="flex-1 flex flex-col h-full overflow-hidden">
- {/* Breadcrumb — directives / <directive title> / <document title>.md */}
- <div className="px-6 py-3 border-b border-dashed border-[rgba(117,170,252,0.2)]">
- <div className="flex items-center gap-2 text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">
- <FileIcon />
- <span>directives /</span>
- <span className="text-[#9bc3ff]">
- {directive.title.trim().length > 0
- ? directive.title
- : directive.id.slice(0, 8)}
- </span>
- <span>/</span>
- <span className="text-white">{docTitle}</span>
- {doc.status === "shipped" && (
- <span className="ml-2 text-[#75aafc] normal-case">shipped</span>
- )}
- {doc.status === "archived" && (
- <span className="ml-2 text-[#7788aa] normal-case">archived</span>
- )}
- {doc.status === "draft" && (
- <span className="ml-2 text-[#556677] normal-case">draft</span>
- )}
- {!!directive.orchestratorTaskId && (
- <span className="ml-auto inline-flex items-center gap-1 text-yellow-400">
- <span className="inline-block w-1.5 h-1.5 rounded-full bg-yellow-400 animate-pulse" />
- orchestrator running
- </span>
- )}
- </div>
- </div>
+ <ContractHeader
+ directive={directive}
+ doc={doc}
+ docTitle={docTitle}
+ onContractChanged={(updated) => {
+ setDoc(updated);
+ onDocumentChanged();
+ }}
+ />
<DocumentEditor
// Keying by document id ensures the Lexical editor remounts cleanly
diff --git a/makima/migrations/20260509000000_contract_lifecycle_states.sql b/makima/migrations/20260509000000_contract_lifecycle_states.sql
new file mode 100644
index 0000000..fc9c7bf
--- /dev/null
+++ b/makima/migrations/20260509000000_contract_lifecycle_states.sql
@@ -0,0 +1,19 @@
+-- Add 'queued' to the contract status enum.
+--
+-- The unified directive workflow runs contracts sequentially in the
+-- directive's shared worktree — only one contract is `active` at a time.
+-- When a user clicks "Lock & Start" on a draft, it goes to `active` if
+-- the slot is free, otherwise it goes to `queued` and waits for the
+-- current active contract to ship/archive. The `complete_contract`
+-- handler auto-promotes the lowest-position `queued` row to `active`.
+--
+-- The constraint replacement is straightforward — drop + re-add. No
+-- existing rows can be in 'queued' yet, so the new CHECK is satisfied
+-- by every row.
+
+ALTER TABLE directive_documents
+ DROP CONSTRAINT IF EXISTS directive_documents_status_check;
+
+ALTER TABLE directive_documents
+ ADD CONSTRAINT directive_documents_status_check
+ CHECK (status IN ('draft', 'queued', 'active', 'shipped', 'archived'));
diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs
index 12d5e4d..e58f58c 100644
--- a/makima/src/db/repository.rs
+++ b/makima/src/db/repository.rs
@@ -35,6 +35,8 @@ pub enum RepositoryError {
/// The actual current version in the database
actual: i32,
},
+ /// Caller-facing precondition failure (wrong status, etc.).
+ Validation(String),
}
impl From<sqlx::Error> for RepositoryError {
@@ -54,6 +56,7 @@ impl std::fmt::Display for RepositoryError {
expected, actual
)
}
+ RepositoryError::Validation(msg) => write!(f, "Validation error: {}", msg),
}
}
}
@@ -6038,11 +6041,16 @@ pub async fn mark_directive_document_shipped(
/// 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.
+///
+/// If the archived contract was `active`, the next-up `queued` contract
+/// in the same directive auto-promotes to `active` (sequential queue).
pub async fn archive_directive_document(
pool: &PgPool,
document_id: Uuid,
) -> Result<Option<DirectiveDocument>, sqlx::Error> {
- sqlx::query_as::<_, DirectiveDocument>(
+ let mut tx = pool.begin().await?;
+
+ let archived = sqlx::query_as::<_, DirectiveDocument>(
r#"
UPDATE directive_documents
SET status = 'archived',
@@ -6054,8 +6062,270 @@ pub async fn archive_directive_document(
"#,
)
.bind(document_id)
- .fetch_optional(pool)
- .await
+ .fetch_optional(&mut *tx)
+ .await?;
+
+ if let Some(ref doc) = archived {
+ promote_next_queued_contract(&mut tx, doc.directive_id).await?;
+ }
+
+ tx.commit().await?;
+ Ok(archived)
+}
+
+// ============================================================================
+// Lifecycle transitions: start / pause / complete / unlock
+//
+// The lifecycle is `draft → queued → active → shipped → archived`. At most
+// one contract per directive sits in `active` at a time — the queue is
+// serialised because a directive owns a single shared worktree. Helpers
+// below enforce that invariant in SQL transactions.
+// ============================================================================
+
+/// Lock a draft contract and either activate it (if no sibling is active)
+/// or queue it. Returns the updated row, or `Ok(None)` if the contract
+/// doesn't exist. Errors with `RepositoryError::Validation` if the
+/// contract is in any state other than `draft`.
+pub async fn start_contract(
+ pool: &PgPool,
+ contract_id: Uuid,
+) -> Result<Option<DirectiveDocument>, RepositoryError> {
+ let mut tx = pool.begin().await?;
+
+ let current = sqlx::query_as::<_, DirectiveDocument>(
+ r#"SELECT * FROM directive_documents WHERE id = $1"#,
+ )
+ .bind(contract_id)
+ .fetch_optional(&mut *tx)
+ .await?;
+
+ let current = match current {
+ Some(c) => c,
+ None => return Ok(None),
+ };
+
+ if current.status != "draft" {
+ return Err(RepositoryError::Validation(format!(
+ "contract is in status '{}'; only 'draft' contracts can be started",
+ current.status
+ )));
+ }
+
+ // If any sibling is already active, this one queues. Otherwise it
+ // claims the active slot directly.
+ let active_count: (i64,) = sqlx::query_as(
+ r#"SELECT COUNT(*)::BIGINT FROM directive_documents
+ WHERE directive_id = $1 AND status = 'active'"#,
+ )
+ .bind(current.directive_id)
+ .fetch_one(&mut *tx)
+ .await?;
+
+ let new_status = if active_count.0 > 0 { "queued" } else { "active" };
+
+ let updated = sqlx::query_as::<_, DirectiveDocument>(
+ r#"
+ UPDATE directive_documents
+ SET status = $2,
+ version = version + 1,
+ updated_at = NOW()
+ WHERE id = $1
+ RETURNING *
+ "#,
+ )
+ .bind(contract_id)
+ .bind(new_status)
+ .fetch_optional(&mut *tx)
+ .await?;
+
+ tx.commit().await?;
+ Ok(updated)
+}
+
+/// Pause an active contract — moves it back to `queued` so the next
+/// queued sibling can pick up the active slot. The orchestrator-daemon
+/// stop is the caller's responsibility.
+pub async fn pause_contract(
+ pool: &PgPool,
+ contract_id: Uuid,
+) -> Result<Option<DirectiveDocument>, RepositoryError> {
+ let mut tx = pool.begin().await?;
+
+ let current = sqlx::query_as::<_, DirectiveDocument>(
+ r#"SELECT * FROM directive_documents WHERE id = $1"#,
+ )
+ .bind(contract_id)
+ .fetch_optional(&mut *tx)
+ .await?;
+
+ let current = match current {
+ Some(c) => c,
+ None => return Ok(None),
+ };
+
+ if current.status != "active" {
+ return Err(RepositoryError::Validation(format!(
+ "contract is in status '{}'; only 'active' contracts can be paused",
+ current.status
+ )));
+ }
+
+ let updated = sqlx::query_as::<_, DirectiveDocument>(
+ r#"
+ UPDATE directive_documents
+ SET status = 'queued',
+ version = version + 1,
+ updated_at = NOW()
+ WHERE id = $1
+ RETURNING *
+ "#,
+ )
+ .bind(contract_id)
+ .fetch_optional(&mut *tx)
+ .await?;
+
+ // The slot is free — promote the next queued contract (lowest
+ // position, excluding the one we just paused).
+ promote_next_queued_contract(&mut tx, current.directive_id).await?;
+
+ tx.commit().await?;
+ Ok(updated)
+}
+
+/// Mark an active contract as `shipped` — the work is done. Optional
+/// pr_url / pr_branch are recorded if supplied. Promotes the next
+/// queued sibling to `active`.
+pub async fn complete_contract(
+ pool: &PgPool,
+ contract_id: Uuid,
+ pr_url: Option<&str>,
+ pr_branch: Option<&str>,
+) -> Result<Option<DirectiveDocument>, RepositoryError> {
+ let mut tx = pool.begin().await?;
+
+ let current = sqlx::query_as::<_, DirectiveDocument>(
+ r#"SELECT * FROM directive_documents WHERE id = $1"#,
+ )
+ .bind(contract_id)
+ .fetch_optional(&mut *tx)
+ .await?;
+
+ let current = match current {
+ Some(c) => c,
+ None => return Ok(None),
+ };
+
+ if current.status != "active" && current.status != "queued" {
+ return Err(RepositoryError::Validation(format!(
+ "contract is in status '{}'; only 'active' or 'queued' contracts can be completed",
+ current.status
+ )));
+ }
+
+ let updated = sqlx::query_as::<_, DirectiveDocument>(
+ r#"
+ UPDATE directive_documents
+ SET status = 'shipped',
+ pr_url = COALESCE($2, pr_url),
+ pr_branch = COALESCE($3, pr_branch),
+ shipped_at = NOW(),
+ version = version + 1,
+ updated_at = NOW()
+ WHERE id = $1
+ RETURNING *
+ "#,
+ )
+ .bind(contract_id)
+ .bind(pr_url)
+ .bind(pr_branch)
+ .fetch_optional(&mut *tx)
+ .await?;
+
+ promote_next_queued_contract(&mut tx, current.directive_id).await?;
+
+ tx.commit().await?;
+ Ok(updated)
+}
+
+/// Unlock a queued or active contract back to `draft` so the spec is
+/// editable again. If the contract was active, the slot frees and the
+/// next queued sibling auto-promotes.
+pub async fn unlock_contract(
+ pool: &PgPool,
+ contract_id: Uuid,
+) -> Result<Option<DirectiveDocument>, RepositoryError> {
+ let mut tx = pool.begin().await?;
+
+ let current = sqlx::query_as::<_, DirectiveDocument>(
+ r#"SELECT * FROM directive_documents WHERE id = $1"#,
+ )
+ .bind(contract_id)
+ .fetch_optional(&mut *tx)
+ .await?;
+
+ let current = match current {
+ Some(c) => c,
+ None => return Ok(None),
+ };
+
+ if current.status != "queued" && current.status != "active" {
+ return Err(RepositoryError::Validation(format!(
+ "contract is in status '{}'; only 'queued' or 'active' contracts can be unlocked",
+ current.status
+ )));
+ }
+
+ let was_active = current.status == "active";
+
+ let updated = sqlx::query_as::<_, DirectiveDocument>(
+ r#"
+ UPDATE directive_documents
+ SET status = 'draft',
+ version = version + 1,
+ updated_at = NOW()
+ WHERE id = $1
+ RETURNING *
+ "#,
+ )
+ .bind(contract_id)
+ .fetch_optional(&mut *tx)
+ .await?;
+
+ if was_active {
+ promote_next_queued_contract(&mut tx, current.directive_id).await?;
+ }
+
+ tx.commit().await?;
+ Ok(updated)
+}
+
+/// Find the lowest-position `queued` contract under a directive and
+/// flip it to `active`. No-op when no queued contract exists.
+///
+/// Caller must hold the parent transaction so the count → promote
+/// sequence stays atomic w.r.t. other lifecycle transitions.
+async fn promote_next_queued_contract(
+ tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
+ directive_id: Uuid,
+) -> Result<(), sqlx::Error> {
+ sqlx::query(
+ r#"
+ UPDATE directive_documents
+ SET status = 'active',
+ version = version + 1,
+ updated_at = NOW()
+ WHERE id = (
+ SELECT id FROM directive_documents
+ WHERE directive_id = $1 AND status = 'queued'
+ ORDER BY position ASC, created_at ASC
+ LIMIT 1
+ )
+ "#,
+ )
+ .bind(directive_id)
+ .execute(&mut **tx)
+ .await?;
+ Ok(())
}
/// Count the number of currently-active documents under a directive.
diff --git a/makima/src/server/handlers/directive_documents.rs b/makima/src/server/handlers/directive_documents.rs
index ed38ee4..23081b5 100644
--- a/makima/src/server/handlers/directive_documents.rs
+++ b/makima/src/server/handlers/directive_documents.rs
@@ -21,7 +21,7 @@ use utoipa::ToSchema;
use uuid::Uuid;
use crate::db::models::{DirectiveStep, Task};
-use crate::db::repository;
+use crate::db::repository::{self, RepositoryError};
use crate::server::auth::Authenticated;
use crate::server::messages::ApiError;
use crate::server::state::SharedState;
@@ -60,6 +60,16 @@ pub struct ReorderDirectiveDocumentRequest {
pub position: i32,
}
+/// Body for `POST /api/v1/contracts/{document_id}/complete`. Both fields
+/// are optional — supplying them records PR provenance; leaving them off
+/// just marks the contract done.
+#[derive(Debug, Default, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CompleteContractRequest {
+ pub pr_url: Option<String>,
+ pub pr_branch: Option<String>,
+}
+
/// Body for `POST /api/v1/contracts/{document_id}/ship`.
#[derive(Debug, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
@@ -656,3 +666,213 @@ pub async fn reorder_contract(
}
}
}
+
+// =============================================================================
+// Contract lifecycle: start / pause / complete / unlock
+//
+// State machine: `draft → queued → active → shipped → archived`. The
+// repository functions enforce transition validity and handle queue
+// auto-promotion when the active slot frees. Handlers here own auth
+// and mapping to HTTP status codes.
+// =============================================================================
+
+/// Common path: load + ownership-check, then dispatch a state-transition
+/// closure. Cuts the boilerplate from start/pause/complete/unlock down
+/// to a couple of lines each.
+async fn run_contract_transition<F, Fut>(
+ pool: sqlx::PgPool,
+ owner_id: Uuid,
+ contract_id: Uuid,
+ f: F,
+) -> impl IntoResponse
+where
+ F: FnOnce(sqlx::PgPool, Uuid) -> Fut,
+ Fut: std::future::Future<
+ Output = Result<Option<crate::db::models::DirectiveDocument>, RepositoryError>,
+ >,
+{
+ match load_owned_document(&pool, owner_id, contract_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 f(pool, contract_id).await {
+ Ok(Some(doc)) => Json(doc).into_response(),
+ Ok(None) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Contract not found")),
+ )
+ .into_response(),
+ Err(RepositoryError::Validation(msg)) => (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new("VALIDATION", &msg)),
+ )
+ .into_response(),
+ Err(RepositoryError::VersionConflict { expected, actual }) => (
+ StatusCode::CONFLICT,
+ Json(ApiError::new(
+ "VERSION_CONFLICT",
+ &format!("expected version {}, actual {}", expected, actual),
+ )),
+ )
+ .into_response(),
+ Err(RepositoryError::Database(e)) => {
+ tracing::error!("Contract transition failed: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("TRANSITION_FAILED", &e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Lock & start a draft contract. If a sibling is already `active`,
+/// this contract goes to `queued`; otherwise it activates immediately.
+#[utoipa::path(
+ post,
+ path = "/api/v1/contracts/{document_id}/start",
+ params(("document_id" = Uuid, Path, description = "Contract ID")),
+ responses(
+ (status = 200, description = "Contract started", body = crate::db::models::DirectiveDocument),
+ (status = 400, description = "Invalid state transition", body = ApiError),
+ (status = 404, description = "Not found", body = ApiError),
+ ),
+ security(("bearer_auth" = []), ("api_key" = [])),
+ tag = "Directive Documents"
+)]
+pub async fn start_contract(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(document_id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(pool) = state.db_pool.clone() else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+ run_contract_transition(pool, auth.owner_id, document_id, |pool, id| async move {
+ repository::start_contract(&pool, id).await
+ })
+ .await
+ .into_response()
+}
+
+/// Pause an active contract — moves it back to `queued` and lets the
+/// next queued sibling take the active slot. The orchestrator daemon
+/// stop is the caller's responsibility.
+#[utoipa::path(
+ post,
+ path = "/api/v1/contracts/{document_id}/pause",
+ params(("document_id" = Uuid, Path, description = "Contract ID")),
+ responses(
+ (status = 200, description = "Contract paused", body = crate::db::models::DirectiveDocument),
+ (status = 400, description = "Invalid state transition", body = ApiError),
+ (status = 404, description = "Not found", body = ApiError),
+ ),
+ security(("bearer_auth" = []), ("api_key" = [])),
+ tag = "Directive Documents"
+)]
+pub async fn pause_contract(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(document_id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(pool) = state.db_pool.clone() else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+ run_contract_transition(pool, auth.owner_id, document_id, |pool, id| async move {
+ repository::pause_contract(&pool, id).await
+ })
+ .await
+ .into_response()
+}
+
+/// Mark an active contract as `shipped` (work done). PR url + branch
+/// are optional — pass them to record provenance, leave them off to
+/// just close out the contract. Auto-promotes the next queued sibling.
+#[utoipa::path(
+ post,
+ path = "/api/v1/contracts/{document_id}/complete",
+ params(("document_id" = Uuid, Path, description = "Contract ID")),
+ request_body = CompleteContractRequest,
+ responses(
+ (status = 200, description = "Contract completed", body = crate::db::models::DirectiveDocument),
+ (status = 400, description = "Invalid state transition", body = ApiError),
+ (status = 404, description = "Not found", body = ApiError),
+ ),
+ security(("bearer_auth" = []), ("api_key" = [])),
+ tag = "Directive Documents"
+)]
+pub async fn complete_contract(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(document_id): Path<Uuid>,
+ Json(req): Json<CompleteContractRequest>,
+) -> impl IntoResponse {
+ let Some(pool) = state.db_pool.clone() else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+ run_contract_transition(pool, auth.owner_id, document_id, move |pool, id| async move {
+ repository::complete_contract(&pool, id, req.pr_url.as_deref(), req.pr_branch.as_deref()).await
+ })
+ .await
+ .into_response()
+}
+
+/// Unlock a queued or active contract back to `draft` so its spec is
+/// editable again. If the contract was active, the slot frees and the
+/// next queued sibling auto-promotes.
+#[utoipa::path(
+ post,
+ path = "/api/v1/contracts/{document_id}/unlock",
+ params(("document_id" = Uuid, Path, description = "Contract ID")),
+ responses(
+ (status = 200, description = "Contract unlocked", body = crate::db::models::DirectiveDocument),
+ (status = 400, description = "Invalid state transition", body = ApiError),
+ (status = 404, description = "Not found", body = ApiError),
+ ),
+ security(("bearer_auth" = []), ("api_key" = [])),
+ tag = "Directive Documents"
+)]
+pub async fn unlock_contract(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(document_id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(pool) = state.db_pool.clone() else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+ run_contract_transition(pool, auth.owner_id, document_id, |pool, id| async move {
+ repository::unlock_contract(&pool, id).await
+ })
+ .await
+ .into_response()
+}
diff --git a/makima/src/server/handlers/files.rs b/makima/src/server/handlers/files.rs
index 05e871c..711be41 100644
--- a/makima/src/server/handlers/files.rs
+++ b/makima/src/server/handlers/files.rs
@@ -277,6 +277,11 @@ pub async fn update_file(
)
.into_response()
}
+ Err(RepositoryError::Validation(msg)) => (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new("VALIDATION", &msg)),
+ )
+ .into_response(),
}
}
diff --git a/makima/src/server/handlers/mesh.rs b/makima/src/server/handlers/mesh.rs
index 63b1827..be5387e 100644
--- a/makima/src/server/handlers/mesh.rs
+++ b/makima/src/server/handlers/mesh.rs
@@ -467,6 +467,11 @@ pub async fn update_task(
)
.into_response()
}
+ Err(RepositoryError::Validation(msg)) => (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new("VALIDATION", &msg)),
+ )
+ .into_response(),
}
}
diff --git a/makima/src/server/handlers/versions.rs b/makima/src/server/handlers/versions.rs
index 15118d6..bb1b00c 100644
--- a/makima/src/server/handlers/versions.rs
+++ b/makima/src/server/handlers/versions.rs
@@ -203,5 +203,10 @@ pub async fn restore_version(
)
.into_response()
}
+ Err(RepositoryError::Validation(msg)) => (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new("VALIDATION", &msg)),
+ )
+ .into_response(),
}
}
diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs
index 68d3dea..a3a1886 100644
--- a/makima/src/server/mod.rs
+++ b/makima/src/server/mod.rs
@@ -242,6 +242,22 @@ pub fn make_router(state: SharedState) -> Router {
post(directive_documents::reorder_contract),
)
.route(
+ "/contracts/{document_id}/start",
+ post(directive_documents::start_contract),
+ )
+ .route(
+ "/contracts/{document_id}/pause",
+ post(directive_documents::pause_contract),
+ )
+ .route(
+ "/contracts/{document_id}/complete",
+ post(directive_documents::complete_contract),
+ )
+ .route(
+ "/contracts/{document_id}/unlock",
+ post(directive_documents::unlock_contract),
+ )
+ .route(
"/contracts/{document_id}/tasks",
get(directive_documents::list_document_tasks),
)
diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs
index 7ddaf1b..184d12a 100644
--- a/makima/src/server/openapi.rs
+++ b/makima/src/server/openapi.rs
@@ -124,6 +124,10 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
directive_documents::ship_document,
directive_documents::archive_document,
directive_documents::reorder_contract,
+ directive_documents::start_contract,
+ directive_documents::pause_contract,
+ directive_documents::complete_contract,
+ directive_documents::unlock_contract,
directive_documents::list_document_tasks,
// Order endpoints
orders::list_orders,
@@ -233,6 +237,7 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
directive_documents::UpdateDirectiveDocumentRequest,
directive_documents::ShipDirectiveDocumentRequest,
directive_documents::ReorderDirectiveDocumentRequest,
+ directive_documents::CompleteContractRequest,
directive_documents::DocumentTasksResponse,
// Order schemas
Order,