1use anyhow::{anyhow, Result};
2use rusqlite::{params, Connection};
3
4use crate::memory::state_key::StateKeyDecision;
5
6pub const SHORT_CURRENT_TTL_SECONDS: i64 = 24 * 60 * 60;
7pub const BRANCH_SNAPSHOT_TTL_SECONDS: i64 = 7 * 24 * 60 * 60;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum MemoryLifecycleOp {
11 Add,
12 Update,
13 Invalidate,
14 Noop,
15 Defer,
16 Conflict,
17}
18
19impl MemoryLifecycleOp {
20 pub fn as_str(self) -> &'static str {
21 match self {
22 Self::Add => "add",
23 Self::Update => "update",
24 Self::Invalidate => "invalidate",
25 Self::Noop => "noop",
26 Self::Defer => "defer",
27 Self::Conflict => "conflict",
28 }
29 }
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct LifecycleOutcome {
34 pub op: MemoryLifecycleOp,
35 pub memory_id: Option<i64>,
36 pub superseded: usize,
37 pub noop: bool,
38 pub deferred: bool,
39 pub reason: Option<String>,
40}
41
42#[allow(clippy::too_many_arguments)]
43pub fn apply_add(
44 conn: &Connection,
45 session_id: Option<&str>,
46 project: &str,
47 topic_key: Option<&str>,
48 title: &str,
49 content: &str,
50 memory_type: &str,
51 files: Option<&str>,
52 branch: Option<&str>,
53 scope: &str,
54) -> Result<LifecycleOutcome> {
55 let memory_id = crate::memory::insert_memory_full(
56 conn,
57 session_id,
58 project,
59 topic_key,
60 title,
61 content,
62 memory_type,
63 files,
64 branch,
65 scope,
66 None,
67 )?;
68 Ok(LifecycleOutcome {
69 op: MemoryLifecycleOp::Add,
70 memory_id: Some(memory_id),
71 superseded: 0,
72 noop: false,
73 deferred: false,
74 reason: None,
75 })
76}
77
78#[allow(clippy::too_many_arguments)]
79pub fn apply_update(
80 conn: &Connection,
81 session_id: Option<&str>,
82 project: &str,
83 topic_key: &str,
84 title: &str,
85 content: &str,
86 memory_type: &str,
87 files: Option<&str>,
88 branch: Option<&str>,
89 scope: &str,
90 superseded_ids: &[i64],
91) -> Result<LifecycleOutcome> {
92 let tx = conn.unchecked_transaction()?;
93 let ownership = lifecycle_ownership(project, scope);
94 let state_key =
95 crate::memory::state_key::derive_state_key(memory_type, Some(topic_key), title, content);
96 let mut superseded_targets = superseded_ids.to_vec();
97 superseded_targets.extend(find_active_same_state_or_topic(
98 &tx,
99 &ownership,
100 memory_type,
101 topic_key,
102 state_key.as_ref(),
103 )?);
104 let memory_id = insert_replacement_memory(
105 &tx,
106 session_id,
107 project,
108 topic_key,
109 title,
110 content,
111 memory_type,
112 files,
113 branch,
114 scope,
115 &ownership,
116 state_key.as_ref(),
117 )?;
118 let superseded = soft_supersede(&tx, project, &superseded_targets, Some(memory_id))?;
119 crate::memory::edge::insert_supersedes_edges(
120 &tx,
121 &superseded_targets,
122 memory_id,
123 crate::memory::edge::MemoryEdgeWriteContext {
124 reason: Some("lifecycle update supersedes old memory"),
125 ..Default::default()
126 },
127 )?;
128 tx.commit()?;
129 Ok(LifecycleOutcome {
130 op: MemoryLifecycleOp::Update,
131 memory_id: Some(memory_id),
132 superseded,
133 noop: false,
134 deferred: false,
135 reason: None,
136 })
137}
138
139pub fn apply_invalidate(
140 conn: &Connection,
141 project: &str,
142 memory_ids: &[i64],
143 reason: Option<&str>,
144) -> Result<LifecycleOutcome> {
145 let tx = conn.unchecked_transaction()?;
146 let superseded = soft_supersede(&tx, project, memory_ids, None)?;
147 tx.commit()?;
148 Ok(LifecycleOutcome {
149 op: MemoryLifecycleOp::Invalidate,
150 memory_id: None,
151 superseded,
152 noop: false,
153 deferred: false,
154 reason: reason.map(str::to_string),
155 })
156}
157
158#[allow(clippy::too_many_arguments)]
159fn insert_replacement_memory(
160 conn: &Connection,
161 session_id: Option<&str>,
162 project: &str,
163 topic_key: &str,
164 title: &str,
165 content: &str,
166 memory_type: &str,
167 files: Option<&str>,
168 branch: Option<&str>,
169 scope: &str,
170 ownership: &LifecycleOwnership<'_>,
171 state_key: Option<&StateKeyDecision>,
172) -> Result<i64> {
173 let now = chrono::Utc::now().timestamp();
174 let (expires_at_epoch, valid_from_epoch) =
175 ttl_metadata(memory_type, Some(topic_key), content, now);
176 let search_context = crate::memory::search_context::build_search_context(
177 memory_type,
178 Some(topic_key),
179 content,
180 files,
181 );
182 conn.execute(
183 "INSERT INTO memories
184 (session_id, project, topic_key, title, content, memory_type, files, search_context,
185 created_at_epoch, updated_at_epoch, status, branch, scope,
186 source_project, target_project, owner_scope, owner_key, context_class,
187 expires_at_epoch, valid_from_epoch)
188 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8,
189 ?9, ?9, 'active', ?10, ?11,
190 ?12, ?13, ?14, ?15, 'startup_core',
191 ?16, ?17)",
192 params![
193 session_id,
194 project,
195 topic_key,
196 title,
197 content,
198 memory_type,
199 files,
200 search_context,
201 now,
202 branch,
203 scope,
204 ownership.source_project,
205 ownership.target_project,
206 ownership.owner_scope,
207 ownership.owner_key,
208 expires_at_epoch,
209 valid_from_epoch
210 ],
211 )?;
212 let memory_id = conn.last_insert_rowid();
213 if let Some(state_key) = state_key {
214 crate::memory::state_key::attach_current_memory(
215 conn,
216 memory_id,
217 ownership.owner_scope,
218 ownership.owner_key,
219 memory_type,
220 state_key,
221 now,
222 )?;
223 }
224 crate::retrieval::vector::upsert_memory_embedding(
225 conn,
226 memory_id,
227 title,
228 content,
229 memory_type,
230 Some(topic_key),
231 )?;
232 Ok(memory_id)
233}
234
235struct LifecycleOwnership<'a> {
236 source_project: &'a str,
237 target_project: Option<&'a str>,
238 owner_scope: &'static str,
239 owner_key: &'a str,
240}
241
242fn lifecycle_ownership<'a>(project: &'a str, scope: &str) -> LifecycleOwnership<'a> {
243 if scope == "global" {
244 LifecycleOwnership {
245 source_project: project,
246 target_project: None,
247 owner_scope: "user",
248 owner_key: "user:default",
249 }
250 } else {
251 LifecycleOwnership {
252 source_project: project,
253 target_project: Some(project),
254 owner_scope: "repo",
255 owner_key: project,
256 }
257 }
258}
259
260fn find_active_same_state_or_topic(
261 conn: &Connection,
262 ownership: &LifecycleOwnership<'_>,
263 memory_type: &str,
264 topic_key: &str,
265 state_key: Option<&StateKeyDecision>,
266) -> Result<Vec<i64>> {
267 let mut ids = Vec::new();
268 if let Some(state_key) = state_key {
269 ids.extend(crate::memory::state_key::active_memory_ids(
270 conn,
271 ownership.owner_scope,
272 ownership.owner_key,
273 memory_type,
274 &state_key.state_key,
275 chrono::Utc::now().timestamp(),
276 false,
277 )?);
278 }
279 ids.extend(find_active_same_topic_key(
280 conn,
281 ownership,
282 memory_type,
283 topic_key,
284 )?);
285 Ok(ids)
286}
287
288fn find_active_same_topic_key(
289 conn: &Connection,
290 ownership: &LifecycleOwnership<'_>,
291 memory_type: &str,
292 topic_key: &str,
293) -> Result<Vec<i64>> {
294 let mut stmt = conn.prepare(
295 "SELECT id FROM memories
296 WHERE memory_type = ?1
297 AND topic_key = ?2
298 AND COALESCE(
299 owner_scope,
300 CASE WHEN COALESCE(scope, 'project') = 'global' THEN 'user' ELSE 'repo' END
301 ) = ?3
302 AND COALESCE(
303 owner_key,
304 CASE WHEN COALESCE(scope, 'project') = 'global' THEN 'user:default' ELSE project END
305 ) = ?4
306 AND status = 'active'",
307 )?;
308 let rows = stmt.query_map(
309 params![
310 memory_type,
311 topic_key,
312 ownership.owner_scope,
313 ownership.owner_key
314 ],
315 |row| row.get(0),
316 )?;
317 crate::db::query::collect_rows(rows)
318}
319
320pub fn noop(reason: impl Into<String>) -> LifecycleOutcome {
321 LifecycleOutcome {
322 op: MemoryLifecycleOp::Noop,
323 memory_id: None,
324 superseded: 0,
325 noop: true,
326 deferred: false,
327 reason: Some(reason.into()),
328 }
329}
330
331pub fn defer(reason: impl Into<String>) -> LifecycleOutcome {
332 LifecycleOutcome {
333 op: MemoryLifecycleOp::Defer,
334 memory_id: None,
335 superseded: 0,
336 noop: false,
337 deferred: true,
338 reason: Some(reason.into()),
339 }
340}
341
342pub fn default_ttl_seconds(
343 memory_type: &str,
344 topic_key: Option<&str>,
345 content: &str,
346) -> Option<i64> {
347 let topic_key = topic_key.unwrap_or_default().to_ascii_lowercase();
348 let content = content.to_ascii_lowercase();
349
350 if has_any(&topic_key, short_current_needles()) {
351 return Some(SHORT_CURRENT_TTL_SECONDS);
352 }
353
354 if has_any(&topic_key, branch_snapshot_needles()) {
355 return Some(BRANCH_SNAPSHOT_TTL_SECONDS);
356 }
357
358 if durable_type_has_no_content_ttl(memory_type) {
359 return None;
360 }
361
362 if has_any(&content, short_current_needles()) {
363 return Some(SHORT_CURRENT_TTL_SECONDS);
364 }
365
366 if has_any(&content, branch_snapshot_needles()) {
367 return Some(BRANCH_SNAPSHOT_TTL_SECONDS);
368 }
369
370 None
371}
372
373pub fn expires_at_epoch(
374 memory_type: &str,
375 topic_key: Option<&str>,
376 content: &str,
377 now_epoch: i64,
378) -> Option<i64> {
379 default_ttl_seconds(memory_type, topic_key, content).map(|ttl| now_epoch + ttl)
380}
381
382pub fn ttl_metadata(
383 memory_type: &str,
384 topic_key: Option<&str>,
385 content: &str,
386 now_epoch: i64,
387) -> (Option<i64>, Option<i64>) {
388 let expires_at_epoch = expires_at_epoch(memory_type, topic_key, content, now_epoch);
389 let valid_from_epoch = expires_at_epoch.map(|_| now_epoch);
390 (expires_at_epoch, valid_from_epoch)
391}
392
393pub fn expire_active_memories(conn: &Connection, now_epoch: i64) -> Result<usize> {
394 let tx = conn.unchecked_transaction()?;
395 let mut stmt = tx.prepare(
396 "SELECT id FROM memories
397 WHERE status = 'active'
398 AND expires_at_epoch IS NOT NULL
399 AND expires_at_epoch <= ?1",
400 )?;
401 let rows = stmt.query_map(params![now_epoch], |row| row.get::<_, i64>(0))?;
402 let expiring_ids = crate::db::query::collect_rows(rows)?;
403 drop(stmt);
404 let changed = tx.execute(
405 "UPDATE memories
406 SET status = 'stale',
407 valid_to_epoch = COALESCE(valid_to_epoch, ?1),
408 updated_at_epoch = ?1
409 WHERE status = 'active'
410 AND expires_at_epoch IS NOT NULL
411 AND expires_at_epoch <= ?1",
412 params![now_epoch],
413 )?;
414 crate::memory::preference::compilation::enqueue_for_memory_ids(&tx, &expiring_ids)?;
415 tx.commit()?;
416 Ok(changed)
417}
418
419pub fn count_expired_active_memories(conn: &Connection, now_epoch: i64) -> Result<usize> {
420 let count: i64 = conn.query_row(
421 "SELECT COUNT(*) FROM memories
422 WHERE status = 'active'
423 AND expires_at_epoch IS NOT NULL
424 AND expires_at_epoch <= ?1",
425 params![now_epoch],
426 |row| row.get(0),
427 )?;
428 Ok(count as usize)
429}
430
431pub fn soft_supersede(
432 conn: &Connection,
433 project: &str,
434 memory_ids: &[i64],
435 replacement_id: Option<i64>,
436) -> Result<usize> {
437 let mut seen = std::collections::HashSet::with_capacity(memory_ids.len());
438 let targets = memory_ids
439 .iter()
440 .copied()
441 .filter(|id| Some(*id) != replacement_id && seen.insert(*id))
442 .collect::<Vec<_>>();
443 for id in &targets {
444 let exists: bool = conn.query_row(
445 "SELECT EXISTS(SELECT 1 FROM memories WHERE id = ?1 AND project = ?2)",
446 params![id, project],
447 |row| row.get(0),
448 )?;
449 if !exists {
450 return Err(anyhow!(
451 "failed to mark superseded memory stale: id={} project={}",
452 id,
453 project
454 ));
455 }
456 }
457
458 crate::memory::preference::compilation::enqueue_for_memory_ids(conn, &targets)?;
459
460 let mut changed = 0usize;
461 let now = chrono::Utc::now().timestamp();
462 for id in targets {
463 let updated = conn.execute(
464 "UPDATE memories
465 SET status = 'stale',
466 valid_to_epoch = COALESCE(valid_to_epoch, ?3)
467 WHERE id = ?1 AND project = ?2",
468 params![id, project, now],
469 )?;
470 if updated != 1 {
471 return Err(anyhow!(
472 "failed to mark superseded memory stale: id={} project={}",
473 id,
474 project
475 ));
476 }
477 changed += updated;
478 }
479 Ok(changed)
480}
481
482fn has_any(haystack: &str, needles: &[&str]) -> bool {
483 needles.iter().any(|needle| haystack.contains(needle))
484}
485
486fn short_current_needles() -> &'static [&'static str] {
487 &[
488 "dev-server",
489 "dev server",
490 "localhost",
491 "127.0.0.1",
492 "port occupied",
493 "port is occupied",
494 "currently running",
495 "server running",
496 "local url",
497 "url healthy",
498 "healthy at",
499 "mergeability",
500 "mergeable",
501 "review status",
502 "review-status",
503 "ci state",
504 "ci status",
505 "ci-status",
506 "github actions",
507 "pull request",
508 "pull-request",
509 "pr #",
510 ]
511}
512
513fn branch_snapshot_needles() -> &'static [&'static str] {
514 &[
515 "git-divergence",
516 "branch-divergence",
517 "branch divergence",
518 "current branch",
519 "git status",
520 "ahead of",
521 "behind origin",
522 "diverged",
523 "dirty worktree",
524 ]
525}
526
527fn durable_type_has_no_content_ttl(memory_type: &str) -> bool {
528 matches!(
529 memory_type,
530 "architecture" | "bugfix" | "lesson" | "preference" | "procedure"
531 )
532}
533
534#[cfg(test)]
535mod ttl_tests;
536#[cfg(test)]
537mod vector_tests;
538
539#[cfg(test)]
540mod tests {
541 use rusqlite::Connection;
542
543 use super::*;
544 use crate::memory::insert_memory;
545 use crate::memory::tests_helper::setup_memory_schema;
546 use crate::retrieval::search::search_with_branch;
547
548 #[test]
549 fn update_preserves_superseded_memory_but_default_search_returns_current_fact() -> Result<()> {
550 let conn = Connection::open_in_memory()?;
551 setup_memory_schema(&conn);
552 let project = "test-lifecycle";
553 let old_id = insert_memory(
554 &conn,
555 Some("s1"),
556 project,
557 Some("deploy-target"),
558 "Deploy target",
559 "Deploy target is staging.",
560 "decision",
561 None,
562 )?;
563
564 let outcome = apply_update(
565 &conn,
566 Some("s2"),
567 project,
568 "deploy-target-current",
569 "Deploy target corrected",
570 "Deploy target is production.",
571 "decision",
572 None,
573 None,
574 "project",
575 &[old_id],
576 )?;
577
578 assert_eq!(outcome.op, MemoryLifecycleOp::Update);
579 assert_eq!(outcome.superseded, 1);
580 let old_status: String = conn.query_row(
581 "SELECT status FROM memories WHERE id = ?1",
582 [old_id],
583 |row| row.get(0),
584 )?;
585 assert_eq!(old_status, "stale");
586
587 let results = search_with_branch(
588 &conn,
589 Some("deploy target"),
590 Some(project),
591 None,
592 10,
593 0,
594 false,
595 None,
596 )?;
597 assert_eq!(results.len(), 1);
598 assert_eq!(results[0].text, "Deploy target is production.");
599 Ok(())
600 }
601
602 #[test]
603 fn update_records_supersedes_edge_in_same_transaction() -> Result<()> {
604 let conn = Connection::open_in_memory()?;
605 setup_memory_schema(&conn);
606 let project = "test-lifecycle-edge";
607 let old_id = insert_memory(
608 &conn,
609 Some("s1"),
610 project,
611 Some("deploy-target"),
612 "Deploy target",
613 "Deploy target is staging.",
614 "decision",
615 None,
616 )?;
617
618 let outcome = apply_update(
619 &conn,
620 Some("s2"),
621 project,
622 "deploy-target",
623 "Deploy target",
624 "Deploy target is production.",
625 "decision",
626 None,
627 None,
628 "project",
629 &[old_id],
630 )?;
631 let Some(new_id) = outcome.memory_id else {
632 anyhow::bail!("update should create replacement");
633 };
634
635 let edge: (String, i64, i64) = conn.query_row(
636 "SELECT edge_type, from_memory_id, to_memory_id
637 FROM memory_edges
638 WHERE from_memory_id = ?1 AND to_memory_id = ?2",
639 [old_id, new_id],
640 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
641 )?;
642 assert_eq!(edge, ("supersedes".to_string(), old_id, new_id));
643 Ok(())
644 }
645
646 #[test]
647 fn invalidate_marks_memory_stale_without_deleting_it() -> Result<()> {
648 let conn = Connection::open_in_memory()?;
649 setup_memory_schema(&conn);
650 let project = "test-lifecycle";
651 let id = insert_memory(
652 &conn,
653 Some("s1"),
654 project,
655 Some("old-fact"),
656 "Old fact",
657 "This fact is no longer valid.",
658 "discovery",
659 None,
660 )?;
661
662 let outcome = apply_invalidate(&conn, project, &[id], Some("contradicted"))?;
663 assert_eq!(outcome.op, MemoryLifecycleOp::Invalidate);
664 assert_eq!(outcome.superseded, 1);
665
666 let (status, content): (String, String) = conn.query_row(
667 "SELECT status, content FROM memories WHERE id = ?1",
668 [id],
669 |row| Ok((row.get(0)?, row.get(1)?)),
670 )?;
671 assert_eq!(status, "stale");
672 assert_eq!(content, "This fact is no longer valid.");
673 Ok(())
674 }
675
676 #[test]
677 fn update_rolls_back_insert_when_superseded_id_is_invalid() -> Result<()> {
678 let conn = Connection::open_in_memory()?;
679 setup_memory_schema(&conn);
680 let project = "test-lifecycle";
681 let old_id = insert_memory(
682 &conn,
683 Some("s1"),
684 project,
685 Some("old-fact"),
686 "Old fact",
687 "Old value.",
688 "decision",
689 None,
690 )?;
691
692 let err = apply_update(
693 &conn,
694 Some("s2"),
695 project,
696 "new-fact",
697 "New fact",
698 "New value.",
699 "decision",
700 None,
701 None,
702 "project",
703 &[old_id, 999_999],
704 )
705 .expect_err("invalid superseded id should fail");
706 assert!(err.to_string().contains("999999") || err.to_string().contains("999_999"));
707
708 let active_new_count: i64 = conn.query_row(
709 "SELECT COUNT(*) FROM memories WHERE project = ?1 AND topic_key = 'new-fact'",
710 [project],
711 |row| row.get(0),
712 )?;
713 let old_status: String = conn.query_row(
714 "SELECT status FROM memories WHERE id = ?1",
715 [old_id],
716 |row| row.get(0),
717 )?;
718 assert_eq!(active_new_count, 0);
719 assert_eq!(old_status, "active");
720 let edge_count: i64 =
721 conn.query_row("SELECT COUNT(*) FROM memory_edges", [], |row| row.get(0))?;
722 assert_eq!(edge_count, 0);
723 Ok(())
724 }
725
726 #[test]
727 fn invalidate_rolls_back_when_any_memory_id_is_invalid() -> Result<()> {
728 let conn = Connection::open_in_memory()?;
729 setup_memory_schema(&conn);
730 let project = "test-lifecycle";
731 let first_id = insert_memory(
732 &conn,
733 Some("s1"),
734 project,
735 Some("first"),
736 "First",
737 "First value.",
738 "discovery",
739 None,
740 )?;
741 let second_id = insert_memory(
742 &conn,
743 Some("s1"),
744 project,
745 Some("second"),
746 "Second",
747 "Second value.",
748 "discovery",
749 None,
750 )?;
751
752 apply_invalidate(
753 &conn,
754 project,
755 &[first_id, 999_999, second_id],
756 Some("bad id"),
757 )
758 .expect_err("mixed-validity invalidation should fail");
759
760 let statuses = conn
761 .prepare("SELECT status FROM memories WHERE id IN (?1, ?2) ORDER BY id ASC")?
762 .query_map([first_id, second_id], |row| row.get::<_, String>(0))?
763 .collect::<std::result::Result<Vec<_>, _>>()?;
764 assert_eq!(statuses, vec!["active".to_string(), "active".to_string()]);
765 Ok(())
766 }
767
768 #[test]
769 fn noop_and_defer_are_explicit_outcomes() {
770 assert!(noop("duplicate").noop);
771 assert!(defer("ambiguous conflict").deferred);
772 }
773}