scc-engine 0.2.11

SCC engine facade: one orchestration seam for CLI, HTTP, MCP, SDKs, RPC, FFI
Documentation
//! Engine state: lessons, beads, evidence import, runtime.
//!
//! Value-returning operations; the CLI renders. Import bumps the evidence
//! epoch and recompiles the derived layer (moved with the code).

use std::io::Write;
use std::path::{Path, PathBuf};

// trace:exempt reason=internal-detail
pub fn lessons_add(root: &Path, text: &str) -> crate::Result<(String, PathBuf)> {
    let dir = crate::workspace::scc_dir(root);
    std::fs::create_dir_all(&dir)?;
    let path = dir.join("lessons.jsonl");
    let n = std::fs::read_to_string(&path)
        .map(|s| s.lines().filter(|l| !l.trim().is_empty()).count())
        .unwrap_or(0);
    let id = format!("lesson-{}", n + 1);
    let record = serde_json::json!({
        "id": id,
        "text": text,
        "created_at": scc_core::now_rfc3339(),
    });
    let mut f = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(&path)
        .map_err(|e| crate::EngineError::Other(format!("lessons: {e}")))?;
    writeln!(f, "{record}").map_err(|e| crate::EngineError::Other(format!("lessons: {e}")))?;
    Ok((id, path))
}

// trace:exempt reason=internal-detail
pub fn lessons_list(root: &Path, limit: usize) -> crate::Result<Vec<(String, Vec<String>)>> {
    let store = crate::workspace::open_store(root)?;
    Ok(scc_indexer::adapters::hindsight::lessons(&store, limit))
}

// trace:exempt reason=internal-detail
pub fn beads(root: &Path, limit: usize) -> crate::Result<Vec<String>> {
    Ok(scc_indexer::adapters::beads::active_beads(root, limit))
}

// trace:exempt reason=internal-detail
pub fn import_evidence(root: &Path, format: &str, file: &str) -> crate::Result<scc_indexer::adapters::ImportReport> {
    let store = crate::workspace::open_store(root)?;
    let report = match format {
        "scip" => scc_indexer::adapters::import_scip(&store, std::path::Path::new(file)),
        "ccg" => scc_indexer::adapters::import_ccg(&store, std::path::Path::new(file)),
        "gitnexus" => scc_indexer::adapters::gitnexus::import_gitnexus(&store, std::path::Path::new(file))
            .map(|r| scc_indexer::adapters::ImportReport {
                symbols: r.symbols,
                calls: r.edges,
                imports: 0,
                errors: r.errors,
            }),
        "beads" => scc_indexer::adapters::beads::import_beads(&store, std::path::Path::new(file))
            .map(|r| scc_indexer::adapters::ImportReport {
                symbols: r.tasks,
                calls: r.dependencies,
                imports: r.active,
                errors: r.errors,
            }),
        "cbm" => scc_indexer::adapters::cbm::import_cbm(&store, std::path::Path::new(file))
            .map(|r| scc_indexer::adapters::ImportReport {
                symbols: r.symbols,
                calls: r.relationships,
                imports: 0,
                errors: r.errors,
            }),
        "hindsight" => scc_indexer::adapters::hindsight::import_hindsight(&store, std::path::Path::new(file))
            .map(|r| scc_indexer::adapters::ImportReport {
                symbols: r.lessons,
                calls: 0,
                imports: 0,
                errors: r.errors,
            }),
        "tracelayer" => scc_indexer::adapters::tracelayer::import_tracelayer(&store, std::path::Path::new(file))
            .map(|r| scc_indexer::adapters::ImportReport {
                symbols: r.requirements + r.implementations + r.tests + r.decisions,
                calls: r.relationships,
                imports: r.work_items,
                errors: r.errors,
            }),
        other => {
            return import_plugin_evidence(root, other, file);
        }
    }
    .map_err(crate::EngineError::Other)?;
    store.bump_epoch(scc_store::ModelEpochKind::Evidence)?;
    crate::index::recompile(&store)?;
    Ok(report)
}

