From 0302b4596e14210884df5d645df9a179d8f0c1c6 Mon Sep 17 00:00:00 2001 From: soryu Date: Thu, 5 Feb 2026 00:48:38 +0000 Subject: Add multi-repository support for chains Chains can now have multiple repositories attached, with one marked as primary. Repositories are used by contracts created from chain definitions. Backend changes: - Add chain_repositories table migration - Add ChainRepository model with CRUD operations - Add API endpoints for listing, adding, deleting repositories - Add endpoint to set a repository as primary - Update Chain and ChainEditorData models to use repositories - Update chain parser to support repositories in YAML format - Remove deprecated repository_url/local_path from Chain Frontend changes: - Add ChainRepository interface and API functions - Add repository section to ChainEditor showing attached repos - Add modal for adding new repositories (remote or local) - Support setting primary repository and removing repositories Co-Authored-By: Claude Opus 4.5 --- .../frontend/src/components/chains/ChainEditor.tsx | 321 ++++++++++++++++++++- makima/frontend/src/lib/api.ts | 89 +++++- 2 files changed, 391 insertions(+), 19 deletions(-) (limited to 'makima/frontend/src') diff --git a/makima/frontend/src/components/chains/ChainEditor.tsx b/makima/frontend/src/components/chains/ChainEditor.tsx index 5b77170..6b9aa70 100644 --- a/makima/frontend/src/components/chains/ChainEditor.tsx +++ b/makima/frontend/src/components/chains/ChainEditor.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect, useMemo } from "react"; +import { useState, useCallback, useEffect } from "react"; import { ReactFlow, Node, @@ -7,9 +7,7 @@ import { Background, useNodesState, useEdgesState, - addEdge, Connection, - NodeProps, Handle, Position, BackgroundVariant, @@ -24,6 +22,8 @@ import type { ChainContractDefinition, ChainDefinitionGraphResponse, AddContractDefinitionRequest, + ChainRepository, + AddChainRepositoryRequest, } from "../../lib/api"; import { listChainDefinitions, @@ -33,6 +33,10 @@ import { getChainDefinitionGraph, startChain, stopChain, + listChainRepositories, + addChainRepository, + deleteChainRepository, + setChainRepositoryPrimary, } from "../../lib/api"; const statusColors: Record = { @@ -58,7 +62,14 @@ const GRID_SPACING_X = 280; const GRID_SPACING_Y = 120; // Custom node component for definitions -function DefinitionNode({ data, selected }: NodeProps) { +function DefinitionNodeComponent({ + data, + selected, +}: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: any; + selected?: boolean; +}) { const isCheckpoint = data.contractType === "checkpoint"; const status = data.isInstantiated ? data.contractStatus || "pending" : "pending"; const colors = getStatusColor(status, isCheckpoint); @@ -130,7 +141,14 @@ function DefinitionNode({ data, selected }: NodeProps) { } // Custom node for contracts (active chains) -function ContractNode({ data, selected }: NodeProps) { +function ContractNodeComponent({ + data, + selected, +}: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: any; + selected?: boolean; +}) { const colors = getStatusColor(data.status); return ( @@ -184,9 +202,10 @@ function ContractNode({ data, selected }: NodeProps) { ); } -const nodeTypes = { - definition: DefinitionNode, - contract: ContractNode, +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const nodeTypes: Record = { + definition: DefinitionNodeComponent, + contract: ContractNodeComponent, }; function getStatusColor(status: string, isCheckpoint = false) { @@ -233,28 +252,32 @@ export function ChainEditor({ const [isStopping, setIsStopping] = useState(false); const [error, setError] = useState(null); const [selectedNodeId, setSelectedNodeId] = useState(null); + const [repositories, setRepositories] = useState([]); + const [showAddRepo, setShowAddRepo] = useState(false); - const [nodes, setNodes, onNodesChange] = useNodesState([]); - const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [nodes, setNodes, onNodesChange] = useNodesState([] as Node[]); + const [edges, setEdges, onEdgesChange] = useEdgesState([] as Edge[]); const showDefinitions = chain.status === "pending" || chain.status === "archived"; const canEdit = chain.status === "pending"; - // Load definitions when chain changes + // Load definitions and repositories when chain changes useEffect(() => { - async function loadDefinitions() { + async function loadData() { try { - const [defs, defGraph] = await Promise.all([ + const [defs, defGraph, repos] = await Promise.all([ listChainDefinitions(chain.id), getChainDefinitionGraph(chain.id), + listChainRepositories(chain.id), ]); setDefinitions(defs); setDefinitionGraph(defGraph); + setRepositories(repos); } catch (err) { - console.error("Failed to load definitions:", err); + console.error("Failed to load data:", err); } } - loadDefinitions(); + loadData(); }, [chain.id]); // Convert definitions/contracts to React Flow nodes and edges @@ -523,6 +546,51 @@ export function ChainEditor({ [chain.id, definitions] ); + // Repository handlers + const handleAddRepository = useCallback( + async (req: AddChainRepositoryRequest) => { + try { + await addChainRepository(chain.id, req); + const repos = await listChainRepositories(chain.id); + setRepositories(repos); + setShowAddRepo(false); + } catch (err) { + console.error("Failed to add repository:", err); + setError(err instanceof Error ? err.message : "Failed to add repository"); + } + }, + [chain.id] + ); + + const handleDeleteRepository = useCallback( + async (repoId: string) => { + if (!confirm("Remove this repository from the chain?")) return; + try { + await deleteChainRepository(chain.id, repoId); + const repos = await listChainRepositories(chain.id); + setRepositories(repos); + } catch (err) { + console.error("Failed to delete repository:", err); + setError(err instanceof Error ? err.message : "Failed to delete repository"); + } + }, + [chain.id] + ); + + const handleSetPrimary = useCallback( + async (repoId: string) => { + try { + await setChainRepositoryPrimary(chain.id, repoId); + const repos = await listChainRepositories(chain.id); + setRepositories(repos); + } catch (err) { + console.error("Failed to set primary:", err); + setError(err instanceof Error ? err.message : "Failed to set primary repository"); + } + }, + [chain.id] + ); + return (
{/* Header */} @@ -583,6 +651,67 @@ export function ChainEditor({ )}
+ {/* Repository section */} +
+
+ + Repositories ({repositories.length}) + + {canEdit && ( + + )} +
+ {repositories.length > 0 && ( +
+ {repositories.map((repo) => ( +
+ + + {repo.name} + + {repo.isPrimary && ( + primary + )} + {canEdit && !repo.isPrimary && ( + + )} + {canEdit && ( + + )} +
+ ))} +
+ )} + {repositories.length === 0 && ( +

+ No repositories attached. {canEdit && "Add one to use with contracts."} +

+ )} +
+ {/* Main content */}
{/* React Flow Canvas */} @@ -713,6 +842,14 @@ export function ChainEditor({ onCancel={() => setShowAddDefinition(false)} /> )} + + {/* Add Repository Modal */} + {showAddRepo && ( + setShowAddRepo(false)} + /> + )}
); } @@ -985,3 +1122,157 @@ function CheckIcon({ className }: { className?: string }) { ); } + +function RepoIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +// Add Repository Modal +interface AddRepositoryModalProps { + onSubmit: (req: AddChainRepositoryRequest) => void; + onCancel: () => void; +} + +function AddRepositoryModal({ onSubmit, onCancel }: AddRepositoryModalProps) { + const [name, setName] = useState(""); + const [repositoryUrl, setRepositoryUrl] = useState(""); + const [localPath, setLocalPath] = useState(""); + const [sourceType, setSourceType] = useState<"remote" | "local">("remote"); + const [isPrimary, setIsPrimary] = useState(false); + + const handleSubmit = () => { + if (!name.trim()) return; + if (sourceType === "remote" && !repositoryUrl.trim()) return; + if (sourceType === "local" && !localPath.trim()) return; + + const req: AddChainRepositoryRequest = { + name: name.trim(), + sourceType, + isPrimary, + ...(sourceType === "remote" + ? { repositoryUrl: repositoryUrl.trim() } + : { localPath: localPath.trim() }), + }; + onSubmit(req); + }; + + return ( +
+
+

