diff options
| author | soryu <soryu@soryu.co> | 2026-02-09 00:11:51 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-02-09 00:11:51 +0000 |
| commit | 8c23b3ab6f7fabca01b0468911bae073aa5ced32 (patch) | |
| tree | f50159aee13b13f0b55618ac09e9be1f89a41bb2 | |
| parent | 3662b334dfd68cfdf00ed44ae88927c2e1b2aabe (diff) | |
| download | soryu-8c23b3ab6f7fabca01b0468911bae073aa5ced32.tar.gz soryu-8c23b3ab6f7fabca01b0468911bae073aa5ced32.zip | |
Add new directive mechanism v3
32 files changed, 3085 insertions, 517 deletions
diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx index fb95c7f..46fef7a 100644 --- a/makima/frontend/src/components/NavStrip.tsx +++ b/makima/frontend/src/components/NavStrip.tsx @@ -13,6 +13,7 @@ const NAV_LINKS: NavLink[] = [ { label: "Contracts", href: "/contracts", requiresAuth: true }, { label: "Board", href: "/workflow", requiresAuth: true }, { label: "Mesh", href: "/mesh", requiresAuth: true }, + { label: "Directives", href: "/directives", requiresAuth: true }, { label: "History", href: "/history", requiresAuth: true }, ]; diff --git a/makima/frontend/src/components/directives/DirectiveDAG.tsx b/makima/frontend/src/components/directives/DirectiveDAG.tsx new file mode 100644 index 0000000..f288a0d --- /dev/null +++ b/makima/frontend/src/components/directives/DirectiveDAG.tsx @@ -0,0 +1,87 @@ +import { useMemo } from "react"; +import type { DirectiveStep } from "../../lib/api"; +import { StepNode } from "./StepNode"; + +interface DirectiveDAGProps { + steps: DirectiveStep[]; + onComplete?: (stepId: string) => void; + onFail?: (stepId: string) => void; + onSkip?: (stepId: string) => void; +} + +interface Layer { + steps: DirectiveStep[]; +} + +function topoSort(steps: DirectiveStep[]): Layer[] { + if (steps.length === 0) return []; + + const stepMap = new Map(steps.map((s) => [s.id, s])); + const assigned = new Set<string>(); + const layers: Layer[] = []; + + // Iteratively find steps whose dependencies are all assigned + let remaining = [...steps]; + while (remaining.length > 0) { + const layer: DirectiveStep[] = []; + for (const step of remaining) { + const depsResolved = step.dependsOn.every( + (depId) => assigned.has(depId) || !stepMap.has(depId) + ); + if (depsResolved) { + layer.push(step); + } + } + + if (layer.length === 0) { + // Cycle detected or orphaned — push all remaining + layers.push({ steps: remaining }); + break; + } + + for (const s of layer) { + assigned.add(s.id); + } + layers.push({ steps: layer.sort((a, b) => a.orderIndex - b.orderIndex) }); + remaining = remaining.filter((s) => !assigned.has(s.id)); + } + + return layers; +} + +export function DirectiveDAG({ steps, onComplete, onFail, onSkip }: DirectiveDAGProps) { + const layers = useMemo(() => topoSort(steps), [steps]); + + if (steps.length === 0) { + return ( + <div className="text-center py-8 text-[#7788aa] font-mono text-sm"> + No steps yet. Add steps to build the DAG. + </div> + ); + } + + return ( + <div className="flex flex-col gap-4 items-center py-4"> + {layers.map((layer, layerIdx) => ( + <div key={layerIdx}> + {layerIdx > 0 && ( + <div className="flex justify-center py-1"> + <div className="w-px h-4 bg-[#2a3a5a]" /> + </div> + )} + <div className="flex flex-wrap gap-3 justify-center"> + {layer.steps.map((step) => ( + <StepNode + key={step.id} + step={step} + onComplete={onComplete ? () => onComplete(step.id) : undefined} + onFail={onFail ? () => onFail(step.id) : undefined} + onSkip={onSkip ? () => onSkip(step.id) : undefined} + /> + ))} + </div> + </div> + ))} + </div> + ); +} diff --git a/makima/frontend/src/components/directives/DirectiveDetail.tsx b/makima/frontend/src/components/directives/DirectiveDetail.tsx new file mode 100644 index 0000000..abd2c55 --- /dev/null +++ b/makima/frontend/src/components/directives/DirectiveDetail.tsx @@ -0,0 +1,216 @@ +import { useState } from "react"; +import type { DirectiveWithSteps, DirectiveStatus } from "../../lib/api"; +import { DirectiveDAG } from "./DirectiveDAG"; + +const STATUS_BADGE: Record<DirectiveStatus, { color: string; label: string }> = { + draft: { color: "text-[#7788aa] border-[#2a3a5a]", label: "DRAFT" }, + active: { color: "text-green-400 border-green-800", label: "ACTIVE" }, + idle: { color: "text-yellow-400 border-yellow-800", label: "IDLE" }, + paused: { color: "text-orange-400 border-orange-800", label: "PAUSED" }, + archived: { color: "text-[#556677] border-[#2a3a5a]", label: "ARCHIVED" }, +}; + +interface DirectiveDetailProps { + directive: DirectiveWithSteps; + onStart: () => void; + onPause: () => void; + onAdvance: () => void; + onCompleteStep: (stepId: string) => void; + onFailStep: (stepId: string) => void; + onSkipStep: (stepId: string) => void; + onUpdateGoal: (goal: string) => void; + onDelete: () => void; + onRefresh: () => void; +} + +export function DirectiveDetail({ + directive, + onStart, + onPause, + onAdvance, + onCompleteStep, + onFailStep, + onSkipStep, + onUpdateGoal, + onDelete, + onRefresh, +}: DirectiveDetailProps) { + const [editingGoal, setEditingGoal] = useState(false); + const [goalText, setGoalText] = useState(directive.goal); + const badge = STATUS_BADGE[directive.status] || STATUS_BADGE.draft; + + const completedSteps = directive.steps.filter((s) => s.status === "completed").length; + const totalSteps = directive.steps.length; + const progress = totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0; + + const handleGoalSave = () => { + if (goalText.trim() && goalText !== directive.goal) { + onUpdateGoal(goalText.trim()); + } + setEditingGoal(false); + }; + + return ( + <div className="flex flex-col h-full overflow-y-auto"> + {/* Header */} + <div className="px-4 py-3 border-b border-dashed border-[rgba(117,170,252,0.2)]"> + <div className="flex items-center justify-between mb-2"> + <h2 className="text-[14px] font-mono text-white font-medium truncate pr-2"> + {directive.title} + </h2> + <div className="flex items-center gap-2 shrink-0"> + <span + className={`text-[10px] font-mono ${badge.color} border rounded px-2 py-0.5`} + > + {badge.label} + </span> + <button + type="button" + onClick={onRefresh} + className="text-[10px] font-mono text-[#7788aa] hover:text-white" + title="Refresh" + > + [refresh] + </button> + </div> + </div> + + {/* Progress bar */} + {totalSteps > 0 && ( + <div className="flex items-center gap-2 mb-2"> + <div className="flex-1 h-1.5 bg-[#1a2540] rounded overflow-hidden"> + <div + className="h-full bg-emerald-600 rounded transition-all" + style={{ width: `${progress}%` }} + /> + </div> + <span className="text-[10px] font-mono text-[#7788aa] shrink-0"> + {completedSteps}/{totalSteps} steps + </span> + </div> + )} + + {/* Repo info */} + {(directive.repositoryUrl || directive.localPath) && ( + <div className="text-[10px] font-mono text-[#556677] mb-2 truncate"> + {directive.repositoryUrl || directive.localPath} + {directive.baseBranch && ` @ ${directive.baseBranch}`} + </div> + )} + + {/* Controls */} + <div className="flex flex-wrap gap-2"> + {(directive.status === "draft" || directive.status === "paused") && ( + <button + type="button" + onClick={onStart} + className="text-[10px] font-mono text-green-400 hover:text-green-300 border border-green-800 rounded px-2 py-1" + > + Start + </button> + )} + {directive.status === "active" && ( + <> + <button + type="button" + onClick={onPause} + className="text-[10px] font-mono text-orange-400 hover:text-orange-300 border border-orange-800 rounded px-2 py-1" + > + Pause + </button> + <button + type="button" + onClick={onAdvance} + className="text-[10px] font-mono text-[#75aafc] hover:text-white border border-[rgba(117,170,252,0.3)] rounded px-2 py-1" + > + Advance + </button> + </> + )} + {directive.status === "idle" && ( + <div className="flex items-center gap-2"> + <span className="text-[10px] font-mono text-yellow-400"> + All steps done. Update goal to add new work. + </span> + <button + type="button" + onClick={() => setEditingGoal(true)} + className="text-[10px] font-mono text-[#75aafc] hover:text-white border border-[rgba(117,170,252,0.3)] rounded px-2 py-1" + > + Update Goal + </button> + </div> + )} + <button + type="button" + onClick={onDelete} + className="text-[10px] font-mono text-red-400 hover:text-red-300 border border-red-800 rounded px-2 py-1 ml-auto" + > + Delete + </button> + </div> + </div> + + {/* Goal */} + <div className="px-4 py-3 border-b border-[rgba(117,170,252,0.1)]"> + <div className="flex items-center justify-between mb-1"> + <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide"> + Goal + </span> + {!editingGoal && ( + <button + type="button" + onClick={() => { setGoalText(directive.goal); setEditingGoal(true); }} + className="text-[9px] font-mono text-[#556677] hover:text-[#75aafc]" + > + [edit] + </button> + )} + </div> + {editingGoal ? ( + <div className="flex flex-col gap-1.5"> + <textarea + value={goalText} + onChange={(e) => setGoalText(e.target.value)} + className="w-full bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1.5 text-[11px] font-mono text-white resize-y min-h-[60px]" + rows={3} + /> + <div className="flex gap-1.5"> + <button + type="button" + onClick={handleGoalSave} + className="text-[10px] font-mono text-emerald-400 hover:text-emerald-300 border border-emerald-800 rounded px-2 py-0.5" + > + Save + </button> + <button + type="button" + onClick={() => setEditingGoal(false)} + className="text-[10px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-2 py-0.5" + > + Cancel + </button> + </div> + </div> + ) : ( + <p className="text-[11px] font-mono text-[#c0d0e0] whitespace-pre-wrap"> + {directive.goal} + </p> + )} + </div> + + {/* DAG */} + <div className="px-4 py-3 flex-1"> + <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide block mb-2"> + Steps ({totalSteps}) + </span> + <DirectiveDAG + steps={directive.steps} + onComplete={onCompleteStep} + onFail={onFailStep} + onSkip={onSkipStep} + /> + </div> + </div> + ); +} diff --git a/makima/frontend/src/components/directives/DirectiveList.tsx b/makima/frontend/src/components/directives/DirectiveList.tsx new file mode 100644 index 0000000..6393ea7 --- /dev/null +++ b/makima/frontend/src/components/directives/DirectiveList.tsx @@ -0,0 +1,87 @@ +import type { DirectiveSummary, DirectiveStatus } from "../../lib/api"; + +const STATUS_BADGE: Record<DirectiveStatus, { color: string; label: string }> = { + draft: { color: "text-[#7788aa] border-[#2a3a5a]", label: "DRAFT" }, + active: { color: "text-green-400 border-green-800", label: "ACTIVE" }, + idle: { color: "text-yellow-400 border-yellow-800", label: "IDLE" }, + paused: { color: "text-orange-400 border-orange-800", label: "PAUSED" }, + archived: { color: "text-[#556677] border-[#2a3a5a]", label: "ARCHIVED" }, +}; + +interface DirectiveListProps { + directives: DirectiveSummary[]; + selectedId: string | null; + onSelect: (id: string) => void; + onCreate: () => void; +} + +export function DirectiveList({ directives, selectedId, onSelect, onCreate }: DirectiveListProps) { + return ( + <div className="flex flex-col h-full"> + <div className="flex items-center justify-between px-3 py-2 border-b border-dashed border-[rgba(117,170,252,0.2)]"> + <span className="text-[11px] font-mono text-[#9bc3ff] uppercase tracking-wide"> + Directives + </span> + <button + type="button" + onClick={onCreate} + className="text-[11px] font-mono text-[#75aafc] hover:text-white bg-transparent border border-[rgba(117,170,252,0.3)] rounded px-2 py-0.5 hover:border-[rgba(117,170,252,0.6)] transition-colors" + > + + New + </button> + </div> + <div className="flex-1 overflow-y-auto"> + {directives.length === 0 ? ( + <div className="px-3 py-6 text-center text-[#556677] font-mono text-[11px]"> + No directives yet + </div> + ) : ( + directives.map((d) => { + const badge = STATUS_BADGE[d.status] || STATUS_BADGE.draft; + const progress = d.totalSteps > 0 + ? Math.round((d.completedSteps / d.totalSteps) * 100) + : 0; + + return ( + <button + key={d.id} + type="button" + onClick={() => onSelect(d.id)} + className={`w-full text-left px-3 py-2.5 border-b border-[rgba(117,170,252,0.1)] hover:bg-[rgba(117,170,252,0.05)] transition-colors ${ + selectedId === d.id ? "bg-[rgba(117,170,252,0.1)]" : "" + }`} + > + <div className="flex items-center justify-between mb-1"> + <span className="text-[12px] font-mono text-white truncate pr-2"> + {d.title} + </span> + <span + className={`text-[9px] font-mono ${badge.color} border rounded px-1.5 py-0.5 shrink-0`} + > + {badge.label} + </span> + </div> + <p className="text-[10px] text-[#7788aa] font-mono truncate mb-1.5"> + {d.goal} + </p> + {d.totalSteps > 0 && ( + <div className="flex items-center gap-2"> + <div className="flex-1 h-1 bg-[#1a2540] rounded overflow-hidden"> + <div + className="h-full bg-emerald-600 rounded transition-all" + style={{ width: `${progress}%` }} + /> + </div> + <span className="text-[9px] font-mono text-[#556677] shrink-0"> + {d.completedSteps}/{d.totalSteps} + </span> + </div> + )} + </button> + ); + }) + )} + </div> + </div> + ); +} diff --git a/makima/frontend/src/components/directives/StepNode.tsx b/makima/frontend/src/components/directives/StepNode.tsx new file mode 100644 index 0000000..fa91956 --- /dev/null +++ b/makima/frontend/src/components/directives/StepNode.tsx @@ -0,0 +1,82 @@ +import type { DirectiveStep, StepStatus } from "../../lib/api"; + +const STATUS_COLORS: Record<StepStatus, { bg: string; border: string; text: string }> = { + pending: { bg: "bg-[#1a2540]", border: "border-[#2a3a5a]", text: "text-[#7788aa]" }, + ready: { bg: "bg-[#2a2a10]", border: "border-[#4a4a20]", text: "text-yellow-400" }, + running: { bg: "bg-[#0a2a1a]", border: "border-[#1a5a3a]", text: "text-green-400" }, + completed: { bg: "bg-[#0a2a2a]", border: "border-[#1a5a5a]", text: "text-emerald-400" }, + failed: { bg: "bg-[#2a1a1a]", border: "border-[#5a2a2a]", text: "text-red-400" }, + skipped: { bg: "bg-[#1a1a2a]", border: "border-[#2a2a4a]", text: "text-[#7788aa]" }, +}; + +const STATUS_LABELS: Record<StepStatus, string> = { + pending: "PENDING", + ready: "READY", + running: "RUNNING", + completed: "DONE", + failed: "FAILED", + skipped: "SKIP", +}; + +interface StepNodeProps { + step: DirectiveStep; + onComplete?: () => void; + onFail?: () => void; + onSkip?: () => void; +} + +export function StepNode({ step, onComplete, onFail, onSkip }: StepNodeProps) { + const colors = STATUS_COLORS[step.status] || STATUS_COLORS.pending; + const label = STATUS_LABELS[step.status] || step.status.toUpperCase(); + + return ( + <div + className={`${colors.bg} ${colors.border} border rounded px-3 py-2 min-w-[160px] max-w-[220px]`} + > + <div className="flex items-center justify-between gap-2 mb-1"> + <span className="text-[11px] font-mono text-white truncate font-medium"> + {step.name} + </span> + <span className={`text-[9px] font-mono ${colors.text} uppercase shrink-0`}> + {label} + </span> + </div> + {step.description && ( + <p className="text-[10px] text-[#7788aa] font-mono truncate mb-1"> + {step.description} + </p> + )} + {(step.status === "running" || step.status === "ready") && ( + <div className="flex gap-1 mt-1"> + {onComplete && ( + <button + type="button" + onClick={onComplete} + className="text-[9px] font-mono text-emerald-400 hover:text-emerald-300 bg-transparent border border-emerald-800 rounded px-1.5 py-0.5" + > + Done + </button> + )} + {onFail && ( + <button + type="button" + onClick={onFail} + className="text-[9px] font-mono text-red-400 hover:text-red-300 bg-transparent border border-red-800 rounded px-1.5 py-0.5" + > + Fail + </button> + )} + {onSkip && ( + <button + type="button" + onClick={onSkip} + className="text-[9px] font-mono text-[#7788aa] hover:text-white bg-transparent border border-[#2a3a5a] rounded px-1.5 py-0.5" + > + Skip + </button> + )} + </div> + )} + </div> + ); +} diff --git a/makima/frontend/src/hooks/useDirectives.ts b/makima/frontend/src/hooks/useDirectives.ts new file mode 100644 index 0000000..b69275a --- /dev/null +++ b/makima/frontend/src/hooks/useDirectives.ts @@ -0,0 +1,150 @@ +import { useState, useEffect, useCallback } from "react"; +import { + type DirectiveSummary, + type DirectiveWithSteps, + type CreateDirectiveRequest, + type UpdateDirectiveRequest, + type CreateDirectiveStepRequest, + listDirectives, + createDirective, + getDirective, + updateDirective, + deleteDirective, + createDirectiveStep, + deleteDirectiveStep, + startDirective, + pauseDirective, + advanceDirective, + completeDirectiveStep, + failDirectiveStep, + skipDirectiveStep, + updateDirectiveGoal, +} from "../lib/api"; + +export function useDirectives() { + const [directives, setDirectives] = useState<DirectiveSummary[]>([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + + const refresh = useCallback(async () => { + try { + setLoading(true); + setError(null); + const res = await listDirectives(); + setDirectives(res.directives); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load directives"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + const create = useCallback(async (req: CreateDirectiveRequest) => { + const d = await createDirective(req); + await refresh(); + return d; + }, [refresh]); + + const remove = useCallback(async (id: string) => { + await deleteDirective(id); + await refresh(); + }, [refresh]); + + return { directives, loading, error, refresh, create, remove }; +} + +export function useDirective(id: string | undefined) { + const [directive, setDirective] = useState<DirectiveWithSteps | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + + const refresh = useCallback(async () => { + if (!id) return; + try { + setLoading(true); + setError(null); + const d = await getDirective(id); + setDirective(d); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load directive"); + } finally { + setLoading(false); + } + }, [id]); + + useEffect(() => { + refresh(); + }, [refresh]); + + const update = useCallback(async (req: UpdateDirectiveRequest) => { + if (!id) return; + await updateDirective(id, req); + await refresh(); + }, [id, refresh]); + + const addStep = useCallback(async (req: CreateDirectiveStepRequest) => { + if (!id) return; + await createDirectiveStep(id, req); + await refresh(); + }, [id, refresh]); + + const removeStep = useCallback(async (stepId: string) => { + if (!id) return; + await deleteDirectiveStep(id, stepId); + await refresh(); + }, [id, refresh]); + + const start = useCallback(async () => { + if (!id) return; + await startDirective(id); + await refresh(); + }, [id, refresh]); + + const pause = useCallback(async () => { + if (!id) return; + await pauseDirective(id); + await refresh(); + }, [id, refresh]); + + const advance = useCallback(async () => { + if (!id) return; + await advanceDirective(id); + await refresh(); + }, [id, refresh]); + + const completeStep = useCallback(async (stepId: string) => { + if (!id) return; + await completeDirectiveStep(id, stepId); + await refresh(); + }, [id, refresh]); + + const failStep = useCallback(async (stepId: string) => { + if (!id) return; + await failDirectiveStep(id, stepId); + await refresh(); + }, [id, refresh]); + + const skipStep = useCallback(async (stepId: string) => { + if (!id) return; + await skipDirectiveStep(id, stepId); + await refresh(); + }, [id, refresh]); + + const updateGoal = useCallback(async (goal: string) => { + if (!id) return; + await updateDirectiveGoal(id, goal); + await refresh(); + }, [id, refresh]); + + return { + directive, loading, error, refresh, + update, addStep, removeStep, + start, pause, advance, + completeStep, failStep, skipStep, + updateGoal, + }; +} diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index 7732725..b1422df 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -3003,4 +3003,226 @@ export async function listTaskPatches(taskId: string, contractId: string): Promi return res.json(); } +// ============================================================================= +// Directive Types & API +// ============================================================================= + +export type DirectiveStatus = "draft" | "active" | "idle" | "paused" | "archived"; +export type StepStatus = "pending" | "ready" | "running" | "completed" | "failed" | "skipped"; + +export interface Directive { + id: string; + ownerId: string; + title: string; + goal: string; + status: DirectiveStatus; + repositoryUrl: string | null; + localPath: string | null; + baseBranch: string | null; + orchestratorTaskId: string | null; + goalUpdatedAt: string; + startedAt: string | null; + version: number; + createdAt: string; + updatedAt: string; +} + +export interface DirectiveStep { + id: string; + directiveId: string; + name: string; + description: string | null; + taskPlan: string | null; + dependsOn: string[]; + status: StepStatus; + taskId: string | null; + orderIndex: number; + generation: number; + startedAt: string | null; + completedAt: string | null; + createdAt: string; +} + +export interface DirectiveWithSteps extends Directive { + steps: DirectiveStep[]; +} + +export interface DirectiveSummary { + id: string; + ownerId: string; + title: string; + goal: string; + status: DirectiveStatus; + repositoryUrl: string | null; + orchestratorTaskId: string | null; + version: number; + createdAt: string; + updatedAt: string; + totalSteps: number; + completedSteps: number; + runningSteps: number; + failedSteps: number; +} + +export interface DirectiveListResponse { + directives: DirectiveSummary[]; + total: number; +} + +export interface CreateDirectiveRequest { + title: string; + goal: string; + repositoryUrl?: string; + localPath?: string; + baseBranch?: string; +} + +export interface UpdateDirectiveRequest { + title?: string; + goal?: string; + status?: string; + repositoryUrl?: string; + localPath?: string; + baseBranch?: string; + orchestratorTaskId?: string; + version?: number; +} + +export interface CreateDirectiveStepRequest { + name: string; + description?: string; + taskPlan?: string; + dependsOn?: string[]; + orderIndex?: number; + generation?: number; +} + +export interface UpdateDirectiveStepRequest { + name?: string; + description?: string; + taskPlan?: string; + dependsOn?: string[]; + status?: string; + taskId?: string; + orderIndex?: number; +} + +export async function listDirectives(): Promise<DirectiveListResponse> { + const res = await authFetch(`${API_BASE}/api/v1/directives`); + if (!res.ok) throw new Error(`Failed to list directives: ${res.statusText}`); + return res.json(); +} + +export async function createDirective(req: CreateDirectiveRequest): Promise<Directive> { + const res = await authFetch(`${API_BASE}/api/v1/directives`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(req), + }); + if (!res.ok) throw new Error(`Failed to create directive: ${res.statusText}`); + return res.json(); +} + +export async function getDirective(id: string): Promise<DirectiveWithSteps> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${id}`); + if (!res.ok) throw new Error(`Failed to get directive: ${res.statusText}`); + return res.json(); +} + +export async function updateDirective(id: string, req: UpdateDirectiveRequest): Promise<Directive> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(req), + }); + if (!res.ok) throw new Error(`Failed to update directive: ${res.statusText}`); + return res.json(); +} + +export async function deleteDirective(id: string): Promise<void> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${id}`, { method: "DELETE" }); + if (!res.ok) throw new Error(`Failed to delete directive: ${res.statusText}`); +} + +export async function createDirectiveStep(directiveId: string, req: CreateDirectiveStepRequest): Promise<DirectiveStep> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/steps`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(req), + }); + if (!res.ok) throw new Error(`Failed to create step: ${res.statusText}`); + return res.json(); +} + +export async function batchCreateDirectiveSteps(directiveId: string, steps: CreateDirectiveStepRequest[]): Promise<DirectiveStep[]> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/steps/batch`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(steps), + }); + if (!res.ok) throw new Error(`Failed to batch create steps: ${res.statusText}`); + return res.json(); +} + +export async function updateDirectiveStep(directiveId: string, stepId: string, req: UpdateDirectiveStepRequest): Promise<DirectiveStep> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/steps/${stepId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(req), + }); + if (!res.ok) throw new Error(`Failed to update step: ${res.statusText}`); + return res.json(); +} + +export async function deleteDirectiveStep(directiveId: string, stepId: string): Promise<void> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/steps/${stepId}`, { method: "DELETE" }); + if (!res.ok) throw new Error(`Failed to delete step: ${res.statusText}`); +} + +export async function startDirective(id: string): Promise<DirectiveWithSteps> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${id}/start`, { method: "POST" }); + if (!res.ok) throw new Error(`Failed to start directive: ${res.statusText}`); + return res.json(); +} + +export async function pauseDirective(id: string): Promise<Directive> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${id}/pause`, { method: "POST" }); + if (!res.ok) throw new Error(`Failed to pause directive: ${res.statusText}`); + return res.json(); +} + +export async function advanceDirective(id: string): Promise<DirectiveWithSteps> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${id}/advance`, { method: "POST" }); + if (!res.ok) throw new Error(`Failed to advance directive: ${res.statusText}`); + return res.json(); +} + +export async function completeDirectiveStep(directiveId: string, stepId: string): Promise<DirectiveStep> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/steps/${stepId}/complete`, { method: "POST" }); + if (!res.ok) throw new Error(`Failed to complete step: ${res.statusText}`); + return res.json(); +} + +export async function failDirectiveStep(directiveId: string, stepId: string): Promise<DirectiveStep> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/steps/${stepId}/fail`, { method: "POST" }); + if (!res.ok) throw new Error(`Failed to fail step: ${res.statusText}`); + return res.json(); +} + +export async function skipDirectiveStep(directiveId: string, stepId: string): Promise<DirectiveStep> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/steps/${stepId}/skip`, { method: "POST" }); + if (!res.ok) throw new Error(`Failed to skip step: ${res.statusText}`); + return res.json(); +} + +export async function updateDirectiveGoal(id: string, goal: string): Promise<Directive> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${id}/goal`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ goal }), + }); + if (!res.ok) throw new Error(`Failed to update goal: ${res.statusText}`); + return res.json(); +} + diff --git a/makima/frontend/src/main.tsx b/makima/frontend/src/main.tsx index 50fffe4..3dc68f5 100644 --- a/makima/frontend/src/main.tsx +++ b/makima/frontend/src/main.tsx @@ -19,6 +19,7 @@ import LoginPage from "./routes/login"; import SettingsPage from "./routes/settings"; import ContractFilePage from "./routes/contract-file"; import SpeakPage from "./routes/speak"; +import DirectivesPage from "./routes/directives"; createRoot(document.getElementById("root")!).render( <StrictMode> @@ -128,6 +129,22 @@ createRoot(document.getElementById("root")!).render( } /> <Route + path="/directives" + element={ + <ProtectedRoute> + <DirectivesPage /> + </ProtectedRoute> + } + /> + <Route + path="/directives/:id" + element={ + <ProtectedRoute> + <DirectivesPage /> + </ProtectedRoute> + } + /> + <Route path="/speak" element={ <ProtectedRoute> diff --git a/makima/frontend/src/routes/directives.tsx b/makima/frontend/src/routes/directives.tsx new file mode 100644 index 0000000..82e5d48 --- /dev/null +++ b/makima/frontend/src/routes/directives.tsx @@ -0,0 +1,168 @@ +import { useState, useEffect } from "react"; +import { useParams, useNavigate } from "react-router"; +import { Masthead } from "../components/Masthead"; +import { DirectiveList } from "../components/directives/DirectiveList"; +import { DirectiveDetail } from "../components/directives/DirectiveDetail"; +import { useDirectives, useDirective } from "../hooks/useDirectives"; +import { useAuth } from "../contexts/AuthContext"; + +export default function DirectivesPage() { + const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); + const navigate = useNavigate(); + const { id: selectedId } = useParams<{ id: string }>(); + const { directives, loading: listLoading, create, remove } = useDirectives(); + const { directive, refresh: refreshDetail, start, pause, advance, completeStep, failStep, skipStep, updateGoal } = useDirective(selectedId); + + const [showCreate, setShowCreate] = useState(false); + const [newTitle, setNewTitle] = useState(""); + const [newGoal, setNewGoal] = useState(""); + const [newRepoUrl, setNewRepoUrl] = useState(""); + + useEffect(() => { + if (!authLoading && isAuthConfigured && !isAuthenticated) { + navigate("/login"); + } + }, [authLoading, isAuthConfigured, isAuthenticated, navigate]); + + if (authLoading) { + return ( + <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> + <Masthead showNav /> + <main className="flex-1 flex items-center justify-center"> + <p className="text-[#7788aa] font-mono text-sm">Loading...</p> + </main> + </div> + ); + } + + const handleCreate = async () => { + if (!newTitle.trim() || !newGoal.trim()) return; + try { + const d = await create({ + title: newTitle.trim(), + goal: newGoal.trim(), + repositoryUrl: newRepoUrl.trim() || undefined, + }); + setShowCreate(false); + setNewTitle(""); + setNewGoal(""); + setNewRepoUrl(""); + navigate(`/directives/${d.id}`); + } catch (e) { + console.error("Failed to create directive:", e); + } + }; + + const handleDelete = async () => { + if (!selectedId) return; + if (!window.confirm("Delete this directive?")) return; + try { + await remove(selectedId); + navigate("/directives"); + } catch (e) { + console.error("Failed to delete:", e); + } + }; + + return ( + <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> + <Masthead showNav /> + <main className="flex-1 flex overflow-hidden" style={{ height: "calc(100vh - 80px)" }}> + {/* Left: List */} + <div className="w-[280px] shrink-0 border-r border-dashed border-[rgba(117,170,252,0.2)] overflow-hidden flex flex-col"> + <DirectiveList + directives={directives} + selectedId={selectedId ?? null} + onSelect={(id) => navigate(`/directives/${id}`)} + onCreate={() => setShowCreate(true)} + /> + </div> + + {/* Right: Detail or Create */} + <div className="flex-1 overflow-hidden"> + {showCreate ? ( + <div className="p-4 max-w-lg"> + <h2 className="text-[14px] font-mono text-white font-medium mb-4"> + New Directive + </h2> + <div className="flex flex-col gap-3"> + <div> + <label className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide block mb-1"> + Title + </label> + <input + value={newTitle} + onChange={(e) => setNewTitle(e.target.value)} + placeholder="Project title..." + className="w-full bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1.5 text-[12px] font-mono text-white" + /> + </div> + <div> + <label className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide block mb-1"> + Goal + </label> + <textarea + value={newGoal} + onChange={(e) => setNewGoal(e.target.value)} + placeholder="What should this directive accomplish?" + rows={4} + className="w-full bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1.5 text-[12px] font-mono text-white resize-y" + /> + </div> + <div> + <label className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide block mb-1"> + Repository URL (optional) + </label> + <input + value={newRepoUrl} + onChange={(e) => setNewRepoUrl(e.target.value)} + placeholder="https://github.com/..." + className="w-full bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1.5 text-[12px] font-mono text-white" + /> + </div> + <div className="flex gap-2"> + <button + type="button" + onClick={handleCreate} + disabled={!newTitle.trim() || !newGoal.trim()} + className="text-[11px] font-mono text-emerald-400 hover:text-emerald-300 border border-emerald-800 rounded px-3 py-1 disabled:opacity-50" + > + Create + </button> + <button + type="button" + onClick={() => setShowCreate(false)} + className="text-[11px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-3 py-1" + > + Cancel + </button> + </div> + </div> + </div> + ) : selectedId && directive ? ( + <DirectiveDetail + directive={directive} + onStart={start} + onPause={pause} + onAdvance={advance} + onCompleteStep={completeStep} + onFailStep={failStep} + onSkipStep={skipStep} + onUpdateGoal={updateGoal} + onDelete={handleDelete} + onRefresh={refreshDetail} + /> + ) : ( + <div className="flex-1 flex items-center justify-center h-full"> + <p className="text-[#556677] font-mono text-[12px]"> + {listLoading + ? "Loading..." + : "Select a directive or create a new one"} + </p> + </div> + )} + </div> + </main> + </div> + ); +} diff --git a/makima/frontend/tsconfig.tsbuildinfo b/makima/frontend/tsconfig.tsbuildinfo index 0fbecaa..f36c337 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/files/bodyrenderer.tsx","./src/components/files/cliinput.tsx","./src/components/files/conflictnotification.tsx","./src/components/files/elementcontextmenu.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/files/reposyncindicator.tsx","./src/components/files/updatenotification.tsx","./src/components/files/versionhistorydropdown.tsx","./src/components/history/checkpointcard.tsx","./src/components/history/checkpointlist.tsx","./src/components/history/conversationmessage.tsx","./src/components/history/conversationview.tsx","./src/components/history/historyfilters.tsx","./src/components/history/resumecontrols.tsx","./src/components/history/timelineeventcard.tsx","./src/components/history/timelinelist.tsx","./src/components/history/index.ts","./src/components/listen/contractpickermodal.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/discusscontractmodal.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptanalysispanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/components/mesh/branchtaskmodal.tsx","./src/components/mesh/contractcompletequestion.tsx","./src/components/mesh/directoryinput.tsx","./src/components/mesh/gitactionspanel.tsx","./src/components/mesh/inlinesubtaskeditor.tsx","./src/components/mesh/mergeconflictresolver.tsx","./src/components/mesh/overlaydiffviewer.tsx","./src/components/mesh/prpreview.tsx","./src/components/mesh/patcheslistpanel.tsx","./src/components/mesh/subtasktree.tsx","./src/components/mesh/taskdetail.tsx","./src/components/mesh/tasklist.tsx","./src/components/mesh/taskoutput.tsx","./src/components/mesh/tasktree.tsx","./src/components/mesh/unifiedmeshchatinput.tsx","./src/components/mesh/worktreefilespanel.tsx","./src/components/workflow/phasecolumn.tsx","./src/components/workflow/workflowboard.tsx","./src/components/workflow/workflowcontractcard.tsx","./src/contexts/authcontext.tsx","./src/contexts/supervisorquestionscontext.tsx","./src/hooks/usecontracts.ts","./src/hooks/usefilesubscription.ts","./src/hooks/usefiles.ts","./src/hooks/usemeshchathistory.ts","./src/hooks/usemicrophone.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/files.tsx","./src/routes/history.tsx","./src/routes/listen.tsx","./src/routes/login.tsx","./src/routes/mesh.tsx","./src/routes/settings.tsx","./src/routes/speak.tsx","./src/routes/workflow.tsx","./src/types/messages.ts"],"version":"5.9.3"}
\ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/japanesehovertext.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/phaseconfirmationnotification.tsx","./src/components/protectedroute.tsx","./src/components/rewritelink.tsx","./src/components/simplemarkdown.tsx","./src/components/supervisorquestionnotification.tsx","./src/components/charts/chartrenderer.tsx","./src/components/contracts/commandmodepanel.tsx","./src/components/contracts/contractcliinput.tsx","./src/components/contracts/contractcontextmenu.tsx","./src/components/contracts/contractdetail.tsx","./src/components/contracts/contractlist.tsx","./src/components/contracts/phasebadge.tsx","./src/components/contracts/phaseconfirmationmodal.tsx","./src/components/contracts/phasedeliverablespanel.tsx","./src/components/contracts/phasehint.tsx","./src/components/contracts/phaseprogressbar.tsx","./src/components/contracts/quickactionbuttons.tsx","./src/components/contracts/repositorypanel.tsx","./src/components/contracts/taskderivationpreview.tsx","./src/components/directives/directivedag.tsx","./src/components/directives/directivedetail.tsx","./src/components/directives/directivelist.tsx","./src/components/directives/stepnode.tsx","./src/components/files/bodyrenderer.tsx","./src/components/files/cliinput.tsx","./src/components/files/conflictnotification.tsx","./src/components/files/elementcontextmenu.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/files/reposyncindicator.tsx","./src/components/files/updatenotification.tsx","./src/components/files/versionhistorydropdown.tsx","./src/components/history/checkpointcard.tsx","./src/components/history/checkpointlist.tsx","./src/components/history/conversationmessage.tsx","./src/components/history/conversationview.tsx","./src/components/history/historyfilters.tsx","./src/components/history/resumecontrols.tsx","./src/components/history/timelineeventcard.tsx","./src/components/history/timelinelist.tsx","./src/components/history/index.ts","./src/components/listen/contractpickermodal.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/discusscontractmodal.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptanalysispanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/components/mesh/branchtaskmodal.tsx","./src/components/mesh/contractcompletequestion.tsx","./src/components/mesh/directoryinput.tsx","./src/components/mesh/gitactionspanel.tsx","./src/components/mesh/inlinesubtaskeditor.tsx","./src/components/mesh/mergeconflictresolver.tsx","./src/components/mesh/overlaydiffviewer.tsx","./src/components/mesh/prpreview.tsx","./src/components/mesh/patcheslistpanel.tsx","./src/components/mesh/subtasktree.tsx","./src/components/mesh/taskdetail.tsx","./src/components/mesh/tasklist.tsx","./src/components/mesh/taskoutput.tsx","./src/components/mesh/tasktree.tsx","./src/components/mesh/unifiedmeshchatinput.tsx","./src/components/mesh/worktreefilespanel.tsx","./src/components/workflow/phasecolumn.tsx","./src/components/workflow/workflowboard.tsx","./src/components/workflow/workflowcontractcard.tsx","./src/contexts/authcontext.tsx","./src/contexts/supervisorquestionscontext.tsx","./src/hooks/usecontracts.ts","./src/hooks/usedirectives.ts","./src/hooks/usefilesubscription.ts","./src/hooks/usefiles.ts","./src/hooks/usemeshchathistory.ts","./src/hooks/usemicrophone.ts","./src/hooks/usespeakwebsocket.ts","./src/hooks/usetasksubscription.ts","./src/hooks/usetasks.ts","./src/hooks/usetextscramble.ts","./src/hooks/useversionhistory.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/listenapi.ts","./src/lib/markdown.ts","./src/lib/supabase.ts","./src/routes/_index.tsx","./src/routes/contract-file.tsx","./src/routes/contracts.tsx","./src/routes/directives.tsx","./src/routes/files.tsx","./src/routes/history.tsx","./src/routes/listen.tsx","./src/routes/login.tsx","./src/routes/mesh.tsx","./src/routes/settings.tsx","./src/routes/speak.tsx","./src/routes/workflow.tsx","./src/types/messages.ts"],"version":"5.9.3"}
\ No newline at end of file diff --git a/makima/migrations/20260210000000_create_directive_system.sql b/makima/migrations/20260210000000_create_directive_system.sql new file mode 100644 index 0000000..a2cd784 --- /dev/null +++ b/makima/migrations/20260210000000_create_directive_system.sql @@ -0,0 +1,50 @@ +-- Directive system v3: long-lived DAG-based project management +-- Directives are ongoing top-level entities (alternative to contracts) for managing +-- large, whole-repository projects via a DAG of auto-progressing tasks. + +CREATE TABLE directives ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id UUID NOT NULL REFERENCES owners(id) ON DELETE CASCADE, + title VARCHAR(500) NOT NULL, + goal TEXT NOT NULL, + -- Status: draft -> active <-> idle -> archived + -- draft = being planned, DAG not yet started + -- active = executing steps, orchestrator running + -- idle = all current steps done, waiting for new work + -- paused = user-paused execution + -- archived = project finished, no longer active + status VARCHAR(32) NOT NULL DEFAULT 'draft' + CHECK (status IN ('draft', 'active', 'idle', 'paused', 'archived')), + repository_url VARCHAR(512), + local_path VARCHAR(512), + base_branch VARCHAR(255), + orchestrator_task_id UUID REFERENCES tasks(id) ON DELETE SET NULL, + -- Tracks when the goal/requirements were last changed (orchestrator watches this) + goal_updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + started_at TIMESTAMPTZ, + version INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE directive_steps ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + directive_id UUID NOT NULL REFERENCES directives(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + description TEXT, + task_plan TEXT, + depends_on UUID[] NOT NULL DEFAULT '{}', + status VARCHAR(32) NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'ready', 'running', 'completed', 'failed', 'skipped')), + task_id UUID REFERENCES tasks(id) ON DELETE SET NULL, + order_index INTEGER NOT NULL DEFAULT 0, + -- Which "generation" of planning created this step (for tracking re-plans) + generation INTEGER NOT NULL DEFAULT 1, + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Link tasks back to directives +ALTER TABLE tasks ADD COLUMN directive_id UUID REFERENCES directives(id) ON DELETE SET NULL; +ALTER TABLE tasks ADD COLUMN directive_step_id UUID REFERENCES directive_steps(id) ON DELETE SET NULL; diff --git a/makima/src/bin/makima.rs b/makima/src/bin/makima.rs index ee5895c..639c88b 100644 --- a/makima/src/bin/makima.rs +++ b/makima/src/bin/makima.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use makima::daemon::api::{ApiClient, CreateContractRequest}; use makima::daemon::cli::{ Cli, CliConfig, Commands, ConfigCommand, ContractCommand, - SupervisorCommand, ViewArgs, + DirectiveCommand, SupervisorCommand, ViewArgs, }; use makima::daemon::tui::{self, Action, App, ListItem, ViewType, TuiWsClient, WsEvent, OutputLine, OutputMessageType, WsConnectionState, RepositorySuggestion}; use makima::daemon::config::{DaemonConfig, RepoEntry}; @@ -29,6 +29,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { Commands::Daemon(args) => run_daemon(args).await, Commands::Supervisor(cmd) => run_supervisor(cmd).await, Commands::Contract(cmd) => run_contract(cmd).await, + Commands::Directive(cmd) => run_directive(cmd).await, Commands::View(args) => run_view(args).await, Commands::Config(cmd) => run_config(cmd).await, } @@ -711,6 +712,108 @@ async fn run_contract( Ok(()) } +/// Run directive commands. +async fn run_directive( + cmd: DirectiveCommand, +) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { + use makima::daemon::api::directive::*; + + match cmd { + DirectiveCommand::List(args) => { + let client = ApiClient::new(args.api_url, args.api_key)?; + let result = client.list_directives().await?; + println!("{}", serde_json::to_string(&result.0)?); + } + DirectiveCommand::Get(args) | DirectiveCommand::Status(args) => { + let client = ApiClient::new(args.api_url, args.api_key)?; + let result = client.get_directive(args.directive_id).await?; + println!("{}", serde_json::to_string(&result.0)?); + } + DirectiveCommand::AddStep(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + let depends_on: Vec<uuid::Uuid> = args + .depends_on + .map(|d| { + d.split(',') + .filter_map(|s| uuid::Uuid::parse_str(s.trim()).ok()) + .collect() + }) + .unwrap_or_default(); + let req = CreateStepRequest { + name: args.name, + description: args.description, + task_plan: args.task_plan, + depends_on, + order_index: args.order_index, + }; + let result = client.directive_add_step(args.common.directive_id, req).await?; + println!("{}", serde_json::to_string(&result.0)?); + } + DirectiveCommand::RemoveStep(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + client.directive_remove_step(args.common.directive_id, args.step_id).await?; + println!(r#"{{"success": true}}"#); + } + DirectiveCommand::SetDeps(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + let depends_on: Vec<uuid::Uuid> = args + .depends_on + .split(',') + .filter_map(|s| uuid::Uuid::parse_str(s.trim()).ok()) + .collect(); + let result = client + .directive_set_deps(args.common.directive_id, args.step_id, depends_on) + .await?; + println!("{}", serde_json::to_string(&result.0)?); + } + DirectiveCommand::Start(args) => { + let client = ApiClient::new(args.api_url, args.api_key)?; + let result = client.directive_start(args.directive_id).await?; + println!("{}", serde_json::to_string(&result.0)?); + } + DirectiveCommand::Pause(args) => { + let client = ApiClient::new(args.api_url, args.api_key)?; + let result = client.directive_pause(args.directive_id).await?; + println!("{}", serde_json::to_string(&result.0)?); + } + DirectiveCommand::Advance(args) => { + let client = ApiClient::new(args.api_url, args.api_key)?; + let result = client.directive_advance(args.directive_id).await?; + println!("{}", serde_json::to_string(&result.0)?); + } + DirectiveCommand::CompleteStep(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + let result = client + .directive_complete_step(args.common.directive_id, args.step_id) + .await?; + println!("{}", serde_json::to_string(&result.0)?); + } + DirectiveCommand::FailStep(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + let result = client + .directive_fail_step(args.common.directive_id, args.step_id) + .await?; + println!("{}", serde_json::to_string(&result.0)?); + } + DirectiveCommand::SkipStep(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + let result = client + .directive_skip_step(args.common.directive_id, args.step_id) + .await?; + println!("{}", serde_json::to_string(&result.0)?); + } + DirectiveCommand::UpdateGoal(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + let result = client + .directive_update_goal(args.common.directive_id, &args.goal) + .await?; + println!("{}", serde_json::to_string(&result.0)?); + } + } + + Ok(()) +} + /// Run the TUI view command. async fn run_view(args: ViewArgs) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { // Load CLI config for defaults diff --git a/makima/src/daemon/api/directive.rs b/makima/src/daemon/api/directive.rs new file mode 100644 index 0000000..fbd27fe --- /dev/null +++ b/makima/src/daemon/api/directive.rs @@ -0,0 +1,124 @@ +//! Directive API methods. + +use serde::Serialize; +use uuid::Uuid; + +use super::client::{ApiClient, ApiError}; +use super::supervisor::JsonValue; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateStepRequest { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub task_plan: Option<String>, + pub depends_on: Vec<Uuid>, + pub order_index: i32, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateGoalRequest { + pub goal: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateStepDepsRequest { + pub depends_on: Vec<Uuid>, +} + +impl ApiClient { + /// List all directives. + pub async fn list_directives(&self) -> Result<JsonValue, ApiError> { + self.get("/api/v1/directives").await + } + + /// Get a directive with its steps. + pub async fn get_directive(&self, directive_id: Uuid) -> Result<JsonValue, ApiError> { + self.get(&format!("/api/v1/directives/{}", directive_id)).await + } + + /// Add a step to a directive. + pub async fn directive_add_step( + &self, + directive_id: Uuid, + req: CreateStepRequest, + ) -> Result<JsonValue, ApiError> { + self.post(&format!("/api/v1/directives/{}/steps", directive_id), &req).await + } + + /// Remove a step from a directive. + pub async fn directive_remove_step( + &self, + directive_id: Uuid, + step_id: Uuid, + ) -> Result<(), ApiError> { + self.delete(&format!("/api/v1/directives/{}/steps/{}", directive_id, step_id)).await + } + + /// Set dependencies for a step. + pub async fn directive_set_deps( + &self, + directive_id: Uuid, + step_id: Uuid, + depends_on: Vec<Uuid>, + ) -> Result<JsonValue, ApiError> { + let req = UpdateStepDepsRequest { depends_on }; + self.put(&format!("/api/v1/directives/{}/steps/{}", directive_id, step_id), &req).await + } + + /// Start a directive. + pub async fn directive_start(&self, directive_id: Uuid) -> Result<JsonValue, ApiError> { + self.post_empty(&format!("/api/v1/directives/{}/start", directive_id)).await + } + + /// Pause a directive. + pub async fn directive_pause(&self, directive_id: Uuid) -> Result<JsonValue, ApiError> { + self.post_empty(&format!("/api/v1/directives/{}/pause", directive_id)).await + } + + /// Advance the directive DAG. + pub async fn directive_advance(&self, directive_id: Uuid) -> Result<JsonValue, ApiError> { + self.post_empty(&format!("/api/v1/directives/{}/advance", directive_id)).await + } + + /// Mark a step as completed. + pub async fn directive_complete_step( + &self, + directive_id: Uuid, + step_id: Uuid, + ) -> Result<JsonValue, ApiError> { + self.post_empty(&format!("/api/v1/directives/{}/steps/{}/complete", directive_id, step_id)).await + } + + /// Mark a step as failed. + pub async fn directive_fail_step( + &self, + directive_id: Uuid, + step_id: Uuid, + ) -> Result<JsonValue, ApiError> { + self.post_empty(&format!("/api/v1/directives/{}/steps/{}/fail", directive_id, step_id)).await + } + + /// Mark a step as skipped. + pub async fn directive_skip_step( + &self, + directive_id: Uuid, + step_id: Uuid, + ) -> Result<JsonValue, ApiError> { + self.post_empty(&format!("/api/v1/directives/{}/steps/{}/skip", directive_id, step_id)).await + } + + /// Update the directive's goal. + pub async fn directive_update_goal( + &self, + directive_id: Uuid, + goal: &str, + ) -> Result<JsonValue, ApiError> { + let req = UpdateGoalRequest { goal: goal.to_string() }; + self.put(&format!("/api/v1/directives/{}/goal", directive_id), &req).await + } +} diff --git a/makima/src/daemon/api/mod.rs b/makima/src/daemon/api/mod.rs index 49d80e0..2d1efbf 100644 --- a/makima/src/daemon/api/mod.rs +++ b/makima/src/daemon/api/mod.rs @@ -2,6 +2,7 @@ pub mod client; pub mod contract; +pub mod directive; pub mod supervisor; pub use client::ApiClient; diff --git a/makima/src/daemon/cli/directive.rs b/makima/src/daemon/cli/directive.rs new file mode 100644 index 0000000..5de60ed --- /dev/null +++ b/makima/src/daemon/cli/directive.rs @@ -0,0 +1,101 @@ +//! Directive subcommand - directive management commands for orchestrator tasks. + +use clap::Args; +use uuid::Uuid; + +/// Common arguments for directive commands. +#[derive(Args, Debug, Clone)] +pub struct DirectiveArgs { + /// API URL + #[arg(long, env = "MAKIMA_API_URL", default_value = "https://api.makima.jp", global = true)] + pub api_url: String, + + /// API key for authentication + #[arg(long, env = "MAKIMA_API_KEY", global = true)] + pub api_key: String, + + /// Directive ID + #[arg(long, env = "MAKIMA_DIRECTIVE_ID", global = true)] + pub directive_id: Uuid, +} + +/// Arguments for listing directives (no directive_id required). +#[derive(Args, Debug, Clone)] +pub struct DirectiveListArgs { + /// API URL + #[arg(long, env = "MAKIMA_API_URL", default_value = "https://api.makima.jp", global = true)] + pub api_url: String, + + /// API key for authentication + #[arg(long, env = "MAKIMA_API_KEY", global = true)] + pub api_key: String, +} + +/// Arguments for add-step command. +#[derive(Args, Debug)] +pub struct AddStepArgs { + #[command(flatten)] + pub common: DirectiveArgs, + + /// Step name + pub name: String, + + /// Step description + #[arg(long)] + pub description: Option<String>, + + /// Task plan for the step + #[arg(long)] + pub task_plan: Option<String>, + + /// Comma-separated UUIDs of dependency steps + #[arg(long)] + pub depends_on: Option<String>, + + /// Order index + #[arg(long, default_value = "0")] + pub order_index: i32, +} + +/// Arguments for remove-step command. +#[derive(Args, Debug)] +pub struct RemoveStepArgs { + #[command(flatten)] + pub common: DirectiveArgs, + + /// Step ID to remove + pub step_id: Uuid, +} + +/// Arguments for set-deps command. +#[derive(Args, Debug)] +pub struct SetDepsArgs { + #[command(flatten)] + pub common: DirectiveArgs, + + /// Step ID to update + pub step_id: Uuid, + + /// Comma-separated UUIDs of dependency steps + pub depends_on: String, +} + +/// Arguments for complete-step/fail-step/skip-step commands. +#[derive(Args, Debug)] +pub struct StepActionArgs { + #[command(flatten)] + pub common: DirectiveArgs, + + /// Step ID + pub step_id: Uuid, +} + +/// Arguments for update-goal command. +#[derive(Args, Debug)] +pub struct UpdateGoalArgs { + #[command(flatten)] + pub common: DirectiveArgs, + + /// New goal text + pub goal: String, +} diff --git a/makima/src/daemon/cli/mod.rs b/makima/src/daemon/cli/mod.rs index 0805edd..faafaea 100644 --- a/makima/src/daemon/cli/mod.rs +++ b/makima/src/daemon/cli/mod.rs @@ -3,6 +3,7 @@ pub mod config; pub mod contract; pub mod daemon; +pub mod directive; pub mod server; pub mod supervisor; pub mod view; @@ -12,6 +13,7 @@ use clap::{Parser, Subcommand}; pub use config::CliConfig; pub use contract::ContractArgs; pub use daemon::DaemonArgs; +pub use directive::DirectiveArgs; pub use server::ServerArgs; pub use supervisor::SupervisorArgs; pub use view::ViewArgs; @@ -41,6 +43,10 @@ pub enum Commands { #[command(subcommand)] Contract(ContractCommand), + /// Directive commands for DAG-based project management + #[command(subcommand)] + Directive(DirectiveCommand), + /// Interactive TUI browser for contracts and tasks /// /// Provides a drill-down interface for browsing contracts, viewing their @@ -196,6 +202,49 @@ pub enum ContractCommand { CreateFile(contract::CreateFileArgs), } +/// Directive subcommands for DAG-based project management. +#[derive(Subcommand, Debug)] +pub enum DirectiveCommand { + /// List all directives + List(directive::DirectiveListArgs), + + /// Get directive status with steps + Get(DirectiveArgs), + + /// Get directive status (alias for get) + Status(DirectiveArgs), + + /// Add a step to the directive + AddStep(directive::AddStepArgs), + + /// Remove a step from the directive + RemoveStep(directive::RemoveStepArgs), + + /// Set dependencies for a step + SetDeps(directive::SetDepsArgs), + + /// Start the directive (begin executing steps) + Start(DirectiveArgs), + + /// Pause the directive + Pause(DirectiveArgs), + + /// Advance the DAG (find newly-ready steps) + Advance(DirectiveArgs), + + /// Mark a step as completed + CompleteStep(directive::StepActionArgs), + + /// Mark a step as failed + FailStep(directive::StepActionArgs), + + /// Mark a step as skipped + SkipStep(directive::StepActionArgs), + + /// Update the directive's goal (triggers re-planning) + UpdateGoal(directive::UpdateGoalArgs), +} + impl Cli { /// Parse command-line arguments pub fn parse_args() -> Self { diff --git a/makima/src/daemon/skills/directive.md b/makima/src/daemon/skills/directive.md new file mode 100644 index 0000000..7c55cf8 --- /dev/null +++ b/makima/src/daemon/skills/directive.md @@ -0,0 +1,111 @@ +--- +name: makima-directive +description: Directive commands for makima DAG-based project orchestration. Use these commands to manage long-lived directives with auto-progressing steps. +--- + +# Makima Directive Skill + +You are orchestrating a **directive** — a long-lived project managed through a DAG (directed acyclic graph) of steps. Unlike contracts which are finite and phase-based, directives are **ongoing and continuous**: they stay active as the project evolves, and new features/requirements can be added at any time. + +## Key Concepts + +- **Directive**: A long-lived top-level entity with a goal, repository info, and a mutable DAG of steps +- **Steps**: Nodes in the DAG. Each step can spawn a task using the mesh infrastructure +- **Auto-progression**: When a step completes, newly-ready steps (whose dependencies are met) automatically become ready +- **Continuous evolution**: The goal can be updated at any time. When all steps complete, the directive goes `idle` (not completed) — waiting for new work +- **Statuses**: `draft` → `active` ↔ `idle` → `archived`. Directives are never "completed" — they go idle and wait + +## Commands + +### Check Status +```bash +makima directive status +``` +Returns the directive with all steps, their statuses, and dependency information. + +### Add a Step +```bash +makima directive add-step "Step Name" --description "What this step does" --task-plan "Detailed instructions for the task" --depends-on "uuid1,uuid2" --order-index 1 +``` + +### Remove a Step +```bash +makima directive remove-step <step_id> +``` + +### Set Dependencies +```bash +makima directive set-deps <step_id> "dep_uuid1,dep_uuid2" +``` + +### Start the Directive +```bash +makima directive start +``` +Sets status to `active` and advances any steps with no dependencies to `ready`. + +### Advance the DAG +```bash +makima directive advance +``` +Finds newly-ready steps (all dependencies met) and marks them ready. If all steps are in terminal states, sets the directive to `idle`. + +### Complete a Step +```bash +makima directive complete-step <step_id> +``` + +### Fail a Step +```bash +makima directive fail-step <step_id> +``` + +### Skip a Step +```bash +makima directive skip-step <step_id> +``` + +### Update the Goal +```bash +makima directive update-goal "New or expanded goal text" +``` +Updates the goal and bumps `goalUpdatedAt`. If the directive is `idle`, it reactivates to `active`. + +### Pause +```bash +makima directive pause +``` + +## Orchestration Workflow + +### Initial Setup +1. Check the directive status to understand the goal +2. Decompose the goal into steps with clear dependencies +3. Add steps using `add-step` with appropriate `--depends-on` flags +4. Start the directive with `start` +5. Steps with no dependencies will become `ready` immediately + +### Monitoring and Advancing +1. Periodically check status to see step progress +2. When tasks complete, the DAG auto-advances — newly-ready steps appear +3. Use `advance` to manually trigger DAG progression if needed +4. Mark steps as complete/failed/skipped as appropriate + +### Re-planning (When Goal Updates) +When the goal is updated (you'll see a new `goalUpdatedAt` timestamp): +1. Check the current status to see completed and in-progress steps +2. Identify what's new in the updated goal +3. Add new steps that depend on existing completed steps as appropriate +4. The DAG will auto-advance any newly-ready steps + +### Idle State +When all steps complete, the directive enters `idle` state. This is normal — it means: +- All current work is done +- The directive is waiting for new requirements +- When the user updates the goal, it reactivates automatically +- You should add new steps based on the updated goal + +## Environment Variables +- `MAKIMA_API_URL` - API server URL +- `MAKIMA_API_KEY` - Authentication key +- `MAKIMA_DIRECTIVE_ID` - Current directive ID (set automatically) diff --git a/makima/src/daemon/skills/mod.rs b/makima/src/daemon/skills/mod.rs index 0b05f3a..0c015ba 100644 --- a/makima/src/daemon/skills/mod.rs +++ b/makima/src/daemon/skills/mod.rs @@ -9,8 +9,12 @@ pub const SUPERVISOR_SKILL: &str = include_str!("supervisor.md"); /// Contract skill content - task-contract interaction commands pub const CONTRACT_SKILL: &str = include_str!("contract.md"); +/// Directive skill content - DAG-based project orchestration commands +pub const DIRECTIVE_SKILL: &str = include_str!("directive.md"); + /// All skills as (name, content) pairs for installation pub const ALL_SKILLS: &[(&str, &str)] = &[ ("makima-supervisor", SUPERVISOR_SKILL), ("makima-contract", CONTRACT_SKILL), + ("makima-directive", DIRECTIVE_SKILL), ]; diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index d0a0bd6..9159fd5 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -531,6 +531,14 @@ pub struct Task { /// Standalone completed tasks can be dismissed by the user. #[serde(default)] pub hidden: bool, + + // Directive association + /// Directive this task belongs to (for directive-driven tasks) + #[serde(skip_serializing_if = "Option::is_none")] + pub directive_id: Option<Uuid>, + /// Directive step this task executes + #[serde(skip_serializing_if = "Option::is_none")] + pub directive_step_id: Option<Uuid>, } impl Task { @@ -656,6 +664,10 @@ pub struct CreateTaskRequest { /// Task ID whose worktree this task shares. When set, this task reuses the supervisor's /// worktree instead of creating its own, and should NOT have its worktree deleted during cleanup. pub supervisor_worktree_task_id: Option<Uuid>, + /// Directive this task belongs to (for directive-driven tasks) + pub directive_id: Option<Uuid>, + /// Directive step this task executes + pub directive_step_id: Option<Uuid>, } /// Request payload for updating a task @@ -2682,3 +2694,142 @@ mod tests { } // ============================================================================= +// Directive Types +// ============================================================================= + +/// A directive — a long-lived top-level entity for managing projects via a DAG of steps. +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Directive { + pub id: Uuid, + pub owner_id: Uuid, + pub title: String, + pub goal: String, + /// Status: draft, active, idle, paused, archived + pub status: String, + pub repository_url: Option<String>, + pub local_path: Option<String>, + pub base_branch: Option<String>, + pub orchestrator_task_id: Option<Uuid>, + pub goal_updated_at: DateTime<Utc>, + pub started_at: Option<DateTime<Utc>>, + pub version: i32, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, +} + +/// A step in a directive's DAG. +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DirectiveStep { + pub id: Uuid, + pub directive_id: Uuid, + pub name: String, + pub description: Option<String>, + pub task_plan: Option<String>, + pub depends_on: Vec<Uuid>, + /// Status: pending, ready, running, completed, failed, skipped + pub status: String, + pub task_id: Option<Uuid>, + pub order_index: i32, + pub generation: i32, + pub started_at: Option<DateTime<Utc>>, + pub completed_at: Option<DateTime<Utc>>, + pub created_at: DateTime<Utc>, +} + +/// Directive with its steps for detail view. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DirectiveWithSteps { + #[serde(flatten)] + pub directive: Directive, + pub steps: Vec<DirectiveStep>, +} + +/// Summary for directive list views. +#[derive(Debug, Clone, FromRow, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DirectiveSummary { + pub id: Uuid, + pub owner_id: Uuid, + pub title: String, + pub goal: String, + pub status: String, + pub repository_url: Option<String>, + pub orchestrator_task_id: Option<Uuid>, + pub version: i32, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, + pub total_steps: i64, + pub completed_steps: i64, + pub running_steps: i64, + pub failed_steps: i64, +} + +/// List response for directives. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DirectiveListResponse { + pub directives: Vec<DirectiveSummary>, + pub total: i64, +} + +/// Request to create a new directive. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateDirectiveRequest { + pub title: String, + pub goal: String, + pub repository_url: Option<String>, + pub local_path: Option<String>, + pub base_branch: Option<String>, +} + +/// Request to update a directive. +#[derive(Debug, Default, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateDirectiveRequest { + pub title: Option<String>, + pub goal: Option<String>, + pub status: Option<String>, + pub repository_url: Option<String>, + pub local_path: Option<String>, + pub base_branch: Option<String>, + pub orchestrator_task_id: Option<Uuid>, + pub version: Option<i32>, +} + +/// Request to update a directive's goal (triggers re-planning). +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateGoalRequest { + pub goal: String, +} + +/// Request to create a directive step. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateDirectiveStepRequest { + pub name: String, + pub description: Option<String>, + pub task_plan: Option<String>, + #[serde(default)] + pub depends_on: Vec<Uuid>, + #[serde(default)] + pub order_index: i32, + pub generation: Option<i32>, +} + +/// Request to update a directive step. +#[derive(Debug, Default, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateDirectiveStepRequest { + pub name: Option<String>, + pub description: Option<String>, + pub task_plan: Option<String>, + pub depends_on: Option<Vec<Uuid>>, + pub status: Option<String>, + pub task_id: Option<Uuid>, + pub order_index: Option<i32>, +} diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index 4ed2298..f347fc7 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -11,7 +11,9 @@ use super::models::{ ContractTypeTemplateRecord, ConversationMessage, ConversationSnapshot, CreateContractRequest, CreateFileRequest, CreateTaskRequest, CreateTemplateRequest, Daemon, DaemonTaskAssignment, DaemonWithCapacity, - DeliverableDefinition, + DeliverableDefinition, Directive, DirectiveStep, DirectiveSummary, + CreateDirectiveRequest, CreateDirectiveStepRequest, UpdateDirectiveRequest, + UpdateDirectiveStepRequest, File, FileSummary, FileVersion, HistoryEvent, HistoryQueryFilters, MeshChatConversation, MeshChatMessageRecord, PhaseChangeResult, PhaseConfig, PhaseDefinition, SupervisorHeartbeatRecord, SupervisorState, @@ -4912,3 +4914,414 @@ fn truncate_string(s: &str, max_len: usize) -> String { } } +// ============================================================================= +// Directive CRUD +// ============================================================================= + +/// Create a new directive for an owner. +pub async fn create_directive_for_owner( + pool: &PgPool, + owner_id: Uuid, + req: CreateDirectiveRequest, +) -> Result<Directive, sqlx::Error> { + sqlx::query_as::<_, Directive>( + r#" + INSERT INTO directives (owner_id, title, goal, repository_url, local_path, base_branch) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING * + "#, + ) + .bind(owner_id) + .bind(&req.title) + .bind(&req.goal) + .bind(&req.repository_url) + .bind(&req.local_path) + .bind(&req.base_branch) + .fetch_one(pool) + .await +} + +/// Get a single directive for an owner. +pub async fn get_directive_for_owner( + pool: &PgPool, + owner_id: Uuid, + id: Uuid, +) -> Result<Option<Directive>, sqlx::Error> { + sqlx::query_as::<_, Directive>( + r#"SELECT * FROM directives WHERE id = $1 AND owner_id = $2"#, + ) + .bind(id) + .bind(owner_id) + .fetch_optional(pool) + .await +} + +/// Get a directive with all its steps. +pub async fn get_directive_with_steps_for_owner( + pool: &PgPool, + owner_id: Uuid, + id: Uuid, +) -> Result<Option<(Directive, Vec<DirectiveStep>)>, sqlx::Error> { + let directive = sqlx::query_as::<_, Directive>( + r#"SELECT * FROM directives WHERE id = $1 AND owner_id = $2"#, + ) + .bind(id) + .bind(owner_id) + .fetch_optional(pool) + .await?; + + match directive { + Some(d) => { + let steps = list_directive_steps(pool, d.id).await?; + Ok(Some((d, steps))) + } + None => Ok(None), + } +} + +/// List all directives for an owner with step counts. +pub async fn list_directives_for_owner( + pool: &PgPool, + owner_id: Uuid, +) -> Result<Vec<DirectiveSummary>, sqlx::Error> { + sqlx::query_as::<_, DirectiveSummary>( + r#" + SELECT + d.id, d.owner_id, d.title, d.goal, d.status, d.repository_url, + d.orchestrator_task_id, d.version, d.created_at, d.updated_at, + COALESCE((SELECT COUNT(*) FROM directive_steps WHERE directive_id = d.id), 0) as total_steps, + COALESCE((SELECT COUNT(*) FROM directive_steps WHERE directive_id = d.id AND status = 'completed'), 0) as completed_steps, + COALESCE((SELECT COUNT(*) FROM directive_steps WHERE directive_id = d.id AND status = 'running'), 0) as running_steps, + COALESCE((SELECT COUNT(*) FROM directive_steps WHERE directive_id = d.id AND status = 'failed'), 0) as failed_steps + FROM directives d + WHERE d.owner_id = $1 + ORDER BY d.created_at DESC + "#, + ) + .bind(owner_id) + .fetch_all(pool) + .await +} + +/// Update a directive with optimistic locking. +pub async fn update_directive_for_owner( + pool: &PgPool, + owner_id: Uuid, + id: Uuid, + req: UpdateDirectiveRequest, +) -> Result<Option<Directive>, RepositoryError> { + let current = sqlx::query_as::<_, Directive>( + r#"SELECT * FROM directives WHERE id = $1 AND owner_id = $2"#, + ) + .bind(id) + .bind(owner_id) + .fetch_optional(pool) + .await + .map_err(RepositoryError::Database)?; + + let current = match current { + Some(c) => c, + None => return Ok(None), + }; + + if let Some(expected_version) = req.version { + if expected_version != current.version { + return Err(RepositoryError::VersionConflict { + expected: expected_version, + actual: current.version, + }); + } + } + + let title = req.title.as_deref().unwrap_or(¤t.title); + let goal = req.goal.as_deref().unwrap_or(¤t.goal); + let status = req.status.as_deref().unwrap_or(¤t.status); + let repository_url = req.repository_url.as_deref().or(current.repository_url.as_deref()); + let local_path = req.local_path.as_deref().or(current.local_path.as_deref()); + let base_branch = req.base_branch.as_deref().or(current.base_branch.as_deref()); + let orchestrator_task_id = req.orchestrator_task_id.or(current.orchestrator_task_id); + + let result = sqlx::query_as::<_, Directive>( + r#" + UPDATE directives + SET title = $3, goal = $4, status = $5, repository_url = $6, local_path = $7, + base_branch = $8, orchestrator_task_id = $9, version = version + 1, updated_at = NOW() + WHERE id = $1 AND owner_id = $2 + RETURNING * + "#, + ) + .bind(id) + .bind(owner_id) + .bind(title) + .bind(goal) + .bind(status) + .bind(repository_url) + .bind(local_path) + .bind(base_branch) + .bind(orchestrator_task_id) + .fetch_optional(pool) + .await + .map_err(RepositoryError::Database)?; + + Ok(result) +} + +/// Delete a directive for an owner. +pub async fn delete_directive_for_owner( + pool: &PgPool, + owner_id: Uuid, + id: Uuid, +) -> Result<bool, sqlx::Error> { + let result = sqlx::query( + r#"DELETE FROM directives WHERE id = $1 AND owner_id = $2"#, + ) + .bind(id) + .bind(owner_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +// ============================================================================= +// Directive Step CRUD +// ============================================================================= + +/// List all steps for a directive, ordered by order_index. +pub async fn list_directive_steps( + pool: &PgPool, + directive_id: Uuid, +) -> Result<Vec<DirectiveStep>, sqlx::Error> { + sqlx::query_as::<_, DirectiveStep>( + r#" + SELECT * FROM directive_steps + WHERE directive_id = $1 + ORDER BY order_index, created_at + "#, + ) + .bind(directive_id) + .fetch_all(pool) + .await +} + +/// Create a single directive step. +pub async fn create_directive_step( + pool: &PgPool, + directive_id: Uuid, + req: CreateDirectiveStepRequest, +) -> Result<DirectiveStep, sqlx::Error> { + let generation = req.generation.unwrap_or(1); + sqlx::query_as::<_, DirectiveStep>( + r#" + INSERT INTO directive_steps (directive_id, name, description, task_plan, depends_on, order_index, generation) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING * + "#, + ) + .bind(directive_id) + .bind(&req.name) + .bind(&req.description) + .bind(&req.task_plan) + .bind(&req.depends_on) + .bind(req.order_index) + .bind(generation) + .fetch_one(pool) + .await +} + +/// Batch create multiple directive steps. +pub async fn batch_create_directive_steps( + pool: &PgPool, + directive_id: Uuid, + steps: Vec<CreateDirectiveStepRequest>, +) -> Result<Vec<DirectiveStep>, sqlx::Error> { + let mut results = Vec::with_capacity(steps.len()); + for req in steps { + let step = create_directive_step(pool, directive_id, req).await?; + results.push(step); + } + Ok(results) +} + +/// Update a directive step. +pub async fn update_directive_step( + pool: &PgPool, + step_id: Uuid, + req: UpdateDirectiveStepRequest, +) -> Result<Option<DirectiveStep>, sqlx::Error> { + let current = sqlx::query_as::<_, DirectiveStep>( + r#"SELECT * FROM directive_steps WHERE id = $1"#, + ) + .bind(step_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 task_plan = req.task_plan.as_deref().or(current.task_plan.as_deref()); + let depends_on = req.depends_on.as_deref().unwrap_or(¤t.depends_on); + let status = req.status.as_deref().unwrap_or(¤t.status); + let task_id = req.task_id.or(current.task_id); + let order_index = req.order_index.unwrap_or(current.order_index); + + // Set started_at when transitioning to running + let started_at = if status == "running" && current.status != "running" { + Some(Utc::now()) + } else { + current.started_at + }; + + // Set completed_at when transitioning to terminal state + let completed_at = if matches!(status, "completed" | "failed" | "skipped") + && !matches!(current.status.as_str(), "completed" | "failed" | "skipped") + { + Some(Utc::now()) + } else { + current.completed_at + }; + + sqlx::query_as::<_, DirectiveStep>( + r#" + UPDATE directive_steps + SET name = $2, description = $3, task_plan = $4, depends_on = $5, + status = $6, task_id = $7, order_index = $8, started_at = $9, completed_at = $10 + WHERE id = $1 + RETURNING * + "#, + ) + .bind(step_id) + .bind(name) + .bind(description) + .bind(task_plan) + .bind(depends_on) + .bind(status) + .bind(task_id) + .bind(order_index) + .bind(started_at) + .bind(completed_at) + .fetch_optional(pool) + .await +} + +/// Delete a directive step. +pub async fn delete_directive_step( + pool: &PgPool, + step_id: Uuid, +) -> Result<bool, sqlx::Error> { + let result = sqlx::query(r#"DELETE FROM directive_steps WHERE id = $1"#) + .bind(step_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +// ============================================================================= +// Directive DAG Progression +// ============================================================================= + +/// Advance pending steps to ready if all their dependencies are in terminal states. +/// Returns the newly-ready steps. +pub async fn advance_directive_ready_steps( + pool: &PgPool, + directive_id: Uuid, +) -> Result<Vec<DirectiveStep>, sqlx::Error> { + sqlx::query_as::<_, DirectiveStep>( + r#" + UPDATE directive_steps SET status = 'ready' + WHERE directive_id = $1 AND status = 'pending' + AND NOT EXISTS ( + SELECT 1 FROM unnest(depends_on) AS dep_id + JOIN directive_steps ds ON ds.id = dep_id + WHERE ds.status NOT IN ('completed', 'skipped') + ) + RETURNING * + "#, + ) + .bind(directive_id) + .fetch_all(pool) + .await +} + +/// Check if all steps in a directive are in terminal states. +/// If so, set the directive to 'idle' (not completed — directives are ongoing). +/// Returns true if the directive was set to idle. +pub async fn check_directive_idle( + pool: &PgPool, + directive_id: Uuid, +) -> Result<bool, sqlx::Error> { + let result = sqlx::query( + r#" + UPDATE directives SET status = 'idle', updated_at = NOW() + WHERE id = $1 AND status = 'active' + AND NOT EXISTS ( + SELECT 1 FROM directive_steps + WHERE directive_id = $1 + AND status NOT IN ('completed', 'failed', 'skipped') + ) + AND EXISTS ( + SELECT 1 FROM directive_steps WHERE directive_id = $1 + ) + "#, + ) + .bind(directive_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// Update a directive's goal and bump goal_updated_at. Reactivates if idle. +pub async fn update_directive_goal( + pool: &PgPool, + owner_id: Uuid, + directive_id: Uuid, + goal: &str, +) -> Result<Option<Directive>, sqlx::Error> { + sqlx::query_as::<_, Directive>( + r#" + UPDATE directives + SET goal = $3, + goal_updated_at = NOW(), + status = CASE WHEN status = 'idle' THEN 'active' ELSE status END, + updated_at = NOW(), + version = version + 1 + WHERE id = $1 AND owner_id = $2 + RETURNING * + "#, + ) + .bind(directive_id) + .bind(owner_id) + .bind(goal) + .fetch_optional(pool) + .await +} + +/// Set a directive's status (used for start/pause/archive transitions). +pub async fn set_directive_status( + pool: &PgPool, + owner_id: Uuid, + directive_id: Uuid, + status: &str, +) -> Result<Option<Directive>, sqlx::Error> { + let mut query = String::from( + r#"UPDATE directives SET status = $3, updated_at = NOW(), version = version + 1"#, + ); + if status == "active" { + query.push_str(", started_at = COALESCE(started_at, NOW())"); + } + query.push_str(" WHERE id = $1 AND owner_id = $2 RETURNING *"); + + sqlx::query_as::<_, Directive>(&query) + .bind(directive_id) + .bind(owner_id) + .bind(status) + .fetch_optional(pool) + .await +} + diff --git a/makima/src/llm/contract_tools.rs b/makima/src/llm/contract_tools.rs index 7f7e849..38d1a7e 100644 --- a/makima/src/llm/contract_tools.rs +++ b/makima/src/llm/contract_tools.rs @@ -460,214 +460,6 @@ pub static CONTRACT_TOOLS: once_cell::sync::Lazy<Vec<Tool>> = once_cell::sync::L "required": ["file_id"] }), }, - // ============================================================================= - // Chain Directive Tools (for directive contracts orchestrating chains) - // ============================================================================= - Tool { - name: "create_chain_from_directive".to_string(), - description: "Create a new chain that this directive contract will orchestrate. The chain starts in 'pending' status and contract definitions can be added. Only available to directive contracts.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name for the chain" - }, - "description": { - "type": "string", - "description": "Description of what the chain accomplishes" - } - }, - "required": ["name"] - }), - }, - Tool { - name: "add_chain_contract".to_string(), - description: "Add a contract definition to the chain being orchestrated. The contract will be created when its dependencies are met.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Contract name" - }, - "description": { - "type": "string", - "description": "What this contract accomplishes" - }, - "contract_type": { - "type": "string", - "enum": ["simple", "execute", "checkpoint"], - "description": "Contract type (default: simple)" - }, - "depends_on": { - "type": "array", - "items": { "type": "string" }, - "description": "Names of contracts this depends on" - }, - "requirement_ids": { - "type": "array", - "items": { "type": "string" }, - "description": "Requirement IDs this contract addresses (for traceability)" - } - }, - "required": ["name"] - }), - }, - Tool { - name: "set_chain_dependencies".to_string(), - description: "Set which contracts depend on which other contracts in the chain.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "contract_name": { - "type": "string", - "description": "Name of contract that has dependencies" - }, - "depends_on": { - "type": "array", - "items": { "type": "string" }, - "description": "Names of contracts it depends on" - } - }, - "required": ["contract_name", "depends_on"] - }), - }, - Tool { - name: "modify_chain_contract".to_string(), - description: "Update a contract definition in the chain.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name of the contract to modify" - }, - "new_name": { - "type": "string", - "description": "New name for the contract" - }, - "description": { - "type": "string", - "description": "New description" - }, - "add_requirement_ids": { - "type": "array", - "items": { "type": "string" }, - "description": "Requirement IDs to add" - }, - "remove_requirement_ids": { - "type": "array", - "items": { "type": "string" }, - "description": "Requirement IDs to remove" - } - }, - "required": ["name"] - }), - }, - Tool { - name: "remove_chain_contract".to_string(), - description: "Remove a contract definition from the chain (only if not yet instantiated).".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name of the contract to remove" - } - }, - "required": ["name"] - }), - }, - Tool { - name: "preview_chain_dag".to_string(), - description: "Generate a visual preview of the chain DAG structure for review.".to_string(), - parameters: json!({ - "type": "object", - "properties": {} - }), - }, - Tool { - name: "validate_chain_directive".to_string(), - description: "Validate the chain specification is complete and valid (no cycles, all dependencies exist, all requirements covered).".to_string(), - parameters: json!({ - "type": "object", - "properties": {} - }), - }, - Tool { - name: "finalize_chain_directive".to_string(), - description: "Lock the directive and start chain execution. Call this after validation passes and user has approved (if phase_guard enabled).".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "auto_start": { - "type": "boolean", - "description": "Whether to immediately start the chain (default: true)" - } - } - }), - }, - Tool { - name: "get_chain_status".to_string(), - description: "Get current status of the chain being orchestrated, including contract statuses and progress.".to_string(), - parameters: json!({ - "type": "object", - "properties": {} - }), - }, - Tool { - name: "get_uncovered_requirements".to_string(), - description: "List requirements from the directive that are not yet mapped to any contract.".to_string(), - parameters: json!({ - "type": "object", - "properties": {} - }), - }, - Tool { - name: "evaluate_contract_completion".to_string(), - description: "Evaluate whether a completed chain contract meets the directive requirements. Use this after a contract completes to assess if it satisfies acceptance criteria.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "contract_id": { - "type": "string", - "description": "ID of the completed contract to evaluate" - }, - "passed": { - "type": "boolean", - "description": "Whether the evaluation passed" - }, - "feedback": { - "type": "string", - "description": "Evaluation feedback and rationale" - }, - "rework_instructions": { - "type": "string", - "description": "Instructions for rework if evaluation failed" - } - }, - "required": ["contract_id", "passed", "feedback"] - }), - }, - Tool { - name: "request_rework".to_string(), - description: "Request rework on a completed contract that didn't meet requirements. This will block chain progression and notify the contract to address issues.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "contract_id": { - "type": "string", - "description": "ID of the contract needing rework" - }, - "feedback": { - "type": "string", - "description": "Detailed feedback on what needs to be fixed" - } - }, - "required": ["contract_id", "feedback"] - }), - }, ] }); @@ -755,49 +547,6 @@ pub enum ContractToolRequest { include_action_items: bool, }, - // Chain directive tools (for directive contracts) - CreateChainFromDirective { - name: String, - description: Option<String>, - }, - AddChainContract { - name: String, - description: Option<String>, - contract_type: Option<String>, - depends_on: Option<Vec<String>>, - requirement_ids: Option<Vec<String>>, - }, - SetChainDependencies { - contract_name: String, - depends_on: Vec<String>, - }, - ModifyChainContract { - name: String, - new_name: Option<String>, - description: Option<String>, - add_requirement_ids: Option<Vec<String>>, - remove_requirement_ids: Option<Vec<String>>, - }, - RemoveChainContract { - name: String, - }, - PreviewChainDag, - ValidateChainDirective, - FinalizeChainDirective { - auto_start: bool, - }, - GetChainStatus, - GetUncoveredRequirements, - EvaluateContractCompletion { - contract_id: Uuid, - passed: bool, - feedback: String, - rework_instructions: Option<String>, - }, - RequestRework { - contract_id: Uuid, - feedback: String, - }, } /// Task definition for chained task creation @@ -869,20 +618,6 @@ pub fn parse_contract_tool_call(call: &super::tools::ToolCall) -> ContractToolEx "analyze_transcript" => parse_analyze_transcript(call), "create_contract_from_transcript" => parse_create_contract_from_transcript(call), - // Chain directive tools - "create_chain_from_directive" => parse_create_chain_from_directive(call), - "add_chain_contract" => parse_add_chain_contract(call), - "set_chain_dependencies" => parse_set_chain_dependencies(call), - "modify_chain_contract" => parse_modify_chain_contract(call), - "remove_chain_contract" => parse_remove_chain_contract(call), - "preview_chain_dag" => parse_preview_chain_dag(), - "validate_chain_directive" => parse_validate_chain_directive(), - "finalize_chain_directive" => parse_finalize_chain_directive(call), - "get_chain_status" => parse_get_chain_status(), - "get_uncovered_requirements" => parse_get_uncovered_requirements(), - "evaluate_contract_completion" => parse_evaluate_contract_completion(call), - "request_rework" => parse_request_rework(call), - _ => ContractToolExecutionResult { success: false, message: format!("Unknown contract tool: {}", call.name), @@ -1472,229 +1207,6 @@ fn parse_create_contract_from_transcript(call: &super::tools::ToolCall) -> Contr } // ============================================================================= -// Chain Directive Tool Parsing -// ============================================================================= - -fn parse_create_chain_from_directive(call: &super::tools::ToolCall) -> ContractToolExecutionResult { - let name = call.arguments.get("name").and_then(|v| v.as_str()).map(|s| s.to_string()); - let Some(name) = name else { - return error_result("Missing required parameter: name"); - }; - let description = call.arguments.get("description").and_then(|v| v.as_str()).map(|s| s.to_string()); - - ContractToolExecutionResult { - success: true, - message: "Creating chain from directive...".to_string(), - data: None, - request: Some(ContractToolRequest::CreateChainFromDirective { name, description }), - pending_questions: None, - } -} - -fn parse_add_chain_contract(call: &super::tools::ToolCall) -> ContractToolExecutionResult { - let name = call.arguments.get("name").and_then(|v| v.as_str()).map(|s| s.to_string()); - let Some(name) = name else { - return error_result("Missing required parameter: name"); - }; - - let description = call.arguments.get("description").and_then(|v| v.as_str()).map(|s| s.to_string()); - let contract_type = call.arguments.get("contract_type").and_then(|v| v.as_str()).map(|s| s.to_string()); - let depends_on = call.arguments.get("depends_on").and_then(|v| { - v.as_array().map(|arr| { - arr.iter().filter_map(|item| item.as_str().map(|s| s.to_string())).collect() - }) - }); - let requirement_ids = call.arguments.get("requirement_ids").and_then(|v| { - v.as_array().map(|arr| { - arr.iter().filter_map(|item| item.as_str().map(|s| s.to_string())).collect() - }) - }); - - ContractToolExecutionResult { - success: true, - message: format!("Adding contract '{}' to chain...", name), - data: None, - request: Some(ContractToolRequest::AddChainContract { - name, - description, - contract_type, - depends_on, - requirement_ids, - }), - pending_questions: None, - } -} - -fn parse_set_chain_dependencies(call: &super::tools::ToolCall) -> ContractToolExecutionResult { - let contract_name = call.arguments.get("contract_name").and_then(|v| v.as_str()).map(|s| s.to_string()); - let Some(contract_name) = contract_name else { - return error_result("Missing required parameter: contract_name"); - }; - - let depends_on = call.arguments.get("depends_on").and_then(|v| { - v.as_array().map(|arr| { - arr.iter().filter_map(|item| item.as_str().map(|s| s.to_string())).collect() - }) - }).unwrap_or_default(); - - ContractToolExecutionResult { - success: true, - message: format!("Setting dependencies for '{}'...", contract_name), - data: None, - request: Some(ContractToolRequest::SetChainDependencies { contract_name, depends_on }), - pending_questions: None, - } -} - -fn parse_modify_chain_contract(call: &super::tools::ToolCall) -> ContractToolExecutionResult { - let name = call.arguments.get("name").and_then(|v| v.as_str()).map(|s| s.to_string()); - let Some(name) = name else { - return error_result("Missing required parameter: name"); - }; - - let new_name = call.arguments.get("new_name").and_then(|v| v.as_str()).map(|s| s.to_string()); - let description = call.arguments.get("description").and_then(|v| v.as_str()).map(|s| s.to_string()); - let add_requirement_ids = call.arguments.get("add_requirement_ids").and_then(|v| { - v.as_array().map(|arr| { - arr.iter().filter_map(|item| item.as_str().map(|s| s.to_string())).collect() - }) - }); - let remove_requirement_ids = call.arguments.get("remove_requirement_ids").and_then(|v| { - v.as_array().map(|arr| { - arr.iter().filter_map(|item| item.as_str().map(|s| s.to_string())).collect() - }) - }); - - ContractToolExecutionResult { - success: true, - message: format!("Modifying contract '{}'...", name), - data: None, - request: Some(ContractToolRequest::ModifyChainContract { - name, - new_name, - description, - add_requirement_ids, - remove_requirement_ids, - }), - pending_questions: None, - } -} - -fn parse_remove_chain_contract(call: &super::tools::ToolCall) -> ContractToolExecutionResult { - let name = call.arguments.get("name").and_then(|v| v.as_str()).map(|s| s.to_string()); - let Some(name) = name else { - return error_result("Missing required parameter: name"); - }; - - ContractToolExecutionResult { - success: true, - message: format!("Removing contract '{}'...", name), - data: None, - request: Some(ContractToolRequest::RemoveChainContract { name }), - pending_questions: None, - } -} - -fn parse_preview_chain_dag() -> ContractToolExecutionResult { - ContractToolExecutionResult { - success: true, - message: "Generating chain DAG preview...".to_string(), - data: None, - request: Some(ContractToolRequest::PreviewChainDag), - pending_questions: None, - } -} - -fn parse_validate_chain_directive() -> ContractToolExecutionResult { - ContractToolExecutionResult { - success: true, - message: "Validating chain directive...".to_string(), - data: None, - request: Some(ContractToolRequest::ValidateChainDirective), - pending_questions: None, - } -} - -fn parse_finalize_chain_directive(call: &super::tools::ToolCall) -> ContractToolExecutionResult { - let auto_start = call.arguments.get("auto_start").and_then(|v| v.as_bool()).unwrap_or(true); - - ContractToolExecutionResult { - success: true, - message: "Finalizing chain directive...".to_string(), - data: None, - request: Some(ContractToolRequest::FinalizeChainDirective { auto_start }), - pending_questions: None, - } -} - -fn parse_get_chain_status() -> ContractToolExecutionResult { - ContractToolExecutionResult { - success: true, - message: "Getting chain status...".to_string(), - data: None, - request: Some(ContractToolRequest::GetChainStatus), - pending_questions: None, - } -} - -fn parse_get_uncovered_requirements() -> ContractToolExecutionResult { - ContractToolExecutionResult { - success: true, - message: "Getting uncovered requirements...".to_string(), - data: None, - request: Some(ContractToolRequest::GetUncoveredRequirements), - pending_questions: None, - } -} - -fn parse_evaluate_contract_completion(call: &super::tools::ToolCall) -> ContractToolExecutionResult { - let contract_id = parse_uuid_arg(call, "contract_id"); - let Some(contract_id) = contract_id else { - return error_result("Missing or invalid required parameter: contract_id"); - }; - - let passed = call.arguments.get("passed").and_then(|v| v.as_bool()).unwrap_or(false); - let feedback = call.arguments.get("feedback").and_then(|v| v.as_str()).map(|s| s.to_string()); - let Some(feedback) = feedback else { - return error_result("Missing required parameter: feedback"); - }; - let rework_instructions = call.arguments.get("rework_instructions").and_then(|v| v.as_str()).map(|s| s.to_string()); - - ContractToolExecutionResult { - success: true, - message: format!("Evaluating contract completion (passed: {})...", passed), - data: None, - request: Some(ContractToolRequest::EvaluateContractCompletion { - contract_id, - passed, - feedback, - rework_instructions, - }), - pending_questions: None, - } -} - -fn parse_request_rework(call: &super::tools::ToolCall) -> ContractToolExecutionResult { - let contract_id = parse_uuid_arg(call, "contract_id"); - let Some(contract_id) = contract_id else { - return error_result("Missing or invalid required parameter: contract_id"); - }; - - let feedback = call.arguments.get("feedback").and_then(|v| v.as_str()).map(|s| s.to_string()); - let Some(feedback) = feedback else { - return error_result("Missing required parameter: feedback"); - }; - - ContractToolExecutionResult { - success: true, - message: "Requesting rework...".to_string(), - data: None, - request: Some(ContractToolRequest::RequestRework { contract_id, feedback }), - pending_questions: None, - } -} - -// ============================================================================= // Helper Functions // ============================================================================= diff --git a/makima/src/server/handlers/contract_chat.rs b/makima/src/server/handlers/contract_chat.rs index 8153093..2c7a800 100644 --- a/makima/src/server/handlers/contract_chat.rs +++ b/makima/src/server/handlers/contract_chat.rs @@ -1368,6 +1368,8 @@ async fn handle_contract_request( branched_from_task_id: None, conversation_history: None, supervisor_worktree_task_id: None, // Not spawned by supervisor + directive_id: None, + directive_step_id: None, }; match repository::create_task_for_owner(pool, owner_id, create_req).await { @@ -1465,6 +1467,8 @@ async fn handle_contract_request( branched_from_task_id: None, conversation_history: None, supervisor_worktree_task_id: None, // Not spawned by supervisor + directive_id: None, + directive_step_id: None, }; match repository::create_task_for_owner(pool, owner_id, create_req).await { @@ -2217,6 +2221,8 @@ async fn handle_contract_request( branched_from_task_id: None, conversation_history: None, supervisor_worktree_task_id: None, // Not spawned by supervisor + directive_id: None, + directive_step_id: None, }; match repository::create_task_for_owner(pool, owner_id, create_req).await { @@ -2737,6 +2743,8 @@ async fn handle_contract_request( branched_from_task_id: None, conversation_history: None, supervisor_worktree_task_id: None, // Not spawned by supervisor + directive_id: None, + directive_step_id: None, }; if repository::create_task_for_owner(pool, owner_id, task_req).await.is_ok() { @@ -2766,27 +2774,6 @@ async fn handle_contract_request( } - // Chain directive tools - TEMPORARILY DISABLED - // These tools will be reimplemented using the new directive system. - // See the orchestration module for the new implementation. - ContractToolRequest::CreateChainFromDirective { .. } | - ContractToolRequest::AddChainContract { .. } | - ContractToolRequest::SetChainDependencies { .. } | - ContractToolRequest::ModifyChainContract { .. } | - ContractToolRequest::RemoveChainContract { .. } | - ContractToolRequest::PreviewChainDag | - ContractToolRequest::ValidateChainDirective | - ContractToolRequest::FinalizeChainDirective { .. } | - ContractToolRequest::GetChainStatus | - ContractToolRequest::GetUncoveredRequirements | - ContractToolRequest::EvaluateContractCompletion { .. } | - ContractToolRequest::RequestRework { .. } => { - ContractRequestResult { - success: false, - message: "Chain directive tools are temporarily disabled. The directive system is being reimplemented.".to_string(), - data: None, - } - } } } diff --git a/makima/src/server/handlers/contracts.rs b/makima/src/server/handlers/contracts.rs index dc15923..bdd4d40 100644 --- a/makima/src/server/handlers/contracts.rs +++ b/makima/src/server/handlers/contracts.rs @@ -369,6 +369,8 @@ pub async fn create_contract( branched_from_task_id: None, conversation_history: None, supervisor_worktree_task_id: None, // Supervisor uses its own worktree + directive_id: None, + directive_step_id: None, }; match repository::create_task_for_owner(pool, auth.owner_id, supervisor_req).await { diff --git a/makima/src/server/handlers/directives.rs b/makima/src/server/handlers/directives.rs new file mode 100644 index 0000000..d48ff74 --- /dev/null +++ b/makima/src/server/handlers/directives.rs @@ -0,0 +1,841 @@ +//! HTTP handlers for directive CRUD and DAG progression. + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use uuid::Uuid; + +use crate::db::models::{ + CreateDirectiveRequest, CreateDirectiveStepRequest, Directive, DirectiveListResponse, + DirectiveStep, DirectiveWithSteps, UpdateDirectiveRequest, UpdateDirectiveStepRequest, + UpdateGoalRequest, +}; +use crate::db::repository; +use crate::server::auth::Authenticated; +use crate::server::messages::ApiError; +use crate::server::state::SharedState; + +// ============================================================================= +// Directive CRUD +// ============================================================================= + +/// List all directives for the authenticated user. +#[utoipa::path( + get, + path = "/api/v1/directives", + responses( + (status = 200, description = "List of directives", body = DirectiveListResponse), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn list_directives( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, +) -> 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_directives_for_owner(pool, auth.owner_id).await { + Ok(directives) => { + let total = directives.len() as i64; + Json(DirectiveListResponse { directives, total }).into_response() + } + Err(e) => { + tracing::error!("Failed to list directives: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("LIST_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Create a new directive. +#[utoipa::path( + post, + path = "/api/v1/directives", + request_body = CreateDirectiveRequest, + responses( + (status = 201, description = "Directive created", body = Directive), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn create_directive( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Json(req): Json<CreateDirectiveRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::create_directive_for_owner(pool, auth.owner_id, req).await { + Ok(directive) => (StatusCode::CREATED, Json(directive)).into_response(), + Err(e) => { + tracing::error!("Failed to create directive: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("CREATE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Get a directive with all its steps. +#[utoipa::path( + get, + path = "/api/v1/directives/{id}", + params(("id" = Uuid, Path, description = "Directive ID")), + responses( + (status = 200, description = "Directive with steps", body = DirectiveWithSteps), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn get_directive( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::get_directive_with_steps_for_owner(pool, auth.owner_id, id).await { + Ok(Some((directive, steps))) => { + Json(DirectiveWithSteps { directive, steps }).into_response() + } + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to get directive: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Update a directive. +#[utoipa::path( + put, + path = "/api/v1/directives/{id}", + params(("id" = Uuid, Path, description = "Directive ID")), + request_body = UpdateDirectiveRequest, + responses( + (status = 200, description = "Directive updated", body = Directive), + (status = 404, description = "Not found", body = ApiError), + (status = 409, description = "Version conflict", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn update_directive( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<UpdateDirectiveRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::update_directive_for_owner(pool, auth.owner_id, id, req).await { + Ok(Some(directive)) => Json(directive).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(), + Err(repository::RepositoryError::VersionConflict { expected, actual }) => ( + StatusCode::CONFLICT, + Json(ApiError::new( + "VERSION_CONFLICT", + &format!("Expected version {}, but current is {}", expected, actual), + )), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to update directive: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("UPDATE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Delete a directive. +#[utoipa::path( + delete, + path = "/api/v1/directives/{id}", + params(("id" = Uuid, Path, description = "Directive ID")), + responses( + (status = 204, description = "Deleted"), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn delete_directive( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::delete_directive_for_owner(pool, auth.owner_id, id).await { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to delete directive: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DELETE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= +// Step CRUD +// ============================================================================= + +/// Create a step in a directive. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/steps", + params(("id" = Uuid, Path, description = "Directive ID")), + request_body = CreateDirectiveStepRequest, + responses( + (status = 201, description = "Step created", body = DirectiveStep), + (status = 404, description = "Directive not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn create_step( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<CreateDirectiveStepRequest>, +) -> 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 + match repository::get_directive_for_owner(pool, auth.owner_id, id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + match repository::create_directive_step(pool, id, req).await { + Ok(step) => (StatusCode::CREATED, Json(step)).into_response(), + Err(e) => { + tracing::error!("Failed to create step: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("CREATE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Batch create steps in a directive. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/steps/batch", + params(("id" = Uuid, Path, description = "Directive ID")), + request_body = Vec<CreateDirectiveStepRequest>, + responses( + (status = 201, description = "Steps created", body = Vec<DirectiveStep>), + (status = 404, description = "Directive not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn batch_create_steps( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(steps): Json<Vec<CreateDirectiveStepRequest>>, +) -> 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 + match repository::get_directive_for_owner(pool, auth.owner_id, id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + match repository::batch_create_directive_steps(pool, id, steps).await { + Ok(created) => (StatusCode::CREATED, Json(created)).into_response(), + Err(e) => { + tracing::error!("Failed to batch create steps: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("CREATE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Update a step. +#[utoipa::path( + put, + path = "/api/v1/directives/{id}/steps/{step_id}", + params( + ("id" = Uuid, Path, description = "Directive ID"), + ("step_id" = Uuid, Path, description = "Step ID"), + ), + request_body = UpdateDirectiveStepRequest, + responses( + (status = 200, description = "Step updated", body = DirectiveStep), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn update_step( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, step_id)): Path<(Uuid, Uuid)>, + Json(req): Json<UpdateDirectiveStepRequest>, +) -> 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 + match repository::get_directive_for_owner(pool, auth.owner_id, id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + match repository::update_directive_step(pool, step_id, req).await { + Ok(Some(step)) => Json(step).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Step not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to update step: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("UPDATE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Delete a step. +#[utoipa::path( + delete, + path = "/api/v1/directives/{id}/steps/{step_id}", + params( + ("id" = Uuid, Path, description = "Directive ID"), + ("step_id" = Uuid, Path, description = "Step ID"), + ), + responses( + (status = 204, description = "Deleted"), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn delete_step( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, step_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 + match repository::get_directive_for_owner(pool, auth.owner_id, id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + match repository::delete_directive_step(pool, step_id).await { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Step not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to delete step: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DELETE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= +// Directive Lifecycle Actions +// ============================================================================= + +/// Start a directive: sets status=active, advances ready steps. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/start", + params(("id" = Uuid, Path, description = "Directive ID")), + responses( + (status = 200, description = "Directive started", body = DirectiveWithSteps), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn start_directive( + 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(); + }; + + // Set to active + match repository::set_directive_status(pool, auth.owner_id, id, "active").await { + Ok(Some(directive)) => { + // Advance ready steps + let _ = repository::advance_directive_ready_steps(pool, id).await; + let steps = repository::list_directive_steps(pool, id).await.unwrap_or_default(); + Json(DirectiveWithSteps { directive, steps }).into_response() + } + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to start directive: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("START_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Pause a directive. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/pause", + params(("id" = Uuid, Path, description = "Directive ID")), + responses( + (status = 200, description = "Directive paused", body = Directive), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn pause_directive( + 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::set_directive_status(pool, auth.owner_id, id, "paused").await { + Ok(Some(directive)) => Json(directive).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("PAUSE_FAILED", &e.to_string())), + ) + .into_response(), + } +} + +/// Advance a directive: find newly-ready steps. If all steps done, set idle. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/advance", + params(("id" = Uuid, Path, description = "Directive ID")), + responses( + (status = 200, description = "Advance result", body = DirectiveWithSteps), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn advance_directive( + 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(); + }; + + // Verify ownership + let directive = match repository::get_directive_for_owner(pool, auth.owner_id, id).await { + Ok(Some(d)) => d, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + }; + + // Advance ready steps + let _ = repository::advance_directive_ready_steps(pool, id).await; + + // Check if idle + let _ = repository::check_directive_idle(pool, id).await; + + // Return updated state + let directive = match repository::get_directive_for_owner(pool, auth.owner_id, id).await { + Ok(Some(d)) => d, + _ => directive, + }; + let steps = repository::list_directive_steps(pool, id).await.unwrap_or_default(); + Json(DirectiveWithSteps { directive, steps }).into_response() +} + +/// Mark a step as completed. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/steps/{step_id}/complete", + params( + ("id" = Uuid, Path, description = "Directive ID"), + ("step_id" = Uuid, Path, description = "Step ID"), + ), + responses( + (status = 200, description = "Step completed", body = DirectiveStep), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn complete_step( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, step_id)): Path<(Uuid, Uuid)>, +) -> impl IntoResponse { + step_status_change(state, auth, id, step_id, "completed").await +} + +/// Mark a step as failed. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/steps/{step_id}/fail", + params( + ("id" = Uuid, Path, description = "Directive ID"), + ("step_id" = Uuid, Path, description = "Step ID"), + ), + responses( + (status = 200, description = "Step failed", body = DirectiveStep), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn fail_step( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, step_id)): Path<(Uuid, Uuid)>, +) -> impl IntoResponse { + step_status_change(state, auth, id, step_id, "failed").await +} + +/// Mark a step as skipped. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/steps/{step_id}/skip", + params( + ("id" = Uuid, Path, description = "Directive ID"), + ("step_id" = Uuid, Path, description = "Step ID"), + ), + responses( + (status = 200, description = "Step skipped", body = DirectiveStep), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn skip_step( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, step_id)): Path<(Uuid, Uuid)>, +) -> impl IntoResponse { + step_status_change(state, auth, id, step_id, "skipped").await +} + +/// Helper for step status changes. +async fn step_status_change( + state: SharedState, + auth: crate::server::auth::AuthenticatedUser, + directive_id: Uuid, + step_id: Uuid, + new_status: &str, +) -> 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 + match repository::get_directive_for_owner(pool, auth.owner_id, directive_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + let req = UpdateDirectiveStepRequest { + status: Some(new_status.to_string()), + ..Default::default() + }; + + match repository::update_directive_step(pool, step_id, req).await { + Ok(Some(step)) => { + // After step status change, advance the DAG + let _ = repository::advance_directive_ready_steps(pool, directive_id).await; + let _ = repository::check_directive_idle(pool, directive_id).await; + Json(step).into_response() + } + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Step not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to update step status: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("UPDATE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Update a directive's goal (triggers re-planning). +#[utoipa::path( + put, + path = "/api/v1/directives/{id}/goal", + params(("id" = Uuid, Path, description = "Directive ID")), + request_body = UpdateGoalRequest, + responses( + (status = 200, description = "Goal updated", body = Directive), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn update_goal( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<UpdateGoalRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::update_directive_goal(pool, auth.owner_id, id, &req.goal).await { + Ok(Some(directive)) => Json(directive).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to update goal: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("UPDATE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} diff --git a/makima/src/server/handlers/mesh.rs b/makima/src/server/handlers/mesh.rs index fe9ffc0..310bec8 100644 --- a/makima/src/server/handlers/mesh.rs +++ b/makima/src/server/handlers/mesh.rs @@ -2626,6 +2626,8 @@ pub async fn reassign_task( branched_from_task_id: None, conversation_history: None, supervisor_worktree_task_id: None, // Not spawned by supervisor + directive_id: None, + directive_step_id: None, }; let new_task = match repository::create_task_for_owner(pool, auth.owner_id, create_req).await { @@ -3402,6 +3404,8 @@ pub async fn fork_task( branched_from_task_id: None, conversation_history: None, supervisor_worktree_task_id: None, // Not spawned by supervisor + directive_id: None, + directive_step_id: None, }; let new_task = match repository::create_task_for_owner(pool, auth.owner_id, create_req).await { @@ -3560,6 +3564,8 @@ pub async fn resume_from_checkpoint( branched_from_task_id: None, conversation_history: None, supervisor_worktree_task_id: None, // Not spawned by supervisor + directive_id: None, + directive_step_id: None, }; let new_task = match repository::create_task_for_owner(pool, auth.owner_id, create_req).await { @@ -3896,6 +3902,8 @@ pub async fn branch_task( branched_from_task_id: Some(source_task_id), conversation_history, supervisor_worktree_task_id: None, // Not spawned by supervisor + directive_id: None, + directive_step_id: None, }; let task = match repository::create_task_for_owner(pool, auth.owner_id, create_req).await { diff --git a/makima/src/server/handlers/mesh_chat.rs b/makima/src/server/handlers/mesh_chat.rs index a6a3a3c..cf56ab6 100644 --- a/makima/src/server/handlers/mesh_chat.rs +++ b/makima/src/server/handlers/mesh_chat.rs @@ -1021,6 +1021,8 @@ async fn handle_mesh_request( branched_from_task_id: None, conversation_history: None, supervisor_worktree_task_id: None, // Not spawned by supervisor + directive_id: None, + directive_step_id: None, }; match repository::create_task_for_owner(pool, owner_id, create_req).await { diff --git a/makima/src/server/handlers/mesh_daemon.rs b/makima/src/server/handlers/mesh_daemon.rs index 87b5e44..2ea7805 100644 --- a/makima/src/server/handlers/mesh_daemon.rs +++ b/makima/src/server/handlers/mesh_daemon.rs @@ -1303,6 +1303,23 @@ async fn handle_daemon_connection(socket: WebSocket, state: SharedState, auth_re }), ).await; + // Auto-advance directive DAG when a directive step task completes + if let Some(step_id) = updated_task.directive_step_id { + let step_status = if updated_task.status == "done" { "completed" } else { "failed" }; + let step_update = crate::db::models::UpdateDirectiveStepRequest { + status: Some(step_status.to_string()), + ..Default::default() + }; + let _ = repository::update_directive_step(&pool, step_id, step_update).await; + + if let Some(directive_id) = updated_task.directive_id { + // Advance newly-ready steps in the DAG + let _ = repository::advance_directive_ready_steps(&pool, directive_id).await; + // Check if all steps are done → set directive to idle + let _ = repository::check_directive_idle(&pool, directive_id).await; + } + } + } Ok(None) => { tracing::warn!( diff --git a/makima/src/server/handlers/mesh_supervisor.rs b/makima/src/server/handlers/mesh_supervisor.rs index 09758bb..8bf2534 100644 --- a/makima/src/server/handlers/mesh_supervisor.rs +++ b/makima/src/server/handlers/mesh_supervisor.rs @@ -629,6 +629,8 @@ pub async fn spawn_task( branched_from_task_id: None, conversation_history: None, supervisor_worktree_task_id, + directive_id: None, + directive_step_id: None, }; // Create task in DB diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs index ae370c9..29cd09f 100644 --- a/makima/src/server/handlers/mod.rs +++ b/makima/src/server/handlers/mod.rs @@ -6,6 +6,7 @@ pub mod contract_chat; pub mod contract_daemon; pub mod contract_discuss; pub mod contracts; +pub mod directives; pub mod file_ws; pub mod files; pub mod history; diff --git a/makima/src/server/handlers/transcript_analysis.rs b/makima/src/server/handlers/transcript_analysis.rs index 62c65a6..9261c0c 100644 --- a/makima/src/server/handlers/transcript_analysis.rs +++ b/makima/src/server/handlers/transcript_analysis.rs @@ -370,6 +370,8 @@ pub async fn create_contract_from_analysis( branched_from_task_id: None, conversation_history: None, supervisor_worktree_task_id: None, // Not spawned by supervisor + directive_id: None, + directive_step_id: None, }; if let Ok(t) = repository::create_task_for_owner(pool, auth.owner_id, task_req).await { @@ -540,6 +542,8 @@ pub async fn update_contract_from_analysis( branched_from_task_id: None, conversation_history: None, supervisor_worktree_task_id: None, // Not spawned by supervisor + directive_id: None, + directive_step_id: None, }; if let Ok(t) = repository::create_task_for_owner(pool, auth.owner_id, task_req).await { diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index b7a4156..9e1ee50 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -18,7 +18,7 @@ use tower_http::trace::TraceLayer; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; -use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contract_discuss, contracts, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, repository_history, speak, templates, transcript_analysis, users, versions}; +use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contract_discuss, contracts, directives, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, repository_history, speak, templates, transcript_analysis, users, versions}; use crate::server::openapi::ApiDoc; use crate::server::state::SharedState; @@ -212,6 +212,30 @@ pub fn make_router(state: SharedState) -> Router { "/contracts/{id}/tasks/{task_id}", post(contracts::add_task_to_contract).delete(contracts::remove_task_from_contract), ) + // Directive endpoints + .route( + "/directives", + get(directives::list_directives).post(directives::create_directive), + ) + .route( + "/directives/{id}", + get(directives::get_directive) + .put(directives::update_directive) + .delete(directives::delete_directive), + ) + .route("/directives/{id}/steps", post(directives::create_step)) + .route("/directives/{id}/steps/batch", post(directives::batch_create_steps)) + .route( + "/directives/{id}/steps/{step_id}", + put(directives::update_step).delete(directives::delete_step), + ) + .route("/directives/{id}/start", post(directives::start_directive)) + .route("/directives/{id}/pause", post(directives::pause_directive)) + .route("/directives/{id}/advance", post(directives::advance_directive)) + .route("/directives/{id}/steps/{step_id}/complete", post(directives::complete_step)) + .route("/directives/{id}/steps/{step_id}/fail", post(directives::fail_step)) + .route("/directives/{id}/steps/{step_id}/skip", post(directives::skip_step)) + .route("/directives/{id}/goal", put(directives::update_goal)) // Timeline endpoint (unified history for user) .route("/timeline", get(history::get_timeline)) // Contract type templates (built-in only) diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs index 0b6bfba..4e3b85b 100644 --- a/makima/src/server/openapi.rs +++ b/makima/src/server/openapi.rs @@ -8,9 +8,10 @@ use crate::db::models::{ ChangePhaseRequest, Contract, ContractChatHistoryResponse, ContractChatMessageRecord, ContractEvent, ContractListResponse, ContractRepository, ContractSummary, ContractWithRelations, - CreateContractRequest, CreateFileRequest, + CreateContractRequest, CreateDirectiveRequest, CreateDirectiveStepRequest, CreateFileRequest, CreateManagedRepositoryRequest, CreateTaskRequest, Daemon, DaemonDirectoriesResponse, - DaemonDirectory, DaemonListResponse, + DaemonDirectory, DaemonListResponse, Directive, DirectiveListResponse, DirectiveStep, + DirectiveSummary, DirectiveWithSteps, File, FileListResponse, FileSummary, MergeCommitRequest, MergeCompleteCheckResponse, MergeResolveRequest, MergeResultResponse, MergeSkipRequest, MergeStartRequest, MergeStatusResponse, MeshChatConversation, @@ -18,13 +19,14 @@ use crate::db::models::{ RepositoryHistoryListResponse, RepositorySuggestionsQuery, SendMessageRequest, Task, TaskEventListResponse, TaskListResponse, TaskSummary, TaskWithSubtasks, TranscriptEntry, - UpdateContractRequest, UpdateFileRequest, UpdateTaskRequest, + UpdateContractRequest, UpdateDirectiveRequest, UpdateDirectiveStepRequest, + UpdateFileRequest, UpdateGoalRequest, UpdateTaskRequest, }; use crate::server::auth::{ ApiKey, ApiKeyInfoResponse, CreateApiKeyRequest, CreateApiKeyResponse, RefreshApiKeyRequest, RefreshApiKeyResponse, RevokeApiKeyResponse, }; -use crate::server::handlers::{api_keys, contract_chat, contract_discuss, contracts, files, listen, mesh, mesh_chat, mesh_merge, repository_history, users}; +use crate::server::handlers::{api_keys, contract_chat, contract_discuss, contracts, directives, files, listen, mesh, mesh_chat, mesh_merge, repository_history, users}; use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage, TranscriptMessage}; #[derive(OpenApi)] @@ -103,6 +105,23 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage contract_chat::clear_contract_chat_history, // Contract discuss endpoint contract_discuss::discuss_contract_handler, + // Directive endpoints + directives::list_directives, + directives::create_directive, + directives::get_directive, + directives::update_directive, + directives::delete_directive, + directives::create_step, + directives::batch_create_steps, + directives::update_step, + directives::delete_step, + directives::start_directive, + directives::pause_directive, + directives::advance_directive, + directives::complete_step, + directives::fail_step, + directives::skip_step, + directives::update_goal, // Repository history/settings endpoints repository_history::list_repository_history, repository_history::get_repository_suggestions, @@ -187,6 +206,17 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage AddLocalRepositoryRequest, CreateManagedRepositoryRequest, ChangePhaseRequest, + // Directive schemas + Directive, + DirectiveStep, + DirectiveWithSteps, + DirectiveSummary, + DirectiveListResponse, + CreateDirectiveRequest, + UpdateDirectiveRequest, + UpdateGoalRequest, + CreateDirectiveStepRequest, + UpdateDirectiveStepRequest, // Repository history schemas RepositoryHistoryEntry, RepositoryHistoryListResponse, @@ -200,6 +230,7 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage (name = "Contracts", description = "Contract management with workflow phases"), (name = "API Keys", description = "API key management for programmatic access"), (name = "Users", description = "User account management"), + (name = "Directives", description = "Directive management with DAG-based step progression"), (name = "Settings", description = "User settings including repository history"), ) )] |
