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}