Skip to main content

scc_engine/
plugins.rs

1//! Engine plugins namespace: discovery, custom-operation dispatch, cache keys.
2//!
3//! Custom operations (spec 31): a plugin operation like `acme.echo` is
4//! callable through `invoke()`, RPC, HTTP, SDKs, and FFI with no per-transport
5//! code — the fallback arm in `invoke` routes unknown `acme.*` ids here.
6//! Provenance (spec 25): every plugin output is wrapped with its origin.
7//! Failure policy (spec 26): required = hard error, warn/optional = skip
8//! with a structured diagnostic. Cache keys (spec 27) include the plugin
9//! lock so upgrades invalidate ranking-dependent caches automatically.
10
11use std::path::Path;
12
13// trace:exempt reason=internal-detail
14pub struct ActivePlugins {
15    pub plugins: Vec<scc_plugin_host::LoadedPlugin>,
16    pub diagnostics: Vec<scc_plugin_host::PluginDiagnostic>,
17}
18
19// trace:exempt reason=internal-detail
20pub fn active(root: &Path, config: &scc_indexer::Config) -> ActivePlugins {
21    let mut ap = ActivePlugins { plugins: Vec::new(), diagnostics: Vec::new() };
22    let mut plugins = scc_plugin_host::discover(root);
23    // Project allow-list: only `plugins.enabled` run when non-empty.
24    if !config.plugins.enabled.is_empty() {
25        plugins.retain(|p| config.plugins.enabled.contains(&p.manifest.id));
26    }
27    // Per-plugin config from the project file.
28    for p in &mut plugins {
29        if let Some(c) = config.plugins.config.get(&p.manifest.id) {
30            p.config = c.clone();
31        }
32    }
33    // Grants: explicit project grants narrow manifest defaults. Unknown
34    // grant names are diagnostics (fail-loud: a typo must not silently
35    // narrow the grant set and misdirect the later denied error).
36    let mut grants: std::collections::BTreeMap<String, Vec<scc_plugin_api::Permission>> =
37        std::collections::BTreeMap::new();
38    for (k, v) in &config.plugins.grants {
39        let mut perms = Vec::new();
40        for s in v {
41            match scc_plugin_api::Permission::parse(s) {
42                Ok(p) => perms.push(p),
43                Err(e) => ap.diagnostics.push(scc_plugin_host::PluginDiagnostic {
44                    plugin: k.clone(), operation: "grants".into(),
45                    error: e, action: "skipped".into(),
46                }),
47            }
48        }
49        grants.insert(k.clone(), perms);
50    }
51    scc_plugin_host::apply_grants(&mut plugins, &grants);
52    ap.plugins = plugins;
53    ap
54}
55
56// trace:exempt reason=internal-detail
57pub fn lock_entries(ap: &ActivePlugins) -> Vec<serde_json::Value> {
58    ap.plugins.iter().map(scc_plugin_host::lock_entry).collect()
59}
60
61// trace:v1 id=impl.scc-engine-plugins.cache-key-fragment work=WORK-SI-MMMJA4G6 implements=PLAN-SI-SYKFPBEC
62pub fn cache_key_fragment(ap: &ActivePlugins) -> String {
63    // Plugin-affecting state that must invalidate caches (spec 27).
64    let mut h = blake3::Hasher::new();
65    for e in lock_entries(ap) {
66        h.update(e.to_string().as_bytes());
67    }
68    format!("plugins:{}", &h.finalize().to_hex()[..16])
69}
70
71/// Dispatch a plugin-provided operation. Returns `(output, diagnostic)`:
72/// warn/optional failures yield the engine error + a diagnostic instead of
73/// failing the call — the host records what was skipped (spec 26).
74// trace:v1 id=impl.scc-engine-plugins.call-operation work=WORK-SI-MMMJA4G6 implements=PLAN-SI-SYKFPBEC
75pub fn call_operation(ap: &mut ActivePlugins, operation: &str, input: serde_json::Value) -> crate::Result<serde_json::Value> {
76    let plugin = scc_plugin_host::provider_for(&ap.plugins, operation).map_err(|e| crate::EngineError::Other(e.to_string()))?;
77    let id = plugin.manifest.id.clone();
78    let policy = plugin.manifest.failure_policy.clone();
79    match scc_plugin_host::call(plugin, operation, input, None) {
80        Ok(output) => Ok(with_provenance(&id, &plugin.manifest.version, operation, output)),
81        Err(e) => {
82            let action = if policy == "required" { "failed" } else { "skipped" };
83            ap.diagnostics.push(scc_plugin_host::PluginDiagnostic {
84                plugin: id.clone(), operation: operation.into(), error: e.to_string(), action: action.into(),
85            });
86            if policy == "required" {
87                Err(crate::EngineError::Other(format!("plugin {id} {operation}: {e}")))
88            } else {
89                Ok(serde_json::json!({"skipped": true, "plugin": id, "error": e.to_string()}))
90            }
91        }
92    }
93}
94
95// trace:exempt reason=internal-detail
96fn with_provenance(plugin_id: &str, version: &str, operation: &str, mut output: serde_json::Value) -> serde_json::Value {
97    // Mandatory provenance (spec 25): every plugin output carries origin.
98    if let Some(obj) = output.as_object_mut() {
99        obj.insert("_origin".into(), serde_json::json!({
100            "kind": "plugin", "plugin_id": plugin_id,
101            "plugin_version": version, "extension": operation,
102        }));
103    }
104    output
105}
106
107/// Deterministic extension order (§19): priority ascending, plugin id
108/// ascending, then before/after DAG edges. Unknown references and cycles
109/// are startup errors — never silent misordering.
110// trace:exempt reason=internal-detail
111pub struct ExtensionOrder {
112    pub extension_type: String,
113    pub id: String,
114    pub priority: i32,
115    pub after: Vec<String>,
116    pub before: Vec<String>,
117}
118
119// trace:exempt reason=internal-detail
120impl ExtensionOrder {
121    // trace:exempt reason=internal-detail
122    pub fn key(&self) -> String { format!("{}:{}", self.extension_type, self.id) }
123}
124
125// trace:v1 id=impl.scc-engine-plugins.order-extensions work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
126pub fn order_extensions(extensions: &[ExtensionOrder]) -> crate::Result<Vec<usize>> {
127    use std::collections::{BTreeMap, BTreeSet};
128    let n = extensions.len();
129    // Node key: canonical `type:id`.
130    let key_of = |i: usize| -> String { extensions[i].key() };
131    let mut index: BTreeMap<String, usize> = BTreeMap::new();
132    for i in 0..n {
133        let k = key_of(i);
134        if index.insert(k.clone(), i).is_some() {
135            return Err(crate::EngineError::Other(format!("duplicate extension registration '{k}'")));
136        }
137    }
138    // Edges: after(X) means X -> self; before(Y) means self -> Y.
139    let mut preds: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); n];
140    let mut succs: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); n];
141    for i in 0..n {
142        for a in &extensions[i].after {
143            let j = *index.get(a).ok_or_else(|| {
144                crate::EngineError::Other(format!("extension '{}' orders after unknown '{}'", key_of(i), a))
145            })?;
146            if j != i {
147                preds[i].insert(j);
148                succs[j].insert(i);
149            }
150        }
151        for b in &extensions[i].before {
152            let j = *index.get(b).ok_or_else(|| {
153                crate::EngineError::Other(format!("extension '{}' orders before unknown '{}'", key_of(i), b))
154            })?;
155            if j != i {
156                succs[i].insert(j);
157                preds[j].insert(i);
158            }
159        }
160    }
161    // Kahn with (priority, plugin-id, index) tie-break — deterministic.
162    let mut ready: Vec<usize> = (0..n).filter(|&i| preds[i].is_empty()).collect();
163    let sort_key = |i: &usize| (extensions[*i].priority, extensions[*i].id.clone(), *i);
164    ready.sort_by_key(sort_key);
165    let mut out = Vec::with_capacity(n);
166    while let Some(i) = ready.first().cloned() {
167        ready.remove(0);
168        out.push(i);
169        let mut newly: Vec<usize> = Vec::new();
170        for &j in &succs[i] {
171            preds[j].remove(&i);
172            if preds[j].is_empty() {
173                newly.push(j);
174            }
175        }
176        ready.extend(newly);
177        ready.sort_by_key(sort_key);
178    }
179    if out.len() != n {
180        let stuck: Vec<String> = (0..n).filter(|i| !out.contains(i)).map(key_of).collect();
181        return Err(crate::EngineError::Other(format!("extension ordering cycle: {}", stuck.join(", "))));
182    }
183    Ok(out)
184}
185
186/// Manifest extensions flattened to ordering entries, in plugin order.
187// trace:exempt reason=internal-detail
188pub(crate) fn collect_extensions(ap: &ActivePlugins) -> Vec<ExtensionOrder> {
189    let mut out = Vec::new();
190    for p in &ap.plugins {
191        for e in &p.manifest.extensions {
192            out.push(ExtensionOrder { extension_type: e.extension_type.clone(), id: e.id.clone(), priority: e.priority, after: e.after.clone(), before: e.before.clone() });
193        }
194    }
195    out
196}
197
198/// Validate a plugin contribution batch (§24): entity ids and kinds present,
199/// relationship endpoints resolve within (batch entities + graph), custom
200/// ontology namespaced `plugin:<id>/...`, evidence ids present.
201///
202/// Returns the normalized batch on success; broken plugins fail BEFORE any
203/// write — the model is never left half-committed.
204// trace:v1 id=impl.scc-engine-plugins.validate-contribution work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
205pub fn validate_contribution(
206    store: &scc_store::Store,
207    plugin_id: &str,
208    batch: &serde_json::Value,
209) -> crate::Result<serde_json::Value> {
210    // Fail loudly on unknown top-level keys: silently dropping a `flows`
211    // or `invariants` array would let a plugin believe it contributed
212    // facts SCC never stored. Flows/invariants/contracts derive from
213    // entities at graph-compile time — contribute entities, not rows.
214    if let Some(obj) = batch.as_object() {
215        let known = ["entities", "relationships", "evidence", "diagnostics"];
216        let unknown: Vec<&str> = obj.keys().filter(|k| !known.contains(&k.as_str())).map(|k| k.as_str()).collect();
217        if !unknown.is_empty() {
218            return Err(crate::EngineError::Other(format!(
219                "contribution has unsupported top-level keys [{}] (supported: entities, relationships, evidence, diagnostics); flows/invariants/contracts derive from entities at graph-compile time",
220                unknown.join(", ")
221            )));
222        }
223    }
224    let entities = batch.get("entities").and_then(|v| v.as_array()).cloned().unwrap_or_default();
225    let relationships = batch.get("relationships").and_then(|v| v.as_array()).cloned().unwrap_or_default();
226    let evidence = batch.get("evidence").and_then(|v| v.as_array()).cloned().unwrap_or_default();
227    let mut ids: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
228    for e in store.all_entities()? {
229        ids.insert(e.id);
230    }
231    for (i, e) in entities.iter().enumerate() {
232        let id = e.get("id").and_then(|v| v.as_str()).unwrap_or("");
233        let kind = e.get("kind").and_then(|v| v.as_str()).unwrap_or("");
234        if id.is_empty() {
235            return Err(crate::EngineError::Other(format!("contribution entity #{i}: missing id")));
236        }
237        if kind.is_empty() {
238            return Err(crate::EngineError::Other(format!("contribution entity '{id}': missing kind")));
239        }
240        if kind.contains('/') && !kind.starts_with("plugin:") {
241            return Err(crate::EngineError::Other(format!(
242                "contribution entity '{id}': custom kind '{kind}' must be namespaced 'plugin:<id>/...'"
243            )));
244        }
245        ids.insert(id.to_string());
246    }
247    for (i, r) in relationships.iter().enumerate() {
248        let (sub, pred, obj) = (
249            r.get("subject").and_then(|v| v.as_str()).unwrap_or(""),
250            r.get("predicate").and_then(|v| v.as_str()).unwrap_or(""),
251            r.get("object").and_then(|v| v.as_str()).unwrap_or(""),
252        );
253        if sub.is_empty() || pred.is_empty() || obj.is_empty() {
254            return Err(crate::EngineError::Other(format!("contribution relationship #{i}: subject/predicate/object required")));
255        }
256        if pred.contains('/') && !pred.starts_with("plugin:") {
257            return Err(crate::EngineError::Other(format!(
258                "contribution relationship #{i}: custom predicate '{pred}' must be namespaced 'plugin:<id>/...'"
259            )));
260        }
261        for end in [sub, obj] {
262            if !ids.contains(end) {
263                return Err(crate::EngineError::Other(format!(
264                    "contribution relationship #{i}: dangling endpoint '{end}'"
265                )));
266            }
267        }
268    }
269    for (i, e) in evidence.iter().enumerate() {
270        if e.get("id").and_then(|v| v.as_str()).unwrap_or("").is_empty() {
271            return Err(crate::EngineError::Other(format!("contribution evidence #{i}: missing id")));
272        }
273    }
274    // Provenance-stamp every record (§25) before commit.
275    let stamp = |mut v: serde_json::Value| -> serde_json::Value {
276        if let Some(obj) = v.as_object_mut() {
277            obj.insert("_origin".into(), serde_json::json!({
278                "kind": "plugin", "plugin_id": plugin_id,
279            }));
280        }
281        v
282    };
283    let diagnostics = batch.get("diagnostics").and_then(|v| v.as_array()).cloned().unwrap_or_default();
284    Ok(serde_json::json!({
285        "entities": entities.into_iter().map(stamp).collect::<Vec<_>>(),
286        "relationships": relationships.into_iter().map(stamp).collect::<Vec<_>>(),
287        "evidence": evidence.into_iter().map(stamp).collect::<Vec<_>>(),
288        "diagnostics": diagnostics,
289    }))
290}
291
292/// Commit a validated batch: entities, relationships, evidence in order.
293/// Validate-then-commit keeps broken plugins from half-writing the model.
294// trace:v1 id=impl.scc-engine-plugins.commit-contribution work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
295pub fn commit_contribution(
296    store: &scc_store::Store,
297    plugin_id: &str,
298    batch: &serde_json::Value,
299) -> crate::Result<serde_json::Value> {
300    // Spec 24: validate everything BEFORE writing, then commit inside one
301    // batch so a mid-batch failure rolls back instead of leaving half a
302    // graph. Decode errors also abort before any write.
303    let checked = validate_contribution(store, plugin_id, batch)?;
304    let entities: Vec<scc_core::Entity> = checked
305        .get("entities").and_then(|v| v.as_array()).cloned().unwrap_or_default()
306        .into_iter().map(serde_json::from_value)
307        .collect::<std::result::Result<Vec<_>, _>>()
308        .map_err(|e| crate::EngineError::Other(format!("contribution entity decode: {e}")))?;
309    let rels: Vec<scc_core::Relationship> = checked
310        .get("relationships").and_then(|v| v.as_array()).cloned().unwrap_or_default()
311        .into_iter().map(serde_json::from_value)
312        .collect::<std::result::Result<Vec<_>, _>>()
313        .map_err(|e| crate::EngineError::Other(format!("contribution relationship decode: {e}")))?;
314    let mut evs: Vec<scc_core::Evidence> = checked
315        .get("evidence").and_then(|v| v.as_array()).cloned().unwrap_or_default()
316        .into_iter().map(serde_json::from_value)
317        .collect::<std::result::Result<Vec<_>, _>>()
318        .map_err(|e| crate::EngineError::Other(format!("contribution evidence decode: {e}")))?;
319    for ev in evs.iter_mut() {
320        // Provenance stamp survives the typed decode via extractor tag.
321        ev.extractor = Some(format!("plugin:{plugin_id}"));
322    }
323    store.batch_begin().map_err(crate::EngineError::Store)?;
324    let mut counts = (0usize, 0usize, 0usize);
325    for e in &entities {
326        if let Err(err) = store.insert_entity(e, &[format!("plugin:{plugin_id}")]) {
327            store.batch_abort();
328            return Err(crate::EngineError::Store(err));
329        }
330        counts.0 += 1;
331    }
332    for r in &rels {
333        if let Err(err) = store.insert_relationship(r, &format!("plugin:{plugin_id}")) {
334            store.batch_abort();
335            return Err(crate::EngineError::Store(err));
336        }
337        counts.1 += 1;
338    }
339    for ev in &evs {
340        if let Err(err) = store.insert_evidence(ev) {
341            store.batch_abort();
342            return Err(crate::EngineError::Store(err));
343        }
344        counts.2 += 1;
345    }
346    store.batch_end().map_err(crate::EngineError::Store)?;
347    let n_diag = checked.get("diagnostics").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0);
348    Ok(serde_json::json!({"entities": counts.0, "relationships": counts.1, "evidence": counts.2, "diagnostics": n_diag}))
349}
350
351/// Context-section contributions (§17 Context): every `context-section:*`
352/// extension renders one markdown section into the task pack.
353///
354/// Input carries goal/files/symbols; output `section` markdown is spliced
355/// verbatim under a provenance header (`PLUGIN SECTION <id> from <plugin>`).
356/// Failures follow policy: required = hard error, else skip with diagnostic
357/// recorded in the pack warnings channel via the returned notes.
358// trace:v1 id=impl.scc-engine-plugins.context-sections work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
359pub fn context_sections(
360    root: &std::path::Path,
361    config: &scc_indexer::Config,
362    goal: &str,
363    files: &[String],
364    symbols: &[String],
365) -> String {
366    let mut ap = active(root, config);
367    // Deterministic order: registration order (plugin discovery is sorted).
368    let mut out = String::new();
369    let specs: Vec<(String, String, String)> = ap
370        .plugins
371        .iter()
372        .flat_map(|p| {
373            p.manifest.extensions.iter().filter(|e| e.extension_type == "context-section").map(|e| {
374                (p.manifest.id.clone(), e.id.clone(), p.manifest.failure_policy.clone())
375            })
376        })
377        .collect();
378    for (pid, ext_id, policy) in specs {
379        let plug = match ap.plugins.iter().find(|p| p.manifest.id == pid).cloned() {
380            Some(p) => p,
381            None => continue,
382        };
383        let input = serde_json::json!({"goal": goal, "files": files, "symbols": symbols, "section": ext_id});
384        match scc_plugin_host::call(&plug, "context.section", input, None) {
385            Ok(v) => {
386                if let Some(section) = v.get("section").and_then(|s| s.as_str()) {
387                    if !section.trim().is_empty() {
388                        out.push_str(&format!("\n# PLUGIN SECTION {ext_id} (from {pid} — plugin content, not verified facts)\n"));
389                        out.push_str(section.trim());
390                        out.push('\n');
391                    }
392                }
393            }
394            Err(e) => {
395                if policy == "required" {
396                    out.push_str(&format!("\n# PLUGIN SECTION {ext_id} FAILED (from {pid}): {e}\n"));
397                }
398                ap.diagnostics.push(scc_plugin_host::PluginDiagnostic {
399                    plugin: pid, operation: "context.section".into(), error: e.to_string(),
400                    action: if policy == "required" { "failed".into() } else { "skipped".into() },
401                });
402            }
403        }
404    }
405    // Diagnostics outlive the call: stash count in a trailing marker the
406    // pack warnings channel can surface (no silent skips).
407    let _ = ap;
408    out
409}