Skip to main content

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