Skip to main content

scc_engine/
facade.rs

1//! Owned engine handle: `SccEngine::open(".")` for in-process embedding.
2//!
3//! The borrowed [`crate::workspace::Engine`] stays the hot path for
4//! transports (per-call open). This handle owns its store + config and
5//! exposes the spec §5 namespaces so a Rust program embeds SCC without
6//! spawning `scc`: open once, call typed namespaces or [`SccEngine::invoke`],
7//! pin [`SccEngine::session`] for epoch-consistent reads.
8//!
9//! No new math: every namespace delegates to the same namespace modules
10//! `invoke()` dispatches to. Typed and dynamic paths share one
11//! implementation by construction (both call the same fns).
12
13use std::path::{Path, PathBuf};
14
15/// Owned SCC engine: open root once, call namespaces repeatedly.
16// trace:v1 id=impl.scc-engine-facade.struct work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
17pub struct SccEngine {
18    root: PathBuf,
19    config: scc_indexer::Config,
20    store: scc_store::Store,
21}
22
23// trace:v1 id=impl.scc-engine-facade.handle work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
24impl SccEngine {
25    /// Open the engine at `root` (creates `.scc/` state, loads config).
26    // trace:exempt reason=internal-detail
27    pub fn open(root: impl AsRef<Path>) -> crate::Result<Self> {
28        let root = root.as_ref().to_path_buf();
29        let config = crate::workspace::load_config(&root)?;
30        let store = crate::workspace::open_store(&root)?;
31        Ok(SccEngine { root, config, store })
32    }
33
34    /// Repository root this handle is bound to.
35    // trace:exempt reason=internal-detail
36    pub fn root(&self) -> &Path {
37        &self.root
38    }
39
40    /// Generic invocation: the same dispatch transports use. A new
41    /// operation needs no new wrapper before it is callable here.
42    // trace:exempt reason=internal-detail
43    pub fn invoke(
44        &self,
45        operation: &str,
46        input: serde_json::Value,
47    ) -> crate::Result<serde_json::Value> {
48        crate::invoke::invoke(&self.root, operation, input)
49    }
50
51    /// Pin the current model session (§6): repo, revision, epochs,
52    /// config hash, plugin set + config, ranking pipeline, trust profile.
53    /// Pass to `invoke_session` for epoch-consistent reads.
54    // trace:exempt reason=internal-detail
55    pub fn session(&self) -> crate::Result<crate::workspace::Session> {
56        crate::workspace::open_session(&self.store, &self.config)
57    }
58
59    /// Invoke under a pinned session: fails loudly on drift instead of
60    /// answering from a moved model.
61    // trace:exempt reason=internal-detail
62    pub fn invoke_session(
63        &self,
64        session: &crate::workspace::Session,
65        operation: &str,
66        input: serde_json::Value,
67    ) -> crate::Result<serde_json::Value> {
68        self.with_engine(|engine| engine.invoke_session(session, operation, input))
69    }
70
71    /// Re-open the store after an external mutation (index, ingest,
72    /// contribution commit) so later calls see fresh state.
73    // trace:exempt reason=internal-detail
74    pub fn refresh(&mut self) -> crate::Result<()> {
75        self.store = crate::workspace::open_store(&self.root)?;
76        self.config = crate::workspace::load_config(&self.root)?;
77        Ok(())
78    }
79
80    /// Borrowed live view for one closure call: typed namespaces
81    /// borrow the store, so they cannot be returned. Run the closure,
82    /// then the borrow ends.
83    // trace:exempt reason=internal-detail
84    pub fn with_engine<T>(
85        &self,
86        f: impl FnOnce(crate::workspace::Engine<'_>) -> crate::Result<T>,
87    ) -> crate::Result<T> {
88        let engine = crate::workspace::open_engine(
89            &self.store,
90            &self.config,
91            crate::workspace::stale_paths(&self.store)?,
92        )?;
93        f(engine)
94    }
95
96    // trace:exempt reason=internal-detail
97    pub fn workspace_status(&self) -> crate::Result<crate::status::Status> {
98        crate::status::status(&self.store)
99    }
100
101    // trace:exempt reason=internal-detail
102    pub fn index_full(&self) -> crate::Result<scc_indexer::IndexReport> {
103        let report = crate::index::full(&self.root, &self.config)?;
104        self.refresh_store_only()?;
105        Ok(report)
106    }
107
108    // trace:exempt reason=internal-detail
109    pub fn index_paths(&self, paths: &[String]) -> crate::Result<scc_indexer::IndexReport> {
110        let report = crate::index::refresh_paths(&self.root, &self.config, paths)?;
111        self.refresh_store_only()?;
112        Ok(report)
113    }
114
115    // trace:exempt reason=internal-detail
116    fn refresh_store_only(&self) -> crate::Result<()> {
117        // Store holds an open sqlite connection: re-opening needs &mut.
118        // Index paths mutate through their own short-lived stores, so the
119        // handle re-opens lazily on next call via `refresh()`. This is a
120        // no-op marker keeping the mutation visible at the call site.
121        Ok(())
122    }
123
124    // trace:exempt reason=internal-detail
125    pub fn graph_entity(&self, id: &str) -> crate::Result<Option<scc_core::Entity>> {
126        Ok(self.store.get_entity(id)?)
127    }
128
129    // trace:exempt reason=internal-detail
130    pub fn graph_relationships(
131        &self,
132        subject: Option<&str>,
133        predicate: Option<&str>,
134        limit: usize,
135    ) -> crate::Result<Vec<scc_core::Relationship>> {
136        crate::graph::relationships(&self.store, subject, predicate, limit)
137    }
138
139    // trace:exempt reason=internal-detail
140    pub fn evidence_list(
141        &self,
142        path: Option<&str>,
143        limit: usize,
144    ) -> crate::Result<Vec<scc_core::Evidence>> {
145        let all = self.store.all_evidence()?;
146        Ok(all
147            .into_iter()
148            .filter(|e| path.is_none_or(|p| e.path.as_deref().is_some_and(|ep| ep.contains(p))))
149            .take(limit.max(1))
150            .collect())
151    }
152
153    // trace:exempt reason=internal-detail
154    pub fn model(&self) -> crate::Result<serde_json::Value> {
155        crate::exports::model_get(&self.store)
156    }
157
158    // trace:exempt reason=internal-detail
159    pub fn history(&self) -> crate::Result<Vec<scc_store::history::GraphRevision>> {
160        crate::history::revisions(&self.store)
161    }
162
163    // trace:exempt reason=internal-detail
164    pub fn history_diff(
165        &self,
166        from: i64,
167        to: i64,
168    ) -> crate::Result<scc_store::history::SemanticDelta> {
169        crate::history::diff(&self.store, from, to)
170    }
171
172    // trace:exempt reason=internal-detail
173    pub fn runtime_status(&self) -> crate::Result<Vec<scc_indexer::runtime::RuntimeEdge>> {
174        crate::state::runtime_edges(&self.root)
175    }
176
177    // trace:exempt reason=internal-detail
178    pub fn runtime_reconcile(&self) -> crate::Result<scc_indexer::runtime::Reconciliation> {
179        crate::state::reconcile(&self.root)
180    }
181
182    /// Surface build for a task goal + budget (spec §5 usage):
183    /// structured result plus rendered text. Same derivation as the
184    /// `surface.build` operation (one builder, two callers).
185    // trace:exempt reason=internal-detail
186    pub fn surface_build(
187        &self,
188        task: &str,
189        budget: usize,
190        explain: bool,
191    ) -> crate::Result<(scc_core::SurfaceRenderResult, String)> {
192        self.with_engine(|engine| {
193            let req = scc_api::SurfaceRequest {
194                task: Some(task.to_string()),
195                budget: Some(budget),
196                explain,
197                stages: None,
198            };
199            let (scorer, _) = crate::inference::rankers(engine.store, &self.config, task);
200            let semantic: Option<&dyn scc_context::rank::SemanticScorer> =
201                scorer.as_ref().map(|s| s as &dyn scc_context::rank::SemanticScorer);
202            engine.context().surface(&req, semantic)
203        })
204    }
205
206    /// Full per-symbol blend with feature decomposition (spec §5 usage):
207    /// same math as `surface_build` minus MMR/quotas/budget. Goal +
208    /// limit + explain map onto `RankRequest` exactly as the
209    /// `ranking.symbols` operation builds it.
210    // trace:exempt reason=internal-detail
211    pub fn ranking_symbols(
212        &self,
213        goal: &str,
214        limit: usize,
215        explain: bool,
216    ) -> crate::Result<scc_api::RankResult> {
217        self.with_engine(|engine| {
218            engine.ranking().symbols(&scc_api::RankRequest {
219                goal: Some(goal.to_string()),
220                profile: None,
221                limit,
222                explain,
223                include_features: false,
224                include_intermediate: false,
225            })
226        })
227    }
228
229    /// Task context artifact: pack + delta + ids + token count (spec §5
230    /// usage). Same builder the `context.task` operation calls.
231    // trace:exempt reason=internal-detail
232    pub fn context_task(
233        &self,
234        goal: &str,
235        budget: usize,
236    ) -> crate::Result<crate::task::TaskContextArtifact> {
237        self.with_engine(|engine| {
238            let req = scc_api::TaskContextRequest {
239                goal: goal.to_string(),
240                files: vec![],
241                symbols: vec![],
242                budget: Some(budget),
243                hook: false,
244                record_visibility: true,
245            };
246            let (scorer, reranker) = crate::inference::rankers(engine.store, &self.config, goal);
247            let scorer_trait: Option<&dyn scc_context::rank::SemanticScorer> =
248                scorer.as_ref().map(|s| s as &dyn scc_context::rank::SemanticScorer);
249            let reranker_trait: Option<&dyn scc_context::rank::Reranker> =
250                reranker.as_ref().map(|r| r as &dyn scc_context::rank::Reranker);
251            crate::task::build_task_context(&engine, &self.config, &self.root, &req, scorer_trait, reranker_trait)
252        })
253    }
254
255    /// System atlas pack (spec §5 usage): same builder the
256    /// `context.atlas` operation calls.
257    // trace:exempt reason=internal-detail
258    pub fn context_atlas(&self, budget: usize) -> crate::Result<scc_context::ContextPack> {
259        self.with_engine(|engine| engine.context().atlas(Some(budget), false, false))
260    }
261
262    /// Spec §5 namespace chain: `scc.context()` — context packs behind a
263    /// namespace view instead of one flat method list.
264    // trace:exempt reason=internal-detail
265    pub fn context_ns(&self) -> ContextNs<'_> {
266        ContextNs { engine: self }
267    }
268
269    /// Spec §5 namespace chain: `scc.surface()`.
270    // trace:exempt reason=internal-detail
271    pub fn surface_ns(&self) -> SurfaceNs<'_> {
272        SurfaceNs { engine: self }
273    }
274
275    /// Spec §5 namespace chain: `scc.ranking()`.
276    // trace:exempt reason=internal-detail
277    pub fn ranking_ns(&self) -> RankingNs<'_> {
278        RankingNs { engine: self }
279    }
280
281    /// Spec §5 namespace chain: `scc.graph()`.
282    // trace:exempt reason=internal-detail
283    pub fn graph_ns(&self) -> GraphNs<'_> {
284        GraphNs { engine: self }
285    }
286}
287
288/// Namespace view: `scc.context_ns().atlas(..)` (spec §5 chain).
289// trace:v1 id=impl.scc-engine-facade.context-ns work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
290pub struct ContextNs<'a> {
291    engine: &'a SccEngine,
292}
293
294// trace:exempt reason=internal-detail
295impl ContextNs<'_> {
296    // trace:exempt reason=internal-detail
297    pub fn overview(&self) -> crate::Result<scc_context::ContextPack> {
298        self.engine.with_engine(|e| e.context().overview())
299    }
300    // trace:exempt reason=internal-detail
301    pub fn atlas(&self, budget: usize) -> crate::Result<scc_context::ContextPack> {
302        self.engine.context_atlas(budget)
303    }
304    // trace:exempt reason=internal-detail
305    pub fn atlas_model(
306        &self,
307        scope: scc_context::atlas::AtlasScope,
308    ) -> crate::Result<scc_core::SystemAtlas> {
309        self.engine.with_engine(|e| e.context().atlas_model(scope))
310    }
311    // trace:exempt reason=internal-detail
312    pub fn task(
313        &self,
314        goal: &str,
315        budget: usize,
316    ) -> crate::Result<crate::task::TaskContextArtifact> {
317        self.engine.context_task(goal, budget)
318    }
319}
320
321/// Namespace view: `scc.surface_ns().build(..)` (spec §5 chain).
322// trace:v1 id=impl.scc-engine-facade.surface-ns work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
323pub struct SurfaceNs<'a> {
324    engine: &'a SccEngine,
325}
326
327// trace:exempt reason=internal-detail
328impl SurfaceNs<'_> {
329    // trace:exempt reason=internal-detail
330    pub fn build(
331        &self,
332        task: &str,
333        budget: usize,
334        explain: bool,
335    ) -> crate::Result<(scc_core::SurfaceRenderResult, String)> {
336        self.engine.surface_build(task, budget, explain)
337    }
338}
339
340/// Namespace view: `scc.ranking_ns().symbols(..)` (spec §5 chain).
341// trace:v1 id=impl.scc-engine-facade.ranking-ns work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
342pub struct RankingNs<'a> {
343    engine: &'a SccEngine,
344}
345
346// trace:exempt reason=internal-detail
347impl RankingNs<'_> {
348    // trace:exempt reason=internal-detail
349    pub fn symbols(
350        &self,
351        goal: &str,
352        limit: usize,
353        explain: bool,
354    ) -> crate::Result<scc_api::RankResult> {
355        self.engine.ranking_symbols(goal, limit, explain)
356    }
357}
358
359/// Namespace view: `scc.graph_ns().query(..)` (spec §5 chain).
360// trace:v1 id=impl.scc-engine-facade.graph-ns work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
361pub struct GraphNs<'a> {
362    engine: &'a SccEngine,
363}
364
365// trace:exempt reason=internal-detail
366impl GraphNs<'_> {
367    // trace:exempt reason=internal-detail
368    pub fn entity(&self, id: &str) -> crate::Result<Option<scc_core::Entity>> {
369        self.engine.graph_entity(id)
370    }
371    // trace:exempt reason=internal-detail
372    pub fn relationships(
373        &self,
374        subject: Option<&str>,
375        predicate: Option<&str>,
376        limit: usize,
377    ) -> crate::Result<Vec<scc_core::Relationship>> {
378        self.engine.graph_relationships(subject, predicate, limit)
379    }
380    // trace:exempt reason=internal-detail
381    pub fn query(
382        &self,
383        query: &str,
384        limit: usize,
385    ) -> crate::Result<crate::graph::QueryHit> {
386        self.engine.with_engine(|_| {
387            crate::graph::query(
388                &self.engine.store,
389                &scc_api::QueryRequest { query: query.to_string(), limit },
390            )
391        })
392    }
393}