Skip to main content

remem/memory/
lifecycle.rs

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}