1use std::path::{Path, PathBuf};
14use std::str::FromStr;
15
16use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
17use sqlx::{ConnectOptions, SqlitePool};
18use thiserror::Error;
19
20pub mod attack_graph;
21pub mod budget;
22pub mod candidate;
23pub mod chain;
24pub mod exploration_memory;
25pub mod feedback;
26pub mod finding;
27pub mod integration;
28pub mod payload;
29pub mod product;
30pub mod project;
31pub mod repo;
32pub mod repro;
33pub mod run;
34pub mod run_repo_outcome;
35pub mod schedule;
36pub mod schema;
37pub mod spec;
38pub mod trace;
39pub mod webhook;
40
41#[cfg(test)]
42pub(crate) mod testutil;
43
44pub use attack_graph::{attack_graph_edge_id, attack_graph_node_id, AttackGraphStore};
45pub use budget::{BudgetKind, BudgetRecord, BudgetStore};
46pub use candidate::{CandidateFindingRecord, CandidateFindingStore, CandidateStatus};
47pub use chain::{ChainRecord, ChainStore};
48pub use exploration_memory::{
49 compact_memory_for_prompt, ExplorationMemoryInput, ExplorationMemoryRecord,
50 ExplorationMemoryStore,
51};
52pub use feedback::{FeedbackRecord, FeedbackStore, OperatorVerdict};
53pub use finding::{
54 finding_id_hash, FindingFilter, FindingOrigin, FindingRecord, FindingStatus, FindingStore,
55 TriageState,
56};
57pub use integration::{
58 ProjectIntegrationEvent, ProjectIntegrationInsert, ProjectIntegrationKind,
59 ProjectIntegrationPatch, ProjectIntegrationRecord, ProjectIntegrationStore,
60 ProjectIntegrationStoredRecord,
61};
62pub use payload::{PayloadRecord, PayloadStore};
63pub use product::{
64 canonical_risk_rating, clamp_risk_score, AuthzMatrixEntryRecord, AuthzMatrixStore,
65 BusinessLogicTemplateRunRecord, BusinessLogicTemplateRunStore, EnvironmentRunRecord,
66 EnvironmentRunStore, LaunchProfileStore, NyxSignalRecord, NyxSignalStore,
67 PentestCandidateRecord, PentestCandidateStore, ProjectLaunchProfile, ProjectLaunchProfileInput,
68 RouteModelRecord, RouteModelStore, VerificationAttemptRecord, VerificationAttemptStore,
69 VerifiedVulnerabilityRecord, VerifiedVulnerabilityStore,
70};
71pub use project::{
72 ProjectPatch, ProjectPatchOption, ProjectRecord, ProjectRuntimeProfile, ProjectStore,
73 DEFAULT_PROJECT_ID, DEFAULT_PROJECT_NAME,
74};
75pub use repo::{PatchOption, RepoPatch, RepoRecord, RepoStore, SourceKind};
76pub use repro::{ReproBundleRecord, ReproBundleStore};
77pub use run::{RunRecord, RunStatus, RunStore, TriggeredBy};
78pub use run_repo_outcome::{RepoOutcomeLabel, RunRepoOutcomeRecord, RunRepoOutcomeStore};
79pub use schedule::{ScheduleRecord, ScheduleStore};
80pub use schema::{schema_version, CURRENT_SCHEMA_VERSION, MIGRATOR};
81pub use spec::{HarnessSpecRecord, HarnessSpecStore};
82pub use trace::{AgentTraceRecord, AgentTraceStore, TaskKind};
83pub use webhook::{WebhookRecord, WebhookStore};
84
85const DB_FILE_NAME: &str = "state.db";
86
87#[derive(Debug, Error)]
88pub enum StoreError {
89 #[error("failed to open database at {path}: {source}")]
90 Open {
91 path: PathBuf,
92 #[source]
93 source: sqlx::Error,
94 },
95 #[error("failed to apply migrations: {0}")]
96 Migrate(#[from] sqlx::migrate::MigrateError),
97 #[error("database error: {0}")]
98 Sqlx(#[from] sqlx::Error),
99 #[error("invalid project runtime profile JSON: {0}")]
100 ProjectRuntimeProfileJson(#[from] serde_json::Error),
101 #[error("invalid integration JSON: {0}")]
102 IntegrationJson(serde_json::Error),
103 #[error("invalid integration kind: {0}")]
104 InvalidIntegrationKind(String),
105}
106
107#[derive(Debug, Clone)]
109pub struct Store {
110 pool: SqlitePool,
111 path: PathBuf,
112}
113
114impl Store {
115 pub async fn open(state_dir: &Path) -> Result<Self, StoreError> {
118 let path = state_dir.join(DB_FILE_NAME);
119 Self::open_at(&path).await
120 }
121
122 pub async fn open_at(path: &Path) -> Result<Self, StoreError> {
124 if let Some(parent) = path.parent() {
125 std::fs::create_dir_all(parent).map_err(|source| StoreError::Open {
126 path: path.to_path_buf(),
127 source: sqlx::Error::Io(source),
128 })?;
129 }
130
131 let url = format!("sqlite://{}", path.display());
132 let opts = SqliteConnectOptions::from_str(&url)
133 .map_err(|source| StoreError::Open { path: path.to_path_buf(), source })?
134 .create_if_missing(true)
135 .journal_mode(SqliteJournalMode::Wal)
136 .synchronous(SqliteSynchronous::Normal)
137 .foreign_keys(true)
138 .pragma("cache_size", "-8000")
139 .pragma("temp_store", "MEMORY")
140 .disable_statement_logging();
141
142 let pool = SqlitePoolOptions::new()
143 .max_connections(8)
144 .min_connections(1)
145 .connect_with(opts)
146 .await
147 .map_err(|source| StoreError::Open { path: path.to_path_buf(), source })?;
148
149 MIGRATOR.run(&pool).await?;
150 Self::populate_meta(&pool).await?;
151 Self::ensure_default_project(&pool).await?;
152 Self::warn_if_finding_id_collision_pressure(&pool).await?;
153
154 Ok(Self { pool, path: path.to_path_buf() })
155 }
156
157 async fn warn_if_finding_id_collision_pressure(pool: &SqlitePool) -> Result<(), sqlx::Error> {
167 const WARN_THRESHOLD: i64 = 1 << 28;
168 let (max_rowid,): (Option<i64>,) =
169 sqlx::query_as("SELECT MAX(rowid) FROM findings").fetch_one(pool).await?;
170 if max_rowid.unwrap_or(0) >= WARN_THRESHOLD {
171 tracing::warn!(
172 target: "nyx_agent_core::store",
173 approx_findings_rowid = max_rowid,
174 threshold = WARN_THRESHOLD,
175 "findings table crossed 2^28 rows; finding_id_hash truncates BLAKE3 to 64 bits so birthday collisions become statistically meaningful near 2^32 rows. Plan a schema migration that widens finding ids or adds a hash_version column before that boundary."
176 );
177 }
178 Ok(())
179 }
180
181 async fn populate_meta(pool: &SqlitePool) -> Result<(), sqlx::Error> {
186 let now_ms = crate::time::now_epoch_ms();
187 let agent_version = env!("CARGO_PKG_VERSION");
188 let max_migration: (Option<i64>,) =
189 sqlx::query_as("SELECT MAX(version) FROM _sqlx_migrations").fetch_one(pool).await?;
190 let schema_v = max_migration.0.unwrap_or(0);
191 sqlx::query(
192 "UPDATE meta SET \
193 schema_version = ?1, \
194 created_at = CASE WHEN created_at = 0 THEN ?2 ELSE created_at END, \
195 agent_version = ?3 \
196 WHERE id = 1",
197 )
198 .bind(schema_v)
199 .bind(now_ms)
200 .bind(agent_version)
201 .execute(pool)
202 .await?;
203 Ok(())
204 }
205
206 async fn ensure_default_project(pool: &SqlitePool) -> Result<(), StoreError> {
210 let now_ms = crate::time::now_epoch_ms();
211 ProjectStore::new(pool).ensure_default(now_ms).await?;
212 Ok(())
213 }
214
215 pub fn pool(&self) -> &SqlitePool {
216 &self.pool
217 }
218
219 pub fn path(&self) -> &Path {
220 &self.path
221 }
222
223 pub async fn close(self) {
224 self.pool.close().await;
225 }
226
227 pub async fn schema_version(&self) -> Result<i64, StoreError> {
232 let (meta_v,): (i64,) = sqlx::query_as("SELECT schema_version FROM meta WHERE id = 1")
233 .fetch_one(&self.pool)
234 .await?;
235 debug_assert_eq!(
236 meta_v,
237 schema_version(&self.pool).await?,
238 "meta.schema_version drift from _sqlx_migrations"
239 );
240 Ok(meta_v)
241 }
242
243 pub fn projects(&self) -> ProjectStore<'_> {
244 ProjectStore::new(&self.pool)
245 }
246 pub fn repos(&self) -> RepoStore<'_> {
247 RepoStore::new(&self.pool)
248 }
249 pub fn runs(&self) -> RunStore<'_> {
250 RunStore::new(&self.pool)
251 }
252 pub fn attack_graph(&self) -> AttackGraphStore<'_> {
253 AttackGraphStore::new(&self.pool)
254 }
255 pub fn exploration_memory(&self) -> ExplorationMemoryStore<'_> {
256 ExplorationMemoryStore::new(&self.pool)
257 }
258 pub fn run_repo_outcomes(&self) -> RunRepoOutcomeStore<'_> {
259 RunRepoOutcomeStore::new(&self.pool)
260 }
261 pub fn findings(&self) -> FindingStore<'_> {
262 FindingStore::new(&self.pool)
263 }
264 pub fn integrations(&self) -> ProjectIntegrationStore<'_> {
265 ProjectIntegrationStore::new(&self.pool)
266 }
267 pub fn chains(&self) -> ChainStore<'_> {
268 ChainStore::new(&self.pool)
269 }
270 pub fn payloads(&self) -> PayloadStore<'_> {
271 PayloadStore::new(&self.pool)
272 }
273 pub fn launch_profiles(&self) -> LaunchProfileStore<'_> {
274 LaunchProfileStore::new(&self.pool)
275 }
276 pub fn environment_runs(&self) -> EnvironmentRunStore<'_> {
277 EnvironmentRunStore::new(&self.pool)
278 }
279 pub fn nyx_signals(&self) -> NyxSignalStore<'_> {
280 NyxSignalStore::new(&self.pool)
281 }
282 pub fn pentest_candidates(&self) -> PentestCandidateStore<'_> {
283 PentestCandidateStore::new(&self.pool)
284 }
285 pub fn business_logic_template_runs(&self) -> BusinessLogicTemplateRunStore<'_> {
286 BusinessLogicTemplateRunStore::new(&self.pool)
287 }
288 pub fn verification_attempts(&self) -> VerificationAttemptStore<'_> {
289 VerificationAttemptStore::new(&self.pool)
290 }
291 pub fn authz_matrix(&self) -> AuthzMatrixStore<'_> {
292 AuthzMatrixStore::new(&self.pool)
293 }
294 pub fn verified_vulnerabilities(&self) -> VerifiedVulnerabilityStore<'_> {
295 VerifiedVulnerabilityStore::new(&self.pool)
296 }
297 pub fn route_models(&self) -> RouteModelStore<'_> {
298 RouteModelStore::new(&self.pool)
299 }
300 pub fn candidate_findings(&self) -> CandidateFindingStore<'_> {
301 CandidateFindingStore::new(&self.pool)
302 }
303 pub fn agent_traces(&self) -> AgentTraceStore<'_> {
304 AgentTraceStore::new(&self.pool)
305 }
306 pub fn budgets(&self) -> BudgetStore<'_> {
307 BudgetStore::new(&self.pool)
308 }
309 pub fn repro_bundles(&self) -> ReproBundleStore<'_> {
310 ReproBundleStore::new(&self.pool)
311 }
312 pub fn schedules(&self) -> ScheduleStore<'_> {
313 ScheduleStore::new(&self.pool)
314 }
315 pub fn webhooks(&self) -> WebhookStore<'_> {
316 WebhookStore::new(&self.pool)
317 }
318 pub fn feedback(&self) -> FeedbackStore<'_> {
319 FeedbackStore::new(&self.pool)
320 }
321 pub fn harness_specs(&self) -> HarnessSpecStore<'_> {
322 HarnessSpecStore::new(&self.pool)
323 }
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 async fn open_tmp() -> (tempfile::TempDir, Store) {
331 let tmp = tempfile::tempdir().expect("tempdir");
332 let store = Store::open(tmp.path()).await.expect("open");
333 (tmp, store)
334 }
335
336 #[tokio::test]
337 async fn open_creates_db_and_runs_migrations() {
338 let (tmp, store) = open_tmp().await;
339 let db_path = tmp.path().join("state.db");
340 assert!(db_path.exists(), "state.db should be created");
341 let v = store.schema_version().await.expect("schema_version");
342 assert_eq!(v, CURRENT_SCHEMA_VERSION);
343 }
344
345 #[tokio::test]
346 async fn meta_row_populated_with_real_values() {
347 let (_tmp, store) = open_tmp().await;
348 let (schema_v, created_at, agent_version): (i64, i64, String) = sqlx::query_as(
349 "SELECT schema_version, created_at, agent_version FROM meta WHERE id = 1",
350 )
351 .fetch_one(store.pool())
352 .await
353 .expect("meta row");
354 assert_eq!(schema_v, CURRENT_SCHEMA_VERSION);
355 assert!(created_at > 0, "created_at must be real epoch ms, got {created_at}");
356 assert_eq!(agent_version, env!("CARGO_PKG_VERSION"));
357 }
358
359 #[tokio::test]
360 async fn meta_created_at_stable_across_reopens() {
361 let tmp = tempfile::tempdir().expect("tempdir");
362 let store = Store::open(tmp.path()).await.expect("open");
363 let (first,): (i64,) = sqlx::query_as("SELECT created_at FROM meta WHERE id = 1")
364 .fetch_one(store.pool())
365 .await
366 .expect("created_at");
367 store.close().await;
368 assert!(first > 0);
369 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
371 let store = Store::open(tmp.path()).await.expect("reopen");
372 let (second,): (i64,) = sqlx::query_as("SELECT created_at FROM meta WHERE id = 1")
373 .fetch_one(store.pool())
374 .await
375 .expect("created_at");
376 assert_eq!(first, second, "created_at must be preserved across reopens");
377 }
378
379 #[tokio::test]
380 async fn migrations_idempotent_across_reopens() {
381 let tmp = tempfile::tempdir().expect("tempdir");
382 for _ in 0..3 {
383 let store = Store::open(tmp.path()).await.expect("open");
384 assert_eq!(
385 store.schema_version().await.expect("schema_version"),
386 CURRENT_SCHEMA_VERSION
387 );
388 store.close().await;
389 }
390 }
391
392 #[tokio::test]
393 async fn cross_table_finding_payload_chain_trace_roundtrip() {
394 let (_tmp, s) = open_tmp().await;
397 let testutil = &super::testutil::sample_repo;
398 s.repos().upsert(&testutil("repo")).await.expect("repo");
399 s.runs().insert(&super::testutil::sample_run("run-1")).await.expect("run");
400 let f = super::testutil::sample_finding("run-1", "repo", "src/a.rs", "rule-1");
401 let fid = f.id.clone();
402 s.findings().upsert(&f).await.expect("finding");
403 let p = super::testutil::sample_payload("payload-1", &fid);
404 s.payloads().insert(&p).await.expect("payload");
405 let c = super::testutil::sample_chain("chain-1", "run-1", &[&fid]);
406 s.chains().insert(&c).await.expect("chain");
407 s.findings().set_chain(&fid, "chain-1").await.expect("link");
408 let t = super::trace::AgentTraceRecord {
409 id: "trace-1".to_string(),
410 finding_id: Some(fid.clone()),
411 task_kind: "PayloadSynthesis".to_string(),
412 runtime_name: "anthropic".to_string(),
413 model: "claude-opus-4-7".to_string(),
414 prompt_version: Some("payload/v1".to_string()),
415 conversation_jsonl_path: None,
416 tokens_in: 10,
417 tokens_out: 20,
418 cost_usd_micros: 100,
419 cache_hits: 0,
420 cache_misses: 0,
421 duration_ms: Some(5_000),
422 started_at: 5_000,
423 finished_at: Some(10_000),
424 verifier_blob: None,
425 };
426 s.agent_traces().insert(&t).await.expect("trace");
427
428 assert_eq!(s.findings().get(&fid).await.expect("f").expect("row").id, fid);
430 assert_eq!(s.payloads().get("payload-1").await.expect("p").expect("row").finding_id, fid);
431 assert_eq!(s.chains().get("chain-1").await.expect("c").expect("row").run_id, "run-1");
432 assert_eq!(
433 s.agent_traces().get("trace-1").await.expect("t").expect("row").finding_id.as_deref(),
434 Some(fid.as_str())
435 );
436
437 s.runs().delete("run-1").await.expect("del run");
440 assert!(s.findings().get(&fid).await.expect("f").is_none());
441 assert!(s.payloads().get("payload-1").await.expect("p").is_none());
442 assert!(s.chains().get("chain-1").await.expect("c").is_none());
443 let trace = s.agent_traces().get("trace-1").await.expect("t").expect("row");
444 assert!(
445 trace.finding_id.is_none(),
446 "trace.finding_id must be SET NULL after finding cascade-deleted"
447 );
448 }
449
450 #[tokio::test]
451 async fn pragmas_are_set() {
452 let (_tmp, store) = open_tmp().await;
453 let (jmode,): (String,) = sqlx::query_as("PRAGMA journal_mode")
454 .fetch_one(store.pool())
455 .await
456 .expect("journal_mode");
457 assert_eq!(jmode.to_lowercase(), "wal");
458
459 let (sync,): (i64,) = sqlx::query_as("PRAGMA synchronous")
460 .fetch_one(store.pool())
461 .await
462 .expect("synchronous");
463 assert_eq!(sync, 1);
465
466 let (fk,): (i64,) = sqlx::query_as("PRAGMA foreign_keys")
467 .fetch_one(store.pool())
468 .await
469 .expect("foreign_keys");
470 assert_eq!(fk, 1, "foreign keys must be enabled");
471 }
472}