diff options
| author | soryu <soryu@soryu.co> | 2026-02-03 22:01:29 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-02-03 22:01:37 +0000 |
| commit | cf0a25af1d2834bfe6c5ea892ce5769936e5a673 (patch) | |
| tree | 476ba326ac1752281a441b5c17d2b3be4b23a2a9 | |
| parent | 8361916ce67f3d2ba191ebf27cb50e79cb42e39c (diff) | |
| download | soryu-cf0a25af1d2834bfe6c5ea892ce5769936e5a673.tar.gz soryu-cf0a25af1d2834bfe6c5ea892ce5769936e5a673.zip | |
Add makima chain mechanism
27 files changed, 4647 insertions, 12 deletions
@@ -2032,6 +2032,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "serde_yaml", "sha2", "shell-escape", "sqlx", @@ -3276,6 +3277,19 @@ dependencies = [ ] [[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] name = "serial" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4607,6 +4621,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" [[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/makima/Cargo.toml b/makima/Cargo.toml index 950c123..9f47b97 100644 --- a/makima/Cargo.toml +++ b/makima/Cargo.toml @@ -23,6 +23,7 @@ tokio = { version = "1.0", features = ["full", "signal", "process"] } tower-http = { version = "0.6", features = ["cors", "trace"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +serde_yaml = "0.9" toml = "0.8" futures = "0.3" tracing = "0.1" diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx index fb95c7f..5937982 100644 --- a/makima/frontend/src/components/NavStrip.tsx +++ b/makima/frontend/src/components/NavStrip.tsx @@ -11,6 +11,7 @@ interface NavLink { const NAV_LINKS: NavLink[] = [ { label: "Listen", href: "/listen" }, { label: "Contracts", href: "/contracts", requiresAuth: true }, + { label: "Chains", href: "/chains", requiresAuth: true }, { label: "Board", href: "/workflow", requiresAuth: true }, { label: "Mesh", href: "/mesh", requiresAuth: true }, { label: "History", href: "/history", requiresAuth: true }, diff --git a/makima/frontend/src/components/chains/ChainEditor.tsx b/makima/frontend/src/components/chains/ChainEditor.tsx new file mode 100644 index 0000000..9077d19 --- /dev/null +++ b/makima/frontend/src/components/chains/ChainEditor.tsx @@ -0,0 +1,468 @@ +import { useState, useCallback, useMemo, useRef } from "react"; +import type { + ChainWithContracts, + ChainGraphResponse, + ChainContractDetail, +} from "../../lib/api"; + +interface ChainEditorProps { + chain: ChainWithContracts; + graph: ChainGraphResponse | null; + loading: boolean; + onBack: () => void; + onRefresh: () => void; + onContractClick: (contractId: string) => void; +} + +// Node dimensions +const NODE_WIDTH = 180; +const NODE_HEIGHT = 80; +const CANVAS_PADDING = 40; + +export function ChainEditor({ + chain, + graph, + loading, + onBack, + onRefresh, + onContractClick, +}: ChainEditorProps) { + const canvasRef = useRef<HTMLDivElement>(null); + const [selectedNode, setSelectedNode] = useState<string | null>(null); + const [hoveredNode, setHoveredNode] = useState<string | null>(null); + + // Use positions from graph nodes directly (x, y from server) + const nodePositions = useMemo(() => { + if (!graph?.nodes) return new Map<string, { x: number; y: number }>(); + + const positions = new Map<string, { x: number; y: number }>(); + for (const node of graph.nodes) { + positions.set(node.contractId, { + x: CANVAS_PADDING + (node.x || 0) * (NODE_WIDTH + 60), + y: CANVAS_PADDING + (node.y || 0) * (NODE_HEIGHT + 40), + }); + } + return positions; + }, [graph?.nodes]); + + // Canvas dimensions + const canvasDimensions = useMemo(() => { + if (nodePositions.size === 0) { + return { width: 600, height: 400 }; + } + + let maxX = 0; + let maxY = 0; + for (const pos of nodePositions.values()) { + maxX = Math.max(maxX, pos.x + NODE_WIDTH); + maxY = Math.max(maxY, pos.y + NODE_HEIGHT); + } + + return { + width: Math.max(600, maxX + CANVAS_PADDING), + height: Math.max(400, maxY + CANVAS_PADDING), + }; + }, [nodePositions]); + + const handleNodeClick = useCallback((contractId: string) => { + setSelectedNode(contractId); + }, []); + + const handleNodeDoubleClick = useCallback( + (contractId: string) => { + onContractClick(contractId); + }, + [onContractClick] + ); + + const getStatusColor = (status: string) => { + switch (status) { + case "active": + return { bg: "#4ade80", border: "#22c55e", text: "#166534" }; + case "completed": + return { bg: "#60a5fa", border: "#3b82f6", text: "#1e40af" }; + case "pending": + return { bg: "#f59e0b", border: "#d97706", text: "#92400e" }; + case "blocked": + return { bg: "#ef4444", border: "#dc2626", text: "#991b1b" }; + default: + return { bg: "#6b7280", border: "#4b5563", text: "#374151" }; + } + }; + + // Find selected contract from chain.contracts + const selectedContract = selectedNode + ? chain.contracts.find((c) => c.contractId === selectedNode) + : null; + + return ( + <div className="panel h-full flex flex-col"> + {/* Header */} + <div className="p-3 border-b border-[rgba(117,170,252,0.2)]"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-3"> + <button + onClick={onBack} + className="font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors" + > + Back + </button> + <div> + <h2 className="font-mono text-sm text-[#dbe7ff]">{chain.chain.name}</h2> + {chain.chain.description && ( + <p className="font-mono text-xs text-[#8b949e]">{chain.chain.description}</p> + )} + </div> + </div> + <div className="flex items-center gap-2"> + <span + className={`px-2 py-1 font-mono text-[10px] uppercase rounded ${ + chain.chain.status === "active" + ? "text-[#4ade80] bg-[#4ade80]/10" + : chain.chain.status === "completed" + ? "text-[#60a5fa] bg-[#60a5fa]/10" + : "text-[#6b7280] bg-[#6b7280]/10" + }`} + > + {chain.chain.status} + </span> + <button + onClick={onRefresh} + className="px-3 py-1 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] border border-[#3f6fb3] hover:border-[#75aafc] transition-colors" + > + Refresh + </button> + </div> + </div> + </div> + + {/* Main content */} + <div className="flex-1 flex min-h-0"> + {/* DAG Canvas */} + <div className="flex-1 overflow-auto bg-[#050d18]"> + {loading ? ( + <div className="flex items-center justify-center h-full"> + <p className="font-mono text-xs text-[#8b949e]">Loading graph...</p> + </div> + ) : !graph || graph.nodes.length === 0 ? ( + <div className="flex items-center justify-center h-full"> + <div className="text-center"> + <p className="font-mono text-sm text-[#8b949e] mb-2"> + No contracts in this chain yet + </p> + <p className="font-mono text-xs text-[#556677]"> + Contracts will appear here once added via CLI or API + </p> + </div> + </div> + ) : ( + <div + ref={canvasRef} + className="relative" + style={{ + width: canvasDimensions.width, + height: canvasDimensions.height, + minWidth: "100%", + minHeight: "100%", + }} + > + {/* SVG layer for edges */} + <svg + className="absolute inset-0 pointer-events-none" + style={{ + width: canvasDimensions.width, + height: canvasDimensions.height, + }} + > + <defs> + <marker + id="arrowhead" + markerWidth="10" + markerHeight="7" + refX="9" + refY="3.5" + orient="auto" + > + <polygon + points="0 0, 10 3.5, 0 7" + fill="#75aafc" + opacity="0.6" + /> + </marker> + </defs> + {graph.edges.map((edge, index) => { + const fromPos = nodePositions.get(edge.from); + const toPos = nodePositions.get(edge.to); + if (!fromPos || !toPos) return null; + + // Calculate edge path (from bottom of source to top of target) + const startX = fromPos.x + NODE_WIDTH / 2; + const startY = fromPos.y + NODE_HEIGHT; + const endX = toPos.x + NODE_WIDTH / 2; + const endY = toPos.y; + + // Bezier control points for smooth curves + const midY = (startY + endY) / 2; + + const isHighlighted = + hoveredNode === edge.from || hoveredNode === edge.to; + + return ( + <path + key={`${edge.from}-${edge.to}-${index}`} + d={`M ${startX} ${startY} C ${startX} ${midY}, ${endX} ${midY}, ${endX} ${endY}`} + fill="none" + stroke={isHighlighted ? "#75aafc" : "#3f6fb3"} + strokeWidth={isHighlighted ? 2 : 1.5} + strokeDasharray={isHighlighted ? "none" : "4 2"} + markerEnd="url(#arrowhead)" + opacity={isHighlighted ? 1 : 0.6} + /> + ); + })} + </svg> + + {/* Node layer */} + {graph.nodes.map((node) => { + const pos = nodePositions.get(node.contractId); + if (!pos) return null; + + const colors = getStatusColor(node.status); + const isSelected = selectedNode === node.contractId; + const isHovered = hoveredNode === node.contractId; + + return ( + <div + key={node.contractId} + onClick={() => handleNodeClick(node.contractId)} + onDoubleClick={() => handleNodeDoubleClick(node.contractId)} + onMouseEnter={() => setHoveredNode(node.contractId)} + onMouseLeave={() => setHoveredNode(null)} + className={`absolute cursor-pointer transition-all duration-150 ${ + isSelected ? "ring-2 ring-[#75aafc] ring-offset-2 ring-offset-[#050d18]" : "" + }`} + style={{ + left: pos.x, + top: pos.y, + width: NODE_WIDTH, + height: NODE_HEIGHT, + transform: isHovered ? "scale(1.02)" : "scale(1)", + }} + > + <div + className="w-full h-full rounded-lg border-2 bg-[#0a1628] overflow-hidden" + style={{ + borderColor: isSelected ? "#75aafc" : colors.border, + }} + > + {/* Status indicator bar */} + <div + className="h-1.5" + style={{ backgroundColor: colors.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"> + {node.name} + </span> + <ChainIcon className="w-4 h-4 text-[#75aafc] opacity-50 flex-shrink-0 ml-1" /> + </div> + <div className="flex items-center justify-between"> + <span + className="font-mono text-[10px] uppercase px-1.5 py-0.5 rounded" + style={{ + color: colors.bg, + backgroundColor: `${colors.bg}20`, + }} + > + {node.status} + </span> + {node.phase && ( + <span className="font-mono text-[10px] text-[#8b949e]"> + {node.phase} + </span> + )} + </div> + </div> + </div> + </div> + ); + })} + </div> + )} + </div> + + {/* Detail panel */} + {selectedContract && ( + <ContractDetailPanel + contract={selectedContract} + allContracts={chain.contracts} + onClose={() => setSelectedNode(null)} + onSelectContract={setSelectedNode} + onOpenContract={onContractClick} + /> + )} + </div> + + {/* Footer with stats */} + <div className="p-3 border-t border-[rgba(117,170,252,0.2)] bg-[#0a1628]"> + <div className="flex items-center gap-4 font-mono text-[10px] text-[#8b949e]"> + <span>{chain.contracts.length} contracts</span> + <span> + {chain.contracts.filter((c) => c.contractStatus === "completed").length} completed + </span> + <span> + {chain.contracts.filter((c) => c.contractStatus === "active").length} active + </span> + <span className="flex-1" /> + <span>Double-click node to open contract</span> + </div> + </div> + </div> + ); +} + +interface ContractDetailPanelProps { + contract: ChainContractDetail; + allContracts: ChainContractDetail[]; + onClose: () => void; + onSelectContract: (contractId: string) => void; + onOpenContract: (contractId: string) => void; +} + +function ContractDetailPanel({ + contract, + allContracts, + onClose, + onSelectContract, + onOpenContract, +}: ContractDetailPanelProps) { + return ( + <div className="w-72 border-l border-[rgba(117,170,252,0.2)] bg-[#0a1628] overflow-y-auto"> + <div className="p-3 border-b border-[rgba(117,170,252,0.2)]"> + <div className="flex items-center justify-between mb-2"> + <h3 className="font-mono text-xs text-[#75aafc] uppercase"> + Contract Details + </h3> + <button + onClick={onClose} + className="font-mono text-xs text-[#8b949e] hover:text-[#dbe7ff]" + > + Close + </button> + </div> + </div> + + <div className="p-3 space-y-4"> + {/* Name */} + <div> + <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1"> + Name + </label> + <p className="font-mono text-sm text-[#dbe7ff]"> + {contract.contractName} + </p> + </div> + + {/* Status */} + <div> + <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1"> + Status + </label> + <span + className={`inline-block px-2 py-1 font-mono text-xs uppercase rounded ${ + contract.contractStatus === "active" + ? "text-[#4ade80] bg-[#4ade80]/10" + : contract.contractStatus === "completed" + ? "text-[#60a5fa] bg-[#60a5fa]/10" + : "text-[#6b7280] bg-[#6b7280]/10" + }`} + > + {contract.contractStatus} + </span> + </div> + + {/* Phase */} + <div> + <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1"> + Phase + </label> + <span className="font-mono text-sm text-[#dbe7ff]"> + {contract.contractPhase} + </span> + </div> + + {/* Dependencies */} + {contract.dependsOn && contract.dependsOn.length > 0 && ( + <div> + <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1"> + Depends On + </label> + <div className="space-y-1"> + {contract.dependsOn.map((depId) => { + const dep = allContracts.find((c) => c.contractId === depId); + return ( + <button + key={depId} + onClick={() => onSelectContract(depId)} + className="block w-full text-left font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] truncate" + > + {dep?.contractName || depId} + </button> + ); + })} + </div> + </div> + )} + + {/* Order Index */} + <div> + <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1"> + Order Index + </label> + <p className="font-mono text-xs text-[#dbe7ff]"> + {contract.orderIndex} + </p> + </div> + + {/* Created */} + <div> + <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1"> + Created + </label> + <p className="font-mono text-xs text-[#dbe7ff]"> + {new Date(contract.createdAt).toLocaleString()} + </p> + </div> + + {/* Actions */} + <div className="pt-2 border-t border-[rgba(117,170,252,0.2)]"> + <button + onClick={() => onOpenContract(contract.contractId)} + className="w-full px-3 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors" + > + Open Contract + </button> + </div> + </div> + </div> + ); +} + +function ChainIcon({ className }: { className?: string }) { + return ( + <svg + className={className} + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + > + <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /> + <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /> + </svg> + ); +} diff --git a/makima/frontend/src/components/chains/ChainList.tsx b/makima/frontend/src/components/chains/ChainList.tsx new file mode 100644 index 0000000..eda79d7 --- /dev/null +++ b/makima/frontend/src/components/chains/ChainList.tsx @@ -0,0 +1,205 @@ +import { useState, useCallback } from "react"; +import type { ChainSummary, ChainStatus } from "../../lib/api"; + +interface ChainListProps { + chains: ChainSummary[]; + loading: boolean; + onSelect: (chainId: string) => void; + onCreate: () => void; + selectedId?: string; + onArchive: (chain: ChainSummary) => void; +} + +export function ChainList({ + chains, + loading, + onSelect, + onCreate, + selectedId, + onArchive, +}: ChainListProps) { + const [statusFilter, setStatusFilter] = useState<ChainStatus | "all">("all"); + const [contextMenu, setContextMenu] = useState<{ + chain: ChainSummary; + x: number; + y: number; + } | null>(null); + + const filteredChains = chains.filter((chain) => + statusFilter === "all" ? true : chain.status === statusFilter + ); + + const handleContextMenu = useCallback( + (e: React.MouseEvent, chain: ChainSummary) => { + e.preventDefault(); + setContextMenu({ chain, x: e.clientX, y: e.clientY }); + }, + [] + ); + + const closeContextMenu = useCallback(() => { + setContextMenu(null); + }, []); + + const handleArchive = useCallback(() => { + if (contextMenu) { + onArchive(contextMenu.chain); + setContextMenu(null); + } + }, [contextMenu, onArchive]); + + const getStatusColor = (status: ChainStatus) => { + switch (status) { + case "active": + return "text-[#4ade80] bg-[#4ade80]/10"; + case "completed": + return "text-[#60a5fa] bg-[#60a5fa]/10"; + case "archived": + return "text-[#6b7280] bg-[#6b7280]/10"; + default: + return "text-[#8b949e] bg-[#8b949e]/10"; + } + }; + + const getStatusIcon = (status: ChainStatus) => { + switch (status) { + case "active": + return ( + <svg className="w-3 h-3" viewBox="0 0 24 24" fill="currentColor"> + <circle cx="12" cy="12" r="4" /> + </svg> + ); + case "completed": + return ( + <svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3"> + <polyline points="20 6 9 17 4 12" /> + </svg> + ); + case "archived": + return ( + <svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + <path d="M21 8v13H3V8" /> + <path d="M1 3h22v5H1z" /> + <path d="M10 12h4" /> + </svg> + ); + default: + return null; + } + }; + + return ( + <div className="panel h-full flex flex-col" onClick={closeContextMenu}> + {/* Header */} + <div className="p-3 border-b border-[rgba(117,170,252,0.2)]"> + <div className="flex items-center justify-between mb-3"> + <h2 className="font-mono text-sm text-[#75aafc] uppercase">Chains</h2> + <button + onClick={onCreate} + className="px-3 py-1 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors" + > + + New + </button> + </div> + + {/* Status filter */} + <div className="flex gap-1"> + {(["all", "active", "completed", "archived"] as const).map((status) => ( + <button + key={status} + onClick={() => setStatusFilter(status)} + className={`px-2 py-1 font-mono text-[10px] uppercase transition-colors ${ + statusFilter === status + ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]" + : "bg-[#0d1b2d] text-[#8b949e] border border-[#3f6fb3] hover:border-[#75aafc]" + }`} + > + {status} + </button> + ))} + </div> + </div> + + {/* Chain list */} + <div className="flex-1 overflow-y-auto"> + {loading ? ( + <div className="flex items-center justify-center h-32"> + <p className="font-mono text-xs text-[#8b949e]">Loading chains...</p> + </div> + ) : filteredChains.length === 0 ? ( + <div className="flex items-center justify-center h-32"> + <p className="font-mono text-xs text-[#8b949e]"> + {statusFilter === "all" ? "No chains yet" : `No ${statusFilter} chains`} + </p> + </div> + ) : ( + <div className="divide-y divide-[rgba(117,170,252,0.1)]"> + {filteredChains.map((chain) => ( + <div + key={chain.id} + onClick={() => onSelect(chain.id)} + onContextMenu={(e) => handleContextMenu(e, chain)} + className={`p-3 cursor-pointer transition-colors ${ + selectedId === chain.id + ? "bg-[rgba(117,170,252,0.15)]" + : "hover:bg-[rgba(117,170,252,0.05)]" + }`} + > + <div className="flex items-center justify-between mb-1"> + <span className="font-mono text-sm text-[#dbe7ff] truncate"> + {chain.name} + </span> + <span + className={`flex items-center gap-1 px-1.5 py-0.5 font-mono text-[10px] uppercase rounded ${getStatusColor( + chain.status + )}`} + > + {getStatusIcon(chain.status)} + {chain.status} + </span> + </div> + {chain.description && ( + <p className="font-mono text-xs text-[#8b949e] truncate mb-1"> + {chain.description} + </p> + )} + <div className="flex items-center gap-3 font-mono text-[10px] text-[#556677]"> + <span>{chain.contractCount} contracts</span> + <span> + {new Date(chain.updatedAt).toLocaleDateString()} + </span> + </div> + </div> + ))} + </div> + )} + </div> + + {/* Context menu */} + {contextMenu && ( + <div + className="fixed z-50 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] shadow-lg py-1" + style={{ top: contextMenu.y, left: contextMenu.x }} + > + <button + onClick={() => { + onSelect(contextMenu.chain.id); + setContextMenu(null); + }} + className="w-full px-4 py-2 text-left font-mono text-xs text-[#dbe7ff] hover:bg-[rgba(117,170,252,0.1)]" + > + View Details + </button> + {contextMenu.chain.status !== "archived" && ( + <button + onClick={handleArchive} + className="w-full px-4 py-2 text-left font-mono text-xs text-red-400 hover:bg-red-400/10" + > + Archive + </button> + )} + </div> + )} + </div> + ); +} diff --git a/makima/frontend/src/hooks/useChains.ts b/makima/frontend/src/hooks/useChains.ts new file mode 100644 index 0000000..272847a --- /dev/null +++ b/makima/frontend/src/hooks/useChains.ts @@ -0,0 +1,145 @@ +import { useState, useCallback, useEffect } from "react"; +import { + listChains, + getChain, + createChain, + updateChain, + archiveChain, + getChainGraph, + type ChainSummary, + type ChainWithContracts, + type ChainGraphResponse, + type ChainStatus, + type CreateChainRequest, + type UpdateChainRequest, +} from "../lib/api"; + +interface UseChainsResult { + chains: ChainSummary[]; + loading: boolean; + error: string | null; + refresh: () => Promise<void>; + createNewChain: (req: CreateChainRequest) => Promise<ChainWithContracts | null>; + updateExistingChain: ( + chainId: string, + req: UpdateChainRequest + ) => Promise<ChainWithContracts | null>; + archiveExistingChain: (chainId: string) => Promise<boolean>; + getChainById: (chainId: string) => Promise<ChainWithContracts | null>; + getGraph: (chainId: string) => Promise<ChainGraphResponse | null>; +} + +export function useChains(statusFilter?: ChainStatus): UseChainsResult { + const [chains, setChains] = useState<ChainSummary[]>([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + + const fetchChains = useCallback(async () => { + setLoading(true); + setError(null); + try { + const response = await listChains(statusFilter); + setChains(response.chains); + } catch (err) { + console.error("Failed to fetch chains:", err); + setError(err instanceof Error ? err.message : "Failed to fetch chains"); + } finally { + setLoading(false); + } + }, [statusFilter]); + + useEffect(() => { + fetchChains(); + }, [fetchChains]); + + const createNewChain = useCallback( + async (req: CreateChainRequest): Promise<ChainWithContracts | null> => { + try { + const chain = await createChain(req); + // Refresh the list + await fetchChains(); + // Return the full chain with contracts + return await getChain(chain.id); + } catch (err) { + console.error("Failed to create chain:", err); + setError(err instanceof Error ? err.message : "Failed to create chain"); + return null; + } + }, + [fetchChains] + ); + + const updateExistingChain = useCallback( + async ( + chainId: string, + req: UpdateChainRequest + ): Promise<ChainWithContracts | null> => { + try { + await updateChain(chainId, req); + // Refresh the list + await fetchChains(); + // Return the updated chain + return await getChain(chainId); + } catch (err) { + console.error("Failed to update chain:", err); + setError(err instanceof Error ? err.message : "Failed to update chain"); + return null; + } + }, + [fetchChains] + ); + + const archiveExistingChain = useCallback( + async (chainId: string): Promise<boolean> => { + try { + await archiveChain(chainId); + // Refresh the list + await fetchChains(); + return true; + } catch (err) { + console.error("Failed to archive chain:", err); + setError(err instanceof Error ? err.message : "Failed to archive chain"); + return false; + } + }, + [fetchChains] + ); + + const getChainById = useCallback( + async (chainId: string): Promise<ChainWithContracts | null> => { + try { + return await getChain(chainId); + } catch (err) { + console.error("Failed to get chain:", err); + setError(err instanceof Error ? err.message : "Failed to get chain"); + return null; + } + }, + [] + ); + + const getGraph = useCallback( + async (chainId: string): Promise<ChainGraphResponse | null> => { + try { + return await getChainGraph(chainId); + } catch (err) { + console.error("Failed to get chain graph:", err); + setError(err instanceof Error ? err.message : "Failed to get chain graph"); + return null; + } + }, + [] + ); + + return { + chains, + loading, + error, + refresh: fetchChains, + createNewChain, + updateExistingChain, + archiveExistingChain, + getChainById, + getGraph, + }; +} diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index f148d76..445537c 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -2948,3 +2948,239 @@ export async function listTaskPatches(taskId: string, contractId: string): Promi } return res.json(); } + +// ============================================================================= +// Chain Types and API +// ============================================================================= + +/** Chain status */ +export type ChainStatus = "active" | "completed" | "archived"; + +/** Chain summary for list view */ +export interface ChainSummary { + id: string; + name: string; + description: string | null; + status: ChainStatus; + contractCount: number; + completedContractCount: number; + loopEnabled: boolean; + loopCurrentIteration: number | null; + loopMaxIterations: number | null; + createdAt: string; + updatedAt: string; +} + +/** Full chain with contracts */ +export interface Chain { + id: string; + ownerId: string; + name: string; + description: string | null; + status: ChainStatus; + loopEnabled: boolean; + loopMaxIterations: number | null; + loopCurrentIteration: number | null; + loopProgressCheck: string | null; + repositoryUrl: string | null; + localPath: string | null; + version: number; + createdAt: string; + updatedAt: string; +} + +/** Contract detail within a chain */ +export interface ChainContractDetail { + id: string; + chainId: string; + contractId: string; + contractName: string; + contractStatus: string; + contractPhase: string; + dependsOn: string[]; + orderIndex: number; + editorX: number | null; + editorY: number | null; + createdAt: string; +} + +/** Chain with contracts */ +export interface ChainWithContracts { + chain: Chain; + contracts: ChainContractDetail[]; +} + +/** Node in chain graph visualization */ +export interface ChainGraphNode { + id: string; + contractId: string; + name: string; + status: string; + phase: string; + x: number; + y: number; +} + +/** Edge in chain graph */ +export interface ChainGraphEdge { + from: string; + to: string; +} + +/** Chain graph response */ +export interface ChainGraphResponse { + chainId: string; + chainName: string; + chainStatus: string; + nodes: ChainGraphNode[]; + edges: ChainGraphEdge[]; +} + +/** Chain event */ +export interface ChainEvent { + id: string; + chainId: string; + eventType: string; + contractId: string | null; + eventData: Record<string, unknown> | null; + createdAt: string; +} + +/** Chain list response */ +export interface ChainListResponse { + chains: ChainSummary[]; + total: number; +} + +/** Create chain request */ +export interface CreateChainRequest { + name: string; + description?: string; + repositoryUrl?: string; + localPath?: string; + loopEnabled?: boolean; + loopMaxIterations?: number; + loopProgressCheck?: string; + contracts?: CreateChainContractRequest[]; +} + +/** Create chain contract request */ +export interface CreateChainContractRequest { + name: string; + description?: string; + contractType?: string; + initialPhase?: string; + phases?: string[]; + dependsOn?: string[]; + tasks?: { name: string; plan: string }[]; + deliverables?: { id: string; name: string; priority?: string }[]; + editorX?: number; + editorY?: number; +} + +/** Update chain request */ +export interface UpdateChainRequest { + name?: string; + description?: string; + status?: ChainStatus; + loopEnabled?: boolean; + loopMaxIterations?: number; + loopProgressCheck?: string; + version?: number; +} + +/** List chains */ +export async function listChains( + status?: ChainStatus, + limit = 50, + offset = 0 +): Promise<ChainListResponse> { + const params = new URLSearchParams(); + if (status) params.set("status", status); + params.set("limit", String(limit)); + params.set("offset", String(offset)); + + const res = await authFetch(`${API_BASE}/api/v1/chains?${params}`); + if (!res.ok) { + throw new Error(`Failed to list chains: ${res.statusText}`); + } + return res.json(); +} + +/** Get chain by ID */ +export async function getChain(chainId: string): Promise<ChainWithContracts> { + const res = await authFetch(`${API_BASE}/api/v1/chains/${chainId}`); + if (!res.ok) { + throw new Error(`Failed to get chain: ${res.statusText}`); + } + return res.json(); +} + +/** Create a new chain */ +export async function createChain(req: CreateChainRequest): Promise<Chain> { + const res = await authFetch(`${API_BASE}/api/v1/chains`, { + method: "POST", + body: JSON.stringify(req), + }); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to create chain: ${errorText || res.statusText}`); + } + return res.json(); +} + +/** Update a chain */ +export async function updateChain( + chainId: string, + req: UpdateChainRequest +): Promise<Chain> { + const res = await authFetch(`${API_BASE}/api/v1/chains/${chainId}`, { + method: "PUT", + body: JSON.stringify(req), + }); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to update chain: ${errorText || res.statusText}`); + } + return res.json(); +} + +/** Archive a chain */ +export async function archiveChain(chainId: string): Promise<{ archived: boolean }> { + const res = await authFetch(`${API_BASE}/api/v1/chains/${chainId}`, { + method: "DELETE", + }); + if (!res.ok) { + throw new Error(`Failed to archive chain: ${res.statusText}`); + } + return res.json(); +} + +/** Get chain contracts */ +export async function getChainContracts( + chainId: string +): Promise<ChainContractDetail[]> { + const res = await authFetch(`${API_BASE}/api/v1/chains/${chainId}/contracts`); + if (!res.ok) { + throw new Error(`Failed to get chain contracts: ${res.statusText}`); + } + return res.json(); +} + +/** Get chain graph for visualization */ +export async function getChainGraph(chainId: string): Promise<ChainGraphResponse> { + const res = await authFetch(`${API_BASE}/api/v1/chains/${chainId}/graph`); + if (!res.ok) { + throw new Error(`Failed to get chain graph: ${res.statusText}`); + } + return res.json(); +} + +/** Get chain events */ +export async function getChainEvents(chainId: string): Promise<ChainEvent[]> { + const res = await authFetch(`${API_BASE}/api/v1/chains/${chainId}/events`); + if (!res.ok) { + throw new Error(`Failed to get chain events: ${res.statusText}`); + } + return res.json(); +} diff --git a/makima/frontend/src/main.tsx b/makima/frontend/src/main.tsx index 50fffe4..a7ba1a3 100644 --- a/makima/frontend/src/main.tsx +++ b/makima/frontend/src/main.tsx @@ -12,6 +12,7 @@ import HomePage from "./routes/_index"; import ListenPage from "./routes/listen"; import FilesPage from "./routes/files"; import ContractsPage from "./routes/contracts"; +import ChainsPage from "./routes/chains"; import WorkflowPage from "./routes/workflow"; import MeshPage from "./routes/mesh"; import HistoryPage from "./routes/history"; @@ -72,6 +73,22 @@ createRoot(document.getElementById("root")!).render( } /> <Route + path="/chains" + element={ + <ProtectedRoute> + <ChainsPage /> + </ProtectedRoute> + } + /> + <Route + path="/chains/:id" + element={ + <ProtectedRoute> + <ChainsPage /> + </ProtectedRoute> + } + /> + <Route path="/contracts/:id/files/:fileId" element={ <ProtectedRoute> diff --git a/makima/frontend/src/routes/chains.tsx b/makima/frontend/src/routes/chains.tsx new file mode 100644 index 0000000..f01d5a6 --- /dev/null +++ b/makima/frontend/src/routes/chains.tsx @@ -0,0 +1,283 @@ +import { useState, useCallback, useEffect } from "react"; +import { useParams, useNavigate } from "react-router"; +import { Masthead } from "../components/Masthead"; +import { ChainList } from "../components/chains/ChainList"; +import { ChainEditor } from "../components/chains/ChainEditor"; +import { useChains } from "../hooks/useChains"; +import { useAuth } from "../contexts/AuthContext"; +import type { + ChainSummary, + ChainWithContracts, + ChainGraphResponse, + CreateChainRequest, +} from "../lib/api"; + +export default function ChainsPage() { + const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); + const navigate = useNavigate(); + + // Redirect to login if not authenticated (when auth is configured) + useEffect(() => { + if (!authLoading && isAuthConfigured && !isAuthenticated) { + navigate("/login"); + } + }, [authLoading, isAuthConfigured, isAuthenticated, navigate]); + + // Show loading while checking auth + if (authLoading) { + return ( + <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> + <Masthead showNav /> + <main className="flex-1 flex items-center justify-center"> + <p className="text-[#7788aa] font-mono text-sm">Loading...</p> + </main> + </div> + ); + } + + // Don't render if not authenticated (will redirect) + if (isAuthConfigured && !isAuthenticated) { + return null; + } + + return <ChainsPageContent />; +} + +function ChainsPageContent() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { + chains, + loading, + error, + createNewChain, + archiveExistingChain, + getChainById, + getGraph, + } = useChains(); + + const [chainDetail, setChainDetail] = useState<ChainWithContracts | null>(null); + const [chainGraph, setChainGraph] = useState<ChainGraphResponse | null>(null); + const [detailLoading, setDetailLoading] = useState(false); + const [isCreating, setIsCreating] = useState(false); + + // Load chain detail when ID changes + useEffect(() => { + if (id) { + setDetailLoading(true); + Promise.all([getChainById(id), getGraph(id)]).then(([chain, graph]) => { + setChainDetail(chain); + setChainGraph(graph); + setDetailLoading(false); + }); + } else { + setChainDetail(null); + setChainGraph(null); + } + }, [id, getChainById, getGraph]); + + const handleSelect = useCallback( + (chainId: string) => { + navigate(`/chains/${chainId}`); + }, + [navigate] + ); + + const handleBack = useCallback(() => { + navigate("/chains"); + }, [navigate]); + + const handleCreate = useCallback(() => { + setIsCreating(true); + }, []); + + const handleCreateSubmit = useCallback( + async (name: string, description: string) => { + const data: CreateChainRequest = { + name: name.trim(), + description: description.trim() || undefined, + }; + + try { + const result = await createNewChain(data); + if (result) { + setIsCreating(false); + navigate(`/chains/${result.chain.id}`); + } + } catch (err) { + console.error("Failed to create chain:", err); + } + }, + [createNewChain, navigate] + ); + + const handleCreateCancel = useCallback(() => { + setIsCreating(false); + }, []); + + const handleArchive = useCallback( + async (chain: ChainSummary) => { + if (confirm(`Are you sure you want to archive "${chain.name}"?`)) { + const success = await archiveExistingChain(chain.id); + if (success && chain.id === id) { + navigate("/chains"); + } + } + }, + [archiveExistingChain, id, navigate] + ); + + const handleRefresh = useCallback(async () => { + if (id) { + const [chain, graph] = await Promise.all([getChainById(id), getGraph(id)]); + setChainDetail(chain); + setChainGraph(graph); + } + }, [id, getChainById, getGraph]); + + const handleContractClick = useCallback( + (contractId: string) => { + navigate(`/contracts/${contractId}`); + }, + [navigate] + ); + + return ( + <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> + <Masthead showNav /> + <main className="flex-1 flex flex-col p-4 pt-2 gap-4 overflow-hidden"> + {error && ( + <div className="p-3 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-sm"> + {error} + </div> + )} + + {/* Create chain modal */} + {isCreating && ( + <CreateChainModal + onSubmit={handleCreateSubmit} + onCancel={handleCreateCancel} + /> + )} + + <div className="flex-1 grid grid-cols-[350px_1fr] gap-4 min-h-0"> + {/* Chain list */} + <ChainList + chains={chains} + loading={loading} + onSelect={handleSelect} + onCreate={handleCreate} + selectedId={id} + onArchive={handleArchive} + /> + + {/* Chain detail/editor or empty state */} + {chainDetail ? ( + <ChainEditor + chain={chainDetail} + graph={chainGraph} + loading={detailLoading} + onBack={handleBack} + onRefresh={handleRefresh} + onContractClick={handleContractClick} + /> + ) : ( + <div className="panel h-full flex items-center justify-center"> + <div className="text-center"> + <p className="font-mono text-sm text-[#555] mb-4"> + Select a chain or create a new one + </p> + <button + onClick={handleCreate} + className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase" + > + + New Chain + </button> + </div> + </div> + )} + </div> + </main> + </div> + ); +} + +interface CreateChainModalProps { + onSubmit: (name: string, description: string) => void; + onCancel: () => void; +} + +function CreateChainModal({ onSubmit, onCancel }: CreateChainModalProps) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + + const handleSubmit = () => { + if (name.trim()) { + onSubmit(name.trim(), description.trim()); + } + }; + + 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)]"> + <h3 className="font-mono text-sm text-[#75aafc] uppercase mb-4"> + Create Chain + </h3> + + <div className="space-y-4"> + {/* Chain name */} + <div> + <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> + Chain Name + </label> + <input + type="text" + value={name} + onChange={(e) => setName(e.target.value)} + placeholder="e.g., Feature Implementation" + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" + autoFocus + /> + </div> + + {/* Description */} + <div> + <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> + Description (optional) + </label> + <textarea + value={description} + onChange={(e) => setDescription(e.target.value)} + placeholder="Describe what this chain accomplishes..." + 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" + /> + </div> + + <p className="font-mono text-xs text-[#8b949e]"> + A chain links multiple contracts together in a directed acyclic graph (DAG). + Contracts can depend on each other, and dependent contracts start automatically + when their dependencies complete. + </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={!name.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 afddaf9..26dc69a 100644 --- a/makima/frontend/tsconfig.tsbuildinfo +++ b/makima/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/japanesehovertext.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/phaseconfirmationnotification.tsx","./src/components/protectedroute.tsx","./src/components/rewritelink.tsx","./src/components/simplemarkdown.tsx","./src/components/supervisorquestionnotification.tsx","./src/components/charts/chartrenderer.tsx","./src/components/contracts/commandmodepanel.tsx","./src/components/contracts/contractcliinput.tsx","./src/components/contracts/contractcontextmenu.tsx","./src/components/contracts/contractdetail.tsx","./src/components/contracts/contractlist.tsx","./src/components/contracts/phasebadge.tsx","./src/components/contracts/phaseconfirmationmodal.tsx","./src/components/contracts/phasedeliverablespanel.tsx","./src/components/contracts/phasehint.tsx","./src/components/contracts/phaseprogressbar.tsx","./src/components/contracts/quickactionbuttons.tsx","./src/components/contracts/repositorypanel.tsx","./src/components/contracts/taskderivationpreview.tsx","./src/components/files/bodyrenderer.tsx","./src/components/files/cliinput.tsx","./src/components/files/conflictnotification.tsx","./src/components/files/elementcontextmenu.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/files/reposyncindicator.tsx","./src/components/files/updatenotification.tsx","./src/components/files/versionhistorydropdown.tsx","./src/components/history/checkpointcard.tsx","./src/components/history/checkpointlist.tsx","./src/components/history/conversationmessage.tsx","./src/components/history/conversationview.tsx","./src/components/history/historyfilters.tsx","./src/components/history/resumecontrols.tsx","./src/components/history/timelineeventcard.tsx","./src/components/history/timelinelist.tsx","./src/components/history/index.ts","./src/components/listen/contractpickermodal.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptanalysispanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/components/mesh/branchtaskmodal.tsx","./src/components/mesh/contractcompletequestion.tsx","./src/components/mesh/directoryinput.tsx","./src/components/mesh/gitactionspanel.tsx","./src/components/mesh/inlinesubtaskeditor.tsx","./src/components/mesh/mergeconflictresolver.tsx","./src/components/mesh/overlaydiffviewer.tsx","./src/components/mesh/prpreview.tsx","./src/components/mesh/patcheslistpanel.tsx","./src/components/mesh/subtasktree.tsx","./src/components/mesh/taskdetail.tsx","./src/components/mesh/tasklist.tsx","./src/components/mesh/taskoutput.tsx","./src/components/mesh/tasktree.tsx","./src/components/mesh/unifiedmeshchatinput.tsx","./src/components/mesh/worktreefilespanel.tsx","./src/components/workflow/phasecolumn.tsx","./src/components/workflow/workflowboard.tsx","./src/components/workflow/workflowcontractcard.tsx","./src/contexts/authcontext.tsx","./src/contexts/supervisorquestionscontext.tsx","./src/hooks/usecontracts.ts","./src/hooks/usefilesubscription.ts","./src/hooks/usefiles.ts","./src/hooks/usemeshchathistory.ts","./src/hooks/usemicrophone.ts","./src/hooks/usespeakwebsocket.ts","./src/hooks/usetasksubscription.ts","./src/hooks/usetasks.ts","./src/hooks/usetextscramble.ts","./src/hooks/useversionhistory.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/listenapi.ts","./src/lib/markdown.ts","./src/lib/supabase.ts","./src/routes/_index.tsx","./src/routes/contract-file.tsx","./src/routes/contracts.tsx","./src/routes/files.tsx","./src/routes/history.tsx","./src/routes/listen.tsx","./src/routes/login.tsx","./src/routes/mesh.tsx","./src/routes/settings.tsx","./src/routes/speak.tsx","./src/routes/workflow.tsx","./src/types/messages.ts"],"version":"5.9.3"}
\ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/japanesehovertext.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/phaseconfirmationnotification.tsx","./src/components/protectedroute.tsx","./src/components/rewritelink.tsx","./src/components/simplemarkdown.tsx","./src/components/supervisorquestionnotification.tsx","./src/components/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/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/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/files.tsx","./src/routes/history.tsx","./src/routes/listen.tsx","./src/routes/login.tsx","./src/routes/mesh.tsx","./src/routes/settings.tsx","./src/routes/speak.tsx","./src/routes/workflow.tsx","./src/types/messages.ts"],"version":"5.9.3"}
\ No newline at end of file diff --git a/makima/migrations/20260203000000_create_chains.sql b/makima/migrations/20260203000000_create_chains.sql new file mode 100644 index 0000000..7811a45 --- /dev/null +++ b/makima/migrations/20260203000000_create_chains.sql @@ -0,0 +1,60 @@ +-- Chains table - DAG of contracts for multi-contract orchestration +-- Fits Makima's control theme - she controls through invisible chains +CREATE TABLE IF NOT EXISTS chains ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id UUID NOT NULL REFERENCES owners(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + description TEXT, + status VARCHAR(32) NOT NULL DEFAULT 'active', -- active/completed/archived + -- Loop control for iterative execution + loop_enabled BOOLEAN NOT NULL DEFAULT false, + loop_max_iterations INTEGER DEFAULT 10, + loop_current_iteration INTEGER DEFAULT 0, + loop_progress_check TEXT, -- Prompt/criteria for evaluating progress + -- Repository reference (optional - contracts may have their own repos) + repository_url VARCHAR(512), + local_path VARCHAR(512), + -- Versioning for optimistic locking + version INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_chains_owner_id ON chains(owner_id); +CREATE INDEX idx_chains_status ON chains(status); + +-- Chain contracts - links contracts to chains with DAG dependency info +-- The depends_on array forms the DAG edges (directed acyclic graph) +CREATE TABLE IF NOT EXISTS chain_contracts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + chain_id UUID NOT NULL REFERENCES chains(id) ON DELETE CASCADE, + contract_id UUID NOT NULL REFERENCES contracts(id) ON DELETE CASCADE, + -- DAG edges: contract IDs this contract depends on (must complete before this starts) + depends_on UUID[] DEFAULT '{}', + -- Order for display/processing (topological sort order) + order_index INTEGER NOT NULL DEFAULT 0, + -- Position for GUI editor + editor_x FLOAT DEFAULT 0, + editor_y FLOAT DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(chain_id, contract_id) +); + +CREATE INDEX idx_chain_contracts_chain_id ON chain_contracts(chain_id); +CREATE INDEX idx_chain_contracts_contract_id ON chain_contracts(contract_id); + +-- Add chain_id to contracts table for reverse lookup +ALTER TABLE contracts ADD COLUMN IF NOT EXISTS chain_id UUID REFERENCES chains(id) ON DELETE SET NULL; +CREATE INDEX IF NOT EXISTS idx_contracts_chain_id ON contracts(chain_id); + +-- Chain events for audit trail +CREATE TABLE IF NOT EXISTS chain_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + chain_id UUID NOT NULL REFERENCES chains(id) ON DELETE CASCADE, + event_type VARCHAR(64) NOT NULL, -- created, contract_added, contract_completed, loop_iteration, completed + contract_id UUID REFERENCES contracts(id) ON DELETE SET NULL, + event_data JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_chain_events_chain_id ON chain_events(chain_id); diff --git a/makima/src/bin/makima.rs b/makima/src/bin/makima.rs index af9832b..2037b47 100644 --- a/makima/src/bin/makima.rs +++ b/makima/src/bin/makima.rs @@ -6,7 +6,8 @@ use std::sync::Arc; use makima::daemon::api::{ApiClient, CreateContractRequest}; use makima::daemon::cli::{ - Cli, CliConfig, Commands, ConfigCommand, ContractCommand, SupervisorCommand, ViewArgs, + Cli, CliConfig, Commands, ConfigCommand, ContractCommand, ChainCommand, + SupervisorCommand, ViewArgs, }; use makima::daemon::tui::{self, Action, App, ListItem, ViewType, TuiWsClient, WsEvent, OutputLine, OutputMessageType, WsConnectionState, RepositorySuggestion}; use makima::daemon::config::{DaemonConfig, RepoEntry}; @@ -30,6 +31,7 @@ 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, } } @@ -793,6 +795,218 @@ 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 let Some(repo) = &chain.repository_url { + println!("Repository: {}", repo); + } + 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(()) +} + /// Load contracts from API async fn load_contracts(client: &ApiClient) -> Result<Vec<ListItem>, Box<dyn std::error::Error + Send + Sync>> { let result = client.list_contracts().await?; diff --git a/makima/src/daemon/api/chain.rs b/makima/src/daemon/api/chain.rs new file mode 100644 index 0000000..7f7826f --- /dev/null +++ b/makima/src/daemon/api/chain.rs @@ -0,0 +1,52 @@ +//! 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 + } +} diff --git a/makima/src/daemon/api/client.rs b/makima/src/daemon/api/client.rs index 4ba4778..dbf3101 100644 --- a/makima/src/daemon/api/client.rs +++ b/makima/src/daemon/api/client.rs @@ -276,6 +276,48 @@ impl ApiClient { Err(last_error.unwrap()) } + /// Make a DELETE request with response and retry. + pub async fn delete_with_response<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> { + let url = format!("{}{}", self.base_url, path); + let mut last_error = None; + + for attempt in 0..MAX_RETRIES { + if attempt > 0 { + tokio::time::sleep(Self::backoff_delay(attempt - 1)).await; + } + + let result = self.client + .delete(&url) + .header("X-Makima-Tool-Key", &self.api_key) + .header("X-Makima-API-Key", &self.api_key) + .send() + .await; + + match result { + Ok(response) => { + match self.handle_response(response).await { + Ok(value) => return Ok(value), + Err(e) if Self::is_retryable(&e) && attempt < MAX_RETRIES - 1 => { + last_error = Some(e); + continue; + } + Err(e) => return Err(e), + } + } + Err(e) => { + let error = ApiError::Request(e); + if Self::is_retryable(&error) && attempt < MAX_RETRIES - 1 { + last_error = Some(error); + continue; + } + return Err(error); + } + } + } + + Err(last_error.unwrap()) + } + /// Handle API response. async fn handle_response<T: DeserializeOwned>( &self, diff --git a/makima/src/daemon/api/mod.rs b/makima/src/daemon/api/mod.rs index 49d80e0..7868907 100644 --- a/makima/src/daemon/api/mod.rs +++ b/makima/src/daemon/api/mod.rs @@ -1,5 +1,6 @@ //! HTTP API client for makima CLI commands. +pub mod chain; pub mod client; pub mod contract; pub mod supervisor; diff --git a/makima/src/daemon/chain/dag.rs b/makima/src/daemon/chain/dag.rs new file mode 100644 index 0000000..7ba5904 --- /dev/null +++ b/makima/src/daemon/chain/dag.rs @@ -0,0 +1,450 @@ +//! 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 new file mode 100644 index 0000000..5588a27 --- /dev/null +++ b/makima/src/daemon/chain/mod.rs @@ -0,0 +1,13 @@ +//! 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 new file mode 100644 index 0000000..0f16710 --- /dev/null +++ b/makima/src/daemon/chain/parser.rs @@ -0,0 +1,392 @@ +//! 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), +} + +/// 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>, + /// Repository URL (optional - contracts may have their own repos) + #[serde(alias = "repo")] + pub repository_url: Option<String>, + /// Local path for repository + pub local_path: Option<String>, + /// 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 +repo: 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.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 new file mode 100644 index 0000000..9c6f6b4 --- /dev/null +++ b/makima/src/daemon/chain/runner.rs @@ -0,0 +1,364 @@ +//! 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::{ + 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 + api_url: String, + /// API key for authentication + 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), + }; + + CreateChainRequest { + name: chain.name.clone(), + description: chain.description.clone(), + repository_url: chain.repository_url.clone(), + local_path: chain.local_path.clone(), + 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 +repo: 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())); + assert_eq!( + request.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 new file mode 100644 index 0000000..1d7c167 --- /dev/null +++ b/makima/src/daemon/cli/chain.rs @@ -0,0 +1,107 @@ +//! 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 0805edd..035a784 100644 --- a/makima/src/daemon/cli/mod.rs +++ b/makima/src/daemon/cli/mod.rs @@ -1,5 +1,6 @@ //! Command-line interface for the makima CLI. +pub mod chain; pub mod config; pub mod contract; pub mod daemon; @@ -9,6 +10,7 @@ pub mod view; use clap::{Parser, Subcommand}; +pub use chain::ChainArgs; pub use config::CliConfig; pub use contract::ContractArgs; pub use daemon::DaemonArgs; @@ -58,6 +60,14 @@ pub enum Commands { /// Saves configuration to ~/.makima/config.toml for use by CLI 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), } /// Config subcommands for CLI configuration. @@ -196,6 +206,48 @@ 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), +} + impl Cli { /// Parse command-line arguments pub fn parse_args() -> Self { diff --git a/makima/src/daemon/mod.rs b/makima/src/daemon/mod.rs index f5793d6..62da20e 100644 --- a/makima/src/daemon/mod.rs +++ b/makima/src/daemon/mod.rs @@ -8,6 +8,7 @@ //! - `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/db/models.rs b/makima/src/db/models.rs index cef0a22..45ddb52 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -1446,6 +1446,9 @@ pub struct Contract { /// Use `get_phase_config()` to get the parsed PhaseConfig. #[serde(skip_serializing_if = "Option::is_none")] pub phase_config: Option<serde_json::Value>, + /// Chain ID if this contract is part of a chain (DAG of contracts) + #[serde(skip_serializing_if = "Option::is_none")] + pub chain_id: Option<Uuid>, pub version: i32, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>, @@ -2586,6 +2589,359 @@ pub struct HeartbeatHistoryQuery { } // ============================================================================= +// Chains (DAG of contracts for multi-contract orchestration) +// ============================================================================= + +/// Chain status determines the overall state of the chain +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum ChainStatus { + /// Chain is actively running + Active, + /// All contracts completed successfully + Completed, + /// Chain was manually archived + Archived, +} + +impl Default for ChainStatus { + fn default() -> Self { + ChainStatus::Active + } +} + +impl std::fmt::Display for ChainStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ChainStatus::Active => write!(f, "active"), + ChainStatus::Completed => write!(f, "completed"), + ChainStatus::Archived => write!(f, "archived"), + } + } +} + +impl std::str::FromStr for ChainStatus { + type Err = String; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s.to_lowercase().as_str() { + "active" => Ok(ChainStatus::Active), + "completed" => Ok(ChainStatus::Completed), + "archived" => Ok(ChainStatus::Archived), + _ => Err(format!("Invalid chain status: {}", s)), + } + } +} + +/// Chain - a directed acyclic graph (DAG) of contracts +/// Fits Makima's control theme - she controls through invisible chains +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Chain { + pub id: Uuid, + pub owner_id: Uuid, + pub name: String, + pub description: Option<String>, + pub status: String, + /// Whether loop mode is enabled for iterative execution + #[serde(default)] + pub loop_enabled: bool, + /// Maximum loop iterations (default: 10) + pub loop_max_iterations: Option<i32>, + /// Current loop iteration count + pub loop_current_iteration: Option<i32>, + /// Progress check prompt/criteria for evaluating loop completion + pub loop_progress_check: Option<String>, + /// Repository URL for contracts in this chain (optional) + pub repository_url: Option<String>, + /// Local path for contracts in this chain (optional) + pub local_path: Option<String>, + /// Version for optimistic locking + pub version: i32, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, +} + +impl Chain { + /// Parse status string to ChainStatus enum + pub fn status_enum(&self) -> Result<ChainStatus, String> { + self.status.parse() + } +} + +/// Chain contract link - links contracts to chains with DAG dependency info +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainContract { + pub id: Uuid, + pub chain_id: Uuid, + pub contract_id: Uuid, + /// Contract IDs this contract depends on (DAG edges) + #[sqlx(default)] + pub depends_on: Vec<Uuid>, + /// Order for display/processing (topological sort order) + pub order_index: i32, + /// X position for GUI editor + pub editor_x: Option<f64>, + /// Y position for GUI editor + pub editor_y: Option<f64>, + pub created_at: DateTime<Utc>, +} + +/// Chain event for audit trail +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainEvent { + pub id: Uuid, + pub chain_id: Uuid, + pub event_type: String, + pub contract_id: Option<Uuid>, + #[sqlx(json)] + pub event_data: Option<serde_json::Value>, + pub created_at: DateTime<Utc>, +} + +/// Summary of a chain for list views +#[derive(Debug, Clone, FromRow, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainSummary { + pub id: Uuid, + pub name: String, + pub description: Option<String>, + pub status: String, + pub loop_enabled: bool, + pub loop_current_iteration: Option<i32>, + pub contract_count: i64, + pub completed_count: i64, + pub version: i32, + pub created_at: DateTime<Utc>, +} + +/// Chain with contracts for detail view +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainWithContracts { + #[serde(flatten)] + pub chain: Chain, + pub contracts: Vec<ChainContractDetail>, +} + +/// Contract detail within a chain (includes contract info + chain link info) +#[derive(Debug, Clone, FromRow, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainContractDetail { + pub chain_contract_id: Uuid, + pub contract_id: Uuid, + pub contract_name: String, + pub contract_status: String, + pub contract_phase: String, + #[sqlx(default)] + pub depends_on: Vec<Uuid>, + pub order_index: i32, + pub editor_x: Option<f64>, + pub editor_y: Option<f64>, +} + +/// DAG graph structure for visualization +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainGraphResponse { + pub chain_id: Uuid, + pub chain_name: String, + pub chain_status: String, + pub nodes: Vec<ChainGraphNode>, + pub edges: Vec<ChainGraphEdge>, +} + +/// Node in chain DAG graph +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainGraphNode { + pub id: Uuid, + pub contract_id: Uuid, + pub name: String, + pub status: String, + pub phase: String, + pub x: f64, + pub y: f64, +} + +/// Edge in chain DAG graph +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainGraphEdge { + pub from: Uuid, + pub to: Uuid, +} + +/// Response for chain list endpoint +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainListResponse { + pub chains: Vec<ChainSummary>, + pub total: i64, +} + +/// Request payload for creating a new chain +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateChainRequest { + /// Name of the chain + pub name: String, + /// Optional description + pub description: Option<String>, + /// Repository URL for contracts in this chain + pub repository_url: Option<String>, + /// Local path for contracts in this chain + pub local_path: Option<String>, + /// Enable loop mode for iterative execution + #[serde(default)] + pub loop_enabled: Option<bool>, + /// Maximum loop iterations (default: 10) + pub loop_max_iterations: Option<i32>, + /// Progress check prompt for evaluating loop completion + pub loop_progress_check: Option<String>, + /// Contracts to create within this chain + pub contracts: Option<Vec<CreateChainContractRequest>>, +} + +/// Request to create a contract within a chain +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateChainContractRequest { + /// Name of the contract + pub name: String, + /// Optional description + pub description: Option<String>, + /// Contract type + #[serde(default)] + pub contract_type: Option<String>, + /// Initial phase + pub initial_phase: Option<String>, + /// Phases for the contract + pub phases: Option<Vec<String>>, + /// Names of contracts this depends on (resolved to IDs) + pub depends_on: Option<Vec<String>>, + /// Tasks to create in this contract + pub tasks: Option<Vec<CreateChainTaskRequest>>, + /// Deliverables for this contract + pub deliverables: Option<Vec<CreateChainDeliverableRequest>>, + /// Position in GUI editor + pub editor_x: Option<f64>, + pub editor_y: Option<f64>, +} + +/// Task definition within a chain contract +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateChainTaskRequest { + pub name: String, + pub plan: String, +} + +/// Deliverable definition within a chain contract +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateChainDeliverableRequest { + pub id: String, + pub name: String, + pub priority: Option<String>, +} + +/// Request to update an existing chain +#[derive(Debug, Clone, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateChainRequest { + pub name: Option<String>, + pub description: Option<String>, + pub status: Option<String>, + pub loop_enabled: Option<bool>, + pub loop_max_iterations: Option<i32>, + pub loop_progress_check: Option<String>, + /// Version for optimistic locking + pub version: Option<i32>, +} + +/// Request to add a contract to a chain +#[derive(Debug, Clone, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AddContractToChainRequest { + /// Existing contract ID to add + pub contract_id: Option<Uuid>, + /// Or create a new contract with this definition + pub new_contract: Option<CreateChainContractRequest>, + /// Contract IDs this depends on + pub depends_on: Option<Vec<Uuid>>, + /// Position in GUI editor + pub editor_x: Option<f64>, + pub editor_y: Option<f64>, +} + +/// Editor data model for GUI chain editor +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainEditorData { + pub id: Option<Uuid>, + pub name: String, + pub description: Option<String>, + pub repository_url: Option<String>, + pub local_path: Option<String>, + pub loop_enabled: bool, + pub loop_max_iterations: Option<i32>, + pub loop_progress_check: Option<String>, + pub nodes: Vec<ChainEditorNode>, + pub edges: Vec<ChainEditorEdge>, +} + +/// Node in chain editor +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainEditorNode { + pub id: String, + pub x: f64, + pub y: f64, + pub contract: ChainEditorContract, +} + +/// Contract data in chain editor node +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainEditorContract { + pub name: String, + pub description: Option<String>, + #[serde(rename = "type")] + pub contract_type: String, + pub phases: Vec<String>, + pub tasks: Vec<ChainEditorTask>, + pub deliverables: Vec<ChainEditorDeliverable>, +} + +/// Task in chain editor +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainEditorTask { + pub name: String, + pub plan: String, +} + +/// Deliverable in chain editor +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainEditorDeliverable { + pub id: String, + pub name: String, + pub priority: String, +} + +/// Edge in chain editor +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainEditorEdge { + pub from: String, + pub to: String, +} + +// ============================================================================= // Unit Tests // ============================================================================= diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index 2ecbc4a..48b0714 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -6,15 +6,19 @@ use sqlx::PgPool; use uuid::Uuid; use super::models::{ - CheckpointPatch, CheckpointPatchInfo, Contract, ContractChatConversation, - ContractChatMessageRecord, ContractEvent, ContractRepository, ContractSummary, - ContractTypeTemplateRecord, ConversationMessage, ConversationSnapshot, CreateContractRequest, - CreateFileRequest, CreateTaskRequest, CreateTemplateRequest, Daemon, DaemonTaskAssignment, - DaemonWithCapacity, DeliverableDefinition, File, FileSummary, FileVersion, HistoryEvent, - HistoryQueryFilters, MeshChatConversation, MeshChatMessageRecord, PhaseChangeResult, - PhaseConfig, PhaseDefinition, SupervisorHeartbeatRecord, SupervisorState, - Task, TaskCheckpoint, TaskEvent, TaskSummary, UpdateContractRequest, UpdateFileRequest, - UpdateTaskRequest, UpdateTemplateRequest, + AddContractToChainRequest, Chain, ChainContract, ChainContractDetail, ChainEditorContract, + ChainEditorData, ChainEditorDeliverable, ChainEditorEdge, ChainEditorNode, ChainEditorTask, + ChainEvent, ChainGraphEdge, ChainGraphNode, ChainGraphResponse, ChainSummary, + ChainWithContracts, CheckpointPatch, CheckpointPatchInfo, Contract, + ContractChatConversation, ContractChatMessageRecord, ContractEvent, ContractRepository, + ContractSummary, ContractTypeTemplateRecord, ConversationMessage, ConversationSnapshot, + CreateChainRequest, CreateContractRequest, CreateFileRequest, CreateTaskRequest, + CreateTemplateRequest, Daemon, DaemonTaskAssignment, DaemonWithCapacity, + DeliverableDefinition, File, FileSummary, FileVersion, HistoryEvent, HistoryQueryFilters, + MeshChatConversation, MeshChatMessageRecord, PhaseChangeResult, PhaseConfig, PhaseDefinition, + SupervisorHeartbeatRecord, SupervisorState, Task, TaskCheckpoint, TaskEvent, TaskSummary, + UpdateChainRequest, UpdateContractRequest, UpdateFileRequest, UpdateTaskRequest, + UpdateTemplateRequest, }; /// Repository error types. @@ -4896,3 +4900,529 @@ pub async fn sync_supervisor_state( .fetch_optional(pool) .await } + +// ============================================================================= +// Chain Operations (DAG of contracts for multi-contract orchestration) +// ============================================================================= + +/// Create a new chain for a specific owner. +pub async fn create_chain_for_owner( + pool: &PgPool, + owner_id: Uuid, + req: CreateChainRequest, +) -> Result<Chain, sqlx::Error> { + let loop_enabled = req.loop_enabled.unwrap_or(false); + let loop_max_iterations = req.loop_max_iterations.unwrap_or(10); + + sqlx::query_as::<_, Chain>( + r#" + INSERT INTO chains (owner_id, name, description, repository_url, local_path, loop_enabled, loop_max_iterations, loop_progress_check) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING * + "#, + ) + .bind(owner_id) + .bind(&req.name) + .bind(&req.description) + .bind(&req.repository_url) + .bind(&req.local_path) + .bind(loop_enabled) + .bind(loop_max_iterations) + .bind(&req.loop_progress_check) + .fetch_one(pool) + .await +} + +/// Get a chain by ID, scoped to owner. +pub async fn get_chain_for_owner( + pool: &PgPool, + id: Uuid, + owner_id: Uuid, +) -> Result<Option<Chain>, sqlx::Error> { + sqlx::query_as::<_, Chain>( + r#" + SELECT * + FROM chains + WHERE id = $1 AND owner_id = $2 + "#, + ) + .bind(id) + .bind(owner_id) + .fetch_optional(pool) + .await +} + +/// Get a chain by ID (no owner check - for internal use). +pub async fn get_chain(pool: &PgPool, id: Uuid) -> Result<Option<Chain>, sqlx::Error> { + sqlx::query_as::<_, Chain>( + r#" + SELECT * + FROM chains + WHERE id = $1 + "#, + ) + .bind(id) + .fetch_optional(pool) + .await +} + +/// List chains for a specific owner. +pub async fn list_chains_for_owner( + pool: &PgPool, + owner_id: Uuid, +) -> Result<Vec<ChainSummary>, sqlx::Error> { + sqlx::query_as::<_, ChainSummary>( + r#" + SELECT + c.id, + c.name, + c.description, + c.status, + c.loop_enabled, + c.loop_current_iteration, + COUNT(DISTINCT cc.contract_id) as contract_count, + COUNT(DISTINCT CASE WHEN con.status = 'completed' THEN cc.contract_id END) as completed_count, + c.version, + c.created_at + FROM chains c + LEFT JOIN chain_contracts cc ON cc.chain_id = c.id + LEFT JOIN contracts con ON con.id = cc.contract_id + WHERE c.owner_id = $1 + GROUP BY c.id + ORDER BY c.created_at DESC + "#, + ) + .bind(owner_id) + .fetch_all(pool) + .await +} + +/// Update a chain. +pub async fn update_chain_for_owner( + pool: &PgPool, + id: Uuid, + owner_id: Uuid, + req: UpdateChainRequest, +) -> Result<Chain, RepositoryError> { + // First get current version if optimistic locking requested + if let Some(expected_version) = req.version { + let current: Option<(i32,)> = sqlx::query_as( + "SELECT version FROM chains WHERE id = $1 AND owner_id = $2", + ) + .bind(id) + .bind(owner_id) + .fetch_optional(pool) + .await?; + + if let Some((actual_version,)) = current { + if actual_version != expected_version { + return Err(RepositoryError::VersionConflict { + expected: expected_version, + actual: actual_version, + }); + } + } + } + + let result = sqlx::query_as::<_, Chain>( + r#" + UPDATE chains + SET + name = COALESCE($3, name), + description = COALESCE($4, description), + status = COALESCE($5, status), + loop_enabled = COALESCE($6, loop_enabled), + loop_max_iterations = COALESCE($7, loop_max_iterations), + loop_progress_check = COALESCE($8, loop_progress_check), + version = version + 1, + updated_at = NOW() + WHERE id = $1 AND owner_id = $2 + RETURNING * + "#, + ) + .bind(id) + .bind(owner_id) + .bind(&req.name) + .bind(&req.description) + .bind(&req.status) + .bind(req.loop_enabled) + .bind(req.loop_max_iterations) + .bind(&req.loop_progress_check) + .fetch_one(pool) + .await?; + + Ok(result) +} + +/// Delete (archive) a chain. +pub async fn delete_chain_for_owner( + pool: &PgPool, + id: Uuid, + owner_id: Uuid, +) -> Result<bool, sqlx::Error> { + let result = sqlx::query( + r#" + UPDATE chains + SET status = 'archived', updated_at = NOW() + WHERE id = $1 AND owner_id = $2 + "#, + ) + .bind(id) + .bind(owner_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// Add a contract to a chain. +pub async fn add_contract_to_chain( + pool: &PgPool, + chain_id: Uuid, + contract_id: Uuid, + depends_on: Vec<Uuid>, + order_index: i32, + editor_x: Option<f64>, + editor_y: Option<f64>, +) -> Result<ChainContract, sqlx::Error> { + // Also update the contract's chain_id + sqlx::query("UPDATE contracts SET chain_id = $1 WHERE id = $2") + .bind(chain_id) + .bind(contract_id) + .execute(pool) + .await?; + + sqlx::query_as::<_, ChainContract>( + r#" + INSERT INTO chain_contracts (chain_id, contract_id, depends_on, order_index, editor_x, editor_y) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (chain_id, contract_id) DO UPDATE SET + depends_on = EXCLUDED.depends_on, + order_index = EXCLUDED.order_index, + editor_x = EXCLUDED.editor_x, + editor_y = EXCLUDED.editor_y + RETURNING * + "#, + ) + .bind(chain_id) + .bind(contract_id) + .bind(&depends_on) + .bind(order_index) + .bind(editor_x) + .bind(editor_y) + .fetch_one(pool) + .await +} + +/// Remove a contract from a chain. +pub async fn remove_contract_from_chain( + pool: &PgPool, + chain_id: Uuid, + contract_id: Uuid, +) -> Result<bool, sqlx::Error> { + // Clear the contract's chain_id + sqlx::query("UPDATE contracts SET chain_id = NULL WHERE id = $1 AND chain_id = $2") + .bind(contract_id) + .bind(chain_id) + .execute(pool) + .await?; + + let result = sqlx::query( + r#" + DELETE FROM chain_contracts + WHERE chain_id = $1 AND contract_id = $2 + "#, + ) + .bind(chain_id) + .bind(contract_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// List contracts in a chain with their details. +pub async fn list_chain_contracts( + pool: &PgPool, + chain_id: Uuid, +) -> Result<Vec<ChainContractDetail>, sqlx::Error> { + sqlx::query_as::<_, ChainContractDetail>( + r#" + SELECT + cc.id as chain_contract_id, + cc.contract_id, + c.name as contract_name, + c.status as contract_status, + c.phase as contract_phase, + cc.depends_on, + cc.order_index, + cc.editor_x, + cc.editor_y + FROM chain_contracts cc + JOIN contracts c ON c.id = cc.contract_id + WHERE cc.chain_id = $1 + ORDER BY cc.order_index ASC + "#, + ) + .bind(chain_id) + .fetch_all(pool) + .await +} + +/// Get chain with all contracts for detail view. +pub async fn get_chain_with_contracts( + pool: &PgPool, + chain_id: Uuid, + owner_id: Uuid, +) -> Result<Option<ChainWithContracts>, sqlx::Error> { + let chain = get_chain_for_owner(pool, chain_id, owner_id).await?; + + match chain { + Some(chain) => { + let contracts = list_chain_contracts(pool, chain_id).await?; + Ok(Some(ChainWithContracts { chain, contracts })) + } + None => Ok(None), + } +} + +/// Get chain graph structure for visualization. +pub async fn get_chain_graph( + pool: &PgPool, + chain_id: Uuid, +) -> Result<Option<ChainGraphResponse>, sqlx::Error> { + let chain = get_chain(pool, chain_id).await?; + + match chain { + Some(chain) => { + let contracts = list_chain_contracts(pool, chain_id).await?; + + let nodes: Vec<ChainGraphNode> = contracts + .iter() + .map(|c| ChainGraphNode { + id: c.chain_contract_id, + contract_id: c.contract_id, + name: c.contract_name.clone(), + status: c.contract_status.clone(), + phase: c.contract_phase.clone(), + x: c.editor_x.unwrap_or(0.0), + y: c.editor_y.unwrap_or(0.0), + }) + .collect(); + + let mut edges: Vec<ChainGraphEdge> = Vec::new(); + for contract in &contracts { + for dep_id in &contract.depends_on { + // Find the chain_contract_id for this dependency + if let Some(dep) = contracts.iter().find(|c| c.contract_id == *dep_id) { + edges.push(ChainGraphEdge { + from: dep.chain_contract_id, + to: contract.chain_contract_id, + }); + } + } + } + + Ok(Some(ChainGraphResponse { + chain_id: chain.id, + chain_name: chain.name, + chain_status: chain.status, + nodes, + edges, + })) + } + None => Ok(None), + } +} + +/// Record a chain event. +pub async fn record_chain_event( + pool: &PgPool, + chain_id: Uuid, + event_type: &str, + contract_id: Option<Uuid>, + event_data: Option<serde_json::Value>, +) -> Result<ChainEvent, sqlx::Error> { + sqlx::query_as::<_, ChainEvent>( + r#" + INSERT INTO chain_events (chain_id, event_type, contract_id, event_data) + VALUES ($1, $2, $3, $4) + RETURNING * + "#, + ) + .bind(chain_id) + .bind(event_type) + .bind(contract_id) + .bind(event_data) + .fetch_one(pool) + .await +} + +/// List chain events. +pub async fn list_chain_events( + pool: &PgPool, + chain_id: Uuid, +) -> Result<Vec<ChainEvent>, sqlx::Error> { + sqlx::query_as::<_, ChainEvent>( + r#" + SELECT * + FROM chain_events + WHERE chain_id = $1 + ORDER BY created_at DESC + "#, + ) + .bind(chain_id) + .fetch_all(pool) + .await +} + +/// Increment chain loop iteration. +pub async fn increment_chain_loop(pool: &PgPool, chain_id: Uuid) -> Result<Chain, sqlx::Error> { + sqlx::query_as::<_, Chain>( + r#" + UPDATE chains + SET loop_current_iteration = COALESCE(loop_current_iteration, 0) + 1, + updated_at = NOW() + WHERE id = $1 + RETURNING * + "#, + ) + .bind(chain_id) + .fetch_one(pool) + .await +} + +/// Mark a chain as completed. +pub async fn complete_chain(pool: &PgPool, chain_id: Uuid) -> Result<Chain, sqlx::Error> { + sqlx::query_as::<_, Chain>( + r#" + UPDATE chains + SET status = 'completed', + updated_at = NOW() + WHERE id = $1 + RETURNING * + "#, + ) + .bind(chain_id) + .fetch_one(pool) + .await +} + +/// Get contracts in a chain that have no pending dependencies (ready to start). +/// Returns contracts where all depends_on contracts are completed. +pub async fn get_ready_chain_contracts( + pool: &PgPool, + chain_id: Uuid, +) -> Result<Vec<ChainContractDetail>, sqlx::Error> { + sqlx::query_as::<_, ChainContractDetail>( + r#" + SELECT + cc.id as chain_contract_id, + cc.contract_id, + c.name as contract_name, + c.status as contract_status, + c.phase as contract_phase, + cc.depends_on, + cc.order_index, + cc.editor_x, + cc.editor_y + FROM chain_contracts cc + JOIN contracts c ON c.id = cc.contract_id + WHERE cc.chain_id = $1 + AND c.status = 'active' + AND ( + -- No dependencies + cc.depends_on IS NULL + OR array_length(cc.depends_on, 1) IS NULL + OR array_length(cc.depends_on, 1) = 0 + -- Or all dependencies completed + OR NOT EXISTS ( + SELECT 1 + FROM unnest(cc.depends_on) AS dep_id + JOIN contracts dep ON dep.id = dep_id + WHERE dep.status != 'completed' + ) + ) + ORDER BY cc.order_index ASC + "#, + ) + .bind(chain_id) + .fetch_all(pool) + .await +} + +/// Check if all contracts in a chain are completed. +pub async fn is_chain_complete(pool: &PgPool, chain_id: Uuid) -> Result<bool, sqlx::Error> { + let result: (i64,) = sqlx::query_as( + r#" + SELECT COUNT(*) + FROM chain_contracts cc + JOIN contracts c ON c.id = cc.contract_id + WHERE cc.chain_id = $1 + AND c.status != 'completed' + "#, + ) + .bind(chain_id) + .fetch_one(pool) + .await?; + + Ok(result.0 == 0) +} + +/// Get chain editor data for the GUI editor. +pub async fn get_chain_editor_data( + pool: &PgPool, + chain_id: Uuid, + owner_id: Uuid, +) -> Result<Option<ChainEditorData>, sqlx::Error> { + let chain = get_chain_for_owner(pool, chain_id, owner_id).await?; + + match chain { + Some(chain) => { + let contracts = list_chain_contracts(pool, chain_id).await?; + + // Build nodes + let nodes: Vec<ChainEditorNode> = contracts + .iter() + .map(|c| ChainEditorNode { + id: c.contract_id.to_string(), + x: c.editor_x.unwrap_or(0.0), + y: c.editor_y.unwrap_or(0.0), + contract: ChainEditorContract { + name: c.contract_name.clone(), + description: None, // Would need to join with full contract data + contract_type: "simple".to_string(), + phases: vec!["plan".to_string(), "execute".to_string()], + tasks: vec![], + deliverables: vec![], + }, + }) + .collect(); + + // Build edges + let edges: Vec<ChainEditorEdge> = contracts + .iter() + .flat_map(|c| { + c.depends_on.iter().map(move |dep_id| ChainEditorEdge { + from: dep_id.to_string(), + to: c.contract_id.to_string(), + }) + }) + .collect(); + + Ok(Some(ChainEditorData { + id: Some(chain.id), + name: chain.name, + description: chain.description, + repository_url: chain.repository_url, + local_path: chain.local_path, + loop_enabled: chain.loop_enabled, + loop_max_iterations: chain.loop_max_iterations, + loop_progress_check: chain.loop_progress_check, + nodes, + edges, + })) + } + None => Ok(None), + } +} diff --git a/makima/src/server/handlers/chains.rs b/makima/src/server/handlers/chains.rs new file mode 100644 index 0000000..136a868 --- /dev/null +++ b/makima/src/server/handlers/chains.rs @@ -0,0 +1,609 @@ +//! 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::{ + ChainContractDetail, ChainEditorData, ChainEvent, ChainGraphResponse, ChainSummary, + ChainWithContracts, CreateChainRequest, UpdateChainRequest, +}; +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() + } + } +} + +/// 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() + } + } +} diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs index a14c4f7..3e01a3e 100644 --- a/makima/src/server/handlers/mod.rs +++ b/makima/src/server/handlers/mod.rs @@ -1,6 +1,7 @@ //! HTTP and WebSocket request handlers. pub mod api_keys; +pub mod chains; pub mod chat; pub mod contract_chat; pub mod contract_daemon; diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index b351ac1..d110c18 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -18,7 +18,7 @@ use tower_http::trace::TraceLayer; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; -use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contracts, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, repository_history, speak, templates, transcript_analysis, users, versions}; +use crate::server::handlers::{api_keys, chains, chat, contract_chat, contract_daemon, contracts, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, repository_history, speak, templates, transcript_analysis, users, versions}; use crate::server::openapi::ApiDoc; use crate::server::state::SharedState; @@ -213,6 +213,21 @@ pub fn make_router(state: SharedState) -> Router { ) // Timeline endpoint (unified history for user) .route("/timeline", get(history::get_timeline)) + // Chain endpoints (multi-contract orchestration) + .route( + "/chains", + get(chains::list_chains).post(chains::create_chain), + ) + .route( + "/chains/{id}", + get(chains::get_chain) + .put(chains::update_chain) + .delete(chains::delete_chain), + ) + .route("/chains/{id}/contracts", get(chains::get_chain_contracts)) + .route("/chains/{id}/graph", get(chains::get_chain_graph)) + .route("/chains/{id}/events", get(chains::get_chain_events)) + .route("/chains/{id}/editor", get(chains::get_chain_editor)) // Contract type templates (built-in only) .route("/contract-types", get(templates::list_contract_types)) // Settings endpoints |
