diff options
| author | soryu <soryu@soryu.co> | 2026-02-14 21:29:26 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-02-14 21:29:26 +0000 |
| commit | 9aadbc7958d39d181c0dd0600e2b7c30bb6c391a (patch) | |
| tree | ef8bed9718c39041191b58a284ee31f5d8d32521 | |
| parent | c1e55ce4fec79f9909b957f86bd7fa8b76939746 (diff) | |
| download | soryu-9aadbc7958d39d181c0dd0600e2b7c30bb6c391a.tar.gz soryu-9aadbc7958d39d181c0dd0600e2b7c30bb6c391a.zip | |
Makima system improvements: Orders, directive questions, PR creation fix, bug fixes (#62)
* feat: soryu-co/soryu - makima: Fix directive goal update bug - stale closure issue
* WIP: heartbeat checkpoint
* WIP: heartbeat checkpoint
* feat: soryu-co/soryu - makima: Create Orders database schema and backend API
* feat: soryu-co/soryu - makima: Fix task Claude instance not receiving user inputs from input box
* WIP: heartbeat checkpoint
* feat: soryu-co/soryu - makima: Build Orders frontend page replacing the Board page
* WIP: heartbeat checkpoint
* WIP: heartbeat checkpoint
* feat: soryu-co/soryu - makima: Fix directive PR creation system
30 files changed, 2558 insertions, 606 deletions
diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx index 9bb7777..5aba6a3 100644 --- a/makima/frontend/src/components/NavStrip.tsx +++ b/makima/frontend/src/components/NavStrip.tsx @@ -12,7 +12,7 @@ const NAV_LINKS: NavLink[] = [ { label: "Listen", href: "/listen" }, { label: "Directives", href: "/directives", requiresAuth: true }, { label: "Contracts", href: "/contracts", requiresAuth: true }, - { label: "Board", href: "/workflow", requiresAuth: true }, + { label: "Orders", href: "/orders", requiresAuth: true }, { label: "Mesh", href: "/mesh", requiresAuth: true }, { label: "History", href: "/history", requiresAuth: true }, ]; diff --git a/makima/frontend/src/components/directives/DirectiveDetail.tsx b/makima/frontend/src/components/directives/DirectiveDetail.tsx index b73463d..e278939 100644 --- a/makima/frontend/src/components/directives/DirectiveDetail.tsx +++ b/makima/frontend/src/components/directives/DirectiveDetail.tsx @@ -1,8 +1,9 @@ import { useState, useMemo, useEffect, useRef } from "react"; -import type { DirectiveWithSteps, DirectiveStatus } from "../../lib/api"; +import type { DirectiveWithSteps, DirectiveStatus, UpdateDirectiveRequest } from "../../lib/api"; import { DirectiveDAG } from "./DirectiveDAG"; import { DirectiveLogStream } from "./DirectiveLogStream"; import { useMultiTaskSubscription } from "../../hooks/useMultiTaskSubscription"; +import { useSupervisorQuestions } from "../../contexts/SupervisorQuestionsContext"; const STATUS_BADGE: Record<DirectiveStatus, { color: string; label: string }> = { draft: { color: "text-[#7788aa] border-[#2a3a5a]", label: "DRAFT" }, @@ -21,6 +22,7 @@ interface DirectiveDetailProps { onFailStep: (stepId: string) => void; onSkipStep: (stepId: string) => void; onUpdateGoal: (goal: string) => void; + onUpdate: (req: UpdateDirectiveRequest) => void; onDelete: () => void; onRefresh: () => void; onCleanupTasks: () => void; @@ -35,6 +37,7 @@ export function DirectiveDetail({ onFailStep, onSkipStep, onUpdateGoal, + onUpdate, onDelete, onRefresh, onCleanupTasks, @@ -42,6 +45,12 @@ export function DirectiveDetail({ const [editingGoal, setEditingGoal] = useState(false); const [goalText, setGoalText] = useState(directive.goal); const [visibleTaskIds, setVisibleTaskIds] = useState<Set<string> | null>(null); + + // Sync goalText and reset editing state when directive changes + useEffect(() => { + setGoalText(directive.goal); + setEditingGoal(false); + }, [directive.id, directive.goal]); const [searchQuery, setSearchQuery] = useState(""); const [isLogCollapsed, setIsLogCollapsed] = useState(true); const prevHadRunningRef = useRef(false); @@ -53,6 +62,24 @@ export function DirectiveDetail({ const terminalStatuses = new Set(["completed", "failed", "skipped"]); const hasTerminalTasks = directive.steps.some((s) => s.taskId && terminalStatuses.has(s.status)); + // Get pending questions for this directive's tasks + const { pendingQuestions, submitAnswer } = useSupervisorQuestions(); + const directiveTaskIds = useMemo(() => { + const ids = new Set<string>(); + if (directive.orchestratorTaskId) ids.add(directive.orchestratorTaskId); + for (const step of directive.steps) { + if (step.taskId) ids.add(step.taskId); + } + return ids; + }, [directive.orchestratorTaskId, directive.steps]); + + const directiveQuestions = useMemo( + () => pendingQuestions.filter((q) => + q.directiveId === directive.id || directiveTaskIds.has(q.taskId) + ), + [pendingQuestions, directive.id, directiveTaskIds] + ); + // Build task map from directive steps and orchestrator // Derive a stable key from the actual task IDs to avoid recreating the map on every poll const taskMapKey = useMemo(() => { @@ -149,6 +176,26 @@ export function DirectiveDetail({ </div> )} + {/* Reconcile mode toggle */} + <div className="flex items-center gap-2 mb-2"> + <button + type="button" + onClick={() => onUpdate({ reconcileMode: !directive.reconcileMode })} + className={`text-[10px] font-mono border rounded px-2 py-0.5 transition-colors ${ + directive.reconcileMode + ? "text-amber-400 border-amber-800 bg-amber-900/20" + : "text-[#556677] border-[#2a3a5a] hover:text-[#7788aa]" + }`} + > + {directive.reconcileMode ? "Reconcile: ON" : "Reconcile: OFF"} + </button> + <span className="text-[9px] font-mono text-[#445566]"> + {directive.reconcileMode + ? "Questions pause execution" + : "Questions timeout after 30s"} + </span> + </div> + {/* Orchestrator planning indicator */} {directive.orchestratorTaskId && ( <div className="flex items-center gap-2 mb-2 px-2 py-1.5 bg-[#1a1a30] border border-[rgba(117,170,252,0.2)] rounded"> @@ -199,6 +246,20 @@ export function DirectiveDetail({ </div> )} + {/* Pending Questions */} + {directiveQuestions.length > 0 && ( + <div className="mb-2 space-y-2"> + {directiveQuestions.map((q) => ( + <DirectiveQuestionCard + key={q.questionId} + question={q} + taskName={taskMap.get(q.taskId) || "Task"} + onAnswer={(response) => submitAnswer(q.questionId, response)} + /> + ))} + </div> + )} + {/* Controls */} <div className="flex flex-wrap gap-2"> {(directive.status === "draft" || directive.status === "paused") && ( @@ -235,7 +296,7 @@ export function DirectiveDetail({ </span> <button type="button" - onClick={() => setEditingGoal(true)} + onClick={() => { setGoalText(directive.goal); setEditingGoal(true); }} className="text-[10px] font-mono text-[#75aafc] hover:text-white border border-[rgba(117,170,252,0.3)] rounded px-2 py-1" > Update Goal @@ -342,3 +403,77 @@ export function DirectiveDetail({ </div> ); } + +/** Inline question card for directive pending questions */ +function DirectiveQuestionCard({ + question, + taskName, + onAnswer, +}: { + question: { questionId: string; question: string; choices: string[]; context: string | null }; + taskName: string; + onAnswer: (response: string) => void; +}) { + const [customResponse, setCustomResponse] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit = async (response: string) => { + setSubmitting(true); + await onAnswer(response); + setSubmitting(false); + }; + + return ( + <div className="px-2 py-2 bg-[#1a1020] border border-purple-900/50 rounded"> + <div className="flex items-center gap-1.5 mb-1"> + <span className="inline-block w-2 h-2 rounded-full bg-purple-400 animate-pulse" /> + <span className="text-[9px] font-mono text-purple-400 uppercase"> + Question from {taskName} + </span> + </div> + <p className="text-[11px] font-mono text-white mb-1.5">{question.question}</p> + {question.context && ( + <p className="text-[9px] font-mono text-[#556677] mb-1.5">{question.context}</p> + )} + {question.choices.length > 0 ? ( + <div className="flex flex-wrap gap-1"> + {question.choices.map((choice) => ( + <button + key={choice} + type="button" + disabled={submitting} + onClick={() => handleSubmit(choice)} + className="text-[10px] font-mono text-purple-300 hover:text-white border border-purple-800 hover:border-purple-600 rounded px-2 py-0.5 disabled:opacity-50" + > + {choice} + </button> + ))} + </div> + ) : ( + <div className="flex gap-1"> + <input + type="text" + value={customResponse} + onChange={(e) => setCustomResponse(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && customResponse.trim()) { + handleSubmit(customResponse.trim()); + } + }} + placeholder="Type your answer..." + className="flex-1 bg-[#0a0618] border border-purple-900/50 rounded px-2 py-0.5 text-[10px] font-mono text-white placeholder:text-[#445566]" + disabled={submitting} + /> + <button + type="button" + disabled={submitting || !customResponse.trim()} + onClick={() => handleSubmit(customResponse.trim())} + className="text-[10px] font-mono text-purple-300 hover:text-white border border-purple-800 rounded px-2 py-0.5 disabled:opacity-50" + > + Send + </button> + </div> + )} + </div> + ); +} diff --git a/makima/frontend/src/components/mesh/TaskOutput.tsx b/makima/frontend/src/components/mesh/TaskOutput.tsx index f49c366..2db4250 100644 --- a/makima/frontend/src/components/mesh/TaskOutput.tsx +++ b/makima/frontend/src/components/mesh/TaskOutput.tsx @@ -77,7 +77,10 @@ export function TaskOutput({ setInputValue(""); inputRef.current?.focus(); } catch (err) { - setInputError(err instanceof Error ? err.message : "Failed to send input"); + const errorMsg = err instanceof Error ? err.message : "Failed to send input"; + setInputError(errorMsg); + // Auto-dismiss error after 5 seconds + setTimeout(() => setInputError(null), 5000); } finally { setSendingInput(false); } diff --git a/makima/frontend/src/components/orders/OrderDetail.tsx b/makima/frontend/src/components/orders/OrderDetail.tsx new file mode 100644 index 0000000..7f8a95d --- /dev/null +++ b/makima/frontend/src/components/orders/OrderDetail.tsx @@ -0,0 +1,530 @@ +import { useState } from "react"; +import type { + Order, + OrderStatus, + OrderPriority, + OrderType, + UpdateOrderRequest, + DirectiveSummary, +} from "../../lib/api"; + +const STATUS_BADGE: Record<OrderStatus, { color: string; label: string }> = { + open: { color: "text-[#75aafc] border-[rgba(117,170,252,0.4)]", label: "OPEN" }, + in_progress: { color: "text-yellow-400 border-yellow-800", label: "IN PROGRESS" }, + done: { color: "text-emerald-400 border-emerald-800", label: "DONE" }, + archived: { color: "text-[#556677] border-[#2a3a5a]", label: "ARCHIVED" }, +}; + +const PRIORITY_OPTIONS: { value: OrderPriority; color: string; label: string }[] = [ + { value: "critical", color: "text-red-400 border-red-800", label: "Critical" }, + { value: "high", color: "text-orange-400 border-orange-800", label: "High" }, + { value: "medium", color: "text-yellow-400 border-yellow-800", label: "Medium" }, + { value: "low", color: "text-[#75aafc] border-[rgba(117,170,252,0.4)]", label: "Low" }, + { value: "none", color: "text-[#556677] border-[#2a3a5a]", label: "None" }, +]; + +const TYPE_OPTIONS: { value: OrderType; color: string; label: string }[] = [ + { value: "feature", color: "text-[#75aafc]", label: "Feature" }, + { value: "bug", color: "text-red-400", label: "Bug" }, + { value: "spike", color: "text-yellow-400", label: "Spike" }, + { value: "chore", color: "text-[#7788aa]", label: "Chore" }, + { value: "improvement", color: "text-emerald-400", label: "Improvement" }, +]; + +const STATUS_OPTIONS: OrderStatus[] = ["open", "in_progress", "done", "archived"]; + +interface OrderDetailProps { + order: Order; + directives: DirectiveSummary[]; + onUpdate: (req: UpdateOrderRequest) => Promise<void>; + onDelete: () => void; + onLinkDirective: (directiveId: string) => Promise<void>; + onLinkContract: (contractId: string) => Promise<void>; + onConvertToStep: (directiveId: string) => Promise<void>; + onRefresh: () => void; +} + +export function OrderDetail({ + order, + directives, + onUpdate, + onDelete, + onLinkDirective, + onLinkContract, + onConvertToStep, + onRefresh, +}: OrderDetailProps) { + const [editingTitle, setEditingTitle] = useState(false); + const [titleText, setTitleText] = useState(order.title); + const [editingDesc, setEditingDesc] = useState(false); + const [descText, setDescText] = useState(order.description || ""); + 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]; + const currentType = TYPE_OPTIONS.find((t) => t.value === order.orderType) || TYPE_OPTIONS[0]; + + const handleTitleSave = async () => { + if (titleText.trim() && titleText !== order.title) { + await onUpdate({ title: titleText.trim() }); + } + setEditingTitle(false); + }; + + const handleDescSave = async () => { + const newDesc = descText.trim() || null; + if (newDesc !== order.description) { + await onUpdate({ description: newDesc }); + } + setEditingDesc(false); + }; + + const handleLabelsSave = async () => { + const newLabels = labelsText + .split(",") + .map((l) => l.trim()) + .filter((l) => l.length > 0); + await onUpdate({ labels: newLabels }); + setEditingLabels(false); + }; + + const handleStatusChange = async (status: OrderStatus) => { + await onUpdate({ status }); + }; + + const handlePriorityChange = async (priority: OrderPriority) => { + await onUpdate({ priority }); + }; + + const handleTypeChange = async (orderType: OrderType) => { + await onUpdate({ orderType }); + }; + + const handleLinkDirective = async (directiveId: string) => { + await onLinkDirective(directiveId); + 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"> + {/* Header */} + <div className="px-4 py-3 border-b border-dashed border-[rgba(117,170,252,0.2)]"> + <div className="flex items-center justify-between mb-2"> + {editingTitle ? ( + <div className="flex-1 flex items-center gap-2 pr-2"> + <input + value={titleText} + onChange={(e) => setTitleText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleTitleSave(); + if (e.key === "Escape") setEditingTitle(false); + }} + autoFocus + className="flex-1 bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1 text-[14px] font-mono text-white" + /> + <button + type="button" + onClick={handleTitleSave} + className="text-[10px] font-mono text-emerald-400 hover:text-emerald-300" + > + [save] + </button> + <button + type="button" + onClick={() => setEditingTitle(false)} + className="text-[10px] font-mono text-[#556677] hover:text-white" + > + [cancel] + </button> + </div> + ) : ( + <h2 + className="text-[14px] font-mono text-white font-medium truncate pr-2 cursor-pointer hover:text-[#9bc3ff]" + onClick={() => { + setTitleText(order.title); + setEditingTitle(true); + }} + > + {order.title} + </h2> + )} + <div className="flex items-center gap-2 shrink-0"> + <span + className={`text-[10px] font-mono ${badge.color} border rounded px-2 py-0.5`} + > + {badge.label} + </span> + <button + type="button" + onClick={onRefresh} + className="text-[10px] font-mono text-[#7788aa] hover:text-white" + title="Refresh" + > + [refresh] + </button> + </div> + </div> + + {/* Type + Priority inline */} + <div className="flex items-center gap-3 mb-2"> + <span className={`text-[10px] font-mono ${currentType.color}`}> + {currentType.label} + </span> + <span className="text-[10px] font-mono text-[#2a3a5a]">/</span> + <span className={`text-[10px] font-mono ${currentPriority.color} border rounded px-1.5 py-0.5`}> + {currentPriority.label} + </span> + </div> + + {/* 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> + </div> + )} + {order.directiveStepId && ( + <div className="text-[10px] font-mono text-[#556677] mb-1 truncate"> + Step: <span className="text-[#7788aa]">{order.directiveStepId.slice(0, 8)}...</span> + </div> + )} + + {/* Controls */} + <div className="flex flex-wrap gap-2 mt-2"> + <button + type="button" + onClick={onDelete} + className="text-[10px] font-mono text-red-400 hover:text-red-300 border border-red-800 rounded px-2 py-1 ml-auto" + > + Delete + </button> + </div> + </div> + + {/* Status selector */} + <div className="px-4 py-3 border-b border-[rgba(117,170,252,0.1)]"> + <div className="flex items-center justify-between mb-1.5"> + <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide"> + Status + </span> + </div> + <div className="flex gap-1.5 flex-wrap"> + {STATUS_OPTIONS.map((s) => { + const sBadge = STATUS_BADGE[s]; + return ( + <button + key={s} + type="button" + onClick={() => handleStatusChange(s)} + className={`text-[10px] font-mono border rounded px-2 py-0.5 transition-colors ${ + s === order.status + ? `${sBadge.color} bg-[rgba(117,170,252,0.1)]` + : "text-[#556677] border-[#2a3a5a] hover:text-[#7788aa]" + }`} + > + {sBadge.label} + </button> + ); + })} + </div> + </div> + + {/* Priority selector */} + <div className="px-4 py-3 border-b border-[rgba(117,170,252,0.1)]"> + <div className="flex items-center justify-between mb-1.5"> + <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide"> + Priority + </span> + </div> + <div className="flex gap-1.5 flex-wrap"> + {PRIORITY_OPTIONS.map((p) => ( + <button + key={p.value} + type="button" + onClick={() => handlePriorityChange(p.value)} + className={`text-[10px] font-mono border rounded px-2 py-0.5 transition-colors ${ + p.value === order.priority + ? `${p.color} bg-[rgba(117,170,252,0.1)]` + : "text-[#556677] border-[#2a3a5a] hover:text-[#7788aa]" + }`} + > + {p.label} + </button> + ))} + </div> + </div> + + {/* Type selector */} + <div className="px-4 py-3 border-b border-[rgba(117,170,252,0.1)]"> + <div className="flex items-center justify-between mb-1.5"> + <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide"> + Type + </span> + </div> + <div className="flex gap-1.5 flex-wrap"> + {TYPE_OPTIONS.map((t) => ( + <button + key={t.value} + type="button" + onClick={() => handleTypeChange(t.value)} + className={`text-[10px] font-mono border rounded px-2 py-0.5 transition-colors ${ + t.value === order.orderType + ? `${t.color} border-current bg-[rgba(117,170,252,0.1)]` + : "text-[#556677] border-[#2a3a5a] hover:text-[#7788aa]" + }`} + > + {t.label} + </button> + ))} + </div> + </div> + + {/* Description */} + <div className="px-4 py-3 border-b border-[rgba(117,170,252,0.1)]"> + <div className="flex items-center justify-between mb-1"> + <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide"> + Description + </span> + {!editingDesc && ( + <button + type="button" + onClick={() => { + setDescText(order.description || ""); + setEditingDesc(true); + }} + className="text-[9px] font-mono text-[#556677] hover:text-[#75aafc]" + > + [edit] + </button> + )} + </div> + {editingDesc ? ( + <div className="flex flex-col gap-1.5"> + <textarea + value={descText} + onChange={(e) => setDescText(e.target.value)} + className="w-full bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1.5 text-[11px] font-mono text-white resize-y min-h-[80px]" + rows={4} + autoFocus + /> + <div className="flex gap-1.5"> + <button + type="button" + onClick={handleDescSave} + className="text-[10px] font-mono text-emerald-400 hover:text-emerald-300 border border-emerald-800 rounded px-2 py-0.5" + > + Save + </button> + <button + type="button" + onClick={() => setEditingDesc(false)} + className="text-[10px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-2 py-0.5" + > + Cancel + </button> + </div> + </div> + ) : ( + <p className="text-[11px] font-mono text-[#c0d0e0] whitespace-pre-wrap"> + {order.description || <span className="text-[#556677] italic">No description</span>} + </p> + )} + </div> + + {/* Labels */} + <div className="px-4 py-3 border-b border-[rgba(117,170,252,0.1)]"> + <div className="flex items-center justify-between mb-1"> + <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide"> + Labels + </span> + {!editingLabels && ( + <button + type="button" + onClick={() => { + setLabelsText(order.labels.join(", ")); + setEditingLabels(true); + }} + className="text-[9px] font-mono text-[#556677] hover:text-[#75aafc]" + > + [edit] + </button> + )} + </div> + {editingLabels ? ( + <div className="flex flex-col gap-1.5"> + <input + value={labelsText} + onChange={(e) => setLabelsText(e.target.value)} + placeholder="label1, label2, ..." + className="w-full bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1.5 text-[11px] font-mono text-white" + autoFocus + /> + <div className="flex gap-1.5"> + <button + type="button" + onClick={handleLabelsSave} + className="text-[10px] font-mono text-emerald-400 hover:text-emerald-300 border border-emerald-800 rounded px-2 py-0.5" + > + Save + </button> + <button + type="button" + onClick={() => setEditingLabels(false)} + className="text-[10px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-2 py-0.5" + > + Cancel + </button> + </div> + </div> + ) : ( + <div className="flex gap-1 flex-wrap"> + {order.labels.length > 0 ? ( + order.labels.map((l) => ( + <span + key={l} + className="text-[10px] font-mono text-[#9bc3ff] bg-[rgba(117,170,252,0.1)] border border-[rgba(117,170,252,0.2)] rounded px-1.5 py-0.5" + > + {l} + </span> + )) + ) : ( + <span className="text-[10px] font-mono text-[#556677] italic">No labels</span> + )} + </div> + )} + </div> + + {/* Actions */} + <div className="px-4 py-3 flex-1"> + <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide block mb-2"> + Actions + </span> + + <div className="flex flex-col gap-2"> + {/* Link to Directive */} + <div> + <button + type="button" + onClick={() => setShowLinkDirective(!showLinkDirective)} + 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" + > + Link to Directive + </button> + {showLinkDirective && ( + <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={() => handleLinkDirective(d.id)} + className="w-full text-left px-3 py-1.5 text-[10px] font-mono text-[#9bc3ff] 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> + + {/* Link to Contract */} + <div> + <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" + > + Link to Contract + </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> + + {/* Metadata */} + <div className="px-4 py-2 border-t border-[rgba(117,170,252,0.1)]"> + <div className="flex items-center justify-between text-[9px] font-mono text-[#556677]"> + <span>Created {new Date(order.createdAt).toLocaleDateString()}</span> + <span>Updated {new Date(order.updatedAt).toLocaleDateString()}</span> + </div> + </div> + </div> + ); +} diff --git a/makima/frontend/src/components/orders/OrderList.tsx b/makima/frontend/src/components/orders/OrderList.tsx new file mode 100644 index 0000000..76ac7a7 --- /dev/null +++ b/makima/frontend/src/components/orders/OrderList.tsx @@ -0,0 +1,188 @@ +import { useState, useMemo } from "react"; +import type { Order, OrderStatus, OrderPriority, OrderType } from "../../lib/api"; + +const STATUS_BADGE: Record<OrderStatus, { color: string; label: string }> = { + open: { color: "text-[#75aafc] border-[rgba(117,170,252,0.4)]", label: "OPEN" }, + in_progress: { color: "text-yellow-400 border-yellow-800", label: "IN PROGRESS" }, + done: { color: "text-emerald-400 border-emerald-800", label: "DONE" }, + archived: { color: "text-[#556677] border-[#2a3a5a]", label: "ARCHIVED" }, +}; + +const PRIORITY_COLOR: Record<OrderPriority, string> = { + critical: "bg-red-400", + high: "bg-orange-400", + medium: "bg-yellow-400", + low: "bg-[#75aafc]", + none: "bg-[#556677]", +}; + +const TYPE_BADGE: Record<OrderType, { color: string; label: string }> = { + feature: { color: "text-[#75aafc] border-[rgba(117,170,252,0.3)]", label: "FEAT" }, + bug: { color: "text-red-400 border-red-800", label: "BUG" }, + spike: { color: "text-yellow-400 border-yellow-800", label: "SPIKE" }, + chore: { color: "text-[#7788aa] border-[#2a3a5a]", label: "CHORE" }, + improvement: { color: "text-emerald-400 border-emerald-800", label: "IMPROVE" }, +}; + +interface OrderListProps { + orders: Order[]; + selectedId: string | null; + onSelect: (id: string) => void; + onCreate: () => void; + statusFilter: OrderStatus | undefined; + onStatusFilter: (s: OrderStatus | undefined) => void; + typeFilter: OrderType | undefined; + onTypeFilter: (t: OrderType | undefined) => void; +} + +const STATUS_OPTIONS: (OrderStatus | "all")[] = ["all", "open", "in_progress", "done", "archived"]; +const TYPE_OPTIONS: (OrderType | "all")[] = ["all", "feature", "bug", "spike", "chore", "improvement"]; + +export function OrderList({ + orders, + selectedId, + onSelect, + onCreate, + statusFilter, + onStatusFilter, + typeFilter, + onTypeFilter, +}: OrderListProps) { + const [search, setSearch] = useState(""); + + const filtered = useMemo(() => { + if (!search.trim()) return orders; + const q = search.toLowerCase(); + return orders.filter( + (o) => + o.title.toLowerCase().includes(q) || + (o.description && o.description.toLowerCase().includes(q)) || + o.labels.some((l) => l.toLowerCase().includes(q)), + ); + }, [orders, search]); + + return ( + <div className="flex flex-col h-full"> + {/* Header */} + <div className="flex items-center justify-between px-3 py-2 border-b border-dashed border-[rgba(117,170,252,0.2)]"> + <span className="text-[11px] font-mono text-[#9bc3ff] uppercase tracking-wide"> + Orders + </span> + <button + type="button" + onClick={onCreate} + className="text-[11px] font-mono text-[#75aafc] hover:text-white bg-transparent border border-[rgba(117,170,252,0.3)] rounded px-2 py-0.5 hover:border-[rgba(117,170,252,0.6)] transition-colors" + > + + New + </button> + </div> + + {/* Search */} + <div className="px-3 py-1.5 border-b border-[rgba(117,170,252,0.1)]"> + <input + value={search} + onChange={(e) => setSearch(e.target.value)} + placeholder="Search orders..." + className="w-full bg-transparent border-none outline-none text-[11px] font-mono text-white placeholder:text-[#556677]" + /> + </div> + + {/* Filters */} + <div className="px-3 py-1.5 border-b border-[rgba(117,170,252,0.1)] flex flex-col gap-1"> + <div className="flex items-center gap-1 flex-wrap"> + <span className="text-[9px] font-mono text-[#556677] uppercase w-[38px] shrink-0"> + Status + </span> + {STATUS_OPTIONS.map((s) => ( + <button + key={s} + type="button" + onClick={() => onStatusFilter(s === "all" ? undefined : s)} + className={`text-[9px] font-mono px-1.5 py-0.5 rounded transition-colors ${ + (s === "all" && !statusFilter) || s === statusFilter + ? "text-white bg-[rgba(117,170,252,0.15)] border border-[rgba(117,170,252,0.4)]" + : "text-[#556677] hover:text-[#7788aa] border border-transparent" + }`} + > + {s === "all" ? "ALL" : s === "in_progress" ? "WIP" : s.toUpperCase()} + </button> + ))} + </div> + <div className="flex items-center gap-1 flex-wrap"> + <span className="text-[9px] font-mono text-[#556677] uppercase w-[38px] shrink-0"> + Type + </span> + {TYPE_OPTIONS.map((t) => ( + <button + key={t} + type="button" + onClick={() => onTypeFilter(t === "all" ? undefined : t)} + className={`text-[9px] font-mono px-1.5 py-0.5 rounded transition-colors ${ + (t === "all" && !typeFilter) || t === typeFilter + ? "text-white bg-[rgba(117,170,252,0.15)] border border-[rgba(117,170,252,0.4)]" + : "text-[#556677] hover:text-[#7788aa] border border-transparent" + }`} + > + {t === "all" ? "ALL" : t.toUpperCase()} + </button> + ))} + </div> + </div> + + {/* List */} + <div className="flex-1 overflow-y-auto"> + {filtered.length === 0 ? ( + <div className="px-3 py-6 text-center text-[#556677] font-mono text-[11px]"> + No orders found + </div> + ) : ( + filtered.map((o) => { + const statusBadge = STATUS_BADGE[o.status] || STATUS_BADGE.open; + const typeBadge = TYPE_BADGE[o.orderType] || TYPE_BADGE.feature; + const priorityColor = PRIORITY_COLOR[o.priority] || PRIORITY_COLOR.none; + + return ( + <button + key={o.id} + type="button" + onClick={() => onSelect(o.id)} + className={`w-full text-left px-3 py-2.5 border-b border-[rgba(117,170,252,0.1)] hover:bg-[rgba(117,170,252,0.05)] transition-colors ${ + selectedId === o.id ? "bg-[rgba(117,170,252,0.1)]" : "" + }`} + > + <div className="flex items-start gap-2 mb-1"> + {/* Priority dot */} + <span + className={`w-2 h-2 rounded-full ${priorityColor} shrink-0 mt-[3px]`} + title={o.priority} + /> + <span className="text-[12px] font-mono text-white truncate flex-1"> + {o.title} + </span> + </div> + <div className="flex items-center gap-1.5 pl-4"> + <span + className={`text-[9px] font-mono ${statusBadge.color} border rounded px-1.5 py-0.5`} + > + {statusBadge.label} + </span> + <span + className={`text-[9px] font-mono ${typeBadge.color} border rounded px-1.5 py-0.5`} + > + {typeBadge.label} + </span> + {o.labels.length > 0 && ( + <span className="text-[9px] font-mono text-[#556677] truncate"> + {o.labels.slice(0, 2).join(", ")} + {o.labels.length > 2 && ` +${o.labels.length - 2}`} + </span> + )} + </div> + </button> + ); + }) + )} + </div> + </div> + ); +} diff --git a/makima/frontend/src/components/workflow/PhaseColumn.tsx b/makima/frontend/src/components/workflow/PhaseColumn.tsx deleted file mode 100644 index 277b04c..0000000 --- a/makima/frontend/src/components/workflow/PhaseColumn.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { useState } from "react"; -import type { ContractSummary, ContractPhase } from "../../lib/api"; -import { WorkflowContractCard } from "./WorkflowContractCard"; - -interface PhaseColumnProps { - phase: ContractPhase; - contracts: ContractSummary[]; - onContractClick: (contractId: string) => void; - onDrop: (contractId: string, phase: ContractPhase) => void; - onContextMenu?: (e: React.MouseEvent, contract: ContractSummary) => void; -} - -const phaseConfig: Record< - ContractPhase, - { label: string; color: string; bgColor: string; borderColor: string } -> = { - research: { - label: "Research", - color: "text-purple-400", - bgColor: "bg-purple-400/10", - borderColor: "border-purple-400/30", - }, - specify: { - label: "Specify", - color: "text-blue-400", - bgColor: "bg-blue-400/10", - borderColor: "border-blue-400/30", - }, - plan: { - label: "Plan", - color: "text-cyan-400", - bgColor: "bg-cyan-400/10", - borderColor: "border-cyan-400/30", - }, - execute: { - label: "Execute", - color: "text-yellow-400", - bgColor: "bg-yellow-400/10", - borderColor: "border-yellow-400/30", - }, - review: { - label: "Review", - color: "text-green-400", - bgColor: "bg-green-400/10", - borderColor: "border-green-400/30", - }, -}; - -export function PhaseColumn({ - phase, - contracts, - onContractClick, - onDrop, - onContextMenu, -}: PhaseColumnProps) { - const [isDragOver, setIsDragOver] = useState(false); - const config = phaseConfig[phase]; - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - setIsDragOver(true); - }; - - const handleDragLeave = () => { - setIsDragOver(false); - }; - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - setIsDragOver(false); - const contractId = e.dataTransfer.getData("contractId"); - if (contractId) { - onDrop(contractId, phase); - } - }; - - return ( - <div - className={` - flex flex-col min-w-[220px] flex-1 border border-[rgba(117,170,252,0.15)] - ${isDragOver ? "bg-[rgba(117,170,252,0.05)]" : "bg-transparent"} - transition-colors - `} - onDragOver={handleDragOver} - onDragLeave={handleDragLeave} - onDrop={handleDrop} - > - {/* Column header */} - <div - className={` - p-3 border-b ${config.borderColor} ${config.bgColor} - flex items-center justify-between - `} - > - <span className={`font-mono text-xs uppercase tracking-wider ${config.color}`}> - {config.label} - </span> - <span className="font-mono text-[10px] text-[#555]"> - ({contracts.length}) - </span> - </div> - - {/* Cards container */} - <div className="flex-1 overflow-y-auto p-2 space-y-2"> - {contracts.length === 0 ? ( - <div className="p-4 text-center font-mono text-[10px] text-[#555]"> - No contracts - </div> - ) : ( - contracts.map((contract) => ( - <WorkflowContractCard - key={contract.id} - contract={contract} - onClick={() => onContractClick(contract.id)} - onDragStart={(e) => { - e.dataTransfer.setData("contractId", contract.id); - e.dataTransfer.effectAllowed = "move"; - }} - onContextMenu={onContextMenu ? (e) => onContextMenu(e, contract) : undefined} - /> - )) - )} - </div> - </div> - ); -} diff --git a/makima/frontend/src/components/workflow/WorkflowBoard.tsx b/makima/frontend/src/components/workflow/WorkflowBoard.tsx deleted file mode 100644 index e36ca21..0000000 --- a/makima/frontend/src/components/workflow/WorkflowBoard.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { useMemo, useState } from "react"; -import type { ContractSummary, ContractPhase } from "../../lib/api"; -import { PhaseColumn } from "./PhaseColumn"; -import { ContractContextMenu } from "../contracts/ContractContextMenu"; - -interface WorkflowBoardProps { - contracts: ContractSummary[]; - onContractClick: (contractId: string) => void; - onPhaseChange: (contractId: string, newPhase: ContractPhase) => void; - onMarkComplete?: (contract: ContractSummary) => void; - onMarkActive?: (contract: ContractSummary) => void; - onArchive?: (contract: ContractSummary) => void; - onDelete?: (contract: ContractSummary) => void; - onGoToSupervisor?: (contract: ContractSummary) => void; -} - -const phases: ContractPhase[] = ["research", "specify", "plan", "execute", "review"]; - -export function WorkflowBoard({ - contracts, - onContractClick, - onPhaseChange, - onMarkComplete, - onMarkActive, - onArchive, - onDelete, - onGoToSupervisor, -}: WorkflowBoardProps) { - const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null); - const [contextMenuContract, setContextMenuContract] = useState<ContractSummary | null>(null); - - const handleContextMenu = (e: React.MouseEvent, contract: ContractSummary) => { - e.preventDefault(); - e.stopPropagation(); // Prevent interference with drag-and-drop - setContextMenuPosition({ x: e.clientX, y: e.clientY }); - setContextMenuContract(contract); - }; - - const closeContextMenu = () => { - setContextMenuPosition(null); - setContextMenuContract(null); - }; - - // Group contracts by phase - const contractsByPhase = useMemo(() => { - const grouped: Record<ContractPhase, ContractSummary[]> = { - research: [], - specify: [], - plan: [], - execute: [], - review: [], - }; - - for (const contract of contracts) { - const phase = contract.phase as ContractPhase; - if (grouped[phase]) { - grouped[phase].push(contract); - } else { - // Default to research if unknown phase - grouped.research.push(contract); - } - } - - return grouped; - }, [contracts]); - - return ( - <> - <div className="flex gap-2 h-full overflow-x-auto"> - {phases.map((phase) => ( - <PhaseColumn - key={phase} - phase={phase} - contracts={contractsByPhase[phase]} - onContractClick={onContractClick} - onDrop={onPhaseChange} - onContextMenu={handleContextMenu} - /> - ))} - </div> - - {/* Context Menu */} - {contextMenuPosition && contextMenuContract && ( - <ContractContextMenu - x={contextMenuPosition.x} - y={contextMenuPosition.y} - contract={contextMenuContract} - onClose={closeContextMenu} - onMarkComplete={() => onMarkComplete?.(contextMenuContract)} - onMarkActive={() => onMarkActive?.(contextMenuContract)} - onArchive={() => onArchive?.(contextMenuContract)} - onDelete={() => onDelete?.(contextMenuContract)} - onGoToSupervisor={() => onGoToSupervisor?.(contextMenuContract)} - /> - )} - </> - ); -} diff --git a/makima/frontend/src/components/workflow/WorkflowContractCard.tsx b/makima/frontend/src/components/workflow/WorkflowContractCard.tsx deleted file mode 100644 index 86fcd13..0000000 --- a/makima/frontend/src/components/workflow/WorkflowContractCard.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { useNavigate } from "react-router"; -import type { ContractSummary, ContractStatus } from "../../lib/api"; - -interface WorkflowContractCardProps { - contract: ContractSummary; - onClick: () => void; - onDragStart: (e: React.DragEvent) => void; - onContextMenu?: (e: React.MouseEvent) => void; -} - -const statusConfig: Record<ContractStatus, { label: string; color: string }> = { - active: { label: "Active", color: "text-green-400" }, - completed: { label: "Done", color: "text-blue-400" }, - archived: { label: "Archived", color: "text-[#555]" }, -}; - -export function WorkflowContractCard({ - contract, - onClick, - onDragStart, - onContextMenu, -}: WorkflowContractCardProps) { - const navigate = useNavigate(); - const status = statusConfig[contract.status] || statusConfig.active; - - const handleSupervisorClick = (e: React.MouseEvent) => { - e.stopPropagation(); - if (contract.supervisorTaskId) { - navigate(`/mesh/${contract.supervisorTaskId}`); - } - }; - - return ( - <div - draggable - onDragStart={onDragStart} - onClick={onClick} - onContextMenu={onContextMenu} - className="p-3 bg-[rgba(9,13,20,0.8)] border border-[rgba(117,170,252,0.2)] hover:border-[rgba(117,170,252,0.4)] cursor-pointer transition-colors select-none" - > - {/* Header row with name and supervisor button */} - <div className="flex items-center justify-between gap-2 mb-1"> - <div className="font-mono text-sm text-[#dbe7ff] truncate flex-1"> - {contract.name} - </div> - {contract.supervisorTaskId && ( - <button - onClick={handleSupervisorClick} - title="Open Supervisor Task" - className="flex-shrink-0 px-1.5 py-0.5 font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.1)] transition-colors" - > - ▶ - </button> - )} - </div> - - {/* Status and counts row */} - <div className="flex items-center justify-between"> - <span className={`font-mono text-[10px] uppercase ${status.color}`}> - {status.label} - </span> - <div className="flex items-center gap-2 font-mono text-[10px] text-[#555]"> - <span title="Files">{contract.fileCount} files</span> - <span title="Tasks">{contract.taskCount} tasks</span> - </div> - </div> - - {/* Description preview if exists */} - {contract.description && ( - <div className="mt-1 font-mono text-[10px] text-[#555] truncate"> - {contract.description} - </div> - )} - </div> - ); -} diff --git a/makima/frontend/src/hooks/useOrders.ts b/makima/frontend/src/hooks/useOrders.ts new file mode 100644 index 0000000..2dd20bb --- /dev/null +++ b/makima/frontend/src/hooks/useOrders.ts @@ -0,0 +1,123 @@ +import { useState, useEffect, useCallback } from "react"; +import { + type Order, + type OrderStatus, + type OrderType, + type OrderPriority, + type CreateOrderRequest, + type UpdateOrderRequest, + listOrders, + createOrder, + getOrder, + updateOrder, + deleteOrder, + linkOrderToDirective, + linkOrderToContract, + convertOrderToStep, +} from "../lib/api"; + +export function useOrders( + statusFilter?: OrderStatus, + typeFilter?: OrderType, + priorityFilter?: OrderPriority, +) { + const [orders, setOrders] = useState<Order[]>([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + + const refresh = useCallback(async () => { + try { + setLoading(true); + setError(null); + const res = await listOrders(statusFilter, typeFilter, priorityFilter); + setOrders(res.orders); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load orders"); + } finally { + setLoading(false); + } + }, [statusFilter, typeFilter, priorityFilter]); + + useEffect(() => { + refresh(); + }, [refresh]); + + const create = useCallback(async (req: CreateOrderRequest) => { + const o = await createOrder(req); + await refresh(); + return o; + }, [refresh]); + + const remove = useCallback(async (id: string) => { + await deleteOrder(id); + await refresh(); + }, [refresh]); + + return { orders, loading, error, refresh, create, remove }; +} + +export function useOrder(id: string | undefined) { + const [order, setOrder] = useState<Order | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + + const refresh = useCallback(async () => { + if (!id) return; + try { + setLoading(true); + setError(null); + const o = await getOrder(id); + setOrder(o); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load order"); + } finally { + setLoading(false); + } + }, [id]); + + useEffect(() => { + setOrder(null); + setError(null); + setLoading(true); + refresh(); + }, [id]); // eslint-disable-line react-hooks/exhaustive-deps + + const update = useCallback(async (req: UpdateOrderRequest) => { + if (!id) return; + const o = await updateOrder(id, req); + setOrder(o); + return o; + }, [id]); + + const remove = useCallback(async () => { + if (!id) return; + await deleteOrder(id); + }, [id]); + + const linkDirective = useCallback(async (directiveId: string) => { + if (!id) return; + const o = await linkOrderToDirective(id, directiveId); + setOrder(o); + return o; + }, [id]); + + const linkContract = useCallback(async (contractId: string) => { + 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); + await refresh(); + return step; + }, [id, refresh]); + + return { + order, loading, error, refresh, + update, remove, + linkDirective, linkContract, convertToStep, + }; +} diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index 480041c..f88176b 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -2241,6 +2241,8 @@ export interface PendingQuestion { questionId: string; taskId: string; contractId: string; + /** Directive this question relates to (if from a directive task) */ + directiveId?: string | null; question: string; choices: string[]; context: string | null; @@ -3025,6 +3027,8 @@ export interface Directive { completionTaskId: string | null; /** Whether the memory system is enabled for this directive */ memoryEnabled: boolean; + /** Whether questions pause execution indefinitely until answered */ + reconcileMode: boolean; goalUpdatedAt: string; startedAt: string | null; version: number; @@ -3064,6 +3068,8 @@ export interface DirectiveSummary { completionTaskId: string | null; /** Whether the memory system is enabled for this directive */ memoryEnabled: boolean; + /** Whether questions pause execution indefinitely until answered */ + reconcileMode: boolean; version: number; createdAt: string; updatedAt: string; @@ -3086,6 +3092,8 @@ export interface CreateDirectiveRequest { baseBranch?: string; /** Enable the memory system for this directive (default: false) */ memoryEnabled?: boolean; + /** Whether questions pause execution indefinitely until answered (default: false) */ + reconcileMode?: boolean; } export interface UpdateDirectiveRequest { @@ -3098,6 +3106,8 @@ export interface UpdateDirectiveRequest { orchestratorTaskId?: string; /** Enable or disable the memory system for this directive */ memoryEnabled?: boolean; + /** Whether questions pause execution indefinitely until answered */ + reconcileMode?: boolean; version?: number; } @@ -3246,3 +3256,138 @@ export async function cleanupDirectiveTasks(id: string): Promise<{ deleted: numb return res.json(); } +// ============================================================================= +// Orders API +// ============================================================================= + +export type OrderPriority = "critical" | "high" | "medium" | "low" | "none"; +export type OrderStatus = "open" | "in_progress" | "done" | "archived"; +export type OrderType = "feature" | "bug" | "spike" | "chore" | "improvement"; + +export interface Order { + id: string; + ownerId: string; + title: string; + description: string | null; + priority: OrderPriority; + status: OrderStatus; + orderType: OrderType; + labels: string[]; + directiveId: string | null; + directiveStepId: string | null; + contractId: string | null; + repositoryUrl: string | null; + createdAt: string; + updatedAt: string; +} + +export interface OrderListResponse { + orders: Order[]; + total: number; +} + +export interface CreateOrderRequest { + title: string; + description?: string | null; + priority?: OrderPriority; + status?: OrderStatus; + orderType?: OrderType; + labels?: string[]; + directiveId?: string | null; + contractId?: string | null; + repositoryUrl?: string | null; +} + +export interface UpdateOrderRequest { + title?: string; + description?: string | null; + priority?: OrderPriority; + status?: OrderStatus; + orderType?: OrderType; + labels?: string[]; + directiveId?: string | null; + directiveStepId?: string | null; + contractId?: string | null; + repositoryUrl?: string | null; +} + +export async function listOrders( + status?: OrderStatus, + type?: OrderType, + priority?: OrderPriority, + directiveId?: string, + contractId?: 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); + 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}`); + return res.json(); +} + +export async function createOrder(req: CreateOrderRequest): Promise<Order> { + const res = await authFetch(`${API_BASE}/api/v1/orders`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(req), + }); + if (!res.ok) throw new Error(`Failed to create order: ${res.statusText}`); + return res.json(); +} + +export async function getOrder(id: string): Promise<Order> { + const res = await authFetch(`${API_BASE}/api/v1/orders/${id}`); + if (!res.ok) throw new Error(`Failed to get order: ${res.statusText}`); + return res.json(); +} + +export async function updateOrder(id: string, req: UpdateOrderRequest): Promise<Order> { + const res = await authFetch(`${API_BASE}/api/v1/orders/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(req), + }); + if (!res.ok) throw new Error(`Failed to update order: ${res.statusText}`); + return res.json(); +} + +export async function deleteOrder(id: string): Promise<void> { + const res = await authFetch(`${API_BASE}/api/v1/orders/${id}`, { method: "DELETE" }); + if (!res.ok) throw new Error(`Failed to delete order: ${res.statusText}`); +} + +export async function linkOrderToDirective(orderId: string, directiveId: string): Promise<Order> { + const res = await authFetch(`${API_BASE}/api/v1/orders/${orderId}/link-directive`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ directiveId }), + }); + if (!res.ok) throw new Error(`Failed to link order to directive: ${res.statusText}`); + 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> { + 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/main.tsx b/makima/frontend/src/main.tsx index 3dc68f5..acc9afc 100644 --- a/makima/frontend/src/main.tsx +++ b/makima/frontend/src/main.tsx @@ -12,7 +12,7 @@ import HomePage from "./routes/_index"; import ListenPage from "./routes/listen"; import FilesPage from "./routes/files"; import ContractsPage from "./routes/contracts"; -import WorkflowPage from "./routes/workflow"; +import OrdersPage from "./routes/orders"; import MeshPage from "./routes/mesh"; import HistoryPage from "./routes/history"; import LoginPage from "./routes/login"; @@ -81,10 +81,18 @@ createRoot(document.getElementById("root")!).render( } /> <Route - path="/workflow" + path="/orders" element={ <ProtectedRoute> - <WorkflowPage /> + <OrdersPage /> + </ProtectedRoute> + } + /> + <Route + path="/orders/:id" + element={ + <ProtectedRoute> + <OrdersPage /> </ProtectedRoute> } /> diff --git a/makima/frontend/src/routes/directives.tsx b/makima/frontend/src/routes/directives.tsx index ca4437c..643cfee 100644 --- a/makima/frontend/src/routes/directives.tsx +++ b/makima/frontend/src/routes/directives.tsx @@ -12,7 +12,7 @@ export default function DirectivesPage() { const navigate = useNavigate(); const { id: selectedId } = useParams<{ id: string }>(); const { directives, loading: listLoading, create, remove } = useDirectives(); - const { directive, refresh: refreshDetail, start, pause, advance, completeStep, failStep, skipStep, updateGoal, cleanupTasks } = useDirective(selectedId); + const { directive, refresh: refreshDetail, update, start, pause, advance, completeStep, failStep, skipStep, updateGoal, cleanupTasks } = useDirective(selectedId); const [showCreate, setShowCreate] = useState(false); const [newTitle, setNewTitle] = useState(""); @@ -207,6 +207,7 @@ export default function DirectivesPage() { onFailStep={failStep} onSkipStep={skipStep} onUpdateGoal={updateGoal} + onUpdate={update} onDelete={handleDelete} onRefresh={refreshDetail} onCleanupTasks={cleanupTasks} diff --git a/makima/frontend/src/routes/mesh.tsx b/makima/frontend/src/routes/mesh.tsx index cb4a77c..1d1db84 100644 --- a/makima/frontend/src/routes/mesh.tsx +++ b/makima/frontend/src/routes/mesh.tsx @@ -852,7 +852,7 @@ export default function MeshPage() { <div className="flex-1 min-h-0 overflow-hidden"> <TaskOutput entries={taskOutputEntries} - isStreaming={isStreaming || taskDetail.status === "running"} + isStreaming={isStreaming || taskDetail.status === "running" || taskDetail.status === "starting"} viewingSubtaskName={viewingSubtaskName} onClearSubtaskView={viewingSubtaskId ? () => { setViewingSubtaskId(null); diff --git a/makima/frontend/src/routes/orders.tsx b/makima/frontend/src/routes/orders.tsx new file mode 100644 index 0000000..735c557 --- /dev/null +++ b/makima/frontend/src/routes/orders.tsx @@ -0,0 +1,238 @@ +import { useState, useEffect } from "react"; +import { useParams, useNavigate } from "react-router"; +import { Masthead } from "../components/Masthead"; +import { OrderList } from "../components/orders/OrderList"; +import { OrderDetail } from "../components/orders/OrderDetail"; +import { useOrders, useOrder } from "../hooks/useOrders"; +import { useDirectives } from "../hooks/useDirectives"; +import { useAuth } from "../contexts/AuthContext"; +import type { OrderStatus, OrderType, OrderPriority } from "../lib/api"; + +export default function OrdersPage() { + const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); + const navigate = useNavigate(); + const { id: selectedId } = useParams<{ id: string }>(); + + 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 { directives } = useDirectives(); + + const [showCreate, setShowCreate] = useState(false); + const [newTitle, setNewTitle] = useState(""); + const [newDesc, setNewDesc] = useState(""); + const [newPriority, setNewPriority] = useState<OrderPriority>("medium"); + const [newType, setNewType] = useState<OrderType>("feature"); + + useEffect(() => { + if (!authLoading && isAuthConfigured && !isAuthenticated) { + navigate("/login"); + } + }, [authLoading, isAuthConfigured, isAuthenticated, navigate]); + + if (authLoading) { + return ( + <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> + <Masthead showNav /> + <main className="flex-1 flex items-center justify-center"> + <p className="text-[#7788aa] font-mono text-sm">Loading...</p> + </main> + </div> + ); + } + + const handleCreate = async () => { + if (!newTitle.trim()) return; + try { + const o = await create({ + title: newTitle.trim(), + description: newDesc.trim() || undefined, + priority: newPriority, + orderType: newType, + }); + setShowCreate(false); + setNewTitle(""); + setNewDesc(""); + setNewPriority("medium"); + setNewType("feature"); + navigate(`/orders/${o.id}`); + } catch (e) { + console.error("Failed to create order:", e); + } + }; + + const handleDelete = async () => { + if (!selectedId) return; + if (!window.confirm("Delete this order?")) return; + try { + await removeOrder(); + await refreshList(); + navigate("/orders"); + } catch (e) { + console.error("Failed to delete:", e); + } + }; + + const handleUpdate = async (req: Parameters<typeof update>[0]) => { + await update(req); + await refreshList(); + }; + + const handleLinkDirective = async (directiveId: string) => { + await linkDirective(directiveId); + await refreshList(); + }; + + const handleLinkContract = async (contractId: string) => { + await linkContract(contractId); + await refreshList(); + }; + + const handleConvertToStep = async (directiveId: string) => { + await convertToStep(directiveId); + await refreshList(); + }; + + const priorityOptions: { value: OrderPriority; label: string }[] = [ + { value: "critical", label: "Critical" }, + { value: "high", label: "High" }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, + { value: "none", label: "None" }, + ]; + + const typeOptions: { value: OrderType; label: string }[] = [ + { value: "feature", label: "Feature" }, + { value: "bug", label: "Bug" }, + { value: "spike", label: "Spike" }, + { value: "chore", label: "Chore" }, + { value: "improvement", label: "Improvement" }, + ]; + + return ( + <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> + <Masthead showNav /> + <main className="flex-1 flex overflow-hidden" style={{ height: "calc(100vh - 80px)" }}> + {/* Left: List */} + <div className="w-[280px] shrink-0 border-r border-dashed border-[rgba(117,170,252,0.2)] overflow-hidden flex flex-col"> + <OrderList + orders={orders} + selectedId={selectedId ?? null} + onSelect={(id) => navigate(`/orders/${id}`)} + onCreate={() => setShowCreate(true)} + statusFilter={statusFilter} + onStatusFilter={setStatusFilter} + typeFilter={typeFilter} + onTypeFilter={setTypeFilter} + /> + </div> + + {/* Right: Detail or Create */} + <div className="flex-1 overflow-hidden"> + {showCreate ? ( + <div className="p-4 max-w-lg"> + <h2 className="text-[14px] font-mono text-white font-medium mb-4"> + New Order + </h2> + <div className="flex flex-col gap-3"> + <div> + <label className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide block mb-1"> + Title + </label> + <input + value={newTitle} + onChange={(e) => setNewTitle(e.target.value)} + placeholder="Order title..." + 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" + onKeyDown={(e) => { + if (e.key === "Enter" && newTitle.trim()) handleCreate(); + }} + /> + </div> + <div> + <label className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide block mb-1"> + Description (optional) + </label> + <textarea + value={newDesc} + onChange={(e) => setNewDesc(e.target.value)} + placeholder="Describe the order..." + rows={4} + 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 className="flex gap-4"> + <div className="flex-1"> + <label className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide block mb-1"> + Priority + </label> + <select + value={newPriority} + onChange={(e) => setNewPriority(e.target.value as OrderPriority)} + 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" + > + {priorityOptions.map((p) => ( + <option key={p.value} value={p.value}>{p.label}</option> + ))} + </select> + </div> + <div className="flex-1"> + <label className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide block mb-1"> + Type + </label> + <select + value={newType} + onChange={(e) => setNewType(e.target.value as OrderType)} + 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" + > + {typeOptions.map((t) => ( + <option key={t.value} value={t.value}>{t.label}</option> + ))} + </select> + </div> + </div> + <div className="flex gap-2"> + <button + type="button" + onClick={handleCreate} + disabled={!newTitle.trim()} + 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 + </button> + <button + type="button" + onClick={() => setShowCreate(false)} + className="text-[11px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-3 py-1" + > + Cancel + </button> + </div> + </div> + </div> + ) : selectedId && order ? ( + <OrderDetail + order={order} + directives={directives} + onUpdate={handleUpdate} + onDelete={handleDelete} + onLinkDirective={handleLinkDirective} + onLinkContract={handleLinkContract} + onConvertToStep={handleConvertToStep} + onRefresh={refreshDetail} + /> + ) : ( + <div className="flex-1 flex items-center justify-center h-full"> + <p className="text-[#556677] font-mono text-[12px]"> + {listLoading + ? "Loading..." + : "Select an order or create a new one"} + </p> + </div> + )} + </div> + </main> + </div> + ); +} diff --git a/makima/frontend/src/routes/workflow.tsx b/makima/frontend/src/routes/workflow.tsx deleted file mode 100644 index e122092..0000000 --- a/makima/frontend/src/routes/workflow.tsx +++ /dev/null @@ -1,250 +0,0 @@ -import { useState, useCallback, useEffect, useMemo } from "react"; -import { useNavigate } from "react-router"; -import { Masthead } from "../components/Masthead"; -import { WorkflowBoard } from "../components/workflow/WorkflowBoard"; -import { useContracts } from "../hooks/useContracts"; -import { useAuth } from "../contexts/AuthContext"; -import type { ContractPhase, ContractStatus, ContractSummary } from "../lib/api"; - -type StatusFilter = "all" | ContractStatus; - -export default function WorkflowPage() { - const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); - const navigate = useNavigate(); - - // Redirect to login if not authenticated (when auth is configured) - useEffect(() => { - if (!authLoading && isAuthConfigured && !isAuthenticated) { - navigate("/login"); - } - }, [authLoading, isAuthConfigured, isAuthenticated, navigate]); - - // Show loading while checking auth - if (authLoading) { - return ( - <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> - <Masthead showNav /> - <main className="flex-1 flex items-center justify-center"> - <p className="text-[#7788aa] font-mono text-sm">Loading...</p> - </main> - </div> - ); - } - - // Don't render if not authenticated (will redirect) - if (isAuthConfigured && !isAuthenticated) { - return null; - } - - return <WorkflowPageContent />; -} - -function WorkflowPageContent() { - const navigate = useNavigate(); - const { contracts, loading, error, changePhase, saveContract, editContract, removeContract } = useContracts(); - const [statusFilter, setStatusFilter] = useState<StatusFilter>("all"); - const [isCreating, setIsCreating] = useState(false); - const [newContractName, setNewContractName] = useState(""); - - // Filter contracts by status - const filteredContracts = useMemo(() => { - if (statusFilter === "all") { - return contracts; - } - return contracts.filter((c) => c.status === statusFilter); - }, [contracts, statusFilter]); - - const handleContractClick = useCallback( - (contractId: string) => { - navigate(`/contracts/${contractId}`); - }, - [navigate] - ); - - const handlePhaseChange = useCallback( - async (contractId: string, newPhase: ContractPhase) => { - await changePhase(contractId, newPhase); - }, - [changePhase] - ); - - // Context menu handlers - const handleContextMarkComplete = useCallback( - async (contract: ContractSummary) => { - await editContract(contract.id, { status: "completed", version: contract.version }); - }, - [editContract] - ); - - const handleContextMarkActive = useCallback( - async (contract: ContractSummary) => { - await editContract(contract.id, { status: "active", version: contract.version }); - }, - [editContract] - ); - - const handleContextArchive = useCallback( - async (contract: ContractSummary) => { - await editContract(contract.id, { status: "archived", version: contract.version }); - }, - [editContract] - ); - - const handleContextDelete = useCallback( - async (contract: ContractSummary) => { - if (confirm(`Are you sure you want to delete "${contract.name}"?`)) { - await removeContract(contract.id); - } - }, - [removeContract] - ); - - const handleContextGoToSupervisor = useCallback( - (contract: ContractSummary) => { - if (contract.supervisorTaskId) { - navigate(`/mesh/${contract.supervisorTaskId}`); - } - }, - [navigate] - ); - - const handleCreateContract = useCallback(async () => { - if (!newContractName.trim()) return; - const contract = await saveContract({ - name: newContractName.trim(), - }); - if (contract) { - setNewContractName(""); - setIsCreating(false); - navigate(`/contracts/${contract.id}`); - } - }, [newContractName, saveContract, navigate]); - - const handleCancelCreate = useCallback(() => { - setNewContractName(""); - setIsCreating(false); - }, []); - - return ( - <div className="relative z-10 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 shrink-0"> - {error} - </div> - )} - - {/* Header with filter and create button */} - <div className="flex items-center justify-between shrink-0"> - <div className="flex items-center gap-4"> - <h1 className="font-mono text-sm text-[#75aafc] uppercase tracking-wider"> - Board - </h1> - {/* Status filter */} - <div className="flex items-center gap-1"> - {(["all", "active", "completed", "archived"] as StatusFilter[]).map( - (status) => ( - <button - key={status} - onClick={() => setStatusFilter(status)} - className={` - px-2 py-1 font-mono text-[10px] uppercase transition-colors - ${ - statusFilter === status - ? "bg-[rgba(117,170,252,0.1)] text-[#9bc3ff] border border-[rgba(117,170,252,0.3)]" - : "text-[#555] border border-transparent hover:text-[#75aafc]" - } - `} - > - {status} - </button> - ) - )} - </div> - </div> - <button - onClick={() => setIsCreating(true)} - className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors" - > - + New Contract - </button> - </div> - - {/* Create contract modal */} - {isCreating && ( - <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> - <div className="w-full max-w-md p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)]"> - <h3 className="font-mono text-sm text-[#75aafc] uppercase mb-4"> - Create Contract - </h3> - <div className="space-y-4"> - <input - type="text" - value={newContractName} - onChange={(e) => setNewContractName(e.target.value)} - placeholder="Contract name" - className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" - autoFocus - onKeyDown={(e) => { - if (e.key === "Enter") handleCreateContract(); - if (e.key === "Escape") handleCancelCreate(); - }} - /> - <div className="flex gap-2 justify-end"> - <button - onClick={handleCancelCreate} - className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors" - > - Cancel - </button> - <button - onClick={handleCreateContract} - disabled={!newContractName.trim()} - className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" - > - Create - </button> - </div> - </div> - </div> - </div> - )} - - {/* Board */} - <div className="flex-1 min-h-0 overflow-hidden"> - {loading ? ( - <div className="h-full flex items-center justify-center"> - <p className="font-mono text-sm text-[#555]">Loading...</p> - </div> - ) : filteredContracts.length === 0 && statusFilter === "all" ? ( - <div className="h-full flex items-center justify-center"> - <div className="text-center"> - <p className="font-mono text-sm text-[#555] mb-4"> - No contracts yet - </p> - <button - onClick={() => setIsCreating(true)} - className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase" - > - + Create First Contract - </button> - </div> - </div> - ) : ( - <WorkflowBoard - contracts={filteredContracts} - onContractClick={handleContractClick} - onPhaseChange={handlePhaseChange} - onMarkComplete={handleContextMarkComplete} - onMarkActive={handleContextMarkActive} - onArchive={handleContextArchive} - onDelete={handleContextDelete} - onGoToSupervisor={handleContextGoToSupervisor} - /> - )} - </div> - </main> - </div> - ); -} diff --git a/makima/frontend/tsconfig.tsbuildinfo b/makima/frontend/tsconfig.tsbuildinfo index c2bf573..68f2eac 100644 --- a/makima/frontend/tsconfig.tsbuildinfo +++ b/makima/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/japanesehovertext.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/phaseconfirmationnotification.tsx","./src/components/protectedroute.tsx","./src/components/rewritelink.tsx","./src/components/simplemarkdown.tsx","./src/components/supervisorquestionnotification.tsx","./src/components/charts/chartrenderer.tsx","./src/components/contracts/commandmodepanel.tsx","./src/components/contracts/contractcliinput.tsx","./src/components/contracts/contractcontextmenu.tsx","./src/components/contracts/contractdetail.tsx","./src/components/contracts/contractlist.tsx","./src/components/contracts/phasebadge.tsx","./src/components/contracts/phaseconfirmationmodal.tsx","./src/components/contracts/phasedeliverablespanel.tsx","./src/components/contracts/phasehint.tsx","./src/components/contracts/phaseprogressbar.tsx","./src/components/contracts/quickactionbuttons.tsx","./src/components/contracts/repositorypanel.tsx","./src/components/contracts/taskderivationpreview.tsx","./src/components/directives/directivedag.tsx","./src/components/directives/directivedetail.tsx","./src/components/directives/directivelist.tsx","./src/components/directives/directivelogstream.tsx","./src/components/directives/stepnode.tsx","./src/components/files/bodyrenderer.tsx","./src/components/files/cliinput.tsx","./src/components/files/conflictnotification.tsx","./src/components/files/elementcontextmenu.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/files/reposyncindicator.tsx","./src/components/files/updatenotification.tsx","./src/components/files/versionhistorydropdown.tsx","./src/components/history/checkpointcard.tsx","./src/components/history/checkpointlist.tsx","./src/components/history/conversationmessage.tsx","./src/components/history/conversationview.tsx","./src/components/history/historyfilters.tsx","./src/components/history/resumecontrols.tsx","./src/components/history/timelineeventcard.tsx","./src/components/history/timelinelist.tsx","./src/components/history/index.ts","./src/components/listen/contractpickermodal.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/discusscontractmodal.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptanalysispanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/components/mesh/branchtaskmodal.tsx","./src/components/mesh/contractcompletequestion.tsx","./src/components/mesh/directoryinput.tsx","./src/components/mesh/gitactionspanel.tsx","./src/components/mesh/inlinesubtaskeditor.tsx","./src/components/mesh/mergeconflictresolver.tsx","./src/components/mesh/overlaydiffviewer.tsx","./src/components/mesh/prpreview.tsx","./src/components/mesh/patcheslistpanel.tsx","./src/components/mesh/subtasktree.tsx","./src/components/mesh/taskdetail.tsx","./src/components/mesh/tasklist.tsx","./src/components/mesh/taskoutput.tsx","./src/components/mesh/tasktree.tsx","./src/components/mesh/unifiedmeshchatinput.tsx","./src/components/mesh/worktreefilespanel.tsx","./src/components/workflow/phasecolumn.tsx","./src/components/workflow/workflowboard.tsx","./src/components/workflow/workflowcontractcard.tsx","./src/contexts/authcontext.tsx","./src/contexts/supervisorquestionscontext.tsx","./src/hooks/usecontracts.ts","./src/hooks/usedirectives.ts","./src/hooks/usefilesubscription.ts","./src/hooks/usefiles.ts","./src/hooks/usemeshchathistory.ts","./src/hooks/usemicrophone.ts","./src/hooks/usemultitasksubscription.ts","./src/hooks/usespeakwebsocket.ts","./src/hooks/usetasksubscription.ts","./src/hooks/usetasks.ts","./src/hooks/usetextscramble.ts","./src/hooks/useversionhistory.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/listenapi.ts","./src/lib/markdown.ts","./src/lib/supabase.ts","./src/routes/_index.tsx","./src/routes/contract-file.tsx","./src/routes/contracts.tsx","./src/routes/directives.tsx","./src/routes/files.tsx","./src/routes/history.tsx","./src/routes/listen.tsx","./src/routes/login.tsx","./src/routes/mesh.tsx","./src/routes/settings.tsx","./src/routes/speak.tsx","./src/routes/workflow.tsx","./src/types/messages.ts"],"version":"5.9.3"}
\ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/japanesehovertext.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/phaseconfirmationnotification.tsx","./src/components/protectedroute.tsx","./src/components/rewritelink.tsx","./src/components/simplemarkdown.tsx","./src/components/supervisorquestionnotification.tsx","./src/components/charts/chartrenderer.tsx","./src/components/contracts/commandmodepanel.tsx","./src/components/contracts/contractcliinput.tsx","./src/components/contracts/contractcontextmenu.tsx","./src/components/contracts/contractdetail.tsx","./src/components/contracts/contractlist.tsx","./src/components/contracts/phasebadge.tsx","./src/components/contracts/phaseconfirmationmodal.tsx","./src/components/contracts/phasedeliverablespanel.tsx","./src/components/contracts/phasehint.tsx","./src/components/contracts/phaseprogressbar.tsx","./src/components/contracts/quickactionbuttons.tsx","./src/components/contracts/repositorypanel.tsx","./src/components/contracts/taskderivationpreview.tsx","./src/components/directives/directivedag.tsx","./src/components/directives/directivedetail.tsx","./src/components/directives/directivelist.tsx","./src/components/directives/directivelogstream.tsx","./src/components/directives/stepnode.tsx","./src/components/files/bodyrenderer.tsx","./src/components/files/cliinput.tsx","./src/components/files/conflictnotification.tsx","./src/components/files/elementcontextmenu.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/files/reposyncindicator.tsx","./src/components/files/updatenotification.tsx","./src/components/files/versionhistorydropdown.tsx","./src/components/history/checkpointcard.tsx","./src/components/history/checkpointlist.tsx","./src/components/history/conversationmessage.tsx","./src/components/history/conversationview.tsx","./src/components/history/historyfilters.tsx","./src/components/history/resumecontrols.tsx","./src/components/history/timelineeventcard.tsx","./src/components/history/timelinelist.tsx","./src/components/history/index.ts","./src/components/listen/contractpickermodal.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/discusscontractmodal.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptanalysispanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/components/mesh/branchtaskmodal.tsx","./src/components/mesh/contractcompletequestion.tsx","./src/components/mesh/directoryinput.tsx","./src/components/mesh/gitactionspanel.tsx","./src/components/mesh/inlinesubtaskeditor.tsx","./src/components/mesh/mergeconflictresolver.tsx","./src/components/mesh/overlaydiffviewer.tsx","./src/components/mesh/prpreview.tsx","./src/components/mesh/patcheslistpanel.tsx","./src/components/mesh/subtasktree.tsx","./src/components/mesh/taskdetail.tsx","./src/components/mesh/tasklist.tsx","./src/components/mesh/taskoutput.tsx","./src/components/mesh/tasktree.tsx","./src/components/mesh/unifiedmeshchatinput.tsx","./src/components/mesh/worktreefilespanel.tsx","./src/components/orders/orderdetail.tsx","./src/components/orders/orderlist.tsx","./src/contexts/authcontext.tsx","./src/contexts/supervisorquestionscontext.tsx","./src/hooks/usecontracts.ts","./src/hooks/usedirectives.ts","./src/hooks/usefilesubscription.ts","./src/hooks/usefiles.ts","./src/hooks/usemeshchathistory.ts","./src/hooks/usemicrophone.ts","./src/hooks/usemultitasksubscription.ts","./src/hooks/useorders.ts","./src/hooks/usespeakwebsocket.ts","./src/hooks/usetasksubscription.ts","./src/hooks/usetasks.ts","./src/hooks/usetextscramble.ts","./src/hooks/useversionhistory.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/listenapi.ts","./src/lib/markdown.ts","./src/lib/supabase.ts","./src/routes/_index.tsx","./src/routes/contract-file.tsx","./src/routes/contracts.tsx","./src/routes/directives.tsx","./src/routes/files.tsx","./src/routes/history.tsx","./src/routes/listen.tsx","./src/routes/login.tsx","./src/routes/mesh.tsx","./src/routes/orders.tsx","./src/routes/settings.tsx","./src/routes/speak.tsx","./src/types/messages.ts"],"version":"5.9.3"}
\ No newline at end of file diff --git a/makima/migrations/20260214000000_create_orders.sql b/makima/migrations/20260214000000_create_orders.sql new file mode 100644 index 0000000..cb2fbae --- /dev/null +++ b/makima/migrations/20260214000000_create_orders.sql @@ -0,0 +1,38 @@ +-- Orders system: card-based issue tracker (similar to Linear/Jira/GitHub Issues). +-- Orders represent planned work items (features, bugs, spikes) that can later be +-- attached to directives (as steps) or contracts for execution. + +CREATE TABLE orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id UUID NOT NULL REFERENCES owners(id) ON DELETE CASCADE, + title VARCHAR(500) NOT NULL, + description TEXT, + -- Priority: critical > high > medium > low > none + priority VARCHAR(32) NOT NULL DEFAULT 'medium' + CHECK (priority IN ('critical', 'high', 'medium', 'low', 'none')), + -- Status lifecycle: open -> in_progress -> done | archived + status VARCHAR(32) NOT NULL DEFAULT 'open' + CHECK (status IN ('open', 'in_progress', 'done', 'archived')), + -- Type of work item + order_type VARCHAR(32) NOT NULL DEFAULT 'feature' + CHECK (order_type IN ('feature', 'bug', 'spike', 'chore', 'improvement')), + -- Flexible labels stored as JSON array of strings + labels JSONB NOT NULL DEFAULT '[]', + -- Optional links to directives, directive steps, and contracts + directive_id UUID REFERENCES directives(id) ON DELETE SET NULL, + directive_step_id UUID REFERENCES directive_steps(id) ON DELETE SET NULL, + contract_id UUID REFERENCES contracts(id) ON DELETE SET NULL, + -- Repository context + repository_url VARCHAR(512), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Index for listing orders by owner +CREATE INDEX idx_orders_owner_id ON orders(owner_id); +-- Composite index for filtering by owner + status (common query pattern) +CREATE INDEX idx_orders_owner_status ON orders(owner_id, status); +-- Index for looking up orders linked to a directive +CREATE INDEX idx_orders_directive_id ON orders(directive_id); +-- Index for looking up orders linked to a contract +CREATE INDEX idx_orders_contract_id ON orders(contract_id); diff --git a/makima/migrations/20260214100000_directive_reconcile_mode.sql b/makima/migrations/20260214100000_directive_reconcile_mode.sql new file mode 100644 index 0000000..a06e8f2 --- /dev/null +++ b/makima/migrations/20260214100000_directive_reconcile_mode.sql @@ -0,0 +1,4 @@ +-- Add reconcile_mode flag to directives table. +-- When true, directive task questions pause execution indefinitely until answered. +-- When false (default), questions timeout after 30 seconds. +ALTER TABLE directives ADD COLUMN IF NOT EXISTS reconcile_mode BOOLEAN NOT NULL DEFAULT false; diff --git a/makima/src/daemon/skills/directive.md b/makima/src/daemon/skills/directive.md index 68d9277..9d2b644 100644 --- a/makima/src/daemon/skills/directive.md +++ b/makima/src/daemon/skills/directive.md @@ -76,6 +76,12 @@ Updates the goal and bumps `goalUpdatedAt`. If the directive is `idle`, it react makima directive pause ``` +### Update Directive Metadata +```bash +makima directive update --pr-url "<url>" --pr-branch "<branch>" +``` +Updates the directive's PR URL and/or PR branch. Used by completion tasks to store the PR URL after creating it. + ## Memory Commands Directives have an optional key-value memory system that persists across steps and planning cycles. Use memory to share context, decisions, and learned information between steps — so downstream tasks don't need to re-discover what earlier steps already figured out. diff --git a/makima/src/daemon/task/manager.rs b/makima/src/daemon/task/manager.rs index ce5a580..76138c1 100644 --- a/makima/src/daemon/task/manager.rs +++ b/makima/src/daemon/task/manager.rs @@ -1611,14 +1611,14 @@ impl TaskManager { } // Regular message - send to task's stdin - tracing::info!(task_id = %task_id, message_len = message.len(), "Sending message to task"); + tracing::info!(task_id = %task_id, message_len = message.len(), "Sending message to task stdin"); // Send message to the task's stdin via the input channel let inputs = self.task_inputs.read().await; if let Some(sender) = inputs.get(&task_id) { if let Err(e) = sender.send(message).await { - tracing::warn!(task_id = %task_id, error = %e, "Failed to send message to task input channel"); + tracing::warn!(task_id = %task_id, error = %e, "Failed to send message to task input channel (channel may be closed, stdin forwarder may have exited)"); } else { - tracing::info!(task_id = %task_id, "Message sent to task successfully"); + tracing::info!(task_id = %task_id, "Message sent to task input channel successfully, will be forwarded to Claude stdin"); } } else { drop(inputs); // Release read lock before checking if we need to respawn @@ -5192,12 +5192,19 @@ impl TaskManagerInner { // Check if this is a "result" message indicating task completion // With --input-format=stream-json, Claude waits for more input after completion - // We close stdin to signal EOF and let the process exit if line.json_type.as_deref() == Some("result") { - tracing::info!(task_id = %task_id, "Received result message, closing stdin to signal completion"); - let mut stdin_guard = stdin_handle_for_completion.lock().await; - if let Some(mut stdin) = stdin_guard.take() { - let _ = stdin.shutdown().await; + if autonomous_loop { + // In autonomous loop mode, close stdin to let the process exit + // so we can spawn the next iteration with --continue + tracing::info!(task_id = %task_id, "Received result message in autonomous loop, closing stdin to signal completion"); + let mut stdin_guard = stdin_handle_for_completion.lock().await; + if let Some(mut stdin) = stdin_guard.take() { + let _ = stdin.shutdown().await; + } + } else { + // In interactive mode, keep stdin open so the user can send + // follow-up messages. Claude will stay alive waiting for input. + tracing::info!(task_id = %task_id, "Received result message, keeping stdin open for interactive input"); } } diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index 131dffc..6ec6cf4 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -2714,6 +2714,8 @@ pub struct Directive { pub pr_url: Option<String>, pub pr_branch: Option<String>, pub completion_task_id: Option<Uuid>, + /// Whether questions pause execution indefinitely until answered + pub reconcile_mode: bool, pub goal_updated_at: DateTime<Utc>, pub started_at: Option<DateTime<Utc>>, pub version: i32, @@ -2763,6 +2765,8 @@ pub struct DirectiveSummary { pub orchestrator_task_id: Option<Uuid>, pub pr_url: Option<String>, pub completion_task_id: Option<Uuid>, + /// Whether questions pause execution indefinitely until answered + pub reconcile_mode: bool, pub version: i32, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>, @@ -2789,6 +2793,8 @@ pub struct CreateDirectiveRequest { pub repository_url: Option<String>, pub local_path: Option<String>, pub base_branch: Option<String>, + /// Whether questions pause execution indefinitely until answered + pub reconcile_mode: Option<bool>, } /// Request to update a directive. @@ -2804,6 +2810,8 @@ pub struct UpdateDirectiveRequest { pub orchestrator_task_id: Option<Uuid>, pub pr_url: Option<String>, pub pr_branch: Option<String>, + /// Whether questions pause execution indefinitely until answered + pub reconcile_mode: Option<bool>, pub version: Option<i32>, } @@ -2848,3 +2856,120 @@ pub struct UpdateDirectiveStepRequest { pub order_index: Option<i32>, } +// ============================================================================= +// Order Types +// ============================================================================= + +/// 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. +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Order { + pub id: Uuid, + pub owner_id: Uuid, + pub title: String, + pub description: Option<String>, + /// Priority: critical, high, medium, low, none + pub priority: String, + /// Status: open, in_progress, done, archived + pub status: String, + /// Type of work: feature, bug, spike, chore, improvement + pub order_type: String, + /// Flexible labels as JSON array of strings + pub labels: serde_json::Value, + /// Linked directive (optional) + pub directive_id: Option<Uuid>, + /// Linked directive step (optional) + pub directive_step_id: Option<Uuid>, + /// Linked contract (optional) + pub contract_id: Option<Uuid>, + /// Repository context + pub repository_url: Option<String>, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, +} + +/// Request to create a new order. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateOrderRequest { + pub title: String, + pub description: Option<String>, + pub priority: Option<String>, + pub status: Option<String>, + 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>, + pub repository_url: Option<String>, +} + +/// Default empty JSON array for labels. +fn default_empty_labels() -> serde_json::Value { + serde_json::json!([]) +} + +/// Request to update an existing order. +#[derive(Debug, Default, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateOrderRequest { + pub title: Option<String>, + pub description: Option<String>, + pub priority: Option<String>, + pub status: Option<String>, + pub order_type: Option<String>, + 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>, +} + +/// Response for order list endpoint. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct OrderListResponse { + pub orders: Vec<Order>, + pub total: i64, +} + +/// Query parameters for listing orders. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct OrderListQuery { + /// Filter by status (e.g., "open", "in_progress", "done", "archived") + pub status: Option<String>, + /// Filter by order type (e.g., "feature", "bug", "spike", "chore", "improvement") + #[serde(rename = "type")] + pub order_type: Option<String>, + /// Filter by priority (e.g., "critical", "high", "medium", "low", "none") + pub priority: Option<String>, + /// Filter by linked directive ID + pub directive_id: Option<Uuid>, + /// Filter by linked contract ID + pub contract_id: Option<Uuid>, +} + +/// Request body for linking an order to a directive. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +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 d8168f6..ed4a1fa 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -14,6 +14,7 @@ use super::models::{ DeliverableDefinition, Directive, DirectiveStep, DirectiveSummary, CreateDirectiveRequest, CreateDirectiveStepRequest, UpdateDirectiveRequest, UpdateDirectiveStepRequest, + CreateOrderRequest, Order, UpdateOrderRequest, File, FileSummary, FileVersion, HistoryEvent, HistoryQueryFilters, MeshChatConversation, MeshChatMessageRecord, PhaseChangeResult, PhaseConfig, PhaseDefinition, SupervisorHeartbeatRecord, SupervisorState, @@ -4929,8 +4930,8 @@ pub async fn create_directive_for_owner( ) -> Result<Directive, sqlx::Error> { sqlx::query_as::<_, Directive>( r#" - INSERT INTO directives (owner_id, title, goal, repository_url, local_path, base_branch) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO directives (owner_id, title, goal, repository_url, local_path, base_branch, reconcile_mode) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING * "#, ) @@ -4940,6 +4941,7 @@ pub async fn create_directive_for_owner( .bind(&req.repository_url) .bind(&req.local_path) .bind(&req.base_branch) + .bind(req.reconcile_mode.unwrap_or(false)) .fetch_one(pool) .await } @@ -4992,6 +4994,7 @@ pub async fn list_directives_for_owner( SELECT d.id, d.owner_id, d.title, d.goal, d.status, d.repository_url, d.orchestrator_task_id, d.pr_url, d.completion_task_id, + d.reconcile_mode, d.version, d.created_at, d.updated_at, COALESCE(s.total_steps, 0) as total_steps, COALESCE(s.completed_steps, 0) as completed_steps, @@ -5055,12 +5058,14 @@ pub async fn update_directive_for_owner( let orchestrator_task_id = req.orchestrator_task_id.or(current.orchestrator_task_id); let pr_url = req.pr_url.as_deref().or(current.pr_url.as_deref()); let pr_branch = req.pr_branch.as_deref().or(current.pr_branch.as_deref()); + let reconcile_mode = req.reconcile_mode.unwrap_or(current.reconcile_mode); let result = sqlx::query_as::<_, Directive>( r#" UPDATE directives SET title = $3, goal = $4, status = $5, repository_url = $6, local_path = $7, base_branch = $8, orchestrator_task_id = $9, pr_url = $10, pr_branch = $11, + reconcile_mode = $12, version = version + 1, updated_at = NOW() WHERE id = $1 AND owner_id = $2 RETURNING * @@ -5077,6 +5082,7 @@ pub async fn update_directive_for_owner( .bind(orchestrator_task_id) .bind(pr_url) .bind(pr_branch) + .bind(reconcile_mode) .fetch_optional(pool) .await .map_err(RepositoryError::Database)?; @@ -5188,6 +5194,7 @@ pub struct CompletedStepTask { #[derive(Debug, Clone, sqlx::FromRow)] pub struct DirectiveCompletionCheck { pub directive_id: Uuid, + pub owner_id: Uuid, pub completion_task_id: Uuid, pub task_status: String, pub pr_url: Option<String>, @@ -5224,7 +5231,7 @@ pub async fn get_completion_tasks_to_check( ) -> Result<Vec<DirectiveCompletionCheck>, sqlx::Error> { sqlx::query_as::<_, DirectiveCompletionCheck>( r#" - SELECT d.id as directive_id, d.completion_task_id, t.status as task_status, d.pr_url + SELECT d.id as directive_id, d.owner_id, d.completion_task_id, t.status as task_status, d.pr_url FROM directives d JOIN tasks t ON t.id = d.completion_task_id WHERE d.completion_task_id IS NOT NULL @@ -5917,3 +5924,306 @@ pub async fn get_directive_max_generation( Ok(row.0.unwrap_or(0)) } +// ============================================================================= +// Order CRUD +// ============================================================================= + +/// Create a new order for the given owner. +pub async fn create_order( + pool: &PgPool, + owner_id: Uuid, + req: CreateOrderRequest, +) -> Result<Order, sqlx::Error> { + let priority = req.priority.as_deref().unwrap_or("medium"); + let status = req.status.as_deref().unwrap_or("open"); + let order_type = req.order_type.as_deref().unwrap_or("feature"); + + 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) + RETURNING * + "#, + ) + .bind(owner_id) + .bind(&req.title) + .bind(&req.description) + .bind(priority) + .bind(status) + .bind(order_type) + .bind(&req.labels) + .bind(req.directive_id) + .bind(req.contract_id) + .bind(&req.repository_url) + .fetch_one(pool) + .await +} + +/// List orders for the given owner with optional filters. +pub async fn list_orders( + pool: &PgPool, + owner_id: Uuid, + status_filter: Option<&str>, + type_filter: Option<&str>, + priority_filter: Option<&str>, + directive_id_filter: Option<Uuid>, + contract_id_filter: Option<Uuid>, +) -> Result<Vec<Order>, sqlx::Error> { + // Build dynamic query with optional filters + let mut query = String::from("SELECT * FROM orders WHERE owner_id = $1"); + let mut param_idx = 2u32; + + if status_filter.is_some() { + query.push_str(&format!(" AND status = ${}", param_idx)); + param_idx += 1; + } + if type_filter.is_some() { + query.push_str(&format!(" AND order_type = ${}", param_idx)); + param_idx += 1; + } + if priority_filter.is_some() { + query.push_str(&format!(" AND priority = ${}", param_idx)); + param_idx += 1; + } + if directive_id_filter.is_some() { + 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)); + let _ = param_idx; // suppress unused warning + } + query.push_str(" ORDER BY created_at DESC"); + + let mut q = sqlx::query_as::<_, Order>(&query).bind(owner_id); + + if let Some(s) = status_filter { + q = q.bind(s); + } + if let Some(t) = type_filter { + q = q.bind(t); + } + if let Some(p) = priority_filter { + q = q.bind(p); + } + if let Some(d) = directive_id_filter { + q = q.bind(d); + } + if let Some(c) = contract_id_filter { + q = q.bind(c); + } + + q.fetch_all(pool).await +} + +/// Get a single order by ID (owner-scoped). +pub async fn get_order( + pool: &PgPool, + owner_id: Uuid, + order_id: Uuid, +) -> Result<Option<Order>, sqlx::Error> { + sqlx::query_as::<_, Order>( + r#"SELECT * FROM orders WHERE id = $1 AND owner_id = $2"#, + ) + .bind(order_id) + .bind(owner_id) + .fetch_optional(pool) + .await +} + +/// Update an order (owner-scoped). Uses COALESCE pattern to only update provided fields. +pub async fn update_order( + pool: &PgPool, + owner_id: Uuid, + order_id: Uuid, + req: UpdateOrderRequest, +) -> Result<Option<Order>, sqlx::Error> { + let current = sqlx::query_as::<_, Order>( + r#"SELECT * FROM orders WHERE id = $1 AND owner_id = $2"#, + ) + .bind(order_id) + .bind(owner_id) + .fetch_optional(pool) + .await?; + + let current = match current { + Some(c) => c, + None => return Ok(None), + }; + + let title = req.title.as_deref().unwrap_or(¤t.title); + let description = req.description.as_deref().or(current.description.as_deref()); + let priority = req.priority.as_deref().unwrap_or(¤t.priority); + let status = req.status.as_deref().unwrap_or(¤t.status); + let order_type = req.order_type.as_deref().unwrap_or(¤t.order_type); + 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>( + r#" + 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() + WHERE id = $1 AND owner_id = $2 + RETURNING * + "#, + ) + .bind(order_id) + .bind(owner_id) + .bind(title) + .bind(description) + .bind(priority) + .bind(status) + .bind(order_type) + .bind(labels) + .bind(directive_id) + .bind(directive_step_id) + .bind(contract_id) + .bind(repository_url) + .fetch_optional(pool) + .await +} + +/// Delete an order (owner-scoped). Returns true if a row was deleted. +pub async fn delete_order( + pool: &PgPool, + owner_id: Uuid, + order_id: Uuid, +) -> Result<bool, sqlx::Error> { + let result = sqlx::query( + r#"DELETE FROM orders WHERE id = $1 AND owner_id = $2"#, + ) + .bind(order_id) + .bind(owner_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// Link an order to a directive. +pub async fn link_order_to_directive( + pool: &PgPool, + owner_id: Uuid, + order_id: Uuid, + directive_id: Uuid, +) -> Result<Option<Order>, sqlx::Error> { + sqlx::query_as::<_, Order>( + r#" + UPDATE orders + SET directive_id = $3, updated_at = NOW() + WHERE id = $1 AND owner_id = $2 + RETURNING * + "#, + ) + .bind(order_id) + .bind(owner_id) + .bind(directive_id) + .fetch_optional(pool) + .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. +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>( + r#"SELECT * FROM orders WHERE id = $1 AND owner_id = $2"#, + ) + .bind(order_id) + .bind(owner_id) + .fetch_optional(pool) + .await?; + + let order = match order { + Some(o) => o, + 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"#, + ) + .bind(directive_id) + .bind(owner_id) + .fetch_optional(pool) + .await?; + + if directive.is_none() { + return Ok(None); + } + + // Get the next order_index for this directive + let max_index: (Option<i32>,) = sqlx::query_as( + r#"SELECT MAX(order_index) FROM directive_steps WHERE directive_id = $1"#, + ) + .bind(directive_id) + .fetch_one(pool) + .await?; + let next_index = max_index.0.unwrap_or(-1) + 1; + + // Create the directive step from order data + let step = sqlx::query_as::<_, DirectiveStep>( + r#" + INSERT INTO directive_steps (directive_id, name, description, order_index) + VALUES ($1, $2, $3, $4) + RETURNING * + "#, + ) + .bind(directive_id) + .bind(&order.title) + .bind(&order.description) + .bind(next_index) + .fetch_one(pool) + .await?; + + // Link the order to the directive and the new step + sqlx::query( + r#" + UPDATE orders + SET directive_id = $3, directive_step_id = $4, 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?; + + Ok(Some(step)) +} + diff --git a/makima/src/orchestration/directive.rs b/makima/src/orchestration/directive.rs index ea8009d..0deacca 100644 --- a/makima/src/orchestration/directive.rs +++ b/makima/src/orchestration/directive.rs @@ -248,6 +248,16 @@ impl DirectiveOrchestrator { .await?; repository::check_directive_idle(&self.pool, step.directive_id).await?; } + "paused" => { + // Task is paused (e.g., waiting for user answer in reconcile mode) + // Keep step in running status — task will auto-resume when answered + tracing::debug!( + step_id = %step.step_id, + directive_id = %step.directive_id, + task_id = %step.task_id, + "Step task paused (waiting for user response) — keeping step running" + ); + } _ => { // Still running — do nothing } @@ -627,6 +637,45 @@ impl DirectiveOrchestrator { task_id = %check.completion_task_id, "Completion task finished" ); + + // If directive has no pr_url yet, try to extract from task output + if check.pr_url.is_none() { + match self.extract_pr_url_from_task(check.completion_task_id).await { + Ok(Some(url)) => { + tracing::info!( + directive_id = %check.directive_id, + pr_url = %url, + "Extracted PR URL from completion task output" + ); + let update = crate::db::models::UpdateDirectiveRequest { + pr_url: Some(url), + ..Default::default() + }; + let _ = repository::update_directive_for_owner( + &self.pool, + check.owner_id, + check.directive_id, + update, + ) + .await; + } + Ok(None) => { + tracing::warn!( + directive_id = %check.directive_id, + task_id = %check.completion_task_id, + "Completion task finished but no PR URL found in output" + ); + } + Err(e) => { + tracing::warn!( + directive_id = %check.directive_id, + error = %e, + "Failed to extract PR URL from completion task output" + ); + } + } + } + repository::clear_completion_task(&self.pool, check.directive_id).await?; } "failed" | "interrupted" => { @@ -688,6 +737,36 @@ impl DirectiveOrchestrator { Ok(task.id) } + + /// Extract a GitHub PR URL from a completion task's output events. + /// Searches task output for patterns like `https://github.com/.../pull/123`. + async fn extract_pr_url_from_task( + &self, + task_id: Uuid, + ) -> Result<Option<String>, anyhow::Error> { + let events = repository::get_task_output(&self.pool, task_id, Some(500)).await?; + + let pr_url_re = regex::Regex::new(r"https://github\.com/[^/\s]+/[^/\s]+/pull/\d+")?; + + // Search from most recent events backwards for the PR URL + for event in events.iter().rev() { + if let Some(ref data) = event.event_data { + // Check the content field inside event_data JSON + if let Some(content) = data.get("content").and_then(|c| c.as_str()) { + if let Some(m) = pr_url_re.find(content) { + return Ok(Some(m.as_str().to_string())); + } + } + // Also check the raw JSON string representation as fallback + let data_str = data.to_string(); + if let Some(m) = pr_url_re.find(&data_str) { + return Ok(Some(m.as_str().to_string())); + } + } + } + + Ok(None) + } } /// Build the planning prompt for a directive. @@ -952,10 +1031,15 @@ Then create the PR: gh pr create --title "{title}" --body "{pr_body}" --head {directive_branch} --base {base_branch} ``` -After creating the PR, store the URL: +IMPORTANT: After creating the PR, you MUST store the PR URL so the directive system can track it. + +1. Run `gh pr create` as shown above and capture its output +2. The output will contain the PR URL (e.g., https://github.com/owner/repo/pull/123) +3. Then run this command to store the URL: ``` -makima directive update --pr-url "<the PR URL from gh pr create output>" +makima directive update --pr-url "https://github.com/..." ``` +Replace the URL with the actual PR URL from the `gh pr create` output. This step is CRITICAL — the PR will not be tracked by the directive system without it. If there are merge conflicts, resolve them sensibly before pushing. "#, diff --git a/makima/src/server/handlers/mesh.rs b/makima/src/server/handlers/mesh.rs index eb87e17..c840676 100644 --- a/makima/src/server/handlers/mesh.rs +++ b/makima/src/server/handlers/mesh.rs @@ -1070,17 +1070,19 @@ pub async fn send_message( } }; - // Check if task is running (except for AUTH_CODE messages and supervisor tasks) - // Supervisor tasks can receive messages even when not running - daemon will respawn Claude + // Check if task is in a state that can receive messages + // Allow "running" and "starting" (to handle race between status update and message send) + // Also allow AUTH_CODE messages and supervisor tasks regardless of status let is_auth_code = req.message.starts_with("AUTH_CODE:"); let is_supervisor = task.is_supervisor; - if task.status != "running" && !is_auth_code && !is_supervisor { + let can_receive_message = task.status == "running" || task.status == "starting"; + if !can_receive_message && !is_auth_code && !is_supervisor { return ( StatusCode::BAD_REQUEST, Json(ApiError::new( "INVALID_STATE", format!( - "Cannot send message to task in status: {}. Task must be running.", + "Cannot send message to task in status: {}. Task must be running or starting.", task.status ), )), diff --git a/makima/src/server/handlers/mesh_supervisor.rs b/makima/src/server/handlers/mesh_supervisor.rs index c9cb849..90c6dc7 100644 --- a/makima/src/server/handlers/mesh_supervisor.rs +++ b/makima/src/server/handlers/mesh_supervisor.rs @@ -129,6 +129,9 @@ pub struct PendingQuestionSummary { pub question_id: Uuid, pub task_id: Uuid, pub contract_id: Uuid, + /// Directive this question relates to (if from a directive task) + #[serde(skip_serializing_if = "Option::is_none")] + pub directive_id: Option<Uuid>, pub question: String, pub choices: Vec<String>, pub context: Option<String>, @@ -257,11 +260,11 @@ async fn verify_supervisor_auth( ) })?; - // Verify task is a supervisor - if !task.is_supervisor { + // Verify task is a supervisor or a directive task + if !task.is_supervisor && task.directive_id.is_none() { return Err(( StatusCode::FORBIDDEN, - Json(ApiError::new("NOT_SUPERVISOR", "Only supervisor tasks can use these endpoints")), + Json(ApiError::new("NOT_SUPERVISOR", "Only supervisor or directive tasks can use these endpoints")), )); } @@ -1694,17 +1697,43 @@ pub async fn ask_question( } }; - let Some(contract_id) = supervisor.contract_id else { + // Determine context: contract or directive + let contract_id = supervisor.contract_id; + let directive_id = supervisor.directive_id; + + if contract_id.is_none() && directive_id.is_none() { return ( StatusCode::BAD_REQUEST, - Json(ApiError::new("NO_CONTRACT", "Supervisor has no associated contract")), + Json(ApiError::new("NO_CONTEXT", "Supervisor has no associated contract or directive")), ).into_response(); + } + + let is_directive_context = directive_id.is_some() && contract_id.is_none(); + + // For directive context, check reconcile_mode to determine behavior + let directive_reconcile_mode = if let Some(did) = directive_id { + if is_directive_context { + match repository::get_directive_for_owner(pool, owner_id, did).await { + Ok(Some(d)) => d.reconcile_mode, + Ok(None) => false, + Err(e) => { + tracing::warn!(error = %e, "Failed to get directive for reconcile_mode check"); + false + } + } + } else { + false + } + } else { + false }; - // Add the question - let question_id = state.add_supervisor_question( + // Add the question (use Uuid::nil() for contract_id in directive-only context) + let effective_contract_id = contract_id.unwrap_or(Uuid::nil()); + let question_id = state.add_supervisor_question_with_directive( supervisor_id, - contract_id, + effective_contract_id, + directive_id, owner_id, request.question.clone(), request.choices.clone(), @@ -1714,15 +1743,18 @@ pub async fn ask_question( ); // Save state: question asked is a key save point (Task 3.3) - let pending_question = PendingQuestion { - id: question_id, - question: request.question.clone(), - choices: request.choices.clone(), - context: request.context.clone(), - question_type: request.question_type.clone(), - asked_at: chrono::Utc::now(), - }; - save_state_on_question_asked(pool, contract_id, pending_question).await; + // Only for contract context — directive tasks don't use supervisor_states table + if let Some(cid) = contract_id { + let pending_question = PendingQuestion { + id: question_id, + question: request.question.clone(), + choices: request.choices.clone(), + context: request.context.clone(), + question_type: request.question_type.clone(), + asked_at: chrono::Utc::now(), + }; + save_state_on_question_asked(pool, cid, pending_question).await; + } // Broadcast question as task output entry for the task's chat let question_data = serde_json::json!({ @@ -1775,9 +1807,10 @@ pub async fn ask_question( ).into_response(); } - // If phaseguard is enabled, pause the supervisor task and return + // If phaseguard is enabled (or directive reconcile mode), pause the supervisor task and return // The task will be auto-resumed when a message is sent to it (e.g., when user answers) - if request.phaseguard { + let use_phaseguard = request.phaseguard || (is_directive_context && directive_reconcile_mode); + if use_phaseguard { // Pause the supervisor task if let Some(daemon_id) = supervisor.daemon_id { let cmd = DaemonCommand::PauseTask { task_id: supervisor_id }; @@ -1808,7 +1841,13 @@ pub async fn ask_question( } // Poll for response with timeout - let timeout_duration = std::time::Duration::from_secs(request.timeout_seconds.max(1) as u64); + // For directive tasks without reconcile mode, use 30s default timeout + let timeout_secs = if is_directive_context && !directive_reconcile_mode { + 30 + } else { + request.timeout_seconds.max(1) as u64 + }; + let timeout_duration = std::time::Duration::from_secs(timeout_secs); let start = std::time::Instant::now(); let poll_interval = std::time::Duration::from_millis(500); @@ -1819,7 +1858,10 @@ pub async fn ask_question( state.cleanup_question_response(question_id); // Clear pending question from supervisor state (Task 3.3) - clear_pending_question(pool, contract_id, question_id).await; + // Skip for directive context — no supervisor_states for directives + if let Some(cid) = contract_id { + clear_pending_question(pool, cid, question_id).await; + } return ( StatusCode::OK, @@ -1837,7 +1879,10 @@ pub async fn ask_question( state.remove_pending_question(question_id); // Clear pending question from supervisor state on timeout (Task 3.3) - clear_pending_question(pool, contract_id, question_id).await; + // Skip for directive context — no supervisor_states for directives + if let Some(cid) = contract_id { + clear_pending_question(pool, cid, question_id).await; + } return ( StatusCode::REQUEST_TIMEOUT, @@ -1880,6 +1925,7 @@ pub async fn list_pending_questions( question_id: q.question_id, task_id: q.task_id, contract_id: q.contract_id, + directive_id: q.directive_id, question: q.question, choices: q.choices, context: q.context, diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs index 29cd09f..8b06a28 100644 --- a/makima/src/server/handlers/mod.rs +++ b/makima/src/server/handlers/mod.rs @@ -13,6 +13,7 @@ pub mod history; pub mod listen; pub mod mesh; pub mod mesh_chat; +pub mod orders; pub mod mesh_daemon; pub mod mesh_merge; pub mod mesh_supervisor; diff --git a/makima/src/server/handlers/orders.rs b/makima/src/server/handlers/orders.rs new file mode 100644 index 0000000..c43c406 --- /dev/null +++ b/makima/src/server/handlers/orders.rs @@ -0,0 +1,443 @@ +//! HTTP handlers for order CRUD operations. +//! Orders are card-based work items (features, bugs, spikes) similar to +//! GitHub Issues or Linear cards. + +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use uuid::Uuid; + +use crate::db::models::{ + ConvertToStepRequest, CreateOrderRequest, DirectiveStep, LinkContractRequest, + LinkDirectiveRequest, Order, OrderListQuery, OrderListResponse, UpdateOrderRequest, +}; +use crate::db::repository; +use crate::server::auth::Authenticated; +use crate::server::messages::ApiError; +use crate::server::state::SharedState; + +// ============================================================================= +// Order CRUD +// ============================================================================= + +/// List all orders for the authenticated user. +#[utoipa::path( + get, + path = "/api/v1/orders", + params( + ("status" = Option<String>, Query, description = "Filter by status"), + ("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"), + ), + responses( + (status = 200, description = "List of orders", body = OrderListResponse), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Orders" +)] +pub async fn list_orders( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Query(query): Query<OrderListQuery>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::list_orders( + pool, + auth.owner_id, + query.status.as_deref(), + query.order_type.as_deref(), + query.priority.as_deref(), + query.directive_id, + query.contract_id, + ) + .await + { + Ok(orders) => { + let total = orders.len() as i64; + Json(OrderListResponse { orders, total }).into_response() + } + Err(e) => { + tracing::error!("Failed to list orders: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("LIST_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Create a new order. +#[utoipa::path( + post, + path = "/api/v1/orders", + request_body = CreateOrderRequest, + responses( + (status = 201, description = "Order created", body = Order), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Orders" +)] +pub async fn create_order( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Json(req): Json<CreateOrderRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::create_order(pool, auth.owner_id, req).await { + Ok(order) => (StatusCode::CREATED, Json(order)).into_response(), + Err(e) => { + tracing::error!("Failed to create order: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("CREATE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Get an order by ID. +#[utoipa::path( + get, + path = "/api/v1/orders/{id}", + params(("id" = Uuid, Path, description = "Order ID")), + responses( + (status = 200, description = "Order details", 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 get_order( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::get_order(pool, auth.owner_id, 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 get order: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Update an order. +#[utoipa::path( + patch, + path = "/api/v1/orders/{id}", + params(("id" = Uuid, Path, description = "Order ID")), + request_body = UpdateOrderRequest, + responses( + (status = 200, description = "Order updated", 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 update_order( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<UpdateOrderRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::update_order(pool, auth.owner_id, id, req).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 update order: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("UPDATE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Delete an order. +#[utoipa::path( + delete, + path = "/api/v1/orders/{id}", + params(("id" = Uuid, Path, description = "Order ID")), + responses( + (status = 204, description = "Deleted"), + (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 delete_order( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::delete_order(pool, auth.owner_id, id).await { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Order not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to delete order: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DELETE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= +// Order Linking & Conversion +// ============================================================================= + +/// Link an order to a directive. +#[utoipa::path( + post, + path = "/api/v1/orders/{id}/link-directive", + params(("id" = Uuid, Path, description = "Order ID")), + request_body = LinkDirectiveRequest, + responses( + (status = 200, description = "Order linked to directive", 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_directive( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<LinkDirectiveRequest>, +) -> 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 directive exists and belongs to this owner + match repository::get_directive_for_owner(pool, auth.owner_id, req.directive_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive 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_directive(pool, auth.owner_id, id, req.directive_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 directive: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("LINK_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// 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. +#[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), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Orders" +)] +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 ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::convert_order_to_step(pool, auth.owner_id, id, req.directive_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")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to convert order to step: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("CONVERT_FAILED", &e.to_string())), + ) + .into_response() + } + } +} diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index c1e1309..29c55c4 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -18,7 +18,7 @@ use tower_http::trace::TraceLayer; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; -use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contract_discuss, contracts, directives, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, repository_history, speak, templates, transcript_analysis, users, versions}; +use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contract_discuss, contracts, directives, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, orders, repository_history, speak, templates, transcript_analysis, users, versions}; use crate::server::openapi::ApiDoc; use crate::server::state::SharedState; @@ -238,6 +238,20 @@ pub fn make_router(state: SharedState) -> Router { .route("/directives/{id}/steps/{step_id}/skip", post(directives::skip_step)) .route("/directives/{id}/goal", put(directives::update_goal)) .route("/directives/{id}/cleanup-tasks", post(directives::cleanup_tasks)) + // Order endpoints + .route( + "/orders", + get(orders::list_orders).post(orders::create_order), + ) + .route( + "/orders/{id}", + get(orders::get_order) + .patch(orders::update_order) + .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)) // Contract type templates (built-in only) diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs index e68286e..b21dab9 100644 --- a/makima/src/server/openapi.rs +++ b/makima/src/server/openapi.rs @@ -8,26 +8,30 @@ use crate::db::models::{ ChangePhaseRequest, Contract, ContractChatHistoryResponse, ContractChatMessageRecord, ContractEvent, ContractListResponse, ContractRepository, ContractSummary, ContractWithRelations, - CleanupTasksResponse, + CleanupTasksResponse, ConvertToStepRequest, CreateContractRequest, CreateDirectiveRequest, CreateDirectiveStepRequest, CreateFileRequest, - CreateManagedRepositoryRequest, CreateTaskRequest, Daemon, DaemonDirectoriesResponse, + CreateManagedRepositoryRequest, CreateOrderRequest, CreateTaskRequest, + Daemon, DaemonDirectoriesResponse, DaemonDirectory, DaemonListResponse, Directive, DirectiveListResponse, DirectiveStep, DirectiveSummary, DirectiveWithSteps, File, FileListResponse, FileSummary, + LinkContractRequest, LinkDirectiveRequest, MergeCommitRequest, MergeCompleteCheckResponse, MergeResolveRequest, MergeResultResponse, MergeSkipRequest, MergeStartRequest, MergeStatusResponse, MeshChatConversation, - MeshChatHistoryResponse, MeshChatMessageRecord, RepositoryHistoryEntry, + MeshChatHistoryResponse, MeshChatMessageRecord, + Order, OrderListResponse, OrderListQuery, + RepositoryHistoryEntry, RepositoryHistoryListResponse, RepositorySuggestionsQuery, SendMessageRequest, Task, TaskEventListResponse, TaskListResponse, TaskSummary, TaskWithSubtasks, TranscriptEntry, UpdateContractRequest, UpdateDirectiveRequest, UpdateDirectiveStepRequest, - UpdateFileRequest, UpdateGoalRequest, UpdateTaskRequest, + UpdateFileRequest, UpdateGoalRequest, UpdateOrderRequest, UpdateTaskRequest, }; use crate::server::auth::{ ApiKey, ApiKeyInfoResponse, CreateApiKeyRequest, CreateApiKeyResponse, RefreshApiKeyRequest, RefreshApiKeyResponse, RevokeApiKeyResponse, }; -use crate::server::handlers::{api_keys, contract_chat, contract_discuss, contracts, directives, files, listen, mesh, mesh_chat, mesh_merge, repository_history, users}; +use crate::server::handlers::{api_keys, contract_chat, contract_discuss, contracts, directives, files, listen, mesh, mesh_chat, mesh_merge, orders, repository_history, users}; use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage, TranscriptMessage}; #[derive(OpenApi)] @@ -125,6 +129,15 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage directives::skip_step, directives::update_goal, directives::cleanup_tasks, + // Order endpoints + orders::list_orders, + orders::create_order, + orders::get_order, + 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, repository_history::get_repository_suggestions, @@ -222,6 +235,15 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage CreateDirectiveStepRequest, UpdateDirectiveStepRequest, CleanupTasksResponse, + // Order schemas + Order, + OrderListResponse, + OrderListQuery, + CreateOrderRequest, + UpdateOrderRequest, + LinkDirectiveRequest, + LinkContractRequest, + ConvertToStepRequest, // Repository history schemas RepositoryHistoryEntry, RepositoryHistoryListResponse, @@ -236,6 +258,7 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage (name = "API Keys", description = "API key management for programmatic access"), (name = "Users", description = "User account management"), (name = "Directives", description = "Directive management with DAG-based step progression"), + (name = "Orders", description = "Order management — card-based issue tracking for planned work items"), (name = "Settings", description = "User settings including repository history"), ) )] diff --git a/makima/src/server/state.rs b/makima/src/server/state.rs index 58e8545..41c336e 100644 --- a/makima/src/server/state.rs +++ b/makima/src/server/state.rs @@ -142,8 +142,11 @@ pub struct SupervisorQuestionNotification { pub question_id: Uuid, /// Supervisor task that asked the question pub task_id: Uuid, - /// Contract this question relates to + /// Contract this question relates to (Uuid::nil() for directive context) pub contract_id: Uuid, + /// Directive this question relates to (if from a directive task) + #[serde(skip_serializing_if = "Option::is_none")] + pub directive_id: Option<Uuid>, /// Owner ID for data isolation #[serde(skip)] pub owner_id: Option<Uuid>, @@ -170,6 +173,8 @@ pub struct PendingSupervisorQuestion { pub question_id: Uuid, pub task_id: Uuid, pub contract_id: Uuid, + /// Directive this question relates to (if from a directive task) + pub directive_id: Option<Uuid>, pub owner_id: Uuid, pub question: String, pub choices: Vec<String>, @@ -819,6 +824,25 @@ impl AppState { multi_select: bool, question_type: String, ) -> Uuid { + self.add_supervisor_question_with_directive( + task_id, contract_id, None, owner_id, + question, choices, context, multi_select, question_type, + ) + } + + /// Add a pending supervisor question with optional directive context and broadcast it. + pub fn add_supervisor_question_with_directive( + &self, + task_id: Uuid, + contract_id: Uuid, + directive_id: Option<Uuid>, + owner_id: Uuid, + question: String, + choices: Vec<String>, + context: Option<String>, + multi_select: bool, + question_type: String, + ) -> Uuid { let question_id = Uuid::new_v4(); let now = chrono::Utc::now(); @@ -829,6 +853,7 @@ impl AppState { question_id, task_id, contract_id, + directive_id, owner_id, question: question.clone(), choices: choices.clone(), @@ -844,6 +869,7 @@ impl AppState { question_id, task_id, contract_id, + directive_id, owner_id: Some(owner_id), question, choices, @@ -857,6 +883,7 @@ impl AppState { question_id = %question_id, task_id = %task_id, contract_id = %contract_id, + directive_id = ?directive_id, question_type = %question_type, "Supervisor question added" ); @@ -904,6 +931,7 @@ impl AppState { question_id, task_id: question.1.task_id, contract_id: question.1.contract_id, + directive_id: question.1.directive_id, owner_id: Some(question.1.owner_id), question: question.1.question, choices: question.1.choices, |
