summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--makima/frontend/src/components/chains/ChainEditor.tsx696
-rw-r--r--makima/frontend/src/components/chains/ChainList.tsx1
-rw-r--r--makima/frontend/src/lib/api.ts190
-rw-r--r--makima/migrations/20260203100000_chain_definitions.sql32
-rw-r--r--makima/src/db/models.rs107
-rw-r--r--makima/src/db/repository.rs299
-rw-r--r--makima/src/server/handlers/chains.rs648
-rw-r--r--makima/src/server/mod.rs18
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