diff options
| author | soryu <soryu@soryu.co> | 2026-05-08 11:29:20 +0100 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-05-08 11:29:20 +0100 |
| commit | b5811ad26a94f48ddab45251e44861f5595a7b95 (patch) | |
| tree | aab8268536bd63d1751d923562dfb214946652e7 /makima/frontend | |
| parent | 928598b1b8399a95918dc1b315274a9d175eb8d9 (diff) | |
| download | soryu-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>
Diffstat (limited to 'makima/frontend')
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 127 | ||||
| -rw-r--r-- | makima/frontend/src/routes/document-directives.tsx | 56 |
2 files changed, 104 insertions, 79 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: "", }); |
