Skip to main content

spool/
lifecycle_summary.rs

1use crate::lifecycle_format;
2use crate::lifecycle_service::{LifecycleAction, LifecycleWorkbenchSnapshot};
3use crate::lifecycle_store::LedgerEntry;
4use serde::Serialize;
5use serde_json::{Value, json};
6
7#[derive(Debug, Clone, Serialize)]
8pub struct LifecycleQueuePayload {
9    pub entries: Vec<LedgerEntry>,
10    pub summaries: Vec<LifecycleSummary>,
11}
12
13#[derive(Debug, Clone, Serialize)]
14pub struct LifecycleRecordPayload {
15    pub record: LedgerEntry,
16    pub summary: LifecycleSummary,
17}
18
19#[derive(Debug, Clone, Serialize)]
20pub struct LifecycleHistoryPayload {
21    pub record_id: String,
22    pub history: Vec<LedgerEntry>,
23    pub summaries: Vec<LifecycleSummary>,
24}
25
26#[derive(Debug, Clone, Serialize)]
27pub struct LifecycleSummary {
28    pub record_id: String,
29    pub state: &'static str,
30    pub title: String,
31    pub pending_review: bool,
32    pub wakeup_ready: bool,
33    pub actor: Option<String>,
34    pub reason: Option<String>,
35    pub evidence_refs: Vec<String>,
36}
37
38#[derive(Debug, Clone, Serialize)]
39pub struct LifecycleCreateSummary {
40    pub kind: String,
41    #[serde(flatten)]
42    pub summary: LifecycleSummary,
43}
44
45#[derive(Debug, Clone, Serialize)]
46pub struct LifecycleActionSummary {
47    pub action: String,
48    #[serde(flatten)]
49    pub summary: LifecycleSummary,
50}
51
52impl LifecycleSummary {
53    pub fn from_entry(entry: &LedgerEntry) -> Self {
54        Self {
55            record_id: entry.record_id.clone(),
56            state: lifecycle_format::state_label(entry),
57            title: entry.record.title.clone(),
58            pending_review: entry.record.requires_review(),
59            wakeup_ready: entry.record.can_be_returned_in_wakeup(),
60            actor: entry.metadata.actor.clone(),
61            reason: entry.metadata.reason.clone(),
62            evidence_refs: entry.metadata.evidence_refs.clone(),
63        }
64    }
65
66    pub fn to_json(&self) -> Value {
67        serde_json::to_value(self).expect("lifecycle summary should serialize")
68    }
69
70    pub fn metadata_lines(&self) -> String {
71        let mut lines = String::new();
72        if let Some(actor) = self.actor.as_deref() {
73            lines.push_str(&format!("- actor: {}\n", actor));
74        }
75        if let Some(reason) = self.reason.as_deref() {
76            lines.push_str(&format!("- reason: {}\n", reason));
77        }
78        if !self.evidence_refs.is_empty() {
79            lines.push_str(&format!(
80                "- evidence_refs: {}\n",
81                self.evidence_refs.join(", ")
82            ));
83        }
84        lines
85    }
86}
87
88impl LifecycleCreateSummary {
89    pub fn new(kind: &str, entry: &LedgerEntry) -> Self {
90        Self {
91            kind: kind.to_string(),
92            summary: LifecycleSummary::from_entry(entry),
93        }
94    }
95
96    pub fn to_json(&self) -> Value {
97        serde_json::to_value(self).expect("lifecycle create summary should serialize")
98    }
99
100    pub fn render_markdown(&self) -> String {
101        format!(
102            "# Lifecycle create\n\n- kind: {}\n- record_id: `{}`\n- state: {}\n- title: {}\n- pending_review: {}\n- wakeup_ready: {}\n{}",
103            self.kind,
104            self.summary.record_id,
105            self.summary.state,
106            self.summary.title,
107            self.summary.pending_review,
108            self.summary.wakeup_ready,
109            self.summary.metadata_lines()
110        )
111    }
112}
113
114impl LifecycleActionSummary {
115    pub fn new(action: LifecycleAction, entry: &LedgerEntry) -> Self {
116        Self {
117            action: action.label().to_string(),
118            summary: LifecycleSummary::from_entry(entry),
119        }
120    }
121
122    pub fn to_json(&self) -> Value {
123        serde_json::to_value(self).expect("lifecycle action summary should serialize")
124    }
125
126    pub fn render_markdown(&self) -> String {
127        format!(
128            "# Lifecycle action\n\n- action: {}\n- record_id: `{}`\n- state: {}\n- title: {}\n- pending_review: {}\n- wakeup_ready: {}\n{}",
129            self.action,
130            self.summary.record_id,
131            self.summary.state,
132            self.summary.title,
133            self.summary.pending_review,
134            self.summary.wakeup_ready,
135            self.summary.metadata_lines()
136        )
137    }
138}
139
140impl LifecycleQueuePayload {
141    pub fn new(entries: &[LedgerEntry]) -> Self {
142        Self {
143            entries: entries.to_vec(),
144            summaries: entries.iter().map(LifecycleSummary::from_entry).collect(),
145        }
146    }
147
148    pub fn to_json_with_field_name(&self, field_name: &str) -> Value {
149        json!({
150            field_name: self.entries,
151            "summaries": self.summaries,
152        })
153    }
154}
155
156impl LifecycleRecordPayload {
157    pub fn new(entry: &LedgerEntry) -> Self {
158        Self {
159            record: entry.clone(),
160            summary: LifecycleSummary::from_entry(entry),
161        }
162    }
163
164    pub fn to_json(&self) -> Value {
165        serde_json::to_value(self).expect("lifecycle record payload should serialize")
166    }
167
168    pub fn render_markdown(&self, quote_record_id: bool, include_summary_section: bool) -> String {
169        lifecycle_format::render_detail(&self.record, quote_record_id, include_summary_section)
170    }
171}
172
173impl LifecycleHistoryPayload {
174    pub fn new(record_id: &str, history: &[LedgerEntry]) -> Self {
175        Self {
176            record_id: record_id.to_string(),
177            history: history.to_vec(),
178            summaries: history.iter().map(LifecycleSummary::from_entry).collect(),
179        }
180    }
181
182    pub fn to_json(&self) -> Value {
183        serde_json::to_value(self).expect("lifecycle history payload should serialize")
184    }
185
186    pub fn render_markdown(&self, quote_record_id: bool) -> String {
187        lifecycle_format::render_history(&self.record_id, &self.history, quote_record_id)
188    }
189}
190
191pub fn create_payload(
192    kind: &str,
193    entry: &LedgerEntry,
194    snapshot: &LifecycleWorkbenchSnapshot,
195) -> Value {
196    json!({
197        "entry": entry,
198        "summary": LifecycleCreateSummary::new(kind, entry).to_json(),
199        "snapshot": {
200            "pending_review": snapshot.pending_review,
201            "wakeup_ready": snapshot.wakeup_ready
202        }
203    })
204}
205
206pub fn action_payload(
207    entry: &LedgerEntry,
208    snapshot: &LifecycleWorkbenchSnapshot,
209    action: LifecycleAction,
210) -> Value {
211    json!({
212        "action": action.label(),
213        "entry": entry,
214        "summary": LifecycleActionSummary::new(action, entry).to_json(),
215        "snapshot": {
216            "pending_review": snapshot.pending_review,
217            "wakeup_ready": snapshot.wakeup_ready
218        }
219    })
220}
221
222pub fn queue_payload(entries: &[LedgerEntry], field_name: &str) -> Value {
223    LifecycleQueuePayload::new(entries).to_json_with_field_name(field_name)
224}
225
226pub fn record_payload(entry: &LedgerEntry) -> Value {
227    LifecycleRecordPayload::new(entry).to_json()
228}
229
230pub fn history_payload(record_id: &str, history: &[LedgerEntry]) -> Value {
231    LifecycleHistoryPayload::new(record_id, history).to_json()
232}
233
234pub fn render_record_text(
235    entry: &LedgerEntry,
236    quote_record_id: bool,
237    include_summary_section: bool,
238) -> String {
239    LifecycleRecordPayload::new(entry).render_markdown(quote_record_id, include_summary_section)
240}
241
242pub fn render_history_text(
243    record_id: &str,
244    history: &[LedgerEntry],
245    quote_record_id: bool,
246) -> String {
247    LifecycleHistoryPayload::new(record_id, history).render_markdown(quote_record_id)
248}
249
250pub fn render_queue_text(
251    title: &str,
252    entries: &[LedgerEntry],
253    include_record_id: bool,
254    markdown_heading: bool,
255) -> String {
256    lifecycle_format::render_list(title, entries, include_record_id, markdown_heading)
257}
258
259pub fn not_found_payload(record_id: &str) -> Value {
260    json!({
261        "record_id": record_id,
262        "summary": Value::Null,
263    })
264}
265
266pub fn render_create_text(kind: &str, entry: &LedgerEntry) -> String {
267    LifecycleCreateSummary::new(kind, entry).render_markdown()
268}
269
270pub fn render_action_text(action: LifecycleAction, entry: &LedgerEntry) -> String {
271    LifecycleActionSummary::new(action, entry).render_markdown()
272}
273
274#[cfg(test)]
275mod tests {
276    use super::{
277        LifecycleActionSummary, LifecycleCreateSummary, LifecycleHistoryPayload,
278        LifecycleQueuePayload, LifecycleRecordPayload, LifecycleSummary, render_action_text,
279        render_create_text, render_history_text, render_queue_text, render_record_text,
280    };
281    use crate::domain::{MemoryLedgerAction, MemoryRecord, MemoryScope, MemorySourceKind};
282    use crate::lifecycle_service::LifecycleAction;
283    use crate::lifecycle_store::{LedgerEntry, TransitionMetadata};
284
285    fn sample_entry() -> LedgerEntry {
286        LedgerEntry {
287            schema_version: "memory-ledger.v1".to_string(),
288            record_id: "record-1".to_string(),
289            action: MemoryLedgerAction::Accept,
290            recorded_at: "2026-04-13T00:00:00Z".to_string(),
291            source_kind: MemorySourceKind::AiProposal,
292            scope_key: "user:long".to_string(),
293            metadata: TransitionMetadata {
294                actor: Some("long".to_string()),
295                reason: Some("approved after review".to_string()),
296                evidence_refs: vec!["session:1".to_string()],
297            },
298            record: MemoryRecord::new_ai_proposal(
299                "测试偏好",
300                "先 smoke 再收口",
301                "workflow",
302                MemoryScope::User,
303                "session:1",
304            )
305            .apply(crate::domain::MemoryPromotionAction::Accept)
306            .with_user_id("long"),
307        }
308    }
309
310    #[test]
311    fn summary_should_capture_cli_and_mcp_shared_fields() {
312        let summary = LifecycleSummary::from_entry(&sample_entry());
313        assert_eq!(summary.record_id, "record-1");
314        assert_eq!(summary.state, "accepted");
315        assert_eq!(summary.title, "测试偏好");
316        assert!(!summary.pending_review);
317        assert!(summary.wakeup_ready);
318        assert_eq!(summary.actor.as_deref(), Some("long"));
319        assert_eq!(summary.reason.as_deref(), Some("approved after review"));
320        assert_eq!(summary.evidence_refs, vec!["session:1"]);
321    }
322
323    #[test]
324    fn structured_summaries_should_include_create_and_action_specific_fields() {
325        let entry = sample_entry();
326        let create = LifecycleCreateSummary::new("propose", &entry).to_json();
327        let action = LifecycleActionSummary::new(LifecycleAction::Accept, &entry).to_json();
328        assert_eq!(create["kind"], "propose");
329        assert_eq!(create["record_id"], "record-1");
330        assert_eq!(action["action"], "accept");
331        assert_eq!(action["reason"], "approved after review");
332    }
333
334    #[test]
335    fn text_renderers_should_use_summary_fields() {
336        let entry = sample_entry();
337        let create = render_create_text("propose", &entry);
338        let action = render_action_text(LifecycleAction::Accept, &entry);
339        assert!(create.contains("- state: accepted"));
340        assert!(create.contains("- actor: long"));
341        assert!(action.contains("- action: accept"));
342        assert!(action.contains("- reason: approved after review"));
343    }
344
345    #[test]
346    fn read_side_payloads_should_share_summary_generation() {
347        let entry = sample_entry();
348        let queue = LifecycleQueuePayload::new(std::slice::from_ref(&entry));
349        let record = LifecycleRecordPayload::new(&entry);
350        let history = LifecycleHistoryPayload::new("record-1", std::slice::from_ref(&entry));
351        assert_eq!(queue.entries.len(), 1);
352        assert_eq!(queue.summaries[0].record_id, "record-1");
353        assert_eq!(record.summary.state, "accepted");
354        assert_eq!(history.record_id, "record-1");
355        assert_eq!(history.summaries[0].title, "测试偏好");
356    }
357
358    #[test]
359    fn read_side_text_renderers_should_match_existing_markdown_contracts() {
360        let entry = sample_entry();
361        let list = render_queue_text("Pending review", std::slice::from_ref(&entry), true, true);
362        let detail = render_record_text(&entry, true, true);
363        let history = render_history_text("record-1", std::slice::from_ref(&entry), true);
364        assert!(list.contains("# Pending review"));
365        assert!(list.contains("record-1"));
366        assert!(detail.contains("# Memory record"));
367        assert!(detail.contains("## Summary"));
368        assert!(history.contains("# Memory history"));
369        assert!(history.contains("events: 1"));
370    }
371}