From cf0a25af1d2834bfe6c5ea892ce5769936e5a673 Mon Sep 17 00:00:00 2001 From: soryu Date: Tue, 3 Feb 2026 22:01:29 +0000 Subject: Add makima chain mechanism --- Cargo.lock | 20 + makima/Cargo.toml | 1 + makima/frontend/src/components/NavStrip.tsx | 1 + .../frontend/src/components/chains/ChainEditor.tsx | 468 ++++++++++++++++ .../frontend/src/components/chains/ChainList.tsx | 205 +++++++ makima/frontend/src/hooks/useChains.ts | 145 +++++ makima/frontend/src/lib/api.ts | 236 ++++++++ makima/frontend/src/main.tsx | 17 + makima/frontend/src/routes/chains.tsx | 283 ++++++++++ makima/frontend/tsconfig.tsbuildinfo | 2 +- makima/migrations/20260203000000_create_chains.sql | 60 ++ makima/src/bin/makima.rs | 216 +++++++- makima/src/daemon/api/chain.rs | 52 ++ makima/src/daemon/api/client.rs | 42 ++ makima/src/daemon/api/mod.rs | 1 + makima/src/daemon/chain/dag.rs | 450 +++++++++++++++ makima/src/daemon/chain/mod.rs | 13 + makima/src/daemon/chain/parser.rs | 392 +++++++++++++ makima/src/daemon/chain/runner.rs | 364 ++++++++++++ makima/src/daemon/cli/chain.rs | 107 ++++ makima/src/daemon/cli/mod.rs | 52 ++ makima/src/daemon/mod.rs | 1 + makima/src/db/models.rs | 356 ++++++++++++ makima/src/db/repository.rs | 548 +++++++++++++++++- makima/src/server/handlers/chains.rs | 609 +++++++++++++++++++++ makima/src/server/handlers/mod.rs | 1 + makima/src/server/mod.rs | 17 +- 27 files changed, 4647 insertions(+), 12 deletions(-) create mode 100644 makima/frontend/src/components/chains/ChainEditor.tsx create mode 100644 makima/frontend/src/components/chains/ChainList.tsx create mode 100644 makima/frontend/src/hooks/useChains.ts create mode 100644 makima/frontend/src/routes/chains.tsx create mode 100644 makima/migrations/20260203000000_create_chains.sql create mode 100644 makima/src/daemon/api/chain.rs create mode 100644 makima/src/daemon/chain/dag.rs create mode 100644 makima/src/daemon/chain/mod.rs create mode 100644 makima/src/daemon/chain/parser.rs create mode 100644 makima/src/daemon/chain/runner.rs create mode 100644 makima/src/daemon/cli/chain.rs create mode 100644 makima/src/server/handlers/chains.rs diff --git a/Cargo.lock b/Cargo.lock index 1aeb184..f19e460 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2032,6 +2032,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "serde_yaml", "sha2", "shell-escape", "sqlx", @@ -3275,6 +3276,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serial" version = "0.4.0" @@ -4606,6 +4620,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" 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(null); + const [selectedNode, setSelectedNode] = useState(null); + const [hoveredNode, setHoveredNode] = useState(null); + + // Use positions from graph nodes directly (x, y from server) + const nodePositions = useMemo(() => { + if (!graph?.nodes) return new Map(); + + const positions = new Map(); + 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 ( +
+ {/* Header */} +
+
+
+ +
+

{chain.chain.name}

