Skip to main content

mlua_swarm/store/
enhance_log.rs

1//! `EnhanceLogStore` — append-only log of one `LogEntry` per Enhance-flow
2//! invocation.
3//!
4//! Used to preserve, for later inspection and dogfooding, "which issue
5//! patched which blueprint with which ops, what the four verifier axes
6//! decided, and what the rationale was".
7//!
8//! Design:
9//!
10//! - **Append-only K-V.** Appending the same `issue_id` twice returns a
11//!   `Conflict`; the existing entry is immutable.
12//! - List by `blueprint_id` — the historical enhance trace for one
13//!   Blueprint.
14//! - Get by `issue_id` — the record for a single issue.
15//! - Every entry carries a timestamp (ms epoch); lists come back sorted
16//!   ascending by timestamp.
17//!
18//! Persistence is out of scope here — the caller's `dispatch_one` pairs
19//! the `append` with `bp_store.write_new`. Timestamps are stamped
20//! caller-side, where the epoch is already known.
21
22use async_trait::async_trait;
23use serde::{Deserialize, Serialize};
24use std::collections::HashMap;
25use std::sync::Mutex;
26use thiserror::Error;
27
28use crate::blueprint::store::BlueprintId;
29use crate::store::issue::IssueId;
30
31/// Verdict from a single verifier axis (carried forward from
32/// `committer.lua`'s `verdicts_summary`).
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
34pub struct VerdictSummary {
35    /// Verifier axis name (e.g. `"des"`, `"canonical"`, `"noop"`, `"agent-ref"`).
36    pub axis: String,
37    /// `"pass"` or `"deny"`.
38    pub status: String,
39    /// Evidence when `pass`; reason when `deny`.
40    pub detail: String,
41}
42
43/// Trace of one enhance invocation. Both `Applied` and `Rejected` cases
44/// produce a single entry.
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
46pub struct EnhanceLogEntry {
47    /// The Issue that triggered this enhance invocation.
48    pub issue_id: IssueId,
49    /// The Blueprint targeted by the patch.
50    pub blueprint_id: BlueprintId,
51    /// Content hash of the Blueprint before this invocation.
52    pub prev_hash: String,
53    /// `Applied` — the hash of the patched form. `Rejected` — `""` (no
54    /// application happened).
55    pub new_hash: String,
56    /// The natural-language intent that drove the patch spawner.
57    pub intent: String,
58    /// The committer's rationale for the applied/rejected verdict.
59    pub rationale: String,
60    /// Per-axis verifier verdicts (one per entry in `verifier_axes`).
61    pub verdicts: Vec<VerdictSummary>,
62    /// `"applied"` or `"rejected"`.
63    pub status: String,
64    /// For `Rejected`: deny reasons in `"axis: reason"` form.
65    pub reasons: Vec<String>,
66    /// Epoch ms — stamped by the caller.
67    pub ts_ms: i64,
68}
69
70/// Errors surfaced by an [`EnhanceLogStore`] implementation.
71#[derive(Debug, Error)]
72pub enum EnhanceLogStoreError {
73    /// No entry exists for the given `issue_id`.
74    #[error("not found: {0:?}")]
75    NotFound(IssueId),
76    /// `append` was called twice for the same `issue_id`; the store is
77    /// append-only, so the existing entry is left untouched.
78    #[error("conflict: issue_id {0:?} already appended (append-only)")]
79    Conflict(IssueId),
80}
81
82/// Append-only persistence interface for [`EnhanceLogEntry`] records.
83#[async_trait]
84pub trait EnhanceLogStore: Send + Sync {
85    /// Backend name — for diagnostics/logging.
86    fn name(&self) -> &str;
87
88    /// Append a new entry. Returns `Conflict` if `entry.issue_id` was
89    /// already recorded.
90    async fn append(&self, entry: EnhanceLogEntry) -> Result<(), EnhanceLogStoreError>;
91
92    /// Fetch the entry for a single Issue.
93    async fn get(&self, issue_id: &IssueId) -> Result<EnhanceLogEntry, EnhanceLogStoreError>;
94
95    /// List every entry for a Blueprint, ascending by `ts_ms`.
96    async fn list_by_blueprint(
97        &self,
98        blueprint_id: &BlueprintId,
99    ) -> Result<Vec<EnhanceLogEntry>, EnhanceLogStoreError>;
100
101    /// List every entry across all Blueprints, ascending by `ts_ms`.
102    async fn list_all(&self) -> Result<Vec<EnhanceLogEntry>, EnhanceLogStoreError>;
103}
104
105/// Process-volatile [`EnhanceLogStore`] backed by a `HashMap`. Suitable
106/// for tests and single-process defaults; entries are lost on restart.
107#[derive(Default)]
108pub struct InMemoryEnhanceLogStore {
109    inner: Mutex<HashMap<IssueId, EnhanceLogEntry>>,
110}
111
112impl InMemoryEnhanceLogStore {
113    /// Create an empty store.
114    pub fn new() -> Self {
115        Self::default()
116    }
117}
118
119#[async_trait]
120impl EnhanceLogStore for InMemoryEnhanceLogStore {
121    fn name(&self) -> &str {
122        "in-memory"
123    }
124
125    async fn append(&self, entry: EnhanceLogEntry) -> Result<(), EnhanceLogStoreError> {
126        let mut guard = self.inner.lock().unwrap();
127        if guard.contains_key(&entry.issue_id) {
128            return Err(EnhanceLogStoreError::Conflict(entry.issue_id));
129        }
130        guard.insert(entry.issue_id.clone(), entry);
131        Ok(())
132    }
133
134    async fn get(&self, issue_id: &IssueId) -> Result<EnhanceLogEntry, EnhanceLogStoreError> {
135        self.inner
136            .lock()
137            .unwrap()
138            .get(issue_id)
139            .cloned()
140            .ok_or_else(|| EnhanceLogStoreError::NotFound(issue_id.clone()))
141    }
142
143    async fn list_by_blueprint(
144        &self,
145        blueprint_id: &BlueprintId,
146    ) -> Result<Vec<EnhanceLogEntry>, EnhanceLogStoreError> {
147        let mut entries: Vec<EnhanceLogEntry> = self
148            .inner
149            .lock()
150            .unwrap()
151            .values()
152            .filter(|e| &e.blueprint_id == blueprint_id)
153            .cloned()
154            .collect();
155        entries.sort_by_key(|e| e.ts_ms);
156        Ok(entries)
157    }
158
159    async fn list_all(&self) -> Result<Vec<EnhanceLogEntry>, EnhanceLogStoreError> {
160        let mut entries: Vec<EnhanceLogEntry> =
161            self.inner.lock().unwrap().values().cloned().collect();
162        entries.sort_by_key(|e| e.ts_ms);
163        Ok(entries)
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    fn mk_entry(issue: &str, bp: &str, ts: i64) -> EnhanceLogEntry {
172        EnhanceLogEntry {
173            issue_id: IssueId::new(issue),
174            blueprint_id: BlueprintId::new(bp.to_string()),
175            prev_hash: "00".repeat(32),
176            new_hash: "ff".repeat(32),
177            intent: "test intent".into(),
178            rationale: "test rationale".into(),
179            verdicts: vec![VerdictSummary {
180                axis: "des".into(),
181                status: "pass".into(),
182                detail: "ok".into(),
183            }],
184            status: "applied".into(),
185            reasons: vec![],
186            ts_ms: ts,
187        }
188    }
189
190    #[tokio::test]
191    async fn append_then_get_returns_same_entry() {
192        let s = InMemoryEnhanceLogStore::new();
193        let e = mk_entry("i1", "bp-1", 100);
194        s.append(e.clone()).await.unwrap();
195        let got = s.get(&IssueId::new("i1")).await.unwrap();
196        assert_eq!(got, e);
197    }
198
199    #[tokio::test]
200    async fn get_missing_returns_not_found() {
201        let s = InMemoryEnhanceLogStore::new();
202        let err = s.get(&IssueId::new("nope")).await.unwrap_err();
203        assert!(matches!(err, EnhanceLogStoreError::NotFound(_)));
204    }
205
206    #[tokio::test]
207    async fn append_twice_returns_conflict() {
208        let s = InMemoryEnhanceLogStore::new();
209        let e = mk_entry("i2", "bp-1", 200);
210        s.append(e.clone()).await.unwrap();
211        let err = s.append(e).await.unwrap_err();
212        assert!(matches!(err, EnhanceLogStoreError::Conflict(_)));
213    }
214
215    #[tokio::test]
216    async fn list_by_blueprint_filters_and_sorts_by_ts() {
217        let s = InMemoryEnhanceLogStore::new();
218        s.append(mk_entry("ib1", "bp-a", 300)).await.unwrap();
219        s.append(mk_entry("ib2", "bp-a", 100)).await.unwrap();
220        s.append(mk_entry("ib3", "bp-b", 200)).await.unwrap();
221
222        let a_only = s
223            .list_by_blueprint(&BlueprintId::new("bp-a".to_string()))
224            .await
225            .unwrap();
226        assert_eq!(a_only.len(), 2);
227        assert_eq!(a_only[0].issue_id.as_str(), "ib2");
228        assert_eq!(a_only[1].issue_id.as_str(), "ib1");
229
230        let b_only = s
231            .list_by_blueprint(&BlueprintId::new("bp-b".to_string()))
232            .await
233            .unwrap();
234        assert_eq!(b_only.len(), 1);
235        assert_eq!(b_only[0].issue_id.as_str(), "ib3");
236    }
237
238    #[tokio::test]
239    async fn list_all_returns_all_sorted_by_ts() {
240        let s = InMemoryEnhanceLogStore::new();
241        s.append(mk_entry("a", "bp-x", 500)).await.unwrap();
242        s.append(mk_entry("b", "bp-y", 100)).await.unwrap();
243        s.append(mk_entry("c", "bp-z", 300)).await.unwrap();
244        let all = s.list_all().await.unwrap();
245        assert_eq!(all.len(), 3);
246        assert_eq!(all[0].issue_id.as_str(), "b");
247        assert_eq!(all[1].issue_id.as_str(), "c");
248        assert_eq!(all[2].issue_id.as_str(), "a");
249    }
250
251    #[tokio::test]
252    async fn name_is_in_memory() {
253        assert_eq!(InMemoryEnhanceLogStore::new().name(), "in-memory");
254    }
255
256    #[tokio::test]
257    async fn rejected_entry_carries_reasons() {
258        let s = InMemoryEnhanceLogStore::new();
259        let mut e = mk_entry("ir", "bp-r", 400);
260        e.status = "rejected".into();
261        e.new_hash = "".into();
262        e.reasons = vec!["des: blueprint.id missing".into(), "noop: ...".into()];
263        e.verdicts = vec![VerdictSummary {
264            axis: "des".into(),
265            status: "deny".into(),
266            detail: "blueprint.id missing".into(),
267        }];
268        s.append(e.clone()).await.unwrap();
269        let got = s.get(&IssueId::new("ir")).await.unwrap();
270        assert_eq!(got.status, "rejected");
271        assert_eq!(got.reasons.len(), 2);
272        assert!(got.new_hash.is_empty());
273    }
274}