Skip to main content

lean_ctx/http_server/team/
connectors.rs

1//! Managed Connectors for the hosted team server (#281).
2//!
3//! A *connector* is a scheduled, in-process sync from an external source
4//! (GitLab / GitHub) into a team workspace's long-term stores (BM25 + graph +
5//! knowledge). Once a connector has run, `ctx_semantic_search` and
6//! `ctx_knowledge` surface the source's issues / PRs / pipelines to every seat —
7//! no per-call credential transport, no manual `ctx_provider` invocation.
8//!
9//! **Where credentials live.** A connector's credential is only ever present in
10//! the injected `team.json` (a private Coolify env var, `LEAN_CTX_TEAM_CONFIG`).
11//! The control plane keeps the secret encrypted at rest and decrypts it solely
12//! to render that env var; it is never written to disk by the server and never
13//! returned by [`v1_connectors`].
14//!
15//! **Local-Free Invariant.** Connectors are a hosted convenience: they only add
16//! context to a hosted workspace and gate nothing locally.
17
18use 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
37/// Smallest sync cadence we accept (defends external APIs from a hot loop).
38const MIN_INTERVAL_SECS: u64 = 300;
39/// Default sync cadence when a connector omits one (hourly).
40const DEFAULT_INTERVAL_SECS: u64 = 3_600;
41/// How many items a single sync pulls when the connector omits a limit.
42const DEFAULT_LIMIT: usize = 50;
43
44fn default_interval_secs() -> u64 {
45    DEFAULT_INTERVAL_SECS
46}
47fn default_true() -> bool {
48    true
49}
50
51/// One configured connector, deserialized from `team.json` (`connectors[]`).
52#[derive(Clone, Debug, Serialize, Deserialize)]
53#[serde(rename_all = "camelCase")]
54pub struct ConnectorConfig {
55    /// Stable, DNS/file-safe id (unique within the instance).
56    pub id: String,
57    /// Source kind: `gitlab` | `github`.
58    pub provider: String,
59    #[serde(default)]
60    pub display_name: Option<String>,
61    /// Target workspace; the instance default when omitted.
62    #[serde(default)]
63    pub workspace_id: Option<String>,
64    /// Resource to pull: gitlab `issues|merge_requests|pipelines`,
65    /// github `issues|pull_requests|actions`.
66    pub resource: String,
67    /// `group/project` (GitLab) or `owner/repo` (GitHub).
68    #[serde(default)]
69    pub project: Option<String>,
70    /// GitLab host (default `gitlab.com`) or GitHub API base
71    /// (default `https://api.github.com`).
72    #[serde(default)]
73    pub host: Option<String>,
74    /// Optional state filter passed through to the provider (e.g. `opened`).
75    #[serde(default)]
76    pub state: Option<String>,
77    /// Max items per sync.
78    #[serde(default)]
79    pub limit: Option<usize>,
80    /// Desired sync cadence in seconds (clamped to a 5-minute floor).
81    #[serde(default = "default_interval_secs")]
82    pub interval_secs: u64,
83    /// Provider credential (plaintext only inside the private team.json).
84    #[serde(default)]
85    pub secret: Option<String>,
86    #[serde(default = "default_true")]
87    pub enabled: bool,
88}
89
90impl ConnectorConfig {
91    /// Effective cadence, never below the floor.
92    #[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/// Persisted outcome of a connector's most recent sync (one file per connector
107/// under `<state_dir>/<id>.json`). Never contains the credential.
108#[derive(Clone, Debug, Default, Serialize, Deserialize)]
109#[serde(rename_all = "camelCase")]
110pub struct ConnectorRunState {
111    /// RFC3339 timestamp of the last attempt (human-facing).
112    pub last_run_at: Option<String>,
113    /// Epoch seconds of the last attempt (scheduling).
114    #[serde(default)]
115    pub last_run_secs: Option<u64>,
116    /// `ok` | `error`.
117    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/// Pure scheduler decision: is a connector due to run now?
127///
128/// First run (`last_run` is `None`) is always due; afterwards a connector is due
129/// once at least `interval` seconds have elapsed since the last *attempt*. The
130/// `interval` is floored at one second so a misconfigured `0` never busy-loops.
131#[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
145/// Keep only file-safe characters so a connector id can never escape the state
146/// directory (defence in depth — ids are control-plane minted).
147fn 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
177/// Fetch the connector's source data as a `ProviderResult`, constructing a
178/// provider with the connector's own credential (no global env mutation).
179fn 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, &params)
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, &params)
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
231/// Run one sync: fetch → chunk → consolidate → persist into the workspace's
232/// BM25 / graph / knowledge stores. Returns the number of items ingested.
233fn 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
249/// Spawn the background scheduler. Ticks every `tick`, runs each due connector
250/// once (blocking work on the blocking pool), and records its outcome. A no-op
251/// when no connectors are configured.
252pub 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        // Let the server finish binding before the first sync.
266        tokio::time::sleep(Duration::from_secs(5)).await;
267        loop {
268            // Quota backstop (#282): once the hosted index hits quota we pause
269            // ingestion (never delete, never gate reads). Checked once per tick.
270            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                // Provider HTTP + store writes are blocking.
306                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                    // Record the attempt time even on error so a failing
324                    // connector waits its interval before retrying.
325                    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/// Public, secret-free view of a connector and its latest run.
338#[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    /// Whether a credential is configured (the secret itself is never exposed).
350    has_secret: bool,
351    status: ConnectorRunState,
352}
353
354/// `GET /v1/connectors` — secret-free roster + per-connector sync status. Gated
355/// on the `audit` scope (read by the control plane via its audit-only token, the
356/// same path the savings roll-up uses).
357pub 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/// Aggregate connector activity for the usage snapshot (#283). Reads each
394/// connector's persisted run state only; never touches credentials.
395#[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        // 100s elapsed, 300s interval → not due yet.
440        assert!(!is_due(1_100, Some(1_000), 300));
441        // exactly interval → due.
442        assert!(is_due(1_300, Some(1_000), 300));
443        // well past → due.
444        assert!(is_due(5_000, Some(1_000), 300));
445    }
446
447    #[test]
448    fn zero_interval_never_busy_loops() {
449        // A misconfigured 0 is floored to 1s, so a same-second re-check is not due.
450        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    /// End-to-end: a real sync against a live HTTP source must land in the target
530    /// workspace's BM25 store and be searchable afterwards. A tiny axum server
531    /// stands in for the GitHub REST API and answers with a fixture in GitHub's
532    /// exact wire shape; the production sync path
533    /// (HTTP fetch → parse → chunk → consolidate → store) runs for real against
534    /// it. Nothing in the code under test is mocked — only the remote endpoint is
535    /// local so the test is hermetic and needs no credentials or network.
536    #[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` becomes the GitHub `api_base`, so we point the real provider
590            // at our local fixture server.
591            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        // `run_once` does blocking HTTP (ureq) + store writes; keep it off the
600        // async reactor so the fixture server can serve the request.
601        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        // The sync must have persisted a real, searchable BM25 index for the
609        // workspace. `load` reads the persisted artifact directly; the workspace
610        // safety/staleness guards in `load_or_build` are a separate concern of the
611        // workspace lifecycle, not of the connector's write path.
612        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}