diff options
| author | soryu <soryu@soryu.co> | 2026-02-05 00:48:38 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-02-05 00:48:38 +0000 |
| commit | 0302b4596e14210884df5d645df9a179d8f0c1c6 (patch) | |
| tree | 46efe027dffa25a30e4eab87fd62de249c3075ad | |
| parent | e16d49b52a393aa9a762edf57f93434a4bd7844e (diff) | |
| download | soryu-0302b4596e14210884df5d645df9a179d8f0c1c6.tar.gz soryu-0302b4596e14210884df5d645df9a179d8f0c1c6.zip | |
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>
| -rw-r--r-- | makima/frontend/src/components/chains/ChainEditor.tsx | 321 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 89 | ||||
| -rw-r--r-- | makima/migrations/20260205000000_chain_repositories.sql | 27 | ||||
| -rw-r--r-- | makima/src/daemon/chain/parser.rs | 29 | ||||
| -rw-r--r-- | makima/src/daemon/chain/runner.rs | 24 | ||||
| -rw-r--r-- | makima/src/db/models.rs | 64 | ||||
| -rw-r--r-- | makima/src/db/repository.rs | 189 | ||||
| -rw-r--r-- | makima/src/server/handlers/chains.rs | 335 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 13 |
9 files changed, 1030 insertions, 61 deletions
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> + ); +} 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<ChainRepository[]> { + 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<ChainRepository> { + 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<ChainRepository> { + 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(); +} diff --git a/makima/migrations/20260205000000_chain_repositories.sql b/makima/migrations/20260205000000_chain_repositories.sql new file mode 100644 index 0000000..5be8cf2 --- /dev/null +++ b/makima/migrations/20260205000000_chain_repositories.sql @@ -0,0 +1,27 @@ +-- Chain repositories - allow chains to have multiple repositories +-- Similar to contract_repositories but for chains +CREATE TABLE IF NOT EXISTS chain_repositories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + chain_id UUID NOT NULL REFERENCES chains(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, -- display name / repo name + repository_url VARCHAR(512), -- NULL for local repos + local_path VARCHAR(512), -- local filesystem path (for local repos) + source_type VARCHAR(32) NOT NULL DEFAULT 'remote', -- remote/local/managed + status VARCHAR(32) NOT NULL DEFAULT 'ready', -- ready/pending/creating/failed + is_primary BOOLEAN NOT NULL DEFAULT false, -- primary repo for contract defaults + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- source_type values: +-- 'remote' = existing remote repo (GitHub, GitLab, etc) - has repository_url +-- 'local' = existing local repo - has local_path +-- 'managed' = new repo created/managed by Makima daemon - gets repository_url after creation + +CREATE INDEX idx_chain_repositories_chain_id ON chain_repositories(chain_id); +-- Only one primary per chain +CREATE UNIQUE INDEX idx_chain_repositories_primary ON chain_repositories(chain_id) WHERE is_primary = true; + +-- Remove the old single repository fields from chains table (they're now in chain_repositories) +ALTER TABLE chains DROP COLUMN IF EXISTS repository_url; +ALTER TABLE chains DROP COLUMN IF EXISTS local_path; diff --git a/makima/src/daemon/chain/parser.rs b/makima/src/daemon/chain/parser.rs index 0f16710..3851d1f 100644 --- a/makima/src/daemon/chain/parser.rs +++ b/makima/src/daemon/chain/parser.rs @@ -20,6 +20,27 @@ pub enum ParseError { ValidationError(String), } +/// Repository definition in a chain. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RepositoryDefinition { + /// Name of the repository + pub name: String, + /// Repository URL (for remote repos) + pub repository_url: Option<String>, + /// Local path (for local repos) + pub local_path: Option<String>, + /// Source type: remote, local, or managed + #[serde(default = "default_source_type")] + pub source_type: String, + /// Whether this is the primary repository + #[serde(default)] + pub is_primary: bool, +} + +fn default_source_type() -> String { + "remote".to_string() +} + /// Chain definition parsed from YAML. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChainDefinition { @@ -27,11 +48,9 @@ pub struct ChainDefinition { 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>, + /// Repositories for this chain + #[serde(default)] + pub repositories: Vec<RepositoryDefinition>, /// Contracts in this chain pub contracts: Vec<ContractDefinition>, /// Loop configuration diff --git a/makima/src/daemon/chain/runner.rs b/makima/src/daemon/chain/runner.rs index 9c6f6b4..dfbcfa7 100644 --- a/makima/src/daemon/chain/runner.rs +++ b/makima/src/daemon/chain/runner.rs @@ -14,8 +14,8 @@ 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, + AddChainRepositoryRequest, CreateChainContractRequest, CreateChainDeliverableRequest, + CreateChainRequest, CreateChainTaskRequest, }; /// Error type for chain runner operations. @@ -100,11 +100,27 @@ impl ChainRunner { None => (None, None, None), }; + // Convert repository definitions to API format + let repositories: Vec<AddChainRepositoryRequest> = chain + .repositories + .iter() + .map(|r| AddChainRepositoryRequest { + name: r.name.clone(), + repository_url: r.repository_url.clone(), + local_path: r.local_path.clone(), + source_type: r.source_type.clone(), + is_primary: r.is_primary, + }) + .collect(); + CreateChainRequest { name: chain.name.clone(), description: chain.description.clone(), - repository_url: chain.repository_url.clone(), - local_path: chain.local_path.clone(), + repositories: if repositories.is_empty() { + None + } else { + Some(repositories) + }, loop_enabled, loop_max_iterations, loop_progress_check, diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index 4e569ec..30e1603 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -2652,16 +2652,40 @@ pub struct Chain { 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>, } +/// Chain repository record from the database +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChainRepository { + pub id: Uuid, + pub chain_id: Uuid, + pub name: String, + pub repository_url: Option<String>, + pub local_path: Option<String>, + pub source_type: String, + pub status: String, + pub is_primary: bool, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, +} + +impl ChainRepository { + /// Parse source_type string to RepositorySourceType enum + pub fn source_type_enum(&self) -> Result<RepositorySourceType, String> { + self.source_type.parse() + } + + /// Parse status string to RepositoryStatus enum + pub fn status_enum(&self) -> Result<RepositoryStatus, String> { + self.status.parse() + } +} + impl Chain { /// Parse status string to ChainStatus enum pub fn status_enum(&self) -> Result<ChainStatus, String> { @@ -2724,6 +2748,7 @@ pub struct ChainWithContracts { #[serde(flatten)] pub chain: Chain, pub contracts: Vec<ChainContractDetail>, + pub repositories: Vec<ChainRepository>, } /// Contract detail within a chain (includes contract info + chain link info) @@ -2790,10 +2815,8 @@ pub struct CreateChainRequest { 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>, + /// Repositories for this chain + pub repositories: Option<Vec<AddChainRepositoryRequest>>, /// Enable loop mode for iterative execution #[serde(default)] pub loop_enabled: Option<bool>, @@ -2805,6 +2828,28 @@ pub struct CreateChainRequest { pub contracts: Option<Vec<CreateChainContractRequest>>, } +/// Request to add a repository to a chain +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AddChainRepositoryRequest { + /// Display name for the repository + pub name: String, + /// Remote repository URL (for remote repos) + pub repository_url: Option<String>, + /// Local filesystem path (for local repos) + pub local_path: Option<String>, + /// Source type: remote, local, or managed + #[serde(default = "default_source_type")] + pub source_type: String, + /// Whether this is the primary repository + #[serde(default)] + pub is_primary: bool, +} + +fn default_source_type() -> String { + "remote".to_string() +} + /// Request to create a contract within a chain #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] @@ -2934,8 +2979,7 @@ 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 repositories: Vec<ChainRepository>, pub loop_enabled: bool, pub loop_max_iterations: Option<i32>, pub loop_progress_check: Option<String>, diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index ec233ba..2b595b5 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -6,20 +6,20 @@ use sqlx::PgPool; use uuid::Uuid; use super::models::{ - AddContractDefinitionRequest, AddContractToChainRequest, Chain, ChainContract, - ChainContractDefinition, ChainContractDetail, ChainDefinitionGraphNode, + AddChainRepositoryRequest, AddContractDefinitionRequest, AddContractToChainRequest, Chain, + ChainContract, ChainContractDefinition, ChainContractDetail, ChainDefinitionGraphNode, ChainDefinitionGraphResponse, ChainEditorContract, ChainEditorData, ChainEditorDeliverable, ChainEditorEdge, ChainEditorNode, ChainEditorTask, ChainEvent, ChainGraphEdge, ChainGraphNode, - ChainGraphResponse, ChainSummary, ChainWithContracts, CheckpointPatch, CheckpointPatchInfo, - Contract, ContractChatConversation, ContractChatMessageRecord, ContractEvent, - ContractRepository, ContractSummary, ContractTypeTemplateRecord, ConversationMessage, - ConversationSnapshot, CreateChainRequest, CreateContractRequest, CreateFileRequest, - CreateTaskRequest, CreateTemplateRequest, Daemon, DaemonTaskAssignment, DaemonWithCapacity, - DeliverableDefinition, File, FileSummary, FileVersion, HistoryEvent, HistoryQueryFilters, - MeshChatConversation, MeshChatMessageRecord, PhaseChangeResult, PhaseConfig, PhaseDefinition, - SupervisorHeartbeatRecord, SupervisorState, Task, TaskCheckpoint, TaskEvent, TaskSummary, - UpdateChainRequest, UpdateContractDefinitionRequest, UpdateContractRequest, UpdateFileRequest, - UpdateTaskRequest, UpdateTemplateRequest, + ChainGraphResponse, ChainRepository, 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, UpdateContractDefinitionRequest, + UpdateContractRequest, UpdateFileRequest, UpdateTaskRequest, UpdateTemplateRequest, }; /// Repository error types. @@ -4917,16 +4917,14 @@ pub async fn create_chain_for_owner( 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) + INSERT INTO chains (owner_id, name, description, loop_enabled, loop_max_iterations, loop_progress_check) + VALUES ($1, $2, $3, $4, $5, $6) 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) @@ -5181,12 +5179,165 @@ pub async fn get_chain_with_contracts( match chain { Some(chain) => { let contracts = list_chain_contracts(pool, chain_id).await?; - Ok(Some(ChainWithContracts { chain, contracts })) + let repositories = list_chain_repositories(pool, chain_id).await?; + Ok(Some(ChainWithContracts { + chain, + contracts, + repositories, + })) } None => Ok(None), } } +// ============================================================================= +// Chain Repository Operations +// ============================================================================= + +/// List all repositories for a chain. +pub async fn list_chain_repositories( + pool: &PgPool, + chain_id: Uuid, +) -> Result<Vec<ChainRepository>, sqlx::Error> { + sqlx::query_as::<_, ChainRepository>( + r#" + SELECT * + FROM chain_repositories + WHERE chain_id = $1 + ORDER BY is_primary DESC, created_at ASC + "#, + ) + .bind(chain_id) + .fetch_all(pool) + .await +} + +/// Get a chain repository by ID. +pub async fn get_chain_repository( + pool: &PgPool, + chain_id: Uuid, + repository_id: Uuid, +) -> Result<Option<ChainRepository>, sqlx::Error> { + sqlx::query_as::<_, ChainRepository>( + r#" + SELECT * + FROM chain_repositories + WHERE id = $1 AND chain_id = $2 + "#, + ) + .bind(repository_id) + .bind(chain_id) + .fetch_optional(pool) + .await +} + +/// Add a repository to a chain. +pub async fn add_chain_repository( + pool: &PgPool, + chain_id: Uuid, + req: &AddChainRepositoryRequest, +) -> Result<ChainRepository, sqlx::Error> { + // If is_primary, clear other primaries first + if req.is_primary { + sqlx::query( + r#" + UPDATE chain_repositories + SET is_primary = false, updated_at = NOW() + WHERE chain_id = $1 AND is_primary = true + "#, + ) + .bind(chain_id) + .execute(pool) + .await?; + } + + sqlx::query_as::<_, ChainRepository>( + r#" + INSERT INTO chain_repositories (chain_id, name, repository_url, local_path, source_type, status, is_primary) + VALUES ($1, $2, $3, $4, $5, 'ready', $6) + RETURNING * + "#, + ) + .bind(chain_id) + .bind(&req.name) + .bind(&req.repository_url) + .bind(&req.local_path) + .bind(&req.source_type) + .bind(req.is_primary) + .fetch_one(pool) + .await +} + +/// Delete a repository from a chain. +pub async fn delete_chain_repository( + pool: &PgPool, + chain_id: Uuid, + repository_id: Uuid, +) -> Result<bool, sqlx::Error> { + let result = sqlx::query( + r#" + DELETE FROM chain_repositories + WHERE id = $1 AND chain_id = $2 + "#, + ) + .bind(repository_id) + .bind(chain_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// Set a repository as primary for a chain. +pub async fn set_chain_repository_primary( + pool: &PgPool, + chain_id: Uuid, + repository_id: Uuid, +) -> Result<ChainRepository, sqlx::Error> { + // Clear existing primary + sqlx::query( + r#" + UPDATE chain_repositories + SET is_primary = false, updated_at = NOW() + WHERE chain_id = $1 AND is_primary = true + "#, + ) + .bind(chain_id) + .execute(pool) + .await?; + + // Set new primary + sqlx::query_as::<_, ChainRepository>( + r#" + UPDATE chain_repositories + SET is_primary = true, updated_at = NOW() + WHERE id = $1 AND chain_id = $2 + RETURNING * + "#, + ) + .bind(repository_id) + .bind(chain_id) + .fetch_one(pool) + .await +} + +/// Get the primary repository for a chain. +pub async fn get_chain_primary_repository( + pool: &PgPool, + chain_id: Uuid, +) -> Result<Option<ChainRepository>, sqlx::Error> { + sqlx::query_as::<_, ChainRepository>( + r#" + SELECT * + FROM chain_repositories + WHERE chain_id = $1 AND is_primary = true + "#, + ) + .bind(chain_id) + .fetch_optional(pool) + .await +} + /// Get chain graph structure for visualization. pub async fn get_chain_graph( pool: &PgPool, @@ -5381,6 +5532,7 @@ pub async fn get_chain_editor_data( match chain { Some(chain) => { let contracts = list_chain_contracts(pool, chain_id).await?; + let repositories = list_chain_repositories(pool, chain_id).await?; // Build nodes let nodes: Vec<ChainEditorNode> = contracts @@ -5415,8 +5567,7 @@ pub async fn get_chain_editor_data( id: Some(chain.id), name: chain.name, description: chain.description, - repository_url: chain.repository_url, - local_path: chain.local_path, + repositories, loop_enabled: chain.loop_enabled, loop_max_iterations: chain.loop_max_iterations, loop_progress_check: chain.loop_progress_check, diff --git a/makima/src/server/handlers/chains.rs b/makima/src/server/handlers/chains.rs index 6cef72c..9b32495 100644 --- a/makima/src/server/handlers/chains.rs +++ b/makima/src/server/handlers/chains.rs @@ -13,10 +13,10 @@ use utoipa::ToSchema; use uuid::Uuid; use crate::db::models::{ - AddContractDefinitionRequest, ChainContractDefinition, ChainContractDetail, - ChainDefinitionGraphResponse, ChainEditorData, ChainEvent, ChainGraphResponse, ChainSummary, - ChainWithContracts, CreateChainRequest, StartChainRequest, StartChainResponse, - UpdateChainRequest, UpdateContractDefinitionRequest, + AddChainRepositoryRequest, AddContractDefinitionRequest, ChainContractDefinition, + ChainContractDetail, ChainDefinitionGraphResponse, ChainEditorData, ChainEvent, + ChainGraphResponse, ChainRepository, ChainSummary, ChainWithContracts, CreateChainRequest, + StartChainRequest, StartChainResponse, UpdateChainRequest, UpdateContractDefinitionRequest, }; use crate::db::repository::{self, RepositoryError}; use crate::server::auth::Authenticated; @@ -1255,3 +1255,330 @@ pub async fn stop_chain( } } } + +// ============================================================================= +// Chain Repository Handlers +// ============================================================================= + +/// List repositories for a chain. +/// +/// GET /api/v1/chains/{id}/repositories +#[utoipa::path( + get, + path = "/api/v1/chains/{id}/repositories", + params( + ("id" = Uuid, Path, description = "Chain ID") + ), + responses( + (status = 200, description = "List of repositories", body = Vec<ChainRepository>), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Chain not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError) + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Chains" +)] +pub async fn list_chain_repositories( + 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_repositories(pool, chain_id).await { + Ok(repos) => Json(repos).into_response(), + Err(e) => { + tracing::error!("Failed to list chain repositories: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Add a repository to a chain. +/// +/// POST /api/v1/chains/{id}/repositories +#[utoipa::path( + post, + path = "/api/v1/chains/{id}/repositories", + params( + ("id" = Uuid, Path, description = "Chain ID") + ), + request_body = AddChainRepositoryRequest, + responses( + (status = 201, description = "Repository added", body = ChainRepository), + (status = 400, description = "Invalid request", body = ApiError), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Chain not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError) + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Chains" +)] +pub async fn add_chain_repository( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(chain_id): Path<Uuid>, + Json(req): Json<AddChainRepositoryRequest>, +) -> 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 request + if req.name.trim().is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(ApiError::new("VALIDATION_ERROR", "Repository name cannot be empty")), + ) + .into_response(); + } + + // Must have either repository_url or local_path + if req.repository_url.is_none() && req.local_path.is_none() { + return ( + StatusCode::BAD_REQUEST, + Json(ApiError::new( + "VALIDATION_ERROR", + "Repository must have either repository_url or local_path", + )), + ) + .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::add_chain_repository(pool, chain_id, &req).await { + Ok(repo) => (StatusCode::CREATED, Json(repo)).into_response(), + Err(e) => { + tracing::error!("Failed to add chain repository: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Delete a repository from a chain. +/// +/// DELETE /api/v1/chains/{chain_id}/repositories/{repository_id} +#[utoipa::path( + delete, + path = "/api/v1/chains/{chain_id}/repositories/{repository_id}", + params( + ("chain_id" = Uuid, Path, description = "Chain ID"), + ("repository_id" = Uuid, Path, description = "Repository ID") + ), + responses( + (status = 200, description = "Repository deleted"), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Chain or repository 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_repository( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((chain_id, repository_id)): Path<(Uuid, Uuid)>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership + match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Chain not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to verify chain ownership: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::delete_chain_repository(pool, chain_id, repository_id).await { + Ok(true) => Json(serde_json::json!({"deleted": true})).into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Repository not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to delete chain repository: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Set a repository as primary for a chain. +/// +/// PUT /api/v1/chains/{chain_id}/repositories/{repository_id}/primary +#[utoipa::path( + put, + path = "/api/v1/chains/{chain_id}/repositories/{repository_id}/primary", + params( + ("chain_id" = Uuid, Path, description = "Chain ID"), + ("repository_id" = Uuid, Path, description = "Repository ID") + ), + responses( + (status = 200, description = "Repository set as primary", body = ChainRepository), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Chain or repository 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 set_chain_repository_primary( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((chain_id, repository_id)): Path<(Uuid, Uuid)>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership + match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Chain not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to verify chain ownership: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + // Verify repository exists for this chain + match repository::get_chain_repository(pool, chain_id, repository_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Repository not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get chain repository: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::set_chain_repository_primary(pool, chain_id, repository_id).await { + Ok(repo) => Json(repo).into_response(), + Err(e) => { + tracing::error!("Failed to set chain repository as primary: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index 5dde099..f6d2eda 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -245,6 +245,19 @@ pub fn make_router(state: SharedState) -> Router { // Chain control .route("/chains/{id}/start", post(chains::start_chain)) .route("/chains/{id}/stop", post(chains::stop_chain)) + // Chain repositories + .route( + "/chains/{id}/repositories", + get(chains::list_chain_repositories).post(chains::add_chain_repository), + ) + .route( + "/chains/{chain_id}/repositories/{repository_id}", + axum::routing::delete(chains::delete_chain_repository), + ) + .route( + "/chains/{chain_id}/repositories/{repository_id}/primary", + put(chains::set_chain_repository_primary), + ) // Contract type templates (built-in only) .route("/contract-types", get(templates::list_contract_types)) // Settings endpoints |
