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::{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
39/// Smallest sync cadence we accept (defends external APIs from a hot loop).
40const MIN_INTERVAL_SECS: u64 = 300;
41/// Default sync cadence when a connector omits one (hourly).
42const DEFAULT_INTERVAL_SECS: u64 = 3_600;
43/// How many items a single sync pulls when the connector omits a limit.
44const 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/// One configured connector, deserialized from `team.json` (`connectors[]`).
71#[derive(Clone, Debug, Serialize, Deserialize)]
72#[serde(rename_all = "camelCase")]
73pub struct ConnectorConfig {
74    /// Stable, DNS/file-safe id (unique within the instance).
75    pub id: String,
76    /// Source kind: `gitlab` | `github`.
77    pub provider: String,
78    #[serde(default)]
79    pub display_name: Option<String>,
80    /// Target workspace; the instance default when omitted.
81    #[serde(default)]
82    pub workspace_id: Option<String>,
83    /// Resource to pull: gitlab `issues|merge_requests|pipelines`,
84    /// github `issues|pull_requests|actions`.
85    pub resource: String,
86    /// `group/project` (GitLab) or `owner/repo` (GitHub).
87    #[serde(default)]
88    pub project: Option<String>,
89    /// GitLab host (default `gitlab.com`) or GitHub API base
90    /// (default `https://api.github.com`).
91    #[serde(default)]
92    pub host: Option<String>,
93    /// Optional state filter passed through to the provider (e.g. `opened`).
94    #[serde(default)]
95    pub state: Option<String>,
96    /// Max items per sync.
97    #[serde(default)]
98    pub limit: Option<usize>,
99    /// Desired sync cadence in seconds (clamped to a 5-minute floor).
100    #[serde(default = "default_interval_secs")]
101    pub interval_secs: u64,
102    /// Provider credential (plaintext only inside the private team.json).
103    #[serde(default)]
104    pub secret: Option<String>,
105    #[serde(default = "default_true")]
106    pub enabled: bool,
107}
108
109impl ConnectorConfig {
110    /// Effective cadence, never below the floor.
111    #[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/// Persisted outcome of a connector's most recent sync (one file per connector
126/// under `<state_dir>/<id>.json`). Never contains the credential.
127#[derive(Clone, Debug, Default, Serialize, Deserialize)]
128#[serde(rename_all = "camelCase")]
129pub struct ConnectorRunState {
130    /// RFC3339 timestamp of the last attempt (human-facing).
131    pub last_run_at: Option<String>,
132    /// Epoch seconds of the last attempt (scheduling).
133    #[serde(default)]
134    pub last_run_secs: Option<u64>,
135    /// `ok` | `error`.
136    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/// Pure scheduler decision: is a connector due to run now?
146///
147/// First run (`last_run` is `None`) is always due; afterwards a connector is due
148/// once at least `interval` seconds have elapsed since the last *attempt*. The
149/// `interval` is floored at one second so a misconfigured `0` never busy-loops.
150#[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
164/// Keep only file-safe characters so a connector id can never escape the state
165/// directory (defence in depth — ids are control-plane minted).
166fn 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
196/// Fetch the connector's source data as a `ProviderResult`, constructing a
197/// provider with the connector's own credential (no global env mutation).
198fn 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, &params)
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, &params)
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
250/// Run one sync: fetch → chunk → consolidate → persist into the workspace's
251/// BM25 / graph / knowledge stores. Returns the number of items ingested.
252fn 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
268/// Spawn the background scheduler. Ticks every `tick`, runs each due connector
269/// once (blocking work on the blocking pool), and records its outcome. A no-op
270/// when no connectors are configured.
271pub 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        // Let the server finish binding before the first sync.
285        tokio::time::sleep(Duration::from_secs(5)).await;
286        loop {
287            // Quota backstop (#282): once the hosted index hits quota we pause
288            // ingestion (never delete, never gate reads). Checked once per tick.
289            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                // Provider HTTP + store writes are blocking.
325                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                            // OCLA projection: record connector scheduling through trait boundary
337                            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                    // Record the attempt time even on error so a failing
373                    // connector waits its interval before retrying.
374                    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/// Public, secret-free view of a connector and its latest run.
387#[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    /// Whether a credential is configured (the secret itself is never exposed).
399    has_secret: bool,
400    status: ConnectorRunState,
401}
402
403/// `GET /v1/connectors` — secret-free roster + per-connector sync status. Gated
404/// on the `audit` scope (read by the control plane via its audit-only token, the
405/// same path the savings roll-up uses).
406pub 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/// Aggregate connector activity for the usage snapshot (#283). Reads each
443/// connector's persisted run state only; never touches credentials.
444#[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        // 100s elapsed, 300s interval → not due yet.
489        assert!(!is_due(1_100, Some(1_000), 300));
490        // exactly interval → due.
491        assert!(is_due(1_300, Some(1_000), 300));
492        // well past → due.
493        assert!(is_due(5_000, Some(1_000), 300));
494    }
495
496    #[test]
497    fn zero_interval_never_busy_loops() {
498        // A misconfigured 0 is floored to 1s, so a same-second re-check is not due.
499        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    /// End-to-end: a real sync against a live HTTP source must land in the target
579    /// workspace's BM25 store and be searchable afterwards. A tiny axum server
580    /// stands in for the GitHub REST API and answers with a fixture in GitHub's
581    /// exact wire shape; the production sync path
582    /// (HTTP fetch → parse → chunk → consolidate → store) runs for real against
583    /// it. Nothing in the code under test is mocked — only the remote endpoint is
584    /// local so the test is hermetic and needs no credentials or network.
585    #[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` becomes the GitHub `api_base`, so we point the real provider
639            // at our local fixture server.
640            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        // `run_once` does blocking HTTP (ureq) + store writes; keep it off the
649        // async reactor so the fixture server can serve the request.
650        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        // The sync must have persisted a real, searchable BM25 index for the
658        // workspace. `load` reads the persisted artifact directly; the workspace
659        // safety/staleness guards in `load_or_build` are a separate concern of the
660        // workspace lifecycle, not of the connector's write path.
661        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}