Skip to main content

scc_engine/
state.rs

1//! Engine state: lessons, beads, evidence import, runtime.
2//!
3//! Value-returning operations; the CLI renders. Import bumps the evidence
4//! epoch and recompiles the derived layer (moved with the code).
5
6use std::io::Write;
7use std::path::{Path, PathBuf};
8
9// trace:exempt reason=internal-detail
10pub fn lessons_add(root: &Path, text: &str) -> crate::Result<(String, PathBuf)> {
11    let dir = crate::workspace::scc_dir(root);
12    std::fs::create_dir_all(&dir)?;
13    let path = dir.join("lessons.jsonl");
14    let n = std::fs::read_to_string(&path)
15        .map(|s| s.lines().filter(|l| !l.trim().is_empty()).count())
16        .unwrap_or(0);
17    let id = format!("lesson-{}", n + 1);
18    let record = serde_json::json!({
19        "id": id,
20        "text": text,
21        "created_at": scc_core::now_rfc3339(),
22    });
23    let mut f = std::fs::OpenOptions::new()
24        .create(true)
25        .append(true)
26        .open(&path)
27        .map_err(|e| crate::EngineError::Other(format!("lessons: {e}")))?;
28    writeln!(f, "{record}").map_err(|e| crate::EngineError::Other(format!("lessons: {e}")))?;
29    Ok((id, path))
30}
31
32// trace:exempt reason=internal-detail
33pub fn lessons_list(root: &Path, limit: usize) -> crate::Result<Vec<(String, Vec<String>)>> {
34    let store = crate::workspace::open_store(root)?;
35    Ok(scc_indexer::adapters::hindsight::lessons(&store, limit))
36}
37
38// trace:exempt reason=internal-detail
39pub fn beads(root: &Path, limit: usize) -> crate::Result<Vec<String>> {
40    Ok(scc_indexer::adapters::beads::active_beads(root, limit))
41}
42
43// trace:exempt reason=internal-detail
44pub fn import_evidence(root: &Path, format: &str, file: &str) -> crate::Result<scc_indexer::adapters::ImportReport> {
45    let store = crate::workspace::open_store(root)?;
46    let report = match format {
47        "scip" => scc_indexer::adapters::import_scip(&store, std::path::Path::new(file)),
48        "ccg" => scc_indexer::adapters::import_ccg(&store, std::path::Path::new(file)),
49        "gitnexus" => scc_indexer::adapters::gitnexus::import_gitnexus(&store, std::path::Path::new(file))
50            .map(|r| scc_indexer::adapters::ImportReport {
51                symbols: r.symbols,
52                calls: r.edges,
53                imports: 0,
54                errors: r.errors,
55            }),
56        "beads" => scc_indexer::adapters::beads::import_beads(&store, std::path::Path::new(file))
57            .map(|r| scc_indexer::adapters::ImportReport {
58                symbols: r.tasks,
59                calls: r.dependencies,
60                imports: r.active,
61                errors: r.errors,
62            }),
63        "cbm" => scc_indexer::adapters::cbm::import_cbm(&store, std::path::Path::new(file))
64            .map(|r| scc_indexer::adapters::ImportReport {
65                symbols: r.symbols,
66                calls: r.relationships,
67                imports: 0,
68                errors: r.errors,
69            }),
70        "hindsight" => scc_indexer::adapters::hindsight::import_hindsight(&store, std::path::Path::new(file))
71            .map(|r| scc_indexer::adapters::ImportReport {
72                symbols: r.lessons,
73                calls: 0,
74                imports: 0,
75                errors: r.errors,
76            }),
77        "tracelayer" => scc_indexer::adapters::tracelayer::import_tracelayer(&store, std::path::Path::new(file))
78            .map(|r| scc_indexer::adapters::ImportReport {
79                symbols: r.requirements + r.implementations + r.tests + r.decisions,
80                calls: r.relationships,
81                imports: r.work_items,
82                errors: r.errors,
83            }),
84        other => {
85            return Err(crate::EngineError::Other(format!(
86                "unknown import format '{other}' (use scip, ccg, gitnexus, beads, cbm, hindsight, or tracelayer)"
87            )))
88        }
89    }
90    .map_err(crate::EngineError::Other)?;
91    store.bump_epoch(scc_store::ModelEpochKind::Evidence)?;
92    crate::index::recompile(&store)?;
93    Ok(report)
94}
95
96// trace:exempt reason=internal-detail
97pub fn runtime_edges(root: &Path) -> crate::Result<Vec<scc_indexer::runtime::RuntimeEdge>> {
98    let store = crate::workspace::open_store(root)?;
99    scc_indexer::runtime::runtime_edges(&store).map_err(crate::EngineError::Other)
100}
101
102// trace:exempt reason=internal-detail
103pub fn ingest_runtime(root: &Path, body: &str) -> crate::Result<()> {
104    let store = crate::workspace::open_store(root)?;
105    if body.contains("resourceSpans") {
106        scc_indexer::runtime::ingest_otlp_json(&store, body)
107            .map_err(crate::EngineError::Other)?;
108        return Ok(());
109    }
110    scc_indexer::runtime::ingest_simple_edges(&store, body)
111        .map_err(crate::EngineError::Other)?;
112    Ok(())
113}
114
115// trace:exempt reason=internal-detail
116pub fn reconcile(root: &Path) -> crate::Result<scc_indexer::runtime::Reconciliation> {
117    let store = crate::workspace::open_store(root)?;
118    scc_indexer::runtime::reconcile(&store).map_err(crate::EngineError::Other)
119}
120
121// trace:exempt reason=internal-detail
122pub fn embeddings_build(root: &Path) -> crate::Result<serde_json::Value> {
123    use scc_indexer::embed::EmbedConfig;
124    let store = crate::workspace::open_store(root)?;
125    let config = crate::workspace::load_config(root)?;
126    if !config.inference.enabled {
127        return Err(crate::EngineError::Other(
128            "inference is disabled — set `inference.enabled: true` in .scc/config.yaml".into(),
129        ));
130    }
131    let cfg = EmbedConfig::from_config(&config.inference);
132    if cfg.is_remote() && !config.security.allow_remote_models {
133        return Err(crate::EngineError::Other(
134            "remote inference blocked: repository-derived content would leave the machine — set `security.allow_remote_models: true` to allow it (or use a loopback provider)".into(),
135        ));
136    }
137    if store.snapshot_status()?.is_none() {
138        return Err(crate::EngineError::Other("not indexed — run `scc index` first".into()));
139    }
140    let n = scc_indexer::embed::embed_repository(&store, &cfg).map_err(crate::EngineError::Other)?;
141    store.cache_clear()?;
142    Ok(serde_json::json!({"stored": n, "model": cfg.model}))
143}
144
145// trace:exempt reason=internal-detail
146pub fn embeddings_get(store: &scc_store::Store, entity_id: &str) -> crate::Result<serde_json::Value> {
147    match store.get_embedding(entity_id)? {
148        Some((v, model)) => Ok(serde_json::json!({"id": entity_id, "model": model, "dims": v.len(), "vector": v})),
149        None => Ok(serde_json::Value::Null),
150    }
151}
152
153// trace:exempt reason=internal-detail
154pub fn embeddings_status(store: &scc_store::Store) -> crate::Result<serde_json::Value> {
155    let count = store.embedding_count()?;
156    Ok(serde_json::json!({"embeddings": count}))
157}
158
159// trace:exempt reason=internal-detail
160pub fn external_docs(root: &Path, dependency: &str) -> crate::Result<String> {
161    let config = crate::workspace::load_config(root)?;
162    if config.integrations.context7_command.is_empty() {
163        return Err(crate::EngineError::Other(
164            "Context7 is not configured — set integrations.context7_command in .scc/config.yaml".into(),
165        ));
166    }
167    let mut client = scc_indexer::adapters::context7::start(&config.integrations.context7_command, root)
168        .map_err(crate::EngineError::Other)?;
169    client.docs_for(dependency).map_err(crate::EngineError::Other)
170}
171
172/// Namespaced plugin state (§23): get one key. Requires the calling
173/// plugin's StateRead grant (checked by the host before dispatch; the
174/// engine re-checks here so direct invoke() callers are gated too).
175// trace:exempt reason=internal-detail
176pub fn plugin_state_get(
177    store: &scc_store::Store,
178    plugin_id: &str,
179    grants: &[scc_plugin_api::Permission],
180    key: &str,
181) -> crate::Result<serde_json::Value> {
182    require_state_grant(plugin_id, grants, false)?;
183    Ok(store.plugin_state_get(plugin_id, key)?.into())
184}
185
186/// Put one key (JSON text). Requires StateWrite.
187// trace:exempt reason=internal-detail
188pub fn plugin_state_put(
189    store: &scc_store::Store,
190    plugin_id: &str,
191    grants: &[scc_plugin_api::Permission],
192    key: &str,
193    value: &str,
194) -> crate::Result<serde_json::Value> {
195    require_state_grant(plugin_id, grants, true)?;
196    store.plugin_state_put(plugin_id, key, value)?;
197    Ok(serde_json::json!({"ok": true}))
198}
199
200/// Delete one key. Requires StateWrite.
201// trace:exempt reason=internal-detail
202pub fn plugin_state_delete(
203    store: &scc_store::Store,
204    plugin_id: &str,
205    grants: &[scc_plugin_api::Permission],
206    key: &str,
207) -> crate::Result<serde_json::Value> {
208    require_state_grant(plugin_id, grants, true)?;
209    store.plugin_state_delete(plugin_id, key)?;
210    Ok(serde_json::json!({"ok": true}))
211}
212
213/// Scan keys by prefix (ordered, bounded). Requires StateRead.
214// trace:exempt reason=internal-detail
215pub fn plugin_state_scan(
216    store: &scc_store::Store,
217    plugin_id: &str,
218    grants: &[scc_plugin_api::Permission],
219    prefix: &str,
220    limit: usize,
221) -> crate::Result<serde_json::Value> {
222    require_state_grant(plugin_id, grants, false)?;
223    let rows = store.plugin_state_scan(plugin_id, prefix, limit)?;
224    Ok(serde_json::json!({
225        "keys": rows.iter().map(|(k, v)| serde_json::json!({"key": k, "value": v})).collect::<Vec<_>>(),
226    }))
227}
228
229// trace:exempt reason=internal-detail
230fn require_state_grant(
231    plugin_id: &str,
232    grants: &[scc_plugin_api::Permission],
233    write: bool,
234) -> crate::Result<()> {
235    let need = if write {
236        scc_plugin_api::Permission::StateWrite
237    } else {
238        scc_plugin_api::Permission::StateRead
239    };
240    if grants.contains(&need) {
241        Ok(())
242    } else {
243        Err(crate::EngineError::Other(format!(
244            "permission denied: plugin {plugin_id} lacks {}",
245            need.as_str()
246        )))
247    }
248}