diff options
Diffstat (limited to 'makima/frontend')
| -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 |
3 files changed, 796 insertions, 91 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(); +} |
