summaryrefslogtreecommitdiff
path: root/makima
diff options
context:
space:
mode:
Diffstat (limited to 'makima')
-rw-r--r--makima/frontend/src/components/orders/OrderDetail.tsx89
-rw-r--r--makima/frontend/src/components/orders/OrderList.tsx8
-rw-r--r--makima/frontend/src/hooks/useOrders.ts14
-rw-r--r--makima/frontend/src/lib/api.ts24
-rw-r--r--makima/frontend/src/routes/contracts.tsx23
-rw-r--r--makima/frontend/src/routes/orders.tsx34
-rw-r--r--makima/migrations/20260216100000_orders_remove_contract_add_directive_name.sql32
-rw-r--r--makima/src/db/models.rs30
-rw-r--r--makima/src/db/repository.rs60
-rw-r--r--makima/src/orchestration/directive.rs94
-rw-r--r--makima/src/server/handlers/orders.rs85
-rw-r--r--makima/src/server/mod.rs1
-rw-r--r--makima/src/server/openapi.rs7
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(&current.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,