From bd1f21fe387ec57da76c300cbb1ebc0db48553a7 Mon Sep 17 00:00:00 2001 From: soryu Date: Fri, 8 May 2026 12:12:21 +0100 Subject: feat(contracts): lifecycle — Lock/Start/Pause/Complete/Unlock + queue scheduler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the contract lifecycle layer on top of the unified-contracts backbone (#128). State machine: draft → queued → active → shipped → archived At most one contract per directive sits in `active` at any time — the queue is serialised because each directive owns a single shared worktree. Repository helpers handle the transition checks AND auto-promote the next-up `queued` contract whenever the active slot frees (pause / complete / unlock-from-active / archive-from-active). Endpoints (all under /api/v1/contracts/{id}): POST /start draft → queued | active (depending on slot) POST /pause active → queued; promotes next queued POST /complete active → shipped; optional pr_url + pr_branch POST /unlock queued | active → draft; promotes if was active Frontend wiring: * `DirectiveContractStatus` now includes `queued`. * Migration adds `queued` to the CHECK constraint on directive_documents.status. * `ContractHeader` component renders breadcrumb + status pill + status-driven action buttons + a merge-mode (shared / own_pr) radio. Merge mode is editable only while draft / queued so a running flow's branch target can't change mid-stream. * RepositoryError gains a `Validation(String)` arm; the three existing exhaustive matches (files, mesh, versions) get a 400 BAD_REQUEST response for it. Drag-to-reorder UI deferred to a small follow-up — the backend endpoint already exists from the backbone PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- makima/frontend/src/lib/api.ts | 54 ++++- makima/frontend/src/routes/document-directives.tsx | 270 ++++++++++++++++++--- 2 files changed, 292 insertions(+), 32 deletions(-) (limited to 'makima/frontend') 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 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 { + 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 { + return postContractAction(contractId, "start"); +} + +export async function pauseDirectiveContract(contractId: string): Promise { + return postContractAction(contractId, "pause"); +} + +export async function completeDirectiveContract( + contractId: string, + opts: { prUrl?: string; prBranch?: string } = {}, +): Promise { + return postContractAction(contractId, "complete", opts); +} + +export async function unlockDirectiveContract(contractId: string): Promise { + 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 = { 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 = { draft: "bg-[#556677]", + queued: "bg-amber-400", active: "bg-green-400", shipped: "bg-[#75aafc]", archived: "bg-[#3a4a6a]", @@ -891,6 +899,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, + ); + const [error, setError] = useState(null); + + const wrap = useCallback( + async (tag: typeof busy, op: () => Promise) => { + 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 ( +
+ {/* Row 1: breadcrumb + status pill + orchestrator indicator */} +
+ + directives / + + {directive.title.trim().length > 0 + ? directive.title + : directive.id.slice(0, 8)} + + / + {docTitle} + + {!!directive.orchestratorTaskId && ( + + + orchestrator running + + )} +
+ + {/* Row 2: action buttons (status-driven) + merge mode + error */} +
+ {doc.status === "draft" && ( + + {busy === "start" ? "Starting…" : "Lock & Start"} + + )} + {doc.status === "queued" && ( + + {busy === "unlock" ? "Unlocking…" : "Unlock"} + + )} + {doc.status === "active" && ( + <> + + {busy === "pause" ? "Pausing…" : "Pause"} + + + {busy === "complete" ? "Completing…" : "Mark complete"} + + + {busy === "unlock" ? "Unlocking…" : "Unlock"} + + + )} + + {/* Merge mode radios — visible always, editable only in draft/queued */} +
+ merge: + +
+
+ + {error && ( +
{error}
+ )} +
+ ); +} + +function ContractStatusPill({ status }: { status: ContractStatus }) { + const styles: Record = { + 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 {s.label}; +} + +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 ( + + ); +} + +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 ( + + ); + }; + return ( +
+ {opt("shared", "shared")} + {opt("own_pr", "own pr")} +
+ ); +} + // ============================================================================= // Editor shell — wraps DocumentEditor and handles the "no document selected" // and loading states. Two modes: @@ -1093,35 +1321,15 @@ function EditorShell({ return (
- {/* Breadcrumb — directives / / .md */} -
-
- - directives / - - {directive.title.trim().length > 0 - ? directive.title - : directive.id.slice(0, 8)} - - / - {docTitle} - {doc.status === "shipped" && ( - shipped - )} - {doc.status === "archived" && ( - archived - )} - {doc.status === "draft" && ( - draft - )} - {!!directive.orchestratorTaskId && ( - - - orchestrator running - - )} -
-
+ { + setDoc(updated); + onDocumentChanged(); + }} + />