summaryrefslogtreecommitdiff
path: root/makima
diff options
context:
space:
mode:
Diffstat (limited to 'makima')
-rw-r--r--makima/Cargo.toml1
-rw-r--r--makima/frontend/src/components/NavStrip.tsx1
-rw-r--r--makima/frontend/src/components/chains/ChainEditor.tsx468
-rw-r--r--makima/frontend/src/components/chains/ChainList.tsx205
-rw-r--r--makima/frontend/src/hooks/useChains.ts145
-rw-r--r--makima/frontend/src/lib/api.ts236
-rw-r--r--makima/frontend/src/main.tsx17
-rw-r--r--makima/frontend/src/routes/chains.tsx283
-rw-r--r--makima/frontend/tsconfig.tsbuildinfo2
-rw-r--r--makima/migrations/20260203000000_create_chains.sql60
-rw-r--r--makima/src/bin/makima.rs216
-rw-r--r--makima/src/daemon/api/chain.rs52
-rw-r--r--makima/src/daemon/api/client.rs42
-rw-r--r--makima/src/daemon/api/mod.rs1
-rw-r--r--makima/src/daemon/chain/dag.rs450
-rw-r--r--makima/src/daemon/chain/mod.rs13
-rw-r--r--makima/src/daemon/chain/parser.rs392
-rw-r--r--makima/src/daemon/chain/runner.rs364
-rw-r--r--makima/src/daemon/cli/chain.rs107
-rw-r--r--makima/src/daemon/cli/mod.rs52
-rw-r--r--makima/src/daemon/mod.rs1
-rw-r--r--makima/src/db/models.rs356
-rw-r--r--makima/src/db/repository.rs548
-rw-r--r--makima/src/server/handlers/chains.rs609
-rw-r--r--makima/src/server/handlers/mod.rs1
-rw-r--r--makima/src/server/mod.rs17
26 files changed, 4627 insertions, 12 deletions
diff --git a/makima/Cargo.toml b/makima/Cargo.toml
index 950c123..9f47b97 100644
--- a/makima/Cargo.toml
+++ b/makima/Cargo.toml
@@ -23,6 +23,7 @@ tokio = { version = "1.0", features = ["full", "signal", "process"] }
tower-http = { version = "0.6", features = ["cors", "trace"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
+serde_yaml = "0.9"
toml = "0.8"
futures = "0.3"
tracing = "0.1"
diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx
index fb95c7f..5937982 100644
--- a/makima/frontend/src/components/NavStrip.tsx
+++ b/makima/frontend/src/components/NavStrip.tsx
@@ -11,6 +11,7 @@ interface NavLink {
const NAV_LINKS: NavLink[] = [
{ label: "Listen", href: "/listen" },
{ label: "Contracts", href: "/contracts", requiresAuth: true },
+ { label: "Chains", href: "/chains", requiresAuth: true },
{ label: "Board", href: "/workflow", requiresAuth: true },
{ label: "Mesh", href: "/mesh", requiresAuth: true },
{ label: "History", href: "/history", requiresAuth: true },
diff --git a/makima/frontend/src/components/chains/ChainEditor.tsx b/makima/frontend/src/components/chains/ChainEditor.tsx
new file mode 100644
index 0000000..9077d19
--- /dev/null
+++ b/makima/frontend/src/components/chains/ChainEditor.tsx
@@ -0,0 +1,468 @@
+import { useState, useCallback, useMemo, useRef } from "react";
+import type {
+ ChainWithContracts,
+ ChainGraphResponse,
+ ChainContractDetail,
+} from "../../lib/api";
+
+interface ChainEditorProps {
+ chain: ChainWithContracts;
+ graph: ChainGraphResponse | null;
+ loading: boolean;
+ onBack: () => void;
+ onRefresh: () => void;
+ onContractClick: (contractId: string) => void;
+}
+
+// Node dimensions
+const NODE_WIDTH = 180;
+const NODE_HEIGHT = 80;
+const CANVAS_PADDING = 40;
+
+export function ChainEditor({
+ chain,
+ graph,
+ loading,
+ onBack,
+ onRefresh,
+ onContractClick,
+}: ChainEditorProps) {
+ const canvasRef = useRef<HTMLDivElement>(null);
+ const [selectedNode, setSelectedNode] = useState<string | null>(null);
+ const [hoveredNode, setHoveredNode] = useState<string | null>(null);
+
+ // Use positions from graph nodes directly (x, y from server)
+ const nodePositions = useMemo(() => {
+ if (!graph?.nodes) return new Map<string, { x: number; y: number }>();
+
+ const positions = new Map<string, { x: number; y: number }>();
+ for (const node of graph.nodes) {
+ positions.set(node.contractId, {
+ x: CANVAS_PADDING + (node.x || 0) * (NODE_WIDTH + 60),
+ y: CANVAS_PADDING + (node.y || 0) * (NODE_HEIGHT + 40),
+ });
+ }
+ return positions;
+ }, [graph?.nodes]);
+
+ // Canvas dimensions
+ const canvasDimensions = useMemo(() => {
+ if (nodePositions.size === 0) {
+ return { width: 600, height: 400 };
+ }
+
+ let maxX = 0;
+ let maxY = 0;
+ for (const pos of nodePositions.values()) {
+ maxX = Math.max(maxX, pos.x + NODE_WIDTH);
+ maxY = Math.max(maxY, pos.y + NODE_HEIGHT);
+ }
+
+ return {
+ width: Math.max(600, maxX + CANVAS_PADDING),
+ height: Math.max(400, maxY + CANVAS_PADDING),
+ };
+ }, [nodePositions]);
+
+ const handleNodeClick = useCallback((contractId: string) => {
+ setSelectedNode(contractId);
+ }, []);
+
+ const handleNodeDoubleClick = useCallback(
+ (contractId: string) => {
+ onContractClick(contractId);
+ },
+ [onContractClick]
+ );
+
+ const getStatusColor = (status: string) => {
+ switch (status) {
+ case "active":
+ return { bg: "#4ade80", border: "#22c55e", text: "#166534" };
+ case "completed":
+ return { bg: "#60a5fa", border: "#3b82f6", text: "#1e40af" };
+ case "pending":
+ return { bg: "#f59e0b", border: "#d97706", text: "#92400e" };
+ case "blocked":
+ return { bg: "#ef4444", border: "#dc2626", text: "#991b1b" };
+ default:
+ return { bg: "#6b7280", border: "#4b5563", text: "#374151" };
+ }
+ };
+
+ // Find selected contract from chain.contracts
+ const selectedContract = selectedNode
+ ? chain.contracts.find((c) => c.contractId === selectedNode)
+ : null;
+
+ return (
+ <div className="panel h-full flex flex-col">
+ {/* Header */}
+ <div className="p-3 border-b border-[rgba(117,170,252,0.2)]">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-3">
+ <button
+ onClick={onBack}
+ className="font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors"
+ >
+ Back
+ </button>
+ <div>
+ <h2 className="font-mono text-sm text-[#dbe7ff]">{chain.chain.name}</h2>
+ {chain.chain.description && (
+ <p className="font-mono text-xs text-[#8b949e]">{chain.chain.description}</p>
+ )}
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ <span
+ className={`px-2 py-1 font-mono text-[10px] uppercase rounded ${
+ chain.chain.status === "active"
+ ? "text-[#4ade80] bg-[#4ade80]/10"
+ : chain.chain.status === "completed"
+ ? "text-[#60a5fa] bg-[#60a5fa]/10"
+ : "text-[#6b7280] bg-[#6b7280]/10"
+ }`}
+ >
+ {chain.chain.status}
+ </span>
+ <button
+ onClick={onRefresh}
+ className="px-3 py-1 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] border border-[#3f6fb3] hover:border-[#75aafc] transition-colors"
+ >
+ Refresh
+ </button>
+ </div>
+ </div>
+ </div>
+
+ {/* Main content */}
+ <div className="flex-1 flex min-h-0">
+ {/* DAG Canvas */}
+ <div className="flex-1 overflow-auto bg-[#050d18]">
+ {loading ? (
+ <div className="flex items-center justify-center h-full">
+ <p className="font-mono text-xs text-[#8b949e]">Loading graph...</p>
+ </div>
+ ) : !graph || graph.nodes.length === 0 ? (
+ <div className="flex items-center justify-center h-full">
+ <div className="text-center">
+ <p className="font-mono text-sm text-[#8b949e] mb-2">
+ No contracts in this chain yet
+ </p>
+ <p className="font-mono text-xs text-[#556677]">
+ Contracts will appear here once added via CLI or API
+ </p>
+ </div>
+ </div>
+ ) : (
+ <div
+ ref={canvasRef}
+ className="relative"
+ style={{
+ width: canvasDimensions.width,
+ height: canvasDimensions.height,
+ minWidth: "100%",
+ minHeight: "100%",
+ }}
+ >
+ {/* SVG layer for edges */}
+ <svg
+ className="absolute inset-0 pointer-events-none"
+ style={{
+ width: canvasDimensions.width,
+ height: canvasDimensions.height,
+ }}
+ >
+ <defs>
+ <marker
+ id="arrowhead"
+ markerWidth="10"
+ markerHeight="7"
+ refX="9"
+ refY="3.5"
+ orient="auto"
+ >
+ <polygon
+ points="0 0, 10 3.5, 0 7"
+ fill="#75aafc"
+ opacity="0.6"
+ />
+ </marker>
+ </defs>
+ {graph.edges.map((edge, index) => {
+ const fromPos = nodePositions.get(edge.from);
+ const toPos = nodePositions.get(edge.to);
+ if (!fromPos || !toPos) return null;
+
+ // Calculate edge path (from bottom of source to top of target)
+ const startX = fromPos.x + NODE_WIDTH / 2;
+ const startY = fromPos.y + NODE_HEIGHT;
+ const endX = toPos.x + NODE_WIDTH / 2;
+ const endY = toPos.y;
+
+ // Bezier control points for smooth curves
+ const midY = (startY + endY) / 2;
+
+ const isHighlighted =
+ hoveredNode === edge.from || hoveredNode === edge.to;
+
+ return (
+ <path
+ key={`${edge.from}-${edge.to}-${index}`}
+ d={`M ${startX} ${startY} C ${startX} ${midY}, ${endX} ${midY}, ${endX} ${endY}`}
+ fill="none"
+ stroke={isHighlighted ? "#75aafc" : "#3f6fb3"}
+ strokeWidth={isHighlighted ? 2 : 1.5}
+ strokeDasharray={isHighlighted ? "none" : "4 2"}
+ markerEnd="url(#arrowhead)"
+ opacity={isHighlighted ? 1 : 0.6}
+ />
+ );
+ })}
+ </svg>
+
+ {/* Node layer */}
+ {graph.nodes.map((node) => {
+ const pos = nodePositions.get(node.contractId);
+ if (!pos) return null;
+
+ const colors = getStatusColor(node.status);
+ const isSelected = selectedNode === node.contractId;
+ const isHovered = hoveredNode === node.contractId;
+
+ return (
+ <div
+ key={node.contractId}
+ onClick={() => handleNodeClick(node.contractId)}
+ onDoubleClick={() => handleNodeDoubleClick(node.contractId)}
+ onMouseEnter={() => setHoveredNode(node.contractId)}
+ onMouseLeave={() => setHoveredNode(null)}
+ className={`absolute cursor-pointer transition-all duration-150 ${
+ isSelected ? "ring-2 ring-[#75aafc] ring-offset-2 ring-offset-[#050d18]" : ""
+ }`}
+ style={{
+ left: pos.x,
+ top: pos.y,
+ width: NODE_WIDTH,
+ height: NODE_HEIGHT,
+ transform: isHovered ? "scale(1.02)" : "scale(1)",
+ }}
+ >
+ <div
+ className="w-full h-full rounded-lg border-2 bg-[#0a1628] overflow-hidden"
+ style={{
+ borderColor: isSelected ? "#75aafc" : colors.border,
+ }}
+ >
+ {/* Status indicator bar */}
+ <div
+ className="h-1.5"
+ style={{ backgroundColor: colors.bg }}
+ />
+ {/* Content */}
+ <div className="p-2">
+ <div className="flex items-center justify-between mb-1">
+ <span className="font-mono text-xs text-[#dbe7ff] truncate flex-1">
+ {node.name}
+ </span>
+ <ChainIcon className="w-4 h-4 text-[#75aafc] opacity-50 flex-shrink-0 ml-1" />
+ </div>
+ <div className="flex items-center justify-between">
+ <span
+ className="font-mono text-[10px] uppercase px-1.5 py-0.5 rounded"
+ style={{
+ color: colors.bg,
+ backgroundColor: `${colors.bg}20`,
+ }}
+ >
+ {node.status}
+ </span>
+ {node.phase && (
+ <span className="font-mono text-[10px] text-[#8b949e]">
+ {node.phase}
+ </span>
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ )}
+ </div>
+
+ {/* Detail panel */}
+ {selectedContract && (
+ <ContractDetailPanel
+ contract={selectedContract}
+ allContracts={chain.contracts}
+ onClose={() => setSelectedNode(null)}
+ onSelectContract={setSelectedNode}
+ onOpenContract={onContractClick}
+ />
+ )}
+ </div>
+
+ {/* Footer with stats */}
+ <div className="p-3 border-t border-[rgba(117,170,252,0.2)] bg-[#0a1628]">
+ <div className="flex items-center gap-4 font-mono text-[10px] text-[#8b949e]">
+ <span>{chain.contracts.length} contracts</span>
+ <span>
+ {chain.contracts.filter((c) => c.contractStatus === "completed").length} completed
+ </span>
+ <span>
+ {chain.contracts.filter((c) => c.contractStatus === "active").length} active
+ </span>
+ <span className="flex-1" />
+ <span>Double-click node to open contract</span>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+interface ContractDetailPanelProps {
+ contract: ChainContractDetail;
+ allContracts: ChainContractDetail[];
+ onClose: () => void;
+ onSelectContract: (contractId: string) => void;
+ onOpenContract: (contractId: string) => void;
+}
+
+function ContractDetailPanel({
+ contract,
+ allContracts,
+ onClose,
+ onSelectContract,
+ onOpenContract,
+}: ContractDetailPanelProps) {
+ return (
+ <div className="w-72 border-l border-[rgba(117,170,252,0.2)] bg-[#0a1628] overflow-y-auto">
+ <div className="p-3 border-b border-[rgba(117,170,252,0.2)]">
+ <div className="flex items-center justify-between mb-2">
+ <h3 className="font-mono text-xs text-[#75aafc] uppercase">
+ Contract Details
+ </h3>
+ <button
+ onClick={onClose}
+ className="font-mono text-xs text-[#8b949e] hover:text-[#dbe7ff]"
+ >
+ Close
+ </button>
+ </div>
+ </div>
+
+ <div className="p-3 space-y-4">
+ {/* Name */}
+ <div>
+ <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1">
+ Name
+ </label>
+ <p className="font-mono text-sm text-[#dbe7ff]">
+ {contract.contractName}
+ </p>
+ </div>
+
+ {/* Status */}
+ <div>
+ <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1">
+ Status
+ </label>
+ <span
+ className={`inline-block px-2 py-1 font-mono text-xs uppercase rounded ${
+ contract.contractStatus === "active"
+ ? "text-[#4ade80] bg-[#4ade80]/10"
+ : contract.contractStatus === "completed"
+ ? "text-[#60a5fa] bg-[#60a5fa]/10"
+ : "text-[#6b7280] bg-[#6b7280]/10"
+ }`}
+ >
+ {contract.contractStatus}
+ </span>
+ </div>
+
+ {/* Phase */}
+ <div>
+ <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1">
+ Phase
+ </label>
+ <span className="font-mono text-sm text-[#dbe7ff]">
+ {contract.contractPhase}
+ </span>
+ </div>
+
+ {/* Dependencies */}
+ {contract.dependsOn && contract.dependsOn.length > 0 && (
+ <div>
+ <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1">
+ Depends On
+ </label>
+ <div className="space-y-1">
+ {contract.dependsOn.map((depId) => {
+ const dep = allContracts.find((c) => c.contractId === depId);
+ return (
+ <button
+ key={depId}
+ onClick={() => onSelectContract(depId)}
+ className="block w-full text-left font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] truncate"
+ >
+ {dep?.contractName || depId}
+ </button>
+ );
+ })}
+ </div>
+ </div>
+ )}
+
+ {/* Order Index */}
+ <div>
+ <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1">
+ Order Index
+ </label>
+ <p className="font-mono text-xs text-[#dbe7ff]">
+ {contract.orderIndex}
+ </p>
+ </div>
+
+ {/* Created */}
+ <div>
+ <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1">
+ Created
+ </label>
+ <p className="font-mono text-xs text-[#dbe7ff]">
+ {new Date(contract.createdAt).toLocaleString()}
+ </p>
+ </div>
+
+ {/* Actions */}
+ <div className="pt-2 border-t border-[rgba(117,170,252,0.2)]">
+ <button
+ onClick={() => onOpenContract(contract.contractId)}
+ className="w-full px-3 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors"
+ >
+ Open Contract
+ </button>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+function ChainIcon({ className }: { className?: string }) {
+ return (
+ <svg
+ className={className}
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ >
+ <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
+ <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
+ </svg>
+ );
+}
diff --git a/makima/frontend/src/components/chains/ChainList.tsx b/makima/frontend/src/components/chains/ChainList.tsx
new file mode 100644
index 0000000..eda79d7
--- /dev/null
+++ b/makima/frontend/src/components/chains/ChainList.tsx
@@ -0,0 +1,205 @@
+import { useState, useCallback } from "react";
+import type { ChainSummary, ChainStatus } from "../../lib/api";
+
+interface ChainListProps {
+ chains: ChainSummary[];
+ loading: boolean;
+ onSelect: (chainId: string) => void;
+ onCreate: () => void;
+ selectedId?: string;
+ onArchive: (chain: ChainSummary) => void;
+}
+
+export function ChainList({
+ chains,
+ loading,
+ onSelect,
+ onCreate,
+ selectedId,
+ onArchive,
+}: ChainListProps) {
+ const [statusFilter, setStatusFilter] = useState<ChainStatus | "all">("all");
+ const [contextMenu, setContextMenu] = useState<{
+ chain: ChainSummary;
+ x: number;
+ y: number;
+ } | null>(null);
+
+ const filteredChains = chains.filter((chain) =>
+ statusFilter === "all" ? true : chain.status === statusFilter
+ );
+
+ const handleContextMenu = useCallback(
+ (e: React.MouseEvent, chain: ChainSummary) => {
+ e.preventDefault();
+ setContextMenu({ chain, x: e.clientX, y: e.clientY });
+ },
+ []
+ );
+
+ const closeContextMenu = useCallback(() => {
+ setContextMenu(null);
+ }, []);
+
+ const handleArchive = useCallback(() => {
+ if (contextMenu) {
+ onArchive(contextMenu.chain);
+ setContextMenu(null);
+ }
+ }, [contextMenu, onArchive]);
+
+ const getStatusColor = (status: ChainStatus) => {
+ switch (status) {
+ case "active":
+ return "text-[#4ade80] bg-[#4ade80]/10";
+ case "completed":
+ return "text-[#60a5fa] bg-[#60a5fa]/10";
+ case "archived":
+ return "text-[#6b7280] bg-[#6b7280]/10";
+ default:
+ return "text-[#8b949e] bg-[#8b949e]/10";
+ }
+ };
+
+ const getStatusIcon = (status: ChainStatus) => {
+ switch (status) {
+ case "active":
+ return (
+ <svg className="w-3 h-3" viewBox="0 0 24 24" fill="currentColor">
+ <circle cx="12" cy="12" r="4" />
+ </svg>
+ );
+ case "completed":
+ return (
+ <svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
+ <polyline points="20 6 9 17 4 12" />
+ </svg>
+ );
+ case "archived":
+ return (
+ <svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+ <path d="M21 8v13H3V8" />
+ <path d="M1 3h22v5H1z" />
+ <path d="M10 12h4" />
+ </svg>
+ );
+ default:
+ return null;
+ }
+ };
+
+ return (
+ <div className="panel h-full flex flex-col" onClick={closeContextMenu}>
+ {/* Header */}
+ <div className="p-3 border-b border-[rgba(117,170,252,0.2)]">
+ <div className="flex items-center justify-between mb-3">
+ <h2 className="font-mono text-sm text-[#75aafc] uppercase">Chains</h2>
+ <button
+ onClick={onCreate}
+ className="px-3 py-1 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors"
+ >
+ + New
+ </button>
+ </div>
+
+ {/* Status filter */}
+ <div className="flex gap-1">
+ {(["all", "active", "completed", "archived"] as const).map((status) => (
+ <button
+ key={status}
+ onClick={() => setStatusFilter(status)}
+ className={`px-2 py-1 font-mono text-[10px] uppercase transition-colors ${
+ statusFilter === status
+ ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]"
+ : "bg-[#0d1b2d] text-[#8b949e] border border-[#3f6fb3] hover:border-[#75aafc]"
+ }`}
+ >
+ {status}
+ </button>
+ ))}
+ </div>
+ </div>
+
+ {/* Chain list */}
+ <div className="flex-1 overflow-y-auto">
+ {loading ? (
+ <div className="flex items-center justify-center h-32">
+ <p className="font-mono text-xs text-[#8b949e]">Loading chains...</p>
+ </div>
+ ) : filteredChains.length === 0 ? (
+ <div className="flex items-center justify-center h-32">
+ <p className="font-mono text-xs text-[#8b949e]">
+ {statusFilter === "all" ? "No chains yet" : `No ${statusFilter} chains`}
+ </p>
+ </div>
+ ) : (
+ <div className="divide-y divide-[rgba(117,170,252,0.1)]">
+ {filteredChains.map((chain) => (
+ <div
+ key={chain.id}
+ onClick={() => onSelect(chain.id)}
+ onContextMenu={(e) => handleContextMenu(e, chain)}
+ className={`p-3 cursor-pointer transition-colors ${
+ selectedId === chain.id
+ ? "bg-[rgba(117,170,252,0.15)]"
+ : "hover:bg-[rgba(117,170,252,0.05)]"
+ }`}
+ >
+ <div className="flex items-center justify-between mb-1">
+ <span className="font-mono text-sm text-[#dbe7ff] truncate">
+ {chain.name}
+ </span>
+ <span
+ className={`flex items-center gap-1 px-1.5 py-0.5 font-mono text-[10px] uppercase rounded ${getStatusColor(
+ chain.status
+ )}`}
+ >
+ {getStatusIcon(chain.status)}
+ {chain.status}
+ </span>
+ </div>
+ {chain.description && (
+ <p className="font-mono text-xs text-[#8b949e] truncate mb-1">
+ {chain.description}
+ </p>
+ )}
+ <div className="flex items-center gap-3 font-mono text-[10px] text-[#556677]">
+ <span>{chain.contractCount} contracts</span>
+ <span>
+ {new Date(chain.updatedAt).toLocaleDateString()}
+ </span>
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+
+ {/* Context menu */}
+ {contextMenu && (
+ <div
+ className="fixed z-50 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] shadow-lg py-1"
+ style={{ top: contextMenu.y, left: contextMenu.x }}
+ >
+ <button
+ onClick={() => {
+ onSelect(contextMenu.chain.id);
+ setContextMenu(null);
+ }}
+ className="w-full px-4 py-2 text-left font-mono text-xs text-[#dbe7ff] hover:bg-[rgba(117,170,252,0.1)]"
+ >
+ View Details
+ </button>
+ {contextMenu.chain.status !== "archived" && (
+ <button
+ onClick={handleArchive}
+ className="w-full px-4 py-2 text-left font-mono text-xs text-red-400 hover:bg-red-400/10"
+ >
+ Archive
+ </button>
+ )}
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/makima/frontend/src/hooks/useChains.ts b/makima/frontend/src/hooks/useChains.ts
new file mode 100644
index 0000000..272847a
--- /dev/null
+++ b/makima/frontend/src/hooks/useChains.ts
@@ -0,0 +1,145 @@
+import { useState, useCallback, useEffect } from "react";
+import {
+ listChains,
+ getChain,
+ createChain,
+ updateChain,
+ archiveChain,
+ getChainGraph,
+ type ChainSummary,
+ type ChainWithContracts,
+ type ChainGraphResponse,
+ type ChainStatus,
+ type CreateChainRequest,
+ type UpdateChainRequest,
+} from "../lib/api";
+
+interface UseChainsResult {
+ chains: ChainSummary[];
+ loading: boolean;
+ error: string | null;
+ refresh: () => Promise<void>;
+ createNewChain: (req: CreateChainRequest) => Promise<ChainWithContracts | null>;
+ updateExistingChain: (
+ chainId: string,
+ req: UpdateChainRequest
+ ) => Promise<ChainWithContracts | null>;
+ archiveExistingChain: (chainId: string) => Promise<boolean>;
+ getChainById: (chainId: string) => Promise<ChainWithContracts | null>;
+ getGraph: (chainId: string) => Promise<ChainGraphResponse | null>;
+}
+
+export function useChains(statusFilter?: ChainStatus): UseChainsResult {
+ const [chains, setChains] = useState<ChainSummary[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState<string | null>(null);
+
+ const fetchChains = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const response = await listChains(statusFilter);
+ setChains(response.chains);
+ } catch (err) {
+ console.error("Failed to fetch chains:", err);
+ setError(err instanceof Error ? err.message : "Failed to fetch chains");
+ } finally {
+ setLoading(false);
+ }
+ }, [statusFilter]);
+
+ useEffect(() => {
+ fetchChains();
+ }, [fetchChains]);
+
+ const createNewChain = useCallback(
+ async (req: CreateChainRequest): Promise<ChainWithContracts | null> => {
+ try {
+ const chain = await createChain(req);
+ // Refresh the list
+ await fetchChains();
+ // Return the full chain with contracts
+ return await getChain(chain.id);
+ } catch (err) {
+ console.error("Failed to create chain:", err);
+ setError(err instanceof Error ? err.message : "Failed to create chain");
+ return null;
+ }
+ },
+ [fetchChains]
+ );
+
+ const updateExistingChain = useCallback(
+ async (
+ chainId: string,
+ req: UpdateChainRequest
+ ): Promise<ChainWithContracts | null> => {
+ try {
+ await updateChain(chainId, req);
+ // Refresh the list
+ await fetchChains();
+ // Return the updated chain
+ return await getChain(chainId);
+ } catch (err) {
+ console.error("Failed to update chain:", err);
+ setError(err instanceof Error ? err.message : "Failed to update chain");
+ return null;
+ }
+ },
+ [fetchChains]
+ );
+
+ const archiveExistingChain = useCallback(
+ async (chainId: string): Promise<boolean> => {
+ try {
+ await archiveChain(chainId);
+ // Refresh the list
+ await fetchChains();
+ return true;
+ } catch (err) {
+ console.error("Failed to archive chain:", err);
+ setError(err instanceof Error ? err.message : "Failed to archive chain");
+ return false;
+ }
+ },
+ [fetchChains]
+ );
+
+ const getChainById = useCallback(
+ async (chainId: string): Promise<ChainWithContracts | null> => {
+ try {
+ return await getChain(chainId);
+ } catch (err) {
+ console.error("Failed to get chain:", err);
+ setError(err instanceof Error ? err.message : "Failed to get chain");
+ return null;
+ }
+ },
+ []
+ );
+
+ const getGraph = useCallback(
+ async (chainId: string): Promise<ChainGraphResponse | null> => {
+ try {
+ return await getChainGraph(chainId);
+ } catch (err) {
+ console.error("Failed to get chain graph:", err);
+ setError(err instanceof Error ? err.message : "Failed to get chain graph");
+ return null;
+ }
+ },
+ []
+ );
+
+ return {
+ chains,
+ loading,
+ error,
+ refresh: fetchChains,
+ createNewChain,
+ updateExistingChain,
+ archiveExistingChain,
+ getChainById,
+ getGraph,
+ };
+}
diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts
index f148d76..445537c 100644
--- a/makima/frontend/src/lib/api.ts
+++ b/makima/frontend/src/lib/api.ts
@@ -2948,3 +2948,239 @@ export async function listTaskPatches(taskId: string, contractId: string): Promi
}
return res.json();
}
+
+// =============================================================================
+// Chain Types and API
+// =============================================================================
+
+/** Chain status */
+export type ChainStatus = "active" | "completed" | "archived";
+
+/** Chain summary for list view */
+export interface ChainSummary {
+ id: string;
+ name: string;
+ description: string | null;
+ status: ChainStatus;
+ contractCount: number;
+ completedContractCount: number;
+ loopEnabled: boolean;
+ loopCurrentIteration: number | null;
+ loopMaxIterations: number | null;
+ createdAt: string;
+ updatedAt: string;
+}
+
+/** Full chain with contracts */
+export interface Chain {
+ id: string;
+ ownerId: string;
+ name: string;
+ description: string | null;
+ status: ChainStatus;
+ loopEnabled: boolean;
+ loopMaxIterations: number | null;
+ loopCurrentIteration: number | null;
+ loopProgressCheck: string | null;
+ repositoryUrl: string | null;
+ localPath: string | null;
+ version: number;
+ createdAt: string;
+ updatedAt: string;
+}
+
+/** Contract detail within a chain */
+export interface ChainContractDetail {
+ id: string;
+ chainId: string;
+ contractId: string;
+ contractName: string;
+ contractStatus: string;
+ contractPhase: string;
+ dependsOn: string[];
+ orderIndex: number;
+ editorX: number | null;
+ editorY: number | null;
+ createdAt: string;
+}
+
+/** Chain with contracts */
+export interface ChainWithContracts {
+ chain: Chain;
+ contracts: ChainContractDetail[];
+}
+
+/** Node in chain graph visualization */
+export interface ChainGraphNode {
+ id: string;
+ contractId: string;
+ name: string;
+ status: string;
+ phase: string;
+ x: number;
+ y: number;
+}
+
+/** Edge in chain graph */
+export interface ChainGraphEdge {
+ from: string;
+ to: string;
+}
+
+/** Chain graph response */
+export interface ChainGraphResponse {
+ chainId: string;
+ chainName: string;
+ chainStatus: string;
+ nodes: ChainGraphNode[];
+ edges: ChainGraphEdge[];
+}
+
+/** Chain event */
+export interface ChainEvent {
+ id: string;
+ chainId: string;
+ eventType: string;
+ contractId: string | null;
+ eventData: Record<string, unknown> | null;
+ createdAt: string;
+}
+
+/** Chain list response */
+export interface ChainListResponse {
+ chains: ChainSummary[];
+ total: number;
+}
+
+/** Create chain request */
+export interface CreateChainRequest {
+ name: string;
+ description?: string;
+ repositoryUrl?: string;
+ localPath?: string;
+ loopEnabled?: boolean;
+ loopMaxIterations?: number;
+ loopProgressCheck?: string;
+ contracts?: CreateChainContractRequest[];
+}
+
+/** Create chain contract request */
+export interface CreateChainContractRequest {
+ name: string;
+ description?: string;
+ contractType?: string;
+ initialPhase?: string;
+ phases?: string[];
+ dependsOn?: string[];
+ tasks?: { name: string; plan: string }[];
+ deliverables?: { id: string; name: string; priority?: string }[];
+ editorX?: number;
+ editorY?: number;
+}
+
+/** Update chain request */
+export interface UpdateChainRequest {
+ name?: string;
+ description?: string;
+ status?: ChainStatus;
+ loopEnabled?: boolean;
+ loopMaxIterations?: number;
+ loopProgressCheck?: string;
+ version?: number;
+}
+
+/** List chains */
+export async function listChains(
+ status?: ChainStatus,
+ limit = 50,
+ offset = 0
+): Promise<ChainListResponse> {
+ const params = new URLSearchParams();
+ if (status) params.set("status", status);
+ params.set("limit", String(limit));
+ params.set("offset", String(offset));
+
+ const res = await authFetch(`${API_BASE}/api/v1/chains?${params}`);
+ if (!res.ok) {
+ throw new Error(`Failed to list chains: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/** Get chain by ID */
+export async function getChain(chainId: string): Promise<ChainWithContracts> {
+ const res = await authFetch(`${API_BASE}/api/v1/chains/${chainId}`);
+ if (!res.ok) {
+ throw new Error(`Failed to get chain: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/** Create a new chain */
+export async function createChain(req: CreateChainRequest): Promise<Chain> {
+ const res = await authFetch(`${API_BASE}/api/v1/chains`, {
+ method: "POST",
+ body: JSON.stringify(req),
+ });
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Failed to create chain: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
+/** Update a chain */
+export async function updateChain(
+ chainId: string,
+ req: UpdateChainRequest
+): Promise<Chain> {
+ const res = await authFetch(`${API_BASE}/api/v1/chains/${chainId}`, {
+ method: "PUT",
+ body: JSON.stringify(req),
+ });
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Failed to update chain: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
+/** Archive a chain */
+export async function archiveChain(chainId: string): Promise<{ archived: boolean }> {
+ const res = await authFetch(`${API_BASE}/api/v1/chains/${chainId}`, {
+ method: "DELETE",
+ });
+ if (!res.ok) {
+ throw new Error(`Failed to archive chain: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/** Get chain contracts */
+export async function getChainContracts(
+ chainId: string
+): Promise<ChainContractDetail[]> {
+ const res = await authFetch(`${API_BASE}/api/v1/chains/${chainId}/contracts`);
+ if (!res.ok) {
+ throw new Error(`Failed to get chain contracts: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/** Get chain graph for visualization */
+export async function getChainGraph(chainId: string): Promise<ChainGraphResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/chains/${chainId}/graph`);
+ if (!res.ok) {
+ throw new Error(`Failed to get chain graph: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/** Get chain events */
+export async function getChainEvents(chainId: string): Promise<ChainEvent[]> {
+ const res = await authFetch(`${API_BASE}/api/v1/chains/${chainId}/events`);
+ if (!res.ok) {
+ throw new Error(`Failed to get chain events: ${res.statusText}`);
+ }
+ return res.json();
+}
diff --git a/makima/frontend/src/main.tsx b/makima/frontend/src/main.tsx
index 50fffe4..a7ba1a3 100644
--- a/makima/frontend/src/main.tsx
+++ b/makima/frontend/src/main.tsx
@@ -12,6 +12,7 @@ import HomePage from "./routes/_index";
import ListenPage from "./routes/listen";
import FilesPage from "./routes/files";
import ContractsPage from "./routes/contracts";
+import ChainsPage from "./routes/chains";
import WorkflowPage from "./routes/workflow";
import MeshPage from "./routes/mesh";
import HistoryPage from "./routes/history";
@@ -72,6 +73,22 @@ createRoot(document.getElementById("root")!).render(
}
/>
<Route
+ path="/chains"
+ element={
+ <ProtectedRoute>
+ <ChainsPage />
+ </ProtectedRoute>
+ }
+ />
+ <Route
+ path="/chains/:id"
+ element={
+ <ProtectedRoute>
+ <ChainsPage />
+ </ProtectedRoute>
+ }
+ />
+ <Route
path="/contracts/:id/files/:fileId"
element={
<ProtectedRoute>
diff --git a/makima/frontend/src/routes/chains.tsx b/makima/frontend/src/routes/chains.tsx
new file mode 100644
index 0000000..f01d5a6
--- /dev/null
+++ b/makima/frontend/src/routes/chains.tsx
@@ -0,0 +1,283 @@
+import { useState, useCallback, useEffect } from "react";
+import { useParams, useNavigate } from "react-router";
+import { Masthead } from "../components/Masthead";
+import { ChainList } from "../components/chains/ChainList";
+import { ChainEditor } from "../components/chains/ChainEditor";
+import { useChains } from "../hooks/useChains";
+import { useAuth } from "../contexts/AuthContext";
+import type {
+ ChainSummary,
+ ChainWithContracts,
+ ChainGraphResponse,
+ CreateChainRequest,
+} from "../lib/api";
+
+export default function ChainsPage() {
+ const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth();
+ const navigate = useNavigate();
+
+ // Redirect to login if not authenticated (when auth is configured)
+ useEffect(() => {
+ if (!authLoading && isAuthConfigured && !isAuthenticated) {
+ navigate("/login");
+ }
+ }, [authLoading, isAuthConfigured, isAuthenticated, navigate]);
+
+ // Show loading while checking auth
+ if (authLoading) {
+ return (
+ <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
+ <Masthead showNav />
+ <main className="flex-1 flex items-center justify-center">
+ <p className="text-[#7788aa] font-mono text-sm">Loading...</p>
+ </main>
+ </div>
+ );
+ }
+
+ // Don't render if not authenticated (will redirect)
+ if (isAuthConfigured && !isAuthenticated) {
+ return null;
+ }
+
+ return <ChainsPageContent />;
+}
+
+function ChainsPageContent() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const {
+ chains,
+ loading,
+ error,
+ createNewChain,
+ archiveExistingChain,
+ getChainById,
+ getGraph,
+ } = useChains();
+
+ const [chainDetail, setChainDetail] = useState<ChainWithContracts | null>(null);
+ const [chainGraph, setChainGraph] = useState<ChainGraphResponse | null>(null);
+ const [detailLoading, setDetailLoading] = useState(false);
+ const [isCreating, setIsCreating] = useState(false);
+
+ // Load chain detail when ID changes
+ useEffect(() => {
+ if (id) {
+ setDetailLoading(true);
+ Promise.all([getChainById(id), getGraph(id)]).then(([chain, graph]) => {
+ setChainDetail(chain);
+ setChainGraph(graph);
+ setDetailLoading(false);
+ });
+ } else {
+ setChainDetail(null);
+ setChainGraph(null);
+ }
+ }, [id, getChainById, getGraph]);
+
+ const handleSelect = useCallback(
+ (chainId: string) => {
+ navigate(`/chains/${chainId}`);
+ },
+ [navigate]
+ );
+
+ const handleBack = useCallback(() => {
+ navigate("/chains");
+ }, [navigate]);
+
+ const handleCreate = useCallback(() => {
+ setIsCreating(true);
+ }, []);
+
+ const handleCreateSubmit = useCallback(
+ async (name: string, description: string) => {
+ const data: CreateChainRequest = {
+ name: name.trim(),
+ description: description.trim() || undefined,
+ };
+
+ try {
+ const result = await createNewChain(data);
+ if (result) {
+ setIsCreating(false);
+ navigate(`/chains/${result.chain.id}`);
+ }
+ } catch (err) {
+ console.error("Failed to create chain:", err);
+ }
+ },
+ [createNewChain, navigate]
+ );
+
+ const handleCreateCancel = useCallback(() => {
+ setIsCreating(false);
+ }, []);
+
+ const handleArchive = useCallback(
+ async (chain: ChainSummary) => {
+ if (confirm(`Are you sure you want to archive "${chain.name}"?`)) {
+ const success = await archiveExistingChain(chain.id);
+ if (success && chain.id === id) {
+ navigate("/chains");
+ }
+ }
+ },
+ [archiveExistingChain, id, navigate]
+ );
+
+ const handleRefresh = useCallback(async () => {
+ if (id) {
+ const [chain, graph] = await Promise.all([getChainById(id), getGraph(id)]);
+ setChainDetail(chain);
+ setChainGraph(graph);
+ }
+ }, [id, getChainById, getGraph]);
+
+ const handleContractClick = useCallback(
+ (contractId: string) => {
+ navigate(`/contracts/${contractId}`);
+ },
+ [navigate]
+ );
+
+ return (
+ <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
+ <Masthead showNav />
+ <main className="flex-1 flex flex-col p-4 pt-2 gap-4 overflow-hidden">
+ {error && (
+ <div className="p-3 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-sm">
+ {error}
+ </div>
+ )}
+
+ {/* Create chain modal */}
+ {isCreating && (
+ <CreateChainModal
+ onSubmit={handleCreateSubmit}
+ onCancel={handleCreateCancel}
+ />
+ )}
+
+ <div className="flex-1 grid grid-cols-[350px_1fr] gap-4 min-h-0">
+ {/* Chain list */}
+ <ChainList
+ chains={chains}
+ loading={loading}
+ onSelect={handleSelect}
+ onCreate={handleCreate}
+ selectedId={id}
+ onArchive={handleArchive}
+ />
+
+ {/* Chain detail/editor or empty state */}
+ {chainDetail ? (
+ <ChainEditor
+ chain={chainDetail}
+ graph={chainGraph}
+ loading={detailLoading}
+ onBack={handleBack}
+ onRefresh={handleRefresh}
+ onContractClick={handleContractClick}
+ />
+ ) : (
+ <div className="panel h-full flex items-center justify-center">
+ <div className="text-center">
+ <p className="font-mono text-sm text-[#555] mb-4">
+ Select a chain or create a new one
+ </p>
+ <button
+ onClick={handleCreate}
+ className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase"
+ >
+ + New Chain
+ </button>
+ </div>
+ </div>
+ )}
+ </div>
+ </main>
+ </div>
+ );
+}
+
+interface CreateChainModalProps {
+ onSubmit: (name: string, description: string) => void;
+ onCancel: () => void;
+}
+
+function CreateChainModal({ onSubmit, onCancel }: CreateChainModalProps) {
+ const [name, setName] = useState("");
+ const [description, setDescription] = useState("");
+
+ const handleSubmit = () => {
+ if (name.trim()) {
+ onSubmit(name.trim(), description.trim());
+ }
+ };
+
+ return (
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
+ <div className="w-full max-w-lg p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)]">
+ <h3 className="font-mono text-sm text-[#75aafc] uppercase mb-4">
+ Create Chain
+ </h3>
+
+ <div className="space-y-4">
+ {/* Chain name */}
+ <div>
+ <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1">
+ Chain Name
+ </label>
+ <input
+ type="text"
+ value={name}
+ onChange={(e) => setName(e.target.value)}
+ placeholder="e.g., Feature Implementation"
+ className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]"
+ autoFocus
+ />
+ </div>
+
+ {/* Description */}
+ <div>
+ <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1">
+ Description (optional)
+ </label>
+ <textarea
+ value={description}
+ onChange={(e) => setDescription(e.target.value)}
+ placeholder="Describe what this chain accomplishes..."
+ rows={3}
+ className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc] resize-none"
+ />
+ </div>
+
+ <p className="font-mono text-xs text-[#8b949e]">
+ A chain links multiple contracts together in a directed acyclic graph (DAG).
+ Contracts can depend on each other, and dependent contracts start automatically
+ when their dependencies complete.
+ </p>
+
+ {/* Actions */}
+ <div className="flex gap-2 justify-end pt-2">
+ <button
+ onClick={onCancel}
+ className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors"
+ >
+ Cancel
+ </button>
+ <button
+ onClick={handleSubmit}
+ disabled={!name.trim()}
+ className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ Create
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/makima/frontend/tsconfig.tsbuildinfo b/makima/frontend/tsconfig.tsbuildinfo
index afddaf9..26dc69a 100644
--- a/makima/frontend/tsconfig.tsbuildinfo
+++ b/makima/frontend/tsconfig.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/japanesehovertext.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/phaseconfirmationnotification.tsx","./src/components/protectedroute.tsx","./src/components/rewritelink.tsx","./src/components/simplemarkdown.tsx","./src/components/supervisorquestionnotification.tsx","./src/components/charts/chartrenderer.tsx","./src/components/contracts/commandmodepanel.tsx","./src/components/contracts/contractcliinput.tsx","./src/components/contracts/contractcontextmenu.tsx","./src/components/contracts/contractdetail.tsx","./src/components/contracts/contractlist.tsx","./src/components/contracts/phasebadge.tsx","./src/components/contracts/phaseconfirmationmodal.tsx","./src/components/contracts/phasedeliverablespanel.tsx","./src/components/contracts/phasehint.tsx","./src/components/contracts/phaseprogressbar.tsx","./src/components/contracts/quickactionbuttons.tsx","./src/components/contracts/repositorypanel.tsx","./src/components/contracts/taskderivationpreview.tsx","./src/components/files/bodyrenderer.tsx","./src/components/files/cliinput.tsx","./src/components/files/conflictnotification.tsx","./src/components/files/elementcontextmenu.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/files/reposyncindicator.tsx","./src/components/files/updatenotification.tsx","./src/components/files/versionhistorydropdown.tsx","./src/components/history/checkpointcard.tsx","./src/components/history/checkpointlist.tsx","./src/components/history/conversationmessage.tsx","./src/components/history/conversationview.tsx","./src/components/history/historyfilters.tsx","./src/components/history/resumecontrols.tsx","./src/components/history/timelineeventcard.tsx","./src/components/history/timelinelist.tsx","./src/components/history/index.ts","./src/components/listen/contractpickermodal.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptanalysispanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/components/mesh/branchtaskmodal.tsx","./src/components/mesh/contractcompletequestion.tsx","./src/components/mesh/directoryinput.tsx","./src/components/mesh/gitactionspanel.tsx","./src/components/mesh/inlinesubtaskeditor.tsx","./src/components/mesh/mergeconflictresolver.tsx","./src/components/mesh/overlaydiffviewer.tsx","./src/components/mesh/prpreview.tsx","./src/components/mesh/patcheslistpanel.tsx","./src/components/mesh/subtasktree.tsx","./src/components/mesh/taskdetail.tsx","./src/components/mesh/tasklist.tsx","./src/components/mesh/taskoutput.tsx","./src/components/mesh/tasktree.tsx","./src/components/mesh/unifiedmeshchatinput.tsx","./src/components/mesh/worktreefilespanel.tsx","./src/components/workflow/phasecolumn.tsx","./src/components/workflow/workflowboard.tsx","./src/components/workflow/workflowcontractcard.tsx","./src/contexts/authcontext.tsx","./src/contexts/supervisorquestionscontext.tsx","./src/hooks/usecontracts.ts","./src/hooks/usefilesubscription.ts","./src/hooks/usefiles.ts","./src/hooks/usemeshchathistory.ts","./src/hooks/usemicrophone.ts","./src/hooks/usespeakwebsocket.ts","./src/hooks/usetasksubscription.ts","./src/hooks/usetasks.ts","./src/hooks/usetextscramble.ts","./src/hooks/useversionhistory.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/listenapi.ts","./src/lib/markdown.ts","./src/lib/supabase.ts","./src/routes/_index.tsx","./src/routes/contract-file.tsx","./src/routes/contracts.tsx","./src/routes/files.tsx","./src/routes/history.tsx","./src/routes/listen.tsx","./src/routes/login.tsx","./src/routes/mesh.tsx","./src/routes/settings.tsx","./src/routes/speak.tsx","./src/routes/workflow.tsx","./src/types/messages.ts"],"version":"5.9.3"} \ No newline at end of file
+{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/japanesehovertext.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/phaseconfirmationnotification.tsx","./src/components/protectedroute.tsx","./src/components/rewritelink.tsx","./src/components/simplemarkdown.tsx","./src/components/supervisorquestionnotification.tsx","./src/components/chains/chaineditor.tsx","./src/components/chains/chainlist.tsx","./src/components/charts/chartrenderer.tsx","./src/components/contracts/commandmodepanel.tsx","./src/components/contracts/contractcliinput.tsx","./src/components/contracts/contractcontextmenu.tsx","./src/components/contracts/contractdetail.tsx","./src/components/contracts/contractlist.tsx","./src/components/contracts/phasebadge.tsx","./src/components/contracts/phaseconfirmationmodal.tsx","./src/components/contracts/phasedeliverablespanel.tsx","./src/components/contracts/phasehint.tsx","./src/components/contracts/phaseprogressbar.tsx","./src/components/contracts/quickactionbuttons.tsx","./src/components/contracts/repositorypanel.tsx","./src/components/contracts/taskderivationpreview.tsx","./src/components/files/bodyrenderer.tsx","./src/components/files/cliinput.tsx","./src/components/files/conflictnotification.tsx","./src/components/files/elementcontextmenu.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/files/reposyncindicator.tsx","./src/components/files/updatenotification.tsx","./src/components/files/versionhistorydropdown.tsx","./src/components/history/checkpointcard.tsx","./src/components/history/checkpointlist.tsx","./src/components/history/conversationmessage.tsx","./src/components/history/conversationview.tsx","./src/components/history/historyfilters.tsx","./src/components/history/resumecontrols.tsx","./src/components/history/timelineeventcard.tsx","./src/components/history/timelinelist.tsx","./src/components/history/index.ts","./src/components/listen/contractpickermodal.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptanalysispanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/components/mesh/branchtaskmodal.tsx","./src/components/mesh/contractcompletequestion.tsx","./src/components/mesh/directoryinput.tsx","./src/components/mesh/gitactionspanel.tsx","./src/components/mesh/inlinesubtaskeditor.tsx","./src/components/mesh/mergeconflictresolver.tsx","./src/components/mesh/overlaydiffviewer.tsx","./src/components/mesh/prpreview.tsx","./src/components/mesh/patcheslistpanel.tsx","./src/components/mesh/subtasktree.tsx","./src/components/mesh/taskdetail.tsx","./src/components/mesh/tasklist.tsx","./src/components/mesh/taskoutput.tsx","./src/components/mesh/tasktree.tsx","./src/components/mesh/unifiedmeshchatinput.tsx","./src/components/mesh/worktreefilespanel.tsx","./src/components/workflow/phasecolumn.tsx","./src/components/workflow/workflowboard.tsx","./src/components/workflow/workflowcontractcard.tsx","./src/contexts/authcontext.tsx","./src/contexts/supervisorquestionscontext.tsx","./src/hooks/usechains.ts","./src/hooks/usecontracts.ts","./src/hooks/usefilesubscription.ts","./src/hooks/usefiles.ts","./src/hooks/usemeshchathistory.ts","./src/hooks/usemicrophone.ts","./src/hooks/usespeakwebsocket.ts","./src/hooks/usetasksubscription.ts","./src/hooks/usetasks.ts","./src/hooks/usetextscramble.ts","./src/hooks/useversionhistory.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/listenapi.ts","./src/lib/markdown.ts","./src/lib/supabase.ts","./src/routes/_index.tsx","./src/routes/chains.tsx","./src/routes/contract-file.tsx","./src/routes/contracts.tsx","./src/routes/files.tsx","./src/routes/history.tsx","./src/routes/listen.tsx","./src/routes/login.tsx","./src/routes/mesh.tsx","./src/routes/settings.tsx","./src/routes/speak.tsx","./src/routes/workflow.tsx","./src/types/messages.ts"],"version":"5.9.3"} \ No newline at end of file
diff --git a/makima/migrations/20260203000000_create_chains.sql b/makima/migrations/20260203000000_create_chains.sql
new file mode 100644
index 0000000..7811a45
--- /dev/null
+++ b/makima/migrations/20260203000000_create_chains.sql
@@ -0,0 +1,60 @@
+-- Chains table - DAG of contracts for multi-contract orchestration
+-- Fits Makima's control theme - she controls through invisible chains
+CREATE TABLE IF NOT EXISTS chains (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ owner_id UUID NOT NULL REFERENCES owners(id) ON DELETE CASCADE,
+ name VARCHAR(255) NOT NULL,
+ description TEXT,
+ status VARCHAR(32) NOT NULL DEFAULT 'active', -- active/completed/archived
+ -- Loop control for iterative execution
+ loop_enabled BOOLEAN NOT NULL DEFAULT false,
+ loop_max_iterations INTEGER DEFAULT 10,
+ loop_current_iteration INTEGER DEFAULT 0,
+ loop_progress_check TEXT, -- Prompt/criteria for evaluating progress
+ -- Repository reference (optional - contracts may have their own repos)
+ repository_url VARCHAR(512),
+ local_path VARCHAR(512),
+ -- Versioning for optimistic locking
+ version INTEGER NOT NULL DEFAULT 1,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_chains_owner_id ON chains(owner_id);
+CREATE INDEX idx_chains_status ON chains(status);
+
+-- Chain contracts - links contracts to chains with DAG dependency info
+-- The depends_on array forms the DAG edges (directed acyclic graph)
+CREATE TABLE IF NOT EXISTS chain_contracts (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ chain_id UUID NOT NULL REFERENCES chains(id) ON DELETE CASCADE,
+ contract_id UUID NOT NULL REFERENCES contracts(id) ON DELETE CASCADE,
+ -- DAG edges: contract IDs this contract depends on (must complete before this starts)
+ depends_on UUID[] DEFAULT '{}',
+ -- Order for display/processing (topological sort order)
+ order_index INTEGER NOT NULL DEFAULT 0,
+ -- Position for GUI editor
+ editor_x FLOAT DEFAULT 0,
+ editor_y FLOAT DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE(chain_id, contract_id)
+);
+
+CREATE INDEX idx_chain_contracts_chain_id ON chain_contracts(chain_id);
+CREATE INDEX idx_chain_contracts_contract_id ON chain_contracts(contract_id);
+
+-- Add chain_id to contracts table for reverse lookup
+ALTER TABLE contracts ADD COLUMN IF NOT EXISTS chain_id UUID REFERENCES chains(id) ON DELETE SET NULL;
+CREATE INDEX IF NOT EXISTS idx_contracts_chain_id ON contracts(chain_id);
+
+-- Chain events for audit trail
+CREATE TABLE IF NOT EXISTS chain_events (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ chain_id UUID NOT NULL REFERENCES chains(id) ON DELETE CASCADE,
+ event_type VARCHAR(64) NOT NULL, -- created, contract_added, contract_completed, loop_iteration, completed
+ contract_id UUID REFERENCES contracts(id) ON DELETE SET NULL,
+ event_data JSONB,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_chain_events_chain_id ON chain_events(chain_id);
diff --git a/makima/src/bin/makima.rs b/makima/src/bin/makima.rs
index af9832b..2037b47 100644
--- a/makima/src/bin/makima.rs
+++ b/makima/src/bin/makima.rs
@@ -6,7 +6,8 @@ use std::sync::Arc;
use makima::daemon::api::{ApiClient, CreateContractRequest};
use makima::daemon::cli::{
- Cli, CliConfig, Commands, ConfigCommand, ContractCommand, SupervisorCommand, ViewArgs,
+ Cli, CliConfig, Commands, ConfigCommand, ContractCommand, ChainCommand,
+ SupervisorCommand, ViewArgs,
};
use makima::daemon::tui::{self, Action, App, ListItem, ViewType, TuiWsClient, WsEvent, OutputLine, OutputMessageType, WsConnectionState, RepositorySuggestion};
use makima::daemon::config::{DaemonConfig, RepoEntry};
@@ -30,6 +31,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
Commands::Contract(cmd) => run_contract(cmd).await,
Commands::View(args) => run_view(args).await,
Commands::Config(cmd) => run_config(cmd).await,
+ Commands::Chain(cmd) => run_chain(cmd).await,
}
}
@@ -793,6 +795,218 @@ async fn run_config(cmd: ConfigCommand) -> Result<(), Box<dyn std::error::Error
}
}
+/// Run chain commands.
+async fn run_chain(
+ cmd: ChainCommand,
+) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
+ use makima::daemon::chain::{parse_chain_file, validate_dag, ChainRunner};
+
+ match cmd {
+ ChainCommand::Run(args) => {
+ eprintln!("Loading chain from: {}", args.file.display());
+
+ // Load and validate chain
+ let chain = parse_chain_file(&args.file)?;
+ validate_dag(&chain)?;
+
+ if args.dry_run {
+ eprintln!("\n=== DRY RUN - No changes will be made ===\n");
+ }
+
+ let runner = ChainRunner::new(args.common.api_url.clone(), args.common.api_key.clone());
+
+ // Show execution order
+ let order = runner.get_execution_order(&chain)?;
+ eprintln!("Execution order:");
+ for (i, name) in order.iter().enumerate() {
+ eprintln!(" {}. {}", i + 1, name);
+ }
+ eprintln!();
+
+ // Show visualization
+ eprintln!("{}", runner.visualize_dag(&chain));
+
+ if args.dry_run {
+ eprintln!("\n=== DRY RUN COMPLETE ===");
+ let request = runner.to_create_request(&chain);
+ println!("{}", serde_json::to_string_pretty(&request)?);
+ } else {
+ // Create chain via API
+ let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
+ let request = runner.to_create_request(&chain);
+ let result = client.create_chain(request).await?;
+ println!("{}", serde_json::to_string(&result.0)?);
+ }
+ }
+ ChainCommand::Status(args) => {
+ let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
+ let result = client.get_chain(args.chain_id).await?;
+ println!("{}", serde_json::to_string(&result.0)?);
+ }
+ ChainCommand::List(args) => {
+ let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
+ let result = client.list_chains(args.status.as_deref(), args.limit).await?;
+ println!("{}", serde_json::to_string(&result.0)?);
+ }
+ ChainCommand::Contracts(args) => {
+ let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
+ let result = client.get_chain_contracts(args.chain_id).await?;
+ println!("{}", serde_json::to_string(&result.0)?);
+ }
+ ChainCommand::Graph(args) => {
+ let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
+ let result = client.get_chain_graph(args.chain_id).await?;
+
+ // Get the graph data
+ if args.with_status {
+ // Enhanced ASCII visualization with status
+ if let Some(nodes) = result.0.get("nodes").and_then(|v| v.as_array()) {
+ let mut by_depth: std::collections::HashMap<i32, Vec<(&str, &str)>> =
+ std::collections::HashMap::new();
+
+ for node in nodes {
+ let name = node.get("name").and_then(|v| v.as_str()).unwrap_or("?");
+ let status = node
+ .get("status")
+ .and_then(|v| v.as_str())
+ .unwrap_or("pending");
+ let depth = node.get("depth").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
+ by_depth.entry(depth).or_default().push((name, status));
+ }
+
+ let chain_name = result
+ .0
+ .get("name")
+ .and_then(|v| v.as_str())
+ .unwrap_or("Chain");
+ println!("Chain: {}", chain_name);
+ println!();
+
+ let max_depth = by_depth.keys().max().copied().unwrap_or(0);
+ for depth in 0..=max_depth {
+ if let Some(contracts) = by_depth.get(&depth) {
+ let indent = " ".repeat(depth as usize);
+ for (name, status) in contracts {
+ let status_icon = match *status {
+ "completed" | "done" => "\u{2713}",
+ "active" | "running" | "in_progress" => "\u{21bb}",
+ "failed" | "error" => "\u{2717}",
+ _ => "\u{25cb}",
+ };
+ println!("{}[{}] {} {}", indent, name, status_icon, status);
+ }
+ if depth < max_depth {
+ println!("{} |", indent);
+ println!("{} v", indent);
+ }
+ }
+ }
+ }
+ } else {
+ // Simple JSON output
+ println!("{}", serde_json::to_string_pretty(&result.0)?);
+ }
+ }
+ ChainCommand::Validate(args) => {
+ eprintln!("Validating chain file: {}", args.file.display());
+
+ match parse_chain_file(&args.file) {
+ Ok(chain) => {
+ match validate_dag(&chain) {
+ Ok(()) => {
+ eprintln!("\u{2713} Chain definition is valid");
+ eprintln!(" Name: {}", chain.name);
+ eprintln!(" Contracts: {}", chain.contracts.len());
+
+ // Show any warnings
+ for contract in &chain.contracts {
+ if contract.tasks.is_none() || contract.tasks.as_ref().map(|t| t.is_empty()).unwrap_or(true) {
+ eprintln!(" \u{26a0} Contract '{}' has no tasks", contract.name);
+ }
+ }
+
+ println!(r#"{{"valid": true, "name": "{}", "contractCount": {}}}"#,
+ chain.name, chain.contracts.len());
+ }
+ Err(e) => {
+ eprintln!("\u{2717} DAG validation failed: {}", e);
+ println!(r#"{{"valid": false, "error": "{}"}}"#, e);
+ std::process::exit(1);
+ }
+ }
+ }
+ Err(e) => {
+ eprintln!("\u{2717} Parse error: {}", e);
+ println!(r#"{{"valid": false, "error": "{}"}}"#, e);
+ std::process::exit(1);
+ }
+ }
+ }
+ ChainCommand::Preview(args) => {
+ eprintln!("Previewing chain: {}", args.file.display());
+
+ let chain = parse_chain_file(&args.file)?;
+ validate_dag(&chain)?;
+
+ let runner = ChainRunner::new(String::new(), String::new());
+
+ // Show chain info
+ println!("Chain: {}", chain.name);
+ if let Some(desc) = &chain.description {
+ println!("Description: {}", desc);
+ }
+ if let Some(repo) = &chain.repository_url {
+ println!("Repository: {}", repo);
+ }
+ println!();
+
+ // Show execution order
+ let order = runner.get_execution_order(&chain)?;
+ println!("Execution Order:");
+ for (i, name) in order.iter().enumerate() {
+ let contract = chain.contracts.iter().find(|c| c.name == *name).unwrap();
+ let deps = contract
+ .depends_on
+ .as_ref()
+ .map(|d| d.join(", "))
+ .unwrap_or_else(|| "(none)".to_string());
+ let task_count = contract.tasks.as_ref().map(|t| t.len()).unwrap_or(0);
+ println!(
+ " {}. {} [type: {}, tasks: {}, depends: {}]",
+ i + 1,
+ name,
+ contract.contract_type,
+ task_count,
+ deps
+ );
+ }
+ println!();
+
+ // Show DAG visualization
+ println!("{}", runner.visualize_dag(&chain));
+
+ // Show loop config if enabled
+ if let Some(lc) = &chain.loop_config {
+ if lc.enabled {
+ println!("\nLoop Configuration:");
+ println!(" Max iterations: {}", lc.max_iterations);
+ if let Some(check) = &lc.progress_check {
+ println!(" Progress check: {}", check);
+ }
+ }
+ }
+ }
+ ChainCommand::Archive(args) => {
+ let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
+ eprintln!("Archiving chain {}...", args.chain_id);
+ let result = client.archive_chain(args.chain_id).await?;
+ println!("{}", serde_json::to_string(&result.0)?);
+ }
+ }
+
+ Ok(())
+}
+
/// Load contracts from API
async fn load_contracts(client: &ApiClient) -> Result<Vec<ListItem>, Box<dyn std::error::Error + Send + Sync>> {
let result = client.list_contracts().await?;
diff --git a/makima/src/daemon/api/chain.rs b/makima/src/daemon/api/chain.rs
new file mode 100644
index 0000000..7f7826f
--- /dev/null
+++ b/makima/src/daemon/api/chain.rs
@@ -0,0 +1,52 @@
+//! Chain API methods.
+
+use uuid::Uuid;
+
+use super::client::{ApiClient, ApiError};
+use super::supervisor::JsonValue;
+use crate::db::models::CreateChainRequest;
+
+impl ApiClient {
+ /// Create a new chain with contracts.
+ pub async fn create_chain(&self, req: CreateChainRequest) -> Result<JsonValue, ApiError> {
+ self.post("/api/v1/chains", &req).await
+ }
+
+ /// List all chains for the authenticated user.
+ pub async fn list_chains(
+ &self,
+ status: Option<&str>,
+ limit: i32,
+ ) -> Result<JsonValue, ApiError> {
+ let mut params = Vec::new();
+ if let Some(s) = status {
+ params.push(format!("status={}", s));
+ }
+ params.push(format!("limit={}", limit));
+ let query_string = format!("?{}", params.join("&"));
+ self.get(&format!("/api/v1/chains{}", query_string)).await
+ }
+
+ /// Get a chain by ID.
+ pub async fn get_chain(&self, chain_id: Uuid) -> Result<JsonValue, ApiError> {
+ self.get(&format!("/api/v1/chains/{}", chain_id)).await
+ }
+
+ /// Get contracts in a chain.
+ pub async fn get_chain_contracts(&self, chain_id: Uuid) -> Result<JsonValue, ApiError> {
+ self.get(&format!("/api/v1/chains/{}/contracts", chain_id))
+ .await
+ }
+
+ /// Get chain DAG structure for visualization.
+ pub async fn get_chain_graph(&self, chain_id: Uuid) -> Result<JsonValue, ApiError> {
+ self.get(&format!("/api/v1/chains/{}/graph", chain_id))
+ .await
+ }
+
+ /// Archive a chain.
+ pub async fn archive_chain(&self, chain_id: Uuid) -> Result<JsonValue, ApiError> {
+ self.delete_with_response(&format!("/api/v1/chains/{}", chain_id))
+ .await
+ }
+}
diff --git a/makima/src/daemon/api/client.rs b/makima/src/daemon/api/client.rs
index 4ba4778..dbf3101 100644
--- a/makima/src/daemon/api/client.rs
+++ b/makima/src/daemon/api/client.rs
@@ -276,6 +276,48 @@ impl ApiClient {
Err(last_error.unwrap())
}
+ /// Make a DELETE request with response and retry.
+ pub async fn delete_with_response<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
+ let url = format!("{}{}", self.base_url, path);
+ let mut last_error = None;
+
+ for attempt in 0..MAX_RETRIES {
+ if attempt > 0 {
+ tokio::time::sleep(Self::backoff_delay(attempt - 1)).await;
+ }
+
+ let result = self.client
+ .delete(&url)
+ .header("X-Makima-Tool-Key", &self.api_key)
+ .header("X-Makima-API-Key", &self.api_key)
+ .send()
+ .await;
+
+ match result {
+ Ok(response) => {
+ match self.handle_response(response).await {
+ Ok(value) => return Ok(value),
+ Err(e) if Self::is_retryable(&e) && attempt < MAX_RETRIES - 1 => {
+ last_error = Some(e);
+ continue;
+ }
+ Err(e) => return Err(e),
+ }
+ }
+ Err(e) => {
+ let error = ApiError::Request(e);
+ if Self::is_retryable(&error) && attempt < MAX_RETRIES - 1 {
+ last_error = Some(error);
+ continue;
+ }
+ return Err(error);
+ }
+ }
+ }
+
+ Err(last_error.unwrap())
+ }
+
/// Handle API response.
async fn handle_response<T: DeserializeOwned>(
&self,
diff --git a/makima/src/daemon/api/mod.rs b/makima/src/daemon/api/mod.rs
index 49d80e0..7868907 100644
--- a/makima/src/daemon/api/mod.rs
+++ b/makima/src/daemon/api/mod.rs
@@ -1,5 +1,6 @@
//! HTTP API client for makima CLI commands.
+pub mod chain;
pub mod client;
pub mod contract;
pub mod supervisor;
diff --git a/makima/src/daemon/chain/dag.rs b/makima/src/daemon/chain/dag.rs
new file mode 100644
index 0000000..7ba5904
--- /dev/null
+++ b/makima/src/daemon/chain/dag.rs
@@ -0,0 +1,450 @@
+//! DAG validation and traversal for chain contracts.
+//!
+//! Provides cycle detection and topological sorting for contract dependencies.
+
+use std::collections::{HashMap, HashSet, VecDeque};
+use thiserror::Error;
+
+use super::parser::ChainDefinition;
+
+/// Error type for DAG operations.
+#[derive(Error, Debug)]
+pub enum DagError {
+ #[error("Cycle detected in dependency graph: {0}")]
+ CycleDetected(String),
+
+ #[error("Unknown contract in dependency: {0}")]
+ UnknownContract(String),
+}
+
+/// Validates that the chain definition forms a valid DAG (no cycles).
+///
+/// Uses depth-first search with color marking to detect cycles.
+/// Returns Ok(()) if valid, or an error describing the cycle.
+pub fn validate_dag(chain: &ChainDefinition) -> Result<(), DagError> {
+ // Build adjacency list from contract dependencies
+ let mut adjacency: HashMap<&str, Vec<&str>> = HashMap::new();
+ let contract_names: HashSet<&str> = chain.contracts.iter().map(|c| c.name.as_str()).collect();
+
+ for contract in &chain.contracts {
+ let deps: Vec<&str> = contract
+ .depends_on
+ .as_ref()
+ .map(|d| d.iter().map(|s| s.as_str()).collect())
+ .unwrap_or_default();
+
+ // Validate all dependencies exist
+ for dep in &deps {
+ if !contract_names.contains(dep) {
+ return Err(DagError::UnknownContract(format!(
+ "Contract '{}' depends on unknown contract '{}'",
+ contract.name, dep
+ )));
+ }
+ }
+
+ adjacency.insert(contract.name.as_str(), deps);
+ }
+
+ // Color-based DFS for cycle detection
+ // White (0): not visited, Gray (1): in progress, Black (2): completed
+ let mut color: HashMap<&str, u8> = HashMap::new();
+ for name in &contract_names {
+ color.insert(name, 0);
+ }
+
+ // Track path for cycle reporting
+ fn dfs<'a>(
+ node: &'a str,
+ adjacency: &HashMap<&'a str, Vec<&'a str>>,
+ color: &mut HashMap<&'a str, u8>,
+ path: &mut Vec<&'a str>,
+ ) -> Result<(), DagError> {
+ color.insert(node, 1); // Mark as in-progress
+ path.push(node);
+
+ if let Some(deps) = adjacency.get(node) {
+ for dep in deps {
+ match color.get(dep) {
+ Some(1) => {
+ // Found cycle - dep is in current path
+ let cycle_start = path.iter().position(|&n| n == *dep).unwrap();
+ let cycle: Vec<_> = path[cycle_start..].to_vec();
+ return Err(DagError::CycleDetected(format!(
+ "{} -> {}",
+ cycle.join(" -> "),
+ dep
+ )));
+ }
+ Some(0) => {
+ // Not visited - recurse
+ dfs(dep, adjacency, color, path)?;
+ }
+ _ => {
+ // Already completed - skip
+ }
+ }
+ }
+ }
+
+ color.insert(node, 2); // Mark as completed
+ path.pop();
+ Ok(())
+ }
+
+ // Run DFS from each unvisited node
+ for name in &contract_names {
+ if color.get(name) == Some(&0) {
+ let mut path = Vec::new();
+ dfs(name, &adjacency, &mut color, &mut path)?;
+ }
+ }
+
+ Ok(())
+}
+
+/// Returns contracts in topological order (dependencies before dependents).
+///
+/// Uses Kahn's algorithm for topological sorting.
+pub fn topological_sort(chain: &ChainDefinition) -> Result<Vec<&str>, DagError> {
+ // Validate first
+ validate_dag(chain)?;
+
+ // Build in-degree map and adjacency list
+ let mut in_degree: HashMap<&str, usize> = HashMap::new();
+ let mut dependents: HashMap<&str, Vec<&str>> = HashMap::new();
+
+ for contract in &chain.contracts {
+ in_degree.entry(contract.name.as_str()).or_insert(0);
+ dependents.entry(contract.name.as_str()).or_default();
+
+ if let Some(deps) = &contract.depends_on {
+ for dep in deps {
+ *in_degree.entry(contract.name.as_str()).or_insert(0) += 1;
+ dependents
+ .entry(dep.as_str())
+ .or_default()
+ .push(contract.name.as_str());
+ }
+ }
+ }
+
+ // Kahn's algorithm
+ let mut queue: VecDeque<&str> = VecDeque::new();
+ let mut result: Vec<&str> = Vec::new();
+
+ // Start with nodes that have no dependencies
+ for (name, &degree) in &in_degree {
+ if degree == 0 {
+ queue.push_back(name);
+ }
+ }
+
+ while let Some(node) = queue.pop_front() {
+ result.push(node);
+
+ if let Some(deps) = dependents.get(node) {
+ for dep in deps {
+ if let Some(degree) = in_degree.get_mut(dep) {
+ *degree -= 1;
+ if *degree == 0 {
+ queue.push_back(dep);
+ }
+ }
+ }
+ }
+ }
+
+ Ok(result)
+}
+
+/// Returns contracts that are ready to run (have no unmet dependencies).
+///
+/// Takes a set of completed contract names and returns contracts that
+/// can now be started.
+pub fn get_ready_contracts<'a>(
+ chain: &'a ChainDefinition,
+ completed: &HashSet<&str>,
+) -> Vec<&'a str> {
+ chain
+ .contracts
+ .iter()
+ .filter(|c| {
+ // Already completed? Skip
+ if completed.contains(c.name.as_str()) {
+ return false;
+ }
+
+ // Check if all dependencies are met
+ match &c.depends_on {
+ None => true, // No dependencies
+ Some(deps) => deps.iter().all(|d| completed.contains(d.as_str())),
+ }
+ })
+ .map(|c| c.name.as_str())
+ .collect()
+}
+
+/// Get the depth of each contract in the DAG (for layout purposes).
+///
+/// Root nodes (no dependencies) have depth 0.
+/// Each dependent has depth = max(dependency depths) + 1.
+pub fn get_contract_depths(chain: &ChainDefinition) -> HashMap<&str, usize> {
+ let mut depths: HashMap<&str, usize> = HashMap::new();
+
+ // Multiple passes to handle dependencies
+ let max_iterations = chain.contracts.len();
+ for _ in 0..max_iterations {
+ let mut changed = false;
+
+ for contract in &chain.contracts {
+ let new_depth = match &contract.depends_on {
+ None => 0,
+ Some(deps) => {
+ if deps.iter().all(|d| depths.contains_key(d.as_str())) {
+ deps.iter()
+ .filter_map(|d| depths.get(d.as_str()))
+ .max()
+ .copied()
+ .unwrap_or(0)
+ + 1
+ } else {
+ continue; // Dependencies not yet computed
+ }
+ }
+ };
+
+ if depths.get(contract.name.as_str()) != Some(&new_depth) {
+ depths.insert(contract.name.as_str(), new_depth);
+ changed = true;
+ }
+ }
+
+ if !changed {
+ break;
+ }
+ }
+
+ depths
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::daemon::chain::parser::parse_chain_yaml;
+
+ #[test]
+ fn test_valid_dag() {
+ let yaml = r#"
+name: Valid DAG
+contracts:
+ - name: A
+ tasks:
+ - name: Task
+ plan: "Do A"
+ - name: B
+ depends_on: [A]
+ tasks:
+ - name: Task
+ plan: "Do B"
+ - name: C
+ depends_on: [A]
+ tasks:
+ - name: Task
+ plan: "Do C"
+ - name: D
+ depends_on: [B, C]
+ tasks:
+ - name: Task
+ plan: "Do D"
+"#;
+ let chain = parse_chain_yaml(yaml).unwrap();
+ assert!(validate_dag(&chain).is_ok());
+ }
+
+ #[test]
+ fn test_simple_cycle() {
+ let yaml = r#"
+name: Simple Cycle
+contracts:
+ - name: A
+ depends_on: [B]
+ tasks:
+ - name: Task
+ plan: "Do A"
+ - name: B
+ depends_on: [A]
+ tasks:
+ - name: Task
+ plan: "Do B"
+"#;
+ let chain = parse_chain_yaml(yaml).unwrap();
+ let result = validate_dag(&chain);
+ assert!(result.is_err());
+ assert!(result.unwrap_err().to_string().contains("Cycle detected"));
+ }
+
+ #[test]
+ fn test_longer_cycle() {
+ let yaml = r#"
+name: Longer Cycle
+contracts:
+ - name: A
+ depends_on: [C]
+ tasks:
+ - name: Task
+ plan: "Do A"
+ - name: B
+ depends_on: [A]
+ tasks:
+ - name: Task
+ plan: "Do B"
+ - name: C
+ depends_on: [B]
+ tasks:
+ - name: Task
+ plan: "Do C"
+"#;
+ let chain = parse_chain_yaml(yaml).unwrap();
+ let result = validate_dag(&chain);
+ assert!(result.is_err());
+ assert!(result.unwrap_err().to_string().contains("Cycle detected"));
+ }
+
+ #[test]
+ fn test_topological_sort() {
+ let yaml = r#"
+name: Topo Test
+contracts:
+ - name: A
+ tasks:
+ - name: Task
+ plan: "Do A"
+ - name: B
+ depends_on: [A]
+ tasks:
+ - name: Task
+ plan: "Do B"
+ - name: C
+ depends_on: [A]
+ tasks:
+ - name: Task
+ plan: "Do C"
+ - name: D
+ depends_on: [B, C]
+ tasks:
+ - name: Task
+ plan: "Do D"
+"#;
+ let chain = parse_chain_yaml(yaml).unwrap();
+ let sorted = topological_sort(&chain).unwrap();
+
+ // A must come before B, C; B and C must come before D
+ let pos_a = sorted.iter().position(|&n| n == "A").unwrap();
+ let pos_b = sorted.iter().position(|&n| n == "B").unwrap();
+ let pos_c = sorted.iter().position(|&n| n == "C").unwrap();
+ let pos_d = sorted.iter().position(|&n| n == "D").unwrap();
+
+ assert!(pos_a < pos_b);
+ assert!(pos_a < pos_c);
+ assert!(pos_b < pos_d);
+ assert!(pos_c < pos_d);
+ }
+
+ #[test]
+ fn test_get_ready_contracts() {
+ let yaml = r#"
+name: Ready Test
+contracts:
+ - name: A
+ tasks:
+ - name: Task
+ plan: "Do A"
+ - name: B
+ depends_on: [A]
+ tasks:
+ - name: Task
+ plan: "Do B"
+ - name: C
+ tasks:
+ - name: Task
+ plan: "Do C"
+"#;
+ let chain = parse_chain_yaml(yaml).unwrap();
+
+ // Initially A and C are ready (no dependencies)
+ let completed = HashSet::new();
+ let mut ready = get_ready_contracts(&chain, &completed);
+ ready.sort();
+ assert_eq!(ready, vec!["A", "C"]);
+
+ // After A completes, B becomes ready
+ let mut completed = HashSet::new();
+ completed.insert("A");
+ let ready = get_ready_contracts(&chain, &completed);
+ assert!(ready.contains(&"B"));
+ assert!(ready.contains(&"C")); // C still ready if not started
+ }
+
+ #[test]
+ fn test_get_contract_depths() {
+ let yaml = r#"
+name: Depth Test
+contracts:
+ - name: A
+ tasks:
+ - name: Task
+ plan: "Do A"
+ - name: B
+ depends_on: [A]
+ tasks:
+ - name: Task
+ plan: "Do B"
+ - name: C
+ depends_on: [B]
+ tasks:
+ - name: Task
+ plan: "Do C"
+"#;
+ let chain = parse_chain_yaml(yaml).unwrap();
+ let depths = get_contract_depths(&chain);
+
+ assert_eq!(depths.get("A"), Some(&0));
+ assert_eq!(depths.get("B"), Some(&1));
+ assert_eq!(depths.get("C"), Some(&2));
+ }
+
+ #[test]
+ fn test_diamond_dependency_depths() {
+ let yaml = r#"
+name: Diamond Test
+contracts:
+ - name: A
+ tasks:
+ - name: Task
+ plan: "Do A"
+ - name: B
+ depends_on: [A]
+ tasks:
+ - name: Task
+ plan: "Do B"
+ - name: C
+ depends_on: [A]
+ tasks:
+ - name: Task
+ plan: "Do C"
+ - name: D
+ depends_on: [B, C]
+ tasks:
+ - name: Task
+ plan: "Do D"
+"#;
+ let chain = parse_chain_yaml(yaml).unwrap();
+ let depths = get_contract_depths(&chain);
+
+ assert_eq!(depths.get("A"), Some(&0));
+ assert_eq!(depths.get("B"), Some(&1));
+ assert_eq!(depths.get("C"), Some(&1));
+ assert_eq!(depths.get("D"), Some(&2));
+ }
+}
diff --git a/makima/src/daemon/chain/mod.rs b/makima/src/daemon/chain/mod.rs
new file mode 100644
index 0000000..5588a27
--- /dev/null
+++ b/makima/src/daemon/chain/mod.rs
@@ -0,0 +1,13 @@
+//! Chain module - DAG-based multi-contract orchestration.
+//!
+//! Chains are directed acyclic graphs (DAGs) of contracts that work together
+//! to achieve a larger goal. Each contract can depend on others, and contracts
+//! run in parallel when no dependencies exist.
+
+pub mod dag;
+pub mod parser;
+pub mod runner;
+
+pub use dag::{validate_dag, DagError};
+pub use parser::{parse_chain_file, ChainDefinition, ParseError};
+pub use runner::{ChainRunner, RunnerError};
diff --git a/makima/src/daemon/chain/parser.rs b/makima/src/daemon/chain/parser.rs
new file mode 100644
index 0000000..0f16710
--- /dev/null
+++ b/makima/src/daemon/chain/parser.rs
@@ -0,0 +1,392 @@
+//! Chain YAML parser.
+//!
+//! Parses chain definition files in YAML format into structured data
+//! that can be used to create chains and contracts.
+
+use serde::{Deserialize, Serialize};
+use std::path::Path;
+use thiserror::Error;
+
+/// Error type for chain parsing operations.
+#[derive(Error, Debug)]
+pub enum ParseError {
+ #[error("Failed to read chain file: {0}")]
+ IoError(#[from] std::io::Error),
+
+ #[error("Failed to parse YAML: {0}")]
+ YamlError(#[from] serde_yaml::Error),
+
+ #[error("Validation error: {0}")]
+ ValidationError(String),
+}
+
+/// Chain definition parsed from YAML.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ChainDefinition {
+ /// Name of the chain
+ pub name: String,
+ /// Optional description
+ pub description: Option<String>,
+ /// Repository URL (optional - contracts may have their own repos)
+ #[serde(alias = "repo")]
+ pub repository_url: Option<String>,
+ /// Local path for repository
+ pub local_path: Option<String>,
+ /// Contracts in this chain
+ pub contracts: Vec<ContractDefinition>,
+ /// Loop configuration
+ #[serde(rename = "loop")]
+ pub loop_config: Option<LoopConfig>,
+}
+
+/// Contract definition within a chain.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ContractDefinition {
+ /// Name of the contract
+ pub name: String,
+ /// Optional description
+ pub description: Option<String>,
+ /// Contract type (defaults to "simple")
+ #[serde(rename = "type", default = "default_contract_type")]
+ pub contract_type: String,
+ /// Phases for this contract
+ pub phases: Option<Vec<String>>,
+ /// Names of contracts this depends on (DAG edges)
+ pub depends_on: Option<Vec<String>>,
+ /// Tasks to create in this contract
+ pub tasks: Option<Vec<TaskDefinition>>,
+ /// Deliverables for this contract
+ pub deliverables: Option<Vec<DeliverableDefinition>>,
+}
+
+fn default_contract_type() -> String {
+ "simple".to_string()
+}
+
+/// Task definition within a contract.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct TaskDefinition {
+ /// Name of the task
+ pub name: String,
+ /// Plan/instructions for the task
+ pub plan: String,
+}
+
+/// Deliverable definition within a contract.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct DeliverableDefinition {
+ /// Unique identifier for the deliverable
+ pub id: String,
+ /// Name of the deliverable
+ pub name: String,
+ /// Priority level (defaults to "required")
+ #[serde(default = "default_priority")]
+ pub priority: String,
+}
+
+fn default_priority() -> String {
+ "required".to_string()
+}
+
+/// Loop configuration for chain iteration.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct LoopConfig {
+ /// Whether loop is enabled
+ #[serde(default)]
+ pub enabled: bool,
+ /// Maximum number of iterations
+ #[serde(default = "default_max_iterations")]
+ pub max_iterations: i32,
+ /// Progress check prompt/criteria
+ pub progress_check: Option<String>,
+}
+
+fn default_max_iterations() -> i32 {
+ 10
+}
+
+impl ChainDefinition {
+ /// Validate the chain definition.
+ pub fn validate(&self) -> Result<(), ParseError> {
+ // Check for empty name
+ if self.name.trim().is_empty() {
+ return Err(ParseError::ValidationError(
+ "Chain name cannot be empty".to_string(),
+ ));
+ }
+
+ // Check for at least one contract
+ if self.contracts.is_empty() {
+ return Err(ParseError::ValidationError(
+ "Chain must have at least one contract".to_string(),
+ ));
+ }
+
+ // Collect all contract names for dependency validation
+ let contract_names: std::collections::HashSet<_> =
+ self.contracts.iter().map(|c| c.name.as_str()).collect();
+
+ // Check for duplicate contract names
+ if contract_names.len() != self.contracts.len() {
+ return Err(ParseError::ValidationError(
+ "Duplicate contract names found".to_string(),
+ ));
+ }
+
+ // Validate each contract
+ for contract in &self.contracts {
+ contract.validate(&contract_names)?;
+ }
+
+ Ok(())
+ }
+}
+
+impl ContractDefinition {
+ /// Validate the contract definition.
+ pub fn validate(
+ &self,
+ valid_contract_names: &std::collections::HashSet<&str>,
+ ) -> Result<(), ParseError> {
+ // Check for empty name
+ if self.name.trim().is_empty() {
+ return Err(ParseError::ValidationError(
+ "Contract name cannot be empty".to_string(),
+ ));
+ }
+
+ // Validate dependencies exist
+ if let Some(deps) = &self.depends_on {
+ for dep in deps {
+ if !valid_contract_names.contains(dep.as_str()) {
+ return Err(ParseError::ValidationError(format!(
+ "Contract '{}' depends on unknown contract '{}'",
+ self.name, dep
+ )));
+ }
+ // Self-dependency check
+ if dep == &self.name {
+ return Err(ParseError::ValidationError(format!(
+ "Contract '{}' cannot depend on itself",
+ self.name
+ )));
+ }
+ }
+ }
+
+ // Validate tasks
+ if let Some(tasks) = &self.tasks {
+ for task in tasks {
+ if task.name.trim().is_empty() {
+ return Err(ParseError::ValidationError(format!(
+ "Task name cannot be empty in contract '{}'",
+ self.name
+ )));
+ }
+ if task.plan.trim().is_empty() {
+ return Err(ParseError::ValidationError(format!(
+ "Task '{}' in contract '{}' has empty plan",
+ task.name, self.name
+ )));
+ }
+ }
+ }
+
+ Ok(())
+ }
+}
+
+/// Parse a chain definition from a YAML file.
+pub fn parse_chain_file<P: AsRef<Path>>(path: P) -> Result<ChainDefinition, ParseError> {
+ let content = std::fs::read_to_string(path)?;
+ parse_chain_yaml(&content)
+}
+
+/// Parse a chain definition from a YAML string.
+pub fn parse_chain_yaml(yaml: &str) -> Result<ChainDefinition, ParseError> {
+ let definition: ChainDefinition = serde_yaml::from_str(yaml)?;
+ definition.validate()?;
+ Ok(definition)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_parse_simple_chain() {
+ let yaml = r#"
+name: Test Chain
+description: A test chain
+contracts:
+ - name: Research
+ type: simple
+ tasks:
+ - name: Analyze
+ plan: "Analyze the codebase"
+ - name: Implement
+ type: simple
+ depends_on: [Research]
+ tasks:
+ - name: Build
+ plan: "Build the feature"
+"#;
+ let chain = parse_chain_yaml(yaml).unwrap();
+ assert_eq!(chain.name, "Test Chain");
+ assert_eq!(chain.contracts.len(), 2);
+ assert_eq!(chain.contracts[0].name, "Research");
+ assert_eq!(chain.contracts[1].name, "Implement");
+ assert_eq!(
+ chain.contracts[1].depends_on,
+ Some(vec!["Research".to_string()])
+ );
+ }
+
+ #[test]
+ fn test_parse_chain_with_loop() {
+ let yaml = r#"
+name: Iterative Chain
+contracts:
+ - name: Phase1
+ tasks:
+ - name: Task1
+ plan: "Do something"
+loop:
+ enabled: true
+ max_iterations: 5
+ progress_check: "Check if goals are met"
+"#;
+ let chain = parse_chain_yaml(yaml).unwrap();
+ assert!(chain.loop_config.is_some());
+ let loop_config = chain.loop_config.unwrap();
+ assert!(loop_config.enabled);
+ assert_eq!(loop_config.max_iterations, 5);
+ }
+
+ #[test]
+ fn test_parse_chain_with_deliverables() {
+ let yaml = r#"
+name: Feature Chain
+contracts:
+ - name: Research
+ tasks:
+ - name: Survey
+ plan: "Survey existing code"
+ deliverables:
+ - id: analysis
+ name: Codebase Analysis
+ priority: required
+"#;
+ let chain = parse_chain_yaml(yaml).unwrap();
+ let deliverables = chain.contracts[0].deliverables.as_ref().unwrap();
+ assert_eq!(deliverables.len(), 1);
+ assert_eq!(deliverables[0].id, "analysis");
+ }
+
+ #[test]
+ fn test_validation_empty_name() {
+ let yaml = r#"
+name: ""
+contracts:
+ - name: Phase1
+ tasks:
+ - name: Task1
+ plan: "Do something"
+"#;
+ let result = parse_chain_yaml(yaml);
+ assert!(result.is_err());
+ assert!(result
+ .unwrap_err()
+ .to_string()
+ .contains("name cannot be empty"));
+ }
+
+ #[test]
+ fn test_validation_no_contracts() {
+ let yaml = r#"
+name: Empty Chain
+contracts: []
+"#;
+ let result = parse_chain_yaml(yaml);
+ assert!(result.is_err());
+ assert!(result
+ .unwrap_err()
+ .to_string()
+ .contains("at least one contract"));
+ }
+
+ #[test]
+ fn test_validation_unknown_dependency() {
+ let yaml = r#"
+name: Bad Chain
+contracts:
+ - name: Phase1
+ depends_on: [NonExistent]
+ tasks:
+ - name: Task1
+ plan: "Do something"
+"#;
+ let result = parse_chain_yaml(yaml);
+ assert!(result.is_err());
+ assert!(result.unwrap_err().to_string().contains("unknown contract"));
+ }
+
+ #[test]
+ fn test_validation_self_dependency() {
+ let yaml = r#"
+name: Self Ref Chain
+contracts:
+ - name: Phase1
+ depends_on: [Phase1]
+ tasks:
+ - name: Task1
+ plan: "Do something"
+"#;
+ let result = parse_chain_yaml(yaml);
+ assert!(result.is_err());
+ assert!(result
+ .unwrap_err()
+ .to_string()
+ .contains("cannot depend on itself"));
+ }
+
+ #[test]
+ fn test_validation_duplicate_names() {
+ let yaml = r#"
+name: Dup Chain
+contracts:
+ - name: Phase1
+ tasks:
+ - name: Task1
+ plan: "Do something"
+ - name: Phase1
+ tasks:
+ - name: Task2
+ plan: "Do another thing"
+"#;
+ let result = parse_chain_yaml(yaml);
+ assert!(result.is_err());
+ assert!(result
+ .unwrap_err()
+ .to_string()
+ .contains("Duplicate contract names"));
+ }
+
+ #[test]
+ fn test_repo_alias() {
+ let yaml = r#"
+name: Repo Chain
+repo: https://github.com/user/project
+contracts:
+ - name: Phase1
+ tasks:
+ - name: Task1
+ plan: "Work on repo"
+"#;
+ let chain = parse_chain_yaml(yaml).unwrap();
+ assert_eq!(
+ chain.repository_url,
+ Some("https://github.com/user/project".to_string())
+ );
+ }
+}
diff --git a/makima/src/daemon/chain/runner.rs b/makima/src/daemon/chain/runner.rs
new file mode 100644
index 0000000..9c6f6b4
--- /dev/null
+++ b/makima/src/daemon/chain/runner.rs
@@ -0,0 +1,364 @@
+//! Chain runner - creates and orchestrates contracts from chain definitions.
+//!
+//! Handles the lifecycle of a chain:
+//! 1. Parse chain definition
+//! 2. Validate DAG
+//! 3. Create chain record
+//! 4. Create contracts in dependency order
+//! 5. Monitor and trigger dependent contracts
+
+use std::collections::HashMap;
+use std::path::Path;
+use thiserror::Error;
+
+use super::dag::{topological_sort, validate_dag, DagError};
+use super::parser::{parse_chain_file, ChainDefinition, ParseError};
+use crate::db::models::{
+ CreateChainContractRequest, CreateChainDeliverableRequest, CreateChainRequest,
+ CreateChainTaskRequest,
+};
+
+/// Error type for chain runner operations.
+#[derive(Error, Debug)]
+pub enum RunnerError {
+ #[error("Parse error: {0}")]
+ Parse(#[from] ParseError),
+
+ #[error("DAG error: {0}")]
+ Dag(#[from] DagError),
+
+ #[error("API error: {0}")]
+ Api(String),
+
+ #[error("Contract creation failed: {0}")]
+ ContractCreation(String),
+}
+
+/// Chain runner for creating and managing chains.
+pub struct ChainRunner {
+ /// Base API URL
+ api_url: String,
+ /// API key for authentication
+ api_key: String,
+}
+
+impl ChainRunner {
+ /// Create a new chain runner.
+ pub fn new(api_url: String, api_key: String) -> Self {
+ Self { api_url, api_key }
+ }
+
+ /// Load and validate a chain from a YAML file.
+ pub fn load_chain<P: AsRef<Path>>(&self, path: P) -> Result<ChainDefinition, RunnerError> {
+ let chain = parse_chain_file(path)?;
+ validate_dag(&chain)?;
+ Ok(chain)
+ }
+
+ /// Convert a chain definition to a CreateChainRequest for API submission.
+ pub fn to_create_request(&self, chain: &ChainDefinition) -> CreateChainRequest {
+ let contracts: Vec<CreateChainContractRequest> = chain
+ .contracts
+ .iter()
+ .map(|c| CreateChainContractRequest {
+ name: c.name.clone(),
+ description: c.description.clone(),
+ contract_type: Some(c.contract_type.clone()),
+ initial_phase: None,
+ phases: c.phases.clone(),
+ depends_on: c.depends_on.clone(),
+ tasks: c.tasks.as_ref().map(|tasks| {
+ tasks
+ .iter()
+ .map(|t| CreateChainTaskRequest {
+ name: t.name.clone(),
+ plan: t.plan.clone(),
+ })
+ .collect()
+ }),
+ deliverables: c.deliverables.as_ref().map(|dels| {
+ dels.iter()
+ .map(|d| CreateChainDeliverableRequest {
+ id: d.id.clone(),
+ name: d.name.clone(),
+ priority: Some(d.priority.clone()),
+ })
+ .collect()
+ }),
+ editor_x: None,
+ editor_y: None,
+ })
+ .collect();
+
+ let (loop_enabled, loop_max_iterations, loop_progress_check) =
+ match &chain.loop_config {
+ Some(lc) => (
+ Some(lc.enabled),
+ Some(lc.max_iterations),
+ lc.progress_check.clone(),
+ ),
+ None => (None, None, None),
+ };
+
+ CreateChainRequest {
+ name: chain.name.clone(),
+ description: chain.description.clone(),
+ repository_url: chain.repository_url.clone(),
+ local_path: chain.local_path.clone(),
+ loop_enabled,
+ loop_max_iterations,
+ loop_progress_check,
+ contracts: Some(contracts),
+ }
+ }
+
+ /// Get contracts in topological order (for display/debugging).
+ pub fn get_execution_order<'a>(
+ &self,
+ chain: &'a ChainDefinition,
+ ) -> Result<Vec<&'a str>, RunnerError> {
+ Ok(topological_sort(chain)?)
+ }
+
+ /// Generate ASCII visualization of the chain DAG.
+ pub fn visualize_dag(&self, chain: &ChainDefinition) -> String {
+ use super::dag::get_contract_depths;
+
+ let depths = get_contract_depths(chain);
+ let mut lines: Vec<String> = vec![];
+
+ lines.push(format!("Chain: {}", chain.name));
+ if let Some(desc) = &chain.description {
+ lines.push(format!(" {}", desc));
+ }
+ lines.push(String::new());
+
+ // Group contracts by depth
+ let mut by_depth: HashMap<usize, Vec<&str>> = HashMap::new();
+ for contract in &chain.contracts {
+ let depth = depths.get(contract.name.as_str()).copied().unwrap_or(0);
+ by_depth.entry(depth).or_default().push(&contract.name);
+ }
+
+ // Find max depth
+ let max_depth = by_depth.keys().max().copied().unwrap_or(0);
+
+ // Build visualization
+ for depth in 0..=max_depth {
+ if let Some(contracts) = by_depth.get(&depth) {
+ let contract_strs: Vec<String> = contracts
+ .iter()
+ .map(|name| format!("[{}]", name))
+ .collect();
+
+ let indent = " ".repeat(depth);
+ lines.push(format!("{}{}", indent, contract_strs.join(" ")));
+
+ // Draw arrows to next level
+ if depth < max_depth {
+ if let Some(next_contracts) = by_depth.get(&(depth + 1)) {
+ // Find which contracts connect to the next level
+ for next in next_contracts {
+ let next_contract = chain
+ .contracts
+ .iter()
+ .find(|c| c.name.as_str() == *next)
+ .unwrap();
+
+ if let Some(deps) = &next_contract.depends_on {
+ for dep in deps {
+ if contracts.contains(&dep.as_str()) {
+ let arrow_indent = " ".repeat(depth);
+ lines.push(format!("{} │", arrow_indent));
+ lines.push(format!("{} â–¼", arrow_indent));
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ lines.join("\n")
+ }
+}
+
+/// Compute editor positions for contracts based on DAG layout.
+///
+/// Returns a map of contract name to (x, y) positions suitable for
+/// the GUI editor.
+pub fn compute_editor_positions(chain: &ChainDefinition) -> HashMap<String, (f64, f64)> {
+ use super::dag::get_contract_depths;
+
+ let depths = get_contract_depths(chain);
+ let mut positions: HashMap<String, (f64, f64)> = HashMap::new();
+
+ // Group by depth
+ let mut by_depth: HashMap<usize, Vec<&str>> = HashMap::new();
+ for contract in &chain.contracts {
+ let depth = depths.get(contract.name.as_str()).copied().unwrap_or(0);
+ by_depth.entry(depth).or_default().push(&contract.name);
+ }
+
+ // Compute positions: x based on depth, y based on index within depth
+ let x_spacing = 250.0;
+ let y_spacing = 150.0;
+
+ for (depth, contracts) in &by_depth {
+ let x = (*depth as f64) * x_spacing + 100.0;
+ for (i, name) in contracts.iter().enumerate() {
+ let y = (i as f64) * y_spacing + 100.0;
+ positions.insert(name.to_string(), (x, y));
+ }
+ }
+
+ positions
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::daemon::chain::parser::parse_chain_yaml;
+
+ #[test]
+ fn test_to_create_request() {
+ let yaml = r#"
+name: Test Chain
+description: A test chain
+repo: https://github.com/test/repo
+contracts:
+ - name: Research
+ type: simple
+ phases: [plan, execute]
+ tasks:
+ - name: Analyze
+ plan: "Analyze the codebase"
+ deliverables:
+ - id: analysis
+ name: Analysis Doc
+ priority: required
+ - name: Implement
+ depends_on: [Research]
+ tasks:
+ - name: Build
+ plan: "Build the feature"
+loop:
+ enabled: true
+ max_iterations: 5
+ progress_check: "Check completion"
+"#;
+ let chain = parse_chain_yaml(yaml).unwrap();
+ let runner = ChainRunner::new("http://localhost".to_string(), "key".to_string());
+ let request = runner.to_create_request(&chain);
+
+ assert_eq!(request.name, "Test Chain");
+ assert_eq!(request.description, Some("A test chain".to_string()));
+ assert_eq!(
+ request.repository_url,
+ Some("https://github.com/test/repo".to_string())
+ );
+ assert_eq!(request.loop_enabled, Some(true));
+ assert_eq!(request.loop_max_iterations, Some(5));
+
+ let contracts = request.contracts.unwrap();
+ assert_eq!(contracts.len(), 2);
+ assert_eq!(contracts[0].name, "Research");
+ assert_eq!(contracts[0].phases, Some(vec!["plan".to_string(), "execute".to_string()]));
+ assert_eq!(
+ contracts[1].depends_on,
+ Some(vec!["Research".to_string()])
+ );
+ }
+
+ #[test]
+ fn test_get_execution_order() {
+ let yaml = r#"
+name: Order Test
+contracts:
+ - name: C
+ depends_on: [B]
+ tasks:
+ - name: Task
+ plan: "Do C"
+ - name: A
+ tasks:
+ - name: Task
+ plan: "Do A"
+ - name: B
+ depends_on: [A]
+ tasks:
+ - name: Task
+ plan: "Do B"
+"#;
+ let chain = parse_chain_yaml(yaml).unwrap();
+ let runner = ChainRunner::new("http://localhost".to_string(), "key".to_string());
+ let order = runner.get_execution_order(&chain).unwrap();
+
+ let pos_a = order.iter().position(|&n| n == "A").unwrap();
+ let pos_b = order.iter().position(|&n| n == "B").unwrap();
+ let pos_c = order.iter().position(|&n| n == "C").unwrap();
+
+ assert!(pos_a < pos_b);
+ assert!(pos_b < pos_c);
+ }
+
+ #[test]
+ fn test_visualize_dag() {
+ let yaml = r#"
+name: Visual Test
+description: Test visualization
+contracts:
+ - name: A
+ tasks:
+ - name: Task
+ plan: "Do A"
+ - name: B
+ depends_on: [A]
+ tasks:
+ - name: Task
+ plan: "Do B"
+"#;
+ let chain = parse_chain_yaml(yaml).unwrap();
+ let runner = ChainRunner::new("http://localhost".to_string(), "key".to_string());
+ let viz = runner.visualize_dag(&chain);
+
+ assert!(viz.contains("Chain: Visual Test"));
+ assert!(viz.contains("[A]"));
+ assert!(viz.contains("[B]"));
+ }
+
+ #[test]
+ fn test_compute_editor_positions() {
+ let yaml = r#"
+name: Position Test
+contracts:
+ - name: A
+ tasks:
+ - name: Task
+ plan: "Do A"
+ - name: B
+ depends_on: [A]
+ tasks:
+ - name: Task
+ plan: "Do B"
+ - name: C
+ depends_on: [A]
+ tasks:
+ - name: Task
+ plan: "Do C"
+"#;
+ let chain = parse_chain_yaml(yaml).unwrap();
+ let positions = compute_editor_positions(&chain);
+
+ // A should be at depth 0 (x = 100)
+ let (a_x, _) = positions.get("A").unwrap();
+ assert_eq!(*a_x, 100.0);
+
+ // B and C should be at depth 1 (x = 350)
+ let (b_x, _) = positions.get("B").unwrap();
+ let (c_x, _) = positions.get("C").unwrap();
+ assert_eq!(*b_x, 350.0);
+ assert_eq!(*c_x, 350.0);
+ }
+}
diff --git a/makima/src/daemon/cli/chain.rs b/makima/src/daemon/cli/chain.rs
new file mode 100644
index 0000000..1d7c167
--- /dev/null
+++ b/makima/src/daemon/cli/chain.rs
@@ -0,0 +1,107 @@
+//! Chain CLI commands for multi-contract orchestration.
+//!
+//! Provides commands for creating, managing, and visualizing chains
+//! (DAGs of contracts).
+
+use clap::Args;
+use std::path::PathBuf;
+use uuid::Uuid;
+
+/// Common arguments for chain commands requiring API access.
+#[derive(Args, Debug, Clone)]
+pub struct ChainArgs {
+ /// API URL
+ #[arg(long, env = "MAKIMA_API_URL", default_value = "https://api.makima.jp", global = true)]
+ pub api_url: String,
+
+ /// API key for authentication
+ #[arg(long, env = "MAKIMA_API_KEY", global = true)]
+ pub api_key: String,
+}
+
+/// Arguments for the `run` command (create chain from YAML file).
+#[derive(Args, Debug)]
+pub struct RunArgs {
+ #[command(flatten)]
+ pub common: ChainArgs,
+
+ /// Path to the chain YAML file
+ pub file: PathBuf,
+
+ /// Don't actually create the chain, just validate and show what would be created
+ #[arg(long)]
+ pub dry_run: bool,
+}
+
+/// Arguments for the `status` command.
+#[derive(Args, Debug)]
+pub struct StatusArgs {
+ #[command(flatten)]
+ pub common: ChainArgs,
+
+ /// Chain ID
+ pub chain_id: Uuid,
+}
+
+/// Arguments for the `list` command.
+#[derive(Args, Debug)]
+pub struct ListArgs {
+ #[command(flatten)]
+ pub common: ChainArgs,
+
+ /// Filter by status (active, completed, archived)
+ #[arg(long)]
+ pub status: Option<String>,
+
+ /// Limit number of results
+ #[arg(long, default_value = "50")]
+ pub limit: i32,
+}
+
+/// Arguments for the `contracts` command.
+#[derive(Args, Debug)]
+pub struct ContractsArgs {
+ #[command(flatten)]
+ pub common: ChainArgs,
+
+ /// Chain ID
+ pub chain_id: Uuid,
+}
+
+/// Arguments for the `graph` command (ASCII DAG visualization).
+#[derive(Args, Debug)]
+pub struct GraphArgs {
+ #[command(flatten)]
+ pub common: ChainArgs,
+
+ /// Chain ID
+ pub chain_id: Uuid,
+
+ /// Show contract status in nodes
+ #[arg(long)]
+ pub with_status: bool,
+}
+
+/// Arguments for the `validate` command.
+#[derive(Args, Debug)]
+pub struct ValidateArgs {
+ /// Path to the chain YAML file
+ pub file: PathBuf,
+}
+
+/// Arguments for the `preview` command.
+#[derive(Args, Debug)]
+pub struct PreviewArgs {
+ /// Path to the chain YAML file
+ pub file: PathBuf,
+}
+
+/// Arguments for the `archive` command.
+#[derive(Args, Debug)]
+pub struct ArchiveArgs {
+ #[command(flatten)]
+ pub common: ChainArgs,
+
+ /// Chain ID
+ pub chain_id: Uuid,
+}
diff --git a/makima/src/daemon/cli/mod.rs b/makima/src/daemon/cli/mod.rs
index 0805edd..035a784 100644
--- a/makima/src/daemon/cli/mod.rs
+++ b/makima/src/daemon/cli/mod.rs
@@ -1,5 +1,6 @@
//! Command-line interface for the makima CLI.
+pub mod chain;
pub mod config;
pub mod contract;
pub mod daemon;
@@ -9,6 +10,7 @@ pub mod view;
use clap::{Parser, Subcommand};
+pub use chain::ChainArgs;
pub use config::CliConfig;
pub use contract::ContractArgs;
pub use daemon::DaemonArgs;
@@ -58,6 +60,14 @@ pub enum Commands {
/// Saves configuration to ~/.makima/config.toml for use by CLI commands.
#[command(subcommand)]
Config(ConfigCommand),
+
+ /// Chain commands for multi-contract orchestration
+ ///
+ /// Chains are DAGs (directed acyclic graphs) of contracts that work together
+ /// to achieve a larger goal. Contracts can depend on each other, and run
+ /// in parallel when no dependencies exist.
+ #[command(subcommand)]
+ Chain(ChainCommand),
}
/// Config subcommands for CLI configuration.
@@ -196,6 +206,48 @@ pub enum ContractCommand {
CreateFile(contract::CreateFileArgs),
}
+/// Chain subcommands for multi-contract orchestration.
+#[derive(Subcommand, Debug)]
+pub enum ChainCommand {
+ /// Create a chain from a YAML file
+ ///
+ /// Parses the chain definition, validates the DAG, and creates
+ /// contracts in the correct dependency order.
+ Run(chain::RunArgs),
+
+ /// Get chain status and progress
+ Status(chain::StatusArgs),
+
+ /// List all chains
+ List(chain::ListArgs),
+
+ /// List contracts in a chain
+ Contracts(chain::ContractsArgs),
+
+ /// Display ASCII DAG visualization
+ ///
+ /// Shows the chain structure as an ASCII graph with
+ /// contracts as nodes and dependencies as edges.
+ Graph(chain::GraphArgs),
+
+ /// Validate a chain YAML file without creating
+ ///
+ /// Checks syntax, validates the DAG (no cycles), and
+ /// reports any errors.
+ Validate(chain::ValidateArgs),
+
+ /// Preview what would be created from a chain file
+ ///
+ /// Shows execution order and contract details without
+ /// actually creating anything.
+ Preview(chain::PreviewArgs),
+
+ /// Archive a chain
+ ///
+ /// Marks the chain as archived. Does not delete contracts.
+ Archive(chain::ArchiveArgs),
+}
+
impl Cli {
/// Parse command-line arguments
pub fn parse_args() -> Self {
diff --git a/makima/src/daemon/mod.rs b/makima/src/daemon/mod.rs
index f5793d6..62da20e 100644
--- a/makima/src/daemon/mod.rs
+++ b/makima/src/daemon/mod.rs
@@ -8,6 +8,7 @@
//! - `makima view` - Interactive TUI browser for tasks, contracts, and files
pub mod api;
+pub mod chain;
pub mod cli;
pub mod config;
pub mod db;
diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs
index cef0a22..45ddb52 100644
--- a/makima/src/db/models.rs
+++ b/makima/src/db/models.rs
@@ -1446,6 +1446,9 @@ pub struct Contract {
/// Use `get_phase_config()` to get the parsed PhaseConfig.
#[serde(skip_serializing_if = "Option::is_none")]
pub phase_config: Option<serde_json::Value>,
+ /// Chain ID if this contract is part of a chain (DAG of contracts)
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub chain_id: Option<Uuid>,
pub version: i32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
@@ -2586,6 +2589,359 @@ pub struct HeartbeatHistoryQuery {
}
// =============================================================================
+// Chains (DAG of contracts for multi-contract orchestration)
+// =============================================================================
+
+/// Chain status determines the overall state of the chain
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
+#[serde(rename_all = "lowercase")]
+pub enum ChainStatus {
+ /// Chain is actively running
+ Active,
+ /// All contracts completed successfully
+ Completed,
+ /// Chain was manually archived
+ Archived,
+}
+
+impl Default for ChainStatus {
+ fn default() -> Self {
+ ChainStatus::Active
+ }
+}
+
+impl std::fmt::Display for ChainStatus {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ ChainStatus::Active => write!(f, "active"),
+ ChainStatus::Completed => write!(f, "completed"),
+ ChainStatus::Archived => write!(f, "archived"),
+ }
+ }
+}
+
+impl std::str::FromStr for ChainStatus {
+ type Err = String;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ match s.to_lowercase().as_str() {
+ "active" => Ok(ChainStatus::Active),
+ "completed" => Ok(ChainStatus::Completed),
+ "archived" => Ok(ChainStatus::Archived),
+ _ => Err(format!("Invalid chain status: {}", s)),
+ }
+ }
+}
+
+/// Chain - a directed acyclic graph (DAG) of contracts
+/// Fits Makima's control theme - she controls through invisible chains
+#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct Chain {
+ pub id: Uuid,
+ pub owner_id: Uuid,
+ pub name: String,
+ pub description: Option<String>,
+ pub status: String,
+ /// Whether loop mode is enabled for iterative execution
+ #[serde(default)]
+ pub loop_enabled: bool,
+ /// Maximum loop iterations (default: 10)
+ pub loop_max_iterations: Option<i32>,
+ /// Current loop iteration count
+ pub loop_current_iteration: Option<i32>,
+ /// Progress check prompt/criteria for evaluating loop completion
+ pub loop_progress_check: Option<String>,
+ /// Repository URL for contracts in this chain (optional)
+ pub repository_url: Option<String>,
+ /// Local path for contracts in this chain (optional)
+ pub local_path: Option<String>,
+ /// Version for optimistic locking
+ pub version: i32,
+ pub created_at: DateTime<Utc>,
+ pub updated_at: DateTime<Utc>,
+}
+
+impl Chain {
+ /// Parse status string to ChainStatus enum
+ pub fn status_enum(&self) -> Result<ChainStatus, String> {
+ self.status.parse()
+ }
+}
+
+/// Chain contract link - links contracts to chains with DAG dependency info
+#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ChainContract {
+ pub id: Uuid,
+ pub chain_id: Uuid,
+ pub contract_id: Uuid,
+ /// Contract IDs this contract depends on (DAG edges)
+ #[sqlx(default)]
+ pub depends_on: Vec<Uuid>,
+ /// Order for display/processing (topological sort order)
+ pub order_index: i32,
+ /// X position for GUI editor
+ pub editor_x: Option<f64>,
+ /// Y position for GUI editor
+ pub editor_y: Option<f64>,
+ pub created_at: DateTime<Utc>,
+}
+
+/// Chain event for audit trail
+#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ChainEvent {
+ pub id: Uuid,
+ pub chain_id: Uuid,
+ pub event_type: String,
+ pub contract_id: Option<Uuid>,
+ #[sqlx(json)]
+ pub event_data: Option<serde_json::Value>,
+ pub created_at: DateTime<Utc>,
+}
+
+/// Summary of a chain for list views
+#[derive(Debug, Clone, FromRow, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ChainSummary {
+ pub id: Uuid,
+ pub name: String,
+ pub description: Option<String>,
+ pub status: String,
+ pub loop_enabled: bool,
+ pub loop_current_iteration: Option<i32>,
+ pub contract_count: i64,
+ pub completed_count: i64,
+ pub version: i32,
+ pub created_at: DateTime<Utc>,
+}
+
+/// Chain with contracts for detail view
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ChainWithContracts {
+ #[serde(flatten)]
+ pub chain: Chain,
+ pub contracts: Vec<ChainContractDetail>,
+}
+
+/// Contract detail within a chain (includes contract info + chain link info)
+#[derive(Debug, Clone, FromRow, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ChainContractDetail {
+ pub chain_contract_id: Uuid,
+ pub contract_id: Uuid,
+ pub contract_name: String,
+ pub contract_status: String,
+ pub contract_phase: String,
+ #[sqlx(default)]
+ pub depends_on: Vec<Uuid>,
+ pub order_index: i32,
+ pub editor_x: Option<f64>,
+ pub editor_y: Option<f64>,
+}
+
+/// DAG graph structure for visualization
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ChainGraphResponse {
+ pub chain_id: Uuid,
+ pub chain_name: String,
+ pub chain_status: String,
+ pub nodes: Vec<ChainGraphNode>,
+ pub edges: Vec<ChainGraphEdge>,
+}
+
+/// Node in chain DAG graph
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ChainGraphNode {
+ pub id: Uuid,
+ pub contract_id: Uuid,
+ pub name: String,
+ pub status: String,
+ pub phase: String,
+ pub x: f64,
+ pub y: f64,
+}
+
+/// Edge in chain DAG graph
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ChainGraphEdge {
+ pub from: Uuid,
+ pub to: Uuid,
+}
+
+/// Response for chain list endpoint
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ChainListResponse {
+ pub chains: Vec<ChainSummary>,
+ pub total: i64,
+}
+
+/// Request payload for creating a new chain
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CreateChainRequest {
+ /// Name of the chain
+ pub name: String,
+ /// Optional description
+ pub description: Option<String>,
+ /// Repository URL for contracts in this chain
+ pub repository_url: Option<String>,
+ /// Local path for contracts in this chain
+ pub local_path: Option<String>,
+ /// Enable loop mode for iterative execution
+ #[serde(default)]
+ pub loop_enabled: Option<bool>,
+ /// Maximum loop iterations (default: 10)
+ pub loop_max_iterations: Option<i32>,
+ /// Progress check prompt for evaluating loop completion
+ pub loop_progress_check: Option<String>,
+ /// Contracts to create within this chain
+ pub contracts: Option<Vec<CreateChainContractRequest>>,
+}
+
+/// Request to create a contract within a chain
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CreateChainContractRequest {
+ /// Name of the contract
+ pub name: String,
+ /// Optional description
+ pub description: Option<String>,
+ /// Contract type
+ #[serde(default)]
+ pub contract_type: Option<String>,
+ /// Initial phase
+ pub initial_phase: Option<String>,
+ /// Phases for the contract
+ pub phases: Option<Vec<String>>,
+ /// Names of contracts this depends on (resolved to IDs)
+ pub depends_on: Option<Vec<String>>,
+ /// Tasks to create in this contract
+ pub tasks: Option<Vec<CreateChainTaskRequest>>,
+ /// Deliverables for this contract
+ pub deliverables: Option<Vec<CreateChainDeliverableRequest>>,
+ /// Position in GUI editor
+ pub editor_x: Option<f64>,
+ pub editor_y: Option<f64>,
+}
+
+/// Task definition within a chain contract
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CreateChainTaskRequest {
+ pub name: String,
+ pub plan: String,
+}
+
+/// Deliverable definition within a chain contract
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CreateChainDeliverableRequest {
+ pub id: String,
+ pub name: String,
+ pub priority: Option<String>,
+}
+
+/// Request to update an existing chain
+#[derive(Debug, Clone, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct UpdateChainRequest {
+ pub name: Option<String>,
+ pub description: Option<String>,
+ pub status: Option<String>,
+ pub loop_enabled: Option<bool>,
+ pub loop_max_iterations: Option<i32>,
+ pub loop_progress_check: Option<String>,
+ /// Version for optimistic locking
+ pub version: Option<i32>,
+}
+
+/// Request to add a contract to a chain
+#[derive(Debug, Clone, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct AddContractToChainRequest {
+ /// Existing contract ID to add
+ pub contract_id: Option<Uuid>,
+ /// Or create a new contract with this definition
+ pub new_contract: Option<CreateChainContractRequest>,
+ /// Contract IDs this depends on
+ pub depends_on: Option<Vec<Uuid>>,
+ /// Position in GUI editor
+ pub editor_x: Option<f64>,
+ pub editor_y: Option<f64>,
+}
+
+/// Editor data model for GUI chain editor
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ChainEditorData {
+ pub id: Option<Uuid>,
+ pub name: String,
+ pub description: Option<String>,
+ pub repository_url: Option<String>,
+ pub local_path: Option<String>,
+ pub loop_enabled: bool,
+ pub loop_max_iterations: Option<i32>,
+ pub loop_progress_check: Option<String>,
+ pub nodes: Vec<ChainEditorNode>,
+ pub edges: Vec<ChainEditorEdge>,
+}
+
+/// Node in chain editor
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ChainEditorNode {
+ pub id: String,
+ pub x: f64,
+ pub y: f64,
+ pub contract: ChainEditorContract,
+}
+
+/// Contract data in chain editor node
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ChainEditorContract {
+ pub name: String,
+ pub description: Option<String>,
+ #[serde(rename = "type")]
+ pub contract_type: String,
+ pub phases: Vec<String>,
+ pub tasks: Vec<ChainEditorTask>,
+ pub deliverables: Vec<ChainEditorDeliverable>,
+}
+
+/// Task in chain editor
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ChainEditorTask {
+ pub name: String,
+ pub plan: String,
+}
+
+/// Deliverable in chain editor
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ChainEditorDeliverable {
+ pub id: String,
+ pub name: String,
+ pub priority: String,
+}
+
+/// Edge in chain editor
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ChainEditorEdge {
+ pub from: String,
+ pub to: String,
+}
+
+// =============================================================================
// Unit Tests
// =============================================================================
diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs
index 2ecbc4a..48b0714 100644
--- a/makima/src/db/repository.rs
+++ b/makima/src/db/repository.rs
@@ -6,15 +6,19 @@ use sqlx::PgPool;
use uuid::Uuid;
use super::models::{
- CheckpointPatch, CheckpointPatchInfo, Contract, ContractChatConversation,
- ContractChatMessageRecord, ContractEvent, ContractRepository, ContractSummary,
- ContractTypeTemplateRecord, ConversationMessage, ConversationSnapshot, CreateContractRequest,
- CreateFileRequest, CreateTaskRequest, CreateTemplateRequest, Daemon, DaemonTaskAssignment,
- DaemonWithCapacity, DeliverableDefinition, File, FileSummary, FileVersion, HistoryEvent,
- HistoryQueryFilters, MeshChatConversation, MeshChatMessageRecord, PhaseChangeResult,
- PhaseConfig, PhaseDefinition, SupervisorHeartbeatRecord, SupervisorState,
- Task, TaskCheckpoint, TaskEvent, TaskSummary, UpdateContractRequest, UpdateFileRequest,
- UpdateTaskRequest, UpdateTemplateRequest,
+ AddContractToChainRequest, Chain, ChainContract, ChainContractDetail, ChainEditorContract,
+ ChainEditorData, ChainEditorDeliverable, ChainEditorEdge, ChainEditorNode, ChainEditorTask,
+ ChainEvent, ChainGraphEdge, ChainGraphNode, ChainGraphResponse, ChainSummary,
+ ChainWithContracts, CheckpointPatch, CheckpointPatchInfo, Contract,
+ ContractChatConversation, ContractChatMessageRecord, ContractEvent, ContractRepository,
+ ContractSummary, ContractTypeTemplateRecord, ConversationMessage, ConversationSnapshot,
+ CreateChainRequest, CreateContractRequest, CreateFileRequest, CreateTaskRequest,
+ CreateTemplateRequest, Daemon, DaemonTaskAssignment, DaemonWithCapacity,
+ DeliverableDefinition, File, FileSummary, FileVersion, HistoryEvent, HistoryQueryFilters,
+ MeshChatConversation, MeshChatMessageRecord, PhaseChangeResult, PhaseConfig, PhaseDefinition,
+ SupervisorHeartbeatRecord, SupervisorState, Task, TaskCheckpoint, TaskEvent, TaskSummary,
+ UpdateChainRequest, UpdateContractRequest, UpdateFileRequest, UpdateTaskRequest,
+ UpdateTemplateRequest,
};
/// Repository error types.
@@ -4896,3 +4900,529 @@ pub async fn sync_supervisor_state(
.fetch_optional(pool)
.await
}
+
+// =============================================================================
+// Chain Operations (DAG of contracts for multi-contract orchestration)
+// =============================================================================
+
+/// Create a new chain for a specific owner.
+pub async fn create_chain_for_owner(
+ pool: &PgPool,
+ owner_id: Uuid,
+ req: CreateChainRequest,
+) -> Result<Chain, sqlx::Error> {
+ let loop_enabled = req.loop_enabled.unwrap_or(false);
+ let loop_max_iterations = req.loop_max_iterations.unwrap_or(10);
+
+ sqlx::query_as::<_, Chain>(
+ r#"
+ INSERT INTO chains (owner_id, name, description, repository_url, local_path, loop_enabled, loop_max_iterations, loop_progress_check)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ RETURNING *
+ "#,
+ )
+ .bind(owner_id)
+ .bind(&req.name)
+ .bind(&req.description)
+ .bind(&req.repository_url)
+ .bind(&req.local_path)
+ .bind(loop_enabled)
+ .bind(loop_max_iterations)
+ .bind(&req.loop_progress_check)
+ .fetch_one(pool)
+ .await
+}
+
+/// Get a chain by ID, scoped to owner.
+pub async fn get_chain_for_owner(
+ pool: &PgPool,
+ id: Uuid,
+ owner_id: Uuid,
+) -> Result<Option<Chain>, sqlx::Error> {
+ sqlx::query_as::<_, Chain>(
+ r#"
+ SELECT *
+ FROM chains
+ WHERE id = $1 AND owner_id = $2
+ "#,
+ )
+ .bind(id)
+ .bind(owner_id)
+ .fetch_optional(pool)
+ .await
+}
+
+/// Get a chain by ID (no owner check - for internal use).
+pub async fn get_chain(pool: &PgPool, id: Uuid) -> Result<Option<Chain>, sqlx::Error> {
+ sqlx::query_as::<_, Chain>(
+ r#"
+ SELECT *
+ FROM chains
+ WHERE id = $1
+ "#,
+ )
+ .bind(id)
+ .fetch_optional(pool)
+ .await
+}
+
+/// List chains for a specific owner.
+pub async fn list_chains_for_owner(
+ pool: &PgPool,
+ owner_id: Uuid,
+) -> Result<Vec<ChainSummary>, sqlx::Error> {
+ sqlx::query_as::<_, ChainSummary>(
+ r#"
+ SELECT
+ c.id,
+ c.name,
+ c.description,
+ c.status,
+ c.loop_enabled,
+ c.loop_current_iteration,
+ COUNT(DISTINCT cc.contract_id) as contract_count,
+ COUNT(DISTINCT CASE WHEN con.status = 'completed' THEN cc.contract_id END) as completed_count,
+ c.version,
+ c.created_at
+ FROM chains c
+ LEFT JOIN chain_contracts cc ON cc.chain_id = c.id
+ LEFT JOIN contracts con ON con.id = cc.contract_id
+ WHERE c.owner_id = $1
+ GROUP BY c.id
+ ORDER BY c.created_at DESC
+ "#,
+ )
+ .bind(owner_id)
+ .fetch_all(pool)
+ .await
+}
+
+/// Update a chain.
+pub async fn update_chain_for_owner(
+ pool: &PgPool,
+ id: Uuid,
+ owner_id: Uuid,
+ req: UpdateChainRequest,
+) -> Result<Chain, RepositoryError> {
+ // First get current version if optimistic locking requested
+ if let Some(expected_version) = req.version {
+ let current: Option<(i32,)> = sqlx::query_as(
+ "SELECT version FROM chains WHERE id = $1 AND owner_id = $2",
+ )
+ .bind(id)
+ .bind(owner_id)
+ .fetch_optional(pool)
+ .await?;
+
+ if let Some((actual_version,)) = current {
+ if actual_version != expected_version {
+ return Err(RepositoryError::VersionConflict {
+ expected: expected_version,
+ actual: actual_version,
+ });
+ }
+ }
+ }
+
+ let result = sqlx::query_as::<_, Chain>(
+ r#"
+ UPDATE chains
+ SET
+ name = COALESCE($3, name),
+ description = COALESCE($4, description),
+ status = COALESCE($5, status),
+ loop_enabled = COALESCE($6, loop_enabled),
+ loop_max_iterations = COALESCE($7, loop_max_iterations),
+ loop_progress_check = COALESCE($8, loop_progress_check),
+ version = version + 1,
+ updated_at = NOW()
+ WHERE id = $1 AND owner_id = $2
+ RETURNING *
+ "#,
+ )
+ .bind(id)
+ .bind(owner_id)
+ .bind(&req.name)
+ .bind(&req.description)
+ .bind(&req.status)
+ .bind(req.loop_enabled)
+ .bind(req.loop_max_iterations)
+ .bind(&req.loop_progress_check)
+ .fetch_one(pool)
+ .await?;
+
+ Ok(result)
+}
+
+/// Delete (archive) a chain.
+pub async fn delete_chain_for_owner(
+ pool: &PgPool,
+ id: Uuid,
+ owner_id: Uuid,
+) -> Result<bool, sqlx::Error> {
+ let result = sqlx::query(
+ r#"
+ UPDATE chains
+ SET status = 'archived', updated_at = NOW()
+ WHERE id = $1 AND owner_id = $2
+ "#,
+ )
+ .bind(id)
+ .bind(owner_id)
+ .execute(pool)
+ .await?;
+
+ Ok(result.rows_affected() > 0)
+}
+
+/// Add a contract to a chain.
+pub async fn add_contract_to_chain(
+ pool: &PgPool,
+ chain_id: Uuid,
+ contract_id: Uuid,
+ depends_on: Vec<Uuid>,
+ order_index: i32,
+ editor_x: Option<f64>,
+ editor_y: Option<f64>,
+) -> Result<ChainContract, sqlx::Error> {
+ // Also update the contract's chain_id
+ sqlx::query("UPDATE contracts SET chain_id = $1 WHERE id = $2")
+ .bind(chain_id)
+ .bind(contract_id)
+ .execute(pool)
+ .await?;
+
+ sqlx::query_as::<_, ChainContract>(
+ r#"
+ INSERT INTO chain_contracts (chain_id, contract_id, depends_on, order_index, editor_x, editor_y)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ ON CONFLICT (chain_id, contract_id) DO UPDATE SET
+ depends_on = EXCLUDED.depends_on,
+ order_index = EXCLUDED.order_index,
+ editor_x = EXCLUDED.editor_x,
+ editor_y = EXCLUDED.editor_y
+ RETURNING *
+ "#,
+ )
+ .bind(chain_id)
+ .bind(contract_id)
+ .bind(&depends_on)
+ .bind(order_index)
+ .bind(editor_x)
+ .bind(editor_y)
+ .fetch_one(pool)
+ .await
+}
+
+/// Remove a contract from a chain.
+pub async fn remove_contract_from_chain(
+ pool: &PgPool,
+ chain_id: Uuid,
+ contract_id: Uuid,
+) -> Result<bool, sqlx::Error> {
+ // Clear the contract's chain_id
+ sqlx::query("UPDATE contracts SET chain_id = NULL WHERE id = $1 AND chain_id = $2")
+ .bind(contract_id)
+ .bind(chain_id)
+ .execute(pool)
+ .await?;
+
+ let result = sqlx::query(
+ r#"
+ DELETE FROM chain_contracts
+ WHERE chain_id = $1 AND contract_id = $2
+ "#,
+ )
+ .bind(chain_id)
+ .bind(contract_id)
+ .execute(pool)
+ .await?;
+
+ Ok(result.rows_affected() > 0)
+}
+
+/// List contracts in a chain with their details.
+pub async fn list_chain_contracts(
+ pool: &PgPool,
+ chain_id: Uuid,
+) -> Result<Vec<ChainContractDetail>, sqlx::Error> {
+ sqlx::query_as::<_, ChainContractDetail>(
+ r#"
+ SELECT
+ cc.id as chain_contract_id,
+ cc.contract_id,
+ c.name as contract_name,
+ c.status as contract_status,
+ c.phase as contract_phase,
+ cc.depends_on,
+ cc.order_index,
+ cc.editor_x,
+ cc.editor_y
+ FROM chain_contracts cc
+ JOIN contracts c ON c.id = cc.contract_id
+ WHERE cc.chain_id = $1
+ ORDER BY cc.order_index ASC
+ "#,
+ )
+ .bind(chain_id)
+ .fetch_all(pool)
+ .await
+}
+
+/// Get chain with all contracts for detail view.
+pub async fn get_chain_with_contracts(
+ pool: &PgPool,
+ chain_id: Uuid,
+ owner_id: Uuid,
+) -> Result<Option<ChainWithContracts>, sqlx::Error> {
+ let chain = get_chain_for_owner(pool, chain_id, owner_id).await?;
+
+ match chain {
+ Some(chain) => {
+ let contracts = list_chain_contracts(pool, chain_id).await?;
+ Ok(Some(ChainWithContracts { chain, contracts }))
+ }
+ None => Ok(None),
+ }
+}
+
+/// Get chain graph structure for visualization.
+pub async fn get_chain_graph(
+ pool: &PgPool,
+ chain_id: Uuid,
+) -> Result<Option<ChainGraphResponse>, sqlx::Error> {
+ let chain = get_chain(pool, chain_id).await?;
+
+ match chain {
+ Some(chain) => {
+ let contracts = list_chain_contracts(pool, chain_id).await?;
+
+ let nodes: Vec<ChainGraphNode> = contracts
+ .iter()
+ .map(|c| ChainGraphNode {
+ id: c.chain_contract_id,
+ contract_id: c.contract_id,
+ name: c.contract_name.clone(),
+ status: c.contract_status.clone(),
+ phase: c.contract_phase.clone(),
+ x: c.editor_x.unwrap_or(0.0),
+ y: c.editor_y.unwrap_or(0.0),
+ })
+ .collect();
+
+ let mut edges: Vec<ChainGraphEdge> = Vec::new();
+ for contract in &contracts {
+ for dep_id in &contract.depends_on {
+ // Find the chain_contract_id for this dependency
+ if let Some(dep) = contracts.iter().find(|c| c.contract_id == *dep_id) {
+ edges.push(ChainGraphEdge {
+ from: dep.chain_contract_id,
+ to: contract.chain_contract_id,
+ });
+ }
+ }
+ }
+
+ Ok(Some(ChainGraphResponse {
+ chain_id: chain.id,
+ chain_name: chain.name,
+ chain_status: chain.status,
+ nodes,
+ edges,
+ }))
+ }
+ None => Ok(None),
+ }
+}
+
+/// Record a chain event.
+pub async fn record_chain_event(
+ pool: &PgPool,
+ chain_id: Uuid,
+ event_type: &str,
+ contract_id: Option<Uuid>,
+ event_data: Option<serde_json::Value>,
+) -> Result<ChainEvent, sqlx::Error> {
+ sqlx::query_as::<_, ChainEvent>(
+ r#"
+ INSERT INTO chain_events (chain_id, event_type, contract_id, event_data)
+ VALUES ($1, $2, $3, $4)
+ RETURNING *
+ "#,
+ )
+ .bind(chain_id)
+ .bind(event_type)
+ .bind(contract_id)
+ .bind(event_data)
+ .fetch_one(pool)
+ .await
+}
+
+/// List chain events.
+pub async fn list_chain_events(
+ pool: &PgPool,
+ chain_id: Uuid,
+) -> Result<Vec<ChainEvent>, sqlx::Error> {
+ sqlx::query_as::<_, ChainEvent>(
+ r#"
+ SELECT *
+ FROM chain_events
+ WHERE chain_id = $1
+ ORDER BY created_at DESC
+ "#,
+ )
+ .bind(chain_id)
+ .fetch_all(pool)
+ .await
+}
+
+/// Increment chain loop iteration.
+pub async fn increment_chain_loop(pool: &PgPool, chain_id: Uuid) -> Result<Chain, sqlx::Error> {
+ sqlx::query_as::<_, Chain>(
+ r#"
+ UPDATE chains
+ SET loop_current_iteration = COALESCE(loop_current_iteration, 0) + 1,
+ updated_at = NOW()
+ WHERE id = $1
+ RETURNING *
+ "#,
+ )
+ .bind(chain_id)
+ .fetch_one(pool)
+ .await
+}
+
+/// Mark a chain as completed.
+pub async fn complete_chain(pool: &PgPool, chain_id: Uuid) -> Result<Chain, sqlx::Error> {
+ sqlx::query_as::<_, Chain>(
+ r#"
+ UPDATE chains
+ SET status = 'completed',
+ updated_at = NOW()
+ WHERE id = $1
+ RETURNING *
+ "#,
+ )
+ .bind(chain_id)
+ .fetch_one(pool)
+ .await
+}
+
+/// Get contracts in a chain that have no pending dependencies (ready to start).
+/// Returns contracts where all depends_on contracts are completed.
+pub async fn get_ready_chain_contracts(
+ pool: &PgPool,
+ chain_id: Uuid,
+) -> Result<Vec<ChainContractDetail>, sqlx::Error> {
+ sqlx::query_as::<_, ChainContractDetail>(
+ r#"
+ SELECT
+ cc.id as chain_contract_id,
+ cc.contract_id,
+ c.name as contract_name,
+ c.status as contract_status,
+ c.phase as contract_phase,
+ cc.depends_on,
+ cc.order_index,
+ cc.editor_x,
+ cc.editor_y
+ FROM chain_contracts cc
+ JOIN contracts c ON c.id = cc.contract_id
+ WHERE cc.chain_id = $1
+ AND c.status = 'active'
+ AND (
+ -- No dependencies
+ cc.depends_on IS NULL
+ OR array_length(cc.depends_on, 1) IS NULL
+ OR array_length(cc.depends_on, 1) = 0
+ -- Or all dependencies completed
+ OR NOT EXISTS (
+ SELECT 1
+ FROM unnest(cc.depends_on) AS dep_id
+ JOIN contracts dep ON dep.id = dep_id
+ WHERE dep.status != 'completed'
+ )
+ )
+ ORDER BY cc.order_index ASC
+ "#,
+ )
+ .bind(chain_id)
+ .fetch_all(pool)
+ .await
+}
+
+/// Check if all contracts in a chain are completed.
+pub async fn is_chain_complete(pool: &PgPool, chain_id: Uuid) -> Result<bool, sqlx::Error> {
+ let result: (i64,) = sqlx::query_as(
+ r#"
+ SELECT COUNT(*)
+ FROM chain_contracts cc
+ JOIN contracts c ON c.id = cc.contract_id
+ WHERE cc.chain_id = $1
+ AND c.status != 'completed'
+ "#,
+ )
+ .bind(chain_id)
+ .fetch_one(pool)
+ .await?;
+
+ Ok(result.0 == 0)
+}
+
+/// Get chain editor data for the GUI editor.
+pub async fn get_chain_editor_data(
+ pool: &PgPool,
+ chain_id: Uuid,
+ owner_id: Uuid,
+) -> Result<Option<ChainEditorData>, sqlx::Error> {
+ let chain = get_chain_for_owner(pool, chain_id, owner_id).await?;
+
+ match chain {
+ Some(chain) => {
+ let contracts = list_chain_contracts(pool, chain_id).await?;
+
+ // Build nodes
+ let nodes: Vec<ChainEditorNode> = contracts
+ .iter()
+ .map(|c| ChainEditorNode {
+ id: c.contract_id.to_string(),
+ x: c.editor_x.unwrap_or(0.0),
+ y: c.editor_y.unwrap_or(0.0),
+ contract: ChainEditorContract {
+ name: c.contract_name.clone(),
+ description: None, // Would need to join with full contract data
+ contract_type: "simple".to_string(),
+ phases: vec!["plan".to_string(), "execute".to_string()],
+ tasks: vec![],
+ deliverables: vec![],
+ },
+ })
+ .collect();
+
+ // Build edges
+ let edges: Vec<ChainEditorEdge> = contracts
+ .iter()
+ .flat_map(|c| {
+ c.depends_on.iter().map(move |dep_id| ChainEditorEdge {
+ from: dep_id.to_string(),
+ to: c.contract_id.to_string(),
+ })
+ })
+ .collect();
+
+ Ok(Some(ChainEditorData {
+ id: Some(chain.id),
+ name: chain.name,
+ description: chain.description,
+ repository_url: chain.repository_url,
+ local_path: chain.local_path,
+ loop_enabled: chain.loop_enabled,
+ loop_max_iterations: chain.loop_max_iterations,
+ loop_progress_check: chain.loop_progress_check,
+ nodes,
+ edges,
+ }))
+ }
+ None => Ok(None),
+ }
+}
diff --git a/makima/src/server/handlers/chains.rs b/makima/src/server/handlers/chains.rs
new file mode 100644
index 0000000..136a868
--- /dev/null
+++ b/makima/src/server/handlers/chains.rs
@@ -0,0 +1,609 @@
+//! HTTP handlers for chain CRUD operations.
+//!
+//! Chains are DAGs (directed acyclic graphs) of contracts for multi-contract orchestration.
+
+use axum::{
+ extract::{Path, Query, State},
+ http::StatusCode,
+ response::IntoResponse,
+ Json,
+};
+use serde::Deserialize;
+use utoipa::ToSchema;
+use uuid::Uuid;
+
+use crate::db::models::{
+ ChainContractDetail, ChainEditorData, ChainEvent, ChainGraphResponse, ChainSummary,
+ ChainWithContracts, CreateChainRequest, UpdateChainRequest,
+};
+use crate::db::repository::{self, RepositoryError};
+use crate::server::auth::Authenticated;
+use crate::server::messages::ApiError;
+use crate::server::state::SharedState;
+
+// =============================================================================
+// Query Parameters
+// =============================================================================
+
+/// Query parameters for listing chains.
+#[derive(Debug, Deserialize, ToSchema)]
+pub struct ListChainsQuery {
+ /// Filter by status (active, completed, archived)
+ pub status: Option<String>,
+ /// Maximum number of results
+ #[serde(default = "default_limit")]
+ pub limit: i32,
+ /// Offset for pagination
+ #[serde(default)]
+ pub offset: i32,
+}
+
+fn default_limit() -> i32 {
+ 50
+}
+
+// =============================================================================
+// Response Types
+// =============================================================================
+
+/// Response for listing chains.
+#[derive(Debug, serde::Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ChainListResponse {
+ pub chains: Vec<ChainSummary>,
+ pub total: i64,
+}
+
+// =============================================================================
+// Handlers
+// =============================================================================
+
+/// List chains for the authenticated user.
+///
+/// GET /api/v1/chains
+#[utoipa::path(
+ get,
+ path = "/api/v1/chains",
+ responses(
+ (status = 200, description = "List of chains", body = ChainListResponse),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError)
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Chains"
+)]
+pub async fn list_chains(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Query(query): Query<ListChainsQuery>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ match repository::list_chains_for_owner(pool, auth.owner_id).await {
+ Ok(mut chains) => {
+ // Apply filters
+ if let Some(status) = &query.status {
+ chains.retain(|c| c.status == *status);
+ }
+ // Apply pagination
+ let total = chains.len() as i64;
+ let chains: Vec<_> = chains
+ .into_iter()
+ .skip(query.offset as usize)
+ .take(query.limit as usize)
+ .collect();
+ Json(ChainListResponse { chains, total }).into_response()
+ }
+ Err(e) => {
+ tracing::error!("Failed to list chains: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Create a new chain with contracts.
+///
+/// POST /api/v1/chains
+#[utoipa::path(
+ post,
+ path = "/api/v1/chains",
+ request_body = CreateChainRequest,
+ responses(
+ (status = 201, description = "Chain created"),
+ (status = 400, description = "Invalid request", body = ApiError),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError)
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Chains"
+)]
+pub async fn create_chain(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Json(req): Json<CreateChainRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Validate the request
+ if req.name.trim().is_empty() {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new("VALIDATION_ERROR", "Chain name cannot be empty")),
+ )
+ .into_response();
+ }
+
+ match repository::create_chain_for_owner(pool, auth.owner_id, req).await {
+ Ok(chain) => (StatusCode::CREATED, Json(chain)).into_response(),
+ Err(e) => {
+ tracing::error!("Failed to create chain: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Get a chain by ID.
+///
+/// GET /api/v1/chains/{id}
+#[utoipa::path(
+ get,
+ path = "/api/v1/chains/{id}",
+ params(
+ ("id" = Uuid, Path, description = "Chain ID")
+ ),
+ responses(
+ (status = 200, description = "Chain with contracts", body = ChainWithContracts),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Chain not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError)
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Chains"
+)]
+pub async fn get_chain(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(chain_id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ match repository::get_chain_with_contracts(pool, chain_id, auth.owner_id).await {
+ Ok(Some(chain)) => Json(chain).into_response(),
+ Ok(None) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Chain not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to get chain: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Update a chain.
+///
+/// PUT /api/v1/chains/{id}
+#[utoipa::path(
+ put,
+ path = "/api/v1/chains/{id}",
+ params(
+ ("id" = Uuid, Path, description = "Chain ID")
+ ),
+ request_body = UpdateChainRequest,
+ responses(
+ (status = 200, description = "Chain updated"),
+ (status = 400, description = "Invalid request", body = ApiError),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Chain not found", body = ApiError),
+ (status = 409, description = "Version conflict", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError)
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Chains"
+)]
+pub async fn update_chain(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(chain_id): Path<Uuid>,
+ Json(req): Json<UpdateChainRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ match repository::update_chain_for_owner(pool, chain_id, auth.owner_id, req).await {
+ Ok(chain) => Json(chain).into_response(),
+ Err(RepositoryError::VersionConflict { expected, actual }) => (
+ StatusCode::CONFLICT,
+ Json(ApiError::new(
+ "VERSION_CONFLICT",
+ format!("Version conflict: expected {}, found {}", expected, actual),
+ )),
+ )
+ .into_response(),
+ Err(RepositoryError::Database(e)) => {
+ // Check if it's a "row not found" error
+ let error_str = e.to_string();
+ if error_str.contains("no rows") || error_str.contains("RowNotFound") {
+ (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Chain not found")),
+ )
+ .into_response()
+ } else {
+ tracing::error!("Failed to update chain: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+ }
+}
+
+/// Delete (archive) a chain.
+///
+/// DELETE /api/v1/chains/{id}
+#[utoipa::path(
+ delete,
+ path = "/api/v1/chains/{id}",
+ params(
+ ("id" = Uuid, Path, description = "Chain ID")
+ ),
+ responses(
+ (status = 200, description = "Chain archived"),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Chain not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError)
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Chains"
+)]
+pub async fn delete_chain(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(chain_id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ match repository::delete_chain_for_owner(pool, chain_id, auth.owner_id).await {
+ Ok(true) => Json(serde_json::json!({"archived": true})).into_response(),
+ Ok(false) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Chain not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to delete chain: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Get contracts in a chain.
+///
+/// GET /api/v1/chains/{id}/contracts
+#[utoipa::path(
+ get,
+ path = "/api/v1/chains/{id}/contracts",
+ params(
+ ("id" = Uuid, Path, description = "Chain ID")
+ ),
+ responses(
+ (status = 200, description = "List of contracts in chain", body = Vec<ChainContractDetail>),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Chain not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError)
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Chains"
+)]
+pub async fn get_chain_contracts(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(chain_id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Verify ownership
+ match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await {
+ Ok(Some(_)) => {}
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Chain not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to verify chain ownership: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ }
+
+ match repository::list_chain_contracts(pool, chain_id).await {
+ Ok(contracts) => Json(contracts).into_response(),
+ Err(e) => {
+ tracing::error!("Failed to list chain contracts: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Get chain DAG structure for visualization.
+///
+/// GET /api/v1/chains/{id}/graph
+#[utoipa::path(
+ get,
+ path = "/api/v1/chains/{id}/graph",
+ params(
+ ("id" = Uuid, Path, description = "Chain ID")
+ ),
+ responses(
+ (status = 200, description = "Chain graph structure", body = ChainGraphResponse),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Chain not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError)
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Chains"
+)]
+pub async fn get_chain_graph(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(chain_id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Verify ownership first
+ match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await {
+ Ok(Some(_)) => {}
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Chain not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to verify chain ownership: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ }
+
+ match repository::get_chain_graph(pool, chain_id).await {
+ Ok(Some(graph)) => Json(graph).into_response(),
+ Ok(None) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Chain not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to get chain graph: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Get chain events.
+///
+/// GET /api/v1/chains/{id}/events
+#[utoipa::path(
+ get,
+ path = "/api/v1/chains/{id}/events",
+ params(
+ ("id" = Uuid, Path, description = "Chain ID")
+ ),
+ responses(
+ (status = 200, description = "Chain events", body = Vec<ChainEvent>),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Chain not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError)
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Chains"
+)]
+pub async fn get_chain_events(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(chain_id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Verify ownership
+ match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await {
+ Ok(Some(_)) => {}
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Chain not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to verify chain ownership: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ }
+
+ match repository::list_chain_events(pool, chain_id).await {
+ Ok(events) => Json(events).into_response(),
+ Err(e) => {
+ tracing::error!("Failed to list chain events: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Get chain editor data.
+///
+/// GET /api/v1/chains/{id}/editor
+#[utoipa::path(
+ get,
+ path = "/api/v1/chains/{id}/editor",
+ params(
+ ("id" = Uuid, Path, description = "Chain ID")
+ ),
+ responses(
+ (status = 200, description = "Chain editor data", body = ChainEditorData),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Chain not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError)
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Chains"
+)]
+pub async fn get_chain_editor(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(chain_id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ match repository::get_chain_editor_data(pool, chain_id, auth.owner_id).await {
+ Ok(Some(editor_data)) => Json(editor_data).into_response(),
+ Ok(None) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Chain not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to get chain editor data: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs
index a14c4f7..3e01a3e 100644
--- a/makima/src/server/handlers/mod.rs
+++ b/makima/src/server/handlers/mod.rs
@@ -1,6 +1,7 @@
//! HTTP and WebSocket request handlers.
pub mod api_keys;
+pub mod chains;
pub mod chat;
pub mod contract_chat;
pub mod contract_daemon;
diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs
index b351ac1..d110c18 100644
--- a/makima/src/server/mod.rs
+++ b/makima/src/server/mod.rs
@@ -18,7 +18,7 @@ use tower_http::trace::TraceLayer;
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
-use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contracts, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, repository_history, speak, templates, transcript_analysis, users, versions};
+use crate::server::handlers::{api_keys, chains, chat, contract_chat, contract_daemon, contracts, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, repository_history, speak, templates, transcript_analysis, users, versions};
use crate::server::openapi::ApiDoc;
use crate::server::state::SharedState;
@@ -213,6 +213,21 @@ pub fn make_router(state: SharedState) -> Router {
)
// Timeline endpoint (unified history for user)
.route("/timeline", get(history::get_timeline))
+ // Chain endpoints (multi-contract orchestration)
+ .route(
+ "/chains",
+ get(chains::list_chains).post(chains::create_chain),
+ )
+ .route(
+ "/chains/{id}",
+ get(chains::get_chain)
+ .put(chains::update_chain)
+ .delete(chains::delete_chain),
+ )
+ .route("/chains/{id}/contracts", get(chains::get_chain_contracts))
+ .route("/chains/{id}/graph", get(chains::get_chain_graph))
+ .route("/chains/{id}/events", get(chains::get_chain_events))
+ .route("/chains/{id}/editor", get(chains::get_chain_editor))
// Contract type templates (built-in only)
.route("/contract-types", get(templates::list_contract_types))
// Settings endpoints