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}