summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-05-08 11:29:20 +0100
committersoryu <soryu@soryu.co>2026-05-08 11:29:20 +0100
commitb5811ad26a94f48ddab45251e44861f5595a7b95 (patch)
treeaab8268536bd63d1751d923562dfb214946652e7
parent928598b1b8399a95918dc1b315274a9d175eb8d9 (diff)
downloadsoryu-unified-contracts-backbone.tar.gz
soryu-unified-contracts-backbone.zip
feat(directives): unified contracts surface — backboneunified-contracts-backbone
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>
-rw-r--r--makima/frontend/src/lib/api.ts127
-rw-r--r--makima/frontend/src/routes/document-directives.tsx56
-rw-r--r--makima/migrations/20260508000000_add_contract_sequencing_columns.sql48
-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
8 files changed, 396 insertions, 108 deletions
diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts
index 80da511..17cf1d0 100644
--- a/makima/frontend/src/lib/api.ts
+++ b/makima/frontend/src/lib/api.ts
@@ -3583,116 +3583,141 @@ export async function pickUpOrders(directiveId: string): Promise<PickUpOrdersRes
}
// =============================================================================
-// Directive Documents API
+// Directive Contracts API
//
-// A directive_document is one of N markdown documents owned by a directive.
-// Each has its own lifecycle (draft → active → shipped → archived) and may be
-// attached to a PR. The frontend never calls the /ship endpoint — the backend
-// invokes that itself when PRs are raised. Editing a shipped document
-// auto-reactivates it on the backend (see PATCH handler).
+// A "directive contract" (DB table: directive_documents) is one of N spec
+// documents owned by a directive. Each has its own lifecycle
+// (draft → active → shipped → archived) and may be attached to a PR.
+// Contracts run sequentially in the directive's shared worktree (or, when
+// mergeMode = 'own_pr', on a contract-specific branch).
+//
+// Naming note: types are `DirectiveContract*` (rather than `Contract*`)
+// because the legacy contracts system still defines a `Contract` type at
+// the top of this file. Once Phase 5 drops legacy contracts, we'll rename
+// to plain `Contract`. URLs and the user-facing UI already say "contract".
// =============================================================================
-export type DirectiveDocumentStatus = "draft" | "active" | "shipped" | "archived";
+export type DirectiveContractStatus = "draft" | "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
+ * branch + PR carved out at activation time. */
+export type DirectiveContractMergeMode = "shared" | "own_pr";
-export interface DirectiveDocument {
+export interface DirectiveContract {
id: string;
directiveId: string;
title: string;
body: string;
- status: DirectiveDocumentStatus;
+ status: DirectiveContractStatus;
prUrl: string | null;
prBranch: string | null;
shippedAt: string | null;
archivedAt: string | null;
version: number;
+ /** 0-indexed queue position within the parent directive. */
+ position: number;
+ mergeMode: DirectiveContractMergeMode;
createdAt: string;
updatedAt: string;
}
-export interface CreateDirectiveDocumentRequest {
+export interface CreateDirectiveContractRequest {
title?: string;
body?: string;
}
-export interface UpdateDirectiveDocumentRequest {
+export interface UpdateDirectiveContractRequest {
title?: string;
body?: string;
+ mergeMode?: DirectiveContractMergeMode;
}
-export async function listDirectiveDocuments(directiveId: string): Promise<DirectiveDocument[]> {
- const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/documents`);
- if (!res.ok) throw new Error(`Failed to list directive documents: ${res.statusText}`);
+export async function listDirectiveContracts(
+ directiveId: string,
+): Promise<DirectiveContract[]> {
+ const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/contracts`);
+ if (!res.ok) throw new Error(`Failed to list contracts: ${res.statusText}`);
return res.json();
}
-export async function createDirectiveDocument(
+export async function createDirectiveContract(
directiveId: string,
- req: CreateDirectiveDocumentRequest = {},
-): Promise<DirectiveDocument> {
- const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/documents`, {
+ req: CreateDirectiveContractRequest = {},
+): Promise<DirectiveContract> {
+ const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/contracts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req),
});
- if (!res.ok) throw new Error(`Failed to create directive document: ${res.statusText}`);
+ if (!res.ok) throw new Error(`Failed to create contract: ${res.statusText}`);
return res.json();
}
-export async function getDirectiveDocument(documentId: string): Promise<DirectiveDocument> {
- const res = await authFetch(`${API_BASE}/api/v1/directive-documents/${documentId}`);
- if (!res.ok) throw new Error(`Failed to get directive document: ${res.statusText}`);
+export async function getDirectiveContract(contractId: string): Promise<DirectiveContract> {
+ const res = await authFetch(`${API_BASE}/api/v1/contracts/${contractId}`);
+ if (!res.ok) throw new Error(`Failed to get contract: ${res.statusText}`);
return res.json();
}
/**
- * Update a directive document's title and/or body. The backend auto-reactivates
- * a shipped document when its body changes (re-stamps status to `active`),
- * which is why we don't expose a separate "reactivate" call.
+ * Update a contract's title, body, and/or merge mode. Backend auto-
+ * reactivates a shipped contract when its body changes (status flips
+ * back to `active`), so there's no separate reactivate call.
*/
-export async function updateDirectiveDocument(
- documentId: string,
- req: UpdateDirectiveDocumentRequest,
-): Promise<DirectiveDocument> {
- const res = await authFetch(`${API_BASE}/api/v1/directive-documents/${documentId}`, {
+export async function updateDirectiveContract(
+ contractId: string,
+ req: UpdateDirectiveContractRequest,
+): Promise<DirectiveContract> {
+ const res = await authFetch(`${API_BASE}/api/v1/contracts/${contractId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req),
});
- if (!res.ok) throw new Error(`Failed to update directive document: ${res.statusText}`);
+ if (!res.ok) throw new Error(`Failed to update contract: ${res.statusText}`);
+ return res.json();
+}
+
+export async function archiveDirectiveContract(contractId: string): Promise<DirectiveContract> {
+ const res = await authFetch(`${API_BASE}/api/v1/contracts/${contractId}/archive`, {
+ method: "POST",
+ });
+ if (!res.ok) throw new Error(`Failed to archive contract: ${res.statusText}`);
return res.json();
}
-export async function archiveDirectiveDocument(documentId: string): Promise<DirectiveDocument> {
- const res = await authFetch(`${API_BASE}/api/v1/directive-documents/${documentId}/archive`, {
+/** Move a contract to a new 0-indexed queue position inside its directive. */
+export async function reorderDirectiveContract(
+ contractId: string,
+ position: number,
+): Promise<DirectiveContract> {
+ const res = await authFetch(`${API_BASE}/api/v1/contracts/${contractId}/reorder`, {
method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ position }),
});
- if (!res.ok) throw new Error(`Failed to archive directive document: ${res.statusText}`);
+ if (!res.ok) throw new Error(`Failed to reorder contract: ${res.statusText}`);
return res.json();
}
-/** Steps and tasks attached to a single directive document. Drives the
- * per-document `tasks/` subfolder in the sidebar — when the document
- * ships, its tasks visually move with it under shipped/. */
-export interface DocumentTasksResponse {
+/** 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. */
+export interface DirectiveContractTasksResponse {
steps: DirectiveStep[];
tasks: Task[];
}
/**
- * List the steps and ephemeral tasks attached to a specific directive
- * document. Used by the sidebar to render a `tasks/` subfolder beside each
- * document — including shipped documents, whose tasks remain attached so
- * they continue to render under shipped/ alongside the document.
+ * List the steps and ephemeral tasks attached to a specific contract.
+ * Used by the sidebar to render a `tasks/` subfolder beside each
+ * contract — including shipped ones, whose tasks remain attached.
*/
-export async function listDirectiveDocumentTasks(
- documentId: string,
-): Promise<DocumentTasksResponse> {
- const res = await authFetch(
- `${API_BASE}/api/v1/directive-documents/${documentId}/tasks`,
- );
- if (!res.ok) {
- throw new Error(`Failed to list directive document tasks: ${res.statusText}`);
- }
+export async function listDirectiveContractTasks(
+ contractId: string,
+): Promise<DirectiveContractTasksResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/contracts/${contractId}/tasks`);
+ if (!res.ok) throw new Error(`Failed to list contract tasks: ${res.statusText}`);
return res.json();
}
diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx
index 1714aed..d589dac 100644
--- a/makima/frontend/src/routes/document-directives.tsx
+++ b/makima/frontend/src/routes/document-directives.tsx
@@ -7,16 +7,16 @@ import { DocumentEditor } from "../components/directives/DocumentEditor";
import {
type DirectiveSummary,
type DirectiveStatus,
- type DirectiveDocument,
- type DirectiveDocumentStatus,
+ type DirectiveContract as Contract,
+ type DirectiveContractStatus as ContractStatus,
type DirectiveStep,
type Task,
- type DocumentTasksResponse,
- listDirectiveDocuments,
- createDirectiveDocument,
- getDirectiveDocument,
- updateDirectiveDocument,
- listDirectiveDocumentTasks,
+ type DirectiveContractTasksResponse as ContractTasksResponse,
+ listDirectiveContracts as listContracts,
+ createDirectiveContract as createContract,
+ getDirectiveContract as getContract,
+ updateDirectiveContract as updateContract,
+ listDirectiveContractTasks as listContractTasks,
createDirectiveTask,
startDirective,
pauseDirective,
@@ -43,7 +43,7 @@ const STATUS_DOT: Record<DirectiveStatus, string> = {
// Per-document status palette. Active/draft documents use the same bright
// green-ish accent as a running directive; shipped/archived use a muted blue.
-const DOC_STATUS_DOT: Record<DirectiveDocumentStatus, string> = {
+const DOC_STATUS_DOT: Record<ContractStatus, string> = {
draft: "bg-[#556677]",
active: "bg-green-400",
shipped: "bg-[#75aafc]",
@@ -66,7 +66,7 @@ const DOC_STATUS_DOT: Record<DirectiveDocumentStatus, string> = {
// rather than just an id slice). Accepts either a DirectiveSummary or a full
// DirectiveWithSteps — only `title` is read.
function fileLabel(
- doc: DirectiveDocument,
+ doc: Contract,
directive: { title: string },
): string {
const docTitle = doc.title.trim();
@@ -172,7 +172,7 @@ interface DirectiveFolderProps {
/** Called when the user clicks the folder header itself (after toggle). */
onHeaderClick: () => void;
selection: SidebarSelection | null;
- onSelectDocument: (directiveId: string, doc: DirectiveDocument) => void;
+ onSelectDocument: (directiveId: string, doc: Contract) => void;
onCreateDocument: (directive: DirectiveSummary) => Promise<void>;
/** Open the inline "+ New ephemeral task" form for this directive. */
onCreateEphemeralTask: (directive: DirectiveSummary) => void;
@@ -210,7 +210,7 @@ function DirectiveFolder({
// Documents fetched lazily on open. We deliberately scope the fetch to the
// open-state so closed folders don't pay the network cost on initial render.
- const [docs, setDocs] = useState<DirectiveDocument[] | null>(null);
+ const [docs, setDocs] = useState<Contract[] | null>(null);
const [docsLoading, setDocsLoading] = useState(false);
const [docsError, setDocsError] = useState<string | null>(null);
@@ -224,7 +224,7 @@ function DirectiveFolder({
setDocsLoading(true);
setDocsError(null);
try {
- const list = await listDirectiveDocuments(directive.id);
+ const list = await listContracts(directive.id);
setDocs(list);
} catch (e) {
setDocsError(e instanceof Error ? e.message : "Failed to load documents");
@@ -242,8 +242,8 @@ function DirectiveFolder({
// Split the documents into the two visual groups. Memoised so we don't
// recompute on every render.
const { activeDocs, shippedDocs } = useMemo(() => {
- const active: DirectiveDocument[] = [];
- const shipped: DirectiveDocument[] = [];
+ const active: Contract[] = [];
+ const shipped: Contract[] = [];
for (const d of docs ?? []) {
if (d.status === "shipped" || d.status === "archived") {
shipped.push(d);
@@ -447,7 +447,7 @@ function DirectiveFolder({
// =============================================================================
interface DocumentRowProps {
- doc: DirectiveDocument;
+ doc: Contract;
directive: DirectiveSummary;
selected: boolean;
onSelect: () => void;
@@ -547,7 +547,7 @@ function DocumentTasksFolder({
onSelectTask,
}: DocumentTasksFolderProps) {
const [open, setOpen] = useState(defaultOpen);
- const [data, setData] = useState<DocumentTasksResponse | null>(null);
+ const [data, setData] = useState<ContractTasksResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -561,7 +561,7 @@ function DocumentTasksFolder({
setLoading(true);
setError(null);
try {
- const res = await listDirectiveDocumentTasks(documentId);
+ const res = await listContractTasks(documentId);
setData(res);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load tasks");
@@ -770,7 +770,7 @@ interface SidebarProps {
directives: DirectiveSummary[];
loading: boolean;
selection: SidebarSelection | null;
- onSelectDocument: (directiveId: string, doc: DirectiveDocument) => void;
+ onSelectDocument: (directiveId: string, doc: Contract) => void;
onSelectDirective: (directiveId: string) => void;
onCreateDocument: (directive: DirectiveSummary) => Promise<void>;
onCreateContract: () => void;
@@ -894,8 +894,8 @@ function DocumentSidebar({
// =============================================================================
// Editor shell — wraps DocumentEditor and handles the "no document selected"
// and loading states. Two modes:
-// 1) documentId selected → fetch the DirectiveDocument and edit doc.body via
-// updateDirectiveDocument (the call that auto-reactivates a shipped doc).
+// 1) documentId selected → fetch the Contract and edit doc.body via
+// updateContract (the call that auto-reactivates a shipped doc).
// 2) no documentId (legacy fallback, kept for the "select a directive but
// not a document" transitional case) → edit directive.goal as before.
// =============================================================================
@@ -918,7 +918,7 @@ function EditorShell({
const documentId = selection?.documentId ?? null;
// We deliberately don't pull `updateGoal` here — in the multi-document
- // world, edits flow through updateDirectiveDocument (which auto-reactivates
+ // world, edits flow through updateContract (which auto-reactivates
// a shipped doc when its body changes). The legacy directive.goal is
// unused on this surface.
const {
@@ -932,7 +932,7 @@ function EditorShell({
// Document fetch — only when documentId is selected. Refetched whenever the
// id changes; not polled (the document stream is too low-traffic to warrant
// background refresh in this iteration).
- const [doc, setDoc] = useState<DirectiveDocument | null>(null);
+ const [doc, setDoc] = useState<Contract | null>(null);
const [docLoading, setDocLoading] = useState(false);
const [docError, setDocError] = useState<string | null>(null);
@@ -946,7 +946,7 @@ function EditorShell({
let cancelled = false;
setDocLoading(true);
setDocError(null);
- getDirectiveDocument(documentId)
+ getContract(documentId)
.then((d) => {
if (cancelled) return;
setDoc(d);
@@ -970,7 +970,7 @@ function EditorShell({
const onUpdateDocumentBody = useCallback(
async (body: string) => {
if (!documentId) return;
- const updated = await updateDirectiveDocument(documentId, { body });
+ const updated = await updateContract(documentId, { body });
setDoc(updated);
// Tell the sidebar to refetch the directive's document list so the
// status chip flips from `shipped` back to `active` (and any title
@@ -1230,7 +1230,7 @@ export default function DocumentDirectivesPage() {
lastResolvedRef.current = routeDirectiveId;
let cancelled = false;
- listDirectiveDocuments(routeDirectiveId)
+ listContracts(routeDirectiveId)
.then((list) => {
if (cancelled) return;
// Prefer the first 'active' doc; fall back to the first 'draft'.
@@ -1259,7 +1259,7 @@ export default function DocumentDirectivesPage() {
}, [routeDirectiveId, selection?.documentId, selection?.taskId, setSearchParams]);
const handleSelectDocument = useCallback(
- (directiveId: string, doc: DirectiveDocument) => {
+ (directiveId: string, doc: Contract) => {
navigate(`/directives/${directiveId}?document=${doc.id}`);
},
[navigate],
@@ -1278,7 +1278,7 @@ export default function DocumentDirectivesPage() {
const handleCreateDocument = useCallback(
async (directive: DirectiveSummary) => {
- const created = await createDirectiveDocument(directive.id, {
+ const created = await createContract(directive.id, {
title: "",
body: "",
});
diff --git a/makima/migrations/20260508000000_add_contract_sequencing_columns.sql b/makima/migrations/20260508000000_add_contract_sequencing_columns.sql
new file mode 100644
index 0000000..a259285
--- /dev/null
+++ b/makima/migrations/20260508000000_add_contract_sequencing_columns.sql
@@ -0,0 +1,48 @@
+-- Add the contract sequencing columns to directive_documents.
+--
+-- Background: the directive_documents table was always intended to BE the
+-- user-facing "contract" concept (see migration 20260502000000:
+-- "the user calls these 'directive contracts'"). The unified directive
+-- workflow now formalises that — a directive holds a sequence of
+-- contracts; each contract is a spec body whose execution is driven by
+-- tasks in the directive's shared worktree.
+--
+-- This migration is purely additive. It does NOT rename the table —
+-- there is a legacy `contracts` table from the old contracts system
+-- (still present, archived in 20260501000000), and renaming
+-- `directive_documents → contracts` would collide with it. The actual
+-- table rename lands once the legacy table is dropped (Phase 5).
+--
+-- For now: the Rust struct, API endpoints, and frontend types are all
+-- renamed to "Contract" so the user-visible surface matches their model.
+-- The DB column names (`directive_documents`, `directive_document_id`)
+-- stay put.
+--
+-- Two new columns:
+-- * position — integer ordering inside the directive (queue position).
+-- Backfilled by created_at so existing data keeps the
+-- visual order users see today.
+-- * merge_mode — 'shared' (commits go to the directive's branch) or
+-- 'own_pr' (each contract gets its own branch + PR).
+-- Default 'shared'; per-contract toggle in the UI lifts
+-- to 'own_pr'.
+
+ALTER TABLE directive_documents
+ ADD COLUMN position INT NOT NULL DEFAULT 0;
+
+WITH ranked AS (
+ SELECT id,
+ ROW_NUMBER() OVER (
+ PARTITION BY directive_id
+ ORDER BY created_at ASC, id ASC
+ ) - 1 AS rn
+ FROM directive_documents
+)
+UPDATE directive_documents d
+ SET position = r.rn
+ FROM ranked r
+ WHERE d.id = r.id;
+
+ALTER TABLE directive_documents
+ ADD COLUMN merge_mode VARCHAR(16) NOT NULL DEFAULT 'shared'
+ CHECK (merge_mode IN ('shared', 'own_pr'));
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,