mlua_swarm/store/enhance_log/
mod.rs1pub 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
37pub struct VerdictSummary {
38 pub axis: String,
40 pub status: String,
42 pub detail: String,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
49pub struct EnhanceLogEntry {
50 pub issue_id: IssueId,
52 pub blueprint_id: BlueprintId,
54 pub prev_hash: String,
56 pub new_hash: String,
59 pub intent: String,
61 pub rationale: String,
63 pub verdicts: Vec<VerdictSummary>,
65 pub status: String,
67 pub reasons: Vec<String>,
69 pub ts_ms: i64,
71}
72
73#[derive(Debug, Error)]
75pub enum EnhanceLogStoreError {
76 #[error("not found: {0:?}")]
78 NotFound(IssueId),
79 #[error("conflict: issue_id {0:?} already appended (append-only)")]
82 Conflict(IssueId),
83 #[error("other: {0}")]
86 Other(String),
87}
88
89#[async_trait]
91pub trait EnhanceLogStore: Send + Sync {
92 fn name(&self) -> &str;
94
95 async fn append(&self, entry: EnhanceLogEntry) -> Result<(), EnhanceLogStoreError>;
98
99 async fn get(&self, issue_id: &IssueId) -> Result<EnhanceLogEntry, EnhanceLogStoreError>;
101
102 async fn list_by_blueprint(
104 &self,
105 blueprint_id: &BlueprintId,
106 ) -> Result<Vec<EnhanceLogEntry>, EnhanceLogStoreError>;
107
108 async fn list_all(&self) -> Result<Vec<EnhanceLogEntry>, EnhanceLogStoreError>;
110}
111
112#[derive(Default)]
115pub struct InMemoryEnhanceLogStore {
116 inner: Mutex<HashMap<IssueId, EnhanceLogEntry>>,
117}
118
119impl InMemoryEnhanceLogStore {
120 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}