use crate::contradiction::ContradictionHit;
use crate::domain::{MemoryLifecycleState, MemoryRecord};
use crate::lifecycle_store::{
LedgerEntry, LifecycleStore, ProposeMemoryRequest, RecordMemoryRequest, TransitionMetadata,
accept_memory_with_metadata, archive_memory_with_metadata, latest_state_entries,
lifecycle_root_from_config, pending_review_entries, promote_memory_to_canonical_with_metadata,
propose_ai_memory, read_events_for_record, record_manual_memory, wakeup_ready_entries,
};
use serde::Serialize;
use std::path::Path;
use ts_rs::TS;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "../frontend/src/lib/types/generated/")]
pub enum LifecycleAction {
Accept,
PromoteToCanonical,
Archive,
}
impl LifecycleAction {
pub fn label(self) -> &'static str {
match self {
Self::Accept => "accept",
Self::PromoteToCanonical => "promote",
Self::Archive => "archive",
}
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq, TS)]
#[ts(export, export_to = "../frontend/src/lib/types/generated/")]
pub struct LifecycleWorkbenchSnapshot {
pub pending_review: Vec<LedgerEntry>,
pub wakeup_ready: Vec<LedgerEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LifecycleWriteResult {
pub entry: LedgerEntry,
pub snapshot: LifecycleWorkbenchSnapshot,
pub contradictions: Vec<ContradictionHit>,
}
#[derive(Debug, Default, Clone, Copy)]
pub struct LifecycleService;
impl LifecycleService {
pub fn new() -> Self {
Self
}
pub fn load_workbench(self, config_path: &Path) -> anyhow::Result<LifecycleWorkbenchSnapshot> {
let store = self.store_for_config(config_path);
Ok(LifecycleWorkbenchSnapshot {
pending_review: pending_review_entries(&store)?,
wakeup_ready: wakeup_ready_entries(&store)?,
})
}
pub fn apply_action(
self,
config_path: &Path,
record_id: &str,
action: LifecycleAction,
) -> anyhow::Result<LifecycleWriteResult> {
self.apply_action_with_metadata(
config_path,
record_id,
action,
TransitionMetadata::default(),
)
}
pub fn apply_action_with_metadata(
self,
config_path: &Path,
record_id: &str,
action: LifecycleAction,
metadata: TransitionMetadata,
) -> anyhow::Result<LifecycleWriteResult> {
let store = self.store_for_config(config_path);
let entry = match action {
LifecycleAction::Accept => accept_memory_with_metadata(&store, record_id, metadata)?,
LifecycleAction::PromoteToCanonical => {
promote_memory_to_canonical_with_metadata(&store, record_id, metadata)?
}
LifecycleAction::Archive => archive_memory_with_metadata(&store, record_id, metadata)?,
};
let snapshot = self.load_workbench(config_path)?;
Ok(LifecycleWriteResult {
entry,
snapshot,
contradictions: Vec::new(),
})
}
pub fn record_manual(
self,
config_path: &Path,
request: RecordMemoryRequest,
) -> anyhow::Result<LifecycleWriteResult> {
let store = self.store_for_config(config_path);
let entry = record_manual_memory(&store, request)?;
let snapshot = self.load_workbench(config_path)?;
let existing: Vec<(String, MemoryRecord)> = wakeup_ready_entries(&store)
.unwrap_or_default()
.into_iter()
.map(|e| (e.record_id, e.record))
.collect();
let contradictions = crate::contradiction::detect(
&entry.record.summary,
&entry.record.memory_type,
&existing,
);
Ok(LifecycleWriteResult {
entry,
snapshot,
contradictions,
})
}
pub fn propose_ai(
self,
config_path: &Path,
request: ProposeMemoryRequest,
) -> anyhow::Result<LifecycleWriteResult> {
let store = self.store_for_config(config_path);
let entry = propose_ai_memory(&store, request)?;
let snapshot = self.load_workbench(config_path)?;
let existing: Vec<(String, MemoryRecord)> = wakeup_ready_entries(&store)
.unwrap_or_default()
.into_iter()
.map(|e| (e.record_id, e.record))
.collect();
let contradictions = crate::contradiction::detect(
&entry.record.summary,
&entry.record.memory_type,
&existing,
);
Ok(LifecycleWriteResult {
entry,
snapshot,
contradictions,
})
}
pub fn get_record(
self,
config_path: &Path,
record_id: &str,
) -> anyhow::Result<Option<LedgerEntry>> {
let store = self.store_for_config(config_path);
Ok(latest_state_entries(&store)?
.into_iter()
.find(|entry| entry.record_id == record_id))
}
pub fn get_history(
self,
config_path: &Path,
record_id: &str,
) -> anyhow::Result<Vec<LedgerEntry>> {
let store = self.store_for_config(config_path);
read_events_for_record(&store, record_id)
}
fn store_for_config(self, config_path: &Path) -> LifecycleStore {
let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
let lifecycle_root = lifecycle_root_from_config(config_dir);
LifecycleStore::new(lifecycle_root.as_path())
}
}
pub fn available_actions(record: &MemoryRecord) -> &'static [LifecycleAction] {
match record.state {
MemoryLifecycleState::Candidate => &[LifecycleAction::Accept, LifecycleAction::Archive],
MemoryLifecycleState::Accepted => &[
LifecycleAction::PromoteToCanonical,
LifecycleAction::Archive,
],
MemoryLifecycleState::Canonical => &[LifecycleAction::Archive],
MemoryLifecycleState::Draft | MemoryLifecycleState::Archived => &[],
}
}
#[cfg(test)]
mod tests {
use super::{LifecycleAction, LifecycleService, available_actions};
use crate::domain::{MemoryLifecycleState, MemoryScope};
use crate::lifecycle_store::{
LifecycleStore, ProposeMemoryRequest, RecordMemoryRequest, TransitionMetadata,
lifecycle_root_from_config, propose_ai_memory, read_events_for_record,
record_manual_memory,
};
use std::fs;
use tempfile::tempdir;
fn setup_config_path() -> (tempfile::TempDir, std::path::PathBuf, LifecycleStore) {
let temp = tempdir().unwrap();
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, "[vault]\nroot = \"/tmp\"\n").unwrap();
let store = LifecycleStore::new(lifecycle_root_from_config(temp.path()).as_path());
(temp, config_path, store)
}
#[test]
fn service_should_load_pending_review_and_wakeup_ready() {
let (_temp, config_path, store) = setup_config_path();
let _ = record_manual_memory(
&store,
RecordMemoryRequest {
title: "简洁输出".to_string(),
summary: "偏好简洁".to_string(),
memory_type: "preference".to_string(),
scope: MemoryScope::User,
source_ref: "manual:gui".to_string(),
project_id: None,
user_id: Some("long".to_string()),
sensitivity: None,
metadata: TransitionMetadata::default(),
entities: Vec::new(),
tags: Vec::new(),
triggers: Vec::new(),
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
},
)
.unwrap();
let _ = propose_ai_memory(
&store,
ProposeMemoryRequest {
title: "测试偏好".to_string(),
summary: "先 smoke 再收口".to_string(),
memory_type: "workflow".to_string(),
scope: MemoryScope::User,
source_ref: "session:1".to_string(),
project_id: None,
user_id: Some("long".to_string()),
sensitivity: None,
metadata: TransitionMetadata::default(),
entities: Vec::new(),
tags: Vec::new(),
triggers: Vec::new(),
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
},
)
.unwrap();
let snapshot = LifecycleService::new()
.load_workbench(config_path.as_path())
.unwrap();
assert_eq!(snapshot.pending_review.len(), 1);
assert_eq!(snapshot.wakeup_ready.len(), 1);
}
#[test]
fn service_should_apply_action_and_refresh_snapshot() {
let (_temp, config_path, store) = setup_config_path();
let proposal = propose_ai_memory(
&store,
ProposeMemoryRequest {
title: "测试偏好".to_string(),
summary: "先 smoke 再收口".to_string(),
memory_type: "workflow".to_string(),
scope: MemoryScope::User,
source_ref: "session:1".to_string(),
project_id: None,
user_id: Some("long".to_string()),
sensitivity: None,
metadata: TransitionMetadata::default(),
entities: Vec::new(),
tags: Vec::new(),
triggers: Vec::new(),
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
},
)
.unwrap();
let result = LifecycleService::new()
.apply_action(
config_path.as_path(),
&proposal.record_id,
LifecycleAction::Accept,
)
.unwrap();
assert_eq!(result.entry.record.state, MemoryLifecycleState::Accepted);
assert!(result.snapshot.pending_review.is_empty());
assert_eq!(result.snapshot.wakeup_ready.len(), 1);
}
#[test]
fn service_should_record_manual_memory_and_refresh_snapshot() {
let (_temp, config_path, _store) = setup_config_path();
let result = LifecycleService::new()
.record_manual(
config_path.as_path(),
RecordMemoryRequest {
title: "简洁输出".to_string(),
summary: "偏好简洁".to_string(),
memory_type: "preference".to_string(),
scope: MemoryScope::User,
source_ref: "manual:cli".to_string(),
project_id: None,
user_id: Some("long".to_string()),
sensitivity: Some("internal".to_string()),
metadata: TransitionMetadata {
actor: Some("codex".to_string()),
reason: Some("capture stable preference".to_string()),
evidence_refs: vec!["obsidian://note".to_string()],
},
entities: Vec::new(),
tags: Vec::new(),
triggers: Vec::new(),
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
},
)
.unwrap();
assert_eq!(result.entry.record.state, MemoryLifecycleState::Accepted);
assert_eq!(result.entry.metadata.actor.as_deref(), Some("codex"));
assert!(result.snapshot.pending_review.is_empty());
assert_eq!(result.snapshot.wakeup_ready.len(), 1);
}
#[test]
fn service_should_propose_ai_memory_and_refresh_snapshot() {
let (_temp, config_path, _store) = setup_config_path();
let result = LifecycleService::new()
.propose_ai(
config_path.as_path(),
ProposeMemoryRequest {
title: "测试偏好".to_string(),
summary: "先 smoke 再收口".to_string(),
memory_type: "workflow".to_string(),
scope: MemoryScope::User,
source_ref: "session:1".to_string(),
project_id: Some("spool".to_string()),
user_id: Some("long".to_string()),
sensitivity: None,
metadata: TransitionMetadata::default(),
entities: Vec::new(),
tags: Vec::new(),
triggers: Vec::new(),
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
},
)
.unwrap();
assert_eq!(result.entry.record.state, MemoryLifecycleState::Candidate);
assert_eq!(result.snapshot.pending_review.len(), 1);
assert!(result.snapshot.wakeup_ready.is_empty());
}
#[test]
fn service_should_return_record_history_in_event_order() {
let (_temp, config_path, _store) = setup_config_path();
let proposal = LifecycleService::new()
.propose_ai(
config_path.as_path(),
ProposeMemoryRequest {
title: "测试偏好".to_string(),
summary: "先 smoke 再收口".to_string(),
memory_type: "workflow".to_string(),
scope: MemoryScope::User,
source_ref: "session:1".to_string(),
project_id: None,
user_id: Some("long".to_string()),
sensitivity: None,
metadata: TransitionMetadata::default(),
entities: Vec::new(),
tags: Vec::new(),
triggers: Vec::new(),
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
},
)
.unwrap();
let _ = LifecycleService::new()
.apply_action(
config_path.as_path(),
&proposal.entry.record_id,
LifecycleAction::Accept,
)
.unwrap();
let history = LifecycleService::new()
.get_history(config_path.as_path(), &proposal.entry.record_id)
.unwrap();
assert_eq!(history.len(), 2);
assert_eq!(
history[0].action,
crate::domain::MemoryLedgerAction::ProposeAi
);
assert_eq!(history[1].action, crate::domain::MemoryLedgerAction::Accept);
}
#[test]
fn service_should_reject_invalid_transition_without_append() {
let (_temp, config_path, store) = setup_config_path();
let manual = record_manual_memory(
&store,
RecordMemoryRequest {
title: "简洁输出".to_string(),
summary: "偏好简洁".to_string(),
memory_type: "preference".to_string(),
scope: MemoryScope::User,
source_ref: "manual:gui".to_string(),
project_id: None,
user_id: Some("long".to_string()),
sensitivity: None,
metadata: TransitionMetadata::default(),
entities: Vec::new(),
tags: Vec::new(),
triggers: Vec::new(),
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
},
)
.unwrap();
let error = LifecycleService::new()
.apply_action(
config_path.as_path(),
&manual.record_id,
LifecycleAction::Accept,
)
.unwrap_err();
assert!(error.to_string().contains("invalid lifecycle transition"));
assert_eq!(
read_events_for_record(&store, &manual.record_id)
.unwrap()
.len(),
1
);
}
#[test]
fn available_actions_should_follow_lifecycle_state() {
let candidate = crate::domain::MemoryRecord::new_ai_proposal(
"候选",
"摘要",
"workflow",
MemoryScope::User,
"session:1",
);
let accepted = candidate
.clone()
.apply(crate::domain::MemoryPromotionAction::Accept);
let canonical = accepted
.clone()
.apply(crate::domain::MemoryPromotionAction::PromoteToCanonical);
assert_eq!(
available_actions(&candidate),
&[LifecycleAction::Accept, LifecycleAction::Archive]
);
assert_eq!(
available_actions(&accepted),
&[
LifecycleAction::PromoteToCanonical,
LifecycleAction::Archive
]
);
assert_eq!(available_actions(&canonical), &[LifecycleAction::Archive]);
}
#[test]
fn service_should_apply_action_with_metadata() {
let (_temp, config_path, _store) = setup_config_path();
let proposal = LifecycleService::new()
.propose_ai(
config_path.as_path(),
ProposeMemoryRequest {
title: "测试偏好".to_string(),
summary: "先 smoke 再收口".to_string(),
memory_type: "workflow".to_string(),
scope: MemoryScope::User,
source_ref: "session:1".to_string(),
project_id: None,
user_id: Some("long".to_string()),
sensitivity: None,
metadata: TransitionMetadata::default(),
entities: Vec::new(),
tags: Vec::new(),
triggers: Vec::new(),
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
},
)
.unwrap();
let result = LifecycleService::new()
.apply_action_with_metadata(
config_path.as_path(),
&proposal.entry.record_id,
LifecycleAction::Accept,
TransitionMetadata {
actor: Some("long".to_string()),
reason: Some("confirmed from repeated sessions".to_string()),
evidence_refs: vec!["session:1".to_string(), "session:2".to_string()],
},
)
.unwrap();
assert_eq!(result.entry.metadata.actor.as_deref(), Some("long"));
assert_eq!(
result.entry.metadata.reason.as_deref(),
Some("confirmed from repeated sessions")
);
assert_eq!(result.entry.metadata.evidence_refs.len(), 2);
}
}