Skip to main content

spool/
lifecycle_service.rs

1use crate::contradiction::ContradictionHit;
2use crate::domain::{MemoryLifecycleState, MemoryRecord};
3use crate::lifecycle_store::{
4    LedgerEntry, LifecycleStore, ProposeMemoryRequest, RecordMemoryRequest, TransitionMetadata,
5    accept_memory_with_metadata, archive_memory_with_metadata, latest_state_entries,
6    lifecycle_root_from_config, pending_review_entries, promote_memory_to_canonical_with_metadata,
7    propose_ai_memory, read_events_for_record, record_manual_memory, wakeup_ready_entries,
8};
9use serde::Serialize;
10use std::path::Path;
11use ts_rs::TS;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)]
14#[serde(rename_all = "snake_case")]
15#[ts(export, export_to = "../frontend/src/lib/types/generated/")]
16pub enum LifecycleAction {
17    Accept,
18    PromoteToCanonical,
19    Archive,
20}
21
22impl LifecycleAction {
23    pub fn label(self) -> &'static str {
24        match self {
25            Self::Accept => "accept",
26            Self::PromoteToCanonical => "promote",
27            Self::Archive => "archive",
28        }
29    }
30}
31
32#[derive(Debug, Clone, Serialize, PartialEq, Eq, TS)]
33#[ts(export, export_to = "../frontend/src/lib/types/generated/")]
34pub struct LifecycleWorkbenchSnapshot {
35    pub pending_review: Vec<LedgerEntry>,
36    pub wakeup_ready: Vec<LedgerEntry>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct LifecycleWriteResult {
41    pub entry: LedgerEntry,
42    pub snapshot: LifecycleWorkbenchSnapshot,
43    pub contradictions: Vec<ContradictionHit>,
44}
45
46#[derive(Debug, Default, Clone, Copy)]
47pub struct LifecycleService;
48
49impl LifecycleService {
50    pub fn new() -> Self {
51        Self
52    }
53
54    pub fn load_workbench(self, config_path: &Path) -> anyhow::Result<LifecycleWorkbenchSnapshot> {
55        let store = self.store_for_config(config_path);
56        Ok(LifecycleWorkbenchSnapshot {
57            pending_review: pending_review_entries(&store)?,
58            wakeup_ready: wakeup_ready_entries(&store)?,
59        })
60    }
61
62    pub fn apply_action(
63        self,
64        config_path: &Path,
65        record_id: &str,
66        action: LifecycleAction,
67    ) -> anyhow::Result<LifecycleWriteResult> {
68        self.apply_action_with_metadata(
69            config_path,
70            record_id,
71            action,
72            TransitionMetadata::default(),
73        )
74    }
75
76    pub fn apply_action_with_metadata(
77        self,
78        config_path: &Path,
79        record_id: &str,
80        action: LifecycleAction,
81        metadata: TransitionMetadata,
82    ) -> anyhow::Result<LifecycleWriteResult> {
83        let store = self.store_for_config(config_path);
84        let entry = match action {
85            LifecycleAction::Accept => accept_memory_with_metadata(&store, record_id, metadata)?,
86            LifecycleAction::PromoteToCanonical => {
87                promote_memory_to_canonical_with_metadata(&store, record_id, metadata)?
88            }
89            LifecycleAction::Archive => archive_memory_with_metadata(&store, record_id, metadata)?,
90        };
91        let snapshot = self.load_workbench(config_path)?;
92        Ok(LifecycleWriteResult {
93            entry,
94            snapshot,
95            contradictions: Vec::new(),
96        })
97    }
98
99    pub fn record_manual(
100        self,
101        config_path: &Path,
102        request: RecordMemoryRequest,
103    ) -> anyhow::Result<LifecycleWriteResult> {
104        let store = self.store_for_config(config_path);
105        let entry = record_manual_memory(&store, request)?;
106        let snapshot = self.load_workbench(config_path)?;
107        let existing: Vec<(String, MemoryRecord)> = wakeup_ready_entries(&store)
108            .unwrap_or_default()
109            .into_iter()
110            .map(|e| (e.record_id, e.record))
111            .collect();
112        let contradictions = crate::contradiction::detect(
113            &entry.record.summary,
114            &entry.record.memory_type,
115            &existing,
116        );
117        Ok(LifecycleWriteResult {
118            entry,
119            snapshot,
120            contradictions,
121        })
122    }
123
124    pub fn propose_ai(
125        self,
126        config_path: &Path,
127        request: ProposeMemoryRequest,
128    ) -> anyhow::Result<LifecycleWriteResult> {
129        let store = self.store_for_config(config_path);
130        let entry = propose_ai_memory(&store, request)?;
131        let snapshot = self.load_workbench(config_path)?;
132        let existing: Vec<(String, MemoryRecord)> = wakeup_ready_entries(&store)
133            .unwrap_or_default()
134            .into_iter()
135            .map(|e| (e.record_id, e.record))
136            .collect();
137        let contradictions = crate::contradiction::detect(
138            &entry.record.summary,
139            &entry.record.memory_type,
140            &existing,
141        );
142        Ok(LifecycleWriteResult {
143            entry,
144            snapshot,
145            contradictions,
146        })
147    }
148
149    pub fn get_record(
150        self,
151        config_path: &Path,
152        record_id: &str,
153    ) -> anyhow::Result<Option<LedgerEntry>> {
154        let store = self.store_for_config(config_path);
155        Ok(latest_state_entries(&store)?
156            .into_iter()
157            .find(|entry| entry.record_id == record_id))
158    }
159
160    pub fn get_history(
161        self,
162        config_path: &Path,
163        record_id: &str,
164    ) -> anyhow::Result<Vec<LedgerEntry>> {
165        let store = self.store_for_config(config_path);
166        read_events_for_record(&store, record_id)
167    }
168
169    fn store_for_config(self, config_path: &Path) -> LifecycleStore {
170        let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
171        let lifecycle_root = lifecycle_root_from_config(config_dir);
172        LifecycleStore::new(lifecycle_root.as_path())
173    }
174}
175
176pub fn available_actions(record: &MemoryRecord) -> &'static [LifecycleAction] {
177    match record.state {
178        MemoryLifecycleState::Candidate => &[LifecycleAction::Accept, LifecycleAction::Archive],
179        MemoryLifecycleState::Accepted => &[
180            LifecycleAction::PromoteToCanonical,
181            LifecycleAction::Archive,
182        ],
183        MemoryLifecycleState::Canonical => &[LifecycleAction::Archive],
184        MemoryLifecycleState::Draft | MemoryLifecycleState::Archived => &[],
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::{LifecycleAction, LifecycleService, available_actions};
191    use crate::domain::{MemoryLifecycleState, MemoryScope};
192    use crate::lifecycle_store::{
193        LifecycleStore, ProposeMemoryRequest, RecordMemoryRequest, TransitionMetadata,
194        lifecycle_root_from_config, propose_ai_memory, read_events_for_record,
195        record_manual_memory,
196    };
197    use std::fs;
198    use tempfile::tempdir;
199
200    fn setup_config_path() -> (tempfile::TempDir, std::path::PathBuf, LifecycleStore) {
201        let temp = tempdir().unwrap();
202        let config_path = temp.path().join("spool.toml");
203        fs::write(&config_path, "[vault]\nroot = \"/tmp\"\n").unwrap();
204        let store = LifecycleStore::new(lifecycle_root_from_config(temp.path()).as_path());
205        (temp, config_path, store)
206    }
207
208    #[test]
209    fn service_should_load_pending_review_and_wakeup_ready() {
210        let (_temp, config_path, store) = setup_config_path();
211        let _ = record_manual_memory(
212            &store,
213            RecordMemoryRequest {
214                title: "简洁输出".to_string(),
215                summary: "偏好简洁".to_string(),
216                memory_type: "preference".to_string(),
217                scope: MemoryScope::User,
218                source_ref: "manual:gui".to_string(),
219                project_id: None,
220                user_id: Some("long".to_string()),
221                sensitivity: None,
222                metadata: TransitionMetadata::default(),
223                entities: Vec::new(),
224                tags: Vec::new(),
225                triggers: Vec::new(),
226                related_files: Vec::new(),
227                related_records: Vec::new(),
228                supersedes: None,
229                applies_to: Vec::new(),
230                valid_until: None,
231            },
232        )
233        .unwrap();
234        let _ = propose_ai_memory(
235            &store,
236            ProposeMemoryRequest {
237                title: "测试偏好".to_string(),
238                summary: "先 smoke 再收口".to_string(),
239                memory_type: "workflow".to_string(),
240                scope: MemoryScope::User,
241                source_ref: "session:1".to_string(),
242                project_id: None,
243                user_id: Some("long".to_string()),
244                sensitivity: None,
245                metadata: TransitionMetadata::default(),
246                entities: Vec::new(),
247                tags: Vec::new(),
248                triggers: Vec::new(),
249                related_files: Vec::new(),
250                related_records: Vec::new(),
251                supersedes: None,
252                applies_to: Vec::new(),
253                valid_until: None,
254            },
255        )
256        .unwrap();
257
258        let snapshot = LifecycleService::new()
259            .load_workbench(config_path.as_path())
260            .unwrap();
261        assert_eq!(snapshot.pending_review.len(), 1);
262        assert_eq!(snapshot.wakeup_ready.len(), 1);
263    }
264
265    #[test]
266    fn service_should_apply_action_and_refresh_snapshot() {
267        let (_temp, config_path, store) = setup_config_path();
268        let proposal = propose_ai_memory(
269            &store,
270            ProposeMemoryRequest {
271                title: "测试偏好".to_string(),
272                summary: "先 smoke 再收口".to_string(),
273                memory_type: "workflow".to_string(),
274                scope: MemoryScope::User,
275                source_ref: "session:1".to_string(),
276                project_id: None,
277                user_id: Some("long".to_string()),
278                sensitivity: None,
279                metadata: TransitionMetadata::default(),
280                entities: Vec::new(),
281                tags: Vec::new(),
282                triggers: Vec::new(),
283                related_files: Vec::new(),
284                related_records: Vec::new(),
285                supersedes: None,
286                applies_to: Vec::new(),
287                valid_until: None,
288            },
289        )
290        .unwrap();
291
292        let result = LifecycleService::new()
293            .apply_action(
294                config_path.as_path(),
295                &proposal.record_id,
296                LifecycleAction::Accept,
297            )
298            .unwrap();
299        assert_eq!(result.entry.record.state, MemoryLifecycleState::Accepted);
300        assert!(result.snapshot.pending_review.is_empty());
301        assert_eq!(result.snapshot.wakeup_ready.len(), 1);
302    }
303
304    #[test]
305    fn service_should_record_manual_memory_and_refresh_snapshot() {
306        let (_temp, config_path, _store) = setup_config_path();
307        let result = LifecycleService::new()
308            .record_manual(
309                config_path.as_path(),
310                RecordMemoryRequest {
311                    title: "简洁输出".to_string(),
312                    summary: "偏好简洁".to_string(),
313                    memory_type: "preference".to_string(),
314                    scope: MemoryScope::User,
315                    source_ref: "manual:cli".to_string(),
316                    project_id: None,
317                    user_id: Some("long".to_string()),
318                    sensitivity: Some("internal".to_string()),
319                    metadata: TransitionMetadata {
320                        actor: Some("codex".to_string()),
321                        reason: Some("capture stable preference".to_string()),
322                        evidence_refs: vec!["obsidian://note".to_string()],
323                    },
324                    entities: Vec::new(),
325                    tags: Vec::new(),
326                    triggers: Vec::new(),
327                    related_files: Vec::new(),
328                    related_records: Vec::new(),
329                    supersedes: None,
330                    applies_to: Vec::new(),
331                    valid_until: None,
332                },
333            )
334            .unwrap();
335
336        assert_eq!(result.entry.record.state, MemoryLifecycleState::Accepted);
337        assert_eq!(result.entry.metadata.actor.as_deref(), Some("codex"));
338        assert!(result.snapshot.pending_review.is_empty());
339        assert_eq!(result.snapshot.wakeup_ready.len(), 1);
340    }
341
342    #[test]
343    fn service_should_propose_ai_memory_and_refresh_snapshot() {
344        let (_temp, config_path, _store) = setup_config_path();
345        let result = LifecycleService::new()
346            .propose_ai(
347                config_path.as_path(),
348                ProposeMemoryRequest {
349                    title: "测试偏好".to_string(),
350                    summary: "先 smoke 再收口".to_string(),
351                    memory_type: "workflow".to_string(),
352                    scope: MemoryScope::User,
353                    source_ref: "session:1".to_string(),
354                    project_id: Some("spool".to_string()),
355                    user_id: Some("long".to_string()),
356                    sensitivity: None,
357                    metadata: TransitionMetadata::default(),
358                    entities: Vec::new(),
359                    tags: Vec::new(),
360                    triggers: Vec::new(),
361                    related_files: Vec::new(),
362                    related_records: Vec::new(),
363                    supersedes: None,
364                    applies_to: Vec::new(),
365                    valid_until: None,
366                },
367            )
368            .unwrap();
369
370        assert_eq!(result.entry.record.state, MemoryLifecycleState::Candidate);
371        assert_eq!(result.snapshot.pending_review.len(), 1);
372        assert!(result.snapshot.wakeup_ready.is_empty());
373    }
374
375    #[test]
376    fn service_should_return_record_history_in_event_order() {
377        let (_temp, config_path, _store) = setup_config_path();
378        let proposal = LifecycleService::new()
379            .propose_ai(
380                config_path.as_path(),
381                ProposeMemoryRequest {
382                    title: "测试偏好".to_string(),
383                    summary: "先 smoke 再收口".to_string(),
384                    memory_type: "workflow".to_string(),
385                    scope: MemoryScope::User,
386                    source_ref: "session:1".to_string(),
387                    project_id: None,
388                    user_id: Some("long".to_string()),
389                    sensitivity: None,
390                    metadata: TransitionMetadata::default(),
391                    entities: Vec::new(),
392                    tags: Vec::new(),
393                    triggers: Vec::new(),
394                    related_files: Vec::new(),
395                    related_records: Vec::new(),
396                    supersedes: None,
397                    applies_to: Vec::new(),
398                    valid_until: None,
399                },
400            )
401            .unwrap();
402        let _ = LifecycleService::new()
403            .apply_action(
404                config_path.as_path(),
405                &proposal.entry.record_id,
406                LifecycleAction::Accept,
407            )
408            .unwrap();
409
410        let history = LifecycleService::new()
411            .get_history(config_path.as_path(), &proposal.entry.record_id)
412            .unwrap();
413        assert_eq!(history.len(), 2);
414        assert_eq!(
415            history[0].action,
416            crate::domain::MemoryLedgerAction::ProposeAi
417        );
418        assert_eq!(history[1].action, crate::domain::MemoryLedgerAction::Accept);
419    }
420
421    #[test]
422    fn service_should_reject_invalid_transition_without_append() {
423        let (_temp, config_path, store) = setup_config_path();
424        let manual = record_manual_memory(
425            &store,
426            RecordMemoryRequest {
427                title: "简洁输出".to_string(),
428                summary: "偏好简洁".to_string(),
429                memory_type: "preference".to_string(),
430                scope: MemoryScope::User,
431                source_ref: "manual:gui".to_string(),
432                project_id: None,
433                user_id: Some("long".to_string()),
434                sensitivity: None,
435                metadata: TransitionMetadata::default(),
436                entities: Vec::new(),
437                tags: Vec::new(),
438                triggers: Vec::new(),
439                related_files: Vec::new(),
440                related_records: Vec::new(),
441                supersedes: None,
442                applies_to: Vec::new(),
443                valid_until: None,
444            },
445        )
446        .unwrap();
447
448        let error = LifecycleService::new()
449            .apply_action(
450                config_path.as_path(),
451                &manual.record_id,
452                LifecycleAction::Accept,
453            )
454            .unwrap_err();
455        assert!(error.to_string().contains("invalid lifecycle transition"));
456        assert_eq!(
457            read_events_for_record(&store, &manual.record_id)
458                .unwrap()
459                .len(),
460            1
461        );
462    }
463
464    #[test]
465    fn available_actions_should_follow_lifecycle_state() {
466        let candidate = crate::domain::MemoryRecord::new_ai_proposal(
467            "候选",
468            "摘要",
469            "workflow",
470            MemoryScope::User,
471            "session:1",
472        );
473        let accepted = candidate
474            .clone()
475            .apply(crate::domain::MemoryPromotionAction::Accept);
476        let canonical = accepted
477            .clone()
478            .apply(crate::domain::MemoryPromotionAction::PromoteToCanonical);
479
480        assert_eq!(
481            available_actions(&candidate),
482            &[LifecycleAction::Accept, LifecycleAction::Archive]
483        );
484        assert_eq!(
485            available_actions(&accepted),
486            &[
487                LifecycleAction::PromoteToCanonical,
488                LifecycleAction::Archive
489            ]
490        );
491        assert_eq!(available_actions(&canonical), &[LifecycleAction::Archive]);
492    }
493
494    #[test]
495    fn service_should_apply_action_with_metadata() {
496        let (_temp, config_path, _store) = setup_config_path();
497        let proposal = LifecycleService::new()
498            .propose_ai(
499                config_path.as_path(),
500                ProposeMemoryRequest {
501                    title: "测试偏好".to_string(),
502                    summary: "先 smoke 再收口".to_string(),
503                    memory_type: "workflow".to_string(),
504                    scope: MemoryScope::User,
505                    source_ref: "session:1".to_string(),
506                    project_id: None,
507                    user_id: Some("long".to_string()),
508                    sensitivity: None,
509                    metadata: TransitionMetadata::default(),
510                    entities: Vec::new(),
511                    tags: Vec::new(),
512                    triggers: Vec::new(),
513                    related_files: Vec::new(),
514                    related_records: Vec::new(),
515                    supersedes: None,
516                    applies_to: Vec::new(),
517                    valid_until: None,
518                },
519            )
520            .unwrap();
521
522        let result = LifecycleService::new()
523            .apply_action_with_metadata(
524                config_path.as_path(),
525                &proposal.entry.record_id,
526                LifecycleAction::Accept,
527                TransitionMetadata {
528                    actor: Some("long".to_string()),
529                    reason: Some("confirmed from repeated sessions".to_string()),
530                    evidence_refs: vec!["session:1".to_string(), "session:2".to_string()],
531                },
532            )
533            .unwrap();
534
535        assert_eq!(result.entry.metadata.actor.as_deref(), Some("long"));
536        assert_eq!(
537            result.entry.metadata.reason.as_deref(),
538            Some("confirmed from repeated sessions")
539        );
540        assert_eq!(result.entry.metadata.evidence_refs.len(), 2);
541    }
542}