Skip to main content

agentstatedeveloper_core/
engine.rs

1use std::collections::HashMap;
2use std::path::Path;
3use std::sync::Arc;
4
5use agentstategraph::Repository;
6use agentstategraph_core::Namespace;
7use agentstategraph_storage::SqliteStorage;
8
9use crate::audit::{AuditEvent, AuditSink, NullSink, emit_audit, event_types};
10use crate::error::{AsdError, Result};
11use crate::index::{AsgIndexStore, IndexStore};
12use crate::ledger::{AsgLedgerStore, LedgerStore, RatifyOps};
13use crate::policy::{
14    Decision, PermissivePolicyGate, PolicyGate, PolicyStoreGate, Situation, actions,
15};
16use crate::schema::{LedgerEntry, Symbol};
17use crate::search_fts::SearchFtsDb;
18use crate::sidecar::hydrate_from_dir_with_cache;
19use serde_json::json;
20
21/// The top-level ASD engine. Owns an ASG repository, a policy gate, an
22/// audit sink, and optional commercial ratify operations. Cheap to
23/// construct; shared across CLI, MCP, and library consumers.
24///
25/// # Field access
26///
27/// The fields are `pub` for compatibility with CLI/MCP surface code that
28/// accesses `repo`, `ref_name`, `policy`, and `audit` directly. New callers
29/// should prefer the operation methods (`append_ledger_entry`, etc.) which
30/// automatically enforce the policy gate and emit audit events.
31pub struct Engine {
32    pub repo: Repository,
33    pub policy: Arc<dyn PolicyGate>,
34    pub audit: Arc<dyn AuditSink>,
35    pub ref_name: String,
36    /// Commercial ratify operations (Team tier). `None` in the OSS binary —
37    /// ledger approve/reject/withdraw return a commercial-feature error.
38    /// Set by `asd-pro` at startup via [`Engine::set_ratify_ops`].
39    pub ratify: Option<Arc<dyn RatifyOps>>,
40    /// Path to the SQLite backing store, when opened via `open_sqlite`.
41    /// `None` for in-memory engines (tests, etc.).  Commands that bypass
42    /// the git layer for SQLite-cached reads (symbol map, edges) use this.
43    pub db_path: Option<std::path::PathBuf>,
44    /// Single open connection to the ASD SQLite file.  Opened once in
45    /// `open_sqlite` and shared (by borrow) across all stores for the
46    /// lifetime of the command — eliminates per-call `Connection::open`.
47    pub fts: Option<SearchFtsDb>,
48}
49
50impl Engine {
51    /// Derive the ASG namespace from the project directory that owns the
52    /// database. Relative database paths are resolved from the caller's
53    /// working directory so the default `./.asd-state.db` maps to the current
54    /// project directory name.
55    fn namespace_for_db(db_path: &Path) -> Result<Namespace> {
56        let absolute_db_path = if db_path.is_absolute() {
57            db_path.to_path_buf()
58        } else {
59            std::env::current_dir()
60                .map_err(|e| AsdError::Other(format!("resolve current directory: {e}")))?
61                .join(db_path)
62        };
63        let project_dir = absolute_db_path.parent().ok_or_else(|| {
64            AsdError::Other(format!(
65                "ASD database path has no project directory: {}",
66                db_path.display()
67            ))
68        })?;
69        let project_name = project_dir
70            .file_name()
71            .and_then(|name| name.to_str())
72            .ok_or_else(|| {
73                AsdError::Other(format!(
74                    "ASD project directory is not a valid UTF-8 name: {}",
75                    project_dir.display()
76                ))
77            })?;
78        Namespace::new(Self::sanitize_namespace(project_name)).map_err(|e| {
79            AsdError::Other(format!(
80                "ASD project directory cannot be used as an ASG namespace ({project_name:?}): {e}"
81            ))
82        })
83    }
84
85    /// Map an arbitrary project-directory name onto a valid ASG namespace.
86    ///
87    /// ASG namespaces accept only `[A-Za-z0-9_-]` (max 64 chars); the engine
88    /// began rejecting anything else in v0.9.10. Directory names in the wild
89    /// contain dots, spaces, and other characters (a tempdir like `.tmpAbc`,
90    /// or `my project`), so replace every out-of-charset character with `_`,
91    /// cap the length, and fall back to a placeholder if nothing survives.
92    /// Names already in the valid charset pass through unchanged, so existing
93    /// projects keep their namespace and no data moves.
94    fn sanitize_namespace(name: &str) -> String {
95        const MAX: usize = 64; // agentstategraph_core::MAX_NAMESPACE_LEN
96        let mut out: String = name
97            .chars()
98            .map(|c| {
99                if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
100                    c
101                } else {
102                    '_'
103                }
104            })
105            .collect();
106        // Sanitized chars are all single-byte ASCII, so truncating by bytes is
107        // safe on char boundaries.
108        if out.len() > MAX {
109            out.truncate(MAX);
110        }
111        if out.is_empty() {
112            out.push_str("project");
113        }
114        out
115    }
116
117    /// Open (or create) an ASD engine backed by a SQLite file.
118    ///
119    /// If the repository has no symbols and a `.asd/v1/` sidecar exists next
120    /// to the DB file, hydrates automatically — this covers the fresh-clone
121    /// case without requiring a manual `asd hydrate` invocation.
122    pub fn open_sqlite(db_path: &Path) -> Result<Self> {
123        let storage = SqliteStorage::open(db_path).map_err(|e| AsdError::Other(e.to_string()))?;
124        let namespace = Self::namespace_for_db(db_path)?;
125        let repo = Repository::new(Box::new(storage)).with_namespace(namespace);
126        repo.init()?;
127
128        // Open FTS connection once — reused by all stores for this engine's lifetime.
129        let fts = SearchFtsDb::open(db_path).ok();
130
131        let engine = Self {
132            repo,
133            policy: Arc::new(PermissivePolicyGate),
134            audit: Arc::new(NullSink),
135            ref_name: "main".to_string(),
136            ratify: None,
137            db_path: Some(db_path.to_path_buf()),
138            fts,
139        };
140
141        // Auto-hydrate from sidecar when the DB is empty (fresh clone or
142        // deleted DB). We check for the by-qname tree as a proxy for "has
143        // been indexed before". Errors are silently ignored so a missing or
144        // malformed sidecar never blocks normal operation.
145        let project_root = db_path
146            .parent()
147            .filter(|p| !p.as_os_str().is_empty())
148            .map(|p| p.to_path_buf())
149            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
150        let sidecar_root = project_root.join(".asd/v1");
151        // Fast emptiness check: reuse the already-open FTS connection — avoids
152        // a second `Connection::open` that the 0.9.72 code used.
153        let sqlite_has_symbols = engine
154            .fts
155            .as_ref()
156            .map(|fts| fts.symbols_cached_for(&engine.ref_name))
157            .unwrap_or(false);
158        let is_empty = {
159            if sqlite_has_symbols {
160                false // SQLite says there are symbols — definitely not empty.
161            } else {
162                // Cache absent (old version DB, fresh install, or blank slate).
163                // Fall back to git tree walk.
164                engine
165                    .repo
166                    .get_tree(&engine.ref_name, "/asd/v1/index/by-qname")
167                    .ok()
168                    .and_then(|v| v.as_object().map(|m| m.is_empty()))
169                    .unwrap_or(true)
170            }
171        };
172        if is_empty && sidecar_root.exists() {
173            let _ = hydrate_from_dir_with_cache(
174                &engine.repo,
175                engine.fts.as_ref(),
176                &engine.ref_name,
177                &project_root,
178                "asd-auto-hydrate",
179            );
180        }
181
182        // Plan T self-heal: the git trees have symbols but the SQLite symbol
183        // cache is empty — a born-cold DB (hydrate never synced caches before
184        // 1.2.1, or the auto-hydrate above just ran) or a past sync failure
185        // (e.g. SQLITE_BUSY while a server held the DB). Without this, every
186        // read on every subsequent run pays the slow git-walk path forever.
187        // One authoritative walk (~2s at 10k symbols) repairs it here, which
188        // covers CLI, MCP, and asd-serve in one place — they all construct
189        // engines through `open_sqlite`. Safe under concurrent readers:
190        // `sync_symbols`/`sync_call_edges` are transactional full-replaces
191        // (WAL mode, 5s busy timeout) and idempotent, so a concurrent healer
192        // just repeats the same work. In-memory engines (`fts: None`) skip
193        // inside `warm_caches`; failures are logged and non-fatal — the next
194        // open retries.
195        if !sqlite_has_symbols {
196            match engine.warm_caches() {
197                Ok(w) if w.symbols_cached > 0 => {
198                    eprintln!(
199                        "asd: symbol cache was cold — rebuilt from index ({} symbols, {} edges{})",
200                        w.symbols_cached,
201                        w.edges_cached,
202                        if w.fts_rebuilt { ", FTS rebuilt" } else { "" }
203                    );
204                }
205                Ok(_) => {} // nothing indexed yet, or nothing to warm
206                Err(e) => eprintln!("asd: symbol cache self-heal failed (non-fatal): {e}"),
207            }
208        }
209
210        Ok(engine)
211    }
212
213    /// Open an in-memory engine — mostly for tests.
214    pub fn open_in_memory() -> Result<Self> {
215        let storage = SqliteStorage::in_memory().map_err(|e| AsdError::Other(e.to_string()))?;
216        let repo = Repository::new(Box::new(storage));
217        repo.init()?;
218        Ok(Self {
219            repo,
220            policy: Arc::new(PermissivePolicyGate),
221            audit: Arc::new(NullSink),
222            ref_name: "main".to_string(),
223            ratify: None,
224            db_path: None,
225            fts: None,
226        })
227    }
228
229    pub fn set_policy(&mut self, policy: Arc<dyn PolicyGate>) {
230        self.policy = policy;
231    }
232
233    /// Load a policy file and install the real `PolicyStoreGate` backed by
234    /// `agentstategraph-policy`. Rules are imported into an isolated
235    /// in-memory repo at startup; all evaluation is delegated to `PolicyStore`.
236    pub fn load_policy_file(&mut self, path: &Path) -> Result<()> {
237        let gate = PolicyStoreGate::from_file(path)?;
238        self.policy = Arc::new(gate);
239        Ok(())
240    }
241
242    pub fn set_audit_sink(&mut self, sink: Arc<dyn AuditSink>) {
243        self.audit = sink;
244    }
245
246    /// Install the commercial ratify implementation (Team tier).
247    /// Called by `asd-pro` at startup before any subcommand dispatch.
248    pub fn set_ratify_ops(&mut self, ratify: Arc<dyn RatifyOps>) {
249        self.ratify = Some(ratify);
250    }
251
252    // -----------------------------------------------------------------------
253    // High-level operation methods — preferred for new callers.
254    // These enforce the policy gate and emit audit events automatically.
255    // -----------------------------------------------------------------------
256
257    /// Append a ledger entry. Evaluates the configured policy gate first;
258    /// returns an error if the policy denies the operation. On success,
259    /// emits a `ledger.append` audit event.
260    pub fn append_ledger_entry(&self, entry: &LedgerEntry, agent_id: &str) -> Result<()> {
261        let situation = Situation::new("append ledger entry")
262            .with_qualifier("symbol_id", &entry.symbol_id)
263            .with_qualifier("kind", entry.kind.as_str());
264        let decision = self
265            .policy
266            .evaluate(&situation, actions::LEDGER_APPEND, agent_id)?;
267        let (matched_policy, audit_outcome) = match &decision {
268            Decision::Allow { matched_policy } => (matched_policy.clone(), "allowed"),
269            Decision::RequireApproval { matched_policy, .. } => {
270                (Some(matched_policy.clone()), "awaiting-approval")
271            }
272            Decision::NoPolicyMatch => (None, "allowed"),
273            Decision::Deny {
274                matched_policy,
275                reason,
276            } => {
277                return Err(AsdError::Other(format!(
278                    "policy denied by {matched_policy}: {reason}"
279                )));
280            }
281        };
282
283        let store = AsgLedgerStore {
284            repo: &self.repo,
285            fts: self.fts.as_ref(),
286        };
287        store.append_entry(&self.ref_name, entry, agent_id)?;
288
289        let event = AuditEvent::new(event_types::LEDGER_APPEND, agent_id, "agent", audit_outcome)
290            .with_subject(&entry.entry_id)
291            .with_secondary(&entry.symbol_id)
292            .with_matched_policy(matched_policy)
293            .with_payload(json!({ "kind": entry.kind.as_str(), "tags": entry.tags }));
294        emit_audit(self.audit.as_ref(), event);
295        Ok(())
296    }
297
298    /// Write a symbol to the index. Policy-neutral (no gate); emits no audit
299    /// event — indexing is a bulk background operation, not a user action.
300    pub fn put_symbol(&self, symbol: &Symbol, agent_id: &str) -> Result<()> {
301        let store = AsgIndexStore::new(&self.repo);
302        store.put_symbol(&self.ref_name, symbol, agent_id)
303    }
304
305    /// Plan T: populate the SQLite fast-read caches (`asd_symbols_cache`,
306    /// `asd_call_edges`, and — only if empty — the FTS search table) from the
307    /// authoritative git trees.
308    ///
309    /// Reads git directly rather than going through `AsgIndexStore` so a
310    /// stale cache can never be used as its own source. Called from two
311    /// places:
312    ///
313    /// - `open_sqlite` self-heal, when git has symbols but the cache is cold
314    ///   (born-cold hydrate DBs, or a past sync failure such as SQLITE_BUSY);
315    /// - `asd hydrate`, right after loading the sidecar, so hydrate-created
316    ///   DBs are warm before the command exits.
317    ///
318    /// Concurrency: `sync_symbols`/`sync_call_edges` are transactional
319    /// full-replaces on a WAL connection with a busy timeout, and the
320    /// operation is idempotent — concurrent callers converge on the same
321    /// rows. In-memory engines (`fts: None`) return `skipped: true` and do
322    /// nothing.
323    pub fn warm_caches(&self) -> Result<CacheWarmSummary> {
324        let Some(fts) = self.fts.as_ref() else {
325            return Ok(CacheWarmSummary {
326                skipped: true,
327                ..Default::default()
328            });
329        };
330
331        // Authoritative symbol read: one by-qname tree walk (qname-unique by
332        // construction, matching `rebuild_refs`' dedup semantics).
333        let symbols: Vec<Symbol> = self
334            .repo
335            .get_tree(&self.ref_name, "/asd/v1/index/by-qname")
336            .ok()
337            .and_then(|v| v.as_object().cloned())
338            .map(|m| {
339                m.into_values()
340                    .filter_map(|v| serde_json::from_value::<Symbol>(v).ok())
341                    .collect()
342            })
343            .unwrap_or_default();
344        if symbols.is_empty() {
345            // Nothing indexed yet — leave the caches alone so
346            // `symbols_cached_for` keeps routing reads to git.
347            return Ok(CacheWarmSummary::default());
348        }
349
350        // Authoritative edge read: one tree walk per direction. Shape:
351        //   /asd/v1/index/callers/{symbol_id} = {"callers": [id, …]}
352        //   /asd/v1/index/callees/{symbol_id} = {"callees": [id, …]}
353        let read_direction = |dir: &str, field: &str| -> HashMap<String, Vec<String>> {
354            let prefix = format!("{}/index/{}", crate::paths::ASD_ROOT, dir);
355            match self.repo.get_tree(&self.ref_name, &prefix) {
356                Ok(serde_json::Value::Object(map)) => map
357                    .into_iter()
358                    .map(|(symbol_id, v)| {
359                        let ids = v
360                            .get(field)
361                            .and_then(|a| a.as_array())
362                            .map(|arr| {
363                                arr.iter()
364                                    .filter_map(|x| x.as_str().map(|s| s.to_string()))
365                                    .collect()
366                            })
367                            .unwrap_or_default();
368                        (symbol_id, ids)
369                    })
370                    .collect(),
371                _ => HashMap::new(),
372            }
373        };
374        let callers_of = read_direction("callers", "callers");
375        let callees_of = read_direction("callees", "callees");
376
377        let sym_refs: Vec<&Symbol> = symbols.iter().collect();
378        fts.sync_symbols(&sym_refs, &self.ref_name)
379            .map_err(|e| AsdError::Other(format!("symbol cache sync failed: {e}")))?;
380        fts.sync_call_edges(&callees_of, &callers_of, &self.ref_name)
381            .map_err(|e| AsdError::Other(format!("edge cache sync failed: {e}")))?;
382
383        // FTS search table: rebuild ONLY when empty. A populated table came
384        // from `asd index` and already carries the ledger_text/ledger_flags
385        // denormalizations; rebuilding it here would be a pure no-op cost.
386        // An empty one (fresh hydrate DB) gets the same rebuild `asd index`
387        // would produce — ledger data comes from the hydrated ledger tree.
388        let mut fts_rebuilt = false;
389        if fts.fts_symbol_row_count() == 0 {
390            let ledger_data =
391                crate::index_pipeline::build_ledger_fts_data(&self.repo, &self.ref_name);
392            let mut deduped = sym_refs.clone();
393            deduped.sort_by(|a, b| a.qname.cmp(&b.qname));
394            match fts.rebuild_refs(&deduped, &ledger_data) {
395                Ok(()) => fts_rebuilt = true,
396                Err(e) => eprintln!("asd: FTS rebuild during cache warm failed: {e}"),
397            }
398        }
399
400        let edges_cached = callers_of.values().map(Vec::len).sum::<usize>()
401            + callees_of.values().map(Vec::len).sum::<usize>();
402        Ok(CacheWarmSummary {
403            symbols_cached: symbols.len(),
404            edges_cached,
405            fts_rebuilt,
406            skipped: false,
407        })
408    }
409}
410
411/// Result of [`Engine::warm_caches`].
412#[derive(Debug, Clone, Default)]
413pub struct CacheWarmSummary {
414    /// Rows written to `asd_symbols_cache` (0 when git had no symbols).
415    pub symbols_cached: usize,
416    /// Directed edge rows written to `asd_call_edges` (both directions).
417    pub edges_cached: usize,
418    /// Whether the FTS search table was empty and got rebuilt from git.
419    pub fts_rebuilt: bool,
420    /// `true` when the engine has no SQLite connection (in-memory engines) —
421    /// there are no caches to warm.
422    pub skipped: bool,
423}
424
425#[cfg(test)]
426mod tests {
427    use super::Engine;
428
429    #[test]
430    fn sqlite_namespace_uses_project_directory_name() {
431        let temp = tempfile::tempdir().expect("temp directory");
432        let project = temp.path().join("AcmeFlow-ios");
433        std::fs::create_dir(&project).expect("project directory");
434        let namespace =
435            Engine::namespace_for_db(&project.join(".asd-state.db")).expect("valid namespace");
436
437        assert_eq!(namespace.as_str(), "AcmeFlow-ios");
438    }
439
440    #[test]
441    fn sanitize_namespace_maps_invalid_dir_names() {
442        // Valid names pass through unchanged (existing projects keep their ns).
443        assert_eq!(Engine::sanitize_namespace("AcmeFlow-ios"), "AcmeFlow-ios");
444        assert_eq!(Engine::sanitize_namespace("my_repo-2"), "my_repo-2");
445        // Dots, spaces, and other chars → '_' (e.g. tempdirs like `.tmpAbc`).
446        assert_eq!(Engine::sanitize_namespace(".tmpAbc"), "_tmpAbc");
447        assert_eq!(Engine::sanitize_namespace("my project.v2"), "my_project_v2");
448        // Length is capped at the engine's 64-char max.
449        assert_eq!(Engine::sanitize_namespace(&"a".repeat(100)).len(), 64);
450        // Non-ASCII chars each become '_' (still a valid, non-empty namespace).
451        assert_eq!(Engine::sanitize_namespace("。。"), "__");
452        // Only a genuinely empty name falls back to the placeholder.
453        assert_eq!(Engine::sanitize_namespace(""), "project");
454    }
455
456    #[test]
457    fn namespace_for_dotted_dir_no_longer_errors() {
458        let temp = tempfile::tempdir().expect("temp directory");
459        let project = temp.path().join(".hidden.proj");
460        std::fs::create_dir(&project).expect("project directory");
461        let namespace =
462            Engine::namespace_for_db(&project.join(".asd-state.db")).expect("valid namespace");
463        assert_eq!(namespace.as_str(), "_hidden_proj");
464    }
465}