From 6690b714c64aaef5781bc0aac41b777ab72e9070 Mon Sep 17 00:00:00 2001
From: soryu <soryu@soryu.co>
Date: Fri, 8 May 2026 12:12:51 +0100
Subject: feat(contracts): lifecycle — Lock/Start/Pause/Complete/Unlock + queue
 scheduler (#129)
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) <noreply@anthropic.com>
---
 makima/frontend/src/routes/document-directives.tsx | 270 ++++++++++++++++++---
 1 file changed, 239 insertions(+), 31 deletions(-)

(limited to 'makima/frontend/src/routes/document-directives.tsx')

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]",
@@ -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 | "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:
@@ -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
-- 
cgit v1.2.3

