Skip to main content

comindly_core/
team.rs

1//! Team settings, stored with the index (`<index>/_team`, a versioned LanceDB table) so everyone
2//! using it runs the same LLM, embedding model and LLM steps. No secrets: each member's API key
3//! comes from their own credentials file or environment.
4
5use anyhow::Result;
6use serde::{Deserialize, Serialize};
7
8const TABLE: &str = "settings";
9
10/// What everyone using an index shares; `None` leaves comindly's default.
11#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
12pub struct TeamSettings {
13    #[serde(default, skip_serializing_if = "Option::is_none")]
14    pub name: Option<String>,
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub llm_base_url: Option<String>,
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    pub llm_model: Option<String>,
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub embed_model: Option<String>,
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub enrich: Option<bool>,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub flows: Option<bool>,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub guide: Option<bool>,
27    /// Search embeddings when indexing.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub embed: Option<bool>,
30    /// Most symbols summarised by the LLM, most central first.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub max_enrich: Option<usize>,
33    /// Most flows given a written walkthrough, furthest-reaching first.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub max_flows: Option<usize>,
36    /// LLM requests in flight at once.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub llm_concurrency: Option<usize>,
39    /// The LLM's price in USD per million input tokens, to show what indexing costs.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub llm_price_in: Option<f64>,
42    /// The LLM's price in USD per million output tokens.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub llm_price_out: Option<f64>,
45    /// What MCP tools return as text: `md` (default) or `json`; the JSON is always attached too.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub format: Option<String>,
48    /// SCIP use when indexing: `auto` (default), `require` or `off`.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub scip: Option<String>,
51    /// Re-index when a listed repository's default branch moves on its remote.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub auto_index: Option<bool>,
54    /// Also run the LLM steps on automatic re-indexes, for new or changed code only.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub auto_index_llm: Option<bool>,
57    /// Minutes between checks whether a default branch moved (default 5).
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub auto_index_minutes: Option<usize>,
60    /// Clone URLs of the team's repositories, so everyone can get the same set.
61    #[serde(default, skip_serializing_if = "Vec::is_empty")]
62    pub repos: Vec<String>,
63}
64
65/// The stored settings with the table version they came from and who saved them.
66#[derive(Debug, Clone, Default)]
67pub struct Stored {
68    pub settings: TeamSettings,
69    pub version: Option<u64>,
70    pub changed_by: Option<String>,
71    pub changed_at: Option<String>,
72}
73
74fn uri(root: &str) -> String {
75    format!("{}/_team", root.trim_end_matches('/'))
76}
77
78/// The team settings of the index at `root` (empty when none were saved).
79pub async fn load(root: &str) -> Result<Stored> {
80    let s = crate::docstore::load::<TeamSettings>(&uri(root), TABLE).await?;
81    Ok(Stored {
82        settings: s.doc,
83        version: s.version,
84        changed_by: s.changed_by,
85        changed_at: s.changed_at,
86    })
87}
88
89/// Save `settings` as `by` if nobody saved since `expected` (the version they were loaded at).
90pub async fn save_as(
91    root: &str,
92    settings: &TeamSettings,
93    expected: Option<u64>,
94    by: &str,
95) -> Result<u64> {
96    crate::docstore::save(&uri(root), TABLE, settings, expected, by)
97        .await
98        .map_err(|e| match format!("{e}").contains("changed meanwhile") {
99            true => anyhow::anyhow!(
100                "the team settings were changed by someone else; reload and try again"
101            ),
102            false => e,
103        })
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[tokio::test]
111    async fn save_load_and_refuse_a_stale_write() {
112        let dir = std::env::temp_dir().join(format!("comindly-team-{}", std::process::id()));
113        let root = dir.to_str().unwrap();
114        let first = TeamSettings {
115            llm_model: Some("m1".into()),
116            ..Default::default()
117        };
118        let v1 = save_as(root, &first, None, "t").await.unwrap();
119        assert_eq!(load(root).await.unwrap().settings, first);
120        let second = TeamSettings {
121            llm_model: Some("m2".into()),
122            ..Default::default()
123        };
124        save_as(root, &second, Some(v1), "t").await.unwrap();
125        assert!(
126            save_as(root, &first, Some(v1), "t").await.is_err(),
127            "stale version"
128        );
129        assert_eq!(
130            load(root).await.unwrap().settings.llm_model.as_deref(),
131            Some("m2")
132        );
133        std::fs::remove_dir_all(&dir).ok();
134    }
135}