diff options
| author | soryu <soryu@soryu.co> | 2026-02-03 23:49:08 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-02-03 23:49:19 +0000 |
| commit | c732dd048128808cd9f67f6e1176a5b565df5678 (patch) | |
| tree | 6ebf359c9c3f2d8aca264c53da6367b7f0af5fc8 | |
| parent | 9ebc9724afcc0482a8e7cd2369c06208fedbcbd1 (diff) | |
| download | soryu-c732dd048128808cd9f67f6e1176a5b565df5678.tar.gz soryu-c732dd048128808cd9f67f6e1176a5b565df5678.zip | |
Allow chain creation via web interface
| -rw-r--r-- | makima/frontend/src/components/chains/ChainEditor.tsx | 696 | ||||
| -rw-r--r-- | makima/frontend/src/components/chains/ChainList.tsx | 1 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 190 | ||||
| -rw-r--r-- | makima/migrations/20260203100000_chain_definitions.sql | 32 | ||||
| -rw-r--r-- | makima/src/db/models.rs | 107 | ||||
| -rw-r--r-- | makima/src/db/repository.rs | 299 | ||||
| -rw-r--r-- | makima/src/server/handlers/chains.rs | 648 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 18 |
8 files changed, 1886 insertions, 105 deletions
diff --git a/makima/frontend/src/components/chains/ChainEditor.tsx b/makima/frontend/src/components/chains/ChainEditor.tsx index 0dcabe1..9028c3e 100644 --- a/makima/frontend/src/components/chains/ChainEditor.tsx +++ b/makima/frontend/src/components/chains/ChainEditor.tsx @@ -1,14 +1,26 @@ -import { useState, useCallback, useMemo, useRef } from "react"; +import { useState, useCallback, useMemo, useRef, useEffect } from "react"; import type { ChainWithContracts, ChainGraphResponse, ChainContractDetail, + ChainContractDefinition, + ChainDefinitionGraphResponse, + AddContractDefinitionRequest, +} from "../../lib/api"; +import { + listChainDefinitions, + createChainDefinition, + deleteChainDefinition, + getChainDefinitionGraph, + startChain, + stopChain, } from "../../lib/api"; const statusColors: Record<string, string> = { active: "text-green-400", completed: "text-blue-400", archived: "text-[#555]", + pending: "text-yellow-400", }; interface ChainEditorProps { @@ -36,20 +48,56 @@ export function ChainEditor({ const canvasRef = useRef<HTMLDivElement>(null); const [selectedNode, setSelectedNode] = useState<string | null>(null); const [hoveredNode, setHoveredNode] = useState<string | null>(null); + const [definitions, setDefinitions] = useState<ChainContractDefinition[]>([]); + const [definitionGraph, setDefinitionGraph] = useState<ChainDefinitionGraphResponse | null>(null); + const [showAddDefinition, setShowAddDefinition] = useState(false); + const [isStarting, setIsStarting] = useState(false); + const [isStopping, setIsStopping] = useState(false); + const [error, setError] = useState<string | null>(null); + + // Load definitions when chain changes + useEffect(() => { + async function loadDefinitions() { + try { + const [defs, defGraph] = await Promise.all([ + listChainDefinitions(chain.id), + getChainDefinitionGraph(chain.id), + ]); + setDefinitions(defs); + setDefinitionGraph(defGraph); + } catch (err) { + console.error("Failed to load definitions:", err); + } + } + loadDefinitions(); + }, [chain.id]); + + // Determine which view to show: definitions (pending chain) or contracts (active/completed) + const showDefinitions = chain.status === "pending" || chain.status === "archived"; + const currentGraph = showDefinitions ? definitionGraph : graph; // 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), - }); + + if (showDefinitions && definitionGraph?.nodes) { + for (const node of definitionGraph.nodes) { + positions.set(node.id, { + x: CANVAS_PADDING + (node.x || 0) * (NODE_WIDTH + 60), + y: CANVAS_PADDING + (node.y || 0) * (NODE_HEIGHT + 40), + }); + } + } else if (!showDefinitions && graph?.nodes) { + 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]); + }, [showDefinitions, definitionGraph?.nodes, graph?.nodes]); // Canvas dimensions const canvasDimensions = useMemo(() => { @@ -70,15 +118,17 @@ export function ChainEditor({ }; }, [nodePositions]); - const handleNodeClick = useCallback((contractId: string) => { - setSelectedNode(contractId); + const handleNodeClick = useCallback((nodeId: string) => { + setSelectedNode(nodeId); }, []); const handleNodeDoubleClick = useCallback( - (contractId: string) => { - onContractClick(contractId); + (nodeId: string) => { + // For definitions, we can't open a contract yet + if (showDefinitions) return; + onContractClick(nodeId); }, - [onContractClick] + [onContractClick, showDefinitions] ); const getStatusColor = (status: string) => { @@ -96,11 +146,76 @@ export function ChainEditor({ } }; - // Find selected contract from chain.contracts - const selectedContract = selectedNode + // Find selected item (definition or contract) + const selectedDefinition = showDefinitions && selectedNode + ? definitions.find((d) => d.id === selectedNode) + : null; + const selectedContract = !showDefinitions && selectedNode ? chain.contracts.find((c) => c.contractId === selectedNode) : null; + const handleStartChain = useCallback(async () => { + setIsStarting(true); + setError(null); + try { + await startChain(chain.id); + onRefresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to start chain"); + } finally { + setIsStarting(false); + } + }, [chain.id, onRefresh]); + + const handleStopChain = useCallback(async () => { + if (!confirm("Are you sure you want to stop this chain?")) return; + setIsStopping(true); + setError(null); + try { + await stopChain(chain.id); + onRefresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to stop chain"); + } finally { + setIsStopping(false); + } + }, [chain.id, onRefresh]); + + const handleAddDefinition = useCallback(async (req: AddContractDefinitionRequest) => { + try { + await createChainDefinition(chain.id, req); + // Reload definitions + const [defs, defGraph] = await Promise.all([ + listChainDefinitions(chain.id), + getChainDefinitionGraph(chain.id), + ]); + setDefinitions(defs); + setDefinitionGraph(defGraph); + setShowAddDefinition(false); + } catch (err) { + console.error("Failed to add definition:", err); + setError(err instanceof Error ? err.message : "Failed to add definition"); + } + }, [chain.id]); + + const handleDeleteDefinition = useCallback(async (definitionId: string) => { + if (!confirm("Are you sure you want to delete this definition?")) return; + try { + await deleteChainDefinition(chain.id, definitionId); + // Reload definitions + const [defs, defGraph] = await Promise.all([ + listChainDefinitions(chain.id), + getChainDefinitionGraph(chain.id), + ]); + setDefinitions(defs); + setDefinitionGraph(defGraph); + setSelectedNode(null); + } catch (err) { + console.error("Failed to delete definition:", err); + setError(err instanceof Error ? err.message : "Failed to delete definition"); + } + }, [chain.id]); + return ( <div className="panel h-full flex flex-col"> {/* Header */} @@ -128,6 +243,25 @@ export function ChainEditor({ > {chain.status} </span> + {/* Chain control buttons */} + {chain.status === "pending" && definitions.length > 0 && ( + <button + onClick={handleStartChain} + disabled={isStarting} + className="px-3 py-1 font-mono text-xs text-[#dbe7ff] bg-green-600 hover:bg-green-700 border border-green-500 transition-colors disabled:opacity-50" + > + {isStarting ? "Starting..." : "Start Chain"} + </button> + )} + {chain.status === "active" && ( + <button + onClick={handleStopChain} + disabled={isStopping} + className="px-3 py-1 font-mono text-xs text-[#dbe7ff] bg-red-600 hover:bg-red-700 border border-red-500 transition-colors disabled:opacity-50" + > + {isStopping ? "Stopping..." : "Stop"} + </button> + )} <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" @@ -136,6 +270,11 @@ export function ChainEditor({ </button> </div> </div> + {error && ( + <div className="mt-2 p-2 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-xs"> + {error} + </div> + )} </div> {/* Main content */} @@ -146,15 +285,27 @@ export function ChainEditor({ <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 ? ( + ) : !currentGraph || currentGraph.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 + {showDefinitions + ? "No contract definitions yet" + : "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 className="font-mono text-xs text-[#556677] mb-4"> + {showDefinitions + ? "Add contract definitions to build your chain" + : "Start the chain to create contracts from definitions"} </p> + {showDefinitions && ( + <button + onClick={() => setShowAddDefinition(true)} + className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors" + > + + Add Definition + </button> + )} </div> </div> ) : ( @@ -192,7 +343,7 @@ export function ChainEditor({ /> </marker> </defs> - {graph.edges.map((edge, index) => { + {currentGraph.edges.map((edge, index) => { const fromPos = nodePositions.get(edge.from); const toPos = nodePositions.get(edge.to); if (!fromPos || !toPos) return null; @@ -225,77 +376,156 @@ export function ChainEditor({ </svg> {/* Node layer */} - {graph.nodes.map((node) => { - const pos = nodePositions.get(node.contractId); - if (!pos) return null; + {showDefinitions && definitionGraph + ? definitionGraph.nodes.map((node) => { + const pos = nodePositions.get(node.id); + if (!pos) return null; + + const status = node.isInstantiated + ? node.contractStatus || "pending" + : "pending"; + const colors = getStatusColor(status); + const isSelected = selectedNode === node.id; + const isHovered = hoveredNode === node.id; + + return ( + <div + key={node.id} + onClick={() => handleNodeClick(node.id)} + onMouseEnter={() => setHoveredNode(node.id)} + 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, + borderStyle: node.isInstantiated ? "solid" : "dashed", + }} + > + {/* 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.isInstantiated ? status : "definition"} + </span> + <span className="font-mono text-[10px] text-[#8b949e]"> + {node.contractType} + </span> + </div> + </div> + </div> + </div> + ); + }) + : 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; + 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 */} + return ( <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> - )} + 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> - ); - })} + ); + })} </div> )} </div> {/* Detail panel */} + {selectedDefinition && ( + <DefinitionDetailPanel + definition={selectedDefinition} + onClose={() => setSelectedNode(null)} + onDelete={handleDeleteDefinition} + /> + )} {selectedContract && ( <ContractDetailPanel contract={selectedContract} @@ -310,15 +540,154 @@ export function ChainEditor({ {/* 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 + {showDefinitions ? ( + <> + <span>{definitions.length} definitions</span> + <span className="flex-1" /> + {chain.status === "pending" && ( + <button + onClick={() => setShowAddDefinition(true)} + className="text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors" + > + + Add Definition + </button> + )} + </> + ) : ( + <> + <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> + + {/* Add Definition Modal */} + {showAddDefinition && ( + <AddDefinitionModal + existingNames={definitions.map((d) => d.name)} + onSubmit={handleAddDefinition} + onCancel={() => setShowAddDefinition(false)} + /> + )} + </div> + ); +} + +interface DefinitionDetailPanelProps { + definition: ChainContractDefinition; + onClose: () => void; + onDelete: (id: string) => void; +} + +function DefinitionDetailPanel({ + definition, + onClose, + onDelete, +}: DefinitionDetailPanelProps) { + 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"> + Definition 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]">{definition.name}</p> + </div> + + {/* Description */} + {definition.description && ( + <div> + <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1"> + Description + </label> + <p className="font-mono text-xs text-[#dbe7ff]">{definition.description}</p> + </div> + )} + + {/* Contract Type */} + <div> + <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1"> + Contract Type + </label> + <span className="font-mono text-xs text-[#dbe7ff]">{definition.contractType}</span> + </div> + + {/* Initial Phase */} + <div> + <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1"> + Initial Phase + </label> + <span className="font-mono text-xs text-[#dbe7ff]"> + {definition.initialPhase || "plan"} </span> - <span className="flex-1" /> - <span>Double-click node to open contract</span> + </div> + + {/* Dependencies */} + {definition.dependsOnNames && definition.dependsOnNames.length > 0 && ( + <div> + <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1"> + Depends On + </label> + <div className="space-y-1"> + {definition.dependsOnNames.map((depName) => ( + <span + key={depName} + className="block font-mono text-xs text-[#9bc3ff]" + > + {depName} + </span> + ))} + </div> + </div> + )} + + {/* Tasks */} + {definition.tasks && definition.tasks.length > 0 && ( + <div> + <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1"> + Tasks ({definition.tasks.length}) + </label> + <div className="space-y-1"> + {definition.tasks.map((task, i) => ( + <div key={i} className="font-mono text-xs text-[#dbe7ff]"> + {task.name} + </div> + ))} + </div> + </div> + )} + + {/* Actions */} + <div className="pt-2 border-t border-[rgba(117,170,252,0.2)]"> + <button + onClick={() => onDelete(definition.id)} + className="w-full px-3 py-2 font-mono text-xs text-red-400 border border-red-400/30 hover:bg-red-400/10 transition-colors" + > + Delete Definition + </button> </div> </div> </div> @@ -448,6 +817,153 @@ function ContractDetailPanel({ ); } +interface AddDefinitionModalProps { + existingNames: string[]; + onSubmit: (req: AddContractDefinitionRequest) => void; + onCancel: () => void; +} + +function AddDefinitionModal({ + existingNames, + onSubmit, + onCancel, +}: AddDefinitionModalProps) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [contractType, setContractType] = useState("simple"); + const [initialPhase, setInitialPhase] = useState("plan"); + const [dependsOn, setDependsOn] = useState<string[]>([]); + + const handleSubmit = () => { + if (!name.trim()) return; + onSubmit({ + name: name.trim(), + description: description.trim() || undefined, + contractType, + initialPhase, + dependsOn: dependsOn.length > 0 ? dependsOn : undefined, + }); + }; + + const toggleDependency = (depName: string) => { + setDependsOn((prev) => + prev.includes(depName) ? prev.filter((d) => d !== depName) : [...prev, depName] + ); + }; + + 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"> + Add Contract Definition + </h3> + + <div className="space-y-4"> + {/* Name */} + <div> + <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> + Name + </label> + <input + type="text" + value={name} + onChange={(e) => setName(e.target.value)} + placeholder="e.g., Research Phase" + 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="What does this contract accomplish?" + rows={2} + 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> + + {/* Contract Type */} + <div> + <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> + Contract Type + </label> + <select + value={contractType} + onChange={(e) => setContractType(e.target.value)} + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" + > + <option value="simple">Simple</option> + <option value="specification">Specification</option> + <option value="execute">Execute</option> + </select> + </div> + + {/* Initial Phase */} + <div> + <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> + Initial Phase + </label> + <select + value={initialPhase} + onChange={(e) => setInitialPhase(e.target.value)} + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" + > + <option value="plan">Plan</option> + <option value="execute">Execute</option> + <option value="review">Review</option> + </select> + </div> + + {/* Dependencies */} + {existingNames.length > 0 && ( + <div> + <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> + Depends On + </label> + <div className="space-y-1 max-h-32 overflow-y-auto"> + {existingNames.map((depName) => ( + <label key={depName} className="flex items-center gap-2 cursor-pointer"> + <input + type="checkbox" + checked={dependsOn.includes(depName)} + onChange={() => toggleDependency(depName)} + className="accent-[#75aafc]" + /> + <span className="font-mono text-xs text-[#dbe7ff]">{depName}</span> + </label> + ))} + </div> + </div> + )} + + {/* 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" + > + Add Definition + </button> + </div> + </div> + </div> + </div> + ); +} + function ChainIcon({ className }: { className?: string }) { return ( <svg diff --git a/makima/frontend/src/components/chains/ChainList.tsx b/makima/frontend/src/components/chains/ChainList.tsx index befccd2..e185efc 100644 --- a/makima/frontend/src/components/chains/ChainList.tsx +++ b/makima/frontend/src/components/chains/ChainList.tsx @@ -11,6 +11,7 @@ interface ChainListProps { } const statusColors: Record<ChainStatus, string> = { + pending: "text-yellow-400", active: "text-green-400", completed: "text-blue-400", archived: "text-[#555]", diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index bdaedf9..e5cf1d8 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -3008,7 +3008,7 @@ export async function listTaskPatches(taskId: string, contractId: string): Promi // ============================================================================= /** Chain status */ -export type ChainStatus = "active" | "completed" | "archived"; +export type ChainStatus = "pending" | "active" | "completed" | "archived"; /** Chain summary for list view */ export interface ChainSummary { @@ -3237,3 +3237,191 @@ export async function getChainEvents(chainId: string): Promise<ChainEvent[]> { } return res.json(); } + +// ============================================================================= +// Chain Contract Definitions +// ============================================================================= + +/** Task definition for chain contract definitions */ +export interface ChainTaskDefinition { + name: string; + plan: string; +} + +/** Deliverable definition for chain contract definitions (optional priority) */ +export interface ChainDeliverableDefinition { + id: string; + name: string; + priority?: string; +} + +/** Contract definition stored in chain (before actual contract is created) */ +export interface ChainContractDefinition { + id: string; + chainId: string; + name: string; + description: string | null; + contractType: string; + initialPhase: string | null; + dependsOnNames: string[]; + tasks: ChainTaskDefinition[] | null; + deliverables: ChainDeliverableDefinition[] | null; + editorX: number | null; + editorY: number | null; + orderIndex: number; + createdAt: string; +} + +/** Request to add a contract definition to a chain */ +export interface AddContractDefinitionRequest { + name: string; + description?: string; + contractType?: string; + initialPhase?: string; + dependsOn?: string[]; + tasks?: ChainTaskDefinition[]; + deliverables?: ChainDeliverableDefinition[]; + editorX?: number; + editorY?: number; + orderIndex?: number; +} + +/** Request to update a contract definition */ +export interface UpdateContractDefinitionRequest { + name?: string; + description?: string; + contractType?: string; + initialPhase?: string; + dependsOn?: string[]; + tasks?: ChainTaskDefinition[]; + deliverables?: ChainDeliverableDefinition[]; + editorX?: number; + editorY?: number; + orderIndex?: number; +} + +/** Response when starting a chain */ +export interface StartChainResponse { + chainId: string; + supervisorTaskId: string | null; + contractsCreated: string[]; + status: string; +} + +/** Node in definition graph (shows definitions + instantiation status) */ +export interface ChainDefinitionGraphNode { + id: string; + name: string; + contractType: string; + x: number; + y: number; + isInstantiated: boolean; + contractId: string | null; + contractStatus: string | null; +} + +/** Definition graph response */ +export interface ChainDefinitionGraphResponse { + chainId: string; + chainName: string; + chainStatus: string; + nodes: ChainDefinitionGraphNode[]; + edges: ChainGraphEdge[]; +} + +/** List contract definitions for a chain */ +export async function listChainDefinitions( + chainId: string +): Promise<ChainContractDefinition[]> { + const res = await authFetch(`${API_BASE}/api/v1/chains/${chainId}/definitions`); + if (!res.ok) { + throw new Error(`Failed to list chain definitions: ${res.statusText}`); + } + return res.json(); +} + +/** Create a contract definition for a chain */ +export async function createChainDefinition( + chainId: string, + req: AddContractDefinitionRequest +): Promise<ChainContractDefinition> { + const res = await authFetch(`${API_BASE}/api/v1/chains/${chainId}/definitions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(req), + }); + if (!res.ok) { + throw new Error(`Failed to create chain definition: ${res.statusText}`); + } + return res.json(); +} + +/** Update a contract definition */ +export async function updateChainDefinition( + chainId: string, + definitionId: string, + req: UpdateContractDefinitionRequest +): Promise<ChainContractDefinition> { + const res = await authFetch( + `${API_BASE}/api/v1/chains/${chainId}/definitions/${definitionId}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(req), + } + ); + if (!res.ok) { + throw new Error(`Failed to update chain definition: ${res.statusText}`); + } + return res.json(); +} + +/** Delete a contract definition */ +export async function deleteChainDefinition( + chainId: string, + definitionId: string +): Promise<{ deleted: boolean }> { + const res = await authFetch( + `${API_BASE}/api/v1/chains/${chainId}/definitions/${definitionId}`, + { method: "DELETE" } + ); + if (!res.ok) { + throw new Error(`Failed to delete chain definition: ${res.statusText}`); + } + return res.json(); +} + +/** Get definition graph for a chain */ +export async function getChainDefinitionGraph( + chainId: string +): Promise<ChainDefinitionGraphResponse> { + const res = await authFetch(`${API_BASE}/api/v1/chains/${chainId}/definitions/graph`); + if (!res.ok) { + throw new Error(`Failed to get chain definition graph: ${res.statusText}`); + } + return res.json(); +} + +/** Start a chain (creates root contracts and spawns supervisor) */ +export async function startChain(chainId: string): Promise<StartChainResponse> { + const res = await authFetch(`${API_BASE}/api/v1/chains/${chainId}/start`, { + method: "POST", + }); + if (!res.ok) { + const error = await res.json().catch(() => ({ message: res.statusText })); + throw new Error(error.message || `Failed to start chain: ${res.statusText}`); + } + return res.json(); +} + +/** Stop a chain (kills supervisor, marks as archived) */ +export async function stopChain(chainId: string): Promise<{ stopped: boolean; status: string }> { + const res = await authFetch(`${API_BASE}/api/v1/chains/${chainId}/stop`, { + method: "POST", + }); + if (!res.ok) { + const error = await res.json().catch(() => ({ message: res.statusText })); + throw new Error(error.message || `Failed to stop chain: ${res.statusText}`); + } + return res.json(); +} diff --git a/makima/migrations/20260203100000_chain_definitions.sql b/makima/migrations/20260203100000_chain_definitions.sql new file mode 100644 index 0000000..9b47279 --- /dev/null +++ b/makima/migrations/20260203100000_chain_definitions.sql @@ -0,0 +1,32 @@ +-- Chain contract definitions - stores contract specs before actual contracts are created +-- This enables on-demand contract creation when dependencies are met +CREATE TABLE IF NOT EXISTS chain_contract_definitions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + chain_id UUID NOT NULL REFERENCES chains(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + description TEXT, + contract_type VARCHAR(32) NOT NULL DEFAULT 'simple', -- simple/specification/execute + initial_phase VARCHAR(32) DEFAULT 'plan', + -- Dependencies by name (resolved to IDs when contract is created) + depends_on_names TEXT[] DEFAULT '{}', + -- Task definitions as JSON array: [{name, plan}, ...] + tasks JSONB DEFAULT '[]', + -- Deliverable definitions as JSON array: [{id, name, priority}, ...] + deliverables JSONB DEFAULT '[]', + -- Position for GUI editor + editor_x FLOAT DEFAULT 0, + editor_y FLOAT DEFAULT 0, + -- Order for display/processing + order_index INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_chain_contract_definitions_chain_id ON chain_contract_definitions(chain_id); + +-- Add supervisor and retry control to chains +ALTER TABLE chains ADD COLUMN IF NOT EXISTS supervisor_task_id UUID REFERENCES tasks(id) ON DELETE SET NULL; +ALTER TABLE chains ADD COLUMN IF NOT EXISTS max_retries INTEGER NOT NULL DEFAULT 3; + +-- Add definition link and retry tracking to chain_contracts +ALTER TABLE chain_contracts ADD COLUMN IF NOT EXISTS definition_id UUID REFERENCES chain_contract_definitions(id) ON DELETE SET NULL; +ALTER TABLE chain_contracts ADD COLUMN IF NOT EXISTS retry_count INTEGER NOT NULL DEFAULT 0; diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index 45ddb52..eeb30e4 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -2767,7 +2767,7 @@ pub struct ChainGraphNode { } /// Edge in chain DAG graph -#[derive(Debug, Serialize, ToSchema)] +#[derive(Debug, Clone, Serialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ChainGraphEdge { pub from: Uuid, @@ -2942,6 +2942,111 @@ pub struct ChainEditorEdge { } // ============================================================================= +// Chain Contract Definitions (stored specs for on-demand contract creation) +// ============================================================================= + +/// Contract definition within a chain - stored spec before actual contract is created +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainContractDefinition { + pub id: Uuid, + pub chain_id: Uuid, + pub name: String, + pub description: Option<String>, + pub contract_type: String, + pub initial_phase: Option<String>, + /// Names of other definitions this depends on + #[sqlx(default)] + pub depends_on_names: Vec<String>, + /// Task definitions as JSON: [{name, plan}, ...] + pub tasks: Option<serde_json::Value>, + /// Deliverable definitions as JSON: [{id, name, priority}, ...] + pub deliverables: Option<serde_json::Value>, + /// Position in GUI editor + pub editor_x: Option<f64>, + pub editor_y: Option<f64>, + pub order_index: i32, + pub created_at: DateTime<Utc>, +} + +/// Request to add a contract definition to a chain +#[derive(Debug, Clone, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AddContractDefinitionRequest { + pub name: String, + pub description: Option<String>, + #[serde(default = "default_contract_type")] + pub contract_type: String, + pub initial_phase: Option<String>, + /// Names of other definitions this depends on + pub depends_on: Option<Vec<String>>, + /// Task definitions + pub tasks: Option<Vec<CreateChainTaskRequest>>, + /// Deliverable definitions + pub deliverables: Option<Vec<CreateChainDeliverableRequest>>, + /// Position in GUI editor + pub editor_x: Option<f64>, + pub editor_y: Option<f64>, +} + +fn default_contract_type() -> String { + "simple".to_string() +} + +/// Request to update a contract definition +#[derive(Debug, Clone, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateContractDefinitionRequest { + pub name: Option<String>, + pub description: Option<String>, + pub contract_type: Option<String>, + pub initial_phase: Option<String>, + pub depends_on: Option<Vec<String>>, + pub tasks: Option<Vec<CreateChainTaskRequest>>, + pub deliverables: Option<Vec<CreateChainDeliverableRequest>>, + pub editor_x: Option<f64>, + pub editor_y: Option<f64>, +} + +/// Response when starting a chain +#[derive(Debug, Clone, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct StartChainResponse { + pub chain_id: Uuid, + pub supervisor_task_id: Option<Uuid>, + /// Root contracts created (those with no dependencies) + pub contracts_created: Vec<Uuid>, + pub status: String, +} + +/// Graph node for definitions (before contracts are created) +#[derive(Debug, Clone, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainDefinitionGraphNode { + pub id: Uuid, + pub name: String, + pub contract_type: String, + pub x: f64, + pub y: f64, + /// Whether this definition has been instantiated as a contract + pub is_instantiated: bool, + /// The contract ID if instantiated + pub contract_id: Option<Uuid>, + pub contract_status: Option<String>, +} + +/// Graph response for definitions +#[derive(Debug, Clone, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainDefinitionGraphResponse { + pub chain_id: Uuid, + pub chain_name: String, + pub chain_status: String, + pub nodes: Vec<ChainDefinitionGraphNode>, + pub edges: Vec<ChainGraphEdge>, +} + +// ============================================================================= // Unit Tests // ============================================================================= diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index 48b0714..85af178 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -6,19 +6,20 @@ use sqlx::PgPool; use uuid::Uuid; use super::models::{ - 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, + AddContractDefinitionRequest, AddContractToChainRequest, Chain, ChainContract, + ChainContractDefinition, ChainContractDetail, ChainDefinitionGraphNode, + ChainDefinitionGraphResponse, 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, + UpdateChainRequest, UpdateContractDefinitionRequest, UpdateContractRequest, UpdateFileRequest, + UpdateTaskRequest, UpdateTemplateRequest, }; /// Repository error types. @@ -5426,3 +5427,281 @@ pub async fn get_chain_editor_data( None => Ok(None), } } + +// ============================================================================= +// Chain Contract Definition Operations +// ============================================================================= + +/// Create a new contract definition in a chain. +pub async fn create_chain_contract_definition( + pool: &PgPool, + chain_id: Uuid, + req: AddContractDefinitionRequest, +) -> Result<ChainContractDefinition, sqlx::Error> { + // Get the next order index + let max_order: Option<i32> = sqlx::query_scalar( + "SELECT MAX(order_index) FROM chain_contract_definitions WHERE chain_id = $1", + ) + .bind(chain_id) + .fetch_one(pool) + .await?; + + let order_index = max_order.unwrap_or(-1) + 1; + + // Convert tasks and deliverables to JSON + let tasks_json = req.tasks.as_ref().map(|t| serde_json::to_value(t).unwrap()); + let deliverables_json = req + .deliverables + .as_ref() + .map(|d| serde_json::to_value(d).unwrap()); + let depends_on_names: Vec<String> = req.depends_on.unwrap_or_default(); + + sqlx::query_as::<_, ChainContractDefinition>( + r#" + INSERT INTO chain_contract_definitions + (chain_id, name, description, contract_type, initial_phase, depends_on_names, tasks, deliverables, editor_x, editor_y, order_index) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING * + "#, + ) + .bind(chain_id) + .bind(&req.name) + .bind(&req.description) + .bind(&req.contract_type) + .bind(&req.initial_phase) + .bind(&depends_on_names) + .bind(&tasks_json) + .bind(&deliverables_json) + .bind(req.editor_x) + .bind(req.editor_y) + .bind(order_index) + .fetch_one(pool) + .await +} + +/// List all contract definitions in a chain. +pub async fn list_chain_contract_definitions( + pool: &PgPool, + chain_id: Uuid, +) -> Result<Vec<ChainContractDefinition>, sqlx::Error> { + sqlx::query_as::<_, ChainContractDefinition>( + r#" + SELECT * FROM chain_contract_definitions + WHERE chain_id = $1 + ORDER BY order_index ASC + "#, + ) + .bind(chain_id) + .fetch_all(pool) + .await +} + +/// Get a specific contract definition. +pub async fn get_chain_contract_definition( + pool: &PgPool, + definition_id: Uuid, +) -> Result<Option<ChainContractDefinition>, sqlx::Error> { + sqlx::query_as::<_, ChainContractDefinition>( + "SELECT * FROM chain_contract_definitions WHERE id = $1", + ) + .bind(definition_id) + .fetch_optional(pool) + .await +} + +/// Update a contract definition. +pub async fn update_chain_contract_definition( + pool: &PgPool, + definition_id: Uuid, + req: UpdateContractDefinitionRequest, +) -> Result<ChainContractDefinition, sqlx::Error> { + let tasks_json = req.tasks.as_ref().map(|t| serde_json::to_value(t).unwrap()); + let deliverables_json = req + .deliverables + .as_ref() + .map(|d| serde_json::to_value(d).unwrap()); + + sqlx::query_as::<_, ChainContractDefinition>( + r#" + UPDATE chain_contract_definitions SET + name = COALESCE($2, name), + description = COALESCE($3, description), + contract_type = COALESCE($4, contract_type), + initial_phase = COALESCE($5, initial_phase), + depends_on_names = COALESCE($6, depends_on_names), + tasks = COALESCE($7, tasks), + deliverables = COALESCE($8, deliverables), + editor_x = COALESCE($9, editor_x), + editor_y = COALESCE($10, editor_y) + WHERE id = $1 + RETURNING * + "#, + ) + .bind(definition_id) + .bind(&req.name) + .bind(&req.description) + .bind(&req.contract_type) + .bind(&req.initial_phase) + .bind(&req.depends_on) + .bind(&tasks_json) + .bind(&deliverables_json) + .bind(req.editor_x) + .bind(req.editor_y) + .fetch_one(pool) + .await +} + +/// Delete a contract definition. +pub async fn delete_chain_contract_definition( + pool: &PgPool, + definition_id: Uuid, +) -> Result<bool, sqlx::Error> { + let result = sqlx::query("DELETE FROM chain_contract_definitions WHERE id = $1") + .bind(definition_id) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +/// Get definitions that are ready to be instantiated (all dependencies are satisfied). +/// A definition is ready if all definitions it depends on have been instantiated as contracts +/// and those contracts have completed. +pub async fn get_ready_definitions( + pool: &PgPool, + chain_id: Uuid, +) -> Result<Vec<ChainContractDefinition>, sqlx::Error> { + sqlx::query_as::<_, ChainContractDefinition>( + r#" + SELECT d.* + FROM chain_contract_definitions d + WHERE d.chain_id = $1 + -- Not already instantiated + AND NOT EXISTS ( + SELECT 1 FROM chain_contracts cc + WHERE cc.definition_id = d.id + ) + -- All dependencies satisfied (either no deps, or all deps have completed contracts) + AND ( + cardinality(d.depends_on_names) = 0 + OR NOT EXISTS ( + SELECT 1 FROM unnest(d.depends_on_names) AS dep_name + WHERE NOT EXISTS ( + SELECT 1 FROM chain_contract_definitions dep_def + JOIN chain_contracts cc ON cc.definition_id = dep_def.id + JOIN contracts c ON c.id = cc.contract_id + WHERE dep_def.chain_id = d.chain_id + AND dep_def.name = dep_name + AND c.status = 'completed' + ) + ) + ) + ORDER BY d.order_index ASC + "#, + ) + .bind(chain_id) + .fetch_all(pool) + .await +} + +/// Get the definition graph for visualization. +pub async fn get_chain_definition_graph( + pool: &PgPool, + chain_id: Uuid, +) -> Result<Option<ChainDefinitionGraphResponse>, sqlx::Error> { + let chain = sqlx::query_as::<_, Chain>("SELECT * FROM chains WHERE id = $1") + .bind(chain_id) + .fetch_optional(pool) + .await?; + + let Some(chain) = chain else { + return Ok(None); + }; + + let definitions = list_chain_contract_definitions(pool, chain_id).await?; + + // Get instantiated contracts for each definition + let chain_contracts = list_chain_contracts(pool, chain_id).await?; + let instantiated: std::collections::HashMap<Uuid, &ChainContractDetail> = chain_contracts + .iter() + .filter_map(|cc| { + // Find definition_id from cc - we need to query this + // For now, match by name + definitions + .iter() + .find(|d| d.name == cc.contract_name) + .map(|d| (d.id, cc)) + }) + .collect(); + + let nodes: Vec<ChainDefinitionGraphNode> = definitions + .iter() + .map(|d| { + let cc = instantiated.get(&d.id); + ChainDefinitionGraphNode { + id: d.id, + name: d.name.clone(), + contract_type: d.contract_type.clone(), + x: d.editor_x.unwrap_or(0.0), + y: d.editor_y.unwrap_or(0.0), + is_instantiated: cc.is_some(), + contract_id: cc.map(|c| c.contract_id), + contract_status: cc.map(|c| c.contract_status.clone()), + } + }) + .collect(); + + // Build edges from depends_on_names + let name_to_id: std::collections::HashMap<&str, Uuid> = + definitions.iter().map(|d| (d.name.as_str(), d.id)).collect(); + + let edges: Vec<ChainGraphEdge> = definitions + .iter() + .flat_map(|d| { + let target_id = d.id; + let name_to_id = &name_to_id; + d.depends_on_names.iter().filter_map(move |dep_name| { + name_to_id + .get(dep_name.as_str()) + .map(|&from_id| ChainGraphEdge { from: from_id, to: target_id }) + }) + }) + .collect(); + + Ok(Some(ChainDefinitionGraphResponse { + chain_id: chain.id, + chain_name: chain.name, + chain_status: chain.status, + nodes, + edges, + })) +} + +/// Set the supervisor task ID for a chain. +pub async fn set_chain_supervisor_task( + pool: &PgPool, + chain_id: Uuid, + supervisor_task_id: Option<Uuid>, +) -> Result<(), sqlx::Error> { + sqlx::query( + "UPDATE chains SET supervisor_task_id = $2, updated_at = NOW() WHERE id = $1", + ) + .bind(chain_id) + .bind(supervisor_task_id) + .execute(pool) + .await?; + Ok(()) +} + +/// Update chain status. +pub async fn update_chain_status( + pool: &PgPool, + chain_id: Uuid, + status: &str, +) -> Result<(), sqlx::Error> { + sqlx::query("UPDATE chains SET status = $2, updated_at = NOW() WHERE id = $1") + .bind(chain_id) + .bind(status) + .execute(pool) + .await?; + Ok(()) +} diff --git a/makima/src/server/handlers/chains.rs b/makima/src/server/handlers/chains.rs index 136a868..5d26e6a 100644 --- a/makima/src/server/handlers/chains.rs +++ b/makima/src/server/handlers/chains.rs @@ -13,8 +13,10 @@ use utoipa::ToSchema; use uuid::Uuid; use crate::db::models::{ - ChainContractDetail, ChainEditorData, ChainEvent, ChainGraphResponse, ChainSummary, - ChainWithContracts, CreateChainRequest, UpdateChainRequest, + AddContractDefinitionRequest, ChainContractDefinition, ChainContractDetail, + ChainDefinitionGraphResponse, ChainEditorData, ChainEvent, ChainGraphResponse, ChainSummary, + ChainWithContracts, CreateChainRequest, StartChainResponse, UpdateChainRequest, + UpdateContractDefinitionRequest, }; use crate::db::repository::{self, RepositoryError}; use crate::server::auth::Authenticated; @@ -607,3 +609,645 @@ pub async fn get_chain_editor( } } } + +// ============================================================================= +// Contract Definition Handlers +// ============================================================================= + +/// List contract definitions for a chain. +/// +/// GET /api/v1/chains/{id}/definitions +#[utoipa::path( + get, + path = "/api/v1/chains/{id}/definitions", + params( + ("id" = Uuid, Path, description = "Chain ID") + ), + responses( + (status = 200, description = "List of contract definitions", body = Vec<ChainContractDefinition>), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Chain not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError) + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Chains" +)] +pub async fn list_chain_definitions( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(chain_id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership + match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Chain not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to verify chain ownership: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::list_chain_contract_definitions(pool, chain_id).await { + Ok(definitions) => Json(definitions).into_response(), + Err(e) => { + tracing::error!("Failed to list chain definitions: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Create a contract definition for a chain. +/// +/// POST /api/v1/chains/{id}/definitions +#[utoipa::path( + post, + path = "/api/v1/chains/{id}/definitions", + params( + ("id" = Uuid, Path, description = "Chain ID") + ), + request_body = AddContractDefinitionRequest, + responses( + (status = 201, description = "Contract definition created", body = ChainContractDefinition), + (status = 400, description = "Invalid request", body = ApiError), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Chain not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError) + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Chains" +)] +pub async fn create_chain_definition( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(chain_id): Path<Uuid>, + Json(req): Json<AddContractDefinitionRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Validate the request + if req.name.trim().is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(ApiError::new("VALIDATION_ERROR", "Definition name cannot be empty")), + ) + .into_response(); + } + + // Verify ownership + match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Chain not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to verify chain ownership: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::create_chain_contract_definition(pool, chain_id, req).await { + Ok(definition) => (StatusCode::CREATED, Json(definition)).into_response(), + Err(e) => { + tracing::error!("Failed to create chain definition: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Update a contract definition. +/// +/// PUT /api/v1/chains/{chain_id}/definitions/{definition_id} +#[utoipa::path( + put, + path = "/api/v1/chains/{chain_id}/definitions/{definition_id}", + params( + ("chain_id" = Uuid, Path, description = "Chain ID"), + ("definition_id" = Uuid, Path, description = "Definition ID") + ), + request_body = UpdateContractDefinitionRequest, + responses( + (status = 200, description = "Contract definition updated", body = ChainContractDefinition), + (status = 400, description = "Invalid request", body = ApiError), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Chain or definition not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError) + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Chains" +)] +pub async fn update_chain_definition( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((chain_id, definition_id)): Path<(Uuid, Uuid)>, + Json(req): Json<UpdateContractDefinitionRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership + match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Chain not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to verify chain ownership: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + // Verify definition belongs to this chain + match repository::get_chain_contract_definition(pool, definition_id).await { + Ok(Some(def)) if def.chain_id == chain_id => {} + Ok(Some(_)) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Definition not found in this chain")), + ) + .into_response(); + } + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Definition not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get chain definition: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::update_chain_contract_definition(pool, definition_id, req).await { + Ok(definition) => Json(definition).into_response(), + Err(e) => { + tracing::error!("Failed to update chain definition: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Delete a contract definition. +/// +/// DELETE /api/v1/chains/{chain_id}/definitions/{definition_id} +#[utoipa::path( + delete, + path = "/api/v1/chains/{chain_id}/definitions/{definition_id}", + params( + ("chain_id" = Uuid, Path, description = "Chain ID"), + ("definition_id" = Uuid, Path, description = "Definition ID") + ), + responses( + (status = 200, description = "Contract definition deleted"), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Chain or definition not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError) + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Chains" +)] +pub async fn delete_chain_definition( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((chain_id, definition_id)): Path<(Uuid, Uuid)>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership + match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Chain not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to verify chain ownership: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + // Verify definition belongs to this chain before deleting + match repository::get_chain_contract_definition(pool, definition_id).await { + Ok(Some(def)) if def.chain_id == chain_id => {} + Ok(Some(_)) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Definition not found in this chain")), + ) + .into_response(); + } + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Definition not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get chain definition: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::delete_chain_contract_definition(pool, definition_id).await { + Ok(true) => Json(serde_json::json!({"deleted": true})).into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Definition not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to delete chain definition: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Get definition graph for a chain (shows definitions + instantiation status). +/// +/// GET /api/v1/chains/{id}/definitions/graph +#[utoipa::path( + get, + path = "/api/v1/chains/{id}/definitions/graph", + params( + ("id" = Uuid, Path, description = "Chain ID") + ), + responses( + (status = 200, description = "Definition graph structure", body = ChainDefinitionGraphResponse), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Chain not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError) + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Chains" +)] +pub async fn get_chain_definition_graph( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(chain_id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership first + match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Chain not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to verify chain ownership: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::get_chain_definition_graph(pool, chain_id).await { + Ok(Some(graph)) => Json(graph).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Chain not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to get chain definition graph: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= +// Chain Control Handlers +// ============================================================================= + +/// Start a chain (spawns supervisor and creates root contracts). +/// +/// POST /api/v1/chains/{id}/start +#[utoipa::path( + post, + path = "/api/v1/chains/{id}/start", + params( + ("id" = Uuid, Path, description = "Chain ID") + ), + responses( + (status = 200, description = "Chain started", body = StartChainResponse), + (status = 400, description = "Chain cannot be started", body = ApiError), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Chain not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError) + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Chains" +)] +pub async fn start_chain( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(chain_id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership and get chain + let chain = match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { + Ok(Some(c)) => c, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Chain not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get chain: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + }; + + // Check if chain can be started + if chain.status == "active" { + return ( + StatusCode::BAD_REQUEST, + Json(ApiError::new("ALREADY_ACTIVE", "Chain is already active")), + ) + .into_response(); + } + if chain.status == "completed" { + return ( + StatusCode::BAD_REQUEST, + Json(ApiError::new("ALREADY_COMPLETED", "Chain is already completed")), + ) + .into_response(); + } + + // Get definitions to check if there are any + let definitions = match repository::list_chain_contract_definitions(pool, chain_id).await { + Ok(d) => d, + Err(e) => { + tracing::error!("Failed to list chain definitions: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + }; + + if definitions.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(ApiError::new("NO_DEFINITIONS", "Chain has no contract definitions")), + ) + .into_response(); + } + + // TODO: Implement chain supervisor spawning + // For now, just update the chain status to active + match repository::update_chain_status(pool, chain_id, "active").await { + Ok(_) => {} + Err(e) => { + tracing::error!("Failed to update chain status: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + // Return response indicating chain has started + // supervisor_task_id is None until we implement the supervisor daemon + Json(StartChainResponse { + chain_id, + supervisor_task_id: None, + contracts_created: vec![], + status: "started".to_string(), + }) + .into_response() +} + +/// Stop a chain (kills supervisor, marks as archived). +/// +/// POST /api/v1/chains/{id}/stop +#[utoipa::path( + post, + path = "/api/v1/chains/{id}/stop", + params( + ("id" = Uuid, Path, description = "Chain ID") + ), + responses( + (status = 200, description = "Chain stopped"), + (status = 400, description = "Chain cannot be stopped", body = ApiError), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Chain not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError) + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Chains" +)] +pub async fn stop_chain( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(chain_id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership and get chain + let chain = match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { + Ok(Some(c)) => c, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Chain not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get chain: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + }; + + // Check if chain can be stopped + if chain.status != "active" { + return ( + StatusCode::BAD_REQUEST, + Json(ApiError::new( + "NOT_ACTIVE", + format!("Chain is not active (status: {})", chain.status), + )), + ) + .into_response(); + } + + // TODO: Kill the supervisor task if running + // Clear supervisor task ID and set status to archived + match repository::set_chain_supervisor_task(pool, chain_id, None).await { + Ok(_) => {} + Err(e) => { + tracing::error!("Failed to clear chain supervisor: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::update_chain_status(pool, chain_id, "archived").await { + Ok(_) => Json(serde_json::json!({"stopped": true, "status": "archived"})).into_response(), + Err(e) => { + tracing::error!("Failed to update chain status: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index 553797f..5dde099 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -9,7 +9,7 @@ pub mod state; use axum::{ http::StatusCode, response::IntoResponse, - routing::{get, post}, + routing::{get, post, put}, Json, Router, }; use serde::Serialize; @@ -229,6 +229,22 @@ pub fn make_router(state: SharedState) -> Router { .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)) + // Chain contract definitions + .route( + "/chains/{id}/definitions", + get(chains::list_chain_definitions).post(chains::create_chain_definition), + ) + .route( + "/chains/{chain_id}/definitions/{definition_id}", + put(chains::update_chain_definition).delete(chains::delete_chain_definition), + ) + .route( + "/chains/{id}/definitions/graph", + get(chains::get_chain_definition_graph), + ) + // Chain control + .route("/chains/{id}/start", post(chains::start_chain)) + .route("/chains/{id}/stop", post(chains::stop_chain)) // Contract type templates (built-in only) .route("/contract-types", get(templates::list_contract_types)) // Settings endpoints |