// trace:exempt reason=internal-detail
pub fn runtime_edges(root: &Path) -> crate::Result<Vec<scc_indexer::runtime::RuntimeEdge>> {
    let store = crate::workspace::open_store(root)?;
    scc_indexer::runtime::runtime_edges(&store).map_err(crate::EngineError::Other)
}

// trace:exempt reason=internal-detail
pub fn ingest_runtime(root: &Path, body: &str) -> crate::Result<()> {
    let store = crate::workspace::open_store(root)?;
    if body.contains("resourceSpans") {
        scc_indexer::runtime::ingest_otlp_json(&store, body)
            .map_err(crate::EngineError::Other)?;
    } else {
        scc_indexer::runtime::ingest_simple_edges(&store, body)
            .map_err(crate::EngineError::Other)?;
    }
    // Runtime-evidence plugins (§31 RuntimeEvidenceProvider): fan out the
    // same raw body; contributed edges land in runtime_edges (OBSERVED
    // path) via the normal ingest. Failures degrade to builtin-only.
    ingest_plugin_runtime_edges(root, &store, body);
    Ok(())
}

/// Fan out one ingest body to `runtime-evidence` plugins. Each plugin
/// declaring the extension (or the legacy op) is called once with
/// `{"body": ...}` and answers `{"edges": [...]}` in the simple-edges
/// shape. Malformed plugin rows fail that plugin only (degrade, never
/// the ingest); a crashing plugin is skipped with no model change.
// trace:v1 id=impl.scc-engine-state.plugin-runtime work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
pub fn ingest_plugin_runtime_edges(
    root: &Path,
    store: &scc_store::Store,
    body: &str,
) {
    let config = match crate::workspace::load_config(root) {
        Ok(c) => c,
        Err(_) => return,
    };
    let ap = crate::plugins::active(root, &config);
    for plug in &ap.plugins {
        let is_provider = plug.manifest.extensions.iter().any(|e| e.extension_type == "runtime-evidence")
            || plug.manifest.operations.iter().any(|o| o == "runtime.evidence");
        if !is_provider {
            continue;
        }
        let out = match scc_plugin_host::call(
            plug,
            "runtime.evidence",
            serde_json::json!({"body": body}),
            None,
        ) {
            Ok(v) => v,
            Err(_) => continue,
        };
        let edges = out.get("edges").and_then(|v| v.as_array()).cloned().unwrap_or_default();
        if edges.is_empty() {
            continue;
        }
        let payload = serde_json::Value::Array(edges);
        let text = payload.to_string();
        let _ = scc_indexer::runtime::ingest_simple_edges(store, &text);
    }
}

// trace:exempt reason=internal-detail
pub fn reconcile(root: &Path) -> crate::Result<scc_indexer::runtime::Reconciliation> {
    let store = crate::workspace::open_store(root)?;
    scc_indexer::runtime::reconcile(&store).map_err(crate::EngineError::Other)
}

// trace:exempt reason=internal-detail
pub fn embeddings_build(root: &Path) -> crate::Result<serde_json::Value> {
    use scc_indexer::embed::EmbedConfig;
    let store = crate::workspace::open_store(root)?;
    let config = crate::workspace::load_config(root)?;
    if !config.inference.enabled {
        return Err(crate::EngineError::Other(
            "inference is disabled — set `inference.enabled: true` in .scc/config.yaml".into(),
        ));
    }
    let cfg = EmbedConfig::from_config(&config.inference);
    if cfg.is_remote() && !config.security.allow_remote_models {
        return Err(crate::EngineError::Other(
            "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(),
        ));
    }
    if store.snapshot_status()?.is_none() {
        return Err(crate::EngineError::Other("not indexed — run `scc index` first".into()));
    }
    let n = scc_indexer::embed::embed_repository(&store, &cfg).map_err(crate::EngineError::Other)?;
    store.cache_clear()?;
    Ok(serde_json::json!({"stored": n, "model": cfg.model}))
}

