Skip to main content

scc_engine/
workspace.rs

1//! Engine workspace: open/config/freshness — the preamble every
2//! operation repeats (moved verbatim from `scc-cli` lib.rs so all
3//! transports share one implementation).
4
5use crate::context::SccContext;
6use scc_context::ContextCompiler;
7use scc_graph::RealityGraph;
8use scc_indexer::Config;
9use scc_store::Store;
10use std::path::{Path, PathBuf};
11
12// trace:exempt reason=internal-detail
13pub const SCC_DIR: &str = ".scc";
14// trace:exempt reason=internal-detail
15pub const DB_FILE: &str = "scc.db";
16// trace:exempt reason=internal-detail
17pub const CONFIG_FILE: &str = "config.yaml";
18// trace:exempt reason=internal-detail
19pub const CHECKPOINT_FILE: &str = "checkpoint.json";
20
21// trace:exempt reason=internal-detail
22pub fn state_dir(root: &Path) -> PathBuf {
23    match std::env::var("SCC_STATE_DIR") {
24        Ok(dir) if !dir.is_empty() => PathBuf::from(dir),
25        _ => scc_dir(root),
26    }
27}
28
29// trace:exempt reason=internal-detail
30pub fn scc_dir(root: &Path) -> PathBuf {
31    root.join(SCC_DIR)
32}
33
34// trace:exempt reason=internal-detail
35pub fn db_path(root: &Path) -> PathBuf {
36    state_dir(root).join(DB_FILE)
37}
38
39// trace:exempt reason=internal-detail
40pub fn config_path(root: &Path) -> PathBuf {
41    scc_dir(root).join(CONFIG_FILE)
42}
43
44// trace:exempt reason=internal-detail
45pub fn checkpoint_path(root: &Path) -> PathBuf {
46    state_dir(root).join(CHECKPOINT_FILE)
47}
48
49// trace:exempt reason=internal-detail
50pub fn find_root(start: &Path) -> PathBuf {
51    let mut dir = Some(start.to_path_buf());
52    while let Some(d) = dir {
53        if d.join(".git").exists() || d.join(SCC_DIR).exists() {
54            return d;
55        }
56        dir = d.parent().map(|p| p.to_path_buf());
57    }
58    start.to_path_buf()
59}
60
61// trace:exempt reason=internal-detail
62pub fn load_config(root: &Path) -> crate::Result<Config> {
63    let p = config_path(root);
64    if p.exists() {
65        Ok(Config::load(&p)?)
66    } else {
67        Ok(Config::default())
68    }
69}
70
71// trace:exempt reason=internal-detail
72pub fn open_store(root: &Path) -> crate::Result<Store> {
73    let dir = state_dir(root);
74    std::fs::create_dir_all(&dir)?;
75    Ok(Store::open(&db_path(root), root)?)
76}
77
78// trace:exempt reason=internal-detail
79pub fn open_store_recovering(root: &Path) -> crate::Result<(Store, Option<PathBuf>)> {
80    let dir = state_dir(root);
81    std::fs::create_dir_all(&dir)?;
82    Ok(Store::open_recovering(&db_path(root), root)?)
83}
84
85// trace:exempt reason=internal-detail
86pub fn report_quarantine(quarantined: &Option<PathBuf>) {
87    if let Some(q) = quarantined {
88        eprintln!(
89            "warning: existing index was corrupt (malformed database); quarantined to {} and rebuilding from scratch.",
90            q.display()
91        );
92    }
93}
94
95// trace:exempt reason=internal-detail
96pub fn is_store_corruption(e: &crate::EngineError) -> bool {
97    match e {
98        crate::EngineError::Store(s) => Store::is_corruption(s),
99        crate::EngineError::Index(scc_indexer::IndexError::Store(s)) => Store::is_corruption(s),
100        crate::EngineError::Graph(scc_graph::GraphError::Store(s)) => Store::is_corruption(s),
101        _ => false,
102    }
103}
104
105// trace:exempt reason=internal-detail
106pub fn resilient_index<T>(
107    root: &Path,
108    mut attempt: impl FnMut() -> crate::Result<T>,
109) -> crate::Result<T> {
110    match attempt() {
111        Ok(v) => Ok(v),
112        Err(e) if is_store_corruption(&e) => {
113            let q = Store::quarantine_db(&db_path(root))?;
114            report_quarantine(&Some(q));
115            attempt()
116        }
117        Err(e) => Err(e),
118    }
119}
120
121// trace:exempt reason=internal-detail
122pub fn ensure_scc_ignored(root: &Path) {
123    const WANT: [&str; 2] = [".scc/*", "!.scc/intent.yaml"];
124    let gi = root.join(".gitignore");
125    let content = std::fs::read_to_string(&gi).unwrap_or_default();
126    let mut lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
127    let mut changed = false;
128    lines.retain(|l| {
129        let bare = l.trim() == ".scc/" || l.trim() == ".scc";
130        if bare {
131            changed = true;
132        }
133        !bare
134    });
135    for line in WANT {
136        if !lines.iter().any(|l| l.trim() == line) {
137            lines.push(line.to_string());
138            changed = true;
139        }
140    }
141    if changed {
142        let mut out = lines.join("\n");
143        out.push('\n');
144        let _ = std::fs::write(&gi, out);
145    }
146}
147
148// trace:exempt reason=existing
149pub struct Engine<'a> {
150    pub store: &'a Store,
151    pub graph: RealityGraph,
152    pub settings: scc_context::ContextSettings,
153    pub stale: Vec<String>,
154}
155
156// trace:exempt reason=internal-detail
157pub use crate::plugins::cache_key_fragment;
158// trace:exempt reason=internal-detail
159pub fn open_engine<'a>(
160    store: &'a Store,
161    config: &Config,
162    stale: Vec<String>,
163) -> crate::Result<Engine<'a>> {
164    let graph = RealityGraph::load(store)?;
165    // Plugin lock folds into the salt: task/atlas pack caches key on
166    // rank_salt, so a plugin install/upgrade/removal must change the key
167    // (spec 27 — an unkeyed cache would serve pre-plugin packs as fresh).
168    let plugin_salt = cache_key_fragment(&crate::plugins::active(&store.root, config));
169    let settings = scc_context::ContextSettings {
170        startup_tokens: config.context.startup_tokens,
171        task_tokens: config.context.task_tokens,
172        atlas_tokens: config.context.atlas_tokens,
173        detail_tokens: config.context.detail_tokens,
174        include_low_confidence_inference: config.context.include_low_confidence_inference,
175        rank_salt: format!(
176            "{}:{}:{}:{}",
177            config.inference.enabled,
178            config.inference.embedding_model,
179            config.inference.rerank_model,
180            plugin_salt,
181        ),
182        pack_allocator: scc_context::PackAllocator::AdaptivePriority,
183    };
184    Ok(Engine {
185        store,
186        graph,
187        settings,
188        stale,
189    })
190}
191
192// trace:v1 id=impl.crates-scc-engine-src-workspace.engine work=WORK-SI-MMMJA4G6 implements=PLAN-SI-SYKFPBEC
193impl Engine<'_> {
194    // trace:exempt reason=internal-detail
195    pub fn ctx(&self) -> ContextCompiler<'_> {
196        ContextCompiler::new(
197            self.store,
198            &self.graph,
199            self.settings.clone(),
200            self.stale.clone(),
201        )
202    }
203
204    // trace:exempt reason=internal-detail
205    pub fn context(&self) -> SccContext<'_> {
206        SccContext { engine: self }
207    }
208
209    // trace:exempt reason=internal-detail
210    pub fn ranking(&self) -> crate::ranking::Ranker<'_> {
211        crate::ranking::Ranker::new(self)
212    }
213
214    // trace:exempt reason=internal-detail
215    pub fn operations(&self) -> Operations<'_> {
216        Operations { engine: self }
217    }
218
219    // trace:exempt reason=internal-detail
220    pub fn invoke(&self, operation: &str, input: serde_json::Value) -> crate::Result<serde_json::Value> {
221        crate::invoke::invoke(&self.store.root, operation, input)
222    }
223
224    // trace:exempt reason=internal-detail
225    pub fn invoke_session(
226        &self,
227        session: &Session,
228        operation: &str,
229        input: serde_json::Value,
230    ) -> crate::Result<serde_json::Value> {
231        let config = load_config(&self.store.root)?;
232        let live = open_session(self.store, &config)?;
233        if live != *session {
234            let mut drifted = Vec::new();
235            if live.repo_id != session.repo_id { drifted.push("repo_id"); }
236            if live.revision != session.revision { drifted.push("revision"); }
237            if live.epoch != session.epoch { drifted.push("epoch"); }
238            if live.config_hash != session.config_hash { drifted.push("config"); }
239            if live.plugin_lock != session.plugin_lock { drifted.push("plugins"); }
240            if live.rank_salt != session.rank_salt { drifted.push("rank_salt"); }
241            return Err(crate::EngineError::Other(format!(
242                "session stale (drifted: {}); re-open with workspace.session",
243                drifted.join(", ")
244            )));
245        }
246        crate::invoke::invoke(&self.store.root, operation, input)
247    }
248}
249
250// trace:v1 id=impl.crates-scc-engine-src-workspace.stale-paths work=WORK-SI-MMMJA4G6 implements=PLAN-SI-SYKFPBEC
251pub fn stale_paths(store: &Store) -> crate::Result<Vec<String>> {
252    let config = load_config(&store.root)?;
253    let scanned = scc_indexer::scan::scan_repo(&store.root, &config.index).map_err(scc_indexer::IndexError::from)?;
254    let mut fresh: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
255    for f in &scanned {
256        fresh.insert(f.path.as_str(), f.hash.as_str());
257    }
258    let mut out = Vec::new();
259    let mut indexed = std::collections::HashSet::new();
260    for (path, hash, _lang, _kind, _size) in store.all_files()? {
261        indexed.insert(path.clone());
262        match fresh.get(path.as_str()) {
263            Some(current) if *current == hash.as_str() => {}
264            Some(_) => out.push(path),
265            None => out.push(path),
266        }
267    }
268    for f in scanned {
269        if !indexed.contains(&f.path) {
270            out.push(f.path);
271        }
272    }
273    out.sort();
274    out.dedup();
275    Ok(out)
276}
277
278#[allow(dead_code)]
279// trace:exempt reason=internal-detail
280pub struct Operations<'a> {
281    engine: &'a Engine<'a>,
282}
283
284// trace:v1 id=impl.crates-scc-engine-src-workspace.operations work=WORK-SI-MMMJA4G6 implements=PLAN-SI-SYKFPBEC
285impl Operations<'_> {
286    // trace:exempt reason=internal-detail
287    pub fn list(&self) -> &'static [crate::ops::OperationDescriptor] {
288        crate::ops::OPERATIONS
289    }
290
291    // trace:exempt reason=internal-detail
292    pub fn describe(&self, id: &str) -> Option<&'static crate::ops::OperationDescriptor> {
293        crate::ops::describe(id)
294    }
295}
296
297/// Pinned model session (§5): repository id, source revision, model epoch,
298/// config hash, plugin lock, and rank salt captured at open.
299///
300/// Sessions let callers run atlas + ranking + structural against exactly
301/// the same compiled model. `is_current()` re-checks the pin against live
302/// state; a changed epoch/revision/config/plugin set reports stale instead
303/// of silently answering from a moved model.
304#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
305// trace:v1 id=impl.scc-engine-workspace.session work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
306pub struct Session {
307    pub repo_id: String,
308    pub revision: String,
309    pub epoch: String,
310    pub config_hash: String,
311    pub plugin_lock: Vec<serde_json::Value>,
312    pub rank_salt: String,
313}
314
315// trace:exempt reason=internal-detail
316pub fn open_session(store: &Store, config: &Config) -> crate::Result<Session> {
317    let repo = store.repository();
318    let head = store.revisions()?.into_iter().last().map(|r| r.rev).unwrap_or(0);
319    let epoch = store.model_epoch()?.composite(&head.to_string());
320    let revision = store
321        .snapshot_status()?
322        .map(|(s, _)| s.revision)
323        .unwrap_or_else(|| "not-indexed".to_string());
324    let engine = open_engine(store, config, stale_paths(store)?)?;
325    Ok(Session {
326        repo_id: repo.id,
327        revision,
328        epoch,
329        config_hash: scc_indexer::semantic_config_hash(config),
330        plugin_lock: crate::plugins::lock_entries(&crate::plugins::active(&store.root, config)),
331        rank_salt: engine.settings.rank_salt,
332    })
333}
334
335// trace:exempt reason=internal-detail
336pub fn session_is_current(store: &Store, config: &Config, session: &Session) -> crate::Result<bool> {
337    let live = open_session(store, config)?;
338    Ok(live == *session)
339}
340
341