Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions migrations/20260312000001_registry.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
-- Dynamic project registry: auto-discovered GitHub repositories.
-- Tracks repos from `gh repo list` with per-repo config overrides.

CREATE TABLE IF NOT EXISTS registry_repos (
id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL,
owner TEXT NOT NULL,
name TEXT NOT NULL,
full_name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
default_branch TEXT NOT NULL DEFAULT 'main',
is_archived INTEGER NOT NULL DEFAULT 0,
is_fork INTEGER NOT NULL DEFAULT 0,
visibility TEXT NOT NULL DEFAULT 'private',
language TEXT,
local_path TEXT,
clone_url TEXT NOT NULL,
ssh_url TEXT NOT NULL DEFAULT '',
-- Per-repo overrides (NULL = inherit from agent defaults)
worker_model TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
-- Linkage to existing projects table
project_id TEXT,
-- Sync metadata
last_synced_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE SET NULL
);

CREATE UNIQUE INDEX IF NOT EXISTS idx_registry_repos_full_name
ON registry_repos(agent_id, full_name);
CREATE INDEX IF NOT EXISTS idx_registry_repos_agent
ON registry_repos(agent_id);
CREATE INDEX IF NOT EXISTS idx_registry_repos_enabled
ON registry_repos(agent_id, enabled);
68 changes: 64 additions & 4 deletions src/agent/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2051,11 +2051,12 @@ impl Channel {
}
};

if projects.is_empty() {
return None;
}
let mut contexts = Vec::new();

// Collect project_ids that are linked to registry repos so we can
// avoid duplicating them when we append unlinked registry repos below.
let mut linked_project_ids = std::collections::HashSet::new();

let mut contexts = Vec::with_capacity(projects.len());
for project in &projects {
let repos = match store.list_repos(&project.id).await {
Ok(repos) => repos,
Expand Down Expand Up @@ -2105,6 +2106,65 @@ impl Channel {
})
.collect(),
});
linked_project_ids.insert(project.id.clone());
}

// Append enabled registry repos that are NOT already linked to a project,
// so the agent knows about all dynamically discovered repos.
if let Ok(registry_repos) = self
.deps
.registry_store
.list_repos(&self.deps.agent_id, true)
.await
{
for reg_repo in registry_repos {
// Skip repos already represented via a linked project.
if reg_repo
.project_id
.as_ref()
.is_some_and(|pid| linked_project_ids.contains(pid))
{
continue;
}
// Skip archived repos.
if reg_repo.is_archived {
continue;
}
let root_path = reg_repo
.local_path
.clone()
.unwrap_or_else(|| format!("(not cloned) {}", reg_repo.clone_url));
let mut desc_parts = Vec::new();
if let Some(ref lang) = reg_repo.language {
desc_parts.push(lang.clone());
}
if let Some(ref model) = reg_repo.worker_model {
desc_parts.push(format!("model: {model}"));
}
contexts.push(ProjectContext {
name: reg_repo.full_name.clone(),
root_path,
description: if desc_parts.is_empty() {
None
} else {
Some(desc_parts.join(", "))
},
tags: Vec::new(),
repos: vec![ProjectRepoContext {
name: reg_repo.name.clone(),
path: reg_repo
.local_path
.unwrap_or_else(|| reg_repo.clone_url.clone()),
default_branch: reg_repo.default_branch.clone(),
remote_url: Some(reg_repo.clone_url),
}],
worktrees: Vec::new(),
});
}
}

if contexts.is_empty() {
return None;
}

