use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::core::consolidation;
use crate::core::ocla::OclaRegistry;
use crate::core::ocla::types::{ConnectorJob, Observation, OclaRequestContext};
use crate::core::providers::config::GitLabConfig;
use crate::core::providers::github::{GitHubConfig, GitHubProvider};
use crate::core::providers::gitlab::GitLabProvider;
use crate::core::providers::provider_trait::{ContextProvider, ProviderParams};
use crate::core::providers::{ProviderResult, registry};
use super::super::team_billing;
use super::TeamAppState;
const MIN_INTERVAL_SECS: u64 = 300;
const DEFAULT_INTERVAL_SECS: u64 = 3_600;
const DEFAULT_LIMIT: usize = 50;
fn http_ocla_context(component: &str) -> OclaRequestContext {
OclaRequestContext::new(
format!("http-{}", uuid_short()),
"http-server".to_string(),
component.to_string(),
String::new(),
None,
None,
)
}
fn uuid_short() -> String {
let mut bytes = [0u8; 8];
getrandom::fill(&mut bytes).unwrap_or_default();
hex::encode(bytes)
}
fn default_interval_secs() -> u64 {
DEFAULT_INTERVAL_SECS
}
fn default_true() -> bool {
true
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConnectorConfig {
pub id: String,
pub provider: String,
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub workspace_id: Option<String>,
pub resource: String,
#[serde(default)]
pub project: Option<String>,
#[serde(default)]
pub host: Option<String>,
#[serde(default)]
pub state: Option<String>,
#[serde(default)]
pub limit: Option<usize>,
#[serde(default = "default_interval_secs")]
pub interval_secs: u64,
#[serde(default)]
pub secret: Option<String>,
#[serde(default = "default_true")]
pub enabled: bool,
}
impl ConnectorConfig {
#[must_use]
pub fn effective_interval(&self) -> u64 {
self.interval_secs.max(MIN_INTERVAL_SECS)
}
fn has_secret(&self) -> bool {
self.secret.as_deref().is_some_and(|s| !s.trim().is_empty())
}
fn limit(&self) -> usize {
self.limit.unwrap_or(DEFAULT_LIMIT)
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConnectorRunState {
pub last_run_at: Option<String>,
#[serde(default)]
pub last_run_secs: Option<u64>,
pub last_status: Option<String>,
pub last_error: Option<String>,
pub last_item_count: Option<usize>,
#[serde(default)]
pub total_runs: u64,
#[serde(default)]
pub total_items: u64,
}
#[must_use]
pub fn is_due(now: u64, last_run: Option<u64>, interval: u64) -> bool {
match last_run {
None => true,
Some(last) => now.saturating_sub(last) >= interval.max(1),
}
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
fn sanitize_id(id: &str) -> String {
id.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect()
}
fn state_path(dir: &Path, id: &str) -> PathBuf {
dir.join(format!("{}.json", sanitize_id(id)))
}
fn load_state(dir: &Path, id: &str) -> ConnectorRunState {
std::fs::read_to_string(state_path(dir, id))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn save_state(dir: &Path, id: &str, st: &ConnectorRunState) {
let _ = std::fs::create_dir_all(dir);
if let Ok(s) = serde_json::to_string_pretty(st) {
let _ = std::fs::write(state_path(dir, id), s);
}
}
fn fetch(cfg: &ConnectorConfig) -> Result<ProviderResult, String> {
let secret = cfg
.secret
.clone()
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| "connector has no credential configured".to_string())?;
let params = ProviderParams {
state: cfg.state.clone(),
limit: Some(cfg.limit()),
..Default::default()
};
match cfg.provider.as_str() {
"gitlab" => {
let gl = GitLabConfig {
host: cfg
.host
.clone()
.filter(|h| !h.trim().is_empty())
.unwrap_or_else(|| "gitlab.com".to_string()),
token: secret,
project_path: cfg.project.clone(),
};
GitLabProvider::with_config(gl).execute(&cfg.resource, ¶ms)
}
"github" => {
let (owner, repo) = split_owner_repo(cfg.project.as_deref());
let gh = GitHubConfig {
token: secret,
owner,
repo,
api_base: cfg
.host
.clone()
.filter(|h| !h.trim().is_empty())
.unwrap_or_else(|| "https://api.github.com".to_string()),
};
GitHubProvider::with_config(gh).execute(&cfg.resource, ¶ms)
}
other => Err(format!(
"unsupported provider '{other}' (expected gitlab|github)"
)),
}
}
fn split_owner_repo(project: Option<&str>) -> (Option<String>, Option<String>) {
match project.and_then(|p| p.split_once('/')) {
Some((o, r)) => (Some(o.to_string()), Some(r.to_string())),
None => (None, None),
}
}
fn run_once(cfg: &ConnectorConfig, workspace_root: &Path) -> Result<usize, String> {
let result = fetch(cfg)?;
let chunks = registry::result_to_chunks(&result);
let n = chunks.len();
if !chunks.is_empty() {
let artifacts = consolidation::consolidate(&chunks);
if !artifacts.is_empty() {
crate::tools::ctx_provider::apply_artifacts_to_stores(
&artifacts,
&workspace_root.to_string_lossy(),
);
}
}
Ok(n)
}
pub fn spawn_scheduler(
connectors: Arc<Vec<ConnectorConfig>>,
roots: Arc<HashMap<String, String>>,
default_workspace_id: String,
state_dir: PathBuf,
data_dir: PathBuf,
quota_bytes: u64,
tick: Duration,
) {
if connectors.iter().all(|c| !c.enabled) {
return;
}
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(5)).await;
loop {
let over_quota = team_billing::is_over_quota(&data_dir, quota_bytes);
for c in connectors.iter().filter(|c| c.enabled) {
let st = load_state(&state_dir, &c.id);
if !is_due(now_secs(), st.last_run_secs, c.effective_interval()) {
continue;
}
if over_quota {
let mut st = st;
st.last_status = Some("error".to_string());
st.last_error = Some("storage quota exceeded — hosted sync paused".to_string());
st.last_run_secs = Some(now_secs());
st.last_run_at = Some(chrono::Utc::now().to_rfc3339());
st.total_runs = st.total_runs.saturating_add(1);
save_state(&state_dir, &c.id, &st);
tracing::warn!(
connector = %c.id,
"skipping connector sync: storage quota exceeded"
);
continue;
}
let ws = c
.workspace_id
.clone()
.unwrap_or_else(|| default_workspace_id.clone());
let Some(root) = roots.get(&ws).cloned() else {
tracing::warn!(
connector = %c.id,
workspace = %ws,
"connector references unknown workspace; skipping"
);
continue;
};
let cfg = c.clone();
let dir = state_dir.clone();
let _ = tokio::task::spawn_blocking(move || {
let started = now_secs();
let mut st = load_state(&dir, &cfg.id);
match run_once(&cfg, Path::new(&root)) {
Ok(n) => {
st.last_status = Some("ok".to_string());
st.last_error = None;
st.last_item_count = Some(n);
st.total_items = st.total_items.saturating_add(n as u64);
tracing::info!(connector = %cfg.id, items = n, "connector sync ok");
let job = ConnectorJob {
context: http_ocla_context("connector-scheduler"),
connector_id: cfg.id.clone(),
payload_ref: format!("{}:{}", cfg.provider, cfg.resource),
deadline_ms: None,
};
if let Err(e) = OclaRegistry::global()
.connector_scheduler
.schedule_connector(job)
{
tracing::debug!("OCLA connector projection: {e}");
}
let observation = Observation {
context: http_ocla_context("connector-sync"),
name: "connector.sync_complete".to_string(),
attributes: BTreeMap::from([
("connector_id".to_string(), cfg.id.clone()),
("provider".to_string(), cfg.provider.clone()),
("items".to_string(), n.to_string()),
]),
};
if let Err(e) = OclaRegistry::global()
.observation_hook
.observe(observation)
{
tracing::debug!("OCLA observation: {e}");
}
}
Err(e) => {
st.last_status = Some("error".to_string());
st.last_error = Some(e.clone());
tracing::warn!(connector = %cfg.id, error = %e, "connector sync failed");
}
}
st.last_run_secs = Some(started);
st.last_run_at = Some(chrono::Utc::now().to_rfc3339());
st.total_runs = st.total_runs.saturating_add(1);
save_state(&dir, &cfg.id, &st);
})
.await;
}
tokio::time::sleep(tick).await;
}
});
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ConnectorView {
id: String,
provider: String,
display_name: Option<String>,
workspace_id: String,
resource: String,
project: Option<String>,
interval_secs: u64,
enabled: bool,
has_secret: bool,
status: ConnectorRunState,
}
pub async fn v1_connectors(State(state): State<TeamAppState>) -> impl IntoResponse {
let default_ws = state.team.engine.server.default_workspace_id.clone();
let dir = state.team.connectors_state_dir.as_ref().clone();
let views: Vec<ConnectorView> = state
.team
.connectors
.iter()
.map(|c| {
let workspace_id = c.workspace_id.clone().unwrap_or_else(|| default_ws.clone());
ConnectorView {
id: c.id.clone(),
provider: c.provider.clone(),
display_name: c.display_name.clone(),
workspace_id,
resource: c.resource.clone(),
project: c.project.clone(),
interval_secs: c.effective_interval(),
enabled: c.enabled,
has_secret: c.has_secret(),
status: load_state(&dir, &c.id),
}
})
.collect();
(
StatusCode::OK,
Json(json!({
"schema_version": 1,
"generated_at": chrono::Utc::now().to_rfc3339(),
"connector_count": views.len(),
"connectors": views,
})),
)
}
#[must_use]
pub fn usage_rollup(connectors: &[ConnectorConfig], state_dir: &Path) -> serde_json::Value {
let mut total_runs = 0u64;
let mut total_items = 0u64;
let mut ok = 0u64;
let mut errored = 0u64;
let mut last_run_at: Option<String> = None;
for c in connectors {
let st = load_state(state_dir, &c.id);
total_runs = total_runs.saturating_add(st.total_runs);
total_items = total_items.saturating_add(st.total_items);
match st.last_status.as_deref() {
Some("ok") => ok += 1,
Some("error") => errored += 1,
_ => {}
}
if let Some(ts) = st.last_run_at
&& last_run_at.as_deref().is_none_or(|cur| ts.as_str() > cur)
{
last_run_at = Some(ts);
}
}
json!({
"configured": connectors.len(),
"enabled": connectors.iter().filter(|c| c.enabled).count(),
"total_runs": total_runs,
"total_items_ingested": total_items,
"last_status_ok": ok,
"last_status_error": errored,
"last_run_at": last_run_at,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_run_is_always_due() {
assert!(is_due(1_000, None, 3_600));
}
#[test]
fn due_only_after_interval_elapses() {
assert!(!is_due(1_100, Some(1_000), 300));
assert!(is_due(1_300, Some(1_000), 300));
assert!(is_due(5_000, Some(1_000), 300));
}
#[test]
fn zero_interval_never_busy_loops() {
assert!(!is_due(1_000, Some(1_000), 0));
assert!(is_due(1_001, Some(1_000), 0));
}
#[test]
fn interval_is_floored_to_minimum() {
let c = ConnectorConfig {
id: "x".into(),
provider: "gitlab".into(),
display_name: None,
workspace_id: None,
resource: "issues".into(),
project: Some("g/p".into()),
host: None,
state: None,
limit: None,
interval_secs: 5,
secret: Some("t".into()),
enabled: true,
};
assert_eq!(c.effective_interval(), MIN_INTERVAL_SECS);
}
#[test]
fn split_owner_repo_parses_slug() {
assert_eq!(
split_owner_repo(Some("octocat/hello")),
(Some("octocat".to_string()), Some("hello".to_string()))
);
assert_eq!(split_owner_repo(Some("noseparator")), (None, None));
assert_eq!(split_owner_repo(None), (None, None));
}
#[test]
fn sanitize_id_blocks_traversal() {
assert_eq!(sanitize_id("../../etc/passwd"), "______etc_passwd");
assert_eq!(sanitize_id("conn-1_ok"), "conn-1_ok");
}
#[test]
fn unsupported_provider_is_rejected() {
let c = ConnectorConfig {
id: "x".into(),
provider: "bitbucket".into(),
display_name: None,
workspace_id: None,
resource: "issues".into(),
project: Some("g/p".into()),
host: None,
state: None,
limit: None,
interval_secs: 3_600,
secret: Some("t".into()),
enabled: true,
};
let err = fetch(&c).unwrap_err();
assert!(err.contains("unsupported provider"));
}
#[test]
fn missing_secret_is_rejected() {
let c = ConnectorConfig {
id: "x".into(),
provider: "gitlab".into(),
display_name: None,
workspace_id: None,
resource: "issues".into(),
project: Some("g/p".into()),
host: None,
state: None,
limit: None,
interval_secs: 3_600,
secret: None,
enabled: true,
};
let err = fetch(&c).unwrap_err();
assert!(err.contains("no credential"));
}
#[tokio::test]
async fn sync_lands_in_searchable_store_end_to_end() {
use axum::Router;
use axum::routing::get;
let issues = serde_json::json!([
{
"number": 1,
"title": "Zephyr crash on cold start",
"state": "open",
"user": { "login": "alice" },
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-02T00:00:00Z",
"html_url": "http://example.test/1",
"labels": [{ "name": "bug" }],
"body": "Service panics in the Zephyr boot path on a cold start."
},
{
"number": 2,
"title": "Add Borealis dashboard",
"state": "open",
"user": { "login": "bob" },
"created_at": "2026-01-03T00:00:00Z",
"updated_at": "2026-01-04T00:00:00Z",
"html_url": "http://example.test/2",
"labels": [],
"body": "A Borealis analytics panel for the team overview."
}
]);
let app = Router::new().route(
"/repos/acme/widgets/issues",
get(move || {
let body = issues.clone();
async move { Json(body) }
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let workspace = tempfile::tempdir().unwrap();
let ws_root = workspace.path().to_path_buf();
let cfg = ConnectorConfig {
id: "gh-e2e".into(),
provider: "github".into(),
display_name: None,
workspace_id: None,
resource: "issues".into(),
project: Some("acme/widgets".into()),
host: Some(format!("http://{addr}")),
state: Some("open".into()),
limit: Some(50),
interval_secs: 3_600,
secret: Some("test-token".into()),
enabled: true,
};
let ws_sync = ws_root.clone();
let ingested = tokio::task::spawn_blocking(move || run_once(&cfg, &ws_sync))
.await
.unwrap()
.expect("sync against the local source must succeed");
assert_eq!(ingested, 2, "both fixture issues must be ingested");
let hits = tokio::task::spawn_blocking(move || {
crate::core::bm25_index::BM25Index::load(&ws_root)
.expect("the sync must persist a BM25 index")
.search("Zephyr", 5)
})
.await
.unwrap();
assert!(
!hits.is_empty(),
"the synced GitHub issue must be findable in the persisted BM25 index"
);
server.abort();
}
}