diff options
| author | soryu <soryu@soryu.co> | 2026-02-06 20:06:30 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-02-06 20:15:27 +0000 |
| commit | 1b692b8cde4a888c8a35af69231f181b57bf5619 (patch) | |
| tree | 74ce25ce6ee5fb4536b53404e1a0ae923e85c30d /makima | |
| parent | 139be135c2086d725e4f040e744bb25acd436549 (diff) | |
| download | soryu-1b692b8cde4a888c8a35af69231f181b57bf5619.tar.gz soryu-1b692b8cde4a888c8a35af69231f181b57bf5619.zip | |
Fix: Cleanup old chain code
Diffstat (limited to 'makima')
38 files changed, 2119 insertions, 4815 deletions
diff --git a/makima/frontend/src/components/directives/ApprovalsTab.tsx b/makima/frontend/src/components/directives/ApprovalsTab.tsx new file mode 100644 index 0000000..dca48df --- /dev/null +++ b/makima/frontend/src/components/directives/ApprovalsTab.tsx @@ -0,0 +1,77 @@ +import type { DirectiveWithProgress } from "../../lib/api"; + +export function ApprovalsTab({ directive, onRefresh }: { directive: DirectiveWithProgress; onRefresh: () => void }) { + if (directive.pendingApprovals.length === 0) { + return ( + <div className="text-center py-8"> + <p className="font-mono text-sm text-[#556677]">No pending approvals</p> + </div> + ); + } + + const handleApprove = async (approvalId: string) => { + try { + const { approveDirectiveRequest } = await import("../../lib/api"); + await approveDirectiveRequest(directive.id, approvalId); + onRefresh(); + } catch (err) { + console.error("Failed to approve:", err); + } + }; + + const handleDeny = async (approvalId: string) => { + try { + const { denyDirectiveRequest } = await import("../../lib/api"); + await denyDirectiveRequest(directive.id, approvalId); + onRefresh(); + } catch (err) { + console.error("Failed to deny:", err); + } + }; + + return ( + <div className="space-y-3"> + {directive.pendingApprovals.map((approval) => { + const urgencyColor = { + low: "text-[#556677]", + normal: "text-[#75aafc]", + high: "text-yellow-400", + critical: "text-red-400", + }[approval.urgency] || "text-[#556677]"; + + return ( + <div + key={approval.id} + className="p-4 bg-[rgba(117,170,252,0.05)] border border-[rgba(117,170,252,0.2)]" + > + <div className="flex items-start justify-between"> + <div> + <div className="flex items-center gap-2"> + <span className="font-mono text-sm text-[#dbe7ff]">{approval.approvalType}</span> + <span className={`font-mono text-[10px] uppercase ${urgencyColor}`}> + {approval.urgency} + </span> + </div> + <p className="font-mono text-xs text-[#9bc3ff] mt-1">{approval.description}</p> + </div> + <div className="flex gap-2"> + <button + onClick={() => handleApprove(approval.id)} + className="px-3 py-1 font-mono text-[10px] text-[#dbe7ff] bg-green-700 border border-green-600 hover:bg-green-600 uppercase" + > + Approve + </button> + <button + onClick={() => handleDeny(approval.id)} + className="px-3 py-1 font-mono text-[10px] text-[#dbe7ff] bg-red-700 border border-red-600 hover:bg-red-600 uppercase" + > + Deny + </button> + </div> + </div> + </div> + ); + })} + </div> + ); +} diff --git a/makima/frontend/src/components/directives/ChainTab.tsx b/makima/frontend/src/components/directives/ChainTab.tsx new file mode 100644 index 0000000..ccefe81 --- /dev/null +++ b/makima/frontend/src/components/directives/ChainTab.tsx @@ -0,0 +1,212 @@ +import { useState, useMemo } from "react"; +import { + ReactFlow, + Edge, + Controls, + Background, + BackgroundVariant, + MarkerType, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import type { DirectiveWithProgress, DirectiveGraphResponse } from "../../lib/api"; +import { StepNodeComponent, stepStatusStyles } from "./StepNode"; + +// Node types for React Flow +const nodeTypes = { + step: StepNodeComponent, +}; + +export function ChainTab({ directive, graph }: { directive: DirectiveWithProgress; graph: DirectiveGraphResponse | null }) { + const [viewMode, setViewMode] = useState<"dag" | "list">("dag"); + + // Convert graph to React Flow nodes and edges + const { nodes, edges } = useMemo(() => { + if (!graph || !graph.nodes.length) { + // Fallback: generate positions from directive.steps + const stepNodes = directive.steps.map((step, index) => ({ + id: step.id, + type: "step" as const, + position: { + x: (index % 3) * 220 + 50, + y: Math.floor(index / 3) * 120 + 50, + }, + data: { + id: step.id, + name: step.name, + stepType: step.stepType, + status: step.status, + confidenceScore: step.confidenceScore, + confidenceLevel: step.confidenceLevel, + contractId: step.contractId, + editorX: null, + editorY: null, + }, + })); + + // Build edges from dependencies + const stepEdges: Edge[] = []; + directive.steps.forEach((step) => { + (step.dependsOn ?? []).forEach((depName) => { + const depStep = directive.steps.find((s) => s.name === depName); + if (depStep) { + stepEdges.push({ + id: `${depStep.id}-${step.id}`, + source: depStep.id, + target: step.id, + type: "smoothstep", + markerEnd: { type: MarkerType.ArrowClosed, color: "#556677" }, + style: { stroke: "#556677", strokeWidth: 2 }, + }); + } + }); + }); + + return { nodes: stepNodes, edges: stepEdges }; + } + + // Use graph data + const graphNodes = graph.nodes.map((node) => ({ + id: node.id, + type: "step" as const, + position: { + x: node.editorX ?? 50, + y: node.editorY ?? 50, + }, + data: { ...node }, + })); + + const graphEdges: Edge[] = graph.edges.map((edge) => ({ + id: `${edge.source}-${edge.target}`, + source: edge.source, + target: edge.target, + type: "smoothstep", + markerEnd: { type: MarkerType.ArrowClosed, color: "#556677" }, + style: { stroke: "#556677", strokeWidth: 2 }, + })); + + return { nodes: graphNodes, edges: graphEdges }; + }, [graph, directive.steps]); + + if (!directive.chain) { + return ( + <div className="text-center py-8"> + <p className="font-mono text-sm text-[#556677]"> + No chain generated yet. Start the directive to generate a chain. + </p> + </div> + ); + } + + return ( + <div className="space-y-4"> + {/* Chain info header */} + <div className="flex items-center justify-between p-3 bg-[rgba(117,170,252,0.05)] border border-[rgba(117,170,252,0.15)]"> + <div> + <div className="font-mono text-sm text-[#dbe7ff]">{directive.chain.name}</div> + <div className="font-mono text-[10px] text-[#556677]">Generation {directive.chain.generation}</div> + </div> + <div className="flex items-center gap-4"> + <div className="font-mono text-xs text-[#556677]"> + {directive.chain.completedSteps}/{directive.chain.totalSteps} steps + </div> + {/* View toggle */} + <div className="flex border border-[rgba(117,170,252,0.2)]"> + <button + onClick={() => setViewMode("dag")} + className={`px-2 py-1 font-mono text-[10px] uppercase ${ + viewMode === "dag" + ? "bg-[rgba(117,170,252,0.2)] text-[#75aafc]" + : "text-[#556677] hover:text-[#75aafc]" + }`} + > + DAG + </button> + <button + onClick={() => setViewMode("list")} + className={`px-2 py-1 font-mono text-[10px] uppercase ${ + viewMode === "list" + ? "bg-[rgba(117,170,252,0.2)] text-[#75aafc]" + : "text-[#556677] hover:text-[#75aafc]" + }`} + > + List + </button> + </div> + </div> + </div> + + {viewMode === "dag" ? ( + /* DAG visualization */ + <div className="h-[400px] border border-[rgba(117,170,252,0.1)] rounded bg-[#050d18]"> + {directive.steps.length === 0 ? ( + <div className="flex items-center justify-center h-full"> + <p className="font-mono text-sm text-[#556677]">No steps in chain</p> + </div> + ) : ( + <ReactFlow + nodes={nodes} + edges={edges} + nodeTypes={nodeTypes} + fitView + fitViewOptions={{ padding: 0.2 }} + minZoom={0.5} + maxZoom={1.5} + defaultEdgeOptions={{ + type: "smoothstep", + style: { stroke: "#556677", strokeWidth: 2 }, + }} + > + <Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#1a2a3a" /> + <Controls className="!bg-[#0a1628] !border-[rgba(117,170,252,0.2)]" /> + </ReactFlow> + )} + </div> + ) : ( + /* List view */ + <div className="space-y-2"> + <h3 className="font-mono text-xs text-[#75aafc] uppercase">Steps</h3> + {directive.steps.length === 0 ? ( + <p className="font-mono text-sm text-[#556677]">No steps in chain</p> + ) : ( + directive.steps.map((step) => { + const styles = stepStatusStyles[step.status] || stepStatusStyles.pending; + + return ( + <div + key={step.id} + className="p-3 bg-[rgba(117,170,252,0.02)] border border-[rgba(117,170,252,0.1)]" + > + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <span className="font-mono text-sm text-[#dbe7ff]">{step.name}</span> + <span + className="px-1.5 py-0.5 font-mono text-[10px] uppercase border" + style={{ color: styles.text, borderColor: `${styles.border}50` }} + > + {step.status} + </span> + {step.confidenceScore !== null && ( + <span className="font-mono text-[10px] text-[#556677]"> + ({Math.round(step.confidenceScore * 100)}%) + </span> + )} + </div> + <div className="font-mono text-[10px] text-[#556677]">{step.stepType}</div> + </div> + {step.description && ( + <p className="font-mono text-xs text-[#556677] mt-1">{step.description}</p> + )} + {step.dependsOn?.length > 0 && ( + <div className="font-mono text-[10px] text-[#556677] mt-1"> + Depends on: {step.dependsOn.join(", ")} + </div> + )} + </div> + ); + }) + )} + </div> + )} + </div> + ); +} diff --git a/makima/frontend/src/components/directives/CreateDirectiveModal.tsx b/makima/frontend/src/components/directives/CreateDirectiveModal.tsx new file mode 100644 index 0000000..7f52a7e --- /dev/null +++ b/makima/frontend/src/components/directives/CreateDirectiveModal.tsx @@ -0,0 +1,146 @@ +import { useState, useEffect } from "react"; +import type { AutonomyLevel, RepositoryHistoryEntry } from "../../lib/api"; +import { getRepositorySuggestions } from "../../lib/api"; + +interface CreateDirectiveModalProps { + onSubmit: (goal: string, repositoryUrl: string | undefined, autonomyLevel: AutonomyLevel) => void; + onCancel: () => void; +} + +export function CreateDirectiveModal({ onSubmit, onCancel }: CreateDirectiveModalProps) { + const [goal, setGoal] = useState(""); + const [repositoryUrl, setRepositoryUrl] = useState(""); + const [autonomyLevel, setAutonomyLevel] = useState<AutonomyLevel>("guardrails"); + const [suggestions, setSuggestions] = useState<RepositoryHistoryEntry[]>([]); + const [showSuggestions, setShowSuggestions] = useState(false); + + // Load suggestions + useEffect(() => { + getRepositorySuggestions("remote", undefined, 5) + .then((res) => { + setSuggestions(res.entries); + }) + .catch(() => { + setSuggestions([]); + }); + }, []); + + const handleSubmit = () => { + if (goal.trim()) { + onSubmit(goal.trim(), repositoryUrl.trim() || undefined, autonomyLevel); + } + }; + + return ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"> + <div className="w-full max-w-lg p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] max-h-[90vh] overflow-y-auto"> + <h3 className="font-mono text-sm text-[#75aafc] uppercase mb-4"> + Create Directive + </h3> + + <div className="space-y-4"> + {/* Goal */} + <div> + <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> + Goal * + </label> + <textarea + value={goal} + onChange={(e) => setGoal(e.target.value)} + placeholder="Describe what you want to accomplish..." + rows={3} + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc] resize-none" + autoFocus + /> + </div> + + {/* Repository URL */} + <div> + <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> + Repository URL (optional) + </label> + <div className="relative"> + <input + type="text" + value={repositoryUrl} + onChange={(e) => setRepositoryUrl(e.target.value)} + onFocus={() => suggestions.length > 0 && setShowSuggestions(true)} + onBlur={() => setTimeout(() => setShowSuggestions(false), 200)} + placeholder="https://github.com/owner/repo" + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" + /> + {showSuggestions && suggestions.length > 0 && ( + <div className="absolute top-full left-0 right-0 mt-1 border border-[rgba(117,170,252,0.2)] bg-[#0a1525] max-h-32 overflow-y-auto z-10"> + {suggestions.map((s) => ( + <button + key={s.id} + type="button" + onClick={() => { + setRepositoryUrl(s.repositoryUrl || ""); + setShowSuggestions(false); + }} + className="w-full text-left px-3 py-2 font-mono text-xs hover:bg-[rgba(117,170,252,0.1)] border-b border-[rgba(117,170,252,0.1)] last:border-b-0" + > + <div className="text-[#9bc3ff] truncate">{s.name}</div> + <div className="text-[10px] text-[#556677] truncate">{s.repositoryUrl}</div> + </button> + ))} + </div> + )} + </div> + </div> + + {/* Autonomy Level */} + <div> + <label className="block font-mono text-xs text-[#8b949e] uppercase mb-2"> + Autonomy Level + </label> + <div className="flex gap-2"> + {(["full_auto", "guardrails", "manual"] as const).map((level) => ( + <button + key={level} + type="button" + onClick={() => setAutonomyLevel(level)} + className={`flex-1 px-3 py-2 font-mono text-xs uppercase ${ + autonomyLevel === level + ? "text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3]" + : "text-[#556677] border border-[rgba(117,170,252,0.2)] hover:border-[#3f6fb3]" + }`} + > + {level.replace("_", " ")} + </button> + ))} + </div> + <p className="font-mono text-[10px] text-[#556677] mt-1"> + {autonomyLevel === "full_auto" && "Automatic progression without approval gates"} + {autonomyLevel === "guardrails" && "Request approval for yellow/red confidence scores"} + {autonomyLevel === "manual" && "Request approval for all step completions"} + </p> + </div> + + <p className="font-mono text-xs text-[#8b949e]"> + A directive is a top-level goal that generates a chain of steps. Each step spawns + contracts that are verified before progression. + </p> + + {/* Actions */} + <div className="flex gap-2 justify-end pt-2"> + <button + onClick={onCancel} + className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors" + > + Cancel + </button> + <button + onClick={handleSubmit} + disabled={!goal.trim()} + className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + > + Create + </button> + </div> + </div> + </div> + </div> + ); +} diff --git a/makima/frontend/src/components/directives/DirectiveDetail.tsx b/makima/frontend/src/components/directives/DirectiveDetail.tsx new file mode 100644 index 0000000..06b24bb --- /dev/null +++ b/makima/frontend/src/components/directives/DirectiveDetail.tsx @@ -0,0 +1,160 @@ +import { useState } from "react"; +import type { DirectiveWithProgress, DirectiveGraphResponse } from "../../lib/api"; +import { OverviewTab } from "./OverviewTab"; +import { ChainTab } from "./ChainTab"; +import { EventsTab } from "./EventsTab"; +import { EvaluationsTab } from "./EvaluationsTab"; +import { ApprovalsTab } from "./ApprovalsTab"; +import { VerifiersTab } from "./VerifiersTab"; + +interface DirectiveDetailProps { + directive: DirectiveWithProgress; + graph: DirectiveGraphResponse | null; + loading: boolean; + onBack: () => void; + onRefresh: () => void; + onStart: () => void; + onPause: () => void; + onResume: () => void; + onStop: () => void; +} + +export function DirectiveDetail({ + directive, + graph, + loading, + onBack, + onRefresh, + onStart, + onPause, + onResume, + onStop, +}: DirectiveDetailProps) { + const [activeTab, setActiveTab] = useState<"overview" | "chain" | "events" | "evaluations" | "approvals" | "verifiers">("overview"); + + if (loading) { + return ( + <div className="panel h-full flex items-center justify-center"> + <p className="font-mono text-xs text-[#556677]">Loading...</p> + </div> + ); + } + + const statusColor = { + draft: "text-[#556677] bg-[#556677]/10 border-[#556677]/30", + planning: "text-yellow-400 bg-yellow-400/10 border-yellow-400/30", + active: "text-green-400 bg-green-400/10 border-green-400/30", + paused: "text-yellow-400 bg-yellow-400/10 border-yellow-400/30", + completed: "text-[#75aafc] bg-[#75aafc]/10 border-[#75aafc]/30", + archived: "text-[#556677] bg-[#556677]/10 border-[#556677]/30", + failed: "text-red-400 bg-red-400/10 border-red-400/30", + }[directive.status] || "text-[#556677] bg-[#556677]/10 border-[#556677]/30"; + + return ( + <div className="panel h-full flex flex-col overflow-hidden"> + {/* Header */} + <div className="p-3 border-b border-[rgba(117,170,252,0.15)]"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-3"> + <button + onClick={onBack} + className="font-mono text-xs text-[#556677] hover:text-[#9bc3ff]" + > + ← Back + </button> + <h2 className="font-mono text-sm text-[#dbe7ff]"> + {directive.title || directive.goal.slice(0, 50)} + </h2> + <span className={`px-2 py-0.5 font-mono text-[10px] uppercase border ${statusColor}`}> + {directive.status} + </span> + </div> + <div className="flex items-center gap-2"> + {directive.status === "draft" && ( + <button + onClick={onStart} + className="px-3 py-1 font-mono text-[10px] text-[#dbe7ff] bg-green-700 border border-green-600 hover:bg-green-600 uppercase" + > + Start + </button> + )} + {directive.status === "active" && ( + <button + onClick={onPause} + className="px-3 py-1 font-mono text-[10px] text-[#dbe7ff] bg-yellow-700 border border-yellow-600 hover:bg-yellow-600 uppercase" + > + Pause + </button> + )} + {directive.status === "paused" && ( + <button + onClick={onResume} + className="px-3 py-1 font-mono text-[10px] text-[#dbe7ff] bg-green-700 border border-green-600 hover:bg-green-600 uppercase" + > + Resume + </button> + )} + {["active", "paused"].includes(directive.status) && ( + <button + onClick={onStop} + className="px-3 py-1 font-mono text-[10px] text-[#dbe7ff] bg-red-700 border border-red-600 hover:bg-red-600 uppercase" + > + Stop + </button> + )} + <button + onClick={onRefresh} + className="px-3 py-1 font-mono text-[10px] text-[#9bc3ff] hover:text-[#dbe7ff]" + > + Refresh + </button> + </div> + </div> + </div> + + {/* Tabs */} + <div className="flex gap-1 p-2 border-b border-[rgba(117,170,252,0.1)]"> + {(["overview", "chain", "events", "evaluations", "approvals", "verifiers"] as const).map((tab) => ( + <button + key={tab} + onClick={() => setActiveTab(tab)} + className={`px-3 py-1.5 font-mono text-[10px] uppercase ${ + activeTab === tab + ? "text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3]" + : "text-[#556677] hover:text-[#9bc3ff]" + }`} + > + {tab} + {tab === "approvals" && directive.pendingApprovals.length > 0 && ( + <span className="ml-1 px-1 bg-yellow-500 text-black rounded text-[8px]"> + {directive.pendingApprovals.length} + </span> + )} + </button> + ))} + </div> + + {/* Tab Content */} + <div className="flex-1 overflow-y-auto p-4"> + {activeTab === "overview" && ( + <OverviewTab directive={directive} /> + )} + {activeTab === "chain" && ( + <ChainTab directive={directive} graph={graph} /> + )} + {activeTab === "events" && ( + <EventsTab directive={directive} /> + )} + {activeTab === "evaluations" && ( + <EvaluationsTab directive={directive} /> + )} + {activeTab === "approvals" && ( + <ApprovalsTab directive={directive} onRefresh={onRefresh} /> + )} + {activeTab === "verifiers" && ( + <VerifiersTab directive={directive} /> + )} + </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..d0371e0 --- /dev/null +++ b/makima/frontend/src/components/directives/DirectiveList.tsx @@ -0,0 +1,85 @@ +import { useState } from "react"; +import type { DirectiveSummary } from "../../lib/api"; +import { DirectiveListItem } from "./DirectiveListItem"; + +interface DirectiveListProps { + directives: DirectiveSummary[]; + loading: boolean; + onSelect: (id: string) => void; + onCreate: () => void; + selectedId?: string; + onArchive: (directive: DirectiveSummary) => void; +} + +export function DirectiveList({ + directives, + loading, + onSelect, + onCreate, + selectedId, + onArchive, +}: DirectiveListProps) { + const [filter, setFilter] = useState<"all" | "active" | "completed" | "failed">("all"); + + const filteredDirectives = directives.filter((d) => { + if (filter === "all") return true; + if (filter === "active") return ["draft", "planning", "active", "paused"].includes(d.status); + if (filter === "completed") return d.status === "completed"; + if (filter === "failed") return d.status === "failed"; + return true; + }); + + return ( + <div className="panel h-full flex flex-col overflow-hidden"> + <div className="flex items-center justify-between p-3 border-b border-[rgba(117,170,252,0.15)]"> + <h2 className="font-mono text-sm text-[#75aafc] uppercase">Directives</h2> + <button + onClick={onCreate} + className="px-3 py-1 font-mono text-[10px] text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase" + > + + New + </button> + </div> + + {/* Filters */} + <div className="flex gap-1 p-2 border-b border-[rgba(117,170,252,0.1)]"> + {(["all", "active", "completed", "failed"] as const).map((f) => ( + <button + key={f} + onClick={() => setFilter(f)} + className={`px-2 py-1 font-mono text-[10px] uppercase ${ + filter === f + ? "text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3]" + : "text-[#556677] hover:text-[#9bc3ff]" + }`} + > + {f} + </button> + ))} + </div> + + {/* List */} + <div className="flex-1 overflow-y-auto"> + {loading ? ( + <div className="p-4 text-center"> + <p className="font-mono text-xs text-[#556677]">Loading...</p> + </div> + ) : filteredDirectives.length === 0 ? ( + <div className="p-4 text-center"> + <p className="font-mono text-xs text-[#556677]">No directives found</p> + </div> + ) : ( + filteredDirectives.map((d) => ( + <DirectiveListItem + key={d.id} + directive={d} + selected={d.id === selectedId} + onClick={() => onSelect(d.id)} + onArchive={() => onArchive(d)} + /> + )) + )} + </div> + </div> + ); +} diff --git a/makima/frontend/src/components/directives/DirectiveListItem.tsx b/makima/frontend/src/components/directives/DirectiveListItem.tsx new file mode 100644 index 0000000..6ff82e4 --- /dev/null +++ b/makima/frontend/src/components/directives/DirectiveListItem.tsx @@ -0,0 +1,83 @@ +import type { DirectiveSummary } from "../../lib/api"; + +interface DirectiveListItemProps { + directive: DirectiveSummary; + selected: boolean; + onClick: () => void; + onArchive: () => void; +} + +export function DirectiveListItem({ directive, selected, onClick, onArchive }: DirectiveListItemProps) { + const progress = directive.totalSteps > 0 + ? Math.round((directive.completedSteps / directive.totalSteps) * 100) + : 0; + + const statusColor = { + draft: "text-[#556677]", + planning: "text-yellow-400", + active: "text-green-400", + paused: "text-yellow-400", + completed: "text-[#75aafc]", + archived: "text-[#556677]", + failed: "text-red-400", + }[directive.status] || "text-[#556677]"; + + const confidenceColor = { + green: "bg-green-500", + yellow: "bg-yellow-500", + red: "bg-red-500", + }[directive.currentConfidence !== null && directive.currentConfidence >= 0.8 + ? "green" + : directive.currentConfidence !== null && directive.currentConfidence >= 0.5 + ? "yellow" + : "red"] || "bg-[#556677]"; + + return ( + <div + onClick={onClick} + className={`p-3 cursor-pointer border-b border-[rgba(117,170,252,0.1)] hover:bg-[rgba(117,170,252,0.05)] ${ + selected ? "bg-[rgba(117,170,252,0.1)]" : "" + }`} + > + <div className="flex items-start justify-between gap-2"> + <div className="flex-1 min-w-0"> + <div className="font-mono text-sm text-[#dbe7ff] truncate"> + {directive.title || directive.goal.slice(0, 50)} + </div> + <div className="flex items-center gap-2 mt-1"> + <span className={`font-mono text-[10px] uppercase ${statusColor}`}> + {directive.status} + </span> + <span className="font-mono text-[10px] text-[#556677]"> + {directive.completedSteps}/{directive.totalSteps} steps + </span> + </div> + </div> + <div className="flex flex-col items-end gap-1"> + {directive.currentConfidence !== null && ( + <div className={`w-2 h-2 rounded-full ${confidenceColor}`} title={`Confidence: ${Math.round(directive.currentConfidence * 100)}%`} /> + )} + <button + onClick={(e) => { + e.stopPropagation(); + onArchive(); + }} + className="font-mono text-[10px] text-[#556677] hover:text-red-400" + > + Archive + </button> + </div> + </div> + + {/* Progress bar */} + {directive.totalSteps > 0 && ( + <div className="mt-2 h-1 bg-[rgba(117,170,252,0.1)] overflow-hidden"> + <div + className="h-full bg-[#75aafc] transition-all duration-300" + style={{ width: `${progress}%` }} + /> + </div> + )} + </div> + ); +} diff --git a/makima/frontend/src/components/directives/EvaluationsTab.tsx b/makima/frontend/src/components/directives/EvaluationsTab.tsx new file mode 100644 index 0000000..c1d65db --- /dev/null +++ b/makima/frontend/src/components/directives/EvaluationsTab.tsx @@ -0,0 +1,12 @@ +import type { DirectiveWithProgress } from "../../lib/api"; + +export function EvaluationsTab({ directive: _directive }: { directive: DirectiveWithProgress }) { + // TODO: Fetch evaluations separately + return ( + <div className="text-center py-8"> + <p className="font-mono text-sm text-[#556677]"> + Evaluations will be shown here after steps are evaluated + </p> + </div> + ); +} diff --git a/makima/frontend/src/components/directives/EventsTab.tsx b/makima/frontend/src/components/directives/EventsTab.tsx new file mode 100644 index 0000000..4dd739a --- /dev/null +++ b/makima/frontend/src/components/directives/EventsTab.tsx @@ -0,0 +1,77 @@ +import { useMemo } from "react"; +import type { DirectiveWithProgress } from "../../lib/api"; +import { useDirectiveEventSubscription } from "../../hooks/useDirectives"; + +export function EventsTab({ directive }: { directive: DirectiveWithProgress }) { + // Subscribe to real-time events via SSE + const { events: streamEvents, isConnected, error: sseError } = useDirectiveEventSubscription(directive.id); + + // Combine initial events with streamed events (avoiding duplicates) + const allEvents = useMemo(() => { + const eventMap = new Map(); + // Add initial events first + directive.recentEvents.forEach((e) => eventMap.set(e.id, e)); + // Add streamed events (will override any duplicates) + streamEvents.forEach((e) => eventMap.set(e.id, e)); + // Sort by created_at descending (most recent first) + return Array.from(eventMap.values()).sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + }, [directive.recentEvents, streamEvents]); + + return ( + <div className="space-y-4"> + {/* Connection status */} + <div className="flex items-center justify-between text-[10px] font-mono"> + <div className="flex items-center gap-2"> + <span className={isConnected ? "text-green-400" : "text-[#556677]"}> + {isConnected ? "\u25CF Live" : "\u25CB Connecting..."} + </span> + {sseError && <span className="text-red-400">{sseError}</span>} + </div> + <span className="text-[#556677]">{allEvents.length} events</span> + </div> + + {/* Event list */} + {allEvents.length === 0 ? ( + <div className="text-center py-8"> + <p className="font-mono text-sm text-[#556677]">No events yet</p> + </div> + ) : ( + <div className="space-y-2"> + {allEvents.map((event) => { + const severityColors: Record<string, string> = { + info: "text-[#75aafc]", + warning: "text-yellow-400", + error: "text-red-400", + critical: "text-red-600", + }; + const severityColor = severityColors[event.severity] || "text-[#556677]"; + + return ( + <div + key={event.id} + className="p-3 bg-[rgba(117,170,252,0.02)] border border-[rgba(117,170,252,0.1)]" + > + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <span className={`font-mono text-xs ${severityColor}`}>{event.eventType}</span> + <span className="font-mono text-[10px] text-[#556677]">{event.actorType}</span> + </div> + <span className="font-mono text-[10px] text-[#556677]"> + {new Date(event.createdAt).toLocaleString()} + </span> + </div> + {event.eventData != null && ( + <pre className="font-mono text-[10px] text-[#556677] mt-1 overflow-x-auto"> + {JSON.stringify(event.eventData, null, 2)} + </pre> + )} + </div> + ); + })} + </div> + )} + </div> + ); +} diff --git a/makima/frontend/src/components/directives/OverviewTab.tsx b/makima/frontend/src/components/directives/OverviewTab.tsx new file mode 100644 index 0000000..41cd7dc --- /dev/null +++ b/makima/frontend/src/components/directives/OverviewTab.tsx @@ -0,0 +1,73 @@ +import type { DirectiveWithProgress } from "../../lib/api"; + +export function OverviewTab({ directive }: { directive: DirectiveWithProgress }) { + return ( + <div className="space-y-6"> + {/* Goal */} + <div> + <h3 className="font-mono text-xs text-[#75aafc] uppercase mb-2">Goal</h3> + <p className="font-mono text-sm text-[#dbe7ff] whitespace-pre-wrap"> + {directive.goal} + </p> + </div> + + {/* Progress */} + <div> + <h3 className="font-mono text-xs text-[#75aafc] uppercase mb-2">Progress</h3> + <div className="grid grid-cols-3 gap-4"> + <div className="p-3 bg-[rgba(117,170,252,0.05)] border border-[rgba(117,170,252,0.15)]"> + <div className="font-mono text-2xl text-[#dbe7ff]"> + {directive.chain?.completedSteps || 0} + </div> + <div className="font-mono text-[10px] text-[#556677] uppercase">Completed Steps</div> + </div> + <div className="p-3 bg-[rgba(117,170,252,0.05)] border border-[rgba(117,170,252,0.15)]"> + <div className="font-mono text-2xl text-[#dbe7ff]"> + {directive.chain?.totalSteps || 0} + </div> + <div className="font-mono text-[10px] text-[#556677] uppercase">Total Steps</div> + </div> + <div className="p-3 bg-[rgba(117,170,252,0.05)] border border-[rgba(117,170,252,0.15)]"> + <div className="font-mono text-2xl text-[#dbe7ff]"> + {directive.chain?.currentConfidence != null + ? `${Math.round((directive.chain?.currentConfidence ?? 0) * 100)}%` + : "-"} + </div> + <div className="font-mono text-[10px] text-[#556677] uppercase">Confidence</div> + </div> + </div> + </div> + + {/* Configuration */} + <div> + <h3 className="font-mono text-xs text-[#75aafc] uppercase mb-2">Configuration</h3> + <div className="grid grid-cols-2 gap-2 text-sm"> + <div className="flex justify-between"> + <span className="font-mono text-[#556677]">Autonomy Level</span> + <span className="font-mono text-[#dbe7ff]">{directive.autonomyLevel}</span> + </div> + <div className="flex justify-between"> + <span className="font-mono text-[#556677]">Max Rework Cycles</span> + <span className="font-mono text-[#dbe7ff]">{directive.maxReworkCycles}</span> + </div> + <div className="flex justify-between"> + <span className="font-mono text-[#556677]">Green Threshold</span> + <span className="font-mono text-[#dbe7ff]">{directive.confidenceThresholdGreen}</span> + </div> + <div className="flex justify-between"> + <span className="font-mono text-[#556677]">Yellow Threshold</span> + <span className="font-mono text-[#dbe7ff]">{directive.confidenceThresholdYellow}</span> + </div> + </div> + </div> + + {/* Repository */} + {directive.repositoryUrl && ( + <div> + <h3 className="font-mono text-xs text-[#75aafc] uppercase mb-2">Repository</h3> + <p className="font-mono text-sm text-[#9bc3ff]">{directive.repositoryUrl}</p> + </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..e54f5eb --- /dev/null +++ b/makima/frontend/src/components/directives/StepNode.tsx @@ -0,0 +1,87 @@ +import { Handle, Position } from "@xyflow/react"; +import type { StepStatus, ConfidenceLevel, DirectiveGraphNode } from "../../lib/api"; + +// Step status colors for both list and DAG views +export const stepStatusStyles: Record<StepStatus, { border: string; bg: string; text: string }> = { + pending: { border: "#556677", bg: "#556677", text: "#556677" }, + ready: { border: "#3b82f6", bg: "#3b82f6", text: "#3b82f6" }, + running: { border: "#22c55e", bg: "#22c55e", text: "#22c55e" }, + evaluating: { border: "#eab308", bg: "#eab308", text: "#eab308" }, + passed: { border: "#75aafc", bg: "#75aafc", text: "#75aafc" }, + failed: { border: "#ef4444", bg: "#ef4444", text: "#ef4444" }, + rework: { border: "#f97316", bg: "#f97316", text: "#f97316" }, + skipped: { border: "#556677", bg: "#556677", text: "#556677" }, + blocked: { border: "#ef4444", bg: "#ef4444", text: "#ef4444" }, +}; + +// Confidence level colors +export const confidenceColors: Record<ConfidenceLevel, string> = { + green: "#22c55e", + yellow: "#eab308", + red: "#ef4444", +}; + +// Node dimensions +export const NODE_WIDTH = 180; +export const NODE_HEIGHT = 70; + +// Custom node component for steps +export function StepNodeComponent({ data }: { data: DirectiveGraphNode & { selected?: boolean } }) { + const styles = stepStatusStyles[data.status] || stepStatusStyles.pending; + const isRunning = data.status === "running" || data.status === "evaluating"; + + return ( + <div + className={`rounded-lg border-2 bg-[#0a1628] overflow-hidden ${ + isRunning ? "animate-pulse" : "" + }`} + style={{ + width: NODE_WIDTH, + height: NODE_HEIGHT, + borderColor: styles.border, + borderStyle: data.status === "pending" ? "dashed" : "solid", + }} + > + <Handle + type="target" + position={Position.Top} + className="!bg-[#75aafc] !w-3 !h-3 !border-2 !border-[#0a1628]" + /> + + {/* Status indicator bar */} + <div className="h-1.5" style={{ backgroundColor: styles.bg }} /> + + {/* Content */} + <div className="p-2"> + <div className="flex items-center justify-between mb-1"> + <span className="font-mono text-xs text-[#dbe7ff] truncate flex-1">{data.name}</span> + {data.confidenceScore !== null && data.confidenceLevel && ( + <div + className="w-2 h-2 rounded-full flex-shrink-0 ml-1" + style={{ backgroundColor: confidenceColors[data.confidenceLevel] }} + title={`Confidence: ${Math.round(data.confidenceScore * 100)}%`} + /> + )} + </div> + <div className="flex items-center justify-between"> + <span + className="font-mono text-[10px] uppercase px-1.5 py-0.5 rounded" + style={{ + color: styles.text, + backgroundColor: `${styles.bg}20`, + }} + > + {data.status} + </span> + <span className="font-mono text-[10px] text-[#8b949e]">{data.stepType}</span> + </div> + </div> + + <Handle + type="source" + position={Position.Bottom} + className="!bg-[#f59e0b] !w-3 !h-3 !border-2 !border-[#0a1628]" + /> + </div> + ); +} diff --git a/makima/frontend/src/components/directives/VerifiersTab.tsx b/makima/frontend/src/components/directives/VerifiersTab.tsx new file mode 100644 index 0000000..cfcfdd8 --- /dev/null +++ b/makima/frontend/src/components/directives/VerifiersTab.tsx @@ -0,0 +1,12 @@ +import type { DirectiveWithProgress } from "../../lib/api"; + +export function VerifiersTab({ directive: _directive }: { directive: DirectiveWithProgress }) { + // TODO: Fetch verifiers separately + return ( + <div className="text-center py-8"> + <p className="font-mono text-sm text-[#556677]"> + Verifiers will be shown here. Use auto-detect to find available verifiers. + </p> + </div> + ); +} diff --git a/makima/frontend/src/components/directives/index.ts b/makima/frontend/src/components/directives/index.ts new file mode 100644 index 0000000..718b1f2 --- /dev/null +++ b/makima/frontend/src/components/directives/index.ts @@ -0,0 +1,11 @@ +export { DirectiveList } from "./DirectiveList"; +export { DirectiveListItem } from "./DirectiveListItem"; +export { DirectiveDetail } from "./DirectiveDetail"; +export { OverviewTab } from "./OverviewTab"; +export { ChainTab } from "./ChainTab"; +export { EventsTab } from "./EventsTab"; +export { EvaluationsTab } from "./EvaluationsTab"; +export { ApprovalsTab } from "./ApprovalsTab"; +export { VerifiersTab } from "./VerifiersTab"; +export { CreateDirectiveModal } from "./CreateDirectiveModal"; +export { StepNodeComponent, stepStatusStyles, confidenceColors, NODE_WIDTH, NODE_HEIGHT } from "./StepNode"; diff --git a/makima/frontend/src/hooks/useDirectiveDetail.ts b/makima/frontend/src/hooks/useDirectiveDetail.ts new file mode 100644 index 0000000..1167242 --- /dev/null +++ b/makima/frontend/src/hooks/useDirectiveDetail.ts @@ -0,0 +1,125 @@ +import { useState, useCallback, useEffect } from "react"; +import { + getDirective, + getDirectiveGraph, + startDirective, + pauseDirective, + resumeDirective, + stopDirective, + type DirectiveWithProgress, + type DirectiveGraphResponse, + type StartDirectiveResponse, +} from "../lib/api"; + +interface UseDirectiveDetailResult { + directive: DirectiveWithProgress | null; + graph: DirectiveGraphResponse | null; + loading: boolean; + error: string | null; + refresh: () => Promise<void>; + start: () => Promise<StartDirectiveResponse | null>; + pause: () => Promise<boolean>; + resume: () => Promise<boolean>; + stop: () => Promise<boolean>; +} + +export function useDirectiveDetail(directiveId: string | undefined): UseDirectiveDetailResult { + const [directive, setDirective] = useState<DirectiveWithProgress | null>(null); + const [graph, setGraph] = useState<DirectiveGraphResponse | null>(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + + const fetchDetail = useCallback(async () => { + if (!directiveId) { + setDirective(null); + setGraph(null); + return; + } + + setLoading(true); + setError(null); + try { + const [d, g] = await Promise.all([ + getDirective(directiveId), + getDirectiveGraph(directiveId).catch(() => null), + ]); + setDirective(d); + setGraph(g); + } catch (err) { + console.error("Failed to fetch directive detail:", err); + setError(err instanceof Error ? err.message : "Failed to fetch directive"); + setDirective(null); + setGraph(null); + } finally { + setLoading(false); + } + }, [directiveId]); + + useEffect(() => { + fetchDetail(); + }, [fetchDetail]); + + const start = useCallback(async (): Promise<StartDirectiveResponse | null> => { + if (!directiveId) return null; + try { + const response = await startDirective(directiveId); + await fetchDetail(); + return response; + } catch (err) { + console.error("Failed to start directive:", err); + setError(err instanceof Error ? err.message : "Failed to start directive"); + return null; + } + }, [directiveId, fetchDetail]); + + const pause = useCallback(async (): Promise<boolean> => { + if (!directiveId) return false; + try { + await pauseDirective(directiveId); + await fetchDetail(); + return true; + } catch (err) { + console.error("Failed to pause directive:", err); + setError(err instanceof Error ? err.message : "Failed to pause directive"); + return false; + } + }, [directiveId, fetchDetail]); + + const resume = useCallback(async (): Promise<boolean> => { + if (!directiveId) return false; + try { + await resumeDirective(directiveId); + await fetchDetail(); + return true; + } catch (err) { + console.error("Failed to resume directive:", err); + setError(err instanceof Error ? err.message : "Failed to resume directive"); + return false; + } + }, [directiveId, fetchDetail]); + + const stop = useCallback(async (): Promise<boolean> => { + if (!directiveId) return false; + try { + await stopDirective(directiveId); + await fetchDetail(); + return true; + } catch (err) { + console.error("Failed to stop directive:", err); + setError(err instanceof Error ? err.message : "Failed to stop directive"); + return false; + } + }, [directiveId, fetchDetail]); + + return { + directive, + graph, + loading, + error, + refresh: fetchDetail, + start, + pause, + resume, + stop, + }; +} diff --git a/makima/frontend/src/routes/directives.tsx b/makima/frontend/src/routes/directives.tsx index 35e5703..90f0854 100644 --- a/makima/frontend/src/routes/directives.tsx +++ b/makima/frontend/src/routes/directives.tsx @@ -1,31 +1,17 @@ -import { useState, useCallback, useEffect, useMemo } from "react"; +import { useState, useCallback, useEffect } from "react"; import { useParams, useNavigate } from "react-router"; -import { - ReactFlow, - Edge, - Controls, - Background, - Handle, - Position, - BackgroundVariant, - MarkerType, -} from "@xyflow/react"; -import "@xyflow/react/dist/style.css"; import { Masthead } from "../components/Masthead"; -import { useDirectives, useDirectiveEventSubscription } from "../hooks/useDirectives"; +import { useDirectives } from "../hooks/useDirectives"; +import { useDirectiveDetail } from "../hooks/useDirectiveDetail"; import { useAuth } from "../contexts/AuthContext"; import type { DirectiveSummary, - DirectiveWithProgress, - DirectiveGraphResponse, - DirectiveGraphNode, CreateDirectiveRequest, - RepositoryHistoryEntry, AutonomyLevel, - StepStatus, - ConfidenceLevel, } from "../lib/api"; -import { getRepositorySuggestions } from "../lib/api"; +import { DirectiveList } from "../components/directives/DirectiveList"; +import { DirectiveDetail } from "../components/directives/DirectiveDetail"; +import { CreateDirectiveModal } from "../components/directives/CreateDirectiveModal"; export default function DirectivesPage() { const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); @@ -67,33 +53,20 @@ function DirectivesPageContent() { error, createNewDirective, archiveExistingDirective, - getDirectiveById, - getGraph, - start, - pause, - resume, - stop, } = useDirectives(); - const [directiveDetail, setDirectiveDetail] = useState<DirectiveWithProgress | null>(null); - const [directiveGraph, setDirectiveGraph] = useState<DirectiveGraphResponse | null>(null); - const [detailLoading, setDetailLoading] = useState(false); - const [isCreating, setIsCreating] = useState(false); + const { + directive: directiveDetail, + graph: directiveGraph, + loading: detailLoading, + refresh: refreshDetail, + start: handleStart, + pause: handlePause, + resume: handleResume, + stop: handleStop, + } = useDirectiveDetail(id); - // Load directive detail when ID changes - useEffect(() => { - if (id) { - setDetailLoading(true); - Promise.all([getDirectiveById(id), getGraph(id).catch(() => null)]).then(([directive, graph]) => { - setDirectiveDetail(directive); - setDirectiveGraph(graph); - setDetailLoading(false); - }); - } else { - setDirectiveDetail(null); - setDirectiveGraph(null); - } - }, [id, getDirectiveById, getGraph]); + const [isCreating, setIsCreating] = useState(false); const handleSelect = useCallback( (directiveId: string) => { @@ -147,45 +120,6 @@ function DirectivesPageContent() { [archiveExistingDirective, id, navigate] ); - const handleRefresh = useCallback(async () => { - if (id) { - const [directive, graph] = await Promise.all([ - getDirectiveById(id), - getGraph(id).catch(() => null), - ]); - setDirectiveDetail(directive); - setDirectiveGraph(graph); - } - }, [id, getDirectiveById, getGraph]); - - const handleStart = useCallback(async () => { - if (id) { - await start(id); - handleRefresh(); - } - }, [id, start, handleRefresh]); - - const handlePause = useCallback(async () => { - if (id) { - await pause(id); - handleRefresh(); - } - }, [id, pause, handleRefresh]); - - const handleResume = useCallback(async () => { - if (id) { - await resume(id); - handleRefresh(); - } - }, [id, resume, handleRefresh]); - - const handleStop = useCallback(async () => { - if (id) { - await stop(id); - handleRefresh(); - } - }, [id, stop, handleRefresh]); - return ( <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> <Masthead showNav /> @@ -222,7 +156,7 @@ function DirectivesPageContent() { graph={directiveGraph} loading={detailLoading} onBack={handleBack} - onRefresh={handleRefresh} + onRefresh={refreshDetail} onStart={handleStart} onPause={handlePause} onResume={handleResume} @@ -248,1007 +182,3 @@ function DirectivesPageContent() { </div> ); } - -// ============================================================================= -// Directive List Component -// ============================================================================= - -interface DirectiveListProps { - directives: DirectiveSummary[]; - loading: boolean; - onSelect: (id: string) => void; - onCreate: () => void; - selectedId?: string; - onArchive: (directive: DirectiveSummary) => void; -} - -function DirectiveList({ - directives, - loading, - onSelect, - onCreate, - selectedId, - onArchive, -}: DirectiveListProps) { - const [filter, setFilter] = useState<"all" | "active" | "completed" | "failed">("all"); - - const filteredDirectives = directives.filter((d) => { - if (filter === "all") return true; - if (filter === "active") return ["draft", "planning", "active", "paused"].includes(d.status); - if (filter === "completed") return d.status === "completed"; - if (filter === "failed") return d.status === "failed"; - return true; - }); - - return ( - <div className="panel h-full flex flex-col overflow-hidden"> - <div className="flex items-center justify-between p-3 border-b border-[rgba(117,170,252,0.15)]"> - <h2 className="font-mono text-sm text-[#75aafc] uppercase">Directives</h2> - <button - onClick={onCreate} - className="px-3 py-1 font-mono text-[10px] text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase" - > - + New - </button> - </div> - - {/* Filters */} - <div className="flex gap-1 p-2 border-b border-[rgba(117,170,252,0.1)]"> - {(["all", "active", "completed", "failed"] as const).map((f) => ( - <button - key={f} - onClick={() => setFilter(f)} - className={`px-2 py-1 font-mono text-[10px] uppercase ${ - filter === f - ? "text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3]" - : "text-[#556677] hover:text-[#9bc3ff]" - }`} - > - {f} - </button> - ))} - </div> - - {/* List */} - <div className="flex-1 overflow-y-auto"> - {loading ? ( - <div className="p-4 text-center"> - <p className="font-mono text-xs text-[#556677]">Loading...</p> - </div> - ) : filteredDirectives.length === 0 ? ( - <div className="p-4 text-center"> - <p className="font-mono text-xs text-[#556677]">No directives found</p> - </div> - ) : ( - filteredDirectives.map((d) => ( - <DirectiveListItem - key={d.id} - directive={d} - selected={d.id === selectedId} - onClick={() => onSelect(d.id)} - onArchive={() => onArchive(d)} - /> - )) - )} - </div> - </div> - ); -} - -interface DirectiveListItemProps { - directive: DirectiveSummary; - selected: boolean; - onClick: () => void; - onArchive: () => void; -} - -function DirectiveListItem({ directive, selected, onClick, onArchive }: DirectiveListItemProps) { - const progress = directive.totalSteps > 0 - ? Math.round((directive.completedSteps / directive.totalSteps) * 100) - : 0; - - const statusColor = { - draft: "text-[#556677]", - planning: "text-yellow-400", - active: "text-green-400", - paused: "text-yellow-400", - completed: "text-[#75aafc]", - archived: "text-[#556677]", - failed: "text-red-400", - }[directive.status] || "text-[#556677]"; - - const confidenceColor = { - green: "bg-green-500", - yellow: "bg-yellow-500", - red: "bg-red-500", - }[directive.currentConfidence !== null && directive.currentConfidence >= 0.8 - ? "green" - : directive.currentConfidence !== null && directive.currentConfidence >= 0.5 - ? "yellow" - : "red"] || "bg-[#556677]"; - - return ( - <div - onClick={onClick} - className={`p-3 cursor-pointer border-b border-[rgba(117,170,252,0.1)] hover:bg-[rgba(117,170,252,0.05)] ${ - selected ? "bg-[rgba(117,170,252,0.1)]" : "" - }`} - > - <div className="flex items-start justify-between gap-2"> - <div className="flex-1 min-w-0"> - <div className="font-mono text-sm text-[#dbe7ff] truncate"> - {directive.title || directive.goal.slice(0, 50)} - </div> - <div className="flex items-center gap-2 mt-1"> - <span className={`font-mono text-[10px] uppercase ${statusColor}`}> - {directive.status} - </span> - <span className="font-mono text-[10px] text-[#556677]"> - {directive.completedSteps}/{directive.totalSteps} steps - </span> - </div> - </div> - <div className="flex flex-col items-end gap-1"> - {directive.currentConfidence !== null && ( - <div className={`w-2 h-2 rounded-full ${confidenceColor}`} title={`Confidence: ${Math.round(directive.currentConfidence * 100)}%`} /> - )} - <button - onClick={(e) => { - e.stopPropagation(); - onArchive(); - }} - className="font-mono text-[10px] text-[#556677] hover:text-red-400" - > - Archive - </button> - </div> - </div> - - {/* Progress bar */} - {directive.totalSteps > 0 && ( - <div className="mt-2 h-1 bg-[rgba(117,170,252,0.1)] overflow-hidden"> - <div - className="h-full bg-[#75aafc] transition-all duration-300" - style={{ width: `${progress}%` }} - /> - </div> - )} - </div> - ); -} - -// ============================================================================= -// Directive Detail Component -// ============================================================================= - -interface DirectiveDetailProps { - directive: DirectiveWithProgress; - graph: DirectiveGraphResponse | null; - loading: boolean; - onBack: () => void; - onRefresh: () => void; - onStart: () => void; - onPause: () => void; - onResume: () => void; - onStop: () => void; -} - -function DirectiveDetail({ - directive, - graph, - loading, - onBack, - onRefresh, - onStart, - onPause, - onResume, - onStop, -}: DirectiveDetailProps) { - const [activeTab, setActiveTab] = useState<"overview" | "chain" | "events" | "evaluations" | "approvals" | "verifiers">("overview"); - - if (loading) { - return ( - <div className="panel h-full flex items-center justify-center"> - <p className="font-mono text-xs text-[#556677]">Loading...</p> - </div> - ); - } - - const statusColor = { - draft: "text-[#556677] bg-[#556677]/10 border-[#556677]/30", - planning: "text-yellow-400 bg-yellow-400/10 border-yellow-400/30", - active: "text-green-400 bg-green-400/10 border-green-400/30", - paused: "text-yellow-400 bg-yellow-400/10 border-yellow-400/30", - completed: "text-[#75aafc] bg-[#75aafc]/10 border-[#75aafc]/30", - archived: "text-[#556677] bg-[#556677]/10 border-[#556677]/30", - failed: "text-red-400 bg-red-400/10 border-red-400/30", - }[directive.status] || "text-[#556677] bg-[#556677]/10 border-[#556677]/30"; - - return ( - <div className="panel h-full flex flex-col overflow-hidden"> - {/* Header */} - <div className="p-3 border-b border-[rgba(117,170,252,0.15)]"> - <div className="flex items-center justify-between"> - <div className="flex items-center gap-3"> - <button - onClick={onBack} - className="font-mono text-xs text-[#556677] hover:text-[#9bc3ff]" - > - ← Back - </button> - <h2 className="font-mono text-sm text-[#dbe7ff]"> - {directive.title || directive.goal.slice(0, 50)} - </h2> - <span className={`px-2 py-0.5 font-mono text-[10px] uppercase border ${statusColor}`}> - {directive.status} - </span> - </div> - <div className="flex items-center gap-2"> - {directive.status === "draft" && ( - <button - onClick={onStart} - className="px-3 py-1 font-mono text-[10px] text-[#dbe7ff] bg-green-700 border border-green-600 hover:bg-green-600 uppercase" - > - Start - </button> - )} - {directive.status === "active" && ( - <button - onClick={onPause} - className="px-3 py-1 font-mono text-[10px] text-[#dbe7ff] bg-yellow-700 border border-yellow-600 hover:bg-yellow-600 uppercase" - > - Pause - </button> - )} - {directive.status === "paused" && ( - <button - onClick={onResume} - className="px-3 py-1 font-mono text-[10px] text-[#dbe7ff] bg-green-700 border border-green-600 hover:bg-green-600 uppercase" - > - Resume - </button> - )} - {["active", "paused"].includes(directive.status) && ( - <button - onClick={onStop} - className="px-3 py-1 font-mono text-[10px] text-[#dbe7ff] bg-red-700 border border-red-600 hover:bg-red-600 uppercase" - > - Stop - </button> - )} - <button - onClick={onRefresh} - className="px-3 py-1 font-mono text-[10px] text-[#9bc3ff] hover:text-[#dbe7ff]" - > - Refresh - </button> - </div> - </div> - </div> - - {/* Tabs */} - <div className="flex gap-1 p-2 border-b border-[rgba(117,170,252,0.1)]"> - {(["overview", "chain", "events", "evaluations", "approvals", "verifiers"] as const).map((tab) => ( - <button - key={tab} - onClick={() => setActiveTab(tab)} - className={`px-3 py-1.5 font-mono text-[10px] uppercase ${ - activeTab === tab - ? "text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3]" - : "text-[#556677] hover:text-[#9bc3ff]" - }`} - > - {tab} - {tab === "approvals" && directive.pendingApprovals.length > 0 && ( - <span className="ml-1 px-1 bg-yellow-500 text-black rounded text-[8px]"> - {directive.pendingApprovals.length} - </span> - )} - </button> - ))} - </div> - - {/* Tab Content */} - <div className="flex-1 overflow-y-auto p-4"> - {activeTab === "overview" && ( - <OverviewTab directive={directive} /> - )} - {activeTab === "chain" && ( - <ChainTab directive={directive} graph={graph} /> - )} - {activeTab === "events" && ( - <EventsTab directive={directive} /> - )} - {activeTab === "evaluations" && ( - <EvaluationsTab directive={directive} /> - )} - {activeTab === "approvals" && ( - <ApprovalsTab directive={directive} onRefresh={onRefresh} /> - )} - {activeTab === "verifiers" && ( - <VerifiersTab directive={directive} /> - )} - </div> - </div> - ); -} - -// ============================================================================= -// Tab Components -// ============================================================================= - -function OverviewTab({ directive }: { directive: DirectiveWithProgress }) { - return ( - <div className="space-y-6"> - {/* Goal */} - <div> - <h3 className="font-mono text-xs text-[#75aafc] uppercase mb-2">Goal</h3> - <p className="font-mono text-sm text-[#dbe7ff] whitespace-pre-wrap"> - {directive.goal} - </p> - </div> - - {/* Progress */} - <div> - <h3 className="font-mono text-xs text-[#75aafc] uppercase mb-2">Progress</h3> - <div className="grid grid-cols-3 gap-4"> - <div className="p-3 bg-[rgba(117,170,252,0.05)] border border-[rgba(117,170,252,0.15)]"> - <div className="font-mono text-2xl text-[#dbe7ff]"> - {directive.chain?.completedSteps || 0} - </div> - <div className="font-mono text-[10px] text-[#556677] uppercase">Completed Steps</div> - </div> - <div className="p-3 bg-[rgba(117,170,252,0.05)] border border-[rgba(117,170,252,0.15)]"> - <div className="font-mono text-2xl text-[#dbe7ff]"> - {directive.chain?.totalSteps || 0} - </div> - <div className="font-mono text-[10px] text-[#556677] uppercase">Total Steps</div> - </div> - <div className="p-3 bg-[rgba(117,170,252,0.05)] border border-[rgba(117,170,252,0.15)]"> - <div className="font-mono text-2xl text-[#dbe7ff]"> - {directive.chain?.currentConfidence != null - ? `${Math.round((directive.chain?.currentConfidence ?? 0) * 100)}%` - : "-"} - </div> - <div className="font-mono text-[10px] text-[#556677] uppercase">Confidence</div> - </div> - </div> - </div> - - {/* Configuration */} - <div> - <h3 className="font-mono text-xs text-[#75aafc] uppercase mb-2">Configuration</h3> - <div className="grid grid-cols-2 gap-2 text-sm"> - <div className="flex justify-between"> - <span className="font-mono text-[#556677]">Autonomy Level</span> - <span className="font-mono text-[#dbe7ff]">{directive.autonomyLevel}</span> - </div> - <div className="flex justify-between"> - <span className="font-mono text-[#556677]">Max Rework Cycles</span> - <span className="font-mono text-[#dbe7ff]">{directive.maxReworkCycles}</span> - </div> - <div className="flex justify-between"> - <span className="font-mono text-[#556677]">Green Threshold</span> - <span className="font-mono text-[#dbe7ff]">{directive.confidenceThresholdGreen}</span> - </div> - <div className="flex justify-between"> - <span className="font-mono text-[#556677]">Yellow Threshold</span> - <span className="font-mono text-[#dbe7ff]">{directive.confidenceThresholdYellow}</span> - </div> - </div> - </div> - - {/* Repository */} - {directive.repositoryUrl && ( - <div> - <h3 className="font-mono text-xs text-[#75aafc] uppercase mb-2">Repository</h3> - <p className="font-mono text-sm text-[#9bc3ff]">{directive.repositoryUrl}</p> - </div> - )} - </div> - ); -} - -// Step status colors for both list and DAG views -const stepStatusStyles: Record<StepStatus, { border: string; bg: string; text: string }> = { - pending: { border: "#556677", bg: "#556677", text: "#556677" }, - ready: { border: "#3b82f6", bg: "#3b82f6", text: "#3b82f6" }, - running: { border: "#22c55e", bg: "#22c55e", text: "#22c55e" }, - evaluating: { border: "#eab308", bg: "#eab308", text: "#eab308" }, - passed: { border: "#75aafc", bg: "#75aafc", text: "#75aafc" }, - failed: { border: "#ef4444", bg: "#ef4444", text: "#ef4444" }, - rework: { border: "#f97316", bg: "#f97316", text: "#f97316" }, - skipped: { border: "#556677", bg: "#556677", text: "#556677" }, - blocked: { border: "#ef4444", bg: "#ef4444", text: "#ef4444" }, -}; - -// Confidence level colors -const confidenceColors: Record<ConfidenceLevel, string> = { - green: "#22c55e", - yellow: "#eab308", - red: "#ef4444", -}; - -// Node dimensions -const NODE_WIDTH = 180; -const NODE_HEIGHT = 70; - -// Custom node component for steps -function StepNodeComponent({ data }: { data: DirectiveGraphNode & { selected?: boolean } }) { - const styles = stepStatusStyles[data.status] || stepStatusStyles.pending; - const isRunning = data.status === "running" || data.status === "evaluating"; - - return ( - <div - className={`rounded-lg border-2 bg-[#0a1628] overflow-hidden ${ - isRunning ? "animate-pulse" : "" - }`} - style={{ - width: NODE_WIDTH, - height: NODE_HEIGHT, - borderColor: styles.border, - borderStyle: data.status === "pending" ? "dashed" : "solid", - }} - > - <Handle - type="target" - position={Position.Top} - className="!bg-[#75aafc] !w-3 !h-3 !border-2 !border-[#0a1628]" - /> - - {/* Status indicator bar */} - <div className="h-1.5" style={{ backgroundColor: styles.bg }} /> - - {/* Content */} - <div className="p-2"> - <div className="flex items-center justify-between mb-1"> - <span className="font-mono text-xs text-[#dbe7ff] truncate flex-1">{data.name}</span> - {data.confidenceScore !== null && data.confidenceLevel && ( - <div - className="w-2 h-2 rounded-full flex-shrink-0 ml-1" - style={{ backgroundColor: confidenceColors[data.confidenceLevel] }} - title={`Confidence: ${Math.round(data.confidenceScore * 100)}%`} - /> - )} - </div> - <div className="flex items-center justify-between"> - <span - className="font-mono text-[10px] uppercase px-1.5 py-0.5 rounded" - style={{ - color: styles.text, - backgroundColor: `${styles.bg}20`, - }} - > - {data.status} - </span> - <span className="font-mono text-[10px] text-[#8b949e]">{data.stepType}</span> - </div> - </div> - - <Handle - type="source" - position={Position.Bottom} - className="!bg-[#f59e0b] !w-3 !h-3 !border-2 !border-[#0a1628]" - /> - </div> - ); -} - -// Node types for React Flow -const nodeTypes = { - step: StepNodeComponent, -}; - -function ChainTab({ directive, graph }: { directive: DirectiveWithProgress; graph: DirectiveGraphResponse | null }) { - const [viewMode, setViewMode] = useState<"dag" | "list">("dag"); - - // Convert graph to React Flow nodes and edges - const { nodes, edges } = useMemo(() => { - if (!graph || !graph.nodes.length) { - // Fallback: generate positions from directive.steps - const stepNodes = directive.steps.map((step, index) => ({ - id: step.id, - type: "step" as const, - position: { - x: (index % 3) * 220 + 50, - y: Math.floor(index / 3) * 120 + 50, - }, - data: { - id: step.id, - name: step.name, - stepType: step.stepType, - status: step.status, - confidenceScore: step.confidenceScore, - confidenceLevel: step.confidenceLevel, - contractId: step.contractId, - editorX: null, - editorY: null, - }, - })); - - // Build edges from dependencies - const stepEdges: Edge[] = []; - directive.steps.forEach((step) => { - (step.dependsOn ?? []).forEach((depName) => { - const depStep = directive.steps.find((s) => s.name === depName); - if (depStep) { - stepEdges.push({ - id: `${depStep.id}-${step.id}`, - source: depStep.id, - target: step.id, - type: "smoothstep", - markerEnd: { type: MarkerType.ArrowClosed, color: "#556677" }, - style: { stroke: "#556677", strokeWidth: 2 }, - }); - } - }); - }); - - return { nodes: stepNodes, edges: stepEdges }; - } - - // Use graph data - const graphNodes = graph.nodes.map((node) => ({ - id: node.id, - type: "step" as const, - position: { - x: node.editorX ?? 50, - y: node.editorY ?? 50, - }, - data: { ...node }, - })); - - const graphEdges: Edge[] = graph.edges.map((edge) => ({ - id: `${edge.source}-${edge.target}`, - source: edge.source, - target: edge.target, - type: "smoothstep", - markerEnd: { type: MarkerType.ArrowClosed, color: "#556677" }, - style: { stroke: "#556677", strokeWidth: 2 }, - })); - - return { nodes: graphNodes, edges: graphEdges }; - }, [graph, directive.steps]); - - if (!directive.chain) { - return ( - <div className="text-center py-8"> - <p className="font-mono text-sm text-[#556677]"> - No chain generated yet. Start the directive to generate a chain. - </p> - </div> - ); - } - - return ( - <div className="space-y-4"> - {/* Chain info header */} - <div className="flex items-center justify-between p-3 bg-[rgba(117,170,252,0.05)] border border-[rgba(117,170,252,0.15)]"> - <div> - <div className="font-mono text-sm text-[#dbe7ff]">{directive.chain.name}</div> - <div className="font-mono text-[10px] text-[#556677]">Generation {directive.chain.generation}</div> - </div> - <div className="flex items-center gap-4"> - <div className="font-mono text-xs text-[#556677]"> - {directive.chain.completedSteps}/{directive.chain.totalSteps} steps - </div> - {/* View toggle */} - <div className="flex border border-[rgba(117,170,252,0.2)]"> - <button - onClick={() => setViewMode("dag")} - className={`px-2 py-1 font-mono text-[10px] uppercase ${ - viewMode === "dag" - ? "bg-[rgba(117,170,252,0.2)] text-[#75aafc]" - : "text-[#556677] hover:text-[#75aafc]" - }`} - > - DAG - </button> - <button - onClick={() => setViewMode("list")} - className={`px-2 py-1 font-mono text-[10px] uppercase ${ - viewMode === "list" - ? "bg-[rgba(117,170,252,0.2)] text-[#75aafc]" - : "text-[#556677] hover:text-[#75aafc]" - }`} - > - List - </button> - </div> - </div> - </div> - - {viewMode === "dag" ? ( - /* DAG visualization */ - <div className="h-[400px] border border-[rgba(117,170,252,0.1)] rounded bg-[#050d18]"> - {directive.steps.length === 0 ? ( - <div className="flex items-center justify-center h-full"> - <p className="font-mono text-sm text-[#556677]">No steps in chain</p> - </div> - ) : ( - <ReactFlow - nodes={nodes} - edges={edges} - nodeTypes={nodeTypes} - fitView - fitViewOptions={{ padding: 0.2 }} - minZoom={0.5} - maxZoom={1.5} - defaultEdgeOptions={{ - type: "smoothstep", - style: { stroke: "#556677", strokeWidth: 2 }, - }} - > - <Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#1a2a3a" /> - <Controls className="!bg-[#0a1628] !border-[rgba(117,170,252,0.2)]" /> - </ReactFlow> - )} - </div> - ) : ( - /* List view */ - <div className="space-y-2"> - <h3 className="font-mono text-xs text-[#75aafc] uppercase">Steps</h3> - {directive.steps.length === 0 ? ( - <p className="font-mono text-sm text-[#556677]">No steps in chain</p> - ) : ( - directive.steps.map((step) => { - const styles = stepStatusStyles[step.status] || stepStatusStyles.pending; - - return ( - <div - key={step.id} - className="p-3 bg-[rgba(117,170,252,0.02)] border border-[rgba(117,170,252,0.1)]" - > - <div className="flex items-center justify-between"> - <div className="flex items-center gap-2"> - <span className="font-mono text-sm text-[#dbe7ff]">{step.name}</span> - <span - className="px-1.5 py-0.5 font-mono text-[10px] uppercase border" - style={{ color: styles.text, borderColor: `${styles.border}50` }} - > - {step.status} - </span> - {step.confidenceScore !== null && ( - <span className="font-mono text-[10px] text-[#556677]"> - ({Math.round(step.confidenceScore * 100)}%) - </span> - )} - </div> - <div className="font-mono text-[10px] text-[#556677]">{step.stepType}</div> - </div> - {step.description && ( - <p className="font-mono text-xs text-[#556677] mt-1">{step.description}</p> - )} - {step.dependsOn?.length > 0 && ( - <div className="font-mono text-[10px] text-[#556677] mt-1"> - Depends on: {step.dependsOn.join(", ")} - </div> - )} - </div> - ); - }) - )} - </div> - )} - </div> - ); -} - -function EventsTab({ directive }: { directive: DirectiveWithProgress }) { - // Subscribe to real-time events via SSE - const { events: streamEvents, isConnected, error: sseError } = useDirectiveEventSubscription(directive.id); - - // Combine initial events with streamed events (avoiding duplicates) - const allEvents = useMemo(() => { - const eventMap = new Map(); - // Add initial events first - directive.recentEvents.forEach((e) => eventMap.set(e.id, e)); - // Add streamed events (will override any duplicates) - streamEvents.forEach((e) => eventMap.set(e.id, e)); - // Sort by created_at descending (most recent first) - return Array.from(eventMap.values()).sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() - ); - }, [directive.recentEvents, streamEvents]); - - return ( - <div className="space-y-4"> - {/* Connection status */} - <div className="flex items-center justify-between text-[10px] font-mono"> - <div className="flex items-center gap-2"> - <span className={isConnected ? "text-green-400" : "text-[#556677]"}> - {isConnected ? "● Live" : "○ Connecting..."} - </span> - {sseError && <span className="text-red-400">{sseError}</span>} - </div> - <span className="text-[#556677]">{allEvents.length} events</span> - </div> - - {/* Event list */} - {allEvents.length === 0 ? ( - <div className="text-center py-8"> - <p className="font-mono text-sm text-[#556677]">No events yet</p> - </div> - ) : ( - <div className="space-y-2"> - {allEvents.map((event) => { - const severityColors: Record<string, string> = { - info: "text-[#75aafc]", - warning: "text-yellow-400", - error: "text-red-400", - critical: "text-red-600", - }; - const severityColor = severityColors[event.severity] || "text-[#556677]"; - - return ( - <div - key={event.id} - className="p-3 bg-[rgba(117,170,252,0.02)] border border-[rgba(117,170,252,0.1)]" - > - <div className="flex items-center justify-between"> - <div className="flex items-center gap-2"> - <span className={`font-mono text-xs ${severityColor}`}>{event.eventType}</span> - <span className="font-mono text-[10px] text-[#556677]">{event.actorType}</span> - </div> - <span className="font-mono text-[10px] text-[#556677]"> - {new Date(event.createdAt).toLocaleString()} - </span> - </div> - {event.eventData != null && ( - <pre className="font-mono text-[10px] text-[#556677] mt-1 overflow-x-auto"> - {JSON.stringify(event.eventData, null, 2)} - </pre> - )} - </div> - ); - })} - </div> - )} - </div> - ); -} - -function EvaluationsTab({ directive: _directive }: { directive: DirectiveWithProgress }) { - // TODO: Fetch evaluations separately - return ( - <div className="text-center py-8"> - <p className="font-mono text-sm text-[#556677]"> - Evaluations will be shown here after steps are evaluated - </p> - </div> - ); -} - -function ApprovalsTab({ directive, onRefresh }: { directive: DirectiveWithProgress; onRefresh: () => void }) { - if (directive.pendingApprovals.length === 0) { - return ( - <div className="text-center py-8"> - <p className="font-mono text-sm text-[#556677]">No pending approvals</p> - </div> - ); - } - - const handleApprove = async (approvalId: string) => { - try { - const { approveDirectiveRequest } = await import("../lib/api"); - await approveDirectiveRequest(directive.id, approvalId); - onRefresh(); - } catch (err) { - console.error("Failed to approve:", err); - } - }; - - const handleDeny = async (approvalId: string) => { - try { - const { denyDirectiveRequest } = await import("../lib/api"); - await denyDirectiveRequest(directive.id, approvalId); - onRefresh(); - } catch (err) { - console.error("Failed to deny:", err); - } - }; - - return ( - <div className="space-y-3"> - {directive.pendingApprovals.map((approval) => { - const urgencyColor = { - low: "text-[#556677]", - normal: "text-[#75aafc]", - high: "text-yellow-400", - critical: "text-red-400", - }[approval.urgency] || "text-[#556677]"; - - return ( - <div - key={approval.id} - className="p-4 bg-[rgba(117,170,252,0.05)] border border-[rgba(117,170,252,0.2)]" - > - <div className="flex items-start justify-between"> - <div> - <div className="flex items-center gap-2"> - <span className="font-mono text-sm text-[#dbe7ff]">{approval.approvalType}</span> - <span className={`font-mono text-[10px] uppercase ${urgencyColor}`}> - {approval.urgency} - </span> - </div> - <p className="font-mono text-xs text-[#9bc3ff] mt-1">{approval.description}</p> - </div> - <div className="flex gap-2"> - <button - onClick={() => handleApprove(approval.id)} - className="px-3 py-1 font-mono text-[10px] text-[#dbe7ff] bg-green-700 border border-green-600 hover:bg-green-600 uppercase" - > - Approve - </button> - <button - onClick={() => handleDeny(approval.id)} - className="px-3 py-1 font-mono text-[10px] text-[#dbe7ff] bg-red-700 border border-red-600 hover:bg-red-600 uppercase" - > - Deny - </button> - </div> - </div> - </div> - ); - })} - </div> - ); -} - -function VerifiersTab({ directive: _directive }: { directive: DirectiveWithProgress }) { - // TODO: Fetch verifiers separately - return ( - <div className="text-center py-8"> - <p className="font-mono text-sm text-[#556677]"> - Verifiers will be shown here. Use auto-detect to find available verifiers. - </p> - </div> - ); -} - -// ============================================================================= -// Create Directive Modal -// ============================================================================= - -interface CreateDirectiveModalProps { - onSubmit: (goal: string, repositoryUrl: string | undefined, autonomyLevel: AutonomyLevel) => void; - onCancel: () => void; -} - -function CreateDirectiveModal({ onSubmit, onCancel }: CreateDirectiveModalProps) { - const [goal, setGoal] = useState(""); - const [repositoryUrl, setRepositoryUrl] = useState(""); - const [autonomyLevel, setAutonomyLevel] = useState<AutonomyLevel>("guardrails"); - const [suggestions, setSuggestions] = useState<RepositoryHistoryEntry[]>([]); - const [showSuggestions, setShowSuggestions] = useState(false); - - // Load suggestions - useEffect(() => { - getRepositorySuggestions("remote", undefined, 5) - .then((res) => { - setSuggestions(res.entries); - }) - .catch(() => { - setSuggestions([]); - }); - }, []); - - const handleSubmit = () => { - if (goal.trim()) { - onSubmit(goal.trim(), repositoryUrl.trim() || undefined, autonomyLevel); - } - }; - - return ( - <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"> - <div className="w-full max-w-lg p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] max-h-[90vh] overflow-y-auto"> - <h3 className="font-mono text-sm text-[#75aafc] uppercase mb-4"> - Create Directive - </h3> - - <div className="space-y-4"> - {/* Goal */} - <div> - <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> - Goal * - </label> - <textarea - value={goal} - onChange={(e) => setGoal(e.target.value)} - placeholder="Describe what you want to accomplish..." - rows={3} - className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc] resize-none" - autoFocus - /> - </div> - - {/* Repository URL */} - <div> - <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> - Repository URL (optional) - </label> - <div className="relative"> - <input - type="text" - value={repositoryUrl} - onChange={(e) => setRepositoryUrl(e.target.value)} - onFocus={() => suggestions.length > 0 && setShowSuggestions(true)} - onBlur={() => setTimeout(() => setShowSuggestions(false), 200)} - placeholder="https://github.com/owner/repo" - className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" - /> - {showSuggestions && suggestions.length > 0 && ( - <div className="absolute top-full left-0 right-0 mt-1 border border-[rgba(117,170,252,0.2)] bg-[#0a1525] max-h-32 overflow-y-auto z-10"> - {suggestions.map((s) => ( - <button - key={s.id} - type="button" - onClick={() => { - setRepositoryUrl(s.repositoryUrl || ""); - setShowSuggestions(false); - }} - className="w-full text-left px-3 py-2 font-mono text-xs hover:bg-[rgba(117,170,252,0.1)] border-b border-[rgba(117,170,252,0.1)] last:border-b-0" - > - <div className="text-[#9bc3ff] truncate">{s.name}</div> - <div className="text-[10px] text-[#556677] truncate">{s.repositoryUrl}</div> - </button> - ))} - </div> - )} - </div> - </div> - - {/* Autonomy Level */} - <div> - <label className="block font-mono text-xs text-[#8b949e] uppercase mb-2"> - Autonomy Level - </label> - <div className="flex gap-2"> - {(["full_auto", "guardrails", "manual"] as const).map((level) => ( - <button - key={level} - type="button" - onClick={() => setAutonomyLevel(level)} - className={`flex-1 px-3 py-2 font-mono text-xs uppercase ${ - autonomyLevel === level - ? "text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3]" - : "text-[#556677] border border-[rgba(117,170,252,0.2)] hover:border-[#3f6fb3]" - }`} - > - {level.replace("_", " ")} - </button> - ))} - </div> - <p className="font-mono text-[10px] text-[#556677] mt-1"> - {autonomyLevel === "full_auto" && "Automatic progression without approval gates"} - {autonomyLevel === "guardrails" && "Request approval for yellow/red confidence scores"} - {autonomyLevel === "manual" && "Request approval for all step completions"} - </p> - </div> - - <p className="font-mono text-xs text-[#8b949e]"> - A directive is a top-level goal that generates a chain of steps. Each step spawns - contracts that are verified before progression. - </p> - - {/* Actions */} - <div className="flex gap-2 justify-end pt-2"> - <button - onClick={onCancel} - className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors" - > - Cancel - </button> - <button - onClick={handleSubmit} - disabled={!goal.trim()} - className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" - > - Create - </button> - </div> - </div> - </div> - </div> - ); -} diff --git a/makima/frontend/tsconfig.tsbuildinfo b/makima/frontend/tsconfig.tsbuildinfo index 966ee38..cbaa81f 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/chains/chaineditor.tsx","./src/components/chains/chainlist.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/usechains.ts","./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/chains.tsx","./src/routes/contract-file.tsx","./src/routes/contracts.tsx","./src/routes/directives.tsx","./src/routes/files.tsx","./src/routes/history.tsx","./src/routes/listen.tsx","./src/routes/login.tsx","./src/routes/mesh.tsx","./src/routes/settings.tsx","./src/routes/speak.tsx","./src/routes/workflow.tsx","./src/types/messages.ts"],"version":"5.9.3"}
\ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/japanesehovertext.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/phaseconfirmationnotification.tsx","./src/components/protectedroute.tsx","./src/components/rewritelink.tsx","./src/components/simplemarkdown.tsx","./src/components/supervisorquestionnotification.tsx","./src/components/charts/chartrenderer.tsx","./src/components/contracts/commandmodepanel.tsx","./src/components/contracts/contractcliinput.tsx","./src/components/contracts/contractcontextmenu.tsx","./src/components/contracts/contractdetail.tsx","./src/components/contracts/contractlist.tsx","./src/components/contracts/phasebadge.tsx","./src/components/contracts/phaseconfirmationmodal.tsx","./src/components/contracts/phasedeliverablespanel.tsx","./src/components/contracts/phasehint.tsx","./src/components/contracts/phaseprogressbar.tsx","./src/components/contracts/quickactionbuttons.tsx","./src/components/contracts/repositorypanel.tsx","./src/components/contracts/taskderivationpreview.tsx","./src/components/directives/approvalstab.tsx","./src/components/directives/chaintab.tsx","./src/components/directives/createdirectivemodal.tsx","./src/components/directives/directivedetail.tsx","./src/components/directives/directivelist.tsx","./src/components/directives/directivelistitem.tsx","./src/components/directives/evaluationstab.tsx","./src/components/directives/eventstab.tsx","./src/components/directives/overviewtab.tsx","./src/components/directives/stepnode.tsx","./src/components/directives/verifierstab.tsx","./src/components/directives/index.ts","./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/usedirectivedetail.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/src/bin/makima.rs b/makima/src/bin/makima.rs index 822b21f..d7646c2 100644 --- a/makima/src/bin/makima.rs +++ b/makima/src/bin/makima.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use makima::daemon::api::{ApiClient, CreateContractRequest}; use makima::daemon::cli::{ - Cli, CliConfig, Commands, ConfigCommand, ContractCommand, ChainCommand, + Cli, CliConfig, Commands, ConfigCommand, ContractCommand, DirectiveCommand, SupervisorCommand, ViewArgs, }; use makima::daemon::tui::{self, Action, App, ListItem, ViewType, TuiWsClient, WsEvent, OutputLine, OutputMessageType, WsConnectionState, RepositorySuggestion}; @@ -31,7 +31,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { Commands::Contract(cmd) => run_contract(cmd).await, Commands::View(args) => run_view(args).await, Commands::Config(cmd) => run_config(cmd).await, - Commands::Chain(cmd) => run_chain(cmd).await, Commands::Directive(cmd) => run_directive(cmd).await, } } @@ -803,225 +802,6 @@ async fn run_config(cmd: ConfigCommand) -> Result<(), Box<dyn std::error::Error } } -/// Run chain commands. -async fn run_chain( - cmd: ChainCommand, -) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { - use makima::daemon::chain::{parse_chain_file, validate_dag, ChainRunner}; - - match cmd { - ChainCommand::Run(args) => { - eprintln!("Loading chain from: {}", args.file.display()); - - // Load and validate chain - let chain = parse_chain_file(&args.file)?; - validate_dag(&chain)?; - - if args.dry_run { - eprintln!("\n=== DRY RUN - No changes will be made ===\n"); - } - - let runner = ChainRunner::new(args.common.api_url.clone(), args.common.api_key.clone()); - - // Show execution order - let order = runner.get_execution_order(&chain)?; - eprintln!("Execution order:"); - for (i, name) in order.iter().enumerate() { - eprintln!(" {}. {}", i + 1, name); - } - eprintln!(); - - // Show visualization - eprintln!("{}", runner.visualize_dag(&chain)); - - if args.dry_run { - eprintln!("\n=== DRY RUN COMPLETE ==="); - let request = runner.to_create_request(&chain); - println!("{}", serde_json::to_string_pretty(&request)?); - } else { - // Create chain via API - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - let request = runner.to_create_request(&chain); - let result = client.create_chain(request).await?; - println!("{}", serde_json::to_string(&result.0)?); - } - } - ChainCommand::Status(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - let result = client.get_chain(args.chain_id).await?; - println!("{}", serde_json::to_string(&result.0)?); - } - ChainCommand::List(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - let result = client.list_chains(args.status.as_deref(), args.limit).await?; - println!("{}", serde_json::to_string(&result.0)?); - } - ChainCommand::Contracts(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - let result = client.get_chain_contracts(args.chain_id).await?; - println!("{}", serde_json::to_string(&result.0)?); - } - ChainCommand::Graph(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - let result = client.get_chain_graph(args.chain_id).await?; - - // Get the graph data - if args.with_status { - // Enhanced ASCII visualization with status - if let Some(nodes) = result.0.get("nodes").and_then(|v| v.as_array()) { - let mut by_depth: std::collections::HashMap<i32, Vec<(&str, &str)>> = - std::collections::HashMap::new(); - - for node in nodes { - let name = node.get("name").and_then(|v| v.as_str()).unwrap_or("?"); - let status = node - .get("status") - .and_then(|v| v.as_str()) - .unwrap_or("pending"); - let depth = node.get("depth").and_then(|v| v.as_i64()).unwrap_or(0) as i32; - by_depth.entry(depth).or_default().push((name, status)); - } - - let chain_name = result - .0 - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("Chain"); - println!("Chain: {}", chain_name); - println!(); - - let max_depth = by_depth.keys().max().copied().unwrap_or(0); - for depth in 0..=max_depth { - if let Some(contracts) = by_depth.get(&depth) { - let indent = " ".repeat(depth as usize); - for (name, status) in contracts { - let status_icon = match *status { - "completed" | "done" => "\u{2713}", - "active" | "running" | "in_progress" => "\u{21bb}", - "failed" | "error" => "\u{2717}", - _ => "\u{25cb}", - }; - println!("{}[{}] {} {}", indent, name, status_icon, status); - } - if depth < max_depth { - println!("{} |", indent); - println!("{} v", indent); - } - } - } - } - } else { - // Simple JSON output - println!("{}", serde_json::to_string_pretty(&result.0)?); - } - } - ChainCommand::Validate(args) => { - eprintln!("Validating chain file: {}", args.file.display()); - - match parse_chain_file(&args.file) { - Ok(chain) => { - match validate_dag(&chain) { - Ok(()) => { - eprintln!("\u{2713} Chain definition is valid"); - eprintln!(" Name: {}", chain.name); - eprintln!(" Contracts: {}", chain.contracts.len()); - - // Show any warnings - for contract in &chain.contracts { - if contract.tasks.is_none() || contract.tasks.as_ref().map(|t| t.is_empty()).unwrap_or(true) { - eprintln!(" \u{26a0} Contract '{}' has no tasks", contract.name); - } - } - - println!(r#"{{"valid": true, "name": "{}", "contractCount": {}}}"#, - chain.name, chain.contracts.len()); - } - Err(e) => { - eprintln!("\u{2717} DAG validation failed: {}", e); - println!(r#"{{"valid": false, "error": "{}"}}"#, e); - std::process::exit(1); - } - } - } - Err(e) => { - eprintln!("\u{2717} Parse error: {}", e); - println!(r#"{{"valid": false, "error": "{}"}}"#, e); - std::process::exit(1); - } - } - } - ChainCommand::Preview(args) => { - eprintln!("Previewing chain: {}", args.file.display()); - - let chain = parse_chain_file(&args.file)?; - validate_dag(&chain)?; - - let runner = ChainRunner::new(String::new(), String::new()); - - // Show chain info - println!("Chain: {}", chain.name); - if let Some(desc) = &chain.description { - println!("Description: {}", desc); - } - if !chain.repositories.is_empty() { - println!("Repositories:"); - for repo in &chain.repositories { - if let Some(url) = &repo.repository_url { - println!(" - {} ({})", repo.name, url); - } else if let Some(path) = &repo.local_path { - println!(" - {} (local: {})", repo.name, path); - } - } - } - println!(); - - // Show execution order - let order = runner.get_execution_order(&chain)?; - println!("Execution Order:"); - for (i, name) in order.iter().enumerate() { - let contract = chain.contracts.iter().find(|c| c.name == *name).unwrap(); - let deps = contract - .depends_on - .as_ref() - .map(|d| d.join(", ")) - .unwrap_or_else(|| "(none)".to_string()); - let task_count = contract.tasks.as_ref().map(|t| t.len()).unwrap_or(0); - println!( - " {}. {} [type: {}, tasks: {}, depends: {}]", - i + 1, - name, - contract.contract_type, - task_count, - deps - ); - } - println!(); - - // Show DAG visualization - println!("{}", runner.visualize_dag(&chain)); - - // Show loop config if enabled - if let Some(lc) = &chain.loop_config { - if lc.enabled { - println!("\nLoop Configuration:"); - println!(" Max iterations: {}", lc.max_iterations); - if let Some(check) = &lc.progress_check { - println!(" Progress check: {}", check); - } - } - } - } - ChainCommand::Archive(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - eprintln!("Archiving chain {}...", args.chain_id); - let result = client.archive_chain(args.chain_id).await?; - println!("{}", serde_json::to_string(&result.0)?); - } - } - - Ok(()) -} - /// Run directive commands. async fn run_directive( cmd: DirectiveCommand, diff --git a/makima/src/daemon/api/chain.rs b/makima/src/daemon/api/chain.rs deleted file mode 100644 index c37c980..0000000 --- a/makima/src/daemon/api/chain.rs +++ /dev/null @@ -1,78 +0,0 @@ -//! Chain API methods. - -use uuid::Uuid; - -use super::client::{ApiClient, ApiError}; -use super::supervisor::JsonValue; -use crate::db::models::CreateChainRequest; - -impl ApiClient { - /// Create a new chain with contracts. - pub async fn create_chain(&self, req: CreateChainRequest) -> Result<JsonValue, ApiError> { - self.post("/api/v1/chains", &req).await - } - - /// List all chains for the authenticated user. - pub async fn list_chains( - &self, - status: Option<&str>, - limit: i32, - ) -> Result<JsonValue, ApiError> { - let mut params = Vec::new(); - if let Some(s) = status { - params.push(format!("status={}", s)); - } - params.push(format!("limit={}", limit)); - let query_string = format!("?{}", params.join("&")); - self.get(&format!("/api/v1/chains{}", query_string)).await - } - - /// Get a chain by ID. - pub async fn get_chain(&self, chain_id: Uuid) -> Result<JsonValue, ApiError> { - self.get(&format!("/api/v1/chains/{}", chain_id)).await - } - - /// Get contracts in a chain. - pub async fn get_chain_contracts(&self, chain_id: Uuid) -> Result<JsonValue, ApiError> { - self.get(&format!("/api/v1/chains/{}/contracts", chain_id)) - .await - } - - /// Get chain DAG structure for visualization. - pub async fn get_chain_graph(&self, chain_id: Uuid) -> Result<JsonValue, ApiError> { - self.get(&format!("/api/v1/chains/{}/graph", chain_id)) - .await - } - - /// Archive a chain. - pub async fn archive_chain(&self, chain_id: Uuid) -> Result<JsonValue, ApiError> { - self.delete_with_response(&format!("/api/v1/chains/{}", chain_id)) - .await - } - - /// Start a chain (creates root contracts and optionally a supervisor). - pub async fn start_chain(&self, chain_id: Uuid) -> Result<JsonValue, ApiError> { - self.post_empty(&format!("/api/v1/chains/{}/start", chain_id)) - .await - } - - /// Start a chain with supervisor enabled. - pub async fn start_chain_with_supervisor( - &self, - chain_id: Uuid, - repository_url: Option<&str>, - ) -> Result<JsonValue, ApiError> { - #[derive(serde::Serialize)] - #[serde(rename_all = "camelCase")] - struct StartRequest<'a> { - with_supervisor: bool, - repository_url: Option<&'a str>, - } - let req = StartRequest { - with_supervisor: true, - repository_url, - }; - self.post(&format!("/api/v1/chains/{}/start", chain_id), &req) - .await - } -} diff --git a/makima/src/daemon/api/directive.rs b/makima/src/daemon/api/directive.rs index 5281d21..48762d6 100644 --- a/makima/src/daemon/api/directive.rs +++ b/makima/src/daemon/api/directive.rs @@ -159,4 +159,289 @@ impl ApiClient { ) .await } + + // ========================================================================= + // Chain operations + // ========================================================================= + + /// Force chain regeneration (replan). + pub async fn replan_directive_chain( + &self, + directive_id: Uuid, + ) -> Result<JsonValue, ApiError> { + self.post_empty(&format!( + "/api/v1/directives/{}/chain/replan", + directive_id + )) + .await + } + + // ========================================================================= + // Step management + // ========================================================================= + + /// Add a step to a directive's chain. + pub async fn add_directive_step( + &self, + directive_id: Uuid, + name: &str, + description: Option<&str>, + step_type: Option<&str>, + depends_on: Option<Vec<Uuid>>, + ) -> Result<JsonValue, ApiError> { + #[derive(serde::Serialize)] + #[serde(rename_all = "camelCase")] + struct AddStepReq<'a> { + name: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + step_type: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + depends_on: Option<Vec<Uuid>>, + } + let req = AddStepReq { + name, + description, + step_type, + depends_on, + }; + self.post( + &format!("/api/v1/directives/{}/chain/steps", directive_id), + &req, + ) + .await + } + + /// Get a step by ID. + pub async fn get_directive_step( + &self, + directive_id: Uuid, + step_id: Uuid, + ) -> Result<JsonValue, ApiError> { + self.get(&format!( + "/api/v1/directives/{}/chain/steps/{}", + directive_id, step_id + )) + .await + } + + /// Update a step. + pub async fn update_directive_step( + &self, + directive_id: Uuid, + step_id: Uuid, + update: serde_json::Value, + ) -> Result<JsonValue, ApiError> { + self.put( + &format!( + "/api/v1/directives/{}/chain/steps/{}", + directive_id, step_id + ), + &update, + ) + .await + } + + /// Delete a step. + pub async fn delete_directive_step( + &self, + directive_id: Uuid, + step_id: Uuid, + ) -> Result<(), ApiError> { + self.delete(&format!( + "/api/v1/directives/{}/chain/steps/{}", + directive_id, step_id + )) + .await + } + + /// Skip a step. + pub async fn skip_directive_step( + &self, + directive_id: Uuid, + step_id: Uuid, + ) -> Result<JsonValue, ApiError> { + self.post_empty(&format!( + "/api/v1/directives/{}/chain/steps/{}/skip", + directive_id, step_id + )) + .await + } + + /// Force re-evaluation of a step. + pub async fn evaluate_directive_step( + &self, + directive_id: Uuid, + step_id: Uuid, + ) -> Result<JsonValue, ApiError> { + self.post_empty(&format!( + "/api/v1/directives/{}/chain/steps/{}/evaluate", + directive_id, step_id + )) + .await + } + + /// Trigger manual rework for a step. + pub async fn rework_directive_step( + &self, + directive_id: Uuid, + step_id: Uuid, + instructions: Option<&str>, + ) -> Result<JsonValue, ApiError> { + #[derive(serde::Serialize)] + #[serde(rename_all = "camelCase")] + struct ReworkReq<'a> { + instructions: Option<&'a str>, + } + let req = ReworkReq { instructions }; + self.post( + &format!( + "/api/v1/directives/{}/chain/steps/{}/rework", + directive_id, step_id + ), + &req, + ) + .await + } + + // ========================================================================= + // Evaluations + // ========================================================================= + + /// List evaluations for a directive. + pub async fn list_directive_evaluations( + &self, + directive_id: Uuid, + ) -> Result<JsonValue, ApiError> { + self.get(&format!( + "/api/v1/directives/{}/evaluations", + directive_id + )) + .await + } + + // ========================================================================= + // Verifiers + // ========================================================================= + + /// List verifiers for a directive. + pub async fn list_directive_verifiers( + &self, + directive_id: Uuid, + ) -> Result<JsonValue, ApiError> { + self.get(&format!("/api/v1/directives/{}/verifiers", directive_id)) + .await + } + + /// Add a verifier to a directive. + pub async fn add_directive_verifier( + &self, + directive_id: Uuid, + name: &str, + verifier_type: &str, + command: Option<&str>, + ) -> Result<JsonValue, ApiError> { + #[derive(serde::Serialize)] + #[serde(rename_all = "camelCase")] + struct CreateVerifierReq<'a> { + name: &'a str, + verifier_type: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + command: Option<&'a str>, + } + let req = CreateVerifierReq { + name, + verifier_type, + command, + }; + self.post( + &format!("/api/v1/directives/{}/verifiers", directive_id), + &req, + ) + .await + } + + /// Update a verifier. + pub async fn update_directive_verifier( + &self, + directive_id: Uuid, + verifier_id: Uuid, + update: serde_json::Value, + ) -> Result<JsonValue, ApiError> { + self.put( + &format!( + "/api/v1/directives/{}/verifiers/{}", + directive_id, verifier_id + ), + &update, + ) + .await + } + + /// Auto-detect verifiers based on repository content. + pub async fn auto_detect_directive_verifiers( + &self, + directive_id: Uuid, + ) -> Result<JsonValue, ApiError> { + self.post_empty(&format!( + "/api/v1/directives/{}/verifiers/auto-detect", + directive_id + )) + .await + } + + // ========================================================================= + // Requirements & Spec + // ========================================================================= + + /// Update directive requirements. + pub async fn update_directive_requirements( + &self, + directive_id: Uuid, + requirements: serde_json::Value, + ) -> Result<JsonValue, ApiError> { + #[derive(serde::Serialize)] + #[serde(rename_all = "camelCase")] + struct UpdateReq { + requirements: serde_json::Value, + } + let req = UpdateReq { requirements }; + self.put( + &format!("/api/v1/directives/{}/requirements", directive_id), + &req, + ) + .await + } + + /// Update directive acceptance criteria. + pub async fn update_directive_criteria( + &self, + directive_id: Uuid, + acceptance_criteria: serde_json::Value, + ) -> Result<JsonValue, ApiError> { + #[derive(serde::Serialize)] + #[serde(rename_all = "camelCase")] + struct UpdateReq { + acceptance_criteria: serde_json::Value, + } + let req = UpdateReq { acceptance_criteria }; + self.put( + &format!("/api/v1/directives/{}/criteria", directive_id), + &req, + ) + .await + } + + /// Generate a specification from the directive's goal. + pub async fn generate_directive_spec( + &self, + directive_id: Uuid, + ) -> Result<JsonValue, ApiError> { + self.post_empty(&format!( + "/api/v1/directives/{}/generate-spec", + directive_id + )) + .await + } } diff --git a/makima/src/daemon/api/mod.rs b/makima/src/daemon/api/mod.rs index f1f52d0..2d1efbf 100644 --- a/makima/src/daemon/api/mod.rs +++ b/makima/src/daemon/api/mod.rs @@ -1,6 +1,5 @@ //! HTTP API client for makima CLI commands. -pub mod chain; pub mod client; pub mod contract; pub mod directive; diff --git a/makima/src/daemon/chain/dag.rs b/makima/src/daemon/chain/dag.rs deleted file mode 100644 index 7ba5904..0000000 --- a/makima/src/daemon/chain/dag.rs +++ /dev/null @@ -1,450 +0,0 @@ -//! DAG validation and traversal for chain contracts. -//! -//! Provides cycle detection and topological sorting for contract dependencies. - -use std::collections::{HashMap, HashSet, VecDeque}; -use thiserror::Error; - -use super::parser::ChainDefinition; - -/// Error type for DAG operations. -#[derive(Error, Debug)] -pub enum DagError { - #[error("Cycle detected in dependency graph: {0}")] - CycleDetected(String), - - #[error("Unknown contract in dependency: {0}")] - UnknownContract(String), -} - -/// Validates that the chain definition forms a valid DAG (no cycles). -/// -/// Uses depth-first search with color marking to detect cycles. -/// Returns Ok(()) if valid, or an error describing the cycle. -pub fn validate_dag(chain: &ChainDefinition) -> Result<(), DagError> { - // Build adjacency list from contract dependencies - let mut adjacency: HashMap<&str, Vec<&str>> = HashMap::new(); - let contract_names: HashSet<&str> = chain.contracts.iter().map(|c| c.name.as_str()).collect(); - - for contract in &chain.contracts { - let deps: Vec<&str> = contract - .depends_on - .as_ref() - .map(|d| d.iter().map(|s| s.as_str()).collect()) - .unwrap_or_default(); - - // Validate all dependencies exist - for dep in &deps { - if !contract_names.contains(dep) { - return Err(DagError::UnknownContract(format!( - "Contract '{}' depends on unknown contract '{}'", - contract.name, dep - ))); - } - } - - adjacency.insert(contract.name.as_str(), deps); - } - - // Color-based DFS for cycle detection - // White (0): not visited, Gray (1): in progress, Black (2): completed - let mut color: HashMap<&str, u8> = HashMap::new(); - for name in &contract_names { - color.insert(name, 0); - } - - // Track path for cycle reporting - fn dfs<'a>( - node: &'a str, - adjacency: &HashMap<&'a str, Vec<&'a str>>, - color: &mut HashMap<&'a str, u8>, - path: &mut Vec<&'a str>, - ) -> Result<(), DagError> { - color.insert(node, 1); // Mark as in-progress - path.push(node); - - if let Some(deps) = adjacency.get(node) { - for dep in deps { - match color.get(dep) { - Some(1) => { - // Found cycle - dep is in current path - let cycle_start = path.iter().position(|&n| n == *dep).unwrap(); - let cycle: Vec<_> = path[cycle_start..].to_vec(); - return Err(DagError::CycleDetected(format!( - "{} -> {}", - cycle.join(" -> "), - dep - ))); - } - Some(0) => { - // Not visited - recurse - dfs(dep, adjacency, color, path)?; - } - _ => { - // Already completed - skip - } - } - } - } - - color.insert(node, 2); // Mark as completed - path.pop(); - Ok(()) - } - - // Run DFS from each unvisited node - for name in &contract_names { - if color.get(name) == Some(&0) { - let mut path = Vec::new(); - dfs(name, &adjacency, &mut color, &mut path)?; - } - } - - Ok(()) -} - -/// Returns contracts in topological order (dependencies before dependents). -/// -/// Uses Kahn's algorithm for topological sorting. -pub fn topological_sort(chain: &ChainDefinition) -> Result<Vec<&str>, DagError> { - // Validate first - validate_dag(chain)?; - - // Build in-degree map and adjacency list - let mut in_degree: HashMap<&str, usize> = HashMap::new(); - let mut dependents: HashMap<&str, Vec<&str>> = HashMap::new(); - - for contract in &chain.contracts { - in_degree.entry(contract.name.as_str()).or_insert(0); - dependents.entry(contract.name.as_str()).or_default(); - - if let Some(deps) = &contract.depends_on { - for dep in deps { - *in_degree.entry(contract.name.as_str()).or_insert(0) += 1; - dependents - .entry(dep.as_str()) - .or_default() - .push(contract.name.as_str()); - } - } - } - - // Kahn's algorithm - let mut queue: VecDeque<&str> = VecDeque::new(); - let mut result: Vec<&str> = Vec::new(); - - // Start with nodes that have no dependencies - for (name, °ree) in &in_degree { - if degree == 0 { - queue.push_back(name); - } - } - - while let Some(node) = queue.pop_front() { - result.push(node); - - if let Some(deps) = dependents.get(node) { - for dep in deps { - if let Some(degree) = in_degree.get_mut(dep) { - *degree -= 1; - if *degree == 0 { - queue.push_back(dep); - } - } - } - } - } - - Ok(result) -} - -/// Returns contracts that are ready to run (have no unmet dependencies). -/// -/// Takes a set of completed contract names and returns contracts that -/// can now be started. -pub fn get_ready_contracts<'a>( - chain: &'a ChainDefinition, - completed: &HashSet<&str>, -) -> Vec<&'a str> { - chain - .contracts - .iter() - .filter(|c| { - // Already completed? Skip - if completed.contains(c.name.as_str()) { - return false; - } - - // Check if all dependencies are met - match &c.depends_on { - None => true, // No dependencies - Some(deps) => deps.iter().all(|d| completed.contains(d.as_str())), - } - }) - .map(|c| c.name.as_str()) - .collect() -} - -/// Get the depth of each contract in the DAG (for layout purposes). -/// -/// Root nodes (no dependencies) have depth 0. -/// Each dependent has depth = max(dependency depths) + 1. -pub fn get_contract_depths(chain: &ChainDefinition) -> HashMap<&str, usize> { - let mut depths: HashMap<&str, usize> = HashMap::new(); - - // Multiple passes to handle dependencies - let max_iterations = chain.contracts.len(); - for _ in 0..max_iterations { - let mut changed = false; - - for contract in &chain.contracts { - let new_depth = match &contract.depends_on { - None => 0, - Some(deps) => { - if deps.iter().all(|d| depths.contains_key(d.as_str())) { - deps.iter() - .filter_map(|d| depths.get(d.as_str())) - .max() - .copied() - .unwrap_or(0) - + 1 - } else { - continue; // Dependencies not yet computed - } - } - }; - - if depths.get(contract.name.as_str()) != Some(&new_depth) { - depths.insert(contract.name.as_str(), new_depth); - changed = true; - } - } - - if !changed { - break; - } - } - - depths -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::daemon::chain::parser::parse_chain_yaml; - - #[test] - fn test_valid_dag() { - let yaml = r#" -name: Valid DAG -contracts: - - name: A - tasks: - - name: Task - plan: "Do A" - - name: B - depends_on: [A] - tasks: - - name: Task - plan: "Do B" - - name: C - depends_on: [A] - tasks: - - name: Task - plan: "Do C" - - name: D - depends_on: [B, C] - tasks: - - name: Task - plan: "Do D" -"#; - let chain = parse_chain_yaml(yaml).unwrap(); - assert!(validate_dag(&chain).is_ok()); - } - - #[test] - fn test_simple_cycle() { - let yaml = r#" -name: Simple Cycle -contracts: - - name: A - depends_on: [B] - tasks: - - name: Task - plan: "Do A" - - name: B - depends_on: [A] - tasks: - - name: Task - plan: "Do B" -"#; - let chain = parse_chain_yaml(yaml).unwrap(); - let result = validate_dag(&chain); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("Cycle detected")); - } - - #[test] - fn test_longer_cycle() { - let yaml = r#" -name: Longer Cycle -contracts: - - name: A - depends_on: [C] - tasks: - - name: Task - plan: "Do A" - - name: B - depends_on: [A] - tasks: - - name: Task - plan: "Do B" - - name: C - depends_on: [B] - tasks: - - name: Task - plan: "Do C" -"#; - let chain = parse_chain_yaml(yaml).unwrap(); - let result = validate_dag(&chain); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("Cycle detected")); - } - - #[test] - fn test_topological_sort() { - let yaml = r#" -name: Topo Test -contracts: - - name: A - tasks: - - name: Task - plan: "Do A" - - name: B - depends_on: [A] - tasks: - - name: Task - plan: "Do B" - - name: C - depends_on: [A] - tasks: - - name: Task - plan: "Do C" - - name: D - depends_on: [B, C] - tasks: - - name: Task - plan: "Do D" -"#; - let chain = parse_chain_yaml(yaml).unwrap(); - let sorted = topological_sort(&chain).unwrap(); - - // A must come before B, C; B and C must come before D - let pos_a = sorted.iter().position(|&n| n == "A").unwrap(); - let pos_b = sorted.iter().position(|&n| n == "B").unwrap(); - let pos_c = sorted.iter().position(|&n| n == "C").unwrap(); - let pos_d = sorted.iter().position(|&n| n == "D").unwrap(); - - assert!(pos_a < pos_b); - assert!(pos_a < pos_c); - assert!(pos_b < pos_d); - assert!(pos_c < pos_d); - } - - #[test] - fn test_get_ready_contracts() { - let yaml = r#" -name: Ready Test -contracts: - - name: A - tasks: - - name: Task - plan: "Do A" - - name: B - depends_on: [A] - tasks: - - name: Task - plan: "Do B" - - name: C - tasks: - - name: Task - plan: "Do C" -"#; - let chain = parse_chain_yaml(yaml).unwrap(); - - // Initially A and C are ready (no dependencies) - let completed = HashSet::new(); - let mut ready = get_ready_contracts(&chain, &completed); - ready.sort(); - assert_eq!(ready, vec!["A", "C"]); - - // After A completes, B becomes ready - let mut completed = HashSet::new(); - completed.insert("A"); - let ready = get_ready_contracts(&chain, &completed); - assert!(ready.contains(&"B")); - assert!(ready.contains(&"C")); // C still ready if not started - } - - #[test] - fn test_get_contract_depths() { - let yaml = r#" -name: Depth Test -contracts: - - name: A - tasks: - - name: Task - plan: "Do A" - - name: B - depends_on: [A] - tasks: - - name: Task - plan: "Do B" - - name: C - depends_on: [B] - tasks: - - name: Task - plan: "Do C" -"#; - let chain = parse_chain_yaml(yaml).unwrap(); - let depths = get_contract_depths(&chain); - - assert_eq!(depths.get("A"), Some(&0)); - assert_eq!(depths.get("B"), Some(&1)); - assert_eq!(depths.get("C"), Some(&2)); - } - - #[test] - fn test_diamond_dependency_depths() { - let yaml = r#" -name: Diamond Test -contracts: - - name: A - tasks: - - name: Task - plan: "Do A" - - name: B - depends_on: [A] - tasks: - - name: Task - plan: "Do B" - - name: C - depends_on: [A] - tasks: - - name: Task - plan: "Do C" - - name: D - depends_on: [B, C] - tasks: - - name: Task - plan: "Do D" -"#; - let chain = parse_chain_yaml(yaml).unwrap(); - let depths = get_contract_depths(&chain); - - assert_eq!(depths.get("A"), Some(&0)); - assert_eq!(depths.get("B"), Some(&1)); - assert_eq!(depths.get("C"), Some(&1)); - assert_eq!(depths.get("D"), Some(&2)); - } -} diff --git a/makima/src/daemon/chain/mod.rs b/makima/src/daemon/chain/mod.rs deleted file mode 100644 index 5588a27..0000000 --- a/makima/src/daemon/chain/mod.rs +++ /dev/null @@ -1,13 +0,0 @@ -//! Chain module - DAG-based multi-contract orchestration. -//! -//! Chains are directed acyclic graphs (DAGs) of contracts that work together -//! to achieve a larger goal. Each contract can depend on others, and contracts -//! run in parallel when no dependencies exist. - -pub mod dag; -pub mod parser; -pub mod runner; - -pub use dag::{validate_dag, DagError}; -pub use parser::{parse_chain_file, ChainDefinition, ParseError}; -pub use runner::{ChainRunner, RunnerError}; diff --git a/makima/src/daemon/chain/parser.rs b/makima/src/daemon/chain/parser.rs deleted file mode 100644 index b32d0f2..0000000 --- a/makima/src/daemon/chain/parser.rs +++ /dev/null @@ -1,414 +0,0 @@ -//! Chain YAML parser. -//! -//! Parses chain definition files in YAML format into structured data -//! that can be used to create chains and contracts. - -use serde::{Deserialize, Serialize}; -use std::path::Path; -use thiserror::Error; - -/// Error type for chain parsing operations. -#[derive(Error, Debug)] -pub enum ParseError { - #[error("Failed to read chain file: {0}")] - IoError(#[from] std::io::Error), - - #[error("Failed to parse YAML: {0}")] - YamlError(#[from] serde_yaml::Error), - - #[error("Validation error: {0}")] - ValidationError(String), -} - -/// Repository definition in a chain. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RepositoryDefinition { - /// Name of the repository - pub name: String, - /// Repository URL (for remote repos) - pub repository_url: Option<String>, - /// Local path (for local repos) - pub local_path: Option<String>, - /// Source type: remote, local, or managed - #[serde(default = "default_source_type")] - pub source_type: String, - /// Whether this is the primary repository - #[serde(default)] - pub is_primary: bool, -} - -fn default_source_type() -> String { - "remote".to_string() -} - -/// Chain definition parsed from YAML. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChainDefinition { - /// Name of the chain - pub name: String, - /// Optional description - pub description: Option<String>, - /// Repositories for this chain - #[serde(default)] - pub repositories: Vec<RepositoryDefinition>, - /// Contracts in this chain - pub contracts: Vec<ContractDefinition>, - /// Loop configuration - #[serde(rename = "loop")] - pub loop_config: Option<LoopConfig>, -} - -/// Contract definition within a chain. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ContractDefinition { - /// Name of the contract - pub name: String, - /// Optional description - pub description: Option<String>, - /// Contract type (defaults to "simple") - #[serde(rename = "type", default = "default_contract_type")] - pub contract_type: String, - /// Phases for this contract - pub phases: Option<Vec<String>>, - /// Names of contracts this depends on (DAG edges) - pub depends_on: Option<Vec<String>>, - /// Tasks to create in this contract - pub tasks: Option<Vec<TaskDefinition>>, - /// Deliverables for this contract - pub deliverables: Option<Vec<DeliverableDefinition>>, -} - -fn default_contract_type() -> String { - "simple".to_string() -} - -/// Task definition within a contract. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TaskDefinition { - /// Name of the task - pub name: String, - /// Plan/instructions for the task - pub plan: String, -} - -/// Deliverable definition within a contract. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DeliverableDefinition { - /// Unique identifier for the deliverable - pub id: String, - /// Name of the deliverable - pub name: String, - /// Priority level (defaults to "required") - #[serde(default = "default_priority")] - pub priority: String, -} - -fn default_priority() -> String { - "required".to_string() -} - -/// Loop configuration for chain iteration. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LoopConfig { - /// Whether loop is enabled - #[serde(default)] - pub enabled: bool, - /// Maximum number of iterations - #[serde(default = "default_max_iterations")] - pub max_iterations: i32, - /// Progress check prompt/criteria - pub progress_check: Option<String>, -} - -fn default_max_iterations() -> i32 { - 10 -} - -impl ChainDefinition { - /// Validate the chain definition. - pub fn validate(&self) -> Result<(), ParseError> { - // Check for empty name - if self.name.trim().is_empty() { - return Err(ParseError::ValidationError( - "Chain name cannot be empty".to_string(), - )); - } - - // Check for at least one contract - if self.contracts.is_empty() { - return Err(ParseError::ValidationError( - "Chain must have at least one contract".to_string(), - )); - } - - // Collect all contract names for dependency validation - let contract_names: std::collections::HashSet<_> = - self.contracts.iter().map(|c| c.name.as_str()).collect(); - - // Check for duplicate contract names - if contract_names.len() != self.contracts.len() { - return Err(ParseError::ValidationError( - "Duplicate contract names found".to_string(), - )); - } - - // Validate each contract - for contract in &self.contracts { - contract.validate(&contract_names)?; - } - - Ok(()) - } -} - -impl ContractDefinition { - /// Validate the contract definition. - pub fn validate( - &self, - valid_contract_names: &std::collections::HashSet<&str>, - ) -> Result<(), ParseError> { - // Check for empty name - if self.name.trim().is_empty() { - return Err(ParseError::ValidationError( - "Contract name cannot be empty".to_string(), - )); - } - - // Validate dependencies exist - if let Some(deps) = &self.depends_on { - for dep in deps { - if !valid_contract_names.contains(dep.as_str()) { - return Err(ParseError::ValidationError(format!( - "Contract '{}' depends on unknown contract '{}'", - self.name, dep - ))); - } - // Self-dependency check - if dep == &self.name { - return Err(ParseError::ValidationError(format!( - "Contract '{}' cannot depend on itself", - self.name - ))); - } - } - } - - // Validate tasks - if let Some(tasks) = &self.tasks { - for task in tasks { - if task.name.trim().is_empty() { - return Err(ParseError::ValidationError(format!( - "Task name cannot be empty in contract '{}'", - self.name - ))); - } - if task.plan.trim().is_empty() { - return Err(ParseError::ValidationError(format!( - "Task '{}' in contract '{}' has empty plan", - task.name, self.name - ))); - } - } - } - - Ok(()) - } -} - -/// Parse a chain definition from a YAML file. -pub fn parse_chain_file<P: AsRef<Path>>(path: P) -> Result<ChainDefinition, ParseError> { - let content = std::fs::read_to_string(path)?; - parse_chain_yaml(&content) -} - -/// Parse a chain definition from a YAML string. -pub fn parse_chain_yaml(yaml: &str) -> Result<ChainDefinition, ParseError> { - let definition: ChainDefinition = serde_yaml::from_str(yaml)?; - definition.validate()?; - Ok(definition) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_simple_chain() { - let yaml = r#" -name: Test Chain -description: A test chain -contracts: - - name: Research - type: simple - tasks: - - name: Analyze - plan: "Analyze the codebase" - - name: Implement - type: simple - depends_on: [Research] - tasks: - - name: Build - plan: "Build the feature" -"#; - let chain = parse_chain_yaml(yaml).unwrap(); - assert_eq!(chain.name, "Test Chain"); - assert_eq!(chain.contracts.len(), 2); - assert_eq!(chain.contracts[0].name, "Research"); - assert_eq!(chain.contracts[1].name, "Implement"); - assert_eq!( - chain.contracts[1].depends_on, - Some(vec!["Research".to_string()]) - ); - } - - #[test] - fn test_parse_chain_with_loop() { - let yaml = r#" -name: Iterative Chain -contracts: - - name: Phase1 - tasks: - - name: Task1 - plan: "Do something" -loop: - enabled: true - max_iterations: 5 - progress_check: "Check if goals are met" -"#; - let chain = parse_chain_yaml(yaml).unwrap(); - assert!(chain.loop_config.is_some()); - let loop_config = chain.loop_config.unwrap(); - assert!(loop_config.enabled); - assert_eq!(loop_config.max_iterations, 5); - } - - #[test] - fn test_parse_chain_with_deliverables() { - let yaml = r#" -name: Feature Chain -contracts: - - name: Research - tasks: - - name: Survey - plan: "Survey existing code" - deliverables: - - id: analysis - name: Codebase Analysis - priority: required -"#; - let chain = parse_chain_yaml(yaml).unwrap(); - let deliverables = chain.contracts[0].deliverables.as_ref().unwrap(); - assert_eq!(deliverables.len(), 1); - assert_eq!(deliverables[0].id, "analysis"); - } - - #[test] - fn test_validation_empty_name() { - let yaml = r#" -name: "" -contracts: - - name: Phase1 - tasks: - - name: Task1 - plan: "Do something" -"#; - let result = parse_chain_yaml(yaml); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("name cannot be empty")); - } - - #[test] - fn test_validation_no_contracts() { - let yaml = r#" -name: Empty Chain -contracts: [] -"#; - let result = parse_chain_yaml(yaml); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("at least one contract")); - } - - #[test] - fn test_validation_unknown_dependency() { - let yaml = r#" -name: Bad Chain -contracts: - - name: Phase1 - depends_on: [NonExistent] - tasks: - - name: Task1 - plan: "Do something" -"#; - let result = parse_chain_yaml(yaml); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("unknown contract")); - } - - #[test] - fn test_validation_self_dependency() { - let yaml = r#" -name: Self Ref Chain -contracts: - - name: Phase1 - depends_on: [Phase1] - tasks: - - name: Task1 - plan: "Do something" -"#; - let result = parse_chain_yaml(yaml); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("cannot depend on itself")); - } - - #[test] - fn test_validation_duplicate_names() { - let yaml = r#" -name: Dup Chain -contracts: - - name: Phase1 - tasks: - - name: Task1 - plan: "Do something" - - name: Phase1 - tasks: - - name: Task2 - plan: "Do another thing" -"#; - let result = parse_chain_yaml(yaml); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("Duplicate contract names")); - } - - #[test] - fn test_repo_alias() { - let yaml = r#" -name: Repo Chain -repositories: - - name: main - repository_url: https://github.com/user/project -contracts: - - name: Phase1 - tasks: - - name: Task1 - plan: "Work on repo" -"#; - let chain = parse_chain_yaml(yaml).unwrap(); - assert_eq!(chain.repositories.len(), 1); - assert_eq!( - chain.repositories[0].repository_url, - Some("https://github.com/user/project".to_string()) - ); - } -} diff --git a/makima/src/daemon/chain/runner.rs b/makima/src/daemon/chain/runner.rs deleted file mode 100644 index 1814581..0000000 --- a/makima/src/daemon/chain/runner.rs +++ /dev/null @@ -1,388 +0,0 @@ -//! Chain runner - creates and orchestrates contracts from chain definitions. -//! -//! Handles the lifecycle of a chain: -//! 1. Parse chain definition -//! 2. Validate DAG -//! 3. Create chain record -//! 4. Create contracts in dependency order -//! 5. Monitor and trigger dependent contracts - -use std::collections::HashMap; -use std::path::Path; -use thiserror::Error; - -use super::dag::{topological_sort, validate_dag, DagError}; -use super::parser::{parse_chain_file, ChainDefinition, ParseError}; -use crate::db::models::{ - AddChainRepositoryRequest, CreateChainContractRequest, CreateChainDeliverableRequest, - CreateChainRequest, CreateChainTaskRequest, -}; - -/// Error type for chain runner operations. -#[derive(Error, Debug)] -pub enum RunnerError { - #[error("Parse error: {0}")] - Parse(#[from] ParseError), - - #[error("DAG error: {0}")] - Dag(#[from] DagError), - - #[error("API error: {0}")] - Api(String), - - #[error("Contract creation failed: {0}")] - ContractCreation(String), -} - -/// Chain runner for creating and managing chains. -pub struct ChainRunner { - /// Base API URL - #[allow(dead_code)] - api_url: String, - /// API key for authentication - #[allow(dead_code)] - api_key: String, -} - -impl ChainRunner { - /// Create a new chain runner. - pub fn new(api_url: String, api_key: String) -> Self { - Self { api_url, api_key } - } - - /// Load and validate a chain from a YAML file. - pub fn load_chain<P: AsRef<Path>>(&self, path: P) -> Result<ChainDefinition, RunnerError> { - let chain = parse_chain_file(path)?; - validate_dag(&chain)?; - Ok(chain) - } - - /// Convert a chain definition to a CreateChainRequest for API submission. - pub fn to_create_request(&self, chain: &ChainDefinition) -> CreateChainRequest { - let contracts: Vec<CreateChainContractRequest> = chain - .contracts - .iter() - .map(|c| CreateChainContractRequest { - name: c.name.clone(), - description: c.description.clone(), - contract_type: Some(c.contract_type.clone()), - initial_phase: None, - phases: c.phases.clone(), - depends_on: c.depends_on.clone(), - tasks: c.tasks.as_ref().map(|tasks| { - tasks - .iter() - .map(|t| CreateChainTaskRequest { - name: t.name.clone(), - plan: t.plan.clone(), - }) - .collect() - }), - deliverables: c.deliverables.as_ref().map(|dels| { - dels.iter() - .map(|d| CreateChainDeliverableRequest { - id: d.id.clone(), - name: d.name.clone(), - priority: Some(d.priority.clone()), - }) - .collect() - }), - editor_x: None, - editor_y: None, - }) - .collect(); - - let (loop_enabled, loop_max_iterations, loop_progress_check) = - match &chain.loop_config { - Some(lc) => ( - Some(lc.enabled), - Some(lc.max_iterations), - lc.progress_check.clone(), - ), - None => (None, None, None), - }; - - // Convert repository definitions to API format - let repositories: Vec<AddChainRepositoryRequest> = chain - .repositories - .iter() - .map(|r| AddChainRepositoryRequest { - name: r.name.clone(), - repository_url: r.repository_url.clone(), - local_path: r.local_path.clone(), - source_type: r.source_type.clone(), - is_primary: r.is_primary, - }) - .collect(); - - CreateChainRequest { - name: chain.name.clone(), - description: chain.description.clone(), - repository_url: None, // Legacy field, repositories take precedence - repositories: if repositories.is_empty() { - None - } else { - Some(repositories) - }, - loop_enabled, - loop_max_iterations, - loop_progress_check, - contracts: Some(contracts), - } - } - - /// Get contracts in topological order (for display/debugging). - pub fn get_execution_order<'a>( - &self, - chain: &'a ChainDefinition, - ) -> Result<Vec<&'a str>, RunnerError> { - Ok(topological_sort(chain)?) - } - - /// Generate ASCII visualization of the chain DAG. - pub fn visualize_dag(&self, chain: &ChainDefinition) -> String { - use super::dag::get_contract_depths; - - let depths = get_contract_depths(chain); - let mut lines: Vec<String> = vec![]; - - lines.push(format!("Chain: {}", chain.name)); - if let Some(desc) = &chain.description { - lines.push(format!(" {}", desc)); - } - lines.push(String::new()); - - // Group contracts by depth - let mut by_depth: HashMap<usize, Vec<&str>> = HashMap::new(); - for contract in &chain.contracts { - let depth = depths.get(contract.name.as_str()).copied().unwrap_or(0); - by_depth.entry(depth).or_default().push(&contract.name); - } - - // Find max depth - let max_depth = by_depth.keys().max().copied().unwrap_or(0); - - // Build visualization - for depth in 0..=max_depth { - if let Some(contracts) = by_depth.get(&depth) { - let contract_strs: Vec<String> = contracts - .iter() - .map(|name| format!("[{}]", name)) - .collect(); - - let indent = " ".repeat(depth); - lines.push(format!("{}{}", indent, contract_strs.join(" "))); - - // Draw arrows to next level - if depth < max_depth { - if let Some(next_contracts) = by_depth.get(&(depth + 1)) { - // Find which contracts connect to the next level - for next in next_contracts { - let next_contract = chain - .contracts - .iter() - .find(|c| c.name.as_str() == *next) - .unwrap(); - - if let Some(deps) = &next_contract.depends_on { - for dep in deps { - if contracts.contains(&dep.as_str()) { - let arrow_indent = " ".repeat(depth); - lines.push(format!("{} │", arrow_indent)); - lines.push(format!("{} ▼", arrow_indent)); - } - } - } - } - } - } - } - } - - lines.join("\n") - } -} - -/// Compute editor positions for contracts based on DAG layout. -/// -/// Returns a map of contract name to (x, y) positions suitable for -/// the GUI editor. -pub fn compute_editor_positions(chain: &ChainDefinition) -> HashMap<String, (f64, f64)> { - use super::dag::get_contract_depths; - - let depths = get_contract_depths(chain); - let mut positions: HashMap<String, (f64, f64)> = HashMap::new(); - - // Group by depth - let mut by_depth: HashMap<usize, Vec<&str>> = HashMap::new(); - for contract in &chain.contracts { - let depth = depths.get(contract.name.as_str()).copied().unwrap_or(0); - by_depth.entry(depth).or_default().push(&contract.name); - } - - // Compute positions: x based on depth, y based on index within depth - let x_spacing = 250.0; - let y_spacing = 150.0; - - for (depth, contracts) in &by_depth { - let x = (*depth as f64) * x_spacing + 100.0; - for (i, name) in contracts.iter().enumerate() { - let y = (i as f64) * y_spacing + 100.0; - positions.insert(name.to_string(), (x, y)); - } - } - - positions -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::daemon::chain::parser::parse_chain_yaml; - - #[test] - fn test_to_create_request() { - let yaml = r#" -name: Test Chain -description: A test chain -repositories: - - name: main - repository_url: https://github.com/test/repo -contracts: - - name: Research - type: simple - phases: [plan, execute] - tasks: - - name: Analyze - plan: "Analyze the codebase" - deliverables: - - id: analysis - name: Analysis Doc - priority: required - - name: Implement - depends_on: [Research] - tasks: - - name: Build - plan: "Build the feature" -loop: - enabled: true - max_iterations: 5 - progress_check: "Check completion" -"#; - let chain = parse_chain_yaml(yaml).unwrap(); - let runner = ChainRunner::new("http://localhost".to_string(), "key".to_string()); - let request = runner.to_create_request(&chain); - - assert_eq!(request.name, "Test Chain"); - assert_eq!(request.description, Some("A test chain".to_string())); - // Repositories are now in a separate array - let repos = request.repositories.unwrap(); - assert_eq!(repos.len(), 1); - assert_eq!( - repos[0].repository_url, - Some("https://github.com/test/repo".to_string()) - ); - assert_eq!(request.loop_enabled, Some(true)); - assert_eq!(request.loop_max_iterations, Some(5)); - - let contracts = request.contracts.unwrap(); - assert_eq!(contracts.len(), 2); - assert_eq!(contracts[0].name, "Research"); - assert_eq!(contracts[0].phases, Some(vec!["plan".to_string(), "execute".to_string()])); - assert_eq!( - contracts[1].depends_on, - Some(vec!["Research".to_string()]) - ); - } - - #[test] - fn test_get_execution_order() { - let yaml = r#" -name: Order Test -contracts: - - name: C - depends_on: [B] - tasks: - - name: Task - plan: "Do C" - - name: A - tasks: - - name: Task - plan: "Do A" - - name: B - depends_on: [A] - tasks: - - name: Task - plan: "Do B" -"#; - let chain = parse_chain_yaml(yaml).unwrap(); - let runner = ChainRunner::new("http://localhost".to_string(), "key".to_string()); - let order = runner.get_execution_order(&chain).unwrap(); - - let pos_a = order.iter().position(|&n| n == "A").unwrap(); - let pos_b = order.iter().position(|&n| n == "B").unwrap(); - let pos_c = order.iter().position(|&n| n == "C").unwrap(); - - assert!(pos_a < pos_b); - assert!(pos_b < pos_c); - } - - #[test] - fn test_visualize_dag() { - let yaml = r#" -name: Visual Test -description: Test visualization -contracts: - - name: A - tasks: - - name: Task - plan: "Do A" - - name: B - depends_on: [A] - tasks: - - name: Task - plan: "Do B" -"#; - let chain = parse_chain_yaml(yaml).unwrap(); - let runner = ChainRunner::new("http://localhost".to_string(), "key".to_string()); - let viz = runner.visualize_dag(&chain); - - assert!(viz.contains("Chain: Visual Test")); - assert!(viz.contains("[A]")); - assert!(viz.contains("[B]")); - } - - #[test] - fn test_compute_editor_positions() { - let yaml = r#" -name: Position Test -contracts: - - name: A - tasks: - - name: Task - plan: "Do A" - - name: B - depends_on: [A] - tasks: - - name: Task - plan: "Do B" - - name: C - depends_on: [A] - tasks: - - name: Task - plan: "Do C" -"#; - let chain = parse_chain_yaml(yaml).unwrap(); - let positions = compute_editor_positions(&chain); - - // A should be at depth 0 (x = 100) - let (a_x, _) = positions.get("A").unwrap(); - assert_eq!(*a_x, 100.0); - - // B and C should be at depth 1 (x = 350) - let (b_x, _) = positions.get("B").unwrap(); - let (c_x, _) = positions.get("C").unwrap(); - assert_eq!(*b_x, 350.0); - assert_eq!(*c_x, 350.0); - } -} diff --git a/makima/src/daemon/cli/chain.rs b/makima/src/daemon/cli/chain.rs deleted file mode 100644 index 1d7c167..0000000 --- a/makima/src/daemon/cli/chain.rs +++ /dev/null @@ -1,107 +0,0 @@ -//! Chain CLI commands for multi-contract orchestration. -//! -//! Provides commands for creating, managing, and visualizing chains -//! (DAGs of contracts). - -use clap::Args; -use std::path::PathBuf; -use uuid::Uuid; - -/// Common arguments for chain commands requiring API access. -#[derive(Args, Debug, Clone)] -pub struct ChainArgs { - /// 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 the `run` command (create chain from YAML file). -#[derive(Args, Debug)] -pub struct RunArgs { - #[command(flatten)] - pub common: ChainArgs, - - /// Path to the chain YAML file - pub file: PathBuf, - - /// Don't actually create the chain, just validate and show what would be created - #[arg(long)] - pub dry_run: bool, -} - -/// Arguments for the `status` command. -#[derive(Args, Debug)] -pub struct StatusArgs { - #[command(flatten)] - pub common: ChainArgs, - - /// Chain ID - pub chain_id: Uuid, -} - -/// Arguments for the `list` command. -#[derive(Args, Debug)] -pub struct ListArgs { - #[command(flatten)] - pub common: ChainArgs, - - /// Filter by status (active, completed, archived) - #[arg(long)] - pub status: Option<String>, - - /// Limit number of results - #[arg(long, default_value = "50")] - pub limit: i32, -} - -/// Arguments for the `contracts` command. -#[derive(Args, Debug)] -pub struct ContractsArgs { - #[command(flatten)] - pub common: ChainArgs, - - /// Chain ID - pub chain_id: Uuid, -} - -/// Arguments for the `graph` command (ASCII DAG visualization). -#[derive(Args, Debug)] -pub struct GraphArgs { - #[command(flatten)] - pub common: ChainArgs, - - /// Chain ID - pub chain_id: Uuid, - - /// Show contract status in nodes - #[arg(long)] - pub with_status: bool, -} - -/// Arguments for the `validate` command. -#[derive(Args, Debug)] -pub struct ValidateArgs { - /// Path to the chain YAML file - pub file: PathBuf, -} - -/// Arguments for the `preview` command. -#[derive(Args, Debug)] -pub struct PreviewArgs { - /// Path to the chain YAML file - pub file: PathBuf, -} - -/// Arguments for the `archive` command. -#[derive(Args, Debug)] -pub struct ArchiveArgs { - #[command(flatten)] - pub common: ChainArgs, - - /// Chain ID - pub chain_id: Uuid, -} diff --git a/makima/src/daemon/cli/mod.rs b/makima/src/daemon/cli/mod.rs index 91ef87c..77eee80 100644 --- a/makima/src/daemon/cli/mod.rs +++ b/makima/src/daemon/cli/mod.rs @@ -1,6 +1,5 @@ //! Command-line interface for the makima CLI. -pub mod chain; pub mod config; pub mod contract; pub mod daemon; @@ -11,7 +10,6 @@ pub mod view; use clap::{Parser, Subcommand}; -pub use chain::ChainArgs; pub use config::CliConfig; pub use contract::ContractArgs; pub use daemon::DaemonArgs; @@ -63,14 +61,6 @@ pub enum Commands { #[command(subcommand)] Config(ConfigCommand), - /// Chain commands for multi-contract orchestration - /// - /// Chains are DAGs (directed acyclic graphs) of contracts that work together - /// to achieve a larger goal. Contracts can depend on each other, and run - /// in parallel when no dependencies exist. - #[command(subcommand)] - Chain(ChainCommand), - /// Directive commands for autonomous goal-driven orchestration /// /// Directives are top-level goals that generate chains of steps executed @@ -216,48 +206,6 @@ pub enum ContractCommand { CreateFile(contract::CreateFileArgs), } -/// Chain subcommands for multi-contract orchestration. -#[derive(Subcommand, Debug)] -pub enum ChainCommand { - /// Create a chain from a YAML file - /// - /// Parses the chain definition, validates the DAG, and creates - /// contracts in the correct dependency order. - Run(chain::RunArgs), - - /// Get chain status and progress - Status(chain::StatusArgs), - - /// List all chains - List(chain::ListArgs), - - /// List contracts in a chain - Contracts(chain::ContractsArgs), - - /// Display ASCII DAG visualization - /// - /// Shows the chain structure as an ASCII graph with - /// contracts as nodes and dependencies as edges. - Graph(chain::GraphArgs), - - /// Validate a chain YAML file without creating - /// - /// Checks syntax, validates the DAG (no cycles), and - /// reports any errors. - Validate(chain::ValidateArgs), - - /// Preview what would be created from a chain file - /// - /// Shows execution order and contract details without - /// actually creating anything. - Preview(chain::PreviewArgs), - - /// Archive a chain - /// - /// Marks the chain as archived. Does not delete contracts. - Archive(chain::ArchiveArgs), -} - /// Directive subcommands for autonomous goal-driven orchestration. #[derive(Subcommand, Debug)] pub enum DirectiveCommand { diff --git a/makima/src/daemon/mod.rs b/makima/src/daemon/mod.rs index 1ddc4cb..13f0862 100644 --- a/makima/src/daemon/mod.rs +++ b/makima/src/daemon/mod.rs @@ -8,7 +8,6 @@ //! - `makima view` - Interactive TUI browser for tasks, contracts, and files pub mod api; -pub mod chain; pub mod cli; pub mod config; pub mod db; diff --git a/makima/src/daemon/skill_installer.rs b/makima/src/daemon/skill_installer.rs index 87cdc29..4870971 100644 --- a/makima/src/daemon/skill_installer.rs +++ b/makima/src/daemon/skill_installer.rs @@ -3,7 +3,7 @@ //! This module installs makima CLI commands as Claude Code skills //! to ~/.claude/skills/ on daemon startup. Skills allow Claude Code //! instances to use makima commands via slash commands like -//! `/makima-supervisor`, `/makima-contract`, and `/makima-chain`. +//! `/makima-supervisor`, `/makima-contract`, and `/makima-directive`. use std::path::PathBuf; use tokio::fs; diff --git a/makima/src/daemon/skills/chain.md b/makima/src/daemon/skills/chain.md deleted file mode 100644 index 7831540..0000000 --- a/makima/src/daemon/skills/chain.md +++ /dev/null @@ -1,116 +0,0 @@ ---- -name: makima-chain -description: Chain commands for makima multi-contract orchestration. Use when running chains of contracts defined in YAML, checking chain status, or managing contract dependencies. ---- - -# Makima Chain Commands - -Chains are DAGs (directed acyclic graphs) of contracts that work together. Contracts can depend on each other and run in parallel when no dependencies exist. - -Environment variables (`MAKIMA_API_URL`, `MAKIMA_API_KEY`) must be set. - -## Running Chains - -### Run chain from YAML -```bash -makima chain run <yaml_file> -``` -Parses the chain definition, validates the DAG, and creates contracts. - -Options: -- `--dry-run` - Validate and preview without creating - -### Validate chain YAML -```bash -makima chain validate <yaml_file> -``` -Checks syntax and validates DAG structure (no cycles). - -### Preview chain -```bash -makima chain preview <yaml_file> -``` -Shows execution order and contract details without creating. - -## Chain Status - -### Get chain status -```bash -makima chain status <chain_id> -``` - -### List all chains -```bash -makima chain list -``` -Options: -- `--status <active|completed|archived>` - Filter by status -- `--limit <n>` - Limit results (default: 50) - -### List contracts in chain -```bash -makima chain contracts <chain_id> -``` - -### Display ASCII DAG visualization -```bash -makima chain graph <chain_id> -``` -Options: -- `--with-status` - Show contract status in visualization - -## Chain Management - -### Archive chain -```bash -makima chain archive <chain_id> -``` -Marks chain as archived (does not delete contracts). - -## Chain YAML Format - -```yaml -name: my-chain -description: Optional description -repository_url: https://github.com/org/repo - -contracts: - - name: setup - contract_type: implementation - description: Initial setup work - - - name: feature-a - contract_type: implementation - depends_on: [setup] - description: Implement feature A - - - name: feature-b - contract_type: implementation - depends_on: [setup] - description: Implement feature B (parallel with A) - - - name: integration - contract_type: review - depends_on: [feature-a, feature-b] - description: Integrate and test -``` - -## Output Format - -All commands output JSON to stdout. - -Example workflow: -```bash -# Validate before running -makima chain validate my-chain.yaml - -# Preview execution -makima chain preview my-chain.yaml - -# Run the chain -chain_id=$(makima chain run my-chain.yaml | jq -r '.chainId') - -# Monitor progress -makima chain status "$chain_id" -makima chain graph "$chain_id" --with-status -``` diff --git a/makima/src/daemon/skills/chain_directive.md b/makima/src/daemon/skills/chain_directive.md deleted file mode 100644 index 53ac96b..0000000 --- a/makima/src/daemon/skills/chain_directive.md +++ /dev/null @@ -1,224 +0,0 @@ ---- -name: makima-chain-directive -description: Directive contract tools for orchestrating chains. Use when creating chains from goals, adding contracts to chains, evaluating completions, or managing chain structure. ---- - -# Chain Directive Contract Tools - -Directive contracts are special contracts that research, plan, create, and orchestrate chains. They use formal directives with requirements and acceptance criteria, and evaluate each contract completion before allowing the chain to progress. - -## Workflow Overview - -1. **Init**: Create a directive contract + empty chain from a goal -2. **Research**: Directive contract explores codebase, understands requirements -3. **Specify**: Write formal directive with requirements (REQ-001, etc.) and acceptance criteria -4. **Plan**: Design chain structure, add contracts, set dependencies -5. **Execute**: Finalize chain, start execution, evaluate completions -6. **Review**: All contracts complete, create final report - -## Creating a Chain from a Goal - -### Initialize directive-driven chain -``` -POST /api/v1/chains/init -{ - "goal": "Add OAuth2 authentication support", - "repository_url": "https://github.com/org/repo", - "local_path": "/path/to/repo", - "phase_guard": true -} -``` - -Returns: -- `chain_id` - The created chain -- `directive_contract_id` - The directive contract orchestrating the chain -- `supervisor_task_id` - Task ID for the directive contract supervisor - -## Chain Design Tools (for directive contracts) - -These tools are available when working on a directive contract: - -### create_chain_from_directive -Create a new chain linked to this directive contract. -```json -{ - "name": "oauth-implementation", - "description": "Chain for OAuth2 implementation" -} -``` - -### add_chain_contract -Add a contract definition to the chain. -```json -{ - "name": "auth-backend", - "description": "Implement authentication backend", - "contract_type": "implementation", - "depends_on": ["setup"], - "requirement_ids": ["REQ-001", "REQ-002"] -} -``` - -### set_chain_dependencies -Update dependency relationships. -```json -{ - "contract_name": "integration-tests", - "depends_on": ["auth-backend", "auth-frontend"] -} -``` - -### modify_chain_contract -Update a contract definition. -```json -{ - "name": "auth-backend", - "new_name": "authentication-service", - "description": "Updated description", - "add_requirement_ids": ["REQ-003"], - "remove_requirement_ids": ["REQ-001"] -} -``` - -### remove_chain_contract -Remove a contract definition (fails if others depend on it). -```json -{ - "name": "unused-contract" -} -``` - -### preview_chain_dag -Generate visual DAG preview of the chain structure. -Returns ASCII diagram and JSON nodes. - -### validate_chain_directive -Validate chain structure before finalizing. -Checks for: -- Empty chains -- Missing dependencies -- Circular dependencies -- Uncovered requirements - -### finalize_chain_directive -Lock the directive and optionally start chain execution. -```json -{ - "auto_start": true -} -``` - -## Orchestration Tools (during execution) - -### get_chain_status -Get current chain progress and contract statuses. -Returns completed/active/pending counts and contract details. - -### get_uncovered_requirements -List requirements not mapped to any contract. -Returns uncovered requirement IDs and coverage percentage. - -### evaluate_contract_completion -Evaluate a completed contract against the directive. -```json -{ - "contract_id": "uuid", - "passed": true, - "feedback": "All acceptance criteria met", - "rework_instructions": null -} -``` - -### request_rework -Reject completion and request rework. -```json -{ - "contract_id": "uuid", - "feedback": "Missing error handling for edge cases" -} -``` - -## Evaluation Flow - -When a contract completes and evaluation is enabled: - -1. Contract status changes to `completed` -2. Chain contract marked as `pending_evaluation` -3. Directive contract evaluates using `evaluate_contract_completion` -4. **Pass**: Chain progresses, downstream contracts created -5. **Fail**: Contract marked for rework, retry count incremented -6. After max retries (default 3), escalate to user - -## Directive Document Structure - -The directive contains: - -```json -{ - "requirements": [ - { - "id": "REQ-001", - "title": "User Authentication", - "description": "Users must be able to log in with email/password", - "priority": "must", - "category": "feature" - } - ], - "acceptance_criteria": [ - { - "id": "AC-001", - "requirement_ids": ["REQ-001"], - "description": "Login endpoint returns JWT on valid credentials", - "testable": true, - "verification_method": "automated" - } - ], - "constraints": [ - { - "id": "CON-001", - "type": "technical", - "description": "Must use existing PostgreSQL database" - } - ], - "external_dependencies": [ - { - "id": "EXT-001", - "name": "OAuth Provider API", - "type": "api", - "required": true - } - ] -} -``` - -## Example Workflow - -```bash -# 1. Initialize a directive-driven chain -curl -X POST http://localhost:3000/api/v1/chains/init \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"goal": "Add user profile editing feature"}' - -# 2. Directive contract goes through phases: -# - Research: Explores codebase -# - Specify: Writes formal directive -# - Plan: Creates chain contracts using tools -# - Execute: Monitors and evaluates completions - -# 3. Monitor chain progress -curl http://localhost:3000/api/v1/chains/$CHAIN_ID \ - -H "Authorization: Bearer $TOKEN" - -# 4. View directive traceability -curl http://localhost:3000/api/v1/chains/$CHAIN_ID/directive/traceability \ - -H "Authorization: Bearer $TOKEN" -``` - -## Key Concepts - -- **Directive Contract**: The orchestrator that creates and manages the chain -- **Formal Directive**: Structured specification with traceable requirements -- **Continuous Evaluation**: LLM evaluates after every contract completion -- **Block & Rework**: Failed evaluations block progress until fixed -- **Dynamic Modification**: Chain structure can be modified during execution diff --git a/makima/src/daemon/skills/mod.rs b/makima/src/daemon/skills/mod.rs index dafa9ec..c32f550 100644 --- a/makima/src/daemon/skills/mod.rs +++ b/makima/src/daemon/skills/mod.rs @@ -9,9 +9,6 @@ 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"); -/// Chain skill content - multi-contract orchestration commands (legacy) -pub const CHAIN_SKILL: &str = include_str!("chain.md"); - /// Directive skill content - autonomous goal-driven orchestration pub const DIRECTIVE_SKILL: &str = include_str!("directive.md"); @@ -19,6 +16,5 @@ pub const DIRECTIVE_SKILL: &str = include_str!("directive.md"); pub const ALL_SKILLS: &[(&str, &str)] = &[ ("makima-supervisor", SUPERVISOR_SKILL), ("makima-contract", CONTRACT_SKILL), - ("makima-chain", CHAIN_SKILL), ("makima-directive", DIRECTIVE_SKILL), ]; diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index 3a96165..f951751 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -3137,6 +3137,27 @@ pub struct ApprovalActionRequest { pub response: Option<String>, } +/// Request to update directive requirements +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateRequirementsRequest { + pub requirements: Vec<DirectiveRequirement>, +} + +/// Request to update directive acceptance criteria +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateCriteriaRequest { + pub acceptance_criteria: Vec<DirectiveAcceptanceCriterion>, +} + +/// Request to trigger step rework +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ReworkStepRequest { + pub instructions: Option<String>, +} + /// Directive requirement (shared type used in directive specification) #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] diff --git a/makima/src/orchestration/engine.rs b/makima/src/orchestration/engine.rs index c794156..470db40 100644 --- a/makima/src/orchestration/engine.rs +++ b/makima/src/orchestration/engine.rs @@ -110,8 +110,8 @@ impl DirectiveEngine { /// Create a new directive engine. pub fn new(pool: PgPool) -> Self { Self { + planner: ChainPlanner::new(pool.clone()), pool, - planner: ChainPlanner::new(), event_tx: None, } } diff --git a/makima/src/orchestration/mod.rs b/makima/src/orchestration/mod.rs index 41913ca..8c21089 100644 --- a/makima/src/orchestration/mod.rs +++ b/makima/src/orchestration/mod.rs @@ -19,8 +19,8 @@ mod planner; mod verifier; pub use engine::{DirectiveEngine, EngineError}; -pub use planner::{ChainPlanner, PlannerError}; +pub use planner::{ChainPlanner, GeneratedSpec, PlannerError}; pub use verifier::{ auto_detect_verifiers, CompositeEvaluator, ConfidenceLevel, EvaluationResult, Verifier, - VerifierError, VerifierResult, VerifierType, + VerifierError, VerifierInfo, VerifierResult, VerifierType, }; diff --git a/makima/src/orchestration/planner.rs b/makima/src/orchestration/planner.rs index cdca8a0..aec2e48 100644 --- a/makima/src/orchestration/planner.rs +++ b/makima/src/orchestration/planner.rs @@ -93,22 +93,38 @@ pub struct GeneratedChain { pub steps: Vec<GeneratedStep>, } +/// Generated specification from LLM. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GeneratedSpec { + /// Generated title (if improved from goal) + pub title: Option<String>, + /// Structured requirements + pub requirements: serde_json::Value, + /// Structured acceptance criteria + pub acceptance_criteria: serde_json::Value, + /// Constraints extracted from goal + pub constraints: Option<serde_json::Value>, +} + /// Chain planner for LLM-based plan generation. pub struct ChainPlanner { /// Default step types to suggest (reserved for future use) #[allow(dead_code)] default_step_types: Vec<String>, + /// Database pool for persistence + #[allow(dead_code)] + pool: Option<sqlx::PgPool>, } impl Default for ChainPlanner { fn default() -> Self { - Self::new() + Self::without_pool() } } impl ChainPlanner { - /// Create a new chain planner. - pub fn new() -> Self { + /// Create a new chain planner without database pool. + pub fn without_pool() -> Self { Self { default_step_types: vec![ "research".to_string(), @@ -118,9 +134,82 @@ impl ChainPlanner { "review".to_string(), "document".to_string(), ], + pool: None, } } + /// Create a new chain planner (backwards compatible). + pub fn new(pool: sqlx::PgPool) -> Self { + Self { + default_step_types: vec![ + "research".to_string(), + "design".to_string(), + "implement".to_string(), + "test".to_string(), + "review".to_string(), + "document".to_string(), + ], + pool: Some(pool), + } + } + + /// Generate a specification from a directive's goal. + /// + /// Analyzes the goal text to produce structured requirements and + /// acceptance criteria. In production, this would call an LLM for + /// richer spec generation. + pub async fn generate_spec( + &self, + directive: &Directive, + ) -> Result<GeneratedSpec, PlannerError> { + // Build a prompt for spec generation + let prompt = format!( + r#"Analyze this goal and generate structured requirements and acceptance criteria. + +Goal: {} + +Generate a JSON response with: +- title: A concise title +- requirements: Array of {{id, title, description, priority, category}} +- acceptance_criteria: Array of {{id, requirementIds, description, testable, verificationMethod}} +- constraints: Array of constraint strings"#, + directive.goal + ); + + // For now, generate a basic spec from the goal text. + // When LLM integration is available, this will call the LLM with the prompt. + let _prompt = prompt; // Will be used when LLM is wired up + + let title = generate_title_from_goal(&directive.goal); + + let requirements = serde_json::json!([ + { + "id": "REQ-001", + "title": title, + "description": directive.goal, + "priority": "required", + "category": "core" + } + ]); + + let acceptance_criteria = serde_json::json!([ + { + "id": "AC-001", + "requirementIds": ["REQ-001"], + "description": format!("Goal is achieved: {}", directive.goal), + "testable": true, + "verificationMethod": "manual" + } + ]); + + Ok(GeneratedSpec { + title: Some(title), + requirements, + acceptance_criteria, + constraints: None, + }) + } + /// Build a planning prompt for the LLM. pub fn build_planning_prompt(&self, directive: &Directive) -> String { let requirements: Vec<String> = directive @@ -578,6 +667,23 @@ Use the same JSON format as before. Do not include already completed steps."#, } } +/// Generate a concise title from a goal string. +fn generate_title_from_goal(goal: &str) -> String { + // Take the first sentence or first 80 chars + let title = if let Some(pos) = goal.find('.') { + if pos < 100 { + &goal[..pos] + } else { + &goal[..80.min(goal.len())] + } + } else if goal.len() > 80 { + &goal[..80] + } else { + goal + }; + title.trim().to_string() +} + /// Extract JSON from LLM response (handles markdown code blocks). fn extract_json_from_response(response: &str) -> Result<String, PlannerError> { // Try to find JSON in code block @@ -650,14 +756,14 @@ mod tests { #[test] fn test_validate_chain_valid() { - let planner = ChainPlanner::new(); + let planner = ChainPlanner::without_pool(); let chain = make_test_chain(); assert!(planner.validate_chain(&chain).is_ok()); } #[test] fn test_validate_chain_invalid_dependency() { - let planner = ChainPlanner::new(); + let planner = ChainPlanner::without_pool(); let mut chain = make_test_chain(); chain.steps[1].depends_on = vec!["nonexistent".to_string()]; @@ -667,7 +773,7 @@ mod tests { #[test] fn test_validate_chain_cycle() { - let planner = ChainPlanner::new(); + let planner = ChainPlanner::without_pool(); let chain = GeneratedChain { name: "cyclic".to_string(), description: "Has cycle".to_string(), @@ -705,7 +811,7 @@ mod tests { #[test] fn test_topological_sort() { - let planner = ChainPlanner::new(); + let planner = ChainPlanner::without_pool(); let chain = make_test_chain(); let order = planner.topological_sort(&chain).unwrap(); diff --git a/makima/src/orchestration/verifier.rs b/makima/src/orchestration/verifier.rs index e98da50..bc29e47 100644 --- a/makima/src/orchestration/verifier.rs +++ b/makima/src/orchestration/verifier.rs @@ -290,6 +290,18 @@ pub enum VerifierError { Io(#[from] std::io::Error), } +/// Information about a verifier for serialization and database storage. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VerifierInfo { + pub name: String, + pub verifier_type: String, + pub command: String, + pub working_directory: Option<String>, + pub detect_files: Vec<String>, + pub weight: f64, + pub required: bool, +} + /// Verifier trait for pluggable verification implementations. #[async_trait] pub trait Verifier: Send + Sync { @@ -299,6 +311,9 @@ pub trait Verifier: Send + Sync { /// Get the type of this verifier. fn verifier_type(&self) -> VerifierType; + /// Get serializable info about this verifier. + fn info(&self) -> VerifierInfo; + /// Check if this verifier is applicable to the given repository. async fn is_applicable(&self, repo_path: &Path) -> bool; @@ -393,6 +408,18 @@ impl Verifier for CommandVerifier { self.verifier_type.clone() } + fn info(&self) -> VerifierInfo { + VerifierInfo { + name: self.name.clone(), + verifier_type: self.verifier_type.as_str().to_string(), + command: self.command.clone(), + working_directory: self.working_dir.clone(), + detect_files: self.applicable_patterns.clone(), + weight: 1.0, + required: self.required, + } + } + async fn is_applicable(&self, repo_path: &Path) -> bool { if self.applicable_patterns.is_empty() { return true; diff --git a/makima/src/server/handlers/chains.rs b/makima/src/server/handlers/chains.rs deleted file mode 100644 index b8716ca..0000000 --- a/makima/src/server/handlers/chains.rs +++ /dev/null @@ -1,1644 +0,0 @@ -//! HTTP handlers for chain CRUD operations. -//! -//! Chains are DAGs (directed acyclic graphs) of contracts for multi-contract orchestration. - -use axum::{ - extract::{Path, Query, State}, - http::StatusCode, - response::IntoResponse, - Json, -}; -use serde::Deserialize; -use utoipa::ToSchema; -use uuid::Uuid; - -use crate::db::models::{ - AddChainRepositoryRequest, AddContractDefinitionRequest, ChainContractDefinition, - ChainContractDetail, ChainDefinitionGraphResponse, ChainEditorData, ChainEvent, - ChainGraphResponse, ChainRepository, ChainSummary, ChainWithContracts, CreateChainRequest, - InitChainRequest, InitChainResponse, StartChainRequest, StartChainResponse, UpdateChainRequest, - UpdateContractDefinitionRequest, -}; -use crate::db::repository::{self, RepositoryError}; -use crate::server::auth::Authenticated; -use crate::server::messages::ApiError; -use crate::server::state::SharedState; - -// ============================================================================= -// Query Parameters -// ============================================================================= - -/// Query parameters for listing chains. -#[derive(Debug, Deserialize, ToSchema)] -pub struct ListChainsQuery { - /// Filter by status (active, completed, archived) - pub status: Option<String>, - /// Maximum number of results - #[serde(default = "default_limit")] - pub limit: i32, - /// Offset for pagination - #[serde(default)] - pub offset: i32, -} - -fn default_limit() -> i32 { - 50 -} - -// ============================================================================= -// Response Types -// ============================================================================= - -/// Response for listing chains. -#[derive(Debug, serde::Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ChainListResponse { - pub chains: Vec<ChainSummary>, - pub total: i64, -} - -// ============================================================================= -// Handlers -// ============================================================================= - -/// List chains for the authenticated user. -/// -/// GET /api/v1/chains -#[utoipa::path( - get, - path = "/api/v1/chains", - responses( - (status = 200, description = "List of chains", body = ChainListResponse), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError) - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Chains" -)] -pub async fn list_chains( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Query(query): Query<ListChainsQuery>, -) -> 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_chains_for_owner(pool, auth.owner_id).await { - Ok(mut chains) => { - // Apply filters - if let Some(status) = &query.status { - chains.retain(|c| c.status == *status); - } - // Apply pagination - let total = chains.len() as i64; - let chains: Vec<_> = chains - .into_iter() - .skip(query.offset as usize) - .take(query.limit as usize) - .collect(); - Json(ChainListResponse { chains, total }).into_response() - } - Err(e) => { - tracing::error!("Failed to list chains: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Create a new chain with contracts. -/// -/// POST /api/v1/chains -#[utoipa::path( - post, - path = "/api/v1/chains", - request_body = CreateChainRequest, - responses( - (status = 201, description = "Chain created"), - (status = 400, description = "Invalid request", body = ApiError), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError) - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Chains" -)] -pub async fn create_chain( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Json(req): Json<CreateChainRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Validate the request - if req.name.trim().is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(ApiError::new("VALIDATION_ERROR", "Chain name cannot be empty")), - ) - .into_response(); - } - - match repository::create_chain_for_owner(pool, auth.owner_id, req).await { - Ok(chain) => (StatusCode::CREATED, Json(chain)).into_response(), - Err(e) => { - tracing::error!("Failed to create chain: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Initialize a directive-driven chain. -/// -/// Creates a directive contract that will research, plan, create, and orchestrate -/// a chain of contracts to accomplish the given goal. The directive contract goes -/// through Research -> Specify -> Plan -> Execute -> Review phases. -/// -/// POST /api/v1/chains/init -#[utoipa::path( - post, - path = "/api/v1/chains/init", - request_body = InitChainRequest, - responses( - (status = 201, description = "Directive chain initialized", body = InitChainResponse), - (status = 400, description = "Invalid request", body = ApiError), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError) - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Chains" -)] -pub async fn init_chain( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Json(req): Json<InitChainRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Validate the request - if req.goal.trim().is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(ApiError::new("VALIDATION_ERROR", "Goal cannot be empty")), - ) - .into_response(); - } - - match repository::init_chain_for_owner(pool, auth.owner_id, req).await { - Ok(response) => (StatusCode::CREATED, Json(response)).into_response(), - Err(e) => { - tracing::error!("Failed to initialize directive chain: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Get a chain by ID. -/// -/// GET /api/v1/chains/{id} -#[utoipa::path( - get, - path = "/api/v1/chains/{id}", - params( - ("id" = Uuid, Path, description = "Chain ID") - ), - responses( - (status = 200, description = "Chain with contracts", body = ChainWithContracts), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError) - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Chains" -)] -pub async fn get_chain( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(chain_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_chain_with_contracts(pool, chain_id, auth.owner_id).await { - Ok(Some(chain)) => Json(chain).into_response(), - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to get chain: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Update a chain. -/// -/// PUT /api/v1/chains/{id} -#[utoipa::path( - put, - path = "/api/v1/chains/{id}", - params( - ("id" = Uuid, Path, description = "Chain ID") - ), - request_body = UpdateChainRequest, - responses( - (status = 200, description = "Chain updated"), - (status = 400, description = "Invalid request", body = ApiError), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain not found", body = ApiError), - (status = 409, description = "Version conflict", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError) - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Chains" -)] -pub async fn update_chain( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(chain_id): Path<Uuid>, - Json(req): Json<UpdateChainRequest>, -) -> 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_chain_for_owner(pool, chain_id, auth.owner_id, req).await { - Ok(chain) => Json(chain).into_response(), - Err(RepositoryError::VersionConflict { expected, actual }) => ( - StatusCode::CONFLICT, - Json(ApiError::new( - "VERSION_CONFLICT", - format!("Version conflict: expected {}, found {}", expected, actual), - )), - ) - .into_response(), - Err(RepositoryError::Database(e)) => { - // Check if it's a "row not found" error - let error_str = e.to_string(); - if error_str.contains("no rows") || error_str.contains("RowNotFound") { - ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response() - } else { - tracing::error!("Failed to update chain: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } - } -} - -/// Delete (archive) a chain. -/// -/// DELETE /api/v1/chains/{id} -#[utoipa::path( - delete, - path = "/api/v1/chains/{id}", - params( - ("id" = Uuid, Path, description = "Chain ID") - ), - responses( - (status = 200, description = "Chain archived"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError) - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Chains" -)] -pub async fn delete_chain( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(chain_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_chain_for_owner(pool, chain_id, auth.owner_id).await { - Ok(true) => Json(serde_json::json!({"archived": true})).into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to delete chain: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Get contracts in a chain. -/// -/// GET /api/v1/chains/{id}/contracts -#[utoipa::path( - get, - path = "/api/v1/chains/{id}/contracts", - params( - ("id" = Uuid, Path, description = "Chain ID") - ), - responses( - (status = 200, description = "List of contracts in chain", body = Vec<ChainContractDetail>), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError) - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Chains" -)] -pub async fn get_chain_contracts( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(chain_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 - match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to verify chain ownership: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::list_chain_contracts(pool, chain_id).await { - Ok(contracts) => Json(contracts).into_response(), - Err(e) => { - tracing::error!("Failed to list chain contracts: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Get chain DAG structure for visualization. -/// -/// GET /api/v1/chains/{id}/graph -#[utoipa::path( - get, - path = "/api/v1/chains/{id}/graph", - params( - ("id" = Uuid, Path, description = "Chain ID") - ), - responses( - (status = 200, description = "Chain graph structure", body = ChainGraphResponse), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError) - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Chains" -)] -pub async fn get_chain_graph( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(chain_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 first - match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to verify chain ownership: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::get_chain_graph(pool, chain_id).await { - Ok(Some(graph)) => Json(graph).into_response(), - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to get chain graph: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Get chain events. -/// -/// GET /api/v1/chains/{id}/events -#[utoipa::path( - get, - path = "/api/v1/chains/{id}/events", - params( - ("id" = Uuid, Path, description = "Chain ID") - ), - responses( - (status = 200, description = "Chain events", body = Vec<ChainEvent>), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError) - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Chains" -)] -pub async fn get_chain_events( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(chain_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 - match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to verify chain ownership: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::list_chain_events(pool, chain_id).await { - Ok(events) => Json(events).into_response(), - Err(e) => { - tracing::error!("Failed to list chain events: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Get chain editor data. -/// -/// GET /api/v1/chains/{id}/editor -#[utoipa::path( - get, - path = "/api/v1/chains/{id}/editor", - params( - ("id" = Uuid, Path, description = "Chain ID") - ), - responses( - (status = 200, description = "Chain editor data", body = ChainEditorData), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError) - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Chains" -)] -pub async fn get_chain_editor( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(chain_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_chain_editor_data(pool, chain_id, auth.owner_id).await { - Ok(Some(editor_data)) => Json(editor_data).into_response(), - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to get chain editor data: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -// ============================================================================= -// Contract Definition Handlers -// ============================================================================= - -/// List contract definitions for a chain. -/// -/// GET /api/v1/chains/{id}/definitions -#[utoipa::path( - get, - path = "/api/v1/chains/{id}/definitions", - params( - ("id" = Uuid, Path, description = "Chain ID") - ), - responses( - (status = 200, description = "List of contract definitions", body = Vec<ChainContractDefinition>), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError) - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Chains" -)] -pub async fn list_chain_definitions( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(chain_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 - match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to verify chain ownership: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::list_chain_contract_definitions(pool, chain_id).await { - Ok(definitions) => Json(definitions).into_response(), - Err(e) => { - tracing::error!("Failed to list chain definitions: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Create a contract definition for a chain. -/// -/// POST /api/v1/chains/{id}/definitions -#[utoipa::path( - post, - path = "/api/v1/chains/{id}/definitions", - params( - ("id" = Uuid, Path, description = "Chain ID") - ), - request_body = AddContractDefinitionRequest, - responses( - (status = 201, description = "Contract definition created", body = ChainContractDefinition), - (status = 400, description = "Invalid request", body = ApiError), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError) - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Chains" -)] -pub async fn create_chain_definition( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(chain_id): Path<Uuid>, - Json(req): Json<AddContractDefinitionRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Validate the request - if req.name.trim().is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(ApiError::new("VALIDATION_ERROR", "Definition name cannot be empty")), - ) - .into_response(); - } - - // Verify ownership - match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to verify chain ownership: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::create_chain_contract_definition(pool, chain_id, req).await { - Ok(definition) => (StatusCode::CREATED, Json(definition)).into_response(), - Err(e) => { - tracing::error!("Failed to create chain definition: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Update a contract definition. -/// -/// PUT /api/v1/chains/{chain_id}/definitions/{definition_id} -#[utoipa::path( - put, - path = "/api/v1/chains/{chain_id}/definitions/{definition_id}", - params( - ("chain_id" = Uuid, Path, description = "Chain ID"), - ("definition_id" = Uuid, Path, description = "Definition ID") - ), - request_body = UpdateContractDefinitionRequest, - responses( - (status = 200, description = "Contract definition updated", body = ChainContractDefinition), - (status = 400, description = "Invalid request", body = ApiError), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain or definition not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError) - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Chains" -)] -pub async fn update_chain_definition( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path((chain_id, definition_id)): Path<(Uuid, Uuid)>, - Json(req): Json<UpdateContractDefinitionRequest>, -) -> 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 - match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to verify chain ownership: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - // Verify definition belongs to this chain - match repository::get_chain_contract_definition(pool, definition_id).await { - Ok(Some(def)) if def.chain_id == chain_id => {} - Ok(Some(_)) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Definition not found in this chain")), - ) - .into_response(); - } - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Definition not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get chain definition: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::update_chain_contract_definition(pool, definition_id, req).await { - Ok(definition) => Json(definition).into_response(), - Err(e) => { - tracing::error!("Failed to update chain definition: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Delete a contract definition. -/// -/// DELETE /api/v1/chains/{chain_id}/definitions/{definition_id} -#[utoipa::path( - delete, - path = "/api/v1/chains/{chain_id}/definitions/{definition_id}", - params( - ("chain_id" = Uuid, Path, description = "Chain ID"), - ("definition_id" = Uuid, Path, description = "Definition ID") - ), - responses( - (status = 200, description = "Contract definition deleted"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain or definition not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError) - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Chains" -)] -pub async fn delete_chain_definition( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path((chain_id, definition_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 ownership - match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to verify chain ownership: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - // Verify definition belongs to this chain before deleting - match repository::get_chain_contract_definition(pool, definition_id).await { - Ok(Some(def)) if def.chain_id == chain_id => {} - Ok(Some(_)) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Definition not found in this chain")), - ) - .into_response(); - } - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Definition not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get chain definition: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::delete_chain_contract_definition(pool, definition_id).await { - Ok(true) => Json(serde_json::json!({"deleted": true})).into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Definition not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to delete chain definition: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Get definition graph for a chain (shows definitions + instantiation status). -/// -/// GET /api/v1/chains/{id}/definitions/graph -#[utoipa::path( - get, - path = "/api/v1/chains/{id}/definitions/graph", - params( - ("id" = Uuid, Path, description = "Chain ID") - ), - responses( - (status = 200, description = "Definition graph structure", body = ChainDefinitionGraphResponse), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError) - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Chains" -)] -pub async fn get_chain_definition_graph( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(chain_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 first - match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to verify chain ownership: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::get_chain_definition_graph(pool, chain_id).await { - Ok(Some(graph)) => Json(graph).into_response(), - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to get chain definition graph: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -// ============================================================================= -// Chain Control Handlers -// ============================================================================= - -/// Start a chain (spawns supervisor and creates root contracts). -/// -/// POST /api/v1/chains/{id}/start -#[utoipa::path( - post, - path = "/api/v1/chains/{id}/start", - params( - ("id" = Uuid, Path, description = "Chain ID") - ), - request_body(content = Option<StartChainRequest>, description = "Optional start options"), - responses( - (status = 200, description = "Chain started", body = StartChainResponse), - (status = 400, description = "Chain cannot be started", body = ApiError), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError) - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Chains" -)] -pub async fn start_chain( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(chain_id): Path<Uuid>, - _body: Option<Json<StartChainRequest>>, -) -> 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 and get chain - let chain = match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get chain: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Check if chain can be started - if chain.status == "active" { - return ( - StatusCode::BAD_REQUEST, - Json(ApiError::new("ALREADY_ACTIVE", "Chain is already active")), - ) - .into_response(); - } - if chain.status == "completed" { - return ( - StatusCode::BAD_REQUEST, - Json(ApiError::new("ALREADY_COMPLETED", "Chain is already completed")), - ) - .into_response(); - } - - // Get definitions to check if there are any - let definitions = match repository::list_chain_contract_definitions(pool, chain_id).await { - Ok(d) => d, - Err(e) => { - tracing::error!("Failed to list chain definitions: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - if definitions.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(ApiError::new("NO_DEFINITIONS", "Chain has no contract definitions")), - ) - .into_response(); - } - - // Update chain status to active - match repository::update_chain_status(pool, chain_id, "active").await { - Ok(_) => {} - Err(e) => { - tracing::error!("Failed to update chain status: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - // Progress the chain - this creates root contracts (definitions with no dependencies) - let progression = match repository::progress_chain(pool, chain_id, auth.owner_id).await { - Ok(p) => p, - Err(e) => { - tracing::error!("Failed to progress chain: {}", e); - // Chain is active but no contracts created - return partial success - return Json(StartChainResponse { - chain_id, - contracts_created: vec![], - status: "active".to_string(), - }) - .into_response(); - } - }; - - Json(StartChainResponse { - chain_id, - contracts_created: progression.contracts_created, - status: if progression.chain_completed { - "completed".to_string() - } else { - "active".to_string() - }, - }) - .into_response() -} - -/// Stop a chain (kills supervisor, marks as archived). -/// -/// POST /api/v1/chains/{id}/stop -#[utoipa::path( - post, - path = "/api/v1/chains/{id}/stop", - params( - ("id" = Uuid, Path, description = "Chain ID") - ), - responses( - (status = 200, description = "Chain stopped"), - (status = 400, description = "Chain cannot be stopped", body = ApiError), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError) - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Chains" -)] -pub async fn stop_chain( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(chain_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 and get chain - let chain = match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get chain: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Check if chain can be stopped - if chain.status != "active" { - return ( - StatusCode::BAD_REQUEST, - Json(ApiError::new( - "NOT_ACTIVE", - format!("Chain is not active (status: {})", chain.status), - )), - ) - .into_response(); - } - - // Archive the chain - match repository::update_chain_status(pool, chain_id, "archived").await { - Ok(_) => Json(serde_json::json!({"stopped": true, "status": "archived"})).into_response(), - Err(e) => { - tracing::error!("Failed to update chain status: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -// ============================================================================= -// Chain Repository Handlers -// ============================================================================= - -/// List repositories for a chain. -/// -/// GET /api/v1/chains/{id}/repositories -#[utoipa::path( - get, - path = "/api/v1/chains/{id}/repositories", - params( - ("id" = Uuid, Path, description = "Chain ID") - ), - responses( - (status = 200, description = "List of repositories", body = Vec<ChainRepository>), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError) - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Chains" -)] -pub async fn list_chain_repositories( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(chain_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 - match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to verify chain ownership: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::list_chain_repositories(pool, chain_id).await { - Ok(repos) => Json(repos).into_response(), - Err(e) => { - tracing::error!("Failed to list chain repositories: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Add a repository to a chain. -/// -/// POST /api/v1/chains/{id}/repositories -#[utoipa::path( - post, - path = "/api/v1/chains/{id}/repositories", - params( - ("id" = Uuid, Path, description = "Chain ID") - ), - request_body = AddChainRepositoryRequest, - responses( - (status = 201, description = "Repository added", body = ChainRepository), - (status = 400, description = "Invalid request", body = ApiError), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError) - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Chains" -)] -pub async fn add_chain_repository( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(chain_id): Path<Uuid>, - Json(req): Json<AddChainRepositoryRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Validate request - if req.name.trim().is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(ApiError::new("VALIDATION_ERROR", "Repository name cannot be empty")), - ) - .into_response(); - } - - // Must have either repository_url or local_path - if req.repository_url.is_none() && req.local_path.is_none() { - return ( - StatusCode::BAD_REQUEST, - Json(ApiError::new( - "VALIDATION_ERROR", - "Repository must have either repository_url or local_path", - )), - ) - .into_response(); - } - - // Verify ownership - match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to verify chain ownership: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::add_chain_repository(pool, chain_id, &req).await { - Ok(repo) => (StatusCode::CREATED, Json(repo)).into_response(), - Err(e) => { - tracing::error!("Failed to add chain repository: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Delete a repository from a chain. -/// -/// DELETE /api/v1/chains/{chain_id}/repositories/{repository_id} -#[utoipa::path( - delete, - path = "/api/v1/chains/{chain_id}/repositories/{repository_id}", - params( - ("chain_id" = Uuid, Path, description = "Chain ID"), - ("repository_id" = Uuid, Path, description = "Repository ID") - ), - responses( - (status = 200, description = "Repository deleted"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain or repository not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError) - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Chains" -)] -pub async fn delete_chain_repository( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path((chain_id, repository_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 ownership - match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to verify chain ownership: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::delete_chain_repository(pool, chain_id, repository_id).await { - Ok(true) => Json(serde_json::json!({"deleted": true})).into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Repository not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to delete chain repository: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Set a repository as primary for a chain. -/// -/// PUT /api/v1/chains/{chain_id}/repositories/{repository_id}/primary -#[utoipa::path( - put, - path = "/api/v1/chains/{chain_id}/repositories/{repository_id}/primary", - params( - ("chain_id" = Uuid, Path, description = "Chain ID"), - ("repository_id" = Uuid, Path, description = "Repository ID") - ), - responses( - (status = 200, description = "Repository set as primary", body = ChainRepository), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Chain or repository not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError) - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Chains" -)] -pub async fn set_chain_repository_primary( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path((chain_id, repository_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 ownership - match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Chain not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to verify chain ownership: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - // Verify repository exists for this chain - match repository::get_chain_repository(pool, chain_id, repository_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Repository not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get chain repository: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::set_chain_repository_primary(pool, chain_id, repository_id).await { - Ok(repo) => Json(repo).into_response(), - Err(e) => { - tracing::error!("Failed to set chain repository as primary: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} diff --git a/makima/src/server/handlers/directives.rs b/makima/src/server/handlers/directives.rs index 4a78ab5..52422cd 100644 --- a/makima/src/server/handlers/directives.rs +++ b/makima/src/server/handlers/directives.rs @@ -19,8 +19,9 @@ use std::time::Duration; use uuid::Uuid; use crate::db::models::{ - AddStepRequest, CreateDirectiveRequest, CreateVerifierRequest, UpdateDirectiveRequest, - UpdateStepRequest, UpdateVerifierRequest, + AddStepRequest, CreateDirectiveRequest, CreateVerifierRequest, ReworkStepRequest, + UpdateCriteriaRequest, UpdateDirectiveRequest, UpdateRequirementsRequest, UpdateStepRequest, + UpdateVerifierRequest, }; use crate::db::repository; use crate::server::auth::Authenticated; @@ -1567,3 +1568,483 @@ pub async fn deny_request( } } } + +// ============================================================================= +// Step Evaluation & Rework +// ============================================================================= + +/// Force re-evaluation of a step +/// POST /api/v1/directives/:id/steps/:step_id/evaluate +pub async fn evaluate_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 ownership + match repository::get_directive_for_owner(pool, id, auth.owner_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("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + // Set step to evaluating status + match repository::update_step_status(pool, step_id, "evaluating").await { + Ok(_) => {} + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + // Trigger evaluation via engine + let engine = crate::orchestration::DirectiveEngine::new(pool.clone()); + match engine.on_contract_completed(step_id).await { + Ok(()) => { + // Return updated step + match repository::get_chain_step(pool, step_id).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) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response(), + } + } + Err(e) => { + tracing::error!("Failed to evaluate step: {}", e); + ( + StatusCode::BAD_REQUEST, + Json(ApiError::new("EVALUATE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Trigger manual rework for a step +/// POST /api/v1/directives/:id/steps/:step_id/rework +pub async fn rework_step( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, step_id)): Path<(Uuid, Uuid)>, + Json(req): Json<ReworkStepRequest>, +) -> 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 + match repository::get_directive_for_owner(pool, id, auth.owner_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("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + // Set step to rework status and increment rework count + match repository::update_step_status(pool, step_id, "rework").await { + Ok(_) => {} + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + let _ = repository::increment_step_rework_count(pool, step_id).await; + + // Emit rework event + let _ = repository::emit_directive_event( + pool, + id, + None, + Some(step_id), + "step_rework", + "info", + Some(serde_json::json!({ + "step_id": step_id, + "instructions": req.instructions, + "initiated_by": "user", + })), + "user", + Some(auth.owner_id), + ) + .await; + + // Return updated step + match repository::get_chain_step(pool, step_id).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) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response(), + } +} + +// ============================================================================= +// Auto-detect Verifiers +// ============================================================================= + +/// Auto-detect verifiers for a directive based on repository content +/// POST /api/v1/directives/:id/verifiers/auto-detect +pub async fn auto_detect_verifiers( + 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(); + }; + + // Get directive with ownership check + let directive = match repository::get_directive_for_owner(pool, id, auth.owner_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("DB_ERROR", &e.to_string())), + ) + .into_response() + } + }; + + // Get repository path + let repo_path = directive + .local_path + .as_ref() + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + + // Auto-detect verifiers + let detected = crate::orchestration::auto_detect_verifiers(&repo_path).await; + + // Save detected verifiers to the database + let mut created = Vec::new(); + for verifier in &detected { + let info = verifier.info(); + match repository::create_directive_verifier( + pool, + id, + &info.name, + &info.verifier_type, + Some(&info.command), + info.working_directory.as_deref(), + true, // auto_detect + info.detect_files.clone(), + info.weight, + info.required, + ) + .await + { + Ok(v) => created.push(v), + Err(e) => { + tracing::warn!("Failed to create detected verifier '{}': {}", info.name, e); + } + } + } + + Json(serde_json::json!({ + "detected": created.len(), + "verifiers": created, + })) + .into_response() +} + +// ============================================================================= +// Requirements & Criteria +// ============================================================================= + +/// Update directive requirements +/// PUT /api/v1/directives/:id/requirements +pub async fn update_requirements( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<UpdateRequirementsRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Get directive with ownership check to get current version + let directive = match repository::get_directive_for_owner(pool, id, auth.owner_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("DB_ERROR", &e.to_string())), + ) + .into_response() + } + }; + + // Build update request with just requirements + let update = UpdateDirectiveRequest { + title: None, + goal: None, + requirements: Some(serde_json::to_value(&req.requirements).unwrap_or_default()), + acceptance_criteria: None, + constraints: None, + external_dependencies: None, + autonomy_level: None, + confidence_threshold_green: None, + confidence_threshold_yellow: None, + max_total_cost_usd: None, + max_wall_time_minutes: None, + max_rework_cycles: None, + max_chain_regenerations: None, + version: directive.version, + }; + + match repository::update_directive_for_owner(pool, id, auth.owner_id, update).await { + Ok(directive) => Json(directive).into_response(), + Err(repository::RepositoryError::VersionConflict { expected, actual }) => ( + StatusCode::CONFLICT, + Json(ApiError::new( + "VERSION_CONFLICT", + &format!("Version conflict: expected {}, got {}", expected, actual), + )), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to update requirements: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +/// Update directive acceptance criteria +/// PUT /api/v1/directives/:id/criteria +pub async fn update_criteria( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<UpdateCriteriaRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Get directive with ownership check to get current version + let directive = match repository::get_directive_for_owner(pool, id, auth.owner_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("DB_ERROR", &e.to_string())), + ) + .into_response() + } + }; + + // Build update request with just acceptance criteria + let update = UpdateDirectiveRequest { + title: None, + goal: None, + requirements: None, + acceptance_criteria: Some( + serde_json::to_value(&req.acceptance_criteria).unwrap_or_default(), + ), + constraints: None, + external_dependencies: None, + autonomy_level: None, + confidence_threshold_green: None, + confidence_threshold_yellow: None, + max_total_cost_usd: None, + max_wall_time_minutes: None, + max_rework_cycles: None, + max_chain_regenerations: None, + version: directive.version, + }; + + match repository::update_directive_for_owner(pool, id, auth.owner_id, update).await { + Ok(directive) => Json(directive).into_response(), + Err(repository::RepositoryError::VersionConflict { expected, actual }) => ( + StatusCode::CONFLICT, + Json(ApiError::new( + "VERSION_CONFLICT", + &format!("Version conflict: expected {}, got {}", expected, actual), + )), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to update criteria: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= +// Spec Generation +// ============================================================================= + +/// Generate a specification from the directive's goal using LLM +/// POST /api/v1/directives/:id/generate-spec +pub async fn generate_spec( + 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(); + }; + + // Get directive with ownership check + let directive = match repository::get_directive_for_owner(pool, id, auth.owner_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("DB_ERROR", &e.to_string())), + ) + .into_response() + } + }; + + // Use the planner to generate a spec from the goal + let planner = crate::orchestration::ChainPlanner::new(pool.clone()); + match planner.generate_spec(&directive).await { + Ok(spec) => { + // Update the directive with the generated spec + let update = UpdateDirectiveRequest { + title: spec.title, + goal: None, + requirements: Some(spec.requirements), + acceptance_criteria: Some(spec.acceptance_criteria), + constraints: spec.constraints, + external_dependencies: None, + autonomy_level: None, + confidence_threshold_green: None, + confidence_threshold_yellow: None, + max_total_cost_usd: None, + max_wall_time_minutes: None, + max_rework_cycles: None, + max_chain_regenerations: None, + version: directive.version, + }; + + match repository::update_directive_for_owner(pool, id, auth.owner_id, update).await { + Ok(updated) => Json(updated).into_response(), + Err(e) => { + tracing::error!("Failed to save generated spec: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + } + Err(e) => { + tracing::error!("Failed to generate spec: {}", e); + ( + StatusCode::BAD_REQUEST, + Json(ApiError::new("SPEC_GENERATION_FAILED", &e.to_string())), + ) + .into_response() + } + } +} diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index 927e9a5..463a5f5 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -229,6 +229,9 @@ pub fn make_router(state: SharedState) -> Router { .route("/directives/{id}/pause", post(directives::pause_directive)) .route("/directives/{id}/resume", post(directives::resume_directive)) .route("/directives/{id}/stop", post(directives::stop_directive)) + .route("/directives/{id}/requirements", axum::routing::put(directives::update_requirements)) + .route("/directives/{id}/criteria", axum::routing::put(directives::update_criteria)) + .route("/directives/{id}/generate-spec", post(directives::generate_spec)) // Directive chain management .route("/directives/{id}/chain", get(directives::get_chain)) .route("/directives/{id}/chain/graph", get(directives::get_chain_graph)) @@ -245,12 +248,15 @@ pub fn make_router(state: SharedState) -> Router { .delete(directives::delete_step), ) .route("/directives/{id}/chain/steps/{step_id}/skip", post(directives::skip_step)) + .route("/directives/{id}/chain/steps/{step_id}/evaluate", post(directives::evaluate_step)) + .route("/directives/{id}/chain/steps/{step_id}/rework", post(directives::rework_step)) // Directive evaluations .route("/directives/{id}/evaluations", get(directives::list_evaluations)) // Directive events .route("/directives/{id}/events", get(directives::list_events)) .route("/directives/{id}/events/stream", get(directives::stream_events)) // Directive verifiers + .route("/directives/{id}/verifiers/auto-detect", post(directives::auto_detect_verifiers)) .route( "/directives/{id}/verifiers", get(directives::list_verifiers).post(directives::add_verifier), |
