diff options
18 files changed, 1603 insertions, 26 deletions
diff --git a/makima/frontend/src/components/contracts/ContractDetail.tsx b/makima/frontend/src/components/contracts/ContractDetail.tsx index 46b2212..02c129e 100644 --- a/makima/frontend/src/components/contracts/ContractDetail.tsx +++ b/makima/frontend/src/components/contracts/ContractDetail.tsx @@ -195,6 +195,7 @@ export function ContractDetail({ <div className="mt-4 pt-4 border-t border-dashed border-[rgba(117,170,252,0.2)]"> <PhaseProgressBar currentPhase={contract.phase} + contractType={contract.contractType} onPhaseClick={onPhaseChange} /> </div> diff --git a/makima/frontend/src/components/contracts/ContractList.tsx b/makima/frontend/src/components/contracts/ContractList.tsx index 4388283..1eee6a3 100644 --- a/makima/frontend/src/components/contracts/ContractList.tsx +++ b/makima/frontend/src/components/contracts/ContractList.tsx @@ -153,7 +153,7 @@ export function ContractList({ )} <div className="flex items-center justify-between"> - <PhaseProgressBarCompact currentPhase={contract.phase} /> + <PhaseProgressBarCompact currentPhase={contract.phase} contractType={contract.contractType} /> <div className="flex items-center gap-3 text-[10px] font-mono text-[#555]"> {contract.fileCount > 0 && ( <span>{contract.fileCount} files</span> diff --git a/makima/frontend/src/components/contracts/PhaseProgressBar.tsx b/makima/frontend/src/components/contracts/PhaseProgressBar.tsx index 5ee7999..9589db9 100644 --- a/makima/frontend/src/components/contracts/PhaseProgressBar.tsx +++ b/makima/frontend/src/components/contracts/PhaseProgressBar.tsx @@ -1,7 +1,9 @@ -import type { ContractPhase } from "../../lib/api"; +import type { ContractPhase, ContractType } from "../../lib/api"; +import { getValidPhases } from "../../lib/api"; interface PhaseProgressBarProps { currentPhase: ContractPhase; + contractType?: ContractType; onPhaseClick?: (phase: ContractPhase) => void; readonly?: boolean; } @@ -46,14 +48,16 @@ const phaseColors: Record<ContractPhase, { active: string; inactive: string; com export function PhaseProgressBar({ currentPhase, + contractType, onPhaseClick, readonly = false, }: PhaseProgressBarProps) { - const currentIndex = phases.indexOf(currentPhase); + const visiblePhases = contractType ? getValidPhases(contractType) : phases; + const currentIndex = visiblePhases.indexOf(currentPhase); return ( <div className="flex items-center gap-1"> - {phases.map((phase, index) => { + {visiblePhases.map((phase, index) => { const isActive = phase === currentPhase; const isCompleted = index < currentIndex; const colors = phaseColors[phase]; @@ -97,7 +101,7 @@ export function PhaseProgressBar({ </button> {/* Connector line */} - {index < phases.length - 1 && ( + {index < visiblePhases.length - 1 && ( <div className={` w-8 h-0.5 mx-1 @@ -114,14 +118,17 @@ export function PhaseProgressBar({ export function PhaseProgressBarCompact({ currentPhase, + contractType, }: { currentPhase: ContractPhase; + contractType?: ContractType; }) { - const currentIndex = phases.indexOf(currentPhase); + const visiblePhases = contractType ? getValidPhases(contractType) : phases; + const currentIndex = visiblePhases.indexOf(currentPhase); return ( <div className="flex items-center gap-0.5"> - {phases.map((phase, index) => { + {visiblePhases.map((phase, index) => { const isActive = phase === currentPhase; const isCompleted = index < currentIndex; const colors = phaseColors[phase]; diff --git a/makima/frontend/src/components/directives/DOGList.tsx b/makima/frontend/src/components/directives/DOGList.tsx new file mode 100644 index 0000000..de59d7d --- /dev/null +++ b/makima/frontend/src/components/directives/DOGList.tsx @@ -0,0 +1,381 @@ +import { useState } from "react"; +import type { + DirectiveOrderGroup, + DOGStatus, + CreateDOGRequest, +} from "../../lib/api"; + +const DOG_STATUS_BADGE: Record<DOGStatus, { 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 DOG_STATUS_OPTIONS: DOGStatus[] = ["open", "in_progress", "done", "archived"]; + +interface DOGListProps { + dogs: DirectiveOrderGroup[]; + loading: boolean; + onCreateDog: (req: CreateDOGRequest) => Promise<DirectiveOrderGroup | null>; + onUpdateDog: (dogId: string, req: { name?: string; description?: string | null; status?: DOGStatus }) => Promise<void>; + onDeleteDog: (dogId: string) => Promise<void>; + onPickUpOrders: (dogId: string) => Promise<any>; +} + +export function DOGList({ + dogs, + loading, + onCreateDog, + onUpdateDog, + onDeleteDog, + onPickUpOrders, +}: DOGListProps) { + const [showCreate, setShowCreate] = useState(false); + const [newName, setNewName] = useState(""); + const [newDesc, setNewDesc] = useState(""); + const [creating, setCreating] = useState(false); + + const handleCreate = async () => { + if (!newName.trim()) return; + setCreating(true); + try { + await onCreateDog({ + name: newName.trim(), + description: newDesc.trim() || null, + }); + setNewName(""); + setNewDesc(""); + setShowCreate(false); + } catch (e) { + console.error("Failed to create DOG:", e); + } finally { + setCreating(false); + } + }; + + if (loading) { + return ( + <div className="flex items-center justify-center py-8"> + <span className="text-[10px] font-mono text-[#556677]">Loading DOGs...</span> + </div> + ); + } + + return ( + <div className="flex flex-col gap-3"> + {/* Header + Create button */} + <div className="flex items-center justify-between"> + <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide"> + Order Groups ({dogs.length}) + </span> + <button + type="button" + onClick={() => setShowCreate(!showCreate)} + className="text-[10px] font-mono text-[#75aafc] hover:text-white border border-[rgba(117,170,252,0.3)] rounded px-2 py-0.5" + > + {showCreate ? "Cancel" : "+ New DOG"} + </button> + </div> + + {/* Create form */} + {showCreate && ( + <div className="border border-[rgba(117,170,252,0.2)] bg-[#0a1525] rounded p-3 flex flex-col gap-2"> + <div> + <label className="text-[9px] font-mono text-[#7788aa] uppercase tracking-wide block mb-1"> + Name * + </label> + <input + value={newName} + onChange={(e) => setNewName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && newName.trim()) handleCreate(); + if (e.key === "Escape") setShowCreate(false); + }} + placeholder="Group name..." + autoFocus + 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 placeholder:text-[#445566]" + /> + </div> + <div> + <label className="text-[9px] font-mono text-[#7788aa] uppercase tracking-wide block mb-1"> + Description + </label> + <textarea + value={newDesc} + onChange={(e) => setNewDesc(e.target.value)} + placeholder="Optional description..." + rows={2} + 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 placeholder:text-[#445566]" + /> + </div> + <div className="flex gap-1.5"> + <button + type="button" + onClick={handleCreate} + disabled={!newName.trim() || creating} + className="text-[10px] font-mono text-emerald-400 hover:text-emerald-300 border border-emerald-800 rounded px-2 py-0.5 disabled:opacity-50" + > + {creating ? "Creating..." : "Create"} + </button> + <button + type="button" + onClick={() => setShowCreate(false)} + className="text-[10px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-2 py-0.5" + > + Cancel + </button> + </div> + </div> + )} + + {/* DOG list */} + {dogs.length === 0 ? ( + <div className="flex items-center justify-center py-6"> + <span className="text-[10px] font-mono text-[#556677] italic"> + No order groups yet + </span> + </div> + ) : ( + <div className="flex flex-col gap-2"> + {dogs.map((dog) => ( + <DOGCard + key={dog.id} + dog={dog} + onUpdate={onUpdateDog} + onDelete={onDeleteDog} + onPickUpOrders={onPickUpOrders} + /> + ))} + </div> + )} + </div> + ); +} + +function DOGCard({ + dog, + onUpdate, + onDelete, + onPickUpOrders, +}: { + dog: DirectiveOrderGroup; + onUpdate: (dogId: string, req: { name?: string; description?: string | null; status?: DOGStatus }) => Promise<void>; + onDelete: (dogId: string) => Promise<void>; + onPickUpOrders: (dogId: string) => Promise<any>; +}) { + const [editingName, setEditingName] = useState(false); + const [nameText, setNameText] = useState(dog.name); + const [editingDesc, setEditingDesc] = useState(false); + const [descText, setDescText] = useState(dog.description || ""); + const [pickingUp, setPickingUp] = useState(false); + const [pickUpResult, setPickUpResult] = useState<string | null>(null); + const [deleting, setDeleting] = useState(false); + + const badge = DOG_STATUS_BADGE[dog.status] || DOG_STATUS_BADGE.open; + + const handleNameSave = async () => { + if (nameText.trim() && nameText !== dog.name) { + await onUpdate(dog.id, { name: nameText.trim() }); + } + setEditingName(false); + }; + + const handleDescSave = async () => { + const newDesc = descText.trim() || null; + if (newDesc !== dog.description) { + await onUpdate(dog.id, { description: newDesc }); + } + setEditingDesc(false); + }; + + const handleStatusChange = async (status: DOGStatus) => { + await onUpdate(dog.id, { status }); + }; + + const handlePickUpOrders = async () => { + setPickingUp(true); + setPickUpResult(null); + try { + const result = await onPickUpOrders(dog.id); + if (result) { + setPickUpResult(result.message); + setTimeout(() => setPickUpResult(null), 5000); + } + } catch (e) { + setPickUpResult(e instanceof Error ? e.message : "Failed to plan orders"); + setTimeout(() => setPickUpResult(null), 5000); + } finally { + setPickingUp(false); + } + }; + + const handleDelete = async () => { + if (!window.confirm(`Delete DOG "${dog.name}"?`)) return; + setDeleting(true); + try { + await onDelete(dog.id); + } catch (e) { + console.error("Failed to delete DOG:", e); + setDeleting(false); + } + }; + + return ( + <div className="border border-[rgba(117,170,252,0.15)] bg-[#0a1525] rounded"> + {/* Name + Status */} + <div className="px-3 py-2 border-b border-[rgba(117,170,252,0.08)]"> + <div className="flex items-center justify-between mb-1.5"> + {editingName ? ( + <div className="flex-1 flex items-center gap-2 pr-2"> + <input + value={nameText} + onChange={(e) => setNameText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleNameSave(); + if (e.key === "Escape") setEditingName(false); + }} + autoFocus + className="flex-1 bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-0.5 text-[12px] font-mono text-white" + /> + <button + type="button" + onClick={handleNameSave} + className="text-[9px] font-mono text-emerald-400 hover:text-emerald-300" + > + [save] + </button> + <button + type="button" + onClick={() => setEditingName(false)} + className="text-[9px] font-mono text-[#556677] hover:text-white" + > + [cancel] + </button> + </div> + ) : ( + <span + className="text-[12px] font-mono text-white font-medium cursor-pointer hover:text-[#9bc3ff] truncate pr-2" + onClick={() => { + setNameText(dog.name); + setEditingName(true); + }} + > + {dog.name} + </span> + )} + <span className={`text-[9px] font-mono ${badge.color} border rounded px-1.5 py-0.5 shrink-0`}> + {badge.label} + </span> + </div> + + {/* Status selector */} + <div className="flex gap-1 flex-wrap"> + {DOG_STATUS_OPTIONS.map((s) => { + const sBadge = DOG_STATUS_BADGE[s]; + return ( + <button + key={s} + type="button" + onClick={() => handleStatusChange(s)} + className={`text-[9px] font-mono border rounded px-1.5 py-0.5 transition-colors ${ + s === dog.status + ? `${sBadge.color} bg-[rgba(117,170,252,0.1)]` + : "text-[#556677] border-[#2a3a5a] hover:text-[#7788aa]" + }`} + > + {sBadge.label} + </button> + ); + })} + </div> + </div> + + {/* Description */} + <div className="px-3 py-2 border-b border-[rgba(117,170,252,0.08)]"> + <div className="flex items-center justify-between mb-1"> + <span className="text-[9px] font-mono text-[#7788aa] uppercase tracking-wide"> + Description + </span> + {!editingDesc && ( + <button + type="button" + onClick={() => { + setDescText(dog.description || ""); + setEditingDesc(true); + }} + className="text-[8px] font-mono text-[#556677] hover:text-[#75aafc]" + > + [edit] + </button> + )} + </div> + {editingDesc ? ( + <div className="flex flex-col gap-1"> + <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 text-[10px] font-mono text-white resize-y min-h-[40px]" + rows={2} + autoFocus + /> + <div className="flex gap-1"> + <button + type="button" + onClick={handleDescSave} + className="text-[9px] font-mono text-emerald-400 hover:text-emerald-300 border border-emerald-800 rounded px-1.5 py-0.5" + > + Save + </button> + <button + type="button" + onClick={() => setEditingDesc(false)} + className="text-[9px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-1.5 py-0.5" + > + Cancel + </button> + </div> + </div> + ) : ( + <p className="text-[10px] font-mono text-[#c0d0e0] whitespace-pre-wrap"> + {dog.description || <span className="text-[#556677] italic">No description</span>} + </p> + )} + </div> + + {/* Actions */} + <div className="px-3 py-2 flex items-center gap-2"> + <button + type="button" + onClick={handlePickUpOrders} + disabled={pickingUp} + className="text-[10px] font-mono text-[#c084fc] hover:text-[#d8b4fe] border border-[rgba(192,132,252,0.3)] rounded px-2 py-0.5 disabled:opacity-50" + > + {pickingUp ? "Planning..." : "Plan Orders"} + </button> + <button + type="button" + onClick={handleDelete} + disabled={deleting} + className="text-[10px] font-mono text-red-400 hover:text-red-300 border border-red-800 rounded px-2 py-0.5 ml-auto disabled:opacity-50" + > + {deleting ? "Deleting..." : "Delete"} + </button> + </div> + + {/* Pick-up result */} + {pickUpResult && ( + <div className="mx-3 mb-2 px-2 py-1.5 bg-[#1a1030] border border-[rgba(192,132,252,0.2)] rounded"> + <span className="text-[10px] font-mono text-[#c084fc]">{pickUpResult}</span> + </div> + )} + + {/* Metadata */} + <div className="px-3 py-1.5 border-t border-[rgba(117,170,252,0.05)]"> + <span className="text-[8px] font-mono text-[#445566]"> + Created {new Date(dog.createdAt).toLocaleDateString()} + </span> + </div> + </div> + ); +} diff --git a/makima/frontend/src/components/directives/DirectiveDetail.tsx b/makima/frontend/src/components/directives/DirectiveDetail.tsx index 5f3489a..e3302e4 100644 --- a/makima/frontend/src/components/directives/DirectiveDetail.tsx +++ b/makima/frontend/src/components/directives/DirectiveDetail.tsx @@ -1,9 +1,10 @@ import { useState, useMemo, useEffect, useRef } from "react"; -import type { DirectiveWithSteps, DirectiveStatus, UpdateDirectiveRequest } from "../../lib/api"; +import type { DirectiveWithSteps, DirectiveStatus, UpdateDirectiveRequest, DirectiveOrderGroup, CreateDOGRequest, UpdateDOGRequest } from "../../lib/api"; import { DirectiveDAG } from "./DirectiveDAG"; import type { SpecializedStep } from "./DirectiveDAG"; import { DirectiveLogStream } from "./DirectiveLogStream"; import { TaskSlideOutPanel } from "./TaskSlideOutPanel"; +import { DOGList } from "./DOGList"; import { useMultiTaskSubscription } from "../../hooks/useMultiTaskSubscription"; import { useSupervisorQuestions } from "../../contexts/SupervisorQuestionsContext"; @@ -30,6 +31,12 @@ interface DirectiveDetailProps { onCleanup: () => void; onPickUpOrders: () => Promise<{ message: string; orderCount: number; taskId: string | null } | null>; onCreatePR: () => Promise<void>; + dogs: DirectiveOrderGroup[]; + dogsLoading: boolean; + onCreateDog: (req: CreateDOGRequest) => Promise<DirectiveOrderGroup | null>; + onUpdateDog: (dogId: string, req: UpdateDOGRequest) => Promise<void>; + onDeleteDog: (dogId: string) => Promise<void>; + onPickUpDogOrders: (dogId: string) => Promise<any>; } export function DirectiveDetail({ @@ -47,7 +54,14 @@ export function DirectiveDetail({ onCleanup, onPickUpOrders, onCreatePR, + dogs, + dogsLoading, + onCreateDog, + onUpdateDog, + onDeleteDog, + onPickUpDogOrders, }: DirectiveDetailProps) { + const [activeTab, setActiveTab] = useState<"steps" | "dogs">("steps"); const [editingGoal, setEditingGoal] = useState(false); const [goalText, setGoalText] = useState(directive.goal); const [visibleTaskIds, setVisibleTaskIds] = useState<Set<string> | null>(null); @@ -437,21 +451,53 @@ export function DirectiveDetail({ )} </div> - {/* DAG */} - <div className="px-4 py-3 flex-1"> - <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide block mb-2"> + {/* Tab bar */} + <div className="flex items-center gap-0 border-b border-[rgba(117,170,252,0.1)] px-4"> + <button + type="button" + onClick={() => setActiveTab("steps")} + className={`px-3 py-2 text-[10px] font-mono uppercase tracking-wide transition-colors + ${activeTab === "steps" ? "text-[#dbe7ff] border-b-2 border-[#75aafc]" : "text-[#556677] hover:text-[#9bc3ff]"} + `} + > Steps ({totalSteps}) - </span> - <DirectiveDAG - steps={directive.steps} - specializedSteps={specializedSteps} - onComplete={onCompleteStep} - onFail={onFailStep} - onSkip={onSkipStep} - onViewTask={handleViewTask} - /> + </button> + <button + type="button" + onClick={() => setActiveTab("dogs")} + className={`px-3 py-2 text-[10px] font-mono uppercase tracking-wide transition-colors + ${activeTab === "dogs" ? "text-[#dbe7ff] border-b-2 border-[#75aafc]" : "text-[#556677] hover:text-[#9bc3ff]"} + `} + > + DOGs ({dogs.length}) + </button> </div> + {/* Tab content */} + {activeTab === "steps" ? ( + <div className="px-4 py-3 flex-1"> + <DirectiveDAG + steps={directive.steps} + specializedSteps={specializedSteps} + onComplete={onCompleteStep} + onFail={onFailStep} + onSkip={onSkipStep} + onViewTask={handleViewTask} + /> + </div> + ) : ( + <div className="px-4 py-3 flex-1"> + <DOGList + dogs={dogs} + loading={dogsLoading} + onCreateDog={onCreateDog} + onUpdateDog={onUpdateDog} + onDeleteDog={onDeleteDog} + onPickUpOrders={onPickUpDogOrders} + /> + </div> + )} + {/* Log Stream */} {taskMap.size > 0 && ( <div className="px-4 py-3 border-t border-[rgba(117,170,252,0.1)]"> diff --git a/makima/frontend/src/components/orders/OrderDetail.tsx b/makima/frontend/src/components/orders/OrderDetail.tsx index 338cc60..ebc8124 100644 --- a/makima/frontend/src/components/orders/OrderDetail.tsx +++ b/makima/frontend/src/components/orders/OrderDetail.tsx @@ -6,6 +6,7 @@ import type { OrderType, UpdateOrderRequest, DirectiveSummary, + DirectiveOrderGroup, } from "../../lib/api"; const STATUS_BADGE: Record<OrderStatus, { color: string; label: string }> = { @@ -37,6 +38,7 @@ const STATUS_OPTIONS: OrderStatus[] = ["open", "in_progress", "under_review", "d interface OrderDetailProps { order: Order; directives: DirectiveSummary[]; + dogs: DirectiveOrderGroup[]; onUpdate: (req: UpdateOrderRequest) => Promise<void>; onDelete: () => void; onLinkDirective: (directiveId: string) => Promise<void>; @@ -47,6 +49,7 @@ interface OrderDetailProps { export function OrderDetail({ order, directives, + dogs, onUpdate, onDelete, onLinkDirective, @@ -61,6 +64,7 @@ export function OrderDetail({ const [labelsText, setLabelsText] = useState(order.labels.join(", ")); const [showLinkDirective, setShowLinkDirective] = useState(false); const [directiveSearch, setDirectiveSearch] = useState(""); + const [showDogSelector, setShowDogSelector] = useState(false); const badge = STATUS_BADGE[order.status] || STATUS_BADGE.open; const currentPriority = PRIORITY_OPTIONS.find((p) => p.value === order.priority) || PRIORITY_OPTIONS[4]; @@ -192,6 +196,11 @@ export function OrderDetail({ step:{order.directiveStepId.slice(0, 8)} </span> )} + {order.directiveId && ( + <span className="text-[10px] font-mono text-[#75aafc] border border-[rgba(117,170,252,0.3)] rounded px-1.5 py-0.5 truncate max-w-[120px]"> + DOG: {order.dogId ? (dogs.find((d) => d.id === order.dogId)?.name || order.dogId.slice(0, 8) + "...") : "None"} + </span> + )} <button type="button" onClick={onDelete} @@ -498,6 +507,81 @@ export function OrderDetail({ )} </div> + {/* Assign to DOG */} + {order.directiveId && ( + <div> + <div className="flex items-center gap-1.5"> + <button + type="button" + onClick={() => setShowDogSelector(!showDogSelector)} + className="text-[10px] font-mono text-[#75aafc] hover:text-white border border-[rgba(117,170,252,0.3)] rounded px-2 py-1 flex-1 text-left" + > + {order.dogId ? "Change DOG" : "Assign to DOG"} + </button> + {order.dogId && ( + <button + type="button" + onClick={() => onUpdate({ dogId: null })} + className="text-[10px] font-mono text-red-400 hover:text-red-300 border border-red-800 rounded px-2 py-1" + title="Remove DOG assignment" + > + Unlink + </button> + )} + </div> + {showDogSelector && ( + <div className="mt-1 border border-[rgba(117,170,252,0.2)] bg-[#0a1525] rounded"> + <div className="max-h-32 overflow-y-auto"> + {dogs.length === 0 ? ( + <div className="px-3 py-2 text-[10px] font-mono text-[#556677]"> + No DOGs available for this directive + </div> + ) : ( + dogs.map((d) => { + const isAssigned = d.id === order.dogId; + const statusColors: Record<string, string> = { + open: "text-[#75aafc] border-[rgba(117,170,252,0.4)]", + in_progress: "text-yellow-400 border-yellow-800", + done: "text-emerald-400 border-emerald-800", + archived: "text-[#556677] border-[#2a3a5a]", + }; + const sColor = statusColors[d.status] || statusColors.open; + return ( + <button + key={d.id} + type="button" + onClick={async () => { + await onUpdate({ dogId: d.id }); + setShowDogSelector(false); + }} + className={`w-full text-left px-3 py-1.5 text-[10px] font-mono hover:bg-[rgba(117,170,252,0.1)] border-b border-[rgba(117,170,252,0.1)] last:border-b-0 ${ + isAssigned ? "bg-[rgba(117,170,252,0.08)] text-white" : "text-[#9bc3ff]" + }`} + > + <div className="flex items-center gap-1.5"> + <span className={`shrink-0 text-[8px] font-mono ${sColor} border rounded px-1 py-0.5 uppercase`}> + {d.status} + </span> + <span className="truncate">{d.name}</span> + {isAssigned && ( + <span className="shrink-0 text-[8px] text-emerald-400">current</span> + )} + </div> + {d.description && ( + <div className="text-[8px] text-[#556677] truncate mt-0.5"> + {d.description} + </div> + )} + </button> + ); + }) + )} + </div> + </div> + )} + </div> + )} + {/* Convert to Directive Step */} {!order.directiveStepId && order.directiveId && ( <button diff --git a/makima/frontend/src/hooks/useDogs.ts b/makima/frontend/src/hooks/useDogs.ts new file mode 100644 index 0000000..819219e --- /dev/null +++ b/makima/frontend/src/hooks/useDogs.ts @@ -0,0 +1,112 @@ +import { useState, useEffect, useCallback } from "react"; +import { + type DirectiveOrderGroup, + type CreateDOGRequest, + type UpdateDOGRequest, + listDogs, + createDog, + getDog, + updateDog as updateDogApi, + deleteDog as deleteDogApi, + pickUpDogOrders as pickUpDogOrdersApi, +} from "../lib/api"; + +export function useDogs(directiveId: string | undefined) { + const [dogs, setDogs] = useState<DirectiveOrderGroup[]>([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + + const refresh = useCallback(async () => { + if (!directiveId) return; + try { + setLoading(true); + setError(null); + const res = await listDogs(directiveId); + setDogs(res.dogs); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load DOGs"); + } finally { + setLoading(false); + } + }, [directiveId]); + + useEffect(() => { + refresh(); + }, [refresh]); + + const create = useCallback(async (req: CreateDOGRequest) => { + if (!directiveId) return null; + const dog = await createDog(directiveId, req); + await refresh(); + return dog; + }, [directiveId, refresh]); + + const update = useCallback(async (dogId: string, req: UpdateDOGRequest) => { + if (!directiveId) return; + await updateDogApi(directiveId, dogId, req); + await refresh(); + }, [directiveId, refresh]); + + const remove = useCallback(async (dogId: string) => { + if (!directiveId) return; + await deleteDogApi(directiveId, dogId); + await refresh(); + }, [directiveId, refresh]); + + const pickUpOrders = useCallback(async (dogId: string) => { + if (!directiveId) return null; + const result = await pickUpDogOrdersApi(directiveId, dogId); + await refresh(); + return result; + }, [directiveId, refresh]); + + return { dogs, loading, error, refresh, create, update, remove, pickUpOrders }; +} + +export function useDog(directiveId: string | undefined, dogId: string | undefined) { + const [dog, setDog] = useState<DirectiveOrderGroup | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + + const refresh = useCallback(async () => { + if (!directiveId || !dogId) return; + try { + setLoading(true); + setError(null); + const d = await getDog(directiveId, dogId); + setDog(d); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load DOG"); + } finally { + setLoading(false); + } + }, [directiveId, dogId]); + + useEffect(() => { + setDog(null); + setError(null); + setLoading(true); + refresh(); + }, [directiveId, dogId]); // eslint-disable-line react-hooks/exhaustive-deps + + const update = useCallback(async (req: UpdateDOGRequest) => { + if (!directiveId || !dogId) return; + const d = await updateDogApi(directiveId, dogId, req); + setDog(d); + return d; + }, [directiveId, dogId]); + + const remove = useCallback(async () => { + if (!directiveId || !dogId) return; + await deleteDogApi(directiveId, dogId); + }, [directiveId, dogId]); + + const pickUpOrders = useCallback(async () => { + if (!directiveId || !dogId) return null; + const result = await pickUpDogOrdersApi(directiveId, dogId); + await refresh(); + return result; + }, [directiveId, dogId, refresh]); + + return { dog, loading, error, refresh, update, remove, pickUpOrders }; +} diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index 4923c1d..7968583 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -3387,6 +3387,85 @@ export async function pickUpOrders(directiveId: string): Promise<PickUpOrdersRes } // ============================================================================= +// Directive Order Groups (DOGs) API +// ============================================================================= + +export type DOGStatus = "open" | "in_progress" | "done" | "archived"; + +export interface DirectiveOrderGroup { + id: string; + directiveId: string; + ownerId: string; + name: string; + description: string | null; + status: DOGStatus; + createdAt: string; + updatedAt: string; +} + +export interface DOGListResponse { + dogs: DirectiveOrderGroup[]; +} + +export interface CreateDOGRequest { + name: string; + description?: string | null; +} + +export interface UpdateDOGRequest { + name?: string; + description?: string | null; + status?: DOGStatus; +} + +export async function listDogs(directiveId: string): Promise<DOGListResponse> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/dogs`); + if (!res.ok) throw new Error(`Failed to list DOGs: ${res.statusText}`); + return res.json(); +} + +export async function createDog(directiveId: string, req: CreateDOGRequest): Promise<DirectiveOrderGroup> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/dogs`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(req), + }); + if (!res.ok) throw new Error(`Failed to create DOG: ${res.statusText}`); + return res.json(); +} + +export async function getDog(directiveId: string, dogId: string): Promise<DirectiveOrderGroup> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/dogs/${dogId}`); + if (!res.ok) throw new Error(`Failed to get DOG: ${res.statusText}`); + return res.json(); +} + +export async function updateDog(directiveId: string, dogId: string, req: UpdateDOGRequest): Promise<DirectiveOrderGroup> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/dogs/${dogId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(req), + }); + if (!res.ok) throw new Error(`Failed to update DOG: ${res.statusText}`); + return res.json(); +} + +export async function deleteDog(directiveId: string, dogId: string): Promise<void> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/dogs/${dogId}`, { + method: "DELETE", + }); + if (!res.ok) throw new Error(`Failed to delete DOG: ${res.statusText}`); +} + +export async function pickUpDogOrders(directiveId: string, dogId: string): Promise<PickUpOrdersResponse> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/dogs/${dogId}/pick-up-orders`, { + method: "POST", + }); + if (!res.ok) throw new Error(`Failed to pick up DOG orders: ${res.statusText}`); + return res.json(); +} + +// ============================================================================= // Orders API // ============================================================================= @@ -3406,6 +3485,7 @@ export interface Order { directiveId: string | null; directiveStepId: string | null; directiveName: string | null; + dogId: string | null; repositoryUrl: string | null; createdAt: string; updatedAt: string; @@ -3424,6 +3504,7 @@ export interface CreateOrderRequest { orderType?: OrderType; labels?: string[]; directiveId: string; + dogId?: string | null; repositoryUrl?: string | null; } @@ -3436,6 +3517,7 @@ export interface UpdateOrderRequest { labels?: string[]; directiveId?: string | null; directiveStepId?: string | null; + dogId?: string | null; repositoryUrl?: string | null; } @@ -3445,6 +3527,7 @@ export async function listOrders( priority?: OrderPriority, directiveId?: string, search?: string, + dogId?: string, ): Promise<OrderListResponse> { const params = new URLSearchParams(); if (status) params.set("status", status); @@ -3452,6 +3535,7 @@ export async function listOrders( if (priority) params.set("priority", priority); if (directiveId) params.set("directiveId", directiveId); if (search) params.set("search", search); + if (dogId) params.set("dogId", dogId); 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}`); diff --git a/makima/frontend/src/routes/directives.tsx b/makima/frontend/src/routes/directives.tsx index f1b430d..8de0335 100644 --- a/makima/frontend/src/routes/directives.tsx +++ b/makima/frontend/src/routes/directives.tsx @@ -4,6 +4,7 @@ import { Masthead } from "../components/Masthead"; import { DirectiveList } from "../components/directives/DirectiveList"; import { DirectiveDetail } from "../components/directives/DirectiveDetail"; import { useDirectives, useDirective } from "../hooks/useDirectives"; +import { useDogs } from "../hooks/useDogs"; import { useAuth } from "../contexts/AuthContext"; import { getRepositorySuggestions, startDirective, pauseDirective, updateDirective, type RepositoryHistoryEntry, type DirectiveSummary } from "../lib/api"; @@ -13,6 +14,7 @@ export default function DirectivesPage() { const { id: selectedId } = useParams<{ id: string }>(); const { directives, loading: listLoading, create, remove, refresh: refreshList } = useDirectives(); const { directive, refresh: refreshDetail, update, start, pause, advance, completeStep, failStep, skipStep, updateGoal, cleanup, pickUpOrders, createPR } = useDirective(selectedId); + const { dogs, loading: dogsLoading, create: createDog, update: updateDog, remove: removeDog, pickUpOrders: pickUpDogOrders } = useDogs(selectedId); const [showCreate, setShowCreate] = useState(false); const [newTitle, setNewTitle] = useState(""); @@ -259,6 +261,12 @@ export default function DirectivesPage() { onCleanup={cleanup} onPickUpOrders={pickUpOrders} onCreatePR={createPR} + dogs={dogs} + dogsLoading={dogsLoading} + onCreateDog={createDog} + onUpdateDog={updateDog} + onDeleteDog={removeDog} + onPickUpDogOrders={pickUpDogOrders} /> ) : ( <div className="flex-1 flex items-center justify-center h-full"> diff --git a/makima/frontend/src/routes/orders.tsx b/makima/frontend/src/routes/orders.tsx index 5744bdd..cc1e1ad 100644 --- a/makima/frontend/src/routes/orders.tsx +++ b/makima/frontend/src/routes/orders.tsx @@ -5,6 +5,7 @@ import { OrderList } from "../components/orders/OrderList"; import { OrderDetail } from "../components/orders/OrderDetail"; import { useOrders, useOrder } from "../hooks/useOrders"; import { useDirectives } from "../hooks/useDirectives"; +import { useDogs } from "../hooks/useDogs"; import { useAuth } from "../contexts/AuthContext"; import { updateOrder, deleteOrder } from "../lib/api"; import type { Order, OrderStatus, OrderType, OrderPriority } from "../lib/api"; @@ -19,6 +20,7 @@ export default function OrdersPage() { const { orders, loading: listLoading, create, refresh: refreshList } = useOrders(statusFilter, typeFilter); const { order, refresh: refreshDetail, update, remove: removeOrder, linkDirective, convertToStep } = useOrder(selectedId); const { directives } = useDirectives(); + const { dogs } = useDogs(order?.directiveId ?? undefined); const [showCreate, setShowCreate] = useState(false); const [newTitle, setNewTitle] = useState(""); @@ -256,6 +258,7 @@ export default function OrdersPage() { <OrderDetail order={order} directives={directives} + dogs={dogs} onUpdate={handleUpdate} onDelete={handleDelete} onLinkDirective={handleLinkDirective} diff --git a/makima/frontend/tsconfig.tsbuildinfo b/makima/frontend/tsconfig.tsbuildinfo index a063be7..59abd45 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/orchestratorstepnode.tsx","./src/components/directives/stepnode.tsx","./src/components/directives/taskslideoutpanel.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/daemons.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 +{"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/doglist.tsx","./src/components/directives/directivecontextmenu.tsx","./src/components/directives/directivedag.tsx","./src/components/directives/directivedetail.tsx","./src/components/directives/directivelist.tsx","./src/components/directives/directivelogstream.tsx","./src/components/directives/orchestratorstepnode.tsx","./src/components/directives/stepnode.tsx","./src/components/directives/taskslideoutpanel.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/ordercontextmenu.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/usedogs.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/daemons.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/20260303000000_create_directive_order_groups.sql b/makima/migrations/20260303000000_create_directive_order_groups.sql new file mode 100644 index 0000000..8a382e5 --- /dev/null +++ b/makima/migrations/20260303000000_create_directive_order_groups.sql @@ -0,0 +1,19 @@ +-- Directive Order Groups (DOGs): Epic-like groupings of orders within a directive. +CREATE TABLE directive_order_groups ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + directive_id UUID NOT NULL REFERENCES directives(id) ON DELETE CASCADE, + owner_id UUID NOT NULL REFERENCES owners(id) ON DELETE CASCADE, + name VARCHAR(500) NOT NULL, + description TEXT, + status VARCHAR(32) NOT NULL DEFAULT 'open' + CHECK (status IN ('open', 'in_progress', 'done', 'archived')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_dog_directive_id ON directive_order_groups(directive_id); +CREATE INDEX IF NOT EXISTS idx_dog_owner_id ON directive_order_groups(owner_id); + +-- Add optional dog_id to orders +ALTER TABLE orders ADD COLUMN dog_id UUID REFERENCES directive_order_groups(id) ON DELETE SET NULL; +CREATE INDEX IF NOT EXISTS idx_orders_dog_id ON orders(dog_id); diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index 32e55f0..97657dc 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -2925,6 +2925,8 @@ pub struct Order { pub directive_name: Option<String>, /// Repository context pub repository_url: Option<String>, + /// Optional DOG (Directive Order Group) this order belongs to + pub dog_id: Option<Uuid>, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>, } @@ -2943,6 +2945,8 @@ pub struct CreateOrderRequest { /// Directive ID is required for new orders. pub directive_id: Uuid, pub repository_url: Option<String>, + /// Optional DOG (Directive Order Group) to assign this order to. + pub dog_id: Option<Uuid>, } /// Default empty JSON array for labels. @@ -2963,6 +2967,8 @@ pub struct UpdateOrderRequest { pub directive_id: Option<Uuid>, pub directive_step_id: Option<Uuid>, pub repository_url: Option<String>, + /// Optional DOG (Directive Order Group) to assign/reassign this order to. + pub dog_id: Option<Uuid>, } /// Response for order list endpoint. @@ -2986,6 +2992,8 @@ pub struct OrderListQuery { pub priority: Option<String>, /// Filter by linked directive ID pub directive_id: Option<Uuid>, + /// Filter by DOG (Directive Order Group) ID + pub dog_id: Option<Uuid>, /// Text search across title, description, and directive_name (case-insensitive) pub search: Option<String>, } @@ -2997,4 +3005,49 @@ pub struct LinkDirectiveRequest { pub directive_id: Uuid, } +// ============================================================================= +// Directive Order Group (DOG) Types +// ============================================================================= + +/// A Directive Order Group (DOG) — an epic-like grouping of orders within a directive. +/// DOGs allow organizing related orders under a common theme or goal. +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DirectiveOrderGroup { + pub id: Uuid, + pub directive_id: Uuid, + pub owner_id: Uuid, + pub name: String, + pub description: Option<String>, + /// Status: open, in_progress, done, archived + pub status: String, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, +} + +/// Request to create a new Directive Order Group (DOG). +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateDirectiveOrderGroupRequest { + pub name: String, + pub description: Option<String>, +} + +/// Request to update a Directive Order Group (DOG). +#[derive(Debug, Default, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateDirectiveOrderGroupRequest { + pub name: Option<String>, + pub description: Option<String>, + pub status: Option<String>, +} + +/// Response for DOG list endpoint. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DirectiveOrderGroupListResponse { + pub dogs: Vec<DirectiveOrderGroup>, + pub total: i64, +} + diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index f14bc66..57e8a78 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -15,6 +15,7 @@ use super::models::{ CreateDirectiveRequest, CreateDirectiveStepRequest, DirectiveGoalHistory, UpdateDirectiveRequest, UpdateDirectiveStepRequest, CreateOrderRequest, Order, UpdateOrderRequest, + CreateDirectiveOrderGroupRequest, DirectiveOrderGroup, UpdateDirectiveOrderGroupRequest, File, FileSummary, FileVersion, HistoryEvent, HistoryQueryFilters, MeshChatConversation, MeshChatMessageRecord, PhaseChangeResult, PhaseConfig, PhaseDefinition, SupervisorHeartbeatRecord, SupervisorState, @@ -6122,8 +6123,8 @@ pub async fn create_order( sqlx::query_as::<_, Order>( r#" - INSERT INTO orders (owner_id, title, description, priority, status, order_type, labels, directive_id, repository_url) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + INSERT INTO orders (owner_id, title, description, priority, status, order_type, labels, directive_id, repository_url, dog_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING * "#, ) @@ -6136,6 +6137,7 @@ pub async fn create_order( .bind(&req.labels) .bind(req.directive_id) .bind(&req.repository_url) + .bind(req.dog_id) .fetch_one(pool) .await } @@ -6148,6 +6150,7 @@ pub async fn list_orders( type_filter: Option<&str>, priority_filter: Option<&str>, directive_id_filter: Option<Uuid>, + dog_id_filter: Option<Uuid>, search_filter: Option<&str>, ) -> Result<Vec<Order>, sqlx::Error> { // Build dynamic query with optional filters @@ -6170,6 +6173,10 @@ pub async fn list_orders( query.push_str(&format!(" AND directive_id = ${}", param_idx)); param_idx += 1; } + if dog_id_filter.is_some() { + query.push_str(&format!(" AND dog_id = ${}", param_idx)); + param_idx += 1; + } if search_filter.is_some() { query.push_str(&format!( " AND (title ILIKE ${p} OR description ILIKE ${p} OR directive_name ILIKE ${p})", @@ -6193,6 +6200,9 @@ pub async fn list_orders( if let Some(d) = directive_id_filter { q = q.bind(d); } + if let Some(d) = dog_id_filter { + q = q.bind(d); + } if let Some(s) = search_filter { q = q.bind(format!("%{}%", s)); } @@ -6244,13 +6254,14 @@ pub async fn update_order( let directive_id = req.directive_id.or(current.directive_id); let directive_step_id = req.directive_step_id.or(current.directive_step_id); let repository_url = req.repository_url.as_deref().or(current.repository_url.as_deref()); + let dog_id = req.dog_id.or(current.dog_id); 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, - repository_url = $11, updated_at = NOW() + repository_url = $11, dog_id = $12, updated_at = NOW() WHERE id = $1 AND owner_id = $2 RETURNING * "#, @@ -6266,6 +6277,7 @@ pub async fn update_order( .bind(directive_id) .bind(directive_step_id) .bind(repository_url) + .bind(dog_id) .fetch_optional(pool) .await } @@ -6517,3 +6529,172 @@ pub async fn reconcile_directive_orders( Ok(rows.len() as i64) } +// ============================================================================= +// Directive Order Group (DOG) CRUD +// ============================================================================= + +/// Create a new Directive Order Group (DOG) for the given owner and directive. +pub async fn create_directive_order_group( + pool: &PgPool, + directive_id: Uuid, + owner_id: Uuid, + req: CreateDirectiveOrderGroupRequest, +) -> Result<DirectiveOrderGroup, sqlx::Error> { + sqlx::query_as::<_, DirectiveOrderGroup>( + r#" + INSERT INTO directive_order_groups (directive_id, owner_id, name, description) + VALUES ($1, $2, $3, $4) + RETURNING * + "#, + ) + .bind(directive_id) + .bind(owner_id) + .bind(&req.name) + .bind(&req.description) + .fetch_one(pool) + .await +} + +/// List all DOGs for a given directive (owner-scoped). +pub async fn list_directive_order_groups( + pool: &PgPool, + directive_id: Uuid, + owner_id: Uuid, +) -> Result<Vec<DirectiveOrderGroup>, sqlx::Error> { + sqlx::query_as::<_, DirectiveOrderGroup>( + r#" + SELECT * FROM directive_order_groups + WHERE directive_id = $1 AND owner_id = $2 + ORDER BY created_at DESC + "#, + ) + .bind(directive_id) + .bind(owner_id) + .fetch_all(pool) + .await +} + +/// Get a single DOG by ID (owner-scoped). +pub async fn get_directive_order_group( + pool: &PgPool, + id: Uuid, + owner_id: Uuid, +) -> Result<Option<DirectiveOrderGroup>, sqlx::Error> { + sqlx::query_as::<_, DirectiveOrderGroup>( + r#"SELECT * FROM directive_order_groups WHERE id = $1 AND owner_id = $2"#, + ) + .bind(id) + .bind(owner_id) + .fetch_optional(pool) + .await +} + +/// Update a DOG (owner-scoped). Uses fetch-then-update pattern for partial updates. +pub async fn update_directive_order_group( + pool: &PgPool, + id: Uuid, + owner_id: Uuid, + req: UpdateDirectiveOrderGroupRequest, +) -> Result<Option<DirectiveOrderGroup>, sqlx::Error> { + let current = sqlx::query_as::<_, DirectiveOrderGroup>( + r#"SELECT * FROM directive_order_groups WHERE id = $1 AND owner_id = $2"#, + ) + .bind(id) + .bind(owner_id) + .fetch_optional(pool) + .await?; + + let current = match current { + Some(c) => c, + None => return Ok(None), + }; + + let name = req.name.as_deref().unwrap_or(¤t.name); + let description = req.description.as_deref().or(current.description.as_deref()); + let status = req.status.as_deref().unwrap_or(¤t.status); + + sqlx::query_as::<_, DirectiveOrderGroup>( + r#" + UPDATE directive_order_groups + SET name = $3, description = $4, status = $5, updated_at = NOW() + WHERE id = $1 AND owner_id = $2 + RETURNING * + "#, + ) + .bind(id) + .bind(owner_id) + .bind(name) + .bind(description) + .bind(status) + .fetch_optional(pool) + .await +} + +/// Delete a DOG (owner-scoped). Returns true if a row was deleted. +pub async fn delete_directive_order_group( + pool: &PgPool, + id: Uuid, + owner_id: Uuid, +) -> Result<bool, sqlx::Error> { + let result = sqlx::query( + r#"DELETE FROM directive_order_groups WHERE id = $1 AND owner_id = $2"#, + ) + .bind(id) + .bind(owner_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// List orders belonging to a specific DOG (owner-scoped). +pub async fn list_orders_by_dog( + pool: &PgPool, + dog_id: Uuid, + owner_id: Uuid, +) -> Result<Vec<Order>, sqlx::Error> { + sqlx::query_as::<_, Order>( + r#" + SELECT * FROM orders + WHERE dog_id = $1 AND owner_id = $2 + ORDER BY created_at DESC + "#, + ) + .bind(dog_id) + .bind(owner_id) + .fetch_all(pool) + .await +} + +/// Get available orders for pickup filtered to a specific DOG. +/// Like `get_available_orders_for_pickup` but only returns orders belonging to the given DOG. +pub async fn get_available_orders_for_dog_pickup( + pool: &PgPool, + owner_id: Uuid, + directive_id: Uuid, + dog_id: Uuid, +) -> Result<Vec<Order>, sqlx::Error> { + sqlx::query_as::<_, Order>( + r#" + SELECT * + FROM orders + WHERE owner_id = $1 + AND dog_id = $3 + AND status IN ('open', 'in_progress') + AND (directive_id IS NULL OR directive_id = $2) + ORDER BY CASE priority + WHEN 'critical' THEN 0 + WHEN 'high' THEN 1 + WHEN 'medium' THEN 2 + WHEN 'low' THEN 3 + ELSE 4 + END ASC, created_at ASC + "#, + ) + .bind(owner_id) + .bind(directive_id) + .bind(dog_id) + .fetch_all(pool) + .await +} + diff --git a/makima/src/server/handlers/directives.rs b/makima/src/server/handlers/directives.rs index 992affe..d1edf7e 100644 --- a/makima/src/server/handlers/directives.rs +++ b/makima/src/server/handlers/directives.rs @@ -9,11 +9,13 @@ use axum::{ use uuid::Uuid; use crate::db::models::{ - CleanupResponse, CleanupTasksResponse, CreateDirectiveRequest, CreateTaskRequest, + CleanupResponse, CreateDirectiveRequest, CreateTaskRequest, CreateDirectiveStepRequest, Directive, DirectiveListResponse, DirectiveStep, DirectiveWithSteps, PickUpOrdersResponse, UpdateDirectiveRequest, UpdateDirectiveStepRequest, UpdateGoalRequest, - UpdateOrderRequest, + CreateDirectiveOrderGroupRequest, DirectiveOrderGroup, + DirectiveOrderGroupListResponse, UpdateDirectiveOrderGroupRequest, + OrderListResponse, }; use crate::db::repository; use crate::orchestration::directive::{build_cleanup_prompt, build_order_pickup_prompt}; @@ -1375,3 +1377,583 @@ pub async fn pick_up_orders( }) .into_response() } + +// ============================================================================= +// Directive Order Group (DOG) CRUD +// ============================================================================= + +/// List all DOGs for a directive. +#[utoipa::path( + get, + path = "/api/v1/directives/{id}/dogs", + params(("id" = Uuid, Path, description = "Directive ID")), + responses( + (status = 200, description = "List of DOGs", body = DirectiveOrderGroupListResponse), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directive Order Groups" +)] +pub async fn list_dogs( + 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::list_directive_order_groups(pool, id, auth.owner_id).await { + Ok(dogs) => { + let total = dogs.len() as i64; + Json(DirectiveOrderGroupListResponse { dogs, total }).into_response() + } + Err(e) => { + tracing::error!("Failed to list DOGs: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("LIST_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Create a new DOG for a directive. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/dogs", + params(("id" = Uuid, Path, description = "Directive ID")), + request_body = CreateDirectiveOrderGroupRequest, + responses( + (status = 201, description = "DOG created", body = DirectiveOrderGroup), + (status = 400, description = "Invalid directive", body = ApiError), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directive Order Groups" +)] +pub async fn create_dog( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<CreateDirectiveOrderGroupRequest>, +) -> 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, id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::BAD_REQUEST, + Json(ApiError::new( + "INVALID_DIRECTIVE", + "directive_id must reference a valid directive owned by you", + )), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("VALIDATION_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + match repository::create_directive_order_group(pool, id, auth.owner_id, req).await { + Ok(dog) => (StatusCode::CREATED, Json(dog)).into_response(), + Err(e) => { + tracing::error!("Failed to create DOG: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("CREATE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Get a DOG by ID. +#[utoipa::path( + get, + path = "/api/v1/directives/{id}/dogs/{dog_id}", + params( + ("id" = Uuid, Path, description = "Directive ID"), + ("dog_id" = Uuid, Path, description = "DOG ID"), + ), + responses( + (status = 200, description = "DOG details", body = DirectiveOrderGroup), + (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 = "Directive Order Groups" +)] +pub async fn get_dog( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, dog_id)): Path<(Uuid, 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(); + }; + + let _ = id; // directive_id is in the path for REST nesting but we scope by owner_id + + match repository::get_directive_order_group(pool, dog_id, auth.owner_id).await { + Ok(Some(dog)) => Json(dog).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "DOG not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to get DOG: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Update a DOG. +#[utoipa::path( + patch, + path = "/api/v1/directives/{id}/dogs/{dog_id}", + params( + ("id" = Uuid, Path, description = "Directive ID"), + ("dog_id" = Uuid, Path, description = "DOG ID"), + ), + request_body = UpdateDirectiveOrderGroupRequest, + responses( + (status = 200, description = "DOG updated", body = DirectiveOrderGroup), + (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 = "Directive Order Groups" +)] +pub async fn update_dog( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, dog_id)): Path<(Uuid, Uuid)>, + Json(req): Json<UpdateDirectiveOrderGroupRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + let _ = id; // directive_id is in the path for REST nesting but we scope by owner_id + + // Validate status if provided + if let Some(ref status) = req.status { + if !["open", "in_progress", "done", "archived"].contains(&status.as_str()) { + return ( + StatusCode::BAD_REQUEST, + Json(ApiError::new( + "VALIDATION_FAILED", + "status must be one of: open, in_progress, done, archived", + )), + ) + .into_response(); + } + } + + match repository::update_directive_order_group(pool, dog_id, auth.owner_id, req).await { + Ok(Some(dog)) => Json(dog).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "DOG not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to update DOG: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("UPDATE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Delete a DOG. +#[utoipa::path( + delete, + path = "/api/v1/directives/{id}/dogs/{dog_id}", + params( + ("id" = Uuid, Path, description = "Directive ID"), + ("dog_id" = Uuid, Path, description = "DOG 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 = "Directive Order Groups" +)] +pub async fn delete_dog( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, dog_id)): Path<(Uuid, 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(); + }; + + let _ = id; // directive_id is in the path for REST nesting but we scope by owner_id + + match repository::delete_directive_order_group(pool, dog_id, auth.owner_id).await { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "DOG not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to delete DOG: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DELETE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// List orders belonging to a specific DOG. +#[utoipa::path( + get, + path = "/api/v1/directives/{id}/dogs/{dog_id}/orders", + params( + ("id" = Uuid, Path, description = "Directive ID"), + ("dog_id" = Uuid, Path, description = "DOG ID"), + ), + responses( + (status = 200, description = "List of orders in the DOG", body = OrderListResponse), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directive Order Groups" +)] +pub async fn list_dog_orders( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, dog_id)): Path<(Uuid, 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(); + }; + + let _ = id; // directive_id is in the path for REST nesting but we scope by owner_id + + match repository::list_orders_by_dog(pool, dog_id, auth.owner_id).await { + Ok(orders) => { + let total = orders.len() as i64; + Json(OrderListResponse { orders, total }).into_response() + } + Err(e) => { + tracing::error!("Failed to list orders for DOG: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("LIST_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Pick up orders for a specific DOG. Like the directive pick-up-orders +/// endpoint but filtered to orders belonging to the specified DOG. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/dogs/{dog_id}/pick-up-orders", + params( + ("id" = Uuid, Path, description = "Directive ID"), + ("dog_id" = Uuid, Path, description = "DOG ID"), + ), + responses( + (status = 200, description = "Orders picked up for planning", body = PickUpOrdersResponse), + (status = 404, description = "Directive or DOG not found", body = ApiError), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directive Order Groups" +)] +pub async fn pick_up_dog_orders( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, dog_id)): Path<(Uuid, 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(); + }; + + // Verify directive ownership and get directive with steps + let (directive, mut steps) = + match repository::get_directive_with_steps_for_owner(pool, auth.owner_id, id).await { + Ok(Some((d, s))) => (d, s), + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get directive: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + }; + + // Verify the DOG exists and belongs to this owner + match repository::get_directive_order_group(pool, dog_id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "DOG not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + // Auto-remove completed steps that were already included in a PR + if directive.pr_url.is_some() || directive.pr_branch.is_some() { + match crate::orchestration::directive::remove_already_merged_steps(pool, id).await { + Ok(count) if count > 0 => { + tracing::info!("Auto-removed {} completed steps already in PR for directive {}", count, id); + steps = match repository::list_directive_steps(pool, id).await { + Ok(s) => s, + Err(e) => { + tracing::error!("Failed to re-fetch steps after cleanup: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("REFETCH_STEPS_FAILED", &e.to_string())), + ).into_response(); + } + }; + } + Err(e) => { + tracing::warn!("Failed to auto-remove merged steps for directive {}: {}", id, e); + } + _ => {} + } + } + + // Reconcile existing orders + match repository::reconcile_directive_orders(pool, auth.owner_id, id).await { + Ok(count) => { + if count > 0 { + tracing::info!("Reconciled {} orders for directive {}", count, id); + } + } + Err(e) => { + tracing::warn!("Failed to reconcile directive orders: {}", e); + } + } + + // Fetch available orders filtered to this DOG + let orders = match repository::get_available_orders_for_dog_pickup(pool, auth.owner_id, id, dog_id).await { + Ok(o) => o, + Err(e) => { + tracing::error!("Failed to fetch available orders for DOG: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("FETCH_ORDERS_FAILED", &e.to_string())), + ) + .into_response(); + } + }; + + // If no orders available, return early + if orders.is_empty() { + return Json(PickUpOrdersResponse { + message: "No orders available to plan for this DOG".to_string(), + order_count: 0, + task_id: None, + }) + .into_response(); + } + + let order_count = orders.len() as i64; + let order_ids: Vec<Uuid> = orders.iter().map(|o| o.id).collect(); + + // Get generation and goal history for the planning prompt + let generation = + match repository::get_directive_max_generation(pool, id).await { + Ok(g) => g + 1, + Err(e) => { + tracing::error!("Failed to get max generation: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GENERATION_FAILED", &e.to_string())), + ) + .into_response(); + } + }; + + let goal_history = match repository::get_directive_goal_history(pool, id, 3).await { + Ok(h) => h, + Err(e) => { + tracing::warn!("Failed to get goal history: {}", e); + vec![] + } + }; + + // Build the specialized planning prompt + let plan = build_order_pickup_prompt(&directive, &steps, &orders, generation, &goal_history); + + // Link orders to the directive + if let Err(e) = + repository::bulk_link_orders_to_directive(pool, auth.owner_id, &order_ids, id).await + { + tracing::error!("Failed to link orders to directive: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("LINK_ORDERS_FAILED", &e.to_string())), + ) + .into_response(); + } + + // Mark picked-up orders as in_progress + if let Err(e) = + repository::bulk_update_order_status(pool, auth.owner_id, &order_ids, "in_progress").await + { + tracing::warn!("Failed to update order status to in_progress: {}", e); + } + + // Create the planning task + let req = CreateTaskRequest { + contract_id: None, + name: format!("Pick up DOG orders: {}", directive.title), + description: Some("Directive order group pickup planning task".to_string()), + plan, + parent_task_id: None, + is_supervisor: false, + priority: 0, + repository_url: directive.repository_url.clone(), + base_branch: directive.base_branch.clone(), + target_branch: None, + merge_mode: None, + target_repo_path: None, + completion_action: None, + continue_from_task_id: None, + copy_files: None, + checkpoint_sha: None, + branched_from_task_id: None, + conversation_history: None, + supervisor_worktree_task_id: None, + directive_id: Some(directive.id), + directive_step_id: None, + }; + + let task = match repository::create_task_for_owner(pool, auth.owner_id, req).await { + Ok(t) => t, + Err(e) => { + tracing::error!("Failed to create DOG pickup planning task: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("CREATE_TASK_FAILED", &e.to_string())), + ) + .into_response(); + } + }; + + // Assign as orchestrator task + if let Err(e) = repository::assign_orchestrator_task(pool, id, task.id).await { + tracing::error!("Failed to assign orchestrator task: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("ASSIGN_TASK_FAILED", &e.to_string())), + ) + .into_response(); + } + + // Cancel old planning tasks + let cancelled = repository::cancel_old_planning_tasks(pool, id, task.id).await; + if let Ok(count) = cancelled { + if count > 0 { + tracing::info!( + directive_id = %id, + cancelled_count = count, + "Cancelled old planning tasks superseded by DOG order pickup" + ); + } + } + + // Set directive to active if draft/idle/paused + match directive.status.as_str() { + "draft" | "idle" | "paused" => { + if let Err(e) = repository::set_directive_status(pool, auth.owner_id, id, "active").await + { + tracing::warn!("Failed to set directive status to active: {}", e); + } + } + _ => {} + } + + // Advance ready steps + let _ = repository::advance_directive_ready_steps(pool, id).await; + + Json(PickUpOrdersResponse { + message: format!("Planning {} orders from DOG", order_count), + order_count, + task_id: Some(task.id), + }) + .into_response() +} diff --git a/makima/src/server/handlers/mesh_supervisor.rs b/makima/src/server/handlers/mesh_supervisor.rs index 9d2dce7..ebde52b 100644 --- a/makima/src/server/handlers/mesh_supervisor.rs +++ b/makima/src/server/handlers/mesh_supervisor.rs @@ -3222,6 +3222,7 @@ pub async fn create_order_for_task( labels: request.labels, directive_id, repository_url, + dog_id: None, }; match repository::create_order(pool, owner_id, order_req).await { diff --git a/makima/src/server/handlers/orders.rs b/makima/src/server/handlers/orders.rs index 1251f79..03719cb 100644 --- a/makima/src/server/handlers/orders.rs +++ b/makima/src/server/handlers/orders.rs @@ -32,6 +32,7 @@ use crate::server::state::SharedState; ("type" = Option<String>, Query, description = "Filter by order type"), ("priority" = Option<String>, Query, description = "Filter by priority"), ("directive_id" = Option<Uuid>, Query, description = "Filter by directive ID"), + ("dog_id" = Option<Uuid>, Query, description = "Filter by DOG (Directive Order Group) ID"), ("search" = Option<String>, Query, description = "Text search across title, description, and directive name"), ), responses( @@ -62,6 +63,7 @@ pub async fn list_orders( query.order_type.as_deref(), query.priority.as_deref(), query.directive_id, + query.dog_id, query.search.as_deref(), ) .await diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index b84b90e..6321518 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -249,6 +249,19 @@ pub fn make_router(state: SharedState) -> Router { .route("/directives/{id}/cleanup", post(directives::cleanup_directive)) .route("/directives/{id}/create-pr", post(directives::create_pr)) .route("/directives/{id}/pick-up-orders", post(directives::pick_up_orders)) + // Directive Order Group (DOG) endpoints + .route( + "/directives/{id}/dogs", + get(directives::list_dogs).post(directives::create_dog), + ) + .route( + "/directives/{id}/dogs/{dog_id}", + get(directives::get_dog) + .patch(directives::update_dog) + .delete(directives::delete_dog), + ) + .route("/directives/{id}/dogs/{dog_id}/orders", get(directives::list_dog_orders)) + .route("/directives/{id}/dogs/{dog_id}/pick-up-orders", post(directives::pick_up_dog_orders)) // Order endpoints .route( "/orders", |
