diff options
| author | soryu <soryu@soryu.co> | 2026-02-16 17:59:38 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-02-16 17:59:38 +0000 |
| commit | b3de779d87450033f1e0361144c621a1d5f1dbf8 (patch) | |
| tree | 7cb84c2f953bf86f1dd3ec8ff305d70810ac55de /makima | |
| parent | 7d2079d7c13804766405af8044574bfc93a86897 (diff) | |
| download | soryu-b3de779d87450033f1e0361144c621a1d5f1dbf8.tar.gz soryu-b3de779d87450033f1e0361144c621a1d5f1dbf8.zip | |
Fix contracts page overflow, remove contract link from orders, add directive name (#65)
* feat: soryu-co/soryu - makima: Add frontend pick-up-orders button and API integration
* WIP: heartbeat checkpoint
* feat: soryu-co/soryu - makima: Remove contract link from orders and add directive name to order metadata (frontend)
* fix: contracts page overflow - use contained scrolling layout
Changed the contracts page to use contained scrolling matching the
orders/directives pages, preventing the page from growing beyond
viewport height.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: resolve completion_task_id FK violation and duplicate button
The completion_task_id column has an FK to tasks(id), but
claim_directive_for_completion was being called with a placeholder UUID
that did not exist in the tasks table, causing FK constraint violations.
Fix: Create the task FIRST via create_task_for_owner, then use the real
task.id when calling claim_directive_for_completion. Applied in all three
locations: phase_completion Part 1 (idle directives), Part 3 (verification
tasks), and trigger_completion_task (manual PR creation).
Also removes a duplicate "Pick Up Orders" button in DirectiveDetail.tsx.
* fix: restore Order type changes lost during rebase conflict resolution
Re-apply changes from the orders-refactor commit that were dropped when
resolving rebase conflicts with --ours:
- Replace contractId with directiveName in Order interface
- Make directiveId required in CreateOrderRequest
- Remove contractId from UpdateOrderRequest
- Change listOrders parameter from contractId to search
- Remove linkOrderToContract function
- Simplify convertOrderToStep to single argument
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'makima')
| -rw-r--r-- | makima/frontend/src/components/orders/OrderDetail.tsx | 89 | ||||
| -rw-r--r-- | makima/frontend/src/components/orders/OrderList.tsx | 8 | ||||
| -rw-r--r-- | makima/frontend/src/hooks/useOrders.ts | 14 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 24 | ||||
| -rw-r--r-- | makima/frontend/src/routes/contracts.tsx | 23 | ||||
| -rw-r--r-- | makima/frontend/src/routes/orders.tsx | 34 | ||||
| -rw-r--r-- | makima/migrations/20260216100000_orders_remove_contract_add_directive_name.sql | 32 | ||||
| -rw-r--r-- | makima/src/db/models.rs | 30 | ||||
| -rw-r--r-- | makima/src/db/repository.rs | 60 | ||||
| -rw-r--r-- | makima/src/orchestration/directive.rs | 94 | ||||
| -rw-r--r-- | makima/src/server/handlers/orders.rs | 85 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 1 | ||||
| -rw-r--r-- | makima/src/server/openapi.rs | 7 |
13 files changed, 185 insertions, 316 deletions
diff --git a/makima/frontend/src/components/orders/OrderDetail.tsx b/makima/frontend/src/components/orders/OrderDetail.tsx index 7f8a95d..9d4c00c 100644 --- a/makima/frontend/src/components/orders/OrderDetail.tsx +++ b/makima/frontend/src/components/orders/OrderDetail.tsx @@ -39,8 +39,7 @@ interface OrderDetailProps { onUpdate: (req: UpdateOrderRequest) => Promise<void>; onDelete: () => void; onLinkDirective: (directiveId: string) => Promise<void>; - onLinkContract: (contractId: string) => Promise<void>; - onConvertToStep: (directiveId: string) => Promise<void>; + onConvertToStep: () => Promise<void>; onRefresh: () => void; } @@ -50,7 +49,6 @@ export function OrderDetail({ onUpdate, onDelete, onLinkDirective, - onLinkContract, onConvertToStep, onRefresh, }: OrderDetailProps) { @@ -61,9 +59,6 @@ export function OrderDetail({ const [editingLabels, setEditingLabels] = useState(false); const [labelsText, setLabelsText] = useState(order.labels.join(", ")); const [showLinkDirective, setShowLinkDirective] = useState(false); - const [showLinkContract, setShowLinkContract] = useState(false); - const [contractIdInput, setContractIdInput] = useState(""); - const [showConvertToStep, setShowConvertToStep] = useState(false); const badge = STATUS_BADGE[order.status] || STATUS_BADGE.open; const currentPriority = PRIORITY_OPTIONS.find((p) => p.value === order.priority) || PRIORITY_OPTIONS[4]; @@ -110,17 +105,6 @@ export function OrderDetail({ setShowLinkDirective(false); }; - const handleLinkContract = async () => { - if (!contractIdInput.trim()) return; - await onLinkContract(contractIdInput.trim()); - setContractIdInput(""); - setShowLinkContract(false); - }; - - const handleConvertToStep = async (directiveId: string) => { - await onConvertToStep(directiveId); - setShowConvertToStep(false); - }; return ( <div className="flex flex-col h-full overflow-y-auto"> @@ -196,12 +180,9 @@ export function OrderDetail({ {/* Linked entities */} {order.directiveId && ( <div className="text-[10px] font-mono text-[#556677] mb-1 truncate"> - Directive: <a href={`/directives/${order.directiveId}`} className="text-[#75aafc] hover:text-white underline">{order.directiveId.slice(0, 8)}...</a> - </div> - )} - {order.contractId && ( - <div className="text-[10px] font-mono text-[#556677] mb-1 truncate"> - Contract: <a href={`/contracts/${order.contractId}`} className="text-[#75aafc] hover:text-white underline">{order.contractId.slice(0, 8)}...</a> + Directive: <a href={`/directives/${order.directiveId}`} className="text-[#75aafc] hover:text-white underline"> + {order.directiveName || order.directiveId.slice(0, 8) + "..."} + </a> </div> )} {order.directiveStepId && ( @@ -453,67 +434,15 @@ export function OrderDetail({ )} </div> - {/* Link to Contract */} - <div> + {/* Convert to Directive Step */} + {!order.directiveStepId && order.directiveId && ( <button type="button" - onClick={() => setShowLinkContract(!showLinkContract)} - className="text-[10px] font-mono text-[#75aafc] hover:text-white border border-[rgba(117,170,252,0.3)] rounded px-2 py-1 w-full text-left" + onClick={() => onConvertToStep()} + className="text-[10px] font-mono text-yellow-400 hover:text-yellow-300 border border-yellow-800 rounded px-2 py-1 w-full text-left" > - Link to Contract + Convert to Directive Step </button> - {showLinkContract && ( - <div className="mt-1 flex gap-1.5"> - <input - value={contractIdInput} - onChange={(e) => setContractIdInput(e.target.value)} - placeholder="Contract ID..." - className="flex-1 bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1 text-[10px] font-mono text-white" - autoFocus - /> - <button - type="button" - onClick={handleLinkContract} - disabled={!contractIdInput.trim()} - className="text-[10px] font-mono text-emerald-400 hover:text-emerald-300 border border-emerald-800 rounded px-2 py-1 disabled:opacity-50" - > - Link - </button> - </div> - )} - </div> - - {/* Convert to Directive Step */} - {!order.directiveStepId && ( - <div> - <button - type="button" - onClick={() => setShowConvertToStep(!showConvertToStep)} - className="text-[10px] font-mono text-yellow-400 hover:text-yellow-300 border border-yellow-800 rounded px-2 py-1 w-full text-left" - > - Convert to Directive Step - </button> - {showConvertToStep && ( - <div className="mt-1 border border-[rgba(117,170,252,0.2)] bg-[#0a1525] max-h-32 overflow-y-auto rounded"> - {directives.length === 0 ? ( - <div className="px-3 py-2 text-[10px] font-mono text-[#556677]"> - No directives available - </div> - ) : ( - directives.map((d) => ( - <button - key={d.id} - type="button" - onClick={() => handleConvertToStep(d.id)} - className="w-full text-left px-3 py-1.5 text-[10px] font-mono text-yellow-400 hover:bg-[rgba(117,170,252,0.1)] border-b border-[rgba(117,170,252,0.1)] last:border-b-0" - > - {d.title} - </button> - )) - )} - </div> - )} - </div> )} </div> </div> diff --git a/makima/frontend/src/components/orders/OrderList.tsx b/makima/frontend/src/components/orders/OrderList.tsx index 76ac7a7..1d279f7 100644 --- a/makima/frontend/src/components/orders/OrderList.tsx +++ b/makima/frontend/src/components/orders/OrderList.tsx @@ -57,7 +57,8 @@ export function OrderList({ (o) => o.title.toLowerCase().includes(q) || (o.description && o.description.toLowerCase().includes(q)) || - o.labels.some((l) => l.toLowerCase().includes(q)), + o.labels.some((l) => l.toLowerCase().includes(q)) || + (o.directiveName && o.directiveName.toLowerCase().includes(q)), ); }, [orders, search]); @@ -158,6 +159,11 @@ export function OrderList({ /> <span className="text-[12px] font-mono text-white truncate flex-1"> {o.title} + {o.directiveName && ( + <span className="text-[9px] font-mono text-[#556677] truncate block"> + {o.directiveName} + </span> + )} </span> </div> <div className="flex items-center gap-1.5 pl-4"> diff --git a/makima/frontend/src/hooks/useOrders.ts b/makima/frontend/src/hooks/useOrders.ts index 2dd20bb..9380080 100644 --- a/makima/frontend/src/hooks/useOrders.ts +++ b/makima/frontend/src/hooks/useOrders.ts @@ -12,7 +12,6 @@ import { updateOrder, deleteOrder, linkOrderToDirective, - linkOrderToContract, convertOrderToStep, } from "../lib/api"; @@ -101,16 +100,9 @@ export function useOrder(id: string | undefined) { return o; }, [id]); - const linkContract = useCallback(async (contractId: string) => { + const convertToStep = useCallback(async () => { if (!id) return; - const o = await linkOrderToContract(id, contractId); - setOrder(o); - return o; - }, [id]); - - const convertToStep = useCallback(async (directiveId: string) => { - if (!id) return; - const step = await convertOrderToStep(id, directiveId); + const step = await convertOrderToStep(id); await refresh(); return step; }, [id, refresh]); @@ -118,6 +110,6 @@ export function useOrder(id: string | undefined) { return { order, loading, error, refresh, update, remove, - linkDirective, linkContract, convertToStep, + linkDirective, convertToStep, }; } diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index a496412..17bc20f 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -3295,7 +3295,7 @@ export interface Order { labels: string[]; directiveId: string | null; directiveStepId: string | null; - contractId: string | null; + directiveName: string | null; repositoryUrl: string | null; createdAt: string; updatedAt: string; @@ -3313,8 +3313,7 @@ export interface CreateOrderRequest { status?: OrderStatus; orderType?: OrderType; labels?: string[]; - directiveId?: string | null; - contractId?: string | null; + directiveId: string; repositoryUrl?: string | null; } @@ -3327,7 +3326,6 @@ export interface UpdateOrderRequest { labels?: string[]; directiveId?: string | null; directiveStepId?: string | null; - contractId?: string | null; repositoryUrl?: string | null; } @@ -3336,14 +3334,14 @@ export async function listOrders( type?: OrderType, priority?: OrderPriority, directiveId?: string, - contractId?: string, + search?: string, ): Promise<OrderListResponse> { const params = new URLSearchParams(); if (status) params.set("status", status); if (type) params.set("type", type); if (priority) params.set("priority", priority); if (directiveId) params.set("directiveId", directiveId); - if (contractId) params.set("contractId", contractId); + if (search) params.set("search", search); const qs = params.toString(); const res = await authFetch(`${API_BASE}/api/v1/orders${qs ? `?${qs}` : ""}`); if (!res.ok) throw new Error(`Failed to list orders: ${res.statusText}`); @@ -3391,21 +3389,9 @@ export async function linkOrderToDirective(orderId: string, directiveId: string) return res.json(); } -export async function linkOrderToContract(orderId: string, contractId: string): Promise<Order> { - const res = await authFetch(`${API_BASE}/api/v1/orders/${orderId}/link-contract`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ contractId }), - }); - if (!res.ok) throw new Error(`Failed to link order to contract: ${res.statusText}`); - return res.json(); -} - -export async function convertOrderToStep(orderId: string, directiveId: string): Promise<DirectiveStep> { +export async function convertOrderToStep(orderId: string): Promise<DirectiveStep> { const res = await authFetch(`${API_BASE}/api/v1/orders/${orderId}/convert-to-step`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ directiveId }), }); if (!res.ok) throw new Error(`Failed to convert order to step: ${res.statusText}`); return res.json(); diff --git a/makima/frontend/src/routes/contracts.tsx b/makima/frontend/src/routes/contracts.tsx index acb6789..6d838ab 100644 --- a/makima/frontend/src/routes/contracts.tsx +++ b/makima/frontend/src/routes/contracts.tsx @@ -524,15 +524,9 @@ function ContractsPageContent() { return ( <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> <Masthead showNav /> - <main className="flex-1 flex flex-col p-4 pt-2 gap-4 overflow-hidden"> - {error && ( - <div className="p-3 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-sm"> - {error} - </div> - )} - - <div className="flex-1 grid grid-cols-[350px_1fr] gap-4 min-h-0"> - {/* Contract list */} + <main className="flex-1 flex overflow-hidden" style={{ height: "calc(100vh - 80px)" }}> + {/* Left: Contract list */} + <div className="w-[350px] shrink-0 border-r border-dashed border-[rgba(117,170,252,0.2)] overflow-hidden flex flex-col"> <ContractList contracts={contracts} loading={loading} @@ -545,8 +539,18 @@ function ContractsPageContent() { onDelete={handleContextDelete} onGoToSupervisor={handleContextGoToSupervisor} /> + </div> + + {/* Right: Detail or Create */} + <div className="flex-1 overflow-hidden flex flex-col"> + {error && ( + <div className="p-3 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-sm"> + {error} + </div> + )} {/* Contract detail, creation form, or empty state */} + <div className="flex-1 min-h-0 overflow-hidden"> {isCreating ? ( <div className="p-4 max-w-lg overflow-y-auto h-full bg-[#0a1628]"> <h3 className="font-mono text-[10px] text-[#9bc3ff] uppercase tracking-wide mb-4"> @@ -873,6 +877,7 @@ function ContractsPageContent() { </div> </div> )} + </div> </div> </main> </div> diff --git a/makima/frontend/src/routes/orders.tsx b/makima/frontend/src/routes/orders.tsx index 735c557..deca77f 100644 --- a/makima/frontend/src/routes/orders.tsx +++ b/makima/frontend/src/routes/orders.tsx @@ -16,7 +16,7 @@ export default function OrdersPage() { const [statusFilter, setStatusFilter] = useState<OrderStatus | undefined>(undefined); const [typeFilter, setTypeFilter] = useState<OrderType | undefined>(undefined); const { orders, loading: listLoading, create, refresh: refreshList } = useOrders(statusFilter, typeFilter); - const { order, refresh: refreshDetail, update, remove: removeOrder, linkDirective, linkContract, convertToStep } = useOrder(selectedId); + const { order, refresh: refreshDetail, update, remove: removeOrder, linkDirective, convertToStep } = useOrder(selectedId); const { directives } = useDirectives(); const [showCreate, setShowCreate] = useState(false); @@ -24,6 +24,7 @@ export default function OrdersPage() { const [newDesc, setNewDesc] = useState(""); const [newPriority, setNewPriority] = useState<OrderPriority>("medium"); const [newType, setNewType] = useState<OrderType>("feature"); + const [newDirectiveId, setNewDirectiveId] = useState<string>(""); useEffect(() => { if (!authLoading && isAuthConfigured && !isAuthenticated) { @@ -43,19 +44,21 @@ export default function OrdersPage() { } const handleCreate = async () => { - if (!newTitle.trim()) return; + if (!newTitle.trim() || !newDirectiveId) return; try { const o = await create({ title: newTitle.trim(), description: newDesc.trim() || undefined, priority: newPriority, orderType: newType, + directiveId: newDirectiveId, }); setShowCreate(false); setNewTitle(""); setNewDesc(""); setNewPriority("medium"); setNewType("feature"); + setNewDirectiveId(""); navigate(`/orders/${o.id}`); } catch (e) { console.error("Failed to create order:", e); @@ -84,13 +87,8 @@ export default function OrdersPage() { await refreshList(); }; - const handleLinkContract = async (contractId: string) => { - await linkContract(contractId); - await refreshList(); - }; - - const handleConvertToStep = async (directiveId: string) => { - await convertToStep(directiveId); + const handleConvertToStep = async () => { + await convertToStep(); await refreshList(); }; @@ -162,6 +160,21 @@ export default function OrdersPage() { className="w-full bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1.5 text-[12px] font-mono text-white resize-y" /> </div> + <div> + <label className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide block mb-1"> + Directive * + </label> + <select + value={newDirectiveId} + onChange={(e) => setNewDirectiveId(e.target.value)} + className="w-full bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1.5 text-[12px] font-mono text-white" + > + <option value="">Select directive...</option> + {directives.map((d) => ( + <option key={d.id} value={d.id}>{d.title}</option> + ))} + </select> + </div> <div className="flex gap-4"> <div className="flex-1"> <label className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide block mb-1"> @@ -196,7 +209,7 @@ export default function OrdersPage() { <button type="button" onClick={handleCreate} - disabled={!newTitle.trim()} + disabled={!newTitle.trim() || !newDirectiveId} className="text-[11px] font-mono text-emerald-400 hover:text-emerald-300 border border-emerald-800 rounded px-3 py-1 disabled:opacity-50" > Create @@ -218,7 +231,6 @@ export default function OrdersPage() { onUpdate={handleUpdate} onDelete={handleDelete} onLinkDirective={handleLinkDirective} - onLinkContract={handleLinkContract} onConvertToStep={handleConvertToStep} onRefresh={refreshDetail} /> diff --git a/makima/migrations/20260216100000_orders_remove_contract_add_directive_name.sql b/makima/migrations/20260216100000_orders_remove_contract_add_directive_name.sql new file mode 100644 index 0000000..cacd7a7 --- /dev/null +++ b/makima/migrations/20260216100000_orders_remove_contract_add_directive_name.sql @@ -0,0 +1,32 @@ +-- Remove contract_id from orders (orders are tied only to directives) +ALTER TABLE orders DROP COLUMN IF EXISTS contract_id; +DROP INDEX IF EXISTS idx_orders_contract_id; + +-- Add directive_name as a denormalized field for searchability +ALTER TABLE orders ADD COLUMN IF NOT EXISTS directive_name VARCHAR(500); + +-- Make directive_id required for new orders (but keep existing NULLs) +-- We use a trigger approach to populate directive_name automatically +CREATE OR REPLACE FUNCTION update_order_directive_name() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.directive_id IS NOT NULL THEN + SELECT title INTO NEW.directive_name FROM directives WHERE id = NEW.directive_id; + ELSE + NEW.directive_name := NULL; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_order_directive_name + BEFORE INSERT OR UPDATE OF directive_id ON orders + FOR EACH ROW + EXECUTE FUNCTION update_order_directive_name(); + +-- Backfill directive_name for existing orders +UPDATE orders o SET directive_name = d.title +FROM directives d WHERE o.directive_id = d.id; + +-- Index for searching by directive_name +CREATE INDEX IF NOT EXISTS idx_orders_directive_name ON orders(directive_name); diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index bfed942..2951159 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -2880,8 +2880,8 @@ pub struct UpdateDirectiveStepRequest { // ============================================================================= /// An order — a card-based work item (feature, bug, spike, chore, improvement) -/// similar to GitHub Issues or Linear cards. Orders can be linked to directives -/// or contracts for execution. +/// similar to GitHub Issues or Linear cards. Orders are linked to directives +/// for execution. #[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct Order { @@ -2901,8 +2901,8 @@ pub struct Order { pub directive_id: Option<Uuid>, /// Linked directive step (optional) pub directive_step_id: Option<Uuid>, - /// Linked contract (optional) - pub contract_id: Option<Uuid>, + /// Denormalized directive name for searchability (auto-populated by DB trigger) + pub directive_name: Option<String>, /// Repository context pub repository_url: Option<String>, pub created_at: DateTime<Utc>, @@ -2920,8 +2920,8 @@ pub struct CreateOrderRequest { pub order_type: Option<String>, #[serde(default = "default_empty_labels")] pub labels: serde_json::Value, - pub directive_id: Option<Uuid>, - pub contract_id: Option<Uuid>, + /// Directive ID is required for new orders. + pub directive_id: Uuid, pub repository_url: Option<String>, } @@ -2942,7 +2942,6 @@ pub struct UpdateOrderRequest { pub labels: Option<serde_json::Value>, pub directive_id: Option<Uuid>, pub directive_step_id: Option<Uuid>, - pub contract_id: Option<Uuid>, pub repository_url: Option<String>, } @@ -2967,8 +2966,8 @@ pub struct OrderListQuery { pub priority: Option<String>, /// Filter by linked directive ID pub directive_id: Option<Uuid>, - /// Filter by linked contract ID - pub contract_id: Option<Uuid>, + /// Text search across title, description, and directive_name (case-insensitive) + pub search: Option<String>, } /// Request body for linking an order to a directive. @@ -2978,17 +2977,4 @@ pub struct LinkDirectiveRequest { pub directive_id: Uuid, } -/// Request body for linking an order to a contract. -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct LinkContractRequest { - pub contract_id: Uuid, -} - -/// Request body for converting an order to a directive step. -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ConvertToStepRequest { - pub directive_id: Uuid, -} diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index 2ef3fbc..cb6a0c6 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -6032,8 +6032,8 @@ pub async fn create_order( sqlx::query_as::<_, Order>( r#" - INSERT INTO orders (owner_id, title, description, priority, status, order_type, labels, directive_id, contract_id, repository_url) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + INSERT INTO orders (owner_id, title, description, priority, status, order_type, labels, directive_id, repository_url) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING * "#, ) @@ -6045,7 +6045,6 @@ pub async fn create_order( .bind(order_type) .bind(&req.labels) .bind(req.directive_id) - .bind(req.contract_id) .bind(&req.repository_url) .fetch_one(pool) .await @@ -6059,7 +6058,7 @@ pub async fn list_orders( type_filter: Option<&str>, priority_filter: Option<&str>, directive_id_filter: Option<Uuid>, - contract_id_filter: Option<Uuid>, + search_filter: Option<&str>, ) -> Result<Vec<Order>, sqlx::Error> { // Build dynamic query with optional filters let mut query = String::from("SELECT * FROM orders WHERE owner_id = $1"); @@ -6081,8 +6080,11 @@ pub async fn list_orders( query.push_str(&format!(" AND directive_id = ${}", param_idx)); param_idx += 1; } - if contract_id_filter.is_some() { - query.push_str(&format!(" AND contract_id = ${}", param_idx)); + if search_filter.is_some() { + query.push_str(&format!( + " AND (title ILIKE ${p} OR description ILIKE ${p} OR directive_name ILIKE ${p})", + p = param_idx + )); let _ = param_idx; // suppress unused warning } query.push_str(" ORDER BY created_at DESC"); @@ -6101,8 +6103,8 @@ pub async fn list_orders( if let Some(d) = directive_id_filter { q = q.bind(d); } - if let Some(c) = contract_id_filter { - q = q.bind(c); + if let Some(s) = search_filter { + q = q.bind(format!("%{}%", s)); } q.fetch_all(pool).await @@ -6151,7 +6153,6 @@ pub async fn update_order( let labels = req.labels.as_ref().unwrap_or(¤t.labels); let directive_id = req.directive_id.or(current.directive_id); let directive_step_id = req.directive_step_id.or(current.directive_step_id); - let contract_id = req.contract_id.or(current.contract_id); let repository_url = req.repository_url.as_deref().or(current.repository_url.as_deref()); sqlx::query_as::<_, Order>( @@ -6159,7 +6160,7 @@ pub async fn update_order( UPDATE orders SET title = $3, description = $4, priority = $5, status = $6, order_type = $7, labels = $8, directive_id = $9, directive_step_id = $10, - contract_id = $11, repository_url = $12, updated_at = NOW() + repository_url = $11, updated_at = NOW() WHERE id = $1 AND owner_id = $2 RETURNING * "#, @@ -6174,7 +6175,6 @@ pub async fn update_order( .bind(labels) .bind(directive_id) .bind(directive_step_id) - .bind(contract_id) .bind(repository_url) .fetch_optional(pool) .await @@ -6219,36 +6219,13 @@ pub async fn link_order_to_directive( .await } -/// Link an order to a contract. -pub async fn link_order_to_contract( - pool: &PgPool, - owner_id: Uuid, - order_id: Uuid, - contract_id: Uuid, -) -> Result<Option<Order>, sqlx::Error> { - sqlx::query_as::<_, Order>( - r#" - UPDATE orders - SET contract_id = $3, updated_at = NOW() - WHERE id = $1 AND owner_id = $2 - RETURNING * - "#, - ) - .bind(order_id) - .bind(owner_id) - .bind(contract_id) - .fetch_optional(pool) - .await -} - /// Convert an order to a directive step. Creates a new DirectiveStep from the order's -/// title and description, links the order to both the directive and the new step, -/// and returns the created step. +/// title and description, links the order to the new step, and returns the created step. +/// Uses the order's existing directive_id (which is required for new orders). pub async fn convert_order_to_step( pool: &PgPool, owner_id: Uuid, order_id: Uuid, - directive_id: Uuid, ) -> Result<Option<DirectiveStep>, sqlx::Error> { // Verify the order exists and belongs to this owner let order = sqlx::query_as::<_, Order>( @@ -6264,6 +6241,12 @@ pub async fn convert_order_to_step( None => return Ok(None), }; + // Get the directive_id from the order (required for new orders, but legacy data may have NULL) + let directive_id = match order.directive_id { + Some(id) => id, + None => return Ok(None), + }; + // Verify the directive exists and belongs to this owner let directive = sqlx::query_as::<_, Directive>( r#"SELECT * FROM directives WHERE id = $1 AND owner_id = $2"#, @@ -6301,17 +6284,16 @@ pub async fn convert_order_to_step( .fetch_one(pool) .await?; - // Link the order to the directive and the new step + // Link the order to the new step sqlx::query( r#" UPDATE orders - SET directive_id = $3, directive_step_id = $4, updated_at = NOW() + SET directive_step_id = $3, updated_at = NOW() WHERE id = $1 AND owner_id = $2 "#, ) .bind(order_id) .bind(owner_id) - .bind(directive_id) .bind(step.id) .execute(pool) .await?; diff --git a/makima/src/orchestration/directive.rs b/makima/src/orchestration/directive.rs index 736715d..020c2e4 100644 --- a/makima/src/orchestration/directive.rs +++ b/makima/src/orchestration/directive.rs @@ -580,20 +580,11 @@ impl DirectiveOrchestrator { for directive in directives { if let Err(e) = async { - // Atomically claim this directive for completion using a placeholder. - // This prevents a concurrent tick from also spawning a completion task. - let placeholder_id = Uuid::new_v4(); - let claimed = repository::claim_directive_for_completion( - &self.pool, - directive.id, - placeholder_id, - ) - .await?; - - if !claimed { + // Skip if already claimed (completion_task_id is set) + if directive.completion_task_id.is_some() { tracing::debug!( directive_id = %directive.id, - "Directive already claimed for completion — skipping" + "Directive already has a completion task — skipping" ); return Ok::<(), anyhow::Error>(()); } @@ -606,7 +597,6 @@ impl DirectiveOrchestrator { let step_tasks = repository::get_completed_step_tasks(&self.pool, directive.id).await?; if step_tasks.is_empty() { - let _ = repository::clear_completion_task(&self.pool, directive.id).await; return Ok(()); } @@ -640,6 +630,7 @@ impl DirectiveOrchestrator { base_branch, ); + // Create the task FIRST so we have a real task ID for the FK match self .spawn_completion_task( directive.id, @@ -652,6 +643,22 @@ impl DirectiveOrchestrator { .await { Ok(task_id) => { + // Atomically claim with the REAL task ID (satisfies FK constraint) + let claimed = repository::claim_directive_for_completion( + &self.pool, + directive.id, + task_id, + ) + .await?; + + if !claimed { + tracing::debug!( + directive_id = %directive.id, + "Directive already claimed for completion — task will be orphaned" + ); + return Ok(()); + } + let update = crate::db::models::UpdateDirectiveRequest { pr_branch: Some(directive_branch.clone()), ..Default::default() @@ -663,15 +670,13 @@ impl DirectiveOrchestrator { update, ) .await; - repository::assign_completion_task(&self.pool, directive.id, task_id).await?; } Err(e) => { tracing::warn!( directive_id = %directive.id, error = %e, - "Failed to spawn completion task — releasing claim" + "Failed to spawn completion task" ); - let _ = repository::clear_completion_task(&self.pool, directive.id).await; } } Ok(()) @@ -773,15 +778,8 @@ impl DirectiveOrchestrator { for directive in verify_directives { if let Err(e) = async { - let placeholder_id = Uuid::new_v4(); - let claimed = repository::claim_directive_for_completion( - &self.pool, - directive.id, - placeholder_id, - ) - .await?; - - if !claimed { + // Skip if already claimed + if directive.completion_task_id.is_some() { return Ok::<(), anyhow::Error>(()); } @@ -795,6 +793,7 @@ impl DirectiveOrchestrator { let base_branch = directive.base_branch.as_deref().unwrap_or("main"); let prompt = build_verification_prompt(&directive, pr_branch, base_branch); + // Create the task FIRST so we have a real task ID for the FK match self .spawn_completion_task( directive.id, @@ -807,15 +806,27 @@ impl DirectiveOrchestrator { .await { Ok(task_id) => { - repository::assign_completion_task(&self.pool, directive.id, task_id).await?; + // Atomically claim with the REAL task ID (satisfies FK constraint) + let claimed = repository::claim_directive_for_completion( + &self.pool, + directive.id, + task_id, + ) + .await?; + + if !claimed { + tracing::debug!( + directive_id = %directive.id, + "Directive already claimed for verification — task will be orphaned" + ); + } } Err(e) => { tracing::warn!( directive_id = %directive.id, error = %e, - "Failed to spawn verification task — releasing claim" + "Failed to spawn verification task" ); - let _ = repository::clear_completion_task(&self.pool, directive.id).await; } } Ok(()) @@ -906,9 +917,9 @@ impl DirectiveOrchestrator { /// This is the public entry point used by both the orchestrator tick and the /// manual "create PR" API handler. It encapsulates the full flow: /// 1. Validate the directive has completed step tasks -/// 2. Claim the directive for completion (returns error if already claimed) -/// 3. Build branch names and prompt -/// 4. Spawn the completion task and assign it +/// 2. Create the completion task (so we have a real task ID) +/// 3. Atomically claim the directive for completion with the real task ID +/// 4. Dispatch the task to a daemon /// /// Returns the created task ID on success. pub async fn trigger_completion_task( @@ -931,14 +942,6 @@ pub async fn trigger_completion_task( anyhow::bail!("No completed steps with tasks found"); } - // Claim for completion - let placeholder_id = Uuid::new_v4(); - let claimed = - repository::claim_directive_for_completion(pool, directive_id, placeholder_id).await?; - if !claimed { - anyhow::bail!("Directive already claimed for completion"); - } - let base_branch = directive.base_branch.as_deref().unwrap_or("main"); let directive_branch = format!( @@ -970,7 +973,7 @@ pub async fn trigger_completion_task( format!("PR: {}", directive.title) }; - // Create the completion task + // Create the completion task FIRST so we have a real task ID for the FK let req = CreateTaskRequest { contract_id: None, name: task_name, @@ -997,6 +1000,14 @@ pub async fn trigger_completion_task( let task = repository::create_task_for_owner(pool, owner_id, req).await?; + // Atomically claim the directive with the REAL task ID (satisfies FK constraint). + // This prevents concurrent ticks from also spawning a completion task. + let claimed = + repository::claim_directive_for_completion(pool, directive_id, task.id).await?; + if !claimed { + anyhow::bail!("Directive already claimed for completion"); + } + // Update pr_branch on the directive let update = crate::db::models::UpdateDirectiveRequest { pr_branch: Some(directive_branch), @@ -1004,9 +1015,6 @@ pub async fn trigger_completion_task( }; let _ = repository::update_directive_for_owner(pool, owner_id, directive_id, update).await; - // Assign the real task as the completion task - repository::assign_completion_task(pool, directive_id, task.id).await?; - // Try to dispatch to a daemon if let Some(daemon_id) = state.find_alternative_daemon(owner_id, &[]) { let update_req = crate::db::models::UpdateTaskRequest { diff --git a/makima/src/server/handlers/orders.rs b/makima/src/server/handlers/orders.rs index c43c406..cddf6a6 100644 --- a/makima/src/server/handlers/orders.rs +++ b/makima/src/server/handlers/orders.rs @@ -11,7 +11,7 @@ use axum::{ use uuid::Uuid; use crate::db::models::{ - ConvertToStepRequest, CreateOrderRequest, DirectiveStep, LinkContractRequest, + CreateOrderRequest, DirectiveStep, LinkDirectiveRequest, Order, OrderListQuery, OrderListResponse, UpdateOrderRequest, }; use crate::db::repository; @@ -32,7 +32,7 @@ use crate::server::state::SharedState; ("type" = Option<String>, Query, description = "Filter by order type"), ("priority" = Option<String>, Query, description = "Filter by priority"), ("directive_id" = Option<Uuid>, Query, description = "Filter by directive ID"), - ("contract_id" = Option<Uuid>, Query, description = "Filter by contract ID"), + ("search" = Option<String>, Query, description = "Text search across title, description, and directive name"), ), responses( (status = 200, description = "List of orders", body = OrderListResponse), @@ -62,7 +62,7 @@ pub async fn list_orders( query.order_type.as_deref(), query.priority.as_deref(), query.directive_id, - query.contract_id, + query.search.as_deref(), ) .await { @@ -327,80 +327,13 @@ pub async fn link_to_directive( } } -/// Link an order to a contract. -#[utoipa::path( - post, - path = "/api/v1/orders/{id}/link-contract", - params(("id" = Uuid, Path, description = "Order ID")), - request_body = LinkContractRequest, - responses( - (status = 200, description = "Order linked to contract", body = Order), - (status = 404, description = "Not found", body = ApiError), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - ), - security(("bearer_auth" = []), ("api_key" = [])), - tag = "Orders" -)] -pub async fn link_to_contract( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<LinkContractRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify the contract exists and belongs to this owner - match repository::get_contract_for_owner(pool, auth.owner_id, req.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 repository::link_order_to_contract(pool, auth.owner_id, id, req.contract_id).await { - Ok(Some(order)) => Json(order).into_response(), - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Order not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to link order to contract: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("LINK_FAILED", &e.to_string())), - ) - .into_response() - } - } -} - /// Convert an order to a directive step. -/// Creates a new step in the specified directive using the order's title/description, -/// and links the order to both the directive and the new step. +/// Creates a new step in the order's linked directive using the order's title/description, +/// and links the order to the new step. The order must have a directive_id set. #[utoipa::path( post, path = "/api/v1/orders/{id}/convert-to-step", params(("id" = Uuid, Path, description = "Order ID")), - request_body = ConvertToStepRequest, responses( (status = 201, description = "Directive step created from order", body = DirectiveStep), (status = 404, description = "Order or directive not found", body = ApiError), @@ -414,7 +347,6 @@ pub async fn convert_to_step( State(state): State<SharedState>, Authenticated(auth): Authenticated, Path(id): Path<Uuid>, - Json(req): Json<ConvertToStepRequest>, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( @@ -424,11 +356,14 @@ pub async fn convert_to_step( .into_response(); }; - match repository::convert_order_to_step(pool, auth.owner_id, id, req.directive_id).await { + match repository::convert_order_to_step(pool, auth.owner_id, id).await { Ok(Some(step)) => (StatusCode::CREATED, Json(step)).into_response(), Ok(None) => ( StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Order or directive not found")), + Json(ApiError::new( + "NOT_FOUND", + "Order not found or has no linked directive", + )), ) .into_response(), Err(e) => { diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index c963618..6bd5ae0 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -252,7 +252,6 @@ pub fn make_router(state: SharedState) -> Router { .delete(orders::delete_order), ) .route("/orders/{id}/link-directive", post(orders::link_to_directive)) - .route("/orders/{id}/link-contract", post(orders::link_to_contract)) .route("/orders/{id}/convert-to-step", post(orders::convert_to_step)) // Timeline endpoint (unified history for user) .route("/timeline", get(history::get_timeline)) diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs index 9c6463a..87ca9c5 100644 --- a/makima/src/server/openapi.rs +++ b/makima/src/server/openapi.rs @@ -8,14 +8,14 @@ use crate::db::models::{ ChangePhaseRequest, Contract, ContractChatHistoryResponse, ContractChatMessageRecord, ContractEvent, ContractListResponse, ContractRepository, ContractSummary, ContractWithRelations, - CleanupTasksResponse, ConvertToStepRequest, + CleanupTasksResponse, CreateContractRequest, CreateDirectiveRequest, CreateDirectiveStepRequest, CreateFileRequest, CreateManagedRepositoryRequest, CreateOrderRequest, CreateTaskRequest, Daemon, DaemonDirectoriesResponse, DaemonDirectory, DaemonListResponse, Directive, DirectiveListResponse, DirectiveStep, DirectiveSummary, DirectiveWithSteps, File, FileListResponse, FileSummary, - LinkContractRequest, LinkDirectiveRequest, + LinkDirectiveRequest, MergeCommitRequest, MergeCompleteCheckResponse, MergeResolveRequest, MergeResultResponse, MergeSkipRequest, MergeStartRequest, MergeStatusResponse, MeshChatConversation, MeshChatHistoryResponse, MeshChatMessageRecord, @@ -137,7 +137,6 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage orders::update_order, orders::delete_order, orders::link_to_directive, - orders::link_to_contract, orders::convert_to_step, // Repository history/settings endpoints repository_history::list_repository_history, @@ -243,8 +242,6 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage CreateOrderRequest, UpdateOrderRequest, LinkDirectiveRequest, - LinkContractRequest, - ConvertToStepRequest, // Repository history schemas RepositoryHistoryEntry, RepositoryHistoryListResponse, |
