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