// trace:exempt reason=internal-detail
pub fn embeddings_get(store: &scc_store::Store, entity_id: &str) -> crate::Result<serde_json::Value> {
    match store.get_embedding(entity_id)? {
        Some((v, model)) => Ok(serde_json::json!({"id": entity_id, "model": model, "dims": v.len(), "vector": v})),
        None => Ok(serde_json::Value::Null),
    }
}

// trace:exempt reason=internal-detail
pub fn embeddings_status(store: &scc_store::Store) -> crate::Result<serde_json::Value> {
    let count = store.embedding_count()?;
    Ok(serde_json::json!({"embeddings": count}))
}

// trace:exempt reason=internal-detail
pub fn external_docs(root: &Path, dependency: &str) -> crate::Result<String> {
    let config = crate::workspace::load_config(root)?;
    if config.integrations.context7_command.is_empty() {
        return Err(crate::EngineError::Other(
            "Context7 is not configured — set integrations.context7_command in .scc/config.yaml".into(),
        ));
    }
    let mut client = scc_indexer::adapters::context7::start(&config.integrations.context7_command, root)
        .map_err(crate::EngineError::Other)?;
    client.docs_for(dependency).map_err(crate::EngineError::Other)
}

/// Namespaced plugin state (§23): get one key. Requires the calling
/// plugin's StateRead grant (checked by the host before dispatch; the
/// engine re-checks here so direct invoke() callers are gated too).
// trace:exempt reason=internal-detail
pub fn plugin_state_get(
    store: &scc_store::Store,
    plugin_id: &str,
    grants: &[scc_plugin_api::Permission],
    key: &str,
) -> crate::Result<serde_json::Value> {
    require_state_grant(plugin_id, grants, false)?;
    Ok(store.plugin_state_get(plugin_id, key)?.into())
}

/// Put one key (JSON text). Requires StateWrite.
// trace:exempt reason=internal-detail
pub fn plugin_state_put(
    store: &scc_store::Store,
    plugin_id: &str,
    grants: &[scc_plugin_api::Permission],
    key: &str,
    value: &str,
) -> crate::Result<serde_json::Value> {
    require_state_grant(plugin_id, grants, true)?;
    store.plugin_state_put(plugin_id, key, value)?;
    Ok(serde_json::json!({"ok": true}))
}

/// Delete one key. Requires StateWrite.
// trace:exempt reason=internal-detail
pub fn plugin_state_delete(
    store: &scc_store::Store,
    plugin_id: &str,
    grants: &[scc_plugin_api::Permission],
    key: &str,
) -> crate::Result<serde_json::Value> {
    require_state_grant(plugin_id, grants, true)?;
    store.plugin_state_delete(plugin_id, key)?;
    Ok(serde_json::json!({"ok": true}))
}

/// Scan keys by prefix (ordered, bounded). Requires StateRead.
// trace:exempt reason=internal-detail
pub fn plugin_state_scan(
    store: &scc_store::Store,
    plugin_id: &str,
    grants: &[scc_plugin_api::Permission],
    prefix: &str,
    limit: usize,
) -> crate::Result<serde_json::Value> {
    require_state_grant(plugin_id, grants, false)?;
    let rows = store.plugin_state_scan(plugin_id, prefix, limit)?;
    Ok(serde_json::json!({
        "keys": rows.iter().map(|(k, v)| serde_json::json!({"key": k, "value": v})).collect::<Vec<_>>(),
    }))
}

/// Sidecar put/get/scan (§124 item 35, raw half): namespaced raw
/// analyzer facts under (plugin, graph). Same grant gate as plugin state
/// (StateRead for get/scan, StateWrite for put); values are opaque JSON
/// text. Never consumed by ranking/context — promotion only.
// trace:exempt reason=internal-detail
pub fn sidecar_put(
    store: &scc_store::Store,
    plugin_id: &str,
    grants: &[scc_plugin_api::Permission],
    graph: &str,
    key: &str,
    value: &str,
) -> crate::Result<serde_json::Value> {
    require_state_grant(plugin_id, grants, true)?;
    store.sidecar_put(plugin_id, graph, key, value)?;
    Ok(serde_json::json!({"ok": true}))
}

