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/// Keep `.scc/` cache out of git while preserving committable project
122/// files (`intent.yaml`, `plugins.toml`, `plugins.lock`). A bare `.scc/`
123/// line would make git (and our gitignore-respecting walker) prune the
124/// whole directory including project files. Idempotent.
125// trace:v1 id=impl.scc-engine-workspace.ensure-scc-ignored work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
126pub fn ensure_scc_ignored(root: &Path) {
127    const WANT: [&str; 4] = [".scc/*", "!.scc/intent.yaml", "!.scc/plugins.toml", "!.scc/plugins.lock"];
128    let gi = root.join(".gitignore");
129    let content = std::fs::read_to_string(&gi).unwrap_or_default();
130    let mut lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
131    let mut changed = false;
132    lines.retain(|l| {
133        let bare = l.trim() == ".scc/" || l.trim() == ".scc";
134        if bare {
135            changed = true;
136        }
137        !bare
138    });
139    for line in WANT {
140        if !lines.iter().any(|l| l.trim() == line) {
141            lines.push(line.to_string());
142            changed = true;
143        }
144    }
145    if changed {
146        let mut out = lines.join("\n");
147        out.push('\n');
148        let _ = std::fs::write(&gi, out);
149    }
150}
151
152// trace:exempt reason=existing
153pub struct Engine<'a> {
154    pub store: &'a Store,
155    pub graph: RealityGraph,
156    pub settings: scc_context::ContextSettings,
157    pub stale: Vec<String>,
158}
159
160// trace:exempt reason=internal-detail
161pub use crate::plugins::cache_key_fragment;
162// trace:exempt reason=internal-detail
163pub fn open_engine<'a>(
164    store: &'a Store,
165    config: &Config,
166    stale: Vec<String>,
167) -> crate::Result<Engine<'a>> {
168    let graph = RealityGraph::load(store)?;
169    // Plugin lock folds into the salt: task/atlas pack caches key on
170    // rank_salt, so a plugin install/upgrade/removal must change the key
171    // (spec 27 — an unkeyed cache would serve pre-plugin packs as fresh).
172    let plugin_salt = cache_key_fragment(&crate::plugins::active(&store.root, config));
173    let settings = scc_context::ContextSettings {
174        startup_tokens: config.context.startup_tokens,
175        task_tokens: config.context.task_tokens,
176        atlas_tokens: config.context.atlas_tokens,
177        detail_tokens: config.context.detail_tokens,
178        include_low_confidence_inference: config.context.include_low_confidence_inference,
179        rank_salt: format!(
180            "{}:{}:{}:{}",
181            config.inference.enabled,
182            config.inference.embedding_model,
183            config.inference.rerank_model,
184            plugin_salt,
185        ),
186        pack_allocator: scc_context::PackAllocator::AdaptivePriority,
187    };
188    Ok(Engine {
189        store,
190        graph,
191        settings,
192        stale,
193    })
194}
195
196// trace:v1 id=impl.crates-scc-engine-src-workspace.engine work=WORK-SI-MMMJA4G6 implements=PLAN-SI-SYKFPBEC
197impl Engine<'_> {
198    // trace:exempt reason=internal-detail
199    pub fn ctx(&self) -> ContextCompiler<'_> {
200        ContextCompiler::new(
201            self.store,
202            &self.graph,
203            self.settings.clone(),
204            self.stale.clone(),
205        )
206    }
207
208    // trace:exempt reason=internal-detail
209    pub fn context(&self) -> SccContext<'_> {
210        SccContext { engine: self }
211    }
212
213    // trace:exempt reason=internal-detail
214    pub fn ranking(&self) -> crate::ranking::Ranker<'_> {
215        crate::ranking::Ranker::new(self)
216    }
217
218    // trace:exempt reason=internal-detail
219    pub fn operations(&self) -> Operations<'_> {
220        Operations { engine: self }
221    }
222
223    // trace:exempt reason=internal-detail
224    pub fn invoke(&self, operation: &str, input: serde_json::Value) -> crate::Result<serde_json::Value> {
225        crate::invoke::invoke(&self.store.root, operation, input)
226    }
227
228    // trace:exempt reason=internal-detail
229    pub fn invoke_session(
230        &self,
231        session: &Session,
232        operation: &str,
233        input: serde_json::Value,
234    ) -> crate::Result<serde_json::Value> {
235        let config = load_config(&self.store.root)?;
236        let live = open_session(self.store, &config)?;
237        if live != *session {
238            let mut drifted = Vec::new();
239            if live.repo_id != session.repo_id { drifted.push("repo_id"); }
240            if live.revision != session.revision { drifted.push("revision"); }
241            if live.epoch != session.epoch { drifted.push("epoch"); }
242            if live.config_hash != session.config_hash { drifted.push("config"); }
243            if live.plugin_lock != session.plugin_lock { drifted.push("plugins"); }
244            if live.rank_salt != session.rank_salt { drifted.push("rank_salt"); }
245            return Err(crate::EngineError::Other(format!(
246                "session stale (drifted: {}); re-open with workspace.session",
247                drifted.join(", ")
248            )));
249        }
250        crate::invoke::invoke(&self.store.root, operation, input)
251    }
252}
253
254// trace:v1 id=impl.crates-scc-engine-src-workspace.stale-paths work=WORK-SI-MMMJA4G6 implements=PLAN-SI-SYKFPBEC
255pub fn stale_paths(store: &Store) -> crate::Result<Vec<String>> {
256    let config = load_config(&store.root)?;
257    let scanned = scc_indexer::scan::scan_repo(&store.root, &config.index).map_err(scc_indexer::IndexError::from)?;
258    let mut fresh: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
259    for f in &scanned {
260        fresh.insert(f.path.as_str(), f.hash.as_str());
261    }
262    let mut out = Vec::new();
263    let mut indexed = std::collections::HashSet::new();
264    for (path, hash, _lang, _kind, _size) in store.all_files()? {
265        indexed.insert(path.clone());
266        match fresh.get(path.as_str()) {
267            Some(current) if *current == hash.as_str() => {}
268            Some(_) => out.push(path),
269            None => out.push(path),
270        }
271    }
272    for f in scanned {
273        if !indexed.contains(&f.path) {
274            out.push(f.path);
275        }
276    }
277    out.sort();
278    out.dedup();
279    Ok(out)
280}
281
282#[allow(dead_code)]
283// trace:exempt reason=internal-detail
284pub struct Operations<'a> {
285    engine: &'a Engine<'a>,
286}
287
288// trace:v1 id=impl.crates-scc-engine-src-workspace.operations work=WORK-SI-MMMJA4G6 implements=PLAN-SI-SYKFPBEC
289impl Operations<'_> {
290    // trace:exempt reason=internal-detail
291    pub fn list(&self) -> &'static [crate::ops::OperationDescriptor] {
292        crate::ops::OPERATIONS
293    }
294
295    // trace:exempt reason=internal-detail
296    pub fn describe(&self, id: &str) -> Option<&'static crate::ops::OperationDescriptor> {
297        crate::ops::describe(id)
298    }
299}
300
301/// Pinned model session (§5): repository id, source revision, model epoch,
302/// config hash, plugin lock, and rank salt captured at open.
303///
304/// Sessions let callers run atlas + ranking + structural against exactly
305/// the same compiled model. `is_current()` re-checks the pin against live
306/// state; a changed epoch/revision/config/plugin set reports stale instead
307/// of silently answering from a moved model.
308#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
309// trace:v1 id=impl.scc-engine-workspace.session work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
310pub struct Session {
311    pub repo_id: String,
312    pub revision: String,
313    pub epoch: String,
314    pub config_hash: String,
315    pub plugin_lock: Vec<serde_json::Value>,
316    pub rank_salt: String,
317    /// Ranking-pipeline hash (spec §§58/100): the plugin cache-key
318    /// fragment active at session open. "" on repos that predate the
319    /// field (legacy sessions still compare equal when the rest does).
320    #[serde(default)]
321    pub pipeline: String,
322}
323
324// trace:exempt reason=internal-detail
325pub fn open_session(store: &Store, config: &Config) -> crate::Result<Session> {
326    let repo = store.repository();
327    let head = store.revisions()?.into_iter().last().map(|r| r.rev).unwrap_or(0);
328    let epoch = store.model_epoch()?.composite(&head.to_string());
329    let revision = store
330        .snapshot_status()?
331        .map(|(s, _)| s.revision)
332        .unwrap_or_else(|| "not-indexed".to_string());
333    let engine = open_engine(store, config, stale_paths(store)?)?;
334    let ap = crate::plugins::active(&store.root, config);
335    Ok(Session {
336        repo_id: repo.id,
337        revision,
338        epoch,
339        config_hash: scc_indexer::semantic_config_hash(config),
340        plugin_lock: crate::plugins::lock_entries(&ap),
341        rank_salt: engine.settings.rank_salt.clone(),
342        pipeline: crate::plugins::cache_key_fragment(&ap),
343    })
344}
345
346// trace:exempt reason=internal-detail
347pub fn session_is_current(store: &Store, config: &Config, session: &Session) -> crate::Result<bool> {
348    let live = open_session(store, config)?;
349    Ok(live == *session)
350}
351
352