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