+ {chain.chain.description && ( +

{chain.chain.description}

+ )} +
+
+
+ + {chain.chain.status} + + +
+
+
+ + {/* Main content */} +
+ {/* DAG Canvas */} +
+ {loading ? ( +
+

Loading graph...

+
+ ) : !graph || graph.nodes.length === 0 ? ( +
+
+

+ No contracts in this chain yet +

+

+ Contracts will appear here once added via CLI or API +

+
+
+ ) : ( +
+ {/* SVG layer for edges */} + + + + + + + {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 ( + + ); + })} + + + {/* 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 ( +
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)", + }} + > +
+ {/* Status indicator bar */} +
+ {/* Content */} +
+
+ + {node.name} + + +
+
+ + {node.status} + + {node.phase && ( + + {node.phase} + + )} +
+
+
+
+ ); + })} +
+ )} +
+ + {/* Detail panel */} + {selectedContract && ( + setSelectedNode(null)} + onSelectContract={setSelectedNode} + onOpenContract={onContractClick} + /> + )} +
+ + {/* Footer with stats */} +
+
+ {chain.contracts.length} contracts + + {chain.contracts.filter((c) => c.contractStatus === "completed").length} completed + + + {chain.contracts.filter((c) => c.contractStatus === "active").length} active + + + Double-click node to open contract +
+
+
+ ); +} + +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 ( +
+
+
+

+ Contract Details +

+ +
+
+ +
+ {/* Name */} +
+ +

+ {contract.contractName} +

+
+ + {/* Status */} +
+ + + {contract.contractStatus} + +
+ + {/* Phase */} +
+ + + {contract.contractPhase} + +
+ + {/* Dependencies */} + {contract.dependsOn && contract.dependsOn.length > 0 && ( +
+ +
+ {contract.dependsOn.map((depId) => { + const dep = allContracts.find((c) => c.contractId === depId); + return ( + + ); + })} +
+
+ )} + + {/* Order Index */} +
+ +

+ {contract.orderIndex} +

+
+ + {/* Created */} +
+ +

+ {new Date(contract.createdAt).toLocaleString()} +

+
+ + {/* Actions */} +
+ +
+
+
+ ); +} + +function ChainIcon({ className }: { className?: string }) { + return ( + + + + + ); +} 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("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 ( + + + + ); + case "completed": + return ( + + + + ); + case "archived": + return ( + + + + + + ); + default: + return null; + } + }; + + return ( +
+ {/* Header */} +
+
+

Chains

+ +
+ + {/* Status filter */} +
+ {(["all", "active", "completed", "archived"] as const).map((status) => ( + + ))} +
+
+ + {/* Chain list */} +
+ {loading ? ( +
+

Loading chains...

+
+ ) : filteredChains.length === 0 ? ( +
+

+ {statusFilter === "all" ? "No chains yet" : `No ${statusFilter} chains`} +

+
+ ) : ( +
+ {filteredChains.map((chain) => ( +
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)]" + }`} + > +
+ + {chain.name} + + + {getStatusIcon(chain.status)} + {chain.status} + +
+ {chain.description && ( +

+ {chain.description} +

+ )} +
+ {chain.contractCount} contracts + + {new Date(chain.updatedAt).toLocaleDateString()} + +
+
+ ))} +
+ )} +
+ + {/* Context menu */} + {contextMenu && ( +
+ + {contextMenu.chain.status !== "archived" && ( + + )} +
+ )} +
+ ); +} 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; + createNewChain: (req: CreateChainRequest) => Promise; + updateExistingChain: ( + chainId: string, + req: UpdateChainRequest + ) => Promise; + archiveExistingChain: (chainId: string) => Promise; + getChainById: (chainId: string) => Promise; + getGraph: (chainId: string) => Promise; +} + +export function useChains(statusFilter?: ChainStatus): UseChainsResult { + const [chains, setChains] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 | 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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"; @@ -71,6 +72,22 @@ createRoot(document.getElementById("root")!).render( } /> + + + + } + /> + + + + } + /> { + if (!authLoading && isAuthConfigured && !isAuthenticated) { + navigate("/login"); + } + }, [authLoading, isAuthConfigured, isAuthenticated, navigate]); + + // Show loading while checking auth + if (authLoading) { + return ( +
+ +
+

Loading...

+
+
+ ); + } + + // Don't render if not authenticated (will redirect) + if (isAuthConfigured && !isAuthenticated) { + return null; + } + + return ; +} + +function ChainsPageContent() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { + chains, + loading, + error, + createNewChain, + archiveExistingChain, + getChainById, + getGraph, + } = useChains(); + + const [chainDetail, setChainDetail] = useState(null); + const [chainGraph, setChainGraph] = useState(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 ( +
+ +
+ {error && ( +
+ {error} +
+ )} + + {/* Create chain modal */} + {isCreating && ( + + )} + +
+ {/* Chain list */} + + + {/* Chain detail/editor or empty state */} + {chainDetail ? ( + + ) : ( +
+
+

+ Select a chain or create a new one +

+ +
+
+ )} +
+
+
+ ); +} + +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 ( +
+
+

+ Create Chain +

+ +
+ {/* Chain name */} +
+ + 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 + /> +
+ + {/* Description */} +
+ +