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 import_plugin_evidence(root, other, file);
86        }
87    }
88    .map_err(crate::EngineError::Other)?;
89    store.bump_epoch(scc_store::ModelEpochKind::Evidence)?;
90    crate::index::recompile(&store)?;
91    Ok(report)
92}
93
94// trace:exempt reason=internal-detail
95pub fn runtime_edges(root: &Path) -> crate::Result<Vec<scc_indexer::runtime::RuntimeEdge>> {
96    let store = crate::workspace::open_store(root)?;
97    scc_indexer::runtime::runtime_edges(&store).map_err(crate::EngineError::Other)
98}
99
100// trace:exempt reason=internal-detail
101pub fn ingest_runtime(root: &Path, body: &str) -> crate::Result<()> {
102    let store = crate::workspace::open_store(root)?;
103    if body.contains("resourceSpans") {
104        scc_indexer::runtime::ingest_otlp_json(&store, body)
105            .map_err(crate::EngineError::Other)?;
106    } else {
107        scc_indexer::runtime::ingest_simple_edges(&store, body)
108            .map_err(crate::EngineError::Other)?;
109    }
110    // Runtime-evidence plugins (§31 RuntimeEvidenceProvider): fan out the
111    // same raw body; contributed edges land in runtime_edges (OBSERVED
112    // path) via the normal ingest. Failures degrade to builtin-only.
113    ingest_plugin_runtime_edges(root, &store, body);
114    Ok(())
115}
116
117/// Fan out one ingest body to `runtime-evidence` plugins. Each plugin
118/// declaring the extension (or the legacy op) is called once with
119/// `{"body": ...}` and answers `{"edges": [...]}` in the simple-edges
120/// shape. Malformed plugin rows fail that plugin only (degrade, never
121/// the ingest); a crashing plugin is skipped with no model change.
122// trace:v1 id=impl.scc-engine-state.plugin-runtime work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
123pub fn ingest_plugin_runtime_edges(
124    root: &Path,
125    store: &scc_store::Store,
126    body: &str,
127) {
128    let config = match crate::workspace::load_config(root) {
129        Ok(c) => c,
130        Err(_) => return,
131    };
132    let ap = crate::plugins::active(root, &config);
133    for plug in &ap.plugins {
134        let is_provider = plug.manifest.extensions.iter().any(|e| e.extension_type == "runtime-evidence")
135            || plug.manifest.operations.iter().any(|o| o == "runtime.evidence");
136        if !is_provider {
137            continue;
138        }
139        let out = match scc_plugin_host::call(
140            plug,
141            "runtime.evidence",
142            serde_json::json!({"body": body}),
143            None,
144        ) {
145            Ok(v) => v,
146            Err(_) => continue,
147        };
148        let edges = out.get("edges").and_then(|v| v.as_array()).cloned().unwrap_or_default();
149        if edges.is_empty() {
150            continue;
151        }
152        let payload = serde_json::Value::Array(edges);
153        let text = payload.to_string();
154        let _ = scc_indexer::runtime::ingest_simple_edges(store, &text);
155    }
156}
157
158// trace:exempt reason=internal-detail
159pub fn reconcile(root: &Path) -> crate::Result<scc_indexer::runtime::Reconciliation> {
160    let store = crate::workspace::open_store(root)?;
161    scc_indexer::runtime::reconcile(&store).map_err(crate::EngineError::Other)
162}
163
164// trace:exempt reason=internal-detail
165pub fn embeddings_build(root: &Path) -> crate::Result<serde_json::Value> {
166    use scc_indexer::embed::EmbedConfig;
167    let store = crate::workspace::open_store(root)?;
168    let config = crate::workspace::load_config(root)?;
169    if !config.inference.enabled {
170        return Err(crate::EngineError::Other(
171            "inference is disabled — set `inference.enabled: true` in .scc/config.yaml".into(),
172        ));
173    }
174    let cfg = EmbedConfig::from_config(&config.inference);
175    if cfg.is_remote() && !config.security.allow_remote_models {
176        return Err(crate::EngineError::Other(
177            "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(),
178        ));
179    }
180    if store.snapshot_status()?.is_none() {
181        return Err(crate::EngineError::Other("not indexed — run `scc index` first".into()));
182    }
183    let n = scc_indexer::embed::embed_repository(&store, &cfg).map_err(crate::EngineError::Other)?;
184    store.cache_clear()?;
185    Ok(serde_json::json!({"stored": n, "model": cfg.model}))
186}
187
188// trace:exempt reason=internal-detail
189pub fn embeddings_get(store: &scc_store::Store, entity_id: &str) -> crate::Result<serde_json::Value> {
190    match store.get_embedding(entity_id)? {
191        Some((v, model)) => Ok(serde_json::json!({"id": entity_id, "model": model, "dims": v.len(), "vector": v})),
192        None => Ok(serde_json::Value::Null),
193    }
194}
195
196// trace:exempt reason=internal-detail
197pub fn embeddings_status(store: &scc_store::Store) -> crate::Result<serde_json::Value> {
198    let count = store.embedding_count()?;
199    Ok(serde_json::json!({"embeddings": count}))
200}
201
202// trace:exempt reason=internal-detail
203pub fn external_docs(root: &Path, dependency: &str) -> crate::Result<String> {
204    let config = crate::workspace::load_config(root)?;
205    if config.integrations.context7_command.is_empty() {
206        return Err(crate::EngineError::Other(
207            "Context7 is not configured — set integrations.context7_command in .scc/config.yaml".into(),
208        ));
209    }
210    let mut client = scc_indexer::adapters::context7::start(&config.integrations.context7_command, root)
211        .map_err(crate::EngineError::Other)?;
212    client.docs_for(dependency).map_err(crate::EngineError::Other)
213}
214
215/// Namespaced plugin state (§23): get one key. Requires the calling
216/// plugin's StateRead grant (checked by the host before dispatch; the
217/// engine re-checks here so direct invoke() callers are gated too).
218// trace:exempt reason=internal-detail
219pub fn plugin_state_get(
220    store: &scc_store::Store,
221    plugin_id: &str,
222    grants: &[scc_plugin_api::Permission],
223    key: &str,
224) -> crate::Result<serde_json::Value> {
225    require_state_grant(plugin_id, grants, false)?;
226    Ok(store.plugin_state_get(plugin_id, key)?.into())
227}
228
229/// Put one key (JSON text). Requires StateWrite.
230// trace:exempt reason=internal-detail
231pub fn plugin_state_put(
232    store: &scc_store::Store,
233    plugin_id: &str,
234    grants: &[scc_plugin_api::Permission],
235    key: &str,
236    value: &str,
237) -> crate::Result<serde_json::Value> {
238    require_state_grant(plugin_id, grants, true)?;
239    store.plugin_state_put(plugin_id, key, value)?;
240    Ok(serde_json::json!({"ok": true}))
241}
242
243/// Delete one key. Requires StateWrite.
244// trace:exempt reason=internal-detail
245pub fn plugin_state_delete(
246    store: &scc_store::Store,
247    plugin_id: &str,
248    grants: &[scc_plugin_api::Permission],
249    key: &str,
250) -> crate::Result<serde_json::Value> {
251    require_state_grant(plugin_id, grants, true)?;
252    store.plugin_state_delete(plugin_id, key)?;
253    Ok(serde_json::json!({"ok": true}))
254}
255
256/// Scan keys by prefix (ordered, bounded). Requires StateRead.
257// trace:exempt reason=internal-detail
258pub fn plugin_state_scan(
259    store: &scc_store::Store,
260    plugin_id: &str,
261    grants: &[scc_plugin_api::Permission],
262    prefix: &str,
263    limit: usize,
264) -> crate::Result<serde_json::Value> {
265    require_state_grant(plugin_id, grants, false)?;
266    let rows = store.plugin_state_scan(plugin_id, prefix, limit)?;
267    Ok(serde_json::json!({
268        "keys": rows.iter().map(|(k, v)| serde_json::json!({"key": k, "value": v})).collect::<Vec<_>>(),
269    }))
270}
271
272/// Sidecar put/get/scan (§124 item 35, raw half): namespaced raw
273/// analyzer facts under (plugin, graph). Same grant gate as plugin state
274/// (StateRead for get/scan, StateWrite for put); values are opaque JSON
275/// text. Never consumed by ranking/context — promotion only.
276// trace:exempt reason=internal-detail
277pub fn sidecar_put(
278    store: &scc_store::Store,
279    plugin_id: &str,
280    grants: &[scc_plugin_api::Permission],
281    graph: &str,
282    key: &str,
283    value: &str,
284) -> crate::Result<serde_json::Value> {
285    require_state_grant(plugin_id, grants, true)?;
286    store.sidecar_put(plugin_id, graph, key, value)?;
287    Ok(serde_json::json!({"ok": true}))
288}
289
290// trace:exempt reason=internal-detail
291pub fn sidecar_get(
292    store: &scc_store::Store,
293    plugin_id: &str,
294    grants: &[scc_plugin_api::Permission],
295    graph: &str,
296    key: &str,
297) -> crate::Result<serde_json::Value> {
298    require_state_grant(plugin_id, grants, false)?;
299    Ok(store.sidecar_get(plugin_id, graph, key)?.into())
300}
301
302// trace:exempt reason=internal-detail
303pub fn sidecar_scan(
304    store: &scc_store::Store,
305    plugin_id: &str,
306    grants: &[scc_plugin_api::Permission],
307    graph: &str,
308    prefix: &str,
309    limit: usize,
310) -> crate::Result<serde_json::Value> {
311    require_state_grant(plugin_id, grants, false)?;
312    let rows = store.sidecar_scan(plugin_id, graph, prefix, limit)?;
313    Ok(serde_json::json!({
314        "keys": rows.iter().map(|(k, v)| serde_json::json!({"key": k, "value": v})).collect::<Vec<_>>(),
315    }))
316}
317
318// trace:exempt reason=internal-detail
319fn require_state_grant(
320    plugin_id: &str,
321    grants: &[scc_plugin_api::Permission],
322    write: bool,
323) -> crate::Result<()> {
324    let need = if write {
325        scc_plugin_api::Permission::StateWrite
326    } else {
327        scc_plugin_api::Permission::StateRead
328    };
329    if grants.contains(&need) {
330        Ok(())
331    } else {
332        Err(crate::EngineError::Other(format!(
333            "permission denied: plugin {plugin_id} lacks {}",
334            need.as_str()
335        )))
336    }
337}
338
339/// Plugin evidence import (spec §31 EvidenceProvider): `import.<plugin-id>`
340/// asks the plugin declaring the `evidence-provider` extension (or the
341/// legacy `evidence.import` operation) for a contribution batch, then
342/// commits it through the NORMAL validate+commit path — never a side
343/// door. Input carries the file path (`{"file": ...}`); the plugin
344/// answers `{"batch": {entities, relationships, evidence, diagnostics}}`.
345/// Unknown plugin ids fail with the same vocabulary as unknown formats.
346/// Counts map onto ImportReport so every transport renders one shape.
347// trace:v1 id=impl.scc-engine-state.plugin-evidence work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
348pub fn import_plugin_evidence(
349    root: &Path,
350    plugin_id: &str,
351    file: &str,
352) -> crate::Result<scc_indexer::adapters::ImportReport> {
353    let config = crate::workspace::load_config(root)?;
354    let ap = crate::plugins::active(root, &config);
355    let plug = ap
356        .plugins
357        .iter()
358        .find(|p| {
359            p.manifest.id == plugin_id
360                && (p.manifest.extensions.iter().any(|e| e.extension_type == "evidence-provider")
361                    || p.manifest.operations.iter().any(|o| o == "evidence.import"))
362        })
363        .cloned()
364        .ok_or_else(|| {
365            crate::EngineError::Other(format!(
366                "unknown import format '{plugin_id}' (use scip, ccg, gitnexus, beads, cbm, hindsight, tracelayer, or a plugin id declaring evidence-provider)"
367            ))
368        })?;
369    let out = scc_plugin_host::call(
370        &plug,
371        "evidence.import",
372        serde_json::json!({"file": file}),
373        None,
374    )
375    .map_err(|e| crate::EngineError::Other(format!("plugin {plugin_id} evidence.import: {e}")))?;
376    let batch = out.get("batch").cloned().unwrap_or(serde_json::json!({}));
377    let store = crate::workspace::open_store(root)?;
378    let committed =
379        crate::plugins::commit_contribution(&store, plugin_id, &batch)?;
380    store
381        .bump_epoch(scc_store::ModelEpochKind::Evidence)
382        ?;
383    crate::index::recompile(&store)?;
384    Ok(scc_indexer::adapters::ImportReport {
385        symbols: committed.get("entities").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
386        calls: committed.get("relationships").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
387        imports: committed.get("evidence").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
388        errors: committed.get("diagnostics").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
389    })
390}