summaryrefslogtreecommitdiff
path: root/makima
diff options
context:
space:
mode:
Diffstat (limited to 'makima')
-rw-r--r--makima/frontend/src/components/contracts/ContractDetail.tsx1
-rw-r--r--makima/frontend/src/components/contracts/ContractList.tsx2
-rw-r--r--makima/frontend/src/components/contracts/PhaseProgressBar.tsx19
-rw-r--r--makima/frontend/src/components/directives/DOGList.tsx381
-rw-r--r--makima/frontend/src/components/directives/DirectiveDetail.tsx72
-rw-r--r--makima/frontend/src/components/orders/OrderDetail.tsx91
-rw-r--r--makima/frontend/src/hooks/useDogs.ts112
-rw-r--r--makima/frontend/src/lib/api.ts84
-rw-r--r--makima/frontend/src/routes/directives.tsx8
-rw-r--r--makima/frontend/src/routes/orders.tsx3
-rw-r--r--makima/migrations/20260303000000_create_directive_order_groups.sql19
-rw-r--r--makima/src/db/models.rs53
-rw-r--r--makima/src/db/repository.rs187
-rw-r--r--makima/src/server/handlers/directives.rs586
-rw-r--r--makima/src/server/handlers/mesh_supervisor.rs1
-rw-r--r--makima/src/server/handlers/orders.rs2
-rw-r--r--makima/src/server/mod.rs13
17 files changed, 1609 insertions, 25 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 9c3ac97..4267725 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,18 @@ export function OrderDetail({
Step: <span className="text-[#7788aa]">{order.directiveStepId.slice(0, 8)}...</span>
</div>
)}
+ {order.directiveId && (
+ <div className="text-[10px] font-mono text-[#556677] mb-1">
+ DOG:{" "}
+ {order.dogId ? (
+ <span className="text-[#75aafc]">
+ {dogs.find((d) => d.id === order.dogId)?.name || order.dogId.slice(0, 8) + "..."}
+ </span>
+ ) : (
+ <span className="text-[#445566] italic">None</span>
+ )}
+ </div>
+ )}
{/* Controls */}
<div className="flex flex-wrap gap-2 mt-2">
@@ -501,6 +517,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 cee4920..846f52f 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, type RepositoryHistoryEntry } from "../lib/api";
@@ -13,6 +14,7 @@ export default function DirectivesPage() {
const { id: selectedId } = useParams<{ id: string }>();
const { directives, loading: listLoading, create, remove } = 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("");
@@ -213,6 +215,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 aa14e68..06e091a 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 type { OrderStatus, OrderType, OrderPriority } from "../lib/api";
@@ -18,6 +19,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("");
@@ -228,6 +230,7 @@ export default function OrdersPage() {
<OrderDetail
order={order}
directives={directives}
+ dogs={dogs}
onUpdate={handleUpdate}
onDelete={handleDelete}
onLinkDirective={handleLinkDirective}
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(&current.name);
+ let description = req.description.as_deref().or(current.description.as_deref());
+ let status = req.status.as_deref().unwrap_or(&current.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",