1use std::sync::atomic::{AtomicU64, Ordering};
26use std::sync::{Arc, Mutex};
27
28use tokio::sync::mpsc;
29
30use mermaid_domain::Msg;
31use mermaid_domain::checklist::{
32 ApplyReport, ChecklistEdit, ChecklistItem, ChecklistOrigin, ChecklistSpec, ChecklistStatus,
33 ChecklistStore, EvidenceEntry, Stamp, UserChecklistEdit,
34};
35
36#[derive(Clone)]
37pub struct TaskBroker {
38 store: Arc<Mutex<ChecklistStore>>,
39 tokens: Arc<AtomicU64>,
43 msg_tx: mpsc::Sender<Msg>,
44}
45
46impl TaskBroker {
47 #[must_use]
48 pub fn new(msg_tx: mpsc::Sender<Msg>) -> Self {
49 Self {
50 store: Arc::new(Mutex::new(ChecklistStore::default())),
51 tokens: Arc::new(AtomicU64::new(0)),
52 msg_tx,
53 }
54 }
55
56 pub fn seed(&self, store: ChecklistStore) {
60 *self.lock() = store;
61 }
62
63 pub fn add_tokens(&self, completion_tokens: u64) {
68 self.tokens.fetch_add(completion_tokens, Ordering::Relaxed);
69 }
70
71 pub async fn create(
73 &self,
74 specs: Vec<ChecklistSpec>,
75 origin: ChecklistOrigin,
76 ) -> (Vec<ChecklistItem>, ChecklistStore) {
77 let (created, snapshot) = {
78 let mut store = self.lock();
79 let ids = store.create(specs, origin, self.stamp());
80 let created = store
81 .tasks
82 .iter()
83 .filter(|t| ids.contains(&t.id))
84 .cloned()
85 .collect();
86 (created, store.clone())
87 };
88 self.publish(snapshot.clone()).await;
89 (created, snapshot)
90 }
91
92 pub async fn update(&self, edits: Vec<ChecklistEdit>) -> (ApplyReport, ChecklistStore) {
95 let (report, snapshot) = {
96 let mut store = self.lock();
97 let report = store.apply(&edits, self.stamp());
98 (report, store.clone())
99 };
100 self.publish(snapshot.clone()).await;
101 (report, snapshot)
102 }
103
104 pub async fn user_edit(&self, edit: UserChecklistEdit) -> (String, ChecklistStore) {
107 let (line, snapshot) = {
108 let mut store = self.lock();
109 let subject_of = |store: &ChecklistStore, id: u32| {
110 store
111 .tasks
112 .iter()
113 .find(|t| t.id == id)
114 .map(|t| t.subject.clone())
115 .unwrap_or_default()
116 };
117 let line = match edit {
118 UserChecklistEdit::Add { subject } => {
119 let ids = store.create(
120 vec![ChecklistSpec {
121 active_form: subject.clone(),
122 subject: subject.clone(),
123 description: None,
124 in_progress: false,
125 }],
126 ChecklistOrigin::User,
127 self.stamp(),
128 );
129 format!("Added task #{} '{subject}'", ids[0])
130 },
131 UserChecklistEdit::Remove { id } => {
132 let subject = subject_of(&store, id);
133 let report = store.apply(
134 &[ChecklistEdit {
135 id,
136 status: Some(ChecklistStatus::Deleted),
137 ..ChecklistEdit::default()
138 }],
139 self.stamp(),
140 );
141 match report.errors.first() {
142 Some(err) => err.clone(),
143 None => format!("Removed task #{id} '{subject}'"),
144 }
145 },
146 UserChecklistEdit::Done { id } => {
147 let subject = subject_of(&store, id);
148 let report = store.apply(
149 &[ChecklistEdit {
150 id,
151 status: Some(ChecklistStatus::Completed),
152 ..ChecklistEdit::default()
153 }],
154 self.stamp(),
155 );
156 match report.errors.first() {
157 Some(err) => err.clone(),
158 None => format!("Marked task #{id} '{subject}' completed"),
159 }
160 },
161 UserChecklistEdit::Clear => {
162 *store = ChecklistStore::default();
163 "Cleared the task list".to_string()
164 },
165 };
166 (line, store.clone())
167 };
168 self.publish(snapshot.clone()).await;
169 (line, snapshot)
170 }
171
172 pub async fn record_evidence(&self, entry: EvidenceEntry) {
175 let snapshot = {
176 let mut store = self.lock();
177 store.record_evidence(entry).then(|| store.clone())
178 };
179 if let Some(snapshot) = snapshot {
180 self.publish(snapshot).await;
181 }
182 }
183
184 #[must_use]
185 pub fn snapshot(&self) -> ChecklistStore {
186 self.lock().clone()
187 }
188
189 fn stamp(&self) -> Stamp {
190 Stamp {
191 now_epoch: std::time::SystemTime::now()
192 .duration_since(std::time::UNIX_EPOCH)
193 .map(|d| d.as_secs())
194 .unwrap_or(0),
195 run_tokens: self.tokens.load(Ordering::Relaxed),
196 }
197 }
198
199 fn lock(&self) -> std::sync::MutexGuard<'_, ChecklistStore> {
200 self.store
201 .lock()
202 .unwrap_or_else(|poisoned| poisoned.into_inner())
203 }
204
205 async fn publish(&self, store: ChecklistStore) {
208 let _ = self.msg_tx.send(Msg::TasksUpdated { store }).await;
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 fn spec(subject: &str, in_progress: bool) -> ChecklistSpec {
217 ChecklistSpec {
218 subject: subject.into(),
219 active_form: format!("{subject}ing"),
220 description: None,
221 in_progress,
222 }
223 }
224
225 async fn recv_store(rx: &mut mpsc::Receiver<Msg>) -> ChecklistStore {
226 match rx.recv().await {
227 Some(Msg::TasksUpdated { store }) => store,
228 other => panic!("expected TasksUpdated, got {other:?}"),
229 }
230 }
231
232 #[tokio::test]
233 async fn create_and_update_publish_snapshots() {
234 let (tx, mut rx) = mpsc::channel(8);
235 let broker = TaskBroker::new(tx);
236 let (created, _) = broker
237 .create(
238 vec![spec("a", true), spec("b", false)],
239 ChecklistOrigin::Model,
240 )
241 .await;
242 assert_eq!(created.len(), 2);
243 assert_eq!(recv_store(&mut rx).await.counts(), (0, 2));
244
245 let (report, _) = broker
246 .update(vec![ChecklistEdit {
247 id: created[0].id,
248 status: Some(ChecklistStatus::Completed),
249 ..ChecklistEdit::default()
250 }])
251 .await;
252 assert!(report.errors.is_empty());
253 let published = recv_store(&mut rx).await;
254 assert_eq!(published.counts(), (1, 2));
255 }
256
257 #[tokio::test]
258 async fn token_readings_feed_cost_stamps() {
259 let (tx, _rx) = mpsc::channel(8);
260 let broker = TaskBroker::new(tx);
261 broker.add_tokens(1_000);
262 let (created, _) = broker
263 .create(vec![spec("a", true)], ChecklistOrigin::Model)
264 .await;
265 broker.add_tokens(8_400);
266 let (_, snapshot) = broker
267 .update(vec![ChecklistEdit {
268 id: created[0].id,
269 status: Some(ChecklistStatus::Completed),
270 ..ChecklistEdit::default()
271 }])
272 .await;
273 assert_eq!(snapshot.tasks[0].tokens_spent, Some(8_400));
274 }
275
276 #[tokio::test]
277 async fn seed_overwrites_without_publishing() {
278 let (tx, mut rx) = mpsc::channel(8);
279 let broker = TaskBroker::new(tx);
280 let mut store = ChecklistStore::default();
281 store.create(
282 vec![spec("seeded", false)],
283 ChecklistOrigin::Model,
284 Stamp::default(),
285 );
286 broker.seed(store);
287 assert_eq!(broker.snapshot().tasks.len(), 1);
288 assert!(rx.try_recv().is_err(), "seed must not publish");
289 }
290
291 #[tokio::test]
292 async fn user_edits_apply_and_report() {
293 let (tx, mut rx) = mpsc::channel(8);
294 let broker = TaskBroker::new(tx);
295 let (line, _) = broker
296 .user_edit(UserChecklistEdit::Add {
297 subject: "review the docs".into(),
298 })
299 .await;
300 assert_eq!(line, "Added task #1 'review the docs'");
301 assert_eq!(
302 recv_store(&mut rx).await.tasks[0].origin,
303 ChecklistOrigin::User
304 );
305
306 let (line, snapshot) = broker.user_edit(UserChecklistEdit::Remove { id: 9 }).await;
307 assert_eq!(line, "#9: no such task");
308 assert_eq!(snapshot.visible().count(), 1);
309 }
310
311 #[tokio::test]
312 async fn evidence_publishes_only_when_recorded() {
313 let (tx, mut rx) = mpsc::channel(8);
314 let broker = TaskBroker::new(tx);
315 broker
317 .record_evidence(EvidenceEntry {
318 tool: "edit_file".into(),
319 target: "a.rs".into(),
320 status: "ok".into(),
321 })
322 .await;
323 assert!(rx.try_recv().is_err());
324
325 broker
326 .create(vec![spec("a", true)], ChecklistOrigin::Model)
327 .await;
328 let _ = recv_store(&mut rx).await;
329 broker
330 .record_evidence(EvidenceEntry {
331 tool: "edit_file".into(),
332 target: "a.rs".into(),
333 status: "ok".into(),
334 })
335 .await;
336 let published = recv_store(&mut rx).await;
337 assert_eq!(published.tasks[0].evidence.len(), 1);
338 }
339}