Add Repository

+ +
+ {/* Name */} +
+ + setName(e.target.value)} + className="w-full px-3 py-2 bg-[#050d18] border border-[rgba(117,170,252,0.3)] font-mono text-sm text-[#dbe7ff] focus:border-[#75aafc] focus:outline-none" + placeholder="e.g., Main Repository" + /> +
+ + {/* Source Type */} +
+ +
+ + +
+
+ + {/* Repository URL or Local Path */} + {sourceType === "remote" ? ( +
+ + setRepositoryUrl(e.target.value)} + className="w-full px-3 py-2 bg-[#050d18] border border-[rgba(117,170,252,0.3)] font-mono text-sm text-[#dbe7ff] focus:border-[#75aafc] focus:outline-none" + placeholder="https://github.com/user/repo" + /> +
+ ) : ( +
+ + setLocalPath(e.target.value)} + className="w-full px-3 py-2 bg-[#050d18] border border-[rgba(117,170,252,0.3)] font-mono text-sm text-[#dbe7ff] focus:border-[#75aafc] focus:outline-none" + placeholder="/path/to/local/repo" + /> +
+ )} + + {/* Primary checkbox */} + +
+ + {/* Actions */} +
+ + +
+
+
+ ); +} diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index d68c1ad..2f4ee62 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -3025,6 +3025,20 @@ export interface ChainSummary { updatedAt: string; } +/** Chain repository */ +export interface ChainRepository { + id: string; + chainId: string; + name: string; + repositoryUrl: string | null; + localPath: string | null; + sourceType: string; + status: string; + isPrimary: boolean; + createdAt: string; + updatedAt: string; +} + /** Full chain with contracts */ export interface Chain { id: string; @@ -3036,8 +3050,6 @@ export interface Chain { loopMaxIterations: number | null; loopCurrentIteration: number | null; loopProgressCheck: string | null; - repositoryUrl: string | null; - localPath: string | null; version: number; createdAt: string; updatedAt: string; @@ -3061,6 +3073,7 @@ export interface ChainContractDetail { /** Chain with contracts (chain fields are flattened via serde(flatten)) */ export interface ChainWithContracts extends Chain { contracts: ChainContractDetail[]; + repositories: ChainRepository[]; } /** Node in chain graph visualization */ @@ -3105,12 +3118,20 @@ export interface ChainListResponse { total: number; } +/** Add chain repository request */ +export interface AddChainRepositoryRequest { + name: string; + repositoryUrl?: string; + localPath?: string; + sourceType?: string; + isPrimary?: boolean; +} + /** Create chain request */ export interface CreateChainRequest { name: string; description?: string; - repositoryUrl?: string; - localPath?: string; + repositories?: AddChainRepositoryRequest[]; loopEnabled?: boolean; loopMaxIterations?: number; loopProgressCheck?: string; @@ -3446,3 +3467,63 @@ export async function stopChain(chainId: string): Promise<{ stopped: boolean; st } return res.json(); } + +// ============================================================================ +// Chain Repository Operations +// ============================================================================ + +/** List repositories for a chain */ +export async function listChainRepositories(chainId: string): Promise { + const res = await authFetch(`${API_BASE}/api/v1/chains/${chainId}/repositories`); + if (!res.ok) { + throw new Error(`Failed to list chain repositories: ${res.statusText}`); + } + return res.json(); +} + +/** Add a repository to a chain */ +export async function addChainRepository( + chainId: string, + req: AddChainRepositoryRequest +): Promise { + const res = await authFetch(`${API_BASE}/api/v1/chains/${chainId}/repositories`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(req), + }); + if (!res.ok) { + const error = await res.json().catch(() => ({ message: res.statusText })); + throw new Error(error.message || `Failed to add chain repository: ${res.statusText}`); + } + return res.json(); +} + +/** Delete a repository from a chain */ +export async function deleteChainRepository( + chainId: string, + repositoryId: string +): Promise<{ deleted: boolean }> { + const res = await authFetch( + `${API_BASE}/api/v1/chains/${chainId}/repositories/${repositoryId}`, + { method: "DELETE" } + ); + if (!res.ok) { + throw new Error(`Failed to delete chain repository: ${res.statusText}`); + } + return res.json(); +} + +/** Set a repository as primary for a chain */ +export async function setChainRepositoryPrimary( + chainId: string, + repositoryId: string +): Promise { + const res = await authFetch( + `${API_BASE}/api/v1/chains/${chainId}/repositories/${repositoryId}/primary`, + { method: "PUT" } + ); + if (!res.ok) { + throw new Error(`Failed to set chain repository as primary: ${res.statusText}`); + } + return res.json(); +} -- cgit v1.2.3