1use std::collections::HashMap;
19use std::path::{Path, PathBuf};
20use std::sync::Arc;
21use std::time::{Duration, SystemTime, UNIX_EPOCH};
22
23use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
24use serde::{Deserialize, Serialize};
25use serde_json::json;
26
27use crate::core::consolidation;
28use crate::core::providers::config::GitLabConfig;
29use crate::core::providers::github::{GitHubConfig, GitHubProvider};
30use crate::core::providers::gitlab::GitLabProvider;
31use crate::core::providers::provider_trait::{ContextProvider, ProviderParams};
32use crate::core::providers::{ProviderResult, registry};
33
34use super::super::team_billing;
35use super::TeamAppState;
36
37const MIN_INTERVAL_SECS: u64 = 300;
39const DEFAULT_INTERVAL_SECS: u64 = 3_600;
41const DEFAULT_LIMIT: usize = 50;
43
44fn default_interval_secs() -> u64 {
45 DEFAULT_INTERVAL_SECS
46}
47fn default_true() -> bool {
48 true
49}
50
51#[derive(Clone, Debug, Serialize, Deserialize)]
53#[serde(rename_all = "camelCase")]
54pub struct ConnectorConfig {
55 pub id: String,
57 pub provider: String,
59 #[serde(default)]
60 pub display_name: Option<String>,
61 #[serde(default)]
63 pub workspace_id: Option<String>,
64 pub resource: String,
67 #[serde(default)]
69 pub project: Option<String>,
70 #[serde(default)]
73 pub host: Option<String>,
74 #[serde(default)]
76 pub state: Option<String>,
77 #[serde(default)]
79 pub limit: Option<usize>,
80 #[serde(default = "default_interval_secs")]
82 pub interval_secs: u64,
83 #[serde(default)]
85 pub secret: Option<String>,
86 #[serde(default = "default_true")]
87 pub enabled: bool,
88}
89
90impl ConnectorConfig {
91 #[must_use]
93 pub fn effective_interval(&self) -> u64 {
94 self.interval_secs.max(MIN_INTERVAL_SECS)
95 }
96
97 fn has_secret(&self) -> bool {
98 self.secret.as_deref().is_some_and(|s| !s.trim().is_empty())
99 }
100
101 fn limit(&self) -> usize {
102 self.limit.unwrap_or(DEFAULT_LIMIT)
103 }
104}
105
106#[derive(Clone, Debug, Default, Serialize, Deserialize)]
109#[serde(rename_all = "camelCase")]
110pub struct ConnectorRunState {
111 pub last_run_at: Option<String>,
113 #[serde(default)]
115 pub last_run_secs: Option<u64>,
116 pub last_status: Option<String>,
118 pub last_error: Option<String>,
119 pub last_item_count: Option<usize>,
120 #[serde(default)]
121 pub total_runs: u64,
122 #[serde(default)]
123 pub total_items: u64,
124}
125
126#[must_use]
132pub fn is_due(now: u64, last_run: Option<u64>, interval: u64) -> bool {
133 match last_run {
134 None => true,
135 Some(last) => now.saturating_sub(last) >= interval.max(1),
136 }
137}
138
139fn now_secs() -> u64 {
140 SystemTime::now()
141 .duration_since(UNIX_EPOCH)
142 .map_or(0, |d| d.as_secs())
143}
144
145fn sanitize_id(id: &str) -> String {
148 id.chars()
149 .map(|c| {
150 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
151 c
152 } else {
153 '_'
154 }
155 })
156 .collect()
157}
158
159fn state_path(dir: &Path, id: &str) -> PathBuf {
160 dir.join(format!("{}.json", sanitize_id(id)))
161}
162
163fn load_state(dir: &Path, id: &str) -> ConnectorRunState {
164 std::fs::read_to_string(state_path(dir, id))
165 .ok()
166 .and_then(|s| serde_json::from_str(&s).ok())
167 .unwrap_or_default()
168}
169
170fn save_state(dir: &Path, id: &str, st: &ConnectorRunState) {
171 let _ = std::fs::create_dir_all(dir);
172 if let Ok(s) = serde_json::to_string_pretty(st) {
173 let _ = std::fs::write(state_path(dir, id), s);
174 }
175}
176
177fn fetch(cfg: &ConnectorConfig) -> Result<ProviderResult, String> {
180 let secret = cfg
181 .secret
182 .clone()
183 .filter(|s| !s.trim().is_empty())
184 .ok_or_else(|| "connector has no credential configured".to_string())?;
185 let params = ProviderParams {
186 state: cfg.state.clone(),
187 limit: Some(cfg.limit()),
188 ..Default::default()
189 };
190
191 match cfg.provider.as_str() {
192 "gitlab" => {
193 let gl = GitLabConfig {
194 host: cfg
195 .host
196 .clone()
197 .filter(|h| !h.trim().is_empty())
198 .unwrap_or_else(|| "gitlab.com".to_string()),
199 token: secret,
200 project_path: cfg.project.clone(),
201 };
202 GitLabProvider::with_config(gl).execute(&cfg.resource, ¶ms)
203 }
204 "github" => {
205 let (owner, repo) = split_owner_repo(cfg.project.as_deref());
206 let gh = GitHubConfig {
207 token: secret,
208 owner,
209 repo,
210 api_base: cfg
211 .host
212 .clone()
213 .filter(|h| !h.trim().is_empty())
214 .unwrap_or_else(|| "https://api.github.com".to_string()),
215 };
216 GitHubProvider::with_config(gh).execute(&cfg.resource, ¶ms)
217 }
218 other => Err(format!(
219 "unsupported provider '{other}' (expected gitlab|github)"
220 )),
221 }
222}
223
224fn split_owner_repo(project: Option<&str>) -> (Option<String>, Option<String>) {
225 match project.and_then(|p| p.split_once('/')) {
226 Some((o, r)) => (Some(o.to_string()), Some(r.to_string())),
227 None => (None, None),
228 }
229}
230
231fn run_once(cfg: &ConnectorConfig, workspace_root: &Path) -> Result<usize, String> {
234 let result = fetch(cfg)?;
235 let chunks = registry::result_to_chunks(&result);
236 let n = chunks.len();
237 if !chunks.is_empty() {
238 let artifacts = consolidation::consolidate(&chunks);
239 if !artifacts.is_empty() {
240 crate::tools::ctx_provider::apply_artifacts_to_stores(
241 &artifacts,
242 &workspace_root.to_string_lossy(),
243 );
244 }
245 }
246 Ok(n)
247}
248
249pub fn spawn_scheduler(
253 connectors: Arc<Vec<ConnectorConfig>>,
254 roots: Arc<HashMap<String, String>>,
255 default_workspace_id: String,
256 state_dir: PathBuf,
257 data_dir: PathBuf,
258 quota_bytes: u64,
259 tick: Duration,
260) {
261 if connectors.iter().all(|c| !c.enabled) {
262 return;
263 }
264 tokio::spawn(async move {
265 tokio::time::sleep(Duration::from_secs(5)).await;
267 loop {
268 let over_quota = team_billing::is_over_quota(&data_dir, quota_bytes);
271 for c in connectors.iter().filter(|c| c.enabled) {
272 let st = load_state(&state_dir, &c.id);
273 if !is_due(now_secs(), st.last_run_secs, c.effective_interval()) {
274 continue;
275 }
276 if over_quota {
277 let mut st = st;
278 st.last_status = Some("error".to_string());
279 st.last_error = Some("storage quota exceeded — hosted sync paused".to_string());
280 st.last_run_secs = Some(now_secs());
281 st.last_run_at = Some(chrono::Utc::now().to_rfc3339());
282 st.total_runs = st.total_runs.saturating_add(1);
283 save_state(&state_dir, &c.id, &st);
284 tracing::warn!(
285 connector = %c.id,
286 "skipping connector sync: storage quota exceeded"
287 );
288 continue;
289 }
290 let ws = c
291 .workspace_id
292 .clone()
293 .unwrap_or_else(|| default_workspace_id.clone());
294 let Some(root) = roots.get(&ws).cloned() else {
295 tracing::warn!(
296 connector = %c.id,
297 workspace = %ws,
298 "connector references unknown workspace; skipping"
299 );
300 continue;
301 };
302
303 let cfg = c.clone();
304 let dir = state_dir.clone();
305 let _ = tokio::task::spawn_blocking(move || {
307 let started = now_secs();
308 let mut st = load_state(&dir, &cfg.id);
309 match run_once(&cfg, Path::new(&root)) {
310 Ok(n) => {
311 st.last_status = Some("ok".to_string());
312 st.last_error = None;
313 st.last_item_count = Some(n);
314 st.total_items = st.total_items.saturating_add(n as u64);
315 tracing::info!(connector = %cfg.id, items = n, "connector sync ok");
316 }
317 Err(e) => {
318 st.last_status = Some("error".to_string());
319 st.last_error = Some(e.clone());
320 tracing::warn!(connector = %cfg.id, error = %e, "connector sync failed");
321 }
322 }
323 st.last_run_secs = Some(started);
326 st.last_run_at = Some(chrono::Utc::now().to_rfc3339());
327 st.total_runs = st.total_runs.saturating_add(1);
328 save_state(&dir, &cfg.id, &st);
329 })
330 .await;
331 }
332 tokio::time::sleep(tick).await;
333 }
334 });
335}
336
337#[derive(Debug, Serialize)]
339#[serde(rename_all = "camelCase")]
340struct ConnectorView {
341 id: String,
342 provider: String,
343 display_name: Option<String>,
344 workspace_id: String,
345 resource: String,
346 project: Option<String>,
347 interval_secs: u64,
348 enabled: bool,
349 has_secret: bool,
351 status: ConnectorRunState,
352}
353
354pub async fn v1_connectors(State(state): State<TeamAppState>) -> impl IntoResponse {
358 let default_ws = state.team.engine.server.default_workspace_id.clone();
359 let dir = state.team.connectors_state_dir.as_ref().clone();
360
361 let views: Vec<ConnectorView> = state
362 .team
363 .connectors
364 .iter()
365 .map(|c| {
366 let workspace_id = c.workspace_id.clone().unwrap_or_else(|| default_ws.clone());
367 ConnectorView {
368 id: c.id.clone(),
369 provider: c.provider.clone(),
370 display_name: c.display_name.clone(),
371 workspace_id,
372 resource: c.resource.clone(),
373 project: c.project.clone(),
374 interval_secs: c.effective_interval(),
375 enabled: c.enabled,
376 has_secret: c.has_secret(),
377 status: load_state(&dir, &c.id),
378 }
379 })
380 .collect();
381
382 (
383 StatusCode::OK,
384 Json(json!({
385 "schema_version": 1,
386 "generated_at": chrono::Utc::now().to_rfc3339(),
387 "connector_count": views.len(),
388 "connectors": views,
389 })),
390 )
391}
392
393#[must_use]
396pub fn usage_rollup(connectors: &[ConnectorConfig], state_dir: &Path) -> serde_json::Value {
397 let mut total_runs = 0u64;
398 let mut total_items = 0u64;
399 let mut ok = 0u64;
400 let mut errored = 0u64;
401 let mut last_run_at: Option<String> = None;
402 for c in connectors {
403 let st = load_state(state_dir, &c.id);
404 total_runs = total_runs.saturating_add(st.total_runs);
405 total_items = total_items.saturating_add(st.total_items);
406 match st.last_status.as_deref() {
407 Some("ok") => ok += 1,
408 Some("error") => errored += 1,
409 _ => {}
410 }
411 if let Some(ts) = st.last_run_at
412 && last_run_at.as_deref().is_none_or(|cur| ts.as_str() > cur)
413 {
414 last_run_at = Some(ts);
415 }
416 }
417 json!({
418 "configured": connectors.len(),
419 "enabled": connectors.iter().filter(|c| c.enabled).count(),
420 "total_runs": total_runs,
421 "total_items_ingested": total_items,
422 "last_status_ok": ok,
423 "last_status_error": errored,
424 "last_run_at": last_run_at,
425 })
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431
432 #[test]
433 fn first_run_is_always_due() {
434 assert!(is_due(1_000, None, 3_600));
435 }
436
437 #[test]
438 fn due_only_after_interval_elapses() {
439 assert!(!is_due(1_100, Some(1_000), 300));
441 assert!(is_due(1_300, Some(1_000), 300));
443 assert!(is_due(5_000, Some(1_000), 300));
445 }
446
447 #[test]
448 fn zero_interval_never_busy_loops() {
449 assert!(!is_due(1_000, Some(1_000), 0));
451 assert!(is_due(1_001, Some(1_000), 0));
452 }
453
454 #[test]
455 fn interval_is_floored_to_minimum() {
456 let c = ConnectorConfig {
457 id: "x".into(),
458 provider: "gitlab".into(),
459 display_name: None,
460 workspace_id: None,
461 resource: "issues".into(),
462 project: Some("g/p".into()),
463 host: None,
464 state: None,
465 limit: None,
466 interval_secs: 5,
467 secret: Some("t".into()),
468 enabled: true,
469 };
470 assert_eq!(c.effective_interval(), MIN_INTERVAL_SECS);
471 }
472
473 #[test]
474 fn split_owner_repo_parses_slug() {
475 assert_eq!(
476 split_owner_repo(Some("octocat/hello")),
477 (Some("octocat".to_string()), Some("hello".to_string()))
478 );
479 assert_eq!(split_owner_repo(Some("noseparator")), (None, None));
480 assert_eq!(split_owner_repo(None), (None, None));
481 }
482
483 #[test]
484 fn sanitize_id_blocks_traversal() {
485 assert_eq!(sanitize_id("../../etc/passwd"), "______etc_passwd");
486 assert_eq!(sanitize_id("conn-1_ok"), "conn-1_ok");
487 }
488
489 #[test]
490 fn unsupported_provider_is_rejected() {
491 let c = ConnectorConfig {
492 id: "x".into(),
493 provider: "bitbucket".into(),
494 display_name: None,
495 workspace_id: None,
496 resource: "issues".into(),
497 project: Some("g/p".into()),
498 host: None,
499 state: None,
500 limit: None,
501 interval_secs: 3_600,
502 secret: Some("t".into()),
503 enabled: true,
504 };
505 let err = fetch(&c).unwrap_err();
506 assert!(err.contains("unsupported provider"));
507 }
508
509 #[test]
510 fn missing_secret_is_rejected() {
511 let c = ConnectorConfig {
512 id: "x".into(),
513 provider: "gitlab".into(),
514 display_name: None,
515 workspace_id: None,
516 resource: "issues".into(),
517 project: Some("g/p".into()),
518 host: None,
519 state: None,
520 limit: None,
521 interval_secs: 3_600,
522 secret: None,
523 enabled: true,
524 };
525 let err = fetch(&c).unwrap_err();
526 assert!(err.contains("no credential"));
527 }
528
529 #[tokio::test]
537 async fn sync_lands_in_searchable_store_end_to_end() {
538 use axum::Router;
539 use axum::routing::get;
540
541 let issues = serde_json::json!([
542 {
543 "number": 1,
544 "title": "Zephyr crash on cold start",
545 "state": "open",
546 "user": { "login": "alice" },
547 "created_at": "2026-01-01T00:00:00Z",
548 "updated_at": "2026-01-02T00:00:00Z",
549 "html_url": "http://example.test/1",
550 "labels": [{ "name": "bug" }],
551 "body": "Service panics in the Zephyr boot path on a cold start."
552 },
553 {
554 "number": 2,
555 "title": "Add Borealis dashboard",
556 "state": "open",
557 "user": { "login": "bob" },
558 "created_at": "2026-01-03T00:00:00Z",
559 "updated_at": "2026-01-04T00:00:00Z",
560 "html_url": "http://example.test/2",
561 "labels": [],
562 "body": "A Borealis analytics panel for the team overview."
563 }
564 ]);
565
566 let app = Router::new().route(
567 "/repos/acme/widgets/issues",
568 get(move || {
569 let body = issues.clone();
570 async move { Json(body) }
571 }),
572 );
573 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
574 let addr = listener.local_addr().unwrap();
575 let server = tokio::spawn(async move {
576 axum::serve(listener, app).await.unwrap();
577 });
578
579 let workspace = tempfile::tempdir().unwrap();
580 let ws_root = workspace.path().to_path_buf();
581
582 let cfg = ConnectorConfig {
583 id: "gh-e2e".into(),
584 provider: "github".into(),
585 display_name: None,
586 workspace_id: None,
587 resource: "issues".into(),
588 project: Some("acme/widgets".into()),
589 host: Some(format!("http://{addr}")),
592 state: Some("open".into()),
593 limit: Some(50),
594 interval_secs: 3_600,
595 secret: Some("test-token".into()),
596 enabled: true,
597 };
598
599 let ws_sync = ws_root.clone();
602 let ingested = tokio::task::spawn_blocking(move || run_once(&cfg, &ws_sync))
603 .await
604 .unwrap()
605 .expect("sync against the local source must succeed");
606 assert_eq!(ingested, 2, "both fixture issues must be ingested");
607
608 let hits = tokio::task::spawn_blocking(move || {
613 crate::core::bm25_index::BM25Index::load(&ws_root)
614 .expect("the sync must persist a BM25 index")
615 .search("Zephyr", 5)
616 })
617 .await
618 .unwrap();
619 assert!(
620 !hits.is_empty(),
621 "the synced GitHub issue must be findable in the persisted BM25 index"
622 );
623
624 server.abort();
625 }
626}