1#![allow(clippy::all)]
2
3use crate::types::{
7 ClearFileIndexResult, GlobalMemoryRecord, GlobalMemorySearchHit, GlobalMemoryWriteResult,
8 KnowledgeCoverageRecord, KnowledgeItemRecord, KnowledgeItemStatus, KnowledgePromotionRequest,
9 KnowledgePromotionResult, KnowledgeSpaceRecord, MemoryChunk, MemoryConfig, MemoryError,
10 MemoryResult, MemoryStats, MemoryTier, ProjectMemoryStats, DEFAULT_EMBEDDING_DIMENSION,
11};
12use chrono::{DateTime, Utc};
13use rusqlite::{ffi::sqlite3_auto_extension, params, Connection, OptionalExtension, Row};
14use sqlite_vec::sqlite3_vec_init;
15use std::collections::HashSet;
16use std::path::Path;
17use std::sync::Arc;
18use std::time::Duration;
19use tokio::sync::Mutex;
20
21type ProjectIndexStatusRow = (
22 Option<String>,
23 Option<i64>,
24 Option<i64>,
25 Option<i64>,
26 Option<i64>,
27 Option<i64>,
28);
29
30pub struct MemoryDatabase {
32 conn: Arc<Mutex<Connection>>,
33 db_path: std::path::PathBuf,
34}
35
36include!("memory_database_impl_parts/part01.rs");
37include!("memory_database_impl_parts/part02.rs");
38
39fn row_to_chunk(row: &Row, tier: MemoryTier) -> Result<MemoryChunk, rusqlite::Error> {
41 let id: String = row.get(0)?;
42 let content: String = row.get(1)?;
43 let (session_id, project_id, source_idx, created_at_idx, token_count_idx, metadata_idx) =
44 match tier {
45 MemoryTier::Session => (
46 Some(row.get(2)?),
47 row.get(3)?,
48 4usize,
49 5usize,
50 6usize,
51 7usize,
52 ),
53 MemoryTier::Project => (
54 row.get(2)?,
55 Some(row.get(3)?),
56 4usize,
57 5usize,
58 6usize,
59 7usize,
60 ),
61 MemoryTier::Global => (None, None, 2usize, 3usize, 4usize, 5usize),
62 };
63
64 let source: String = row.get(source_idx)?;
65 let created_at_str: String = row.get(created_at_idx)?;
66 let token_count: i64 = row.get(token_count_idx)?;
67 let metadata_str: Option<String> = row.get(metadata_idx)?;
68
69 let created_at = DateTime::parse_from_rfc3339(&created_at_str)
70 .map_err(|e| {
71 rusqlite::Error::FromSqlConversionFailure(5, rusqlite::types::Type::Text, Box::new(e))
72 })?
73 .with_timezone(&Utc);
74
75 let metadata = metadata_str
76 .filter(|s| !s.is_empty())
77 .and_then(|s| serde_json::from_str(&s).ok());
78
79 let source_path = row.get::<_, Option<String>>("source_path").ok().flatten();
80 let source_mtime = row.get::<_, Option<i64>>("source_mtime").ok().flatten();
81 let source_size = row.get::<_, Option<i64>>("source_size").ok().flatten();
82 let source_hash = row.get::<_, Option<String>>("source_hash").ok().flatten();
83
84 Ok(MemoryChunk {
85 id,
86 content,
87 tier,
88 session_id,
89 project_id,
90 source,
91 source_path,
92 source_mtime,
93 source_size,
94 source_hash,
95 created_at,
96 token_count,
97 metadata,
98 })
99}
100
101fn require_scope_id<'a>(tier: MemoryTier, scope: Option<&'a str>) -> MemoryResult<&'a str> {
102 scope
103 .filter(|value| !value.trim().is_empty())
104 .ok_or_else(|| {
105 crate::types::MemoryError::InvalidConfig(match tier {
106 MemoryTier::Session => "tier=session requires session_id".to_string(),
107 MemoryTier::Project => "tier=project requires project_id".to_string(),
108 MemoryTier::Global => "tier=global does not require a scope id".to_string(),
109 })
110 })
111}
112
113fn row_to_global_record(row: &Row) -> Result<GlobalMemoryRecord, rusqlite::Error> {
114 let metadata_str: Option<String> = row.get(12)?;
115 let provenance_str: Option<String> = row.get(13)?;
116 Ok(GlobalMemoryRecord {
117 id: row.get(0)?,
118 user_id: row.get(1)?,
119 source_type: row.get(2)?,
120 content: row.get(3)?,
121 content_hash: row.get(4)?,
122 run_id: row.get(5)?,
123 session_id: row.get(6)?,
124 message_id: row.get(7)?,
125 tool_name: row.get(8)?,
126 project_tag: row.get(9)?,
127 channel_tag: row.get(10)?,
128 host_tag: row.get(11)?,
129 metadata: metadata_str
130 .filter(|s| !s.is_empty())
131 .and_then(|s| serde_json::from_str(&s).ok()),
132 provenance: provenance_str
133 .filter(|s| !s.is_empty())
134 .and_then(|s| serde_json::from_str(&s).ok()),
135 redaction_status: row.get(14)?,
136 redaction_count: row.get::<_, i64>(15)? as u32,
137 visibility: row.get(16)?,
138 demoted: row.get::<_, i64>(17)? != 0,
139 score_boost: row.get(18)?,
140 created_at_ms: row.get::<_, i64>(19)? as u64,
141 updated_at_ms: row.get::<_, i64>(20)? as u64,
142 expires_at_ms: row.get::<_, Option<i64>>(21)?.map(|v| v as u64),
143 })
144}
145
146impl MemoryDatabase {
147 pub async fn get_node_by_uri(
148 &self,
149 uri: &str,
150 ) -> MemoryResult<Option<crate::types::MemoryNode>> {
151 let conn = self.conn.lock().await;
152 let mut stmt = conn.prepare(
153 "SELECT id, uri, parent_uri, node_type, created_at, updated_at, metadata
154 FROM memory_nodes WHERE uri = ?1",
155 )?;
156
157 let result = stmt.query_row(params![uri], |row| {
158 let node_type_str: String = row.get(3)?;
159 let node_type = node_type_str
160 .parse()
161 .unwrap_or(crate::types::NodeType::File);
162 let metadata_str: Option<String> = row.get(6)?;
163 Ok(crate::types::MemoryNode {
164 id: row.get(0)?,
165 uri: row.get(1)?,
166 parent_uri: row.get(2)?,
167 node_type,
168 created_at: row.get::<_, String>(4)?.parse().unwrap_or_default(),
169 updated_at: row.get::<_, String>(5)?.parse().unwrap_or_default(),
170 metadata: metadata_str.and_then(|s| serde_json::from_str(&s).ok()),
171 })
172 });
173
174 match result {
175 Ok(node) => Ok(Some(node)),
176 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
177 Err(e) => Err(MemoryError::Database(e)),
178 }
179 }
180
181 pub async fn create_node(
182 &self,
183 uri: &str,
184 parent_uri: Option<&str>,
185 node_type: crate::types::NodeType,
186 metadata: Option<&serde_json::Value>,
187 ) -> MemoryResult<String> {
188 let id = uuid::Uuid::new_v4().to_string();
189 let now = Utc::now().to_rfc3339();
190 let metadata_str = metadata.map(|m| serde_json::to_string(m)).transpose()?;
191
192 let conn = self.conn.lock().await;
193 conn.execute(
194 "INSERT INTO memory_nodes (id, uri, parent_uri, node_type, created_at, updated_at, metadata)
195 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
196 params![id, uri, parent_uri, node_type.to_string(), now, now, metadata_str],
197 )?;
198
199 Ok(id)
200 }
201
202 pub async fn list_directory(&self, uri: &str) -> MemoryResult<Vec<crate::types::MemoryNode>> {
203 let conn = self.conn.lock().await;
204 let mut stmt = conn.prepare(
205 "SELECT id, uri, parent_uri, node_type, created_at, updated_at, metadata
206 FROM memory_nodes WHERE parent_uri = ?1 ORDER BY node_type DESC, uri ASC",
207 )?;
208
209 let rows = stmt.query_map(params![uri], |row| {
210 let node_type_str: String = row.get(3)?;
211 let node_type = node_type_str
212 .parse()
213 .unwrap_or(crate::types::NodeType::File);
214 let metadata_str: Option<String> = row.get(6)?;
215 Ok(crate::types::MemoryNode {
216 id: row.get(0)?,
217 uri: row.get(1)?,
218 parent_uri: row.get(2)?,
219 node_type,
220 created_at: row.get::<_, String>(4)?.parse().unwrap_or_default(),
221 updated_at: row.get::<_, String>(5)?.parse().unwrap_or_default(),
222 metadata: metadata_str.and_then(|s| serde_json::from_str(&s).ok()),
223 })
224 })?;
225
226 rows.collect::<Result<Vec<_>, _>>()
227 .map_err(MemoryError::Database)
228 }
229
230 pub async fn get_layer(
231 &self,
232 node_id: &str,
233 layer_type: crate::types::LayerType,
234 ) -> MemoryResult<Option<crate::types::MemoryLayer>> {
235 let conn = self.conn.lock().await;
236 let mut stmt = conn.prepare(
237 "SELECT id, node_id, layer_type, content, token_count, embedding_id, created_at, source_chunk_id
238 FROM memory_layers WHERE node_id = ?1 AND layer_type = ?2"
239 )?;
240
241 let result = stmt.query_row(params![node_id, layer_type.to_string()], |row| {
242 let layer_type_str: String = row.get(2)?;
243 let layer_type = layer_type_str
244 .parse()
245 .unwrap_or(crate::types::LayerType::L2);
246 Ok(crate::types::MemoryLayer {
247 id: row.get(0)?,
248 node_id: row.get(1)?,
249 layer_type,
250 content: row.get(3)?,
251 token_count: row.get(4)?,
252 embedding_id: row.get(5)?,
253 created_at: row.get::<_, String>(6)?.parse().unwrap_or_default(),
254 source_chunk_id: row.get(7)?,
255 })
256 });
257
258 match result {
259 Ok(layer) => Ok(Some(layer)),
260 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
261 Err(e) => Err(MemoryError::Database(e)),
262 }
263 }
264
265 pub async fn create_layer(
266 &self,
267 node_id: &str,
268 layer_type: crate::types::LayerType,
269 content: &str,
270 token_count: i64,
271 source_chunk_id: Option<&str>,
272 ) -> MemoryResult<String> {
273 let id = uuid::Uuid::new_v4().to_string();
274 let now = Utc::now().to_rfc3339();
275
276 let conn = self.conn.lock().await;
277 conn.execute(
278 "INSERT INTO memory_layers (id, node_id, layer_type, content, token_count, created_at, source_chunk_id)
279 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
280 params![id, node_id, layer_type.to_string(), content, token_count, now, source_chunk_id],
281 )?;
282
283 Ok(id)
284 }
285
286 pub async fn get_children_tree(
287 &self,
288 parent_uri: &str,
289 max_depth: usize,
290 ) -> MemoryResult<Vec<crate::types::TreeNode>> {
291 if max_depth == 0 {
292 return Ok(Vec::new());
293 }
294
295 let children = self.list_directory(parent_uri).await?;
296 let mut tree_nodes = Vec::new();
297
298 for child in children {
299 let layer_summary = self.get_layer_summary(&child.id).await?;
300
301 let grandchildren = if child.node_type == crate::types::NodeType::Directory {
302 Box::pin(self.get_children_tree(&child.uri, max_depth.saturating_sub(1))).await?
303 } else {
304 Vec::new()
305 };
306
307 tree_nodes.push(crate::types::TreeNode {
308 node: child,
309 children: grandchildren,
310 layer_summary,
311 });
312 }
313
314 Ok(tree_nodes)
315 }
316
317 async fn get_layer_summary(
318 &self,
319 node_id: &str,
320 ) -> MemoryResult<Option<crate::types::LayerSummary>> {
321 let l0 = self.get_layer(node_id, crate::types::LayerType::L0).await?;
322 let l1 = self.get_layer(node_id, crate::types::LayerType::L1).await?;
323 let has_l2 = self
324 .get_layer(node_id, crate::types::LayerType::L2)
325 .await?
326 .is_some();
327
328 if l0.is_none() && l1.is_none() && !has_l2 {
329 return Ok(None);
330 }
331
332 Ok(Some(crate::types::LayerSummary {
333 l0_preview: l0.map(|l| truncate_string(&l.content, 100)),
334 l1_preview: l1.map(|l| truncate_string(&l.content, 200)),
335 has_l2,
336 }))
337 }
338
339 pub async fn node_exists(&self, uri: &str) -> MemoryResult<bool> {
340 let conn = self.conn.lock().await;
341 let count: i64 = conn.query_row(
342 "SELECT COUNT(*) FROM memory_nodes WHERE uri = ?1",
343 params![uri],
344 |row| row.get(0),
345 )?;
346 Ok(count > 0)
347 }
348}
349
350fn row_to_knowledge_space(row: &Row) -> Result<KnowledgeSpaceRecord, rusqlite::Error> {
351 let scope = row
352 .get::<_, String>(1)?
353 .parse()
354 .unwrap_or(tandem_orchestrator::KnowledgeScope::Project);
355 let trust_level = row
356 .get::<_, String>(6)?
357 .parse()
358 .unwrap_or(tandem_orchestrator::KnowledgeTrustLevel::Promoted);
359 let metadata = row
360 .get::<_, Option<String>>(7)?
361 .and_then(|raw| serde_json::from_str(&raw).ok());
362 Ok(KnowledgeSpaceRecord {
363 id: row.get(0)?,
364 scope,
365 project_id: row.get(2)?,
366 namespace: row.get(3)?,
367 title: row.get(4)?,
368 description: row.get(5)?,
369 trust_level,
370 metadata,
371 created_at_ms: row.get::<_, i64>(8)? as u64,
372 updated_at_ms: row.get::<_, i64>(9)? as u64,
373 })
374}
375
376fn row_to_knowledge_item(row: &Row) -> Result<KnowledgeItemRecord, rusqlite::Error> {
377 let trust_level = row
378 .get::<_, String>(8)?
379 .parse()
380 .unwrap_or(tandem_orchestrator::KnowledgeTrustLevel::Promoted);
381 let status = row
382 .get::<_, String>(9)?
383 .parse()
384 .unwrap_or(KnowledgeItemStatus::Working);
385 let payload = row
386 .get::<_, String>(7)
387 .ok()
388 .and_then(|raw| serde_json::from_str(&raw).ok())
389 .unwrap_or(serde_json::Value::Null);
390 let artifact_refs = row
391 .get::<_, String>(11)
392 .ok()
393 .and_then(|raw| serde_json::from_str(&raw).ok())
394 .unwrap_or_default();
395 let source_memory_ids = row
396 .get::<_, String>(12)
397 .ok()
398 .and_then(|raw| serde_json::from_str(&raw).ok())
399 .unwrap_or_default();
400 let metadata = row
401 .get::<_, Option<String>>(14)?
402 .and_then(|raw| serde_json::from_str(&raw).ok());
403 Ok(KnowledgeItemRecord {
404 id: row.get(0)?,
405 space_id: row.get(1)?,
406 coverage_key: row.get(2)?,
407 dedupe_key: row.get(3)?,
408 item_type: row.get(4)?,
409 title: row.get(5)?,
410 summary: row.get(6)?,
411 payload,
412 trust_level,
413 status,
414 run_id: row.get(10)?,
415 artifact_refs,
416 source_memory_ids,
417 freshness_expires_at_ms: row.get::<_, Option<i64>>(13)?.map(|value| value as u64),
418 metadata,
419 created_at_ms: row.get::<_, i64>(15)? as u64,
420 updated_at_ms: row.get::<_, i64>(16)? as u64,
421 })
422}
423
424fn row_to_knowledge_coverage(row: &Row) -> Result<KnowledgeCoverageRecord, rusqlite::Error> {
425 let metadata = row
426 .get::<_, Option<String>>(7)?
427 .and_then(|raw| serde_json::from_str(&raw).ok());
428 Ok(KnowledgeCoverageRecord {
429 coverage_key: row.get(0)?,
430 space_id: row.get(1)?,
431 latest_item_id: row.get(2)?,
432 latest_dedupe_key: row.get(3)?,
433 last_seen_at_ms: row.get::<_, i64>(4)? as u64,
434 last_promoted_at_ms: row.get::<_, Option<i64>>(5)?.map(|value| value as u64),
435 freshness_expires_at_ms: row.get::<_, Option<i64>>(6)?.map(|value| value as u64),
436 metadata,
437 })
438}
439
440fn truncate_string(s: &str, max_len: usize) -> String {
441 if s.len() <= max_len {
442 s.to_string()
443 } else {
444 format!("{}...", &s[..max_len.saturating_sub(3)])
445 }
446}
447
448fn build_fts_query(query: &str) -> String {
449 let tokens = query
450 .split_whitespace()
451 .filter_map(|tok| {
452 let cleaned =
453 tok.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-');
454 if cleaned.is_empty() {
455 None
456 } else {
457 Some(format!("\"{}\"", cleaned))
458 }
459 })
460 .collect::<Vec<_>>();
461 if tokens.is_empty() {
462 "\"\"".to_string()
463 } else {
464 tokens.join(" OR ")
465 }
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471 use serde_json::Value;
472 use tandem_orchestrator::{KnowledgeScope, KnowledgeTrustLevel};
473 use tempfile::TempDir;
474
475 async fn setup_test_db() -> (MemoryDatabase, TempDir) {
476 let temp_dir = TempDir::new().unwrap();
477 let db_path = temp_dir.path().join("test_memory.db");
478 let db = MemoryDatabase::new(&db_path).await.unwrap();
479 (db, temp_dir)
480 }
481
482 #[tokio::test]
483 async fn test_init_schema() {
484 let (db, _temp) = setup_test_db().await;
485 let stats = db.get_stats().await.unwrap();
487 assert_eq!(stats.total_chunks, 0);
488 }
489
490 #[tokio::test]
491 async fn test_knowledge_registry_roundtrip() {
492 let (db, _temp) = setup_test_db().await;
493
494 let space = KnowledgeSpaceRecord {
495 id: "space-1".to_string(),
496 scope: tandem_orchestrator::KnowledgeScope::Project,
497 project_id: Some("project-1".to_string()),
498 namespace: Some("support".to_string()),
499 title: Some("Support Knowledge".to_string()),
500 description: Some("Reusable support guidance".to_string()),
501 trust_level: tandem_orchestrator::KnowledgeTrustLevel::Promoted,
502 metadata: Some(serde_json::json!({"owner": "ops"})),
503 created_at_ms: 1,
504 updated_at_ms: 2,
505 };
506 db.upsert_knowledge_space(&space).await.unwrap();
507
508 let item = KnowledgeItemRecord {
509 id: "item-1".to_string(),
510 space_id: space.id.clone(),
511 coverage_key: "project-1/support/debugging/slow-start".to_string(),
512 dedupe_key: "dedupe-1".to_string(),
513 item_type: "decision".to_string(),
514 title: "Restart service before retry".to_string(),
515 summary: Some("When the service is stale, restart before retrying.".to_string()),
516 payload: serde_json::json!({"action": "restart"}),
517 trust_level: tandem_orchestrator::KnowledgeTrustLevel::Promoted,
518 status: KnowledgeItemStatus::Promoted,
519 run_id: Some("run-1".to_string()),
520 artifact_refs: vec!["artifact://run-1/report".to_string()],
521 source_memory_ids: vec!["memory-1".to_string()],
522 freshness_expires_at_ms: Some(10),
523 metadata: Some(serde_json::json!({"source": "run"})),
524 created_at_ms: 3,
525 updated_at_ms: 4,
526 };
527 db.upsert_knowledge_item(&item).await.unwrap();
528
529 let coverage = KnowledgeCoverageRecord {
530 coverage_key: item.coverage_key.clone(),
531 space_id: space.id.clone(),
532 latest_item_id: Some(item.id.clone()),
533 latest_dedupe_key: Some(item.dedupe_key.clone()),
534 last_seen_at_ms: 5,
535 last_promoted_at_ms: Some(6),
536 freshness_expires_at_ms: Some(10),
537 metadata: Some(serde_json::json!({"coverage": true})),
538 };
539 db.upsert_knowledge_coverage(&coverage).await.unwrap();
540
541 let loaded_space = db.get_knowledge_space(&space.id).await.unwrap().unwrap();
542 assert_eq!(loaded_space.namespace.as_deref(), Some("support"));
543
544 let loaded_items = db
545 .list_knowledge_items(&space.id, Some(&item.coverage_key))
546 .await
547 .unwrap();
548 assert_eq!(loaded_items.len(), 1);
549 assert_eq!(loaded_items[0].title, item.title);
550
551 let loaded_coverage = db
552 .get_knowledge_coverage(&item.coverage_key, &space.id)
553 .await
554 .unwrap()
555 .unwrap();
556 assert_eq!(loaded_coverage.latest_item_id.as_deref(), Some("item-1"));
557 }
558
559 #[tokio::test]
560 async fn test_store_and_retrieve_chunk() {
561 let (db, _temp) = setup_test_db().await;
562
563 let chunk = MemoryChunk {
564 id: "test-1".to_string(),
565 content: "Test content".to_string(),
566 tier: MemoryTier::Session,
567 session_id: Some("session-1".to_string()),
568 project_id: Some("project-1".to_string()),
569 source: "user_message".to_string(),
570 source_path: None,
571 source_mtime: None,
572 source_size: None,
573 source_hash: None,
574 created_at: Utc::now(),
575 token_count: 10,
576 metadata: None,
577 };
578
579 let embedding = vec![0.1f32; DEFAULT_EMBEDDING_DIMENSION];
580 db.store_chunk(&chunk, &embedding).await.unwrap();
581
582 let chunks = db.get_session_chunks("session-1").await.unwrap();
583 assert_eq!(chunks.len(), 1);
584 assert_eq!(chunks[0].content, "Test content");
585 }
586
587 #[tokio::test]
588 async fn test_store_and_retrieve_global_chunk() {
589 let (db, _temp) = setup_test_db().await;
590
591 let chunk = MemoryChunk {
592 id: "global-1".to_string(),
593 content: "Global note".to_string(),
594 tier: MemoryTier::Global,
595 session_id: None,
596 project_id: None,
597 source: "agent_note".to_string(),
598 source_path: None,
599 source_mtime: None,
600 source_size: None,
601 source_hash: None,
602 created_at: Utc::now(),
603 token_count: 7,
604 metadata: Some(serde_json::json!({"kind":"test"})),
605 };
606
607 let embedding = vec![0.2f32; DEFAULT_EMBEDDING_DIMENSION];
608 db.store_chunk(&chunk, &embedding).await.unwrap();
609
610 let chunks = db.get_global_chunks(10).await.unwrap();
611 assert_eq!(chunks.len(), 1);
612 assert_eq!(chunks[0].content, "Global note");
613 assert_eq!(chunks[0].source, "agent_note");
614 assert_eq!(chunks[0].token_count, 7);
615 assert_eq!(chunks[0].tier, MemoryTier::Global);
616 }
617
618 #[tokio::test]
619 async fn test_global_chunk_exists_by_source_hash() {
620 let (db, _temp) = setup_test_db().await;
621
622 let chunk = MemoryChunk {
623 id: "global-hash".to_string(),
624 content: "Global hash note".to_string(),
625 tier: MemoryTier::Global,
626 session_id: None,
627 project_id: None,
628 source: "chat_exchange".to_string(),
629 source_path: None,
630 source_mtime: None,
631 source_size: None,
632 source_hash: Some("hash-123".to_string()),
633 created_at: Utc::now(),
634 token_count: 5,
635 metadata: None,
636 };
637
638 let embedding = vec![0.3f32; DEFAULT_EMBEDDING_DIMENSION];
639 db.store_chunk(&chunk, &embedding).await.unwrap();
640
641 assert!(db
642 .global_chunk_exists_by_source_hash("hash-123")
643 .await
644 .unwrap());
645 assert!(!db
646 .global_chunk_exists_by_source_hash("missing-hash")
647 .await
648 .unwrap());
649 }
650
651 #[tokio::test]
652 async fn test_config_crud() {
653 let (db, _temp) = setup_test_db().await;
654
655 let config = db.get_or_create_config("project-1").await.unwrap();
656 assert_eq!(config.max_chunks, 10000);
657
658 let new_config = MemoryConfig {
659 max_chunks: 5000,
660 ..Default::default()
661 };
662 db.update_config("project-1", &new_config).await.unwrap();
663
664 let updated = db.get_or_create_config("project-1").await.unwrap();
665 assert_eq!(updated.max_chunks, 5000);
666 }
667
668 #[tokio::test]
669 async fn test_global_memory_put_search_and_dedup() {
670 let (db, _temp) = setup_test_db().await;
671 let now = chrono::Utc::now().timestamp_millis() as u64;
672 let record = GlobalMemoryRecord {
673 id: "gm-1".to_string(),
674 user_id: "user-a".to_string(),
675 source_type: "user_message".to_string(),
676 content: "remember rust workspace layout".to_string(),
677 content_hash: "h1".to_string(),
678 run_id: "run-1".to_string(),
679 session_id: Some("s1".to_string()),
680 message_id: Some("m1".to_string()),
681 tool_name: None,
682 project_tag: Some("proj-x".to_string()),
683 channel_tag: None,
684 host_tag: None,
685 metadata: None,
686 provenance: None,
687 redaction_status: "passed".to_string(),
688 redaction_count: 0,
689 visibility: "private".to_string(),
690 demoted: false,
691 score_boost: 0.0,
692 created_at_ms: now,
693 updated_at_ms: now,
694 expires_at_ms: None,
695 };
696 let first = db.put_global_memory_record(&record).await.unwrap();
697 assert!(first.stored);
698 let second = db.put_global_memory_record(&record).await.unwrap();
699 assert!(second.deduped);
700
701 let hits = db
702 .search_global_memory("user-a", "rust workspace", 5, Some("proj-x"), None, None)
703 .await
704 .unwrap();
705 assert!(!hits.is_empty());
706 assert_eq!(hits[0].record.id, "gm-1");
707 }
708
709 #[tokio::test]
710 async fn test_knowledge_registry_round_trip() {
711 let (db, _temp) = setup_test_db().await;
712 let now = chrono::Utc::now().timestamp_millis() as u64;
713
714 let space = KnowledgeSpaceRecord {
715 id: "space-1".to_string(),
716 scope: KnowledgeScope::Project,
717 project_id: Some("project-1".to_string()),
718 namespace: Some("marketing/positioning".to_string()),
719 title: Some("Marketing positioning".to_string()),
720 description: Some("Reusable positioning guidance".to_string()),
721 trust_level: KnowledgeTrustLevel::ApprovedDefault,
722 metadata: Some(serde_json::json!({"owner":"marketing"})),
723 created_at_ms: now,
724 updated_at_ms: now,
725 };
726 db.upsert_knowledge_space(&space).await.unwrap();
727
728 let loaded_space = db.get_knowledge_space("space-1").await.unwrap().unwrap();
729 assert_eq!(loaded_space.id, "space-1");
730 assert_eq!(loaded_space.scope, KnowledgeScope::Project);
731 assert_eq!(loaded_space.project_id.as_deref(), Some("project-1"));
732 assert_eq!(
733 loaded_space.namespace.as_deref(),
734 Some("marketing/positioning")
735 );
736
737 let item = KnowledgeItemRecord {
738 id: "item-1".to_string(),
739 space_id: "space-1".to_string(),
740 coverage_key: "project-1::marketing/positioning::strategy::pricing".to_string(),
741 dedupe_key: "item-1-dedupe".to_string(),
742 item_type: "evidence".to_string(),
743 title: "Pricing sensitivity observation".to_string(),
744 summary: Some("Customers reacted to annual pricing changes".to_string()),
745 payload: serde_json::json!({"claim":"Annual pricing changes created friction"}),
746 trust_level: KnowledgeTrustLevel::Promoted,
747 status: KnowledgeItemStatus::Promoted,
748 run_id: Some("run-1".to_string()),
749 artifact_refs: vec!["artifact://run-1/research-sources".to_string()],
750 source_memory_ids: vec!["memory-1".to_string()],
751 freshness_expires_at_ms: Some(now + 86_400_000),
752 metadata: Some(serde_json::json!({"source_kind":"web"})),
753 created_at_ms: now,
754 updated_at_ms: now,
755 };
756 db.upsert_knowledge_item(&item).await.unwrap();
757
758 let loaded_item = db.get_knowledge_item("item-1").await.unwrap().unwrap();
759 assert_eq!(loaded_item.id, "item-1");
760 assert_eq!(loaded_item.space_id, "space-1");
761 assert_eq!(
762 loaded_item.coverage_key,
763 "project-1::marketing/positioning::strategy::pricing"
764 );
765 assert_eq!(loaded_item.status, KnowledgeItemStatus::Promoted);
766 assert_eq!(
767 loaded_item.artifact_refs,
768 vec!["artifact://run-1/research-sources".to_string()]
769 );
770
771 let by_space = db.list_knowledge_items("space-1", None).await.unwrap();
772 assert_eq!(by_space.len(), 1);
773 let by_coverage = db
774 .list_knowledge_items(
775 "space-1",
776 Some("project-1::marketing/positioning::strategy::pricing"),
777 )
778 .await
779 .unwrap();
780 assert_eq!(by_coverage.len(), 1);
781
782 let coverage = KnowledgeCoverageRecord {
783 coverage_key: "project-1::marketing/positioning::strategy::pricing".to_string(),
784 space_id: "space-1".to_string(),
785 latest_item_id: Some("item-1".to_string()),
786 latest_dedupe_key: Some("item-1-dedupe".to_string()),
787 last_seen_at_ms: now,
788 last_promoted_at_ms: Some(now),
789 freshness_expires_at_ms: Some(now + 86_400_000),
790 metadata: Some(serde_json::json!({"reuse_reason":"same topic"})),
791 };
792 db.upsert_knowledge_coverage(&coverage).await.unwrap();
793
794 let loaded_coverage = db
795 .get_knowledge_coverage(
796 "project-1::marketing/positioning::strategy::pricing",
797 "space-1",
798 )
799 .await
800 .unwrap()
801 .unwrap();
802 assert_eq!(loaded_coverage.space_id, "space-1");
803 assert_eq!(loaded_coverage.latest_item_id.as_deref(), Some("item-1"));
804 assert_eq!(
805 loaded_coverage.latest_dedupe_key.as_deref(),
806 Some("item-1-dedupe")
807 );
808 }
809
810 #[tokio::test]
811 async fn test_knowledge_promotion_working_to_promoted_updates_coverage() {
812 let (db, _temp) = setup_test_db().await;
813 let now = chrono::Utc::now().timestamp_millis() as u64;
814
815 let space = KnowledgeSpaceRecord {
816 id: "space-promote-1".to_string(),
817 scope: KnowledgeScope::Project,
818 project_id: Some("project-1".to_string()),
819 namespace: Some("engineering/debugging".to_string()),
820 title: Some("Engineering debugging".to_string()),
821 description: Some("Reusable debugging guidance".to_string()),
822 trust_level: KnowledgeTrustLevel::Promoted,
823 metadata: None,
824 created_at_ms: now,
825 updated_at_ms: now,
826 };
827 db.upsert_knowledge_space(&space).await.unwrap();
828
829 let item = KnowledgeItemRecord {
830 id: "item-promote-1".to_string(),
831 space_id: space.id.clone(),
832 coverage_key: "project-1::engineering/debugging::startup::race".to_string(),
833 dedupe_key: "dedupe-promote-1".to_string(),
834 item_type: "decision".to_string(),
835 title: "Delay startup-dependent retries".to_string(),
836 summary: Some("Retry only after startup completed.".to_string()),
837 payload: serde_json::json!({"action":"delay_retry"}),
838 trust_level: KnowledgeTrustLevel::Working,
839 status: KnowledgeItemStatus::Working,
840 run_id: Some("run-1".to_string()),
841 artifact_refs: vec!["artifact://run-1/debug".to_string()],
842 source_memory_ids: vec!["memory-1".to_string()],
843 freshness_expires_at_ms: None,
844 metadata: None,
845 created_at_ms: now,
846 updated_at_ms: now,
847 };
848 db.upsert_knowledge_item(&item).await.unwrap();
849
850 let promote = KnowledgePromotionRequest {
851 item_id: item.id.clone(),
852 target_status: KnowledgeItemStatus::Promoted,
853 promoted_at_ms: now + 10,
854 freshness_expires_at_ms: Some(now + 86_400_000),
855 reviewer_id: None,
856 approval_id: None,
857 reason: Some("validated in workflow".to_string()),
858 };
859
860 let result = db.promote_knowledge_item(&promote).await.unwrap().unwrap();
861 assert!(result.promoted);
862 assert_eq!(result.item.status, KnowledgeItemStatus::Promoted);
863 assert_eq!(result.item.trust_level, KnowledgeTrustLevel::Promoted);
864 assert_eq!(
865 result.coverage.latest_item_id.as_deref(),
866 Some("item-promote-1")
867 );
868 assert_eq!(
869 result.coverage.latest_dedupe_key.as_deref(),
870 Some("dedupe-promote-1")
871 );
872 assert_eq!(result.coverage.last_promoted_at_ms, Some(now + 10));
873 }
874
875 #[tokio::test]
876 async fn test_knowledge_promotion_promoted_to_approved_default_requires_review() {
877 let (db, _temp) = setup_test_db().await;
878 let now = chrono::Utc::now().timestamp_millis() as u64;
879
880 let space = KnowledgeSpaceRecord {
881 id: "space-promote-2".to_string(),
882 scope: KnowledgeScope::Project,
883 project_id: Some("project-1".to_string()),
884 namespace: Some("marketing/positioning".to_string()),
885 title: Some("Marketing positioning".to_string()),
886 description: Some("Reusable positioning guidance".to_string()),
887 trust_level: KnowledgeTrustLevel::Promoted,
888 metadata: None,
889 created_at_ms: now,
890 updated_at_ms: now,
891 };
892 db.upsert_knowledge_space(&space).await.unwrap();
893
894 let item = KnowledgeItemRecord {
895 id: "item-promote-2".to_string(),
896 space_id: space.id.clone(),
897 coverage_key: "project-1::marketing/positioning::strategy::pricing".to_string(),
898 dedupe_key: "dedupe-promote-2".to_string(),
899 item_type: "evidence".to_string(),
900 title: "Pricing observation".to_string(),
901 summary: Some("Annual pricing changes created friction".to_string()),
902 payload: serde_json::json!({"claim":"pricing friction"}),
903 trust_level: KnowledgeTrustLevel::Promoted,
904 status: KnowledgeItemStatus::Promoted,
905 run_id: Some("run-2".to_string()),
906 artifact_refs: vec!["artifact://run-2/research".to_string()],
907 source_memory_ids: vec!["memory-2".to_string()],
908 freshness_expires_at_ms: None,
909 metadata: None,
910 created_at_ms: now,
911 updated_at_ms: now,
912 };
913 db.upsert_knowledge_item(&item).await.unwrap();
914
915 let promote = KnowledgePromotionRequest {
916 item_id: item.id.clone(),
917 target_status: KnowledgeItemStatus::ApprovedDefault,
918 promoted_at_ms: now + 5,
919 freshness_expires_at_ms: None,
920 reviewer_id: None,
921 approval_id: None,
922 reason: Some("should require review".to_string()),
923 };
924
925 let err = db.promote_knowledge_item(&promote).await.unwrap_err();
926 match err {
927 MemoryError::InvalidConfig(_) => {}
928 other => panic!("unexpected error: {}", other),
929 }
930 }
931
932 #[tokio::test]
933 async fn test_knowledge_promotion_promoted_to_approved_default_updates_coverage() {
934 let (db, _temp) = setup_test_db().await;
935 let now = chrono::Utc::now().timestamp_millis() as u64;
936
937 let space = KnowledgeSpaceRecord {
938 id: "space-promote-3".to_string(),
939 scope: KnowledgeScope::Project,
940 project_id: Some("project-1".to_string()),
941 namespace: Some("support/runbooks".to_string()),
942 title: Some("Support runbooks".to_string()),
943 description: Some("Reusable runbook guidance".to_string()),
944 trust_level: KnowledgeTrustLevel::Promoted,
945 metadata: None,
946 created_at_ms: now,
947 updated_at_ms: now,
948 };
949 db.upsert_knowledge_space(&space).await.unwrap();
950
951 let item = KnowledgeItemRecord {
952 id: "item-promote-3".to_string(),
953 space_id: space.id.clone(),
954 coverage_key: "project-1::support/runbooks::oncall::restart".to_string(),
955 dedupe_key: "dedupe-promote-3".to_string(),
956 item_type: "runbook".to_string(),
957 title: "Restart service and verify".to_string(),
958 summary: Some("Restart then validate health endpoint.".to_string()),
959 payload: serde_json::json!({"steps":["restart","healthcheck"]}),
960 trust_level: KnowledgeTrustLevel::Promoted,
961 status: KnowledgeItemStatus::Promoted,
962 run_id: Some("run-3".to_string()),
963 artifact_refs: vec!["artifact://run-3/runbook".to_string()],
964 source_memory_ids: vec!["memory-3".to_string()],
965 freshness_expires_at_ms: None,
966 metadata: None,
967 created_at_ms: now,
968 updated_at_ms: now,
969 };
970 db.upsert_knowledge_item(&item).await.unwrap();
971
972 let promote = KnowledgePromotionRequest {
973 item_id: item.id.clone(),
974 target_status: KnowledgeItemStatus::ApprovedDefault,
975 promoted_at_ms: now + 12,
976 freshness_expires_at_ms: Some(now + 172_800_000),
977 reviewer_id: Some("reviewer-1".to_string()),
978 approval_id: Some("approval-1".to_string()),
979 reason: Some("reviewed by ops".to_string()),
980 };
981
982 let result = db.promote_knowledge_item(&promote).await.unwrap().unwrap();
983 assert!(result.promoted);
984 assert_eq!(result.item.status, KnowledgeItemStatus::ApprovedDefault);
985 assert_eq!(
986 result.item.trust_level,
987 KnowledgeTrustLevel::ApprovedDefault
988 );
989 assert_eq!(result.coverage.last_promoted_at_ms, Some(now + 12));
990 assert_eq!(
991 result.coverage.latest_item_id.as_deref(),
992 Some("item-promote-3")
993 );
994 }
995
996 #[tokio::test]
997 async fn test_knowledge_promotion_rejects_deprecated() {
998 let (db, _temp) = setup_test_db().await;
999 let now = chrono::Utc::now().timestamp_millis() as u64;
1000
1001 let space = KnowledgeSpaceRecord {
1002 id: "space-promote-4".to_string(),
1003 scope: KnowledgeScope::Project,
1004 project_id: Some("project-1".to_string()),
1005 namespace: Some("ops".to_string()),
1006 title: Some("Ops knowledge".to_string()),
1007 description: Some("Reusable ops knowledge".to_string()),
1008 trust_level: KnowledgeTrustLevel::Promoted,
1009 metadata: None,
1010 created_at_ms: now,
1011 updated_at_ms: now,
1012 };
1013 db.upsert_knowledge_space(&space).await.unwrap();
1014
1015 let item = KnowledgeItemRecord {
1016 id: "item-promote-4".to_string(),
1017 space_id: space.id.clone(),
1018 coverage_key: "project-1::ops::incident::latency".to_string(),
1019 dedupe_key: "dedupe-promote-4".to_string(),
1020 item_type: "decision".to_string(),
1021 title: "Ignore deprecated item".to_string(),
1022 summary: None,
1023 payload: serde_json::json!({"decision":"deprecated"}),
1024 trust_level: KnowledgeTrustLevel::Promoted,
1025 status: KnowledgeItemStatus::Deprecated,
1026 run_id: Some("run-4".to_string()),
1027 artifact_refs: vec![],
1028 source_memory_ids: vec![],
1029 freshness_expires_at_ms: None,
1030 metadata: None,
1031 created_at_ms: now,
1032 updated_at_ms: now,
1033 };
1034 db.upsert_knowledge_item(&item).await.unwrap();
1035
1036 let promote = KnowledgePromotionRequest {
1037 item_id: item.id.clone(),
1038 target_status: KnowledgeItemStatus::Promoted,
1039 promoted_at_ms: now + 1,
1040 freshness_expires_at_ms: None,
1041 reviewer_id: None,
1042 approval_id: None,
1043 reason: None,
1044 };
1045
1046 let err = db.promote_knowledge_item(&promote).await.unwrap_err();
1047 match err {
1048 MemoryError::InvalidConfig(_) => {}
1049 other => panic!("unexpected error: {}", other),
1050 }
1051 }
1052
1053 #[tokio::test]
1054 async fn test_knowledge_promotion_idempotent_updates_coverage() {
1055 let (db, _temp) = setup_test_db().await;
1056 let now = chrono::Utc::now().timestamp_millis() as u64;
1057
1058 let space = KnowledgeSpaceRecord {
1059 id: "space-promote-5".to_string(),
1060 scope: KnowledgeScope::Project,
1061 project_id: Some("project-1".to_string()),
1062 namespace: Some("engineering/ops".to_string()),
1063 title: Some("Engineering ops".to_string()),
1064 description: None,
1065 trust_level: KnowledgeTrustLevel::Promoted,
1066 metadata: None,
1067 created_at_ms: now,
1068 updated_at_ms: now,
1069 };
1070 db.upsert_knowledge_space(&space).await.unwrap();
1071
1072 let item = KnowledgeItemRecord {
1073 id: "item-promote-5".to_string(),
1074 space_id: space.id.clone(),
1075 coverage_key: "project-1::engineering/ops::deploy::guardrails".to_string(),
1076 dedupe_key: "dedupe-promote-5".to_string(),
1077 item_type: "pattern".to_string(),
1078 title: "Deploy guardrails".to_string(),
1079 summary: None,
1080 payload: serde_json::json!({"pattern":"guardrails"}),
1081 trust_level: KnowledgeTrustLevel::Promoted,
1082 status: KnowledgeItemStatus::Promoted,
1083 run_id: Some("run-5".to_string()),
1084 artifact_refs: vec![],
1085 source_memory_ids: vec![],
1086 freshness_expires_at_ms: None,
1087 metadata: None,
1088 created_at_ms: now,
1089 updated_at_ms: now,
1090 };
1091 db.upsert_knowledge_item(&item).await.unwrap();
1092
1093 let promote = KnowledgePromotionRequest {
1094 item_id: item.id.clone(),
1095 target_status: KnowledgeItemStatus::Promoted,
1096 promoted_at_ms: now + 20,
1097 freshness_expires_at_ms: None,
1098 reviewer_id: None,
1099 approval_id: None,
1100 reason: None,
1101 };
1102
1103 let result = db.promote_knowledge_item(&promote).await.unwrap().unwrap();
1104 assert!(!result.promoted);
1105 assert_eq!(result.coverage.last_promoted_at_ms, Some(now + 20));
1106 assert_eq!(
1107 result.coverage.latest_item_id.as_deref(),
1108 Some("item-promote-5")
1109 );
1110 }
1111
1112 #[tokio::test]
1113 async fn test_knowledge_item_promotion_updates_coverage() {
1114 let (db, _temp) = setup_test_db().await;
1115 let now = chrono::Utc::now().timestamp_millis() as u64;
1116
1117 let space = KnowledgeSpaceRecord {
1118 id: "space-promote".to_string(),
1119 scope: KnowledgeScope::Project,
1120 project_id: Some("project-1".to_string()),
1121 namespace: Some("engineering/debugging".to_string()),
1122 title: Some("Engineering debugging".to_string()),
1123 description: Some("Reusable debugging guidance".to_string()),
1124 trust_level: KnowledgeTrustLevel::Promoted,
1125 metadata: None,
1126 created_at_ms: now,
1127 updated_at_ms: now,
1128 };
1129 db.upsert_knowledge_space(&space).await.unwrap();
1130
1131 let item = KnowledgeItemRecord {
1132 id: "item-promote".to_string(),
1133 space_id: space.id.clone(),
1134 coverage_key: "project-1::engineering/debugging::startup::race".to_string(),
1135 dedupe_key: "dedupe-promote".to_string(),
1136 item_type: "decision".to_string(),
1137 title: "Delay startup-dependent retries".to_string(),
1138 summary: Some("Retry only after startup completes.".to_string()),
1139 payload: serde_json::json!({"action": "delay_retry"}),
1140 trust_level: KnowledgeTrustLevel::Working,
1141 status: KnowledgeItemStatus::Working,
1142 run_id: Some("run-promote".to_string()),
1143 artifact_refs: vec!["artifact://run-promote/report".to_string()],
1144 source_memory_ids: vec!["memory-promote".to_string()],
1145 freshness_expires_at_ms: None,
1146 metadata: Some(serde_json::json!({"source_kind":"run"})),
1147 created_at_ms: now,
1148 updated_at_ms: now,
1149 };
1150 db.upsert_knowledge_item(&item).await.unwrap();
1151
1152 let request = KnowledgePromotionRequest {
1153 item_id: item.id.clone(),
1154 target_status: KnowledgeItemStatus::Promoted,
1155 promoted_at_ms: now + 10,
1156 freshness_expires_at_ms: Some(now + 86_400_000),
1157 reviewer_id: None,
1158 approval_id: None,
1159 reason: Some("validated".to_string()),
1160 };
1161 let promoted = db
1162 .promote_knowledge_item(&request)
1163 .await
1164 .unwrap()
1165 .expect("promotion result");
1166 assert_eq!(promoted.previous_status, KnowledgeItemStatus::Working);
1167 assert!(promoted.promoted);
1168 assert_eq!(promoted.item.status, KnowledgeItemStatus::Promoted);
1169 assert_eq!(promoted.item.trust_level, KnowledgeTrustLevel::Promoted);
1170 assert_eq!(
1171 promoted.item.freshness_expires_at_ms,
1172 Some(now + 86_400_000)
1173 );
1174 assert_eq!(
1175 promoted
1176 .item
1177 .metadata
1178 .as_ref()
1179 .and_then(|value| value.get("promotion"))
1180 .and_then(|value| value.get("to_status"))
1181 .and_then(Value::as_str),
1182 Some("promoted")
1183 );
1184 assert_eq!(
1185 promoted.coverage.latest_item_id.as_deref(),
1186 Some("item-promote")
1187 );
1188 assert_eq!(
1189 promoted.coverage.latest_dedupe_key.as_deref(),
1190 Some("dedupe-promote")
1191 );
1192 assert_eq!(promoted.coverage.last_promoted_at_ms, Some(now + 10));
1193 assert_eq!(
1194 promoted.coverage.freshness_expires_at_ms,
1195 Some(now + 86_400_000)
1196 );
1197
1198 let loaded = db
1199 .get_knowledge_item("item-promote")
1200 .await
1201 .unwrap()
1202 .unwrap();
1203 assert_eq!(loaded.status, KnowledgeItemStatus::Promoted);
1204 assert_eq!(
1205 loaded
1206 .metadata
1207 .as_ref()
1208 .and_then(|value| value.get("promotion"))
1209 .and_then(|value| value.get("from_status"))
1210 .and_then(Value::as_str),
1211 Some("working")
1212 );
1213 }
1214
1215 #[tokio::test]
1216 async fn test_knowledge_item_approved_default_requires_review() {
1217 let (db, _temp) = setup_test_db().await;
1218 let now = chrono::Utc::now().timestamp_millis() as u64;
1219
1220 let space = KnowledgeSpaceRecord {
1221 id: "space-approved".to_string(),
1222 scope: KnowledgeScope::Project,
1223 project_id: Some("project-1".to_string()),
1224 namespace: Some("marketing/positioning".to_string()),
1225 title: Some("Marketing positioning".to_string()),
1226 description: Some("Reusable positioning guidance".to_string()),
1227 trust_level: KnowledgeTrustLevel::Promoted,
1228 metadata: None,
1229 created_at_ms: now,
1230 updated_at_ms: now,
1231 };
1232 db.upsert_knowledge_space(&space).await.unwrap();
1233
1234 let item = KnowledgeItemRecord {
1235 id: "item-approved".to_string(),
1236 space_id: space.id.clone(),
1237 coverage_key: "project-1::marketing/positioning::strategy::pricing".to_string(),
1238 dedupe_key: "dedupe-approved".to_string(),
1239 item_type: "evidence".to_string(),
1240 title: "Pricing sensitivity observation".to_string(),
1241 summary: Some("Customers reacted to annual pricing changes".to_string()),
1242 payload: serde_json::json!({"claim":"Annual pricing changes created friction"}),
1243 trust_level: KnowledgeTrustLevel::Promoted,
1244 status: KnowledgeItemStatus::Promoted,
1245 run_id: Some("run-approved".to_string()),
1246 artifact_refs: vec!["artifact://run-approved/research".to_string()],
1247 source_memory_ids: vec!["memory-approved".to_string()],
1248 freshness_expires_at_ms: Some(now + 1234),
1249 metadata: None,
1250 created_at_ms: now,
1251 updated_at_ms: now,
1252 };
1253 db.upsert_knowledge_item(&item).await.unwrap();
1254
1255 let request = KnowledgePromotionRequest {
1256 item_id: item.id.clone(),
1257 target_status: KnowledgeItemStatus::ApprovedDefault,
1258 promoted_at_ms: now + 20,
1259 freshness_expires_at_ms: Some(now + 90_000_000),
1260 reviewer_id: Some("reviewer-1".to_string()),
1261 approval_id: Some("approval-1".to_string()),
1262 reason: Some("approved as default guidance".to_string()),
1263 };
1264 let promoted = db
1265 .promote_knowledge_item(&request)
1266 .await
1267 .unwrap()
1268 .expect("promotion result");
1269 assert_eq!(promoted.previous_status, KnowledgeItemStatus::Promoted);
1270 assert_eq!(promoted.item.status, KnowledgeItemStatus::ApprovedDefault);
1271 assert_eq!(
1272 promoted.item.trust_level,
1273 KnowledgeTrustLevel::ApprovedDefault
1274 );
1275 assert_eq!(promoted.coverage.last_promoted_at_ms, Some(now + 20));
1276 assert_eq!(
1277 promoted
1278 .item
1279 .metadata
1280 .as_ref()
1281 .and_then(|value| value.get("promotion"))
1282 .and_then(|value| value.get("approval_id"))
1283 .and_then(Value::as_str),
1284 Some("approval-1")
1285 );
1286 }
1287
1288 #[tokio::test]
1289 async fn test_knowledge_item_promotion_rejects_invalid_transition() {
1290 let (db, _temp) = setup_test_db().await;
1291 let now = chrono::Utc::now().timestamp_millis() as u64;
1292
1293 let space = KnowledgeSpaceRecord {
1294 id: "space-invalid".to_string(),
1295 scope: KnowledgeScope::Project,
1296 project_id: Some("project-1".to_string()),
1297 namespace: Some("support".to_string()),
1298 title: Some("Support".to_string()),
1299 description: Some("Support guidance".to_string()),
1300 trust_level: KnowledgeTrustLevel::Promoted,
1301 metadata: None,
1302 created_at_ms: now,
1303 updated_at_ms: now,
1304 };
1305 db.upsert_knowledge_space(&space).await.unwrap();
1306
1307 let item = KnowledgeItemRecord {
1308 id: "item-invalid".to_string(),
1309 space_id: space.id.clone(),
1310 coverage_key: "project-1::support::workflow::triage".to_string(),
1311 dedupe_key: "dedupe-invalid".to_string(),
1312 item_type: "decision".to_string(),
1313 title: "Triage first".to_string(),
1314 summary: None,
1315 payload: serde_json::json!({"action":"triage"}),
1316 trust_level: KnowledgeTrustLevel::Working,
1317 status: KnowledgeItemStatus::Working,
1318 run_id: Some("run-invalid".to_string()),
1319 artifact_refs: vec![],
1320 source_memory_ids: vec![],
1321 freshness_expires_at_ms: None,
1322 metadata: None,
1323 created_at_ms: now,
1324 updated_at_ms: now,
1325 };
1326 db.upsert_knowledge_item(&item).await.unwrap();
1327
1328 let request = KnowledgePromotionRequest {
1329 item_id: item.id.clone(),
1330 target_status: KnowledgeItemStatus::ApprovedDefault,
1331 promoted_at_ms: now + 1,
1332 freshness_expires_at_ms: None,
1333 reviewer_id: Some("reviewer-1".to_string()),
1334 approval_id: Some("approval-1".to_string()),
1335 reason: Some("should fail".to_string()),
1336 };
1337 let err = db.promote_knowledge_item(&request).await.unwrap_err();
1338 assert!(matches!(err, MemoryError::InvalidConfig(_)));
1339 let loaded = db.get_knowledge_item(&item.id).await.unwrap().unwrap();
1340 assert_eq!(loaded.status, KnowledgeItemStatus::Working);
1341 }
1342}