From 0302b4596e14210884df5d645df9a179d8f0c1c6 Mon Sep 17 00:00:00 2001
From: soryu <soryu@soryu.co>
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 <noreply@anthropic.com>
---
 .../frontend/src/components/chains/ChainEditor.tsx | 321 ++++++++++++++++++++-
 1 file changed, 306 insertions(+), 15 deletions(-)

(limited to 'makima/frontend/src/components/chains/ChainEditor.tsx')

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<string, string> = {
@@ -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<string, any> = {
+  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<string | null>(null);
   const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
+  const [repositories, setRepositories] = useState<ChainRepository[]>([]);
+  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 (
     <div className="panel h-full flex flex-col">
       {/* Header */}
@@ -583,6 +651,67 @@ export function ChainEditor({
         )}
       </div>
 
+      {/* Repository section */}
+      <div className="px-3 py-2 border-b border-[rgba(117,170,252,0.2)] bg-[#0a1628]/50">
+        <div className="flex items-center justify-between">
+          <span className="font-mono text-[10px] text-[#8b949e] uppercase">
+            Repositories ({repositories.length})
+          </span>
+          {canEdit && (
+            <button
+              onClick={() => setShowAddRepo(true)}
+              className="font-mono text-[10px] text-[#9bc3ff] hover:text-[#dbe7ff]"
+            >
+              + Add
+            </button>
+          )}
+        </div>
+        {repositories.length > 0 && (
+          <div className="flex flex-wrap gap-2 mt-2">
+            {repositories.map((repo) => (
+              <div
+                key={repo.id}
+                className={`flex items-center gap-1 px-2 py-1 rounded font-mono text-xs ${
+                  repo.isPrimary
+                    ? "bg-[#75aafc]/20 border border-[#75aafc]/50 text-[#75aafc]"
+                    : "bg-[#1a2744] border border-[rgba(117,170,252,0.2)] text-[#9bc3ff]"
+                }`}
+              >
+                <RepoIcon className="w-3 h-3" />
+                <span className="truncate max-w-[150px]" title={repo.name}>
+                  {repo.name}
+                </span>
+                {repo.isPrimary && (
+                  <span className="text-[8px] uppercase ml-1">primary</span>
+                )}
+                {canEdit && !repo.isPrimary && (
+                  <button
+                    onClick={() => handleSetPrimary(repo.id)}
+                    className="ml-1 text-[8px] text-[#556677] hover:text-[#9bc3ff]"
+                    title="Set as primary"
+                  >
+                    ★
+                  </button>
+                )}
+                {canEdit && (
+                  <button
+                    onClick={() => handleDeleteRepository(repo.id)}
+                    className="ml-1 text-[10px] text-[#556677] hover:text-red-400"
+                  >
+                    ✕
+                  </button>
+                )}
+              </div>
+            ))}
+          </div>
+        )}
+        {repositories.length === 0 && (
+          <p className="font-mono text-[10px] text-[#556677] mt-1">
+            No repositories attached. {canEdit && "Add one to use with contracts."}
+          </p>
+        )}
+      </div>
+
       {/* Main content */}
       <div className="flex-1 flex min-h-0">
         {/* React Flow Canvas */}
@@ -713,6 +842,14 @@ export function ChainEditor({
           onCancel={() => setShowAddDefinition(false)}
         />
       )}
+
+      {/* Add Repository Modal */}
+      {showAddRepo && (
+        <AddRepositoryModal
+          onSubmit={handleAddRepository}
+          onCancel={() => setShowAddRepo(false)}
+        />
+      )}
     </div>
   );
 }
@@ -985,3 +1122,157 @@ function CheckIcon({ className }: { className?: string }) {
     </svg>
   );
 }
+
+function RepoIcon({ className }: { className?: string }) {
+  return (
+    <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+      <path
+        strokeLinecap="round"
+        strokeLinejoin="round"
+        d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
+      />
+    </svg>
+  );
+}
+
+// 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 (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
+      <div className="w-full max-w-lg p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)]">
+        <h3 className="font-mono text-sm text-[#75aafc] uppercase mb-4">Add Repository</h3>
+
+        <div className="space-y-4">
+          {/* Name */}
+          <div>
+            <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1">
+              Name *
+            </label>
+            <input
+              type="text"
+              value={name}
+              onChange={(e) => 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"
+            />
+          </div>
+
+          {/* Source Type */}
+          <div>
+            <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1">
+              Source Type
+            </label>
+            <div className="flex gap-4">
+              <label className="flex items-center gap-2 font-mono text-sm text-[#dbe7ff]">
+                <input
+                  type="radio"
+                  checked={sourceType === "remote"}
+                  onChange={() => setSourceType("remote")}
+                  className="accent-[#75aafc]"
+                />
+                Remote (URL)
+              </label>
+              <label className="flex items-center gap-2 font-mono text-sm text-[#dbe7ff]">
+                <input
+                  type="radio"
+                  checked={sourceType === "local"}
+                  onChange={() => setSourceType("local")}
+                  className="accent-[#75aafc]"
+                />
+                Local Path
+              </label>
+            </div>
+          </div>
+
+          {/* Repository URL or Local Path */}
+          {sourceType === "remote" ? (
+            <div>
+              <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1">
+                Repository URL *
+              </label>
+              <input
+                type="text"
+                value={repositoryUrl}
+                onChange={(e) => 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"
+              />
+            </div>
+          ) : (
+            <div>
+              <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1">
+                Local Path *
+              </label>
+              <input
+                type="text"
+                value={localPath}
+                onChange={(e) => 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"
+              />
+            </div>
+          )}
+
+          {/* Primary checkbox */}
+          <label className="flex items-center gap-2 font-mono text-sm text-[#dbe7ff]">
+            <input
+              type="checkbox"
+              checked={isPrimary}
+              onChange={(e) => setIsPrimary(e.target.checked)}
+              className="accent-[#75aafc]"
+            />
+            Set as primary repository
+          </label>
+        </div>
+
+        {/* Actions */}
+        <div className="flex justify-end gap-2 mt-6">
+          <button
+            onClick={onCancel}
+            className="px-4 py-2 font-mono text-xs text-[#8b949e] border border-[rgba(117,170,252,0.3)] hover:border-[#75aafc] transition-colors"
+          >
+            Cancel
+          </button>
+          <button
+            onClick={handleSubmit}
+            disabled={
+              !name.trim() ||
+              (sourceType === "remote" && !repositoryUrl.trim()) ||
+              (sourceType === "local" && !localPath.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"
+          >
+            Add Repository
+          </button>
+        </div>
+      </div>
+    </div>
+  );
+}
-- 
cgit v1.2.3