// trace:exempt reason=internal-detail
pub fn sidecar_get(
    store: &scc_store::Store,
    plugin_id: &str,
    grants: &[scc_plugin_api::Permission],
    graph: &str,
    key: &str,
) -> crate::Result<serde_json::Value> {
    require_state_grant(plugin_id, grants, false)?;
    Ok(store.sidecar_get(plugin_id, graph, key)?.into())
}

// trace:exempt reason=internal-detail
pub fn sidecar_scan(
    store: &scc_store::Store,
    plugin_id: &str,
    grants: &[scc_plugin_api::Permission],
    graph: &str,
    prefix: &str,
    limit: usize,
) -> crate::Result<serde_json::Value> {
    require_state_grant(plugin_id, grants, false)?;
    let rows = store.sidecar_scan(plugin_id, graph, prefix, limit)?;
    Ok(serde_json::json!({
        "keys": rows.iter().map(|(k, v)| serde_json::json!({"key": k, "value": v})).collect::<Vec<_>>(),
    }))
}

// trace:exempt reason=internal-detail
fn require_state_grant(
    plugin_id: &str,
    grants: &[scc_plugin_api::Permission],
    write: bool,
) -> crate::Result<()> {
    let need = if write {
        scc_plugin_api::Permission::StateWrite
    } else {
        scc_plugin_api::Permission::StateRead
    };
    if grants.contains(&need) {
        Ok(())
    } else {
        Err(crate::EngineError::Other(format!(
            "permission denied: plugin {plugin_id} lacks {}",
            need.as_str()
        )))
    }
}

/// Plugin evidence import (spec §31 EvidenceProvider): `import.<plugin-id>`
/// asks the plugin declaring the `evidence-provider` extension (or the
/// legacy `evidence.import` operation) for a contribution batch, then
/// commits it through the NORMAL validate+commit path — never a side
/// door. Input carries the file path (`{"file": ...}`); the plugin
/// answers `{"batch": {entities, relationships, evidence, diagnostics}}`.
/// Unknown plugin ids fail with the same vocabulary as unknown formats.
/// Counts map onto ImportReport so every transport renders one shape.
// trace:v1 id=impl.scc-engine-state.plugin-evidence work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
pub fn import_plugin_evidence(
    root: &Path,
    plugin_id: &str,
    file: &str,
) -> crate::Result<scc_indexer::adapters::ImportReport> {
    let config = crate::workspace::load_config(root)?;
    let ap = crate::plugins::active(root, &config);
    let plug = ap
        .plugins
        .iter()
        .find(|p| {
            p.manifest.id == plugin_id
                && (p.manifest.extensions.iter().any(|e| e.extension_type == "evidence-provider")
                    || p.manifest.operations.iter().any(|o| o == "evidence.import"))
        })
        .cloned()
        .ok_or_else(|| {
            crate::EngineError::Other(format!(
                "unknown import format '{plugin_id}' (use scip, ccg, gitnexus, beads, cbm, hindsight, tracelayer, or a plugin id declaring evidence-provider)"
            ))
        })?;
    let out = scc_plugin_host::call(
        &plug,
        "evidence.import",
        serde_json::json!({"file": file}),
        None,
    )
    .map_err(|e| crate::EngineError::Other(format!("plugin {plugin_id} evidence.import: {e}")))?;
    let batch = out.get("batch").cloned().unwrap_or(serde_json::json!({}));
    let store = crate::workspace::open_store(root)?;
    let committed =
        crate::plugins::commit_contribution(&store, plugin_id, &batch)?;
    store
        .bump_epoch(scc_store::ModelEpochKind::Evidence)
        ?;
    crate::index::recompile(&store)?;
    Ok(scc_indexer::adapters::ImportReport {
        symbols: committed.get("entities").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
        calls: committed.get("relationships").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
        imports: committed.get("evidence").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
        errors: committed.get("diagnostics").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
    })
}