diff options
Diffstat (limited to 'makima')
| -rw-r--r-- | makima/frontend/src/routes/chains.tsx | 231 | ||||
| -rw-r--r-- | makima/src/db/models.rs | 2 | ||||
| -rw-r--r-- | makima/src/db/repository.rs | 42 |
3 files changed, 265 insertions, 10 deletions
diff --git a/makima/frontend/src/routes/chains.tsx b/makima/frontend/src/routes/chains.tsx index 23484b4..9b33304 100644 --- a/makima/frontend/src/routes/chains.tsx +++ b/makima/frontend/src/routes/chains.tsx @@ -10,7 +10,10 @@ import type { ChainWithContracts, ChainGraphResponse, CreateChainRequest, + AddChainRepositoryRequest, + RepositoryHistoryEntry, } from "../lib/api"; +import { getRepositorySuggestions } from "../lib/api"; export default function ChainsPage() { const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); @@ -92,10 +95,11 @@ function ChainsPageContent() { }, []); const handleCreateSubmit = useCallback( - async (name: string, description: string) => { + async (name: string, description: string, repositories: AddChainRepositoryRequest[]) => { const data: CreateChainRequest = { name: name.trim(), description: description.trim() || undefined, + repositories: repositories.length > 0 ? repositories : undefined, }; try { @@ -203,23 +207,89 @@ function ChainsPageContent() { } interface CreateChainModalProps { - onSubmit: (name: string, description: string) => void; + onSubmit: (name: string, description: string, repositories: AddChainRepositoryRequest[]) => void; onCancel: () => void; } +type RepoMode = "remote" | "local" | null; + function CreateChainModal({ onSubmit, onCancel }: CreateChainModalProps) { const [name, setName] = useState(""); const [description, setDescription] = useState(""); + const [repositories, setRepositories] = useState<AddChainRepositoryRequest[]>([]); + + // Repository input state + const [repoMode, setRepoMode] = useState<RepoMode>(null); + const [repoName, setRepoName] = useState(""); + const [repoUrl, setRepoUrl] = useState(""); + const [repoPath, setRepoPath] = useState(""); + + // Suggestions + const [suggestions, setSuggestions] = useState<RepositoryHistoryEntry[]>([]); + const [showSuggestions, setShowSuggestions] = useState(false); + + // Load suggestions when mode changes + useEffect(() => { + if (repoMode) { + getRepositorySuggestions(repoMode, undefined, 10) + .then((res) => { + setSuggestions(res.entries); + setShowSuggestions(res.entries.length > 0); + }) + .catch(() => { + setSuggestions([]); + setShowSuggestions(false); + }); + } else { + setSuggestions([]); + setShowSuggestions(false); + } + }, [repoMode]); + + const applySuggestion = (suggestion: RepositoryHistoryEntry) => { + setRepoName(suggestion.name); + if (suggestion.repositoryUrl) setRepoUrl(suggestion.repositoryUrl); + if (suggestion.localPath) setRepoPath(suggestion.localPath); + setShowSuggestions(false); + }; + + const handleAddRepo = () => { + if (!repoName.trim()) return; + if (repoMode === "remote" && !repoUrl.trim()) return; + if (repoMode === "local" && !repoPath.trim()) return; + + const newRepo: AddChainRepositoryRequest = { + name: repoName.trim(), + sourceType: repoMode || "remote", + isPrimary: repositories.length === 0, // First one is primary + ...(repoMode === "remote" ? { repositoryUrl: repoUrl.trim() } : { localPath: repoPath.trim() }), + }; + + setRepositories([...repositories, newRepo]); + setRepoMode(null); + setRepoName(""); + setRepoUrl(""); + setRepoPath(""); + }; + + const handleRemoveRepo = (index: number) => { + const newRepos = repositories.filter((_, i) => i !== index); + // If we removed the primary, make the first one primary + if (newRepos.length > 0 && repositories[index]?.isPrimary) { + newRepos[0].isPrimary = true; + } + setRepositories(newRepos); + }; const handleSubmit = () => { if (name.trim()) { - onSubmit(name.trim(), description.trim()); + onSubmit(name.trim(), description.trim(), repositories); } }; 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)]"> + <div className="w-full max-w-lg p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] max-h-[90vh] overflow-y-auto"> <h3 className="font-mono text-sm text-[#75aafc] uppercase mb-4"> Create Chain </h3> @@ -228,7 +298,7 @@ function CreateChainModal({ onSubmit, onCancel }: CreateChainModalProps) { {/* Chain name */} <div> <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> - Chain Name + Chain Name * </label> <input type="text" @@ -249,15 +319,158 @@ function CreateChainModal({ onSubmit, onCancel }: CreateChainModalProps) { value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Describe what this chain accomplishes..." - rows={3} + rows={2} className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc] resize-none" /> </div> + {/* Repositories */} + <div> + <label className="block font-mono text-xs text-[#8b949e] uppercase mb-2"> + Repositories + </label> + + {/* Added repositories */} + {repositories.length > 0 && ( + <div className="space-y-2 mb-3"> + {repositories.map((repo, index) => ( + <div + key={index} + className="flex items-center gap-2 px-3 py-2 bg-[#0d1b2d] border border-[rgba(117,170,252,0.2)]" + > + <span className="font-mono text-[10px] text-[#556677] uppercase"> + {repo.sourceType === "remote" ? "URL" : "Local"} + </span> + <span className="font-mono text-xs text-[#dbe7ff] flex-1 truncate"> + {repo.name} + </span> + {repo.isPrimary && ( + <span className="font-mono text-[8px] text-[#75aafc] uppercase px-1 border border-[#75aafc]/30"> + primary + </span> + )} + <button + onClick={() => handleRemoveRepo(index)} + className="font-mono text-xs text-[#556677] hover:text-red-400" + > + ✕ + </button> + </div> + ))} + </div> + )} + + {/* Add repository form */} + {repoMode ? ( + <div className="p-3 border border-[rgba(117,170,252,0.3)] space-y-3"> + <div className="flex items-center justify-between"> + <span className="font-mono text-[10px] text-[#75aafc] uppercase"> + Add {repoMode === "remote" ? "Remote" : "Local"} Repository + </span> + {suggestions.length > 0 && ( + <button + onClick={() => setShowSuggestions(!showSuggestions)} + className="font-mono text-[10px] text-[#556677] hover:text-[#9bc3ff]" + > + {showSuggestions ? "Hide" : `${suggestions.length} suggestions`} + </button> + )} + </div> + + {/* Suggestions dropdown */} + {showSuggestions && suggestions.length > 0 && ( + <div className="border border-[rgba(117,170,252,0.2)] bg-[#0a1525] max-h-28 overflow-y-auto"> + {suggestions.map((s) => ( + <button + key={s.id} + onClick={() => applySuggestion(s)} + className="w-full text-left px-3 py-2 font-mono text-xs hover:bg-[rgba(117,170,252,0.1)] border-b border-[rgba(117,170,252,0.1)] last:border-b-0" + > + <div className="flex items-center justify-between"> + <span className="text-[#9bc3ff] truncate">{s.name}</span> + <span className="text-[10px] text-[#556677]">{s.useCount}×</span> + </div> + <div className="text-[10px] text-[#556677] truncate"> + {repoMode === "local" ? s.localPath : s.repositoryUrl} + </div> + </button> + ))} + </div> + )} + + <input + type="text" + value={repoName} + onChange={(e) => setRepoName(e.target.value)} + placeholder="Repository name" + className="w-full px-3 py-2 bg-[#050d18] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-xs focus:outline-none focus:border-[#75aafc]" + /> + + {repoMode === "remote" ? ( + <input + type="text" + value={repoUrl} + onChange={(e) => setRepoUrl(e.target.value)} + placeholder="https://github.com/owner/repo" + className="w-full px-3 py-2 bg-[#050d18] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-xs focus:outline-none focus:border-[#75aafc]" + /> + ) : ( + <input + type="text" + value={repoPath} + onChange={(e) => setRepoPath(e.target.value)} + placeholder="/path/to/repository" + className="w-full px-3 py-2 bg-[#050d18] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-xs focus:outline-none focus:border-[#75aafc]" + /> + )} + + <div className="flex gap-2"> + <button + onClick={() => setRepoMode(null)} + className="px-3 py-1.5 font-mono text-xs text-[#556677] hover:text-[#9bc3ff]" + > + Cancel + </button> + <button + onClick={handleAddRepo} + disabled={ + !repoName.trim() || + (repoMode === "remote" && !repoUrl.trim()) || + (repoMode === "local" && !repoPath.trim()) + } + className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] disabled:opacity-50" + > + Add + </button> + </div> + </div> + ) : ( + <div className="flex gap-2"> + <button + onClick={() => setRepoMode("remote")} + className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3]" + > + + Remote + </button> + <button + onClick={() => setRepoMode("local")} + className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3]" + > + + Local + </button> + </div> + )} + + {repositories.length === 0 && !repoMode && ( + <p className="font-mono text-[10px] text-[#556677] mt-2"> + Add repositories that contracts in this chain will work with + </p> + )} + </div> + <p className="font-mono text-xs text-[#8b949e]"> - A chain links multiple contracts together in a directed acyclic graph (DAG). - Contracts can depend on each other, and dependent contracts start automatically - when their dependencies complete. + A chain links multiple contracts together in a DAG. Contracts depend on each + other and start automatically when dependencies complete. </p> {/* Actions */} diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index 392d019..e861f1d 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -2825,6 +2825,8 @@ pub struct ChainContractDetail { /// Maximum evaluation retry attempts #[sqlx(default)] pub max_evaluation_retries: i32, + /// When the chain contract was created + pub created_at: DateTime<Utc>, } /// DAG graph structure for visualization diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index 9cb653f..7be7bc8 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -5162,7 +5162,8 @@ pub async fn list_chain_contracts( cc.editor_y, cc.evaluation_status, cc.evaluation_retry_count, - cc.max_evaluation_retries + cc.max_evaluation_retries, + cc.created_at FROM chain_contracts cc JOIN contracts c ON c.id = cc.contract_id WHERE cc.chain_id = $1 @@ -6266,6 +6267,45 @@ async fn create_contract_from_definition( .execute(pool) .await?; + // Copy repositories from chain to contract + let chain_repos = list_chain_repositories(pool, chain_id).await.unwrap_or_default(); + for repo in chain_repos { + if let Some(url) = &repo.repository_url { + // Remote repository + if let Err(e) = add_remote_repository(pool, contract.id, &repo.name, url, repo.is_primary).await { + tracing::warn!( + contract_id = %contract.id, + repo_name = %repo.name, + error = %e, + "Failed to copy repository from chain to contract" + ); + } + } else if let Some(path) = &repo.local_path { + // Local repository + if let Err(e) = add_local_repository(pool, contract.id, &repo.name, path, repo.is_primary).await { + tracing::warn!( + contract_id = %contract.id, + repo_name = %repo.name, + error = %e, + "Failed to copy local repository from chain to contract" + ); + } + } + } + + // Activate the contract so it can start + sqlx::query("UPDATE contracts SET status = 'active' WHERE id = $1") + .bind(contract.id) + .execute(pool) + .await?; + + tracing::info!( + contract_id = %contract.id, + contract_name = %def.name, + chain_id = %chain_id, + "Contract created and activated from chain definition" + ); + Ok(contract.id) } |
