summaryrefslogtreecommitdiff
path: root/makima
diff options
context:
space:
mode:
Diffstat (limited to 'makima')
-rw-r--r--makima/frontend/src/components/chains/ChainEditor.tsx321
-rw-r--r--makima/frontend/src/lib/api.ts89
-rw-r--r--makima/migrations/20260205000000_chain_repositories.sql27
-rw-r--r--makima/src/daemon/chain/parser.rs29
-rw-r--r--makima/src/daemon/chain/runner.rs24
-rw-r--r--makima/src/db/models.rs64
-rw-r--r--makima/src/db/repository.rs189
-rw-r--r--makima/src/server/handlers/chains.rs335
-rw-r--r--makima/src/server/mod.rs13
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