match prompt_engine.render_projects_context(contexts) {
Expand Down
1 change: 1 addition & 0 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ mod models;
mod opencode_proxy;
mod projects;
mod providers;
mod registry;
mod secrets;
mod server;
mod settings;
Expand Down
6 changes: 6 additions & 0 deletions src/api/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,8 @@ pub(super) async fn trigger_warmup(
let (event_tx, memory_event_tx) = crate::create_process_event_buses();
let project_store =
std::sync::Arc::new(crate::projects::ProjectStore::new(sqlite_pool.clone()));
let registry_store =
std::sync::Arc::new(crate::registry::RegistryStore::new(sqlite_pool.clone()));
let deps = crate::AgentDeps {
agent_id: Arc::from(agent_id.as_str()),
memory_search,
Expand All @@ -431,6 +433,7 @@ pub(super) async fn trigger_warmup(
sandbox,
task_store,
project_store,
registry_store,
links: Arc::new(arc_swap::ArcSwap::from_pointee(Vec::new())),
agent_names: Arc::new(std::collections::HashMap::new()),
humans: Arc::new(arc_swap::ArcSwap::from_pointee(humans)),
Expand Down Expand Up @@ -760,6 +763,8 @@ pub async fn create_agent_internal(
);

let project_store = std::sync::Arc::new(crate::projects::ProjectStore::new(db.sqlite.clone()));
let registry_store =
std::sync::Arc::new(crate::registry::RegistryStore::new(db.sqlite.clone()));

// Inject active project root paths into the sandbox allowlist.
crate::projects::refresh_sandbox_project_paths(&project_store, &arc_agent_id, &sandbox).await;
Expand All @@ -771,6 +776,7 @@ pub async fn create_agent_internal(
mcp_manager: mcp_manager.clone(),
task_store: task_store.clone(),
project_store: project_store.clone(),
registry_store: registry_store.clone(),
cron_tool: None,
runtime_config: runtime_config.clone(),
event_tx: event_tx.clone(),
Expand Down
175 changes: 175 additions & 0 deletions src/api/registry.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
//! REST API handlers for the dynamic project registry.

use super::state::ApiState;

use axum::Json;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use serde::{Deserialize, Serialize};
use std::sync::Arc;

use crate::registry::store::RegistryRepo;
use crate::registry::sync::{SyncResult, SyncStatus, sync_registry};

// ---------------------------------------------------------------------------
// Query / request types
// ---------------------------------------------------------------------------

#[derive(Deserialize)]
pub(super) struct AgentQuery {
agent_id: String,
}

#[derive(Deserialize)]
pub(super) struct RepoListQuery {
agent_id: String,
#[serde(default)]
enabled_only: bool,
}

#[derive(Deserialize)]
pub(super) struct RepoQuery {
agent_id: String,
full_name: String,
}

#[derive(Deserialize)]
pub(super) struct UpdateRepoOverridesBody {
agent_id: String,
full_name: String,
/// Set to `Some(Some("model"))` to set, `Some(None)` to clear, `None` to leave unchanged.
worker_model: Option<Option<String>>,
enabled: Option<bool>,
}

// ---------------------------------------------------------------------------
// Response types
// ---------------------------------------------------------------------------

#[derive(Serialize)]
pub(super) struct RepoListResponse {
repos: Vec<RegistryRepo>,
total: usize,
}

#[derive(Serialize)]
pub(super) struct SyncResponse {
result: SyncResult,
}

#[derive(Serialize)]
pub(super) struct StatusResponse {
status: SyncStatus,
}

// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------

/// GET /api/registry/repos — list all registry repos for an agent.
pub(super) async fn list_registry_repos(
State(state): State<Arc<ApiState>>,
Query(query): Query<RepoListQuery>,
) -> Result<Json<RepoListResponse>, StatusCode> {
let stores = state.registry_stores.load();
let store = stores.get(&query.agent_id).ok_or(StatusCode::NOT_FOUND)?;

let repos = store
.list_repos(&query.agent_id, query.enabled_only)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

let total = repos.len();
Ok(Json(RepoListResponse { repos, total }))
}

/// GET /api/registry/repos/detail — get a single repo by full_name.
pub(super) async fn get_registry_repo(
State(state): State<Arc<ApiState>>,
Query(query): Query<RepoQuery>,
) -> Result<Json<RegistryRepo>, StatusCode> {
let stores = state.registry_stores.load();
let store = stores.get(&query.agent_id).ok_or(StatusCode::NOT_FOUND)?;

let repo = store
.get_by_full_name(&query.agent_id, &query.full_name)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;

Ok(Json(repo))
}

/// PUT /api/registry/repos/overrides — update per-repo overrides.
pub(super) async fn update_repo_overrides(
State(state): State<Arc<ApiState>>,
Json(body): Json<UpdateRepoOverridesBody>,
) -> Result<Json<RegistryRepo>, StatusCode> {
let stores = state.registry_stores.load();
let store = stores.get(&body.agent_id).ok_or(StatusCode::NOT_FOUND)?;

let repo = store
.set_overrides(
&body.agent_id,
&body.full_name,
body.worker_model,
body.enabled,
)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;

Ok(Json(repo))
}

/// POST /api/registry/sync — trigger a manual sync.
pub(super) async fn trigger_sync(
State(state): State<Arc<ApiState>>,
Query(query): Query<AgentQuery>,
) -> Result<Json<SyncResponse>, StatusCode> {
let stores = state.registry_stores.load();
let store = stores.get(&query.agent_id).ok_or(StatusCode::NOT_FOUND)?;

let configs = state.runtime_configs.load();
let runtime_config = configs.get(&query.agent_id).ok_or(StatusCode::NOT_FOUND)?;

let registry_config = runtime_config.registry.load();

if !registry_config.enabled {
return Err(StatusCode::BAD_REQUEST);
}

// Update sync status
let status_map = state.registry_sync_status.load();
if let Some(status) = status_map.get(&query.agent_id) {
status.store(Arc::new(SyncStatus::Syncing));
}

let result = sync_registry(store, &query.agent_id, &registry_config)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

// Update sync status
if let Some(status) = status_map.get(&query.agent_id) {
status.store(Arc::new(SyncStatus::Completed {
at: chrono::Utc::now().to_rfc3339(),
result: result.clone(),
}));
}

Ok(Json(SyncResponse { result }))
}

/// GET /api/registry/status — get current sync status.
pub(super) async fn registry_status(
State(state): State<Arc<ApiState>>,
Query(query): Query<AgentQuery>,
) -> Result<Json<StatusResponse>, StatusCode> {
let status_map = state.registry_sync_status.load();
let status = status_map
.get(&query.agent_id)
.ok_or(StatusCode::NOT_FOUND)?;

let current = status.load().as_ref().clone();
Ok(Json(StatusResponse { status: current }))
}
13 changes: 11 additions & 2 deletions src/api/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
use super::state::ApiState;
use super::{
agents, bindings, channels, config, cortex, cron, factory, ingest, links, mcp, memories,
messaging, models, opencode_proxy, projects, providers, secrets, settings, skills, ssh, system,
tasks, tools, webchat, workers,
messaging, models, opencode_proxy, projects, providers, registry, secrets, settings, skills,
ssh, system, tasks, tools, webchat, workers,
};

use axum::Json;
Expand Down Expand Up @@ -202,6 +202,15 @@ pub async fn start_http_server(
.route("/agents/skills/upload", post(skills::upload_skill))
.route("/agents/skills/remove", delete(skills::remove_skill))
.route("/agents/tools", get(tools::list_tools))
// Registry: dynamic project discovery
.route("/registry/repos", get(registry::list_registry_repos))
.route("/registry/repos/detail", get(registry::get_registry_repo))
.route(
"/registry/repos/overrides",
put(registry::update_repo_overrides),
)
.route("/registry/sync", post(registry::trigger_sync))
.route("/registry/status", get(registry::registry_status))
// Secret store management
.route("/secrets/status", get(secrets::secrets_status))
.route("/secrets", get(secrets::list_secrets))
Expand Down
Loading
Loading