1use std::collections::BTreeMap;
2#[cfg(not(target_arch = "wasm32"))]
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5
6use async_trait::async_trait;
7use chrono::{DateTime, Utc};
8#[cfg(not(target_arch = "wasm32"))]
9use rusqlite::{
10 Connection, Error, ErrorCode, OptionalExtension, Transaction, TransactionBehavior, params,
11};
12
13use crate::WorkGraphError;
14use crate::types::{
15 AttentionListRequest, AttentionPruneRequest, WorkAttentionBinding, WorkAttentionBindingId,
16 WorkAttentionStatus, WorkEdge, WorkGraphEvent, WorkGraphEventKind, WorkItem, WorkItemFilter,
17 WorkItemId, WorkNamespace,
18};
19use crate::{WorkAttentionMachine, WorkGraphMachine};
20
21#[cfg(target_arch = "wasm32")]
22use crate::tokio::sync::RwLock;
23#[cfg(not(target_arch = "wasm32"))]
24use tokio::sync::RwLock;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum WorkGraphStoreKind {
28 Disabled,
29 Memory,
30 Sqlite,
31 Custom,
32}
33
34impl WorkGraphStoreKind {
35 pub fn as_str(self) -> &'static str {
36 match self {
37 Self::Disabled => "disabled",
38 Self::Memory => "memory",
39 Self::Sqlite => "sqlite",
40 Self::Custom => "custom",
41 }
42 }
43}
44
45impl std::fmt::Display for WorkGraphStoreKind {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 f.write_str(self.as_str())
48 }
49}
50
51#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
52#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
53pub struct WorkGraphEventFilter {
54 pub realm_id: Option<String>,
55 pub namespace: Option<WorkNamespace>,
56 #[serde(default)]
57 pub all_namespaces: bool,
58 pub after_seq: Option<i64>,
59 pub limit: Option<usize>,
60}
61
62#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
63#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
64pub trait WorkGraphStore: Send + Sync {
65 fn kind(&self) -> WorkGraphStoreKind;
66
67 async fn get_store_time_utc(&self) -> Result<DateTime<Utc>, WorkGraphError>;
68
69 async fn insert_item(
70 &self,
71 item: WorkItem,
72 event: WorkGraphEvent,
73 ) -> Result<WorkItem, WorkGraphError>;
74
75 async fn update_item_cas(
76 &self,
77 item: WorkItem,
78 expected_previous_revision: u64,
79 event: WorkGraphEvent,
80 ) -> Result<WorkItem, WorkGraphError>;
81
82 async fn update_item_and_attention_cas(
83 &self,
84 item: WorkItem,
85 expected_previous_revision: u64,
86 item_event: WorkGraphEvent,
87 attention_updates: Vec<(WorkAttentionBinding, u64, WorkGraphEvent)>,
88 ) -> Result<WorkItem, WorkGraphError>;
89
90 async fn get_item(
91 &self,
92 realm_id: &str,
93 namespace: &WorkNamespace,
94 id: &WorkItemId,
95 ) -> Result<Option<WorkItem>, WorkGraphError>;
96
97 async fn list_items(&self, filter: WorkItemFilter) -> Result<Vec<WorkItem>, WorkGraphError>;
98
99 async fn insert_goal(
100 &self,
101 _item: WorkItem,
102 _item_event: WorkGraphEvent,
103 _attention: WorkAttentionBinding,
104 _attention_event: WorkGraphEvent,
105 ) -> Result<(WorkItem, WorkAttentionBinding), WorkGraphError> {
106 Err(unsupported(self.kind()))
107 }
108
109 async fn update_attention_cas(
110 &self,
111 _attention: WorkAttentionBinding,
112 _expected_previous_revision: u64,
113 _event: WorkGraphEvent,
114 ) -> Result<WorkAttentionBinding, WorkGraphError> {
115 Err(unsupported(self.kind()))
116 }
117
118 async fn reassign_attention_cas(
119 &self,
120 _previous: WorkAttentionBinding,
121 _expected_previous_revision: u64,
122 _previous_event: WorkGraphEvent,
123 _replacement: WorkAttentionBinding,
124 _replacement_event: WorkGraphEvent,
125 ) -> Result<(WorkAttentionBinding, WorkAttentionBinding), WorkGraphError> {
126 Err(unsupported(self.kind()))
127 }
128
129 async fn get_attention(
130 &self,
131 _realm_id: &str,
132 _namespace: &WorkNamespace,
133 _binding_id: &WorkAttentionBindingId,
134 ) -> Result<Option<WorkAttentionBinding>, WorkGraphError> {
135 Err(unsupported(self.kind()))
136 }
137
138 async fn list_attention(
139 &self,
140 _filter: AttentionListRequest,
141 ) -> Result<Vec<WorkAttentionBinding>, WorkGraphError> {
142 Err(unsupported(self.kind()))
143 }
144
145 async fn list_attention_bounded(
149 &self,
150 filter: AttentionListRequest,
151 limit: usize,
152 ) -> Result<Vec<WorkAttentionBinding>, WorkGraphError> {
153 let mut bindings = self.list_attention(filter).await?;
154 bindings.truncate(limit);
155 Ok(bindings)
156 }
157
158 async fn prune_terminal_attention(
162 &self,
163 _filter: AttentionPruneRequest,
164 ) -> Result<u64, WorkGraphError> {
165 Err(unsupported(self.kind()))
166 }
167
168 async fn insert_edge(
169 &self,
170 edge: WorkEdge,
171 event: WorkGraphEvent,
172 ) -> Result<WorkEdge, WorkGraphError>;
173
174 async fn insert_edge_validated(
175 &self,
176 _edge: WorkEdge,
177 _event: WorkGraphEvent,
178 ) -> Result<WorkEdge, WorkGraphError> {
179 Err(unsupported(self.kind()))
180 }
181
182 async fn list_edges(
183 &self,
184 realm_id: &str,
185 namespace: &WorkNamespace,
186 ) -> Result<Vec<WorkEdge>, WorkGraphError>;
187
188 async fn list_edges_bounded(
190 &self,
191 realm_id: &str,
192 namespace: &WorkNamespace,
193 limit: usize,
194 ) -> Result<Vec<WorkEdge>, WorkGraphError> {
195 let mut edges = self.list_edges(realm_id, namespace).await?;
196 edges.truncate(limit);
197 Ok(edges)
198 }
199
200 async fn list_events(
201 &self,
202 filter: WorkGraphEventFilter,
203 ) -> Result<Vec<WorkGraphEvent>, WorkGraphError>;
204
205 async fn latest_event_seq(
207 &self,
208 filter: WorkGraphEventFilter,
209 ) -> Result<Option<i64>, WorkGraphError> {
210 Ok(self
211 .list_events(filter)
212 .await?
213 .into_iter()
214 .filter_map(|event| event.seq)
215 .max())
216 }
217}
218
219#[derive(Default)]
220pub struct DisabledWorkGraphStore;
221
222#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
223#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
224impl WorkGraphStore for DisabledWorkGraphStore {
225 fn kind(&self) -> WorkGraphStoreKind {
226 WorkGraphStoreKind::Disabled
227 }
228
229 async fn get_store_time_utc(&self) -> Result<DateTime<Utc>, WorkGraphError> {
230 Err(unsupported(self.kind()))
231 }
232
233 async fn insert_item(
234 &self,
235 _item: WorkItem,
236 _event: WorkGraphEvent,
237 ) -> Result<WorkItem, WorkGraphError> {
238 Err(unsupported(self.kind()))
239 }
240
241 async fn update_item_cas(
242 &self,
243 _item: WorkItem,
244 _expected_previous_revision: u64,
245 _event: WorkGraphEvent,
246 ) -> Result<WorkItem, WorkGraphError> {
247 Err(unsupported(self.kind()))
248 }
249
250 async fn update_item_and_attention_cas(
251 &self,
252 _item: WorkItem,
253 _expected_previous_revision: u64,
254 _item_event: WorkGraphEvent,
255 _attention_updates: Vec<(WorkAttentionBinding, u64, WorkGraphEvent)>,
256 ) -> Result<WorkItem, WorkGraphError> {
257 Err(unsupported(self.kind()))
258 }
259
260 async fn get_item(
261 &self,
262 _realm_id: &str,
263 _namespace: &WorkNamespace,
264 _id: &WorkItemId,
265 ) -> Result<Option<WorkItem>, WorkGraphError> {
266 Err(unsupported(self.kind()))
267 }
268
269 async fn list_items(&self, _filter: WorkItemFilter) -> Result<Vec<WorkItem>, WorkGraphError> {
270 Err(unsupported(self.kind()))
271 }
272
273 async fn insert_goal(
274 &self,
275 _item: WorkItem,
276 _item_event: WorkGraphEvent,
277 _attention: WorkAttentionBinding,
278 _attention_event: WorkGraphEvent,
279 ) -> Result<(WorkItem, WorkAttentionBinding), WorkGraphError> {
280 Err(unsupported(self.kind()))
281 }
282
283 async fn update_attention_cas(
284 &self,
285 _attention: WorkAttentionBinding,
286 _expected_previous_revision: u64,
287 _event: WorkGraphEvent,
288 ) -> Result<WorkAttentionBinding, WorkGraphError> {
289 Err(unsupported(self.kind()))
290 }
291
292 async fn get_attention(
293 &self,
294 _realm_id: &str,
295 _namespace: &WorkNamespace,
296 _binding_id: &WorkAttentionBindingId,
297 ) -> Result<Option<WorkAttentionBinding>, WorkGraphError> {
298 Err(unsupported(self.kind()))
299 }
300
301 async fn list_attention(
302 &self,
303 _filter: AttentionListRequest,
304 ) -> Result<Vec<WorkAttentionBinding>, WorkGraphError> {
305 Err(unsupported(self.kind()))
306 }
307
308 async fn insert_edge(
309 &self,
310 _edge: WorkEdge,
311 _event: WorkGraphEvent,
312 ) -> Result<WorkEdge, WorkGraphError> {
313 Err(unsupported(self.kind()))
314 }
315
316 async fn insert_edge_validated(
317 &self,
318 _edge: WorkEdge,
319 _event: WorkGraphEvent,
320 ) -> Result<WorkEdge, WorkGraphError> {
321 Err(unsupported(self.kind()))
322 }
323
324 async fn list_edges(
325 &self,
326 _realm_id: &str,
327 _namespace: &WorkNamespace,
328 ) -> Result<Vec<WorkEdge>, WorkGraphError> {
329 Err(unsupported(self.kind()))
330 }
331
332 async fn list_events(
333 &self,
334 _filter: WorkGraphEventFilter,
335 ) -> Result<Vec<WorkGraphEvent>, WorkGraphError> {
336 Err(unsupported(self.kind()))
337 }
338}
339
340fn unsupported(kind: WorkGraphStoreKind) -> WorkGraphError {
341 WorkGraphError::UnsupportedBackend(kind.to_string())
342}
343
344#[derive(Default)]
345pub struct MemoryWorkGraphStore {
346 inner: Arc<RwLock<MemoryWorkGraphState>>,
347}
348
349#[derive(Default)]
350struct MemoryWorkGraphState {
351 items: BTreeMap<(String, WorkNamespace, WorkItemId), WorkItem>,
352 attention: BTreeMap<(String, WorkNamespace, WorkAttentionBindingId), WorkAttentionBinding>,
353 edges: Vec<WorkEdge>,
354 events: Vec<WorkGraphEvent>,
355 next_event_seq: i64,
356}
357
358impl MemoryWorkGraphStore {
359 pub fn new() -> Self {
360 Self::default()
361 }
362}
363
364#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
365#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
366impl WorkGraphStore for MemoryWorkGraphStore {
367 fn kind(&self) -> WorkGraphStoreKind {
368 WorkGraphStoreKind::Memory
369 }
370
371 async fn get_store_time_utc(&self) -> Result<DateTime<Utc>, WorkGraphError> {
372 Ok(Utc::now())
373 }
374
375 async fn insert_item(
376 &self,
377 item: WorkItem,
378 event: WorkGraphEvent,
379 ) -> Result<WorkItem, WorkGraphError> {
380 WorkGraphMachine::validate_item_projection(&item)?;
381 let mut guard = self.inner.write().await;
382 let key = item_key(&item.realm_id, &item.namespace, &item.id);
383 if guard.items.contains_key(&key) {
384 return Err(WorkGraphError::Conflict(format!(
385 "work item {} already exists",
386 item.id
387 )));
388 }
389 guard.items.insert(key, item.clone());
390 guard.append_event(event);
391 Ok(item)
392 }
393
394 async fn update_item_cas(
395 &self,
396 item: WorkItem,
397 expected_previous_revision: u64,
398 event: WorkGraphEvent,
399 ) -> Result<WorkItem, WorkGraphError> {
400 WorkGraphMachine::validate_item_projection(&item)?;
401 let mut guard = self.inner.write().await;
402 let key = item_key(&item.realm_id, &item.namespace, &item.id);
403 let Some(current) = guard.items.get(&key) else {
404 return Err(WorkGraphError::not_found(
405 item.realm_id.clone(),
406 item.namespace.clone(),
407 item.id.clone(),
408 ));
409 };
410 if current.revision != expected_previous_revision {
411 return Err(WorkGraphError::StaleRevision {
412 id: item.id.clone(),
413 expected: expected_previous_revision,
414 actual: current.revision,
415 });
416 }
417 guard.items.insert(key, item.clone());
418 guard.append_event(event);
419 Ok(item)
420 }
421
422 async fn get_item(
423 &self,
424 realm_id: &str,
425 namespace: &WorkNamespace,
426 id: &WorkItemId,
427 ) -> Result<Option<WorkItem>, WorkGraphError> {
428 let guard = self.inner.read().await;
429 Ok(guard.items.get(&item_key(realm_id, namespace, id)).cloned())
430 }
431
432 async fn list_items(&self, filter: WorkItemFilter) -> Result<Vec<WorkItem>, WorkGraphError> {
433 let guard = self.inner.read().await;
434 let compare = |left: &WorkItem, right: &WorkItem| {
435 left.updated_at
436 .cmp(&right.updated_at)
437 .then_with(|| left.id.cmp(&right.id))
438 };
439 if let Some(limit) = filter.limit {
440 let mut items = Vec::with_capacity(limit.min(1024));
441 for item in guard
442 .items
443 .values()
444 .filter(|item| item_matches_filter(item, &filter))
445 {
446 let index = items
447 .binary_search_by(|existing| compare(existing, item))
448 .unwrap_or_else(|index| index);
449 if index < limit {
450 items.insert(index, item.clone());
451 if items.len() > limit {
452 items.pop();
453 }
454 }
455 }
456 return Ok(items);
457 }
458 let mut items = guard
459 .items
460 .values()
461 .filter(|item| item_matches_filter(item, &filter))
462 .cloned()
463 .collect::<Vec<_>>();
464 items.sort_by(compare);
465 Ok(items)
466 }
467
468 async fn insert_goal(
469 &self,
470 item: WorkItem,
471 item_event: WorkGraphEvent,
472 attention: WorkAttentionBinding,
473 attention_event: WorkGraphEvent,
474 ) -> Result<(WorkItem, WorkAttentionBinding), WorkGraphError> {
475 WorkGraphMachine::validate_item_projection(&item)?;
476 let mut guard = self.inner.write().await;
477 let item_key = item_key(&item.realm_id, &item.namespace, &item.id);
478 if guard.items.contains_key(&item_key) {
479 return Err(WorkGraphError::Conflict(format!(
480 "work item {} already exists",
481 item.id
482 )));
483 }
484 let attention_key = attention_key(
485 &attention.work_ref.realm_id,
486 &attention.work_ref.namespace,
487 &attention.binding_id,
488 );
489 if guard.attention.contains_key(&attention_key) {
490 return Err(WorkGraphError::Conflict(format!(
491 "work attention binding {} already exists",
492 attention.binding_id
493 )));
494 }
495 if let Some(occupant) = active_target_occupant_in(guard.attention.values(), &attention) {
496 return Err(active_target_conflict(&attention, &occupant));
497 }
498 guard.items.insert(item_key, item.clone());
499 guard.attention.insert(attention_key, attention.clone());
500 guard.append_event(item_event);
501 guard.append_event(attention_event);
502 Ok((item, attention))
503 }
504
505 async fn update_attention_cas(
506 &self,
507 attention: WorkAttentionBinding,
508 expected_previous_revision: u64,
509 event: WorkGraphEvent,
510 ) -> Result<WorkAttentionBinding, WorkGraphError> {
511 let mut guard = self.inner.write().await;
512 let key = attention_key(
513 &attention.work_ref.realm_id,
514 &attention.work_ref.namespace,
515 &attention.binding_id,
516 );
517 let Some(current) = guard.attention.get(&key) else {
518 return Err(WorkGraphError::not_found(
519 attention.work_ref.realm_id.clone(),
520 attention.work_ref.namespace.clone(),
521 attention.work_ref.item_id.clone(),
522 ));
523 };
524 if current.machine_state.revision != expected_previous_revision {
525 return Err(WorkGraphError::StaleRevision {
526 id: attention.work_ref.item_id.clone(),
527 expected: expected_previous_revision,
528 actual: current.machine_state.revision,
529 });
530 }
531 if let Some(occupant) = active_target_occupant_in(guard.attention.values(), &attention) {
532 return Err(active_target_conflict(&attention, &occupant));
533 }
534 guard.attention.insert(key, attention.clone());
535 guard.append_event(event);
536 Ok(attention)
537 }
538
539 async fn reassign_attention_cas(
540 &self,
541 previous: WorkAttentionBinding,
542 expected_previous_revision: u64,
543 previous_event: WorkGraphEvent,
544 replacement: WorkAttentionBinding,
545 replacement_event: WorkGraphEvent,
546 ) -> Result<(WorkAttentionBinding, WorkAttentionBinding), WorkGraphError> {
547 let mut guard = self.inner.write().await;
548 let previous_key = attention_key(
549 &previous.work_ref.realm_id,
550 &previous.work_ref.namespace,
551 &previous.binding_id,
552 );
553 let Some(current) = guard.attention.get(&previous_key) else {
554 return Err(WorkGraphError::attention_not_found(
555 previous.work_ref.realm_id.clone(),
556 previous.work_ref.namespace.clone(),
557 previous.binding_id.clone(),
558 ));
559 };
560 if current.machine_state.revision != expected_previous_revision {
561 return Err(WorkGraphError::StaleRevision {
562 id: previous.work_ref.item_id.clone(),
563 expected: expected_previous_revision,
564 actual: current.machine_state.revision,
565 });
566 }
567 let replacement_key = attention_key(
568 &replacement.work_ref.realm_id,
569 &replacement.work_ref.namespace,
570 &replacement.binding_id,
571 );
572 if guard.attention.contains_key(&replacement_key) {
573 return Err(WorkGraphError::Conflict(format!(
574 "work attention binding {} already exists",
575 replacement.binding_id
576 )));
577 }
578 if let Some(occupant) = active_target_occupant_in(
581 guard
582 .attention
583 .values()
584 .filter(|binding| binding.binding_id != previous.binding_id),
585 &replacement,
586 ) {
587 return Err(active_target_conflict(&replacement, &occupant));
588 }
589 guard.attention.insert(previous_key, previous.clone());
590 guard.attention.insert(replacement_key, replacement.clone());
591 guard.append_event(previous_event);
592 guard.append_event(replacement_event);
593 Ok((previous, replacement))
594 }
595
596 async fn update_item_and_attention_cas(
597 &self,
598 item: WorkItem,
599 expected_previous_revision: u64,
600 item_event: WorkGraphEvent,
601 attention_updates: Vec<(WorkAttentionBinding, u64, WorkGraphEvent)>,
602 ) -> Result<WorkItem, WorkGraphError> {
603 WorkGraphMachine::validate_item_projection(&item)?;
604 let mut guard = self.inner.write().await;
605 let key = item_key(&item.realm_id, &item.namespace, &item.id);
606 let Some(current) = guard.items.get(&key) else {
607 return Err(WorkGraphError::not_found(
608 item.realm_id.clone(),
609 item.namespace.clone(),
610 item.id.clone(),
611 ));
612 };
613 if current.revision != expected_previous_revision {
614 return Err(WorkGraphError::StaleRevision {
615 id: item.id.clone(),
616 expected: expected_previous_revision,
617 actual: current.revision,
618 });
619 }
620 for (attention, expected_revision, _) in &attention_updates {
621 let key = attention_key(
622 &attention.work_ref.realm_id,
623 &attention.work_ref.namespace,
624 &attention.binding_id,
625 );
626 let Some(current) = guard.attention.get(&key) else {
627 return Err(WorkGraphError::not_found(
628 attention.work_ref.realm_id.clone(),
629 attention.work_ref.namespace.clone(),
630 attention.work_ref.item_id.clone(),
631 ));
632 };
633 if current.machine_state.revision != *expected_revision {
634 return Err(WorkGraphError::StaleRevision {
635 id: attention.work_ref.item_id.clone(),
636 expected: *expected_revision,
637 actual: current.machine_state.revision,
638 });
639 }
640 }
641 let batch_ids: Vec<WorkAttentionBindingId> = attention_updates
645 .iter()
646 .map(|(attention, _, _)| attention.binding_id.clone())
647 .collect();
648 for (index, (attention, _, _)) in attention_updates.iter().enumerate() {
649 let occupant = active_target_occupant_in(
650 guard
651 .attention
652 .values()
653 .filter(|binding| !batch_ids.contains(&binding.binding_id))
654 .chain(
655 attention_updates[..index]
656 .iter()
657 .map(|(applied, _, _)| applied),
658 ),
659 attention,
660 );
661 if let Some(occupant) = occupant {
662 return Err(active_target_conflict(attention, &occupant));
663 }
664 }
665 guard.items.insert(key, item.clone());
666 guard.append_event(item_event);
667 for (attention, _, event) in attention_updates {
668 let key = attention_key(
669 &attention.work_ref.realm_id,
670 &attention.work_ref.namespace,
671 &attention.binding_id,
672 );
673 guard.attention.insert(key, attention);
674 guard.append_event(event);
675 }
676 Ok(item)
677 }
678
679 async fn get_attention(
680 &self,
681 realm_id: &str,
682 namespace: &WorkNamespace,
683 binding_id: &WorkAttentionBindingId,
684 ) -> Result<Option<WorkAttentionBinding>, WorkGraphError> {
685 let guard = self.inner.read().await;
686 Ok(guard
687 .attention
688 .get(&attention_key(realm_id, namespace, binding_id))
689 .cloned())
690 }
691
692 async fn list_attention(
693 &self,
694 filter: AttentionListRequest,
695 ) -> Result<Vec<WorkAttentionBinding>, WorkGraphError> {
696 let guard = self.inner.read().await;
697 let mut bindings = guard
698 .attention
699 .values()
700 .filter(|binding| attention_matches_filter(binding, &filter))
701 .cloned()
702 .collect::<Vec<_>>();
703 bindings.sort_by(|left, right| {
704 left.updated_at
705 .cmp(&right.updated_at)
706 .then_with(|| left.binding_id.cmp(&right.binding_id))
707 });
708 Ok(bindings)
709 }
710
711 async fn list_attention_bounded(
712 &self,
713 filter: AttentionListRequest,
714 limit: usize,
715 ) -> Result<Vec<WorkAttentionBinding>, WorkGraphError> {
716 let guard = self.inner.read().await;
717 let compare = |left: &WorkAttentionBinding, right: &WorkAttentionBinding| {
718 left.updated_at
719 .cmp(&right.updated_at)
720 .then_with(|| left.binding_id.cmp(&right.binding_id))
721 };
722 let mut bindings = Vec::with_capacity(limit.min(1024));
723 for binding in guard
724 .attention
725 .values()
726 .filter(|binding| attention_matches_filter(binding, &filter))
727 {
728 let index = bindings
729 .binary_search_by(|existing| compare(existing, binding))
730 .unwrap_or_else(|index| index);
731 if index < limit {
732 bindings.insert(index, binding.clone());
733 if bindings.len() > limit {
734 bindings.pop();
735 }
736 }
737 }
738 Ok(bindings)
739 }
740
741 async fn prune_terminal_attention(
742 &self,
743 filter: AttentionPruneRequest,
744 ) -> Result<u64, WorkGraphError> {
745 let mut guard = self.inner.write().await;
746 let before = guard.attention.len();
747 guard.attention.retain(|_, binding| {
748 let in_scope = filter
749 .realm_id
750 .as_ref()
751 .is_none_or(|realm_id| &binding.work_ref.realm_id == realm_id)
752 && filter
753 .namespace
754 .as_ref()
755 .is_none_or(|namespace| &binding.work_ref.namespace == namespace)
756 && filter
757 .updated_before
758 .is_none_or(|updated_before| binding.updated_at < updated_before);
759 !(in_scope && binding.status.is_terminal())
760 });
761 Ok((before - guard.attention.len()) as u64)
762 }
763
764 async fn insert_edge(
765 &self,
766 edge: WorkEdge,
767 event: WorkGraphEvent,
768 ) -> Result<WorkEdge, WorkGraphError> {
769 let mut guard = self.inner.write().await;
770 if guard.edges.iter().any(|existing| existing == &edge) {
771 return Err(duplicate_edge_error(&edge));
772 }
773 guard.edges.push(edge.clone());
774 guard.append_event(event);
775 Ok(edge)
776 }
777
778 async fn insert_edge_validated(
779 &self,
780 edge: WorkEdge,
781 event: WorkGraphEvent,
782 ) -> Result<WorkEdge, WorkGraphError> {
783 let mut guard = self.inner.write().await;
784 if guard.edges.iter().any(|existing| existing == &edge) {
785 return Err(duplicate_edge_error(&edge));
786 }
787 let existing_edges = guard
788 .edges
789 .iter()
790 .filter(|existing| {
791 existing.realm_id == edge.realm_id && existing.namespace == edge.namespace
792 })
793 .cloned()
794 .collect::<Vec<_>>();
795 let existing_items = guard
796 .items
797 .values()
798 .filter(|item| item.realm_id == edge.realm_id && item.namespace == edge.namespace)
799 .cloned()
800 .collect::<Vec<_>>();
801 WorkGraphMachine::validate_link(&edge, &existing_items, &existing_edges)?;
802 guard.edges.push(edge.clone());
803 guard.append_event(event);
804 Ok(edge)
805 }
806
807 async fn list_edges(
808 &self,
809 realm_id: &str,
810 namespace: &WorkNamespace,
811 ) -> Result<Vec<WorkEdge>, WorkGraphError> {
812 let guard = self.inner.read().await;
813 Ok(guard
814 .edges
815 .iter()
816 .filter(|edge| edge.realm_id == realm_id && edge.namespace == *namespace)
817 .cloned()
818 .collect())
819 }
820
821 async fn list_edges_bounded(
822 &self,
823 realm_id: &str,
824 namespace: &WorkNamespace,
825 limit: usize,
826 ) -> Result<Vec<WorkEdge>, WorkGraphError> {
827 let guard = self.inner.read().await;
828 Ok(guard
829 .edges
830 .iter()
831 .filter(|edge| edge.realm_id == realm_id && edge.namespace == *namespace)
832 .take(limit)
833 .cloned()
834 .collect())
835 }
836
837 async fn list_events(
838 &self,
839 filter: WorkGraphEventFilter,
840 ) -> Result<Vec<WorkGraphEvent>, WorkGraphError> {
841 let guard = self.inner.read().await;
842 let events = guard
843 .events
844 .iter()
845 .filter(|event| event_matches_filter(event, &filter))
846 .take(filter.limit.unwrap_or(usize::MAX))
847 .cloned()
848 .collect::<Vec<_>>();
849 Ok(events)
850 }
851
852 async fn latest_event_seq(
853 &self,
854 filter: WorkGraphEventFilter,
855 ) -> Result<Option<i64>, WorkGraphError> {
856 let guard = self.inner.read().await;
857 Ok(guard
858 .events
859 .iter()
860 .filter(|event| event_matches_filter(event, &filter))
861 .filter_map(|event| event.seq)
862 .max())
863 }
864}
865
866impl MemoryWorkGraphState {
867 fn append_event(&mut self, mut event: WorkGraphEvent) {
868 self.next_event_seq += 1;
869 event.seq = Some(self.next_event_seq);
870 self.events.push(event);
871 }
872}
873
874fn item_key(
875 realm_id: &str,
876 namespace: &WorkNamespace,
877 id: &WorkItemId,
878) -> (String, WorkNamespace, WorkItemId) {
879 (realm_id.to_string(), namespace.clone(), id.clone())
880}
881
882fn attention_key(
883 realm_id: &str,
884 namespace: &WorkNamespace,
885 id: &WorkAttentionBindingId,
886) -> (String, WorkNamespace, WorkAttentionBindingId) {
887 (realm_id.to_string(), namespace.clone(), id.clone())
888}
889
890fn item_matches_filter(item: &WorkItem, filter: &WorkItemFilter) -> bool {
891 if let Some(realm_id) = &filter.realm_id
892 && &item.realm_id != realm_id
893 {
894 return false;
895 }
896 if !filter.all_namespaces
897 && let Some(namespace) = &filter.namespace
898 && &item.namespace != namespace
899 {
900 return false;
901 }
902 if !filter.statuses.is_empty() && !filter.statuses.contains(&item.status) {
903 return false;
904 }
905 if !filter.include_terminal && WorkGraphMachine::classify_terminality(item).unwrap_or(true) {
911 return false;
912 }
913 filter
914 .labels
915 .iter()
916 .all(|label| item.labels.contains(label))
917}
918
919fn attention_matches_filter(binding: &WorkAttentionBinding, filter: &AttentionListRequest) -> bool {
920 if let Some(realm_id) = &filter.realm_id
921 && &binding.work_ref.realm_id != realm_id
922 {
923 return false;
924 }
925 if let Some(namespace) = &filter.namespace
926 && &binding.work_ref.namespace != namespace
927 {
928 return false;
929 }
930 if let Some(target) = &filter.target
931 && &binding.target != target
932 {
933 return false;
934 }
935 if let Some(status) = &filter.status
936 && !attention_status_matches_filter(&binding.status, status)
937 {
938 return false;
939 }
940 true
941}
942
943fn attention_status_matches_filter(
944 actual: &crate::types::WorkAttentionStatus,
945 filter: &crate::types::WorkAttentionStatus,
946) -> bool {
947 use crate::types::WorkAttentionStatus;
948
949 match (actual, filter) {
950 (WorkAttentionStatus::Active, WorkAttentionStatus::Active)
951 | (WorkAttentionStatus::Superseded, WorkAttentionStatus::Superseded)
952 | (WorkAttentionStatus::Stopped, WorkAttentionStatus::Stopped) => true,
953 (WorkAttentionStatus::Paused { .. }, WorkAttentionStatus::Paused { until: None }) => true,
954 (
955 WorkAttentionStatus::Paused {
956 until: Some(actual_until),
957 },
958 WorkAttentionStatus::Paused {
959 until: Some(filter_until),
960 },
961 ) => actual_until == filter_until,
962 _ => false,
963 }
964}
965
966fn event_matches_filter(event: &WorkGraphEvent, filter: &WorkGraphEventFilter) -> bool {
967 if let Some(after_seq) = filter.after_seq
968 && event.seq.unwrap_or_default() <= after_seq
969 {
970 return false;
971 }
972 if let Some(realm_id) = &filter.realm_id
973 && &event.realm_id != realm_id
974 {
975 return false;
976 }
977 if !filter.all_namespaces
978 && let Some(namespace) = &filter.namespace
979 && &event.namespace != namespace
980 {
981 return false;
982 }
983 true
984}
985
986#[cfg(not(target_arch = "wasm32"))]
987pub struct SqliteWorkGraphStore {
988 path: PathBuf,
989}
990
991#[cfg(not(target_arch = "wasm32"))]
992impl SqliteWorkGraphStore {
993 pub fn open(path: impl Into<PathBuf>) -> Result<Self, WorkGraphError> {
994 let store = Self { path: path.into() };
995 store.with_connection(|_| Ok(()))?;
997 Ok(store)
998 }
999
1000 pub fn path(&self) -> &Path {
1001 &self.path
1002 }
1003
1004 pub fn rebuild_projection_from_events(&self) -> Result<(), WorkGraphError> {
1005 self.with_connection(|conn| {
1006 let tx = conn
1010 .transaction_with_behavior(TransactionBehavior::Immediate)
1011 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1012 tx.execute("DELETE FROM workgraph_items", [])
1013 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1014 tx.execute("DELETE FROM workgraph_edges", [])
1015 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1016 tx.execute("DELETE FROM workgraph_attention", [])
1017 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1018
1019 let events = {
1020 let mut stmt = tx
1021 .prepare("SELECT event_json FROM workgraph_events ORDER BY seq ASC")
1022 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1023 let rows = stmt
1024 .query_map([], |row| row_json::<WorkGraphEvent>(row, 0))
1025 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1026 let mut events = Vec::new();
1027 for row in rows {
1028 events.push(row.map_err(|err| WorkGraphError::Store(err.to_string()))?);
1029 }
1030 events
1031 };
1032
1033 for event in events {
1034 replay_event_tx(&tx, &event)?;
1035 }
1036 normalize_attention_for_terminal_items_tx(&tx)?;
1037 tx.commit()
1038 .map_err(|err| WorkGraphError::Store(err.to_string()))
1039 })
1040 }
1041
1042 fn with_connection<T>(
1043 &self,
1044 f: impl FnOnce(&mut Connection) -> Result<T, WorkGraphError>,
1045 ) -> Result<T, WorkGraphError> {
1046 let _guard = meerkat_sqlite::OperationGuard::for_database(&self.path)
1049 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1050 let mut conn = meerkat_sqlite::open_with(
1051 &self.path,
1052 meerkat_sqlite::ConnectionProfile::PRIMARY,
1053 meerkat_sqlite::OpenOptions {
1054 schema_preflight: &[&WORKGRAPH_DOMAIN],
1055 ..Default::default()
1056 },
1057 )
1058 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1059 meerkat_sqlite::apply_domain_migrations(&mut conn, &WORKGRAPH_DOMAIN)
1060 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1061 f(&mut conn)
1062 }
1063}
1064
1065#[cfg(not(target_arch = "wasm32"))]
1066#[async_trait]
1067impl WorkGraphStore for SqliteWorkGraphStore {
1068 fn kind(&self) -> WorkGraphStoreKind {
1069 WorkGraphStoreKind::Sqlite
1070 }
1071
1072 async fn get_store_time_utc(&self) -> Result<DateTime<Utc>, WorkGraphError> {
1073 Ok(Utc::now())
1074 }
1075
1076 async fn insert_item(
1077 &self,
1078 item: WorkItem,
1079 event: WorkGraphEvent,
1080 ) -> Result<WorkItem, WorkGraphError> {
1081 WorkGraphMachine::validate_item_projection(&item)?;
1082 self.with_connection(|conn| {
1083 let tx = conn
1084 .transaction_with_behavior(TransactionBehavior::Immediate)
1085 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1086 insert_item_tx(&tx, &item)?;
1087 insert_event_tx(&tx, &event)?;
1088 tx.commit()
1089 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1090 Ok(item)
1091 })
1092 }
1093
1094 async fn update_item_cas(
1095 &self,
1096 item: WorkItem,
1097 expected_previous_revision: u64,
1098 event: WorkGraphEvent,
1099 ) -> Result<WorkItem, WorkGraphError> {
1100 WorkGraphMachine::validate_item_projection(&item)?;
1101 self.with_connection(|conn| {
1102 let tx = conn
1103 .transaction_with_behavior(TransactionBehavior::Immediate)
1104 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1105 let changed = update_item_tx(&tx, &item, expected_previous_revision)?;
1106 if changed == 0 {
1107 let actual = current_revision_tx(&tx, &item.realm_id, &item.namespace, &item.id)?;
1108 return match actual {
1109 Some(actual) => Err(WorkGraphError::StaleRevision {
1110 id: item.id,
1111 expected: expected_previous_revision,
1112 actual,
1113 }),
1114 None => Err(WorkGraphError::not_found(
1115 item.realm_id,
1116 item.namespace,
1117 item.id,
1118 )),
1119 };
1120 }
1121 insert_event_tx(&tx, &event)?;
1122 tx.commit()
1123 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1124 Ok(item)
1125 })
1126 }
1127
1128 async fn get_item(
1129 &self,
1130 realm_id: &str,
1131 namespace: &WorkNamespace,
1132 id: &WorkItemId,
1133 ) -> Result<Option<WorkItem>, WorkGraphError> {
1134 self.with_connection(|conn| select_item(conn, realm_id, namespace, id))
1135 }
1136
1137 async fn list_items(&self, filter: WorkItemFilter) -> Result<Vec<WorkItem>, WorkGraphError> {
1138 self.with_connection(|conn| list_sqlite_items(conn, &filter))
1139 }
1140
1141 async fn insert_goal(
1142 &self,
1143 item: WorkItem,
1144 item_event: WorkGraphEvent,
1145 attention: WorkAttentionBinding,
1146 attention_event: WorkGraphEvent,
1147 ) -> Result<(WorkItem, WorkAttentionBinding), WorkGraphError> {
1148 WorkGraphMachine::validate_item_projection(&item)?;
1149 self.with_connection(|conn| {
1150 let tx = conn
1151 .transaction_with_behavior(TransactionBehavior::Immediate)
1152 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1153 if let Some(occupant) = active_target_occupant_tx(&tx, &attention)? {
1154 return Err(active_target_conflict(&attention, &occupant));
1155 }
1156 insert_item_tx(&tx, &item)?;
1157 insert_attention_tx(&tx, &attention)?;
1158 insert_event_tx(&tx, &item_event)?;
1159 insert_event_tx(&tx, &attention_event)?;
1160 tx.commit()
1161 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1162 Ok((item, attention))
1163 })
1164 }
1165
1166 async fn update_attention_cas(
1167 &self,
1168 attention: WorkAttentionBinding,
1169 expected_previous_revision: u64,
1170 event: WorkGraphEvent,
1171 ) -> Result<WorkAttentionBinding, WorkGraphError> {
1172 self.with_connection(|conn| {
1173 let tx = conn
1174 .transaction_with_behavior(TransactionBehavior::Immediate)
1175 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1176 let changed = update_attention_tx(&tx, &attention, expected_previous_revision)?;
1177 if changed == 0 {
1178 let actual = current_attention_revision_tx(
1179 &tx,
1180 &attention.work_ref.realm_id,
1181 &attention.work_ref.namespace,
1182 &attention.binding_id,
1183 )?;
1184 return match actual {
1185 Some(actual) => Err(WorkGraphError::StaleRevision {
1186 id: attention.work_ref.item_id,
1187 expected: expected_previous_revision,
1188 actual,
1189 }),
1190 None => Err(WorkGraphError::not_found(
1191 attention.work_ref.realm_id,
1192 attention.work_ref.namespace,
1193 attention.work_ref.item_id,
1194 )),
1195 };
1196 }
1197 if let Some(occupant) = active_target_occupant_tx(&tx, &attention)? {
1201 return Err(active_target_conflict(&attention, &occupant));
1202 }
1203 insert_event_tx(&tx, &event)?;
1204 tx.commit()
1205 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1206 Ok(attention)
1207 })
1208 }
1209
1210 async fn reassign_attention_cas(
1211 &self,
1212 previous: WorkAttentionBinding,
1213 expected_previous_revision: u64,
1214 previous_event: WorkGraphEvent,
1215 replacement: WorkAttentionBinding,
1216 replacement_event: WorkGraphEvent,
1217 ) -> Result<(WorkAttentionBinding, WorkAttentionBinding), WorkGraphError> {
1218 self.with_connection(|conn| {
1219 let tx = conn
1220 .transaction_with_behavior(TransactionBehavior::Immediate)
1221 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1222 let changed = update_attention_tx(&tx, &previous, expected_previous_revision)?;
1223 if changed == 0 {
1224 let actual = current_attention_revision_tx(
1225 &tx,
1226 &previous.work_ref.realm_id,
1227 &previous.work_ref.namespace,
1228 &previous.binding_id,
1229 )?;
1230 return match actual {
1231 Some(actual) => Err(WorkGraphError::StaleRevision {
1232 id: previous.work_ref.item_id,
1233 expected: expected_previous_revision,
1234 actual,
1235 }),
1236 None => Err(WorkGraphError::attention_not_found(
1237 previous.work_ref.realm_id,
1238 previous.work_ref.namespace,
1239 previous.binding_id,
1240 )),
1241 };
1242 }
1243 if let Some(occupant) = active_target_occupant_tx(&tx, &replacement)? {
1247 return Err(active_target_conflict(&replacement, &occupant));
1248 }
1249 insert_attention_tx(&tx, &replacement)?;
1250 insert_event_tx(&tx, &previous_event)?;
1251 insert_event_tx(&tx, &replacement_event)?;
1252 tx.commit()
1253 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1254 Ok((previous, replacement))
1255 })
1256 }
1257
1258 async fn update_item_and_attention_cas(
1259 &self,
1260 item: WorkItem,
1261 expected_previous_revision: u64,
1262 item_event: WorkGraphEvent,
1263 attention_updates: Vec<(WorkAttentionBinding, u64, WorkGraphEvent)>,
1264 ) -> Result<WorkItem, WorkGraphError> {
1265 WorkGraphMachine::validate_item_projection(&item)?;
1266 self.with_connection(|conn| {
1267 let tx = conn
1268 .transaction_with_behavior(TransactionBehavior::Immediate)
1269 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1270 let changed = update_item_tx(&tx, &item, expected_previous_revision)?;
1271 if changed == 0 {
1272 let actual = current_revision_tx(&tx, &item.realm_id, &item.namespace, &item.id)?;
1273 return match actual {
1274 Some(actual) => Err(WorkGraphError::StaleRevision {
1275 id: item.id,
1276 expected: expected_previous_revision,
1277 actual,
1278 }),
1279 None => Err(WorkGraphError::not_found(
1280 item.realm_id,
1281 item.namespace,
1282 item.id,
1283 )),
1284 };
1285 }
1286 insert_event_tx(&tx, &item_event)?;
1287 for (attention, expected_revision, event) in &attention_updates {
1288 let changed = update_attention_tx(&tx, attention, *expected_revision)?;
1289 if changed == 0 {
1290 let actual = current_attention_revision_tx(
1291 &tx,
1292 &attention.work_ref.realm_id,
1293 &attention.work_ref.namespace,
1294 &attention.binding_id,
1295 )?;
1296 return match actual {
1297 Some(actual) => Err(WorkGraphError::StaleRevision {
1298 id: attention.work_ref.item_id.clone(),
1299 expected: *expected_revision,
1300 actual,
1301 }),
1302 None => Err(WorkGraphError::not_found(
1303 attention.work_ref.realm_id.clone(),
1304 attention.work_ref.namespace.clone(),
1305 attention.work_ref.item_id.clone(),
1306 )),
1307 };
1308 }
1309 if let Some(occupant) = active_target_occupant_tx(&tx, attention)? {
1312 return Err(active_target_conflict(attention, &occupant));
1313 }
1314 insert_event_tx(&tx, event)?;
1315 }
1316 tx.commit()
1317 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1318 Ok(item)
1319 })
1320 }
1321
1322 async fn get_attention(
1323 &self,
1324 realm_id: &str,
1325 namespace: &WorkNamespace,
1326 binding_id: &WorkAttentionBindingId,
1327 ) -> Result<Option<WorkAttentionBinding>, WorkGraphError> {
1328 self.with_connection(|conn| select_attention(conn, realm_id, namespace, binding_id))
1329 }
1330
1331 async fn list_attention(
1332 &self,
1333 filter: AttentionListRequest,
1334 ) -> Result<Vec<WorkAttentionBinding>, WorkGraphError> {
1335 self.with_connection(|conn| list_sqlite_attention(conn, &filter, None))
1336 }
1337
1338 async fn list_attention_bounded(
1339 &self,
1340 filter: AttentionListRequest,
1341 limit: usize,
1342 ) -> Result<Vec<WorkAttentionBinding>, WorkGraphError> {
1343 self.with_connection(|conn| list_sqlite_attention(conn, &filter, Some(limit)))
1344 }
1345
1346 async fn prune_terminal_attention(
1347 &self,
1348 filter: AttentionPruneRequest,
1349 ) -> Result<u64, WorkGraphError> {
1350 self.with_connection(|conn| {
1351 let tx = conn
1352 .transaction_with_behavior(TransactionBehavior::Immediate)
1353 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1354 let candidates: Vec<(String, String, String)> = {
1358 let mut stmt = tx
1359 .prepare(
1360 "SELECT realm_id, namespace, binding_id, attention_json
1361 FROM workgraph_attention
1362 WHERE status IN ('superseded', 'stopped') OR status IS NULL",
1363 )
1364 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1365 let rows = stmt
1366 .query_map([], |row| {
1367 Ok((
1368 row.get::<_, String>(0)?,
1369 row.get::<_, String>(1)?,
1370 row.get::<_, String>(2)?,
1371 row_json::<WorkAttentionBinding>(row, 3)?,
1372 ))
1373 })
1374 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1375 let mut candidates = Vec::new();
1376 for row in rows {
1377 let (realm_id, namespace, binding_id, binding) =
1378 row.map_err(|err| WorkGraphError::Store(err.to_string()))?;
1379 let in_scope = filter
1380 .realm_id
1381 .as_ref()
1382 .is_none_or(|realm| &binding.work_ref.realm_id == realm)
1383 && filter
1384 .namespace
1385 .as_ref()
1386 .is_none_or(|ns| &binding.work_ref.namespace == ns)
1387 && filter
1388 .updated_before
1389 .is_none_or(|updated_before| binding.updated_at < updated_before);
1390 if in_scope && binding.status.is_terminal() {
1391 candidates.push((realm_id, namespace, binding_id));
1392 }
1393 }
1394 candidates
1395 };
1396 let mut pruned = 0u64;
1397 for (realm_id, namespace, binding_id) in candidates {
1398 pruned += tx
1399 .execute(
1400 "DELETE FROM workgraph_attention
1401 WHERE realm_id = ?1 AND namespace = ?2 AND binding_id = ?3",
1402 params![realm_id, namespace, binding_id],
1403 )
1404 .map_err(|err| WorkGraphError::Store(err.to_string()))?
1405 as u64;
1406 }
1407 tx.commit()
1408 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1409 Ok(pruned)
1410 })
1411 }
1412
1413 async fn insert_edge(
1414 &self,
1415 edge: WorkEdge,
1416 event: WorkGraphEvent,
1417 ) -> Result<WorkEdge, WorkGraphError> {
1418 self.with_connection(|conn| {
1419 let tx = conn
1420 .transaction_with_behavior(TransactionBehavior::Immediate)
1421 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1422 insert_edge_tx(&tx, &edge)?;
1423 insert_event_tx(&tx, &event)?;
1424 tx.commit()
1425 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1426 Ok(edge)
1427 })
1428 }
1429
1430 async fn insert_edge_validated(
1431 &self,
1432 edge: WorkEdge,
1433 event: WorkGraphEvent,
1434 ) -> Result<WorkEdge, WorkGraphError> {
1435 self.with_connection(|conn| {
1436 let tx = conn
1437 .transaction_with_behavior(TransactionBehavior::Immediate)
1438 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1439 let existing_edges = list_sqlite_edges(&tx, &edge.realm_id, &edge.namespace, None)?;
1440 let existing_items = list_sqlite_items(
1441 &tx,
1442 &WorkItemFilter {
1443 realm_id: Some(edge.realm_id.clone()),
1444 namespace: Some(edge.namespace.clone()),
1445 include_terminal: true,
1446 ..WorkItemFilter::default()
1447 },
1448 )?;
1449 WorkGraphMachine::validate_link(&edge, &existing_items, &existing_edges)?;
1450 insert_edge_tx(&tx, &edge)?;
1451 insert_event_tx(&tx, &event)?;
1452 tx.commit()
1453 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1454 Ok(edge)
1455 })
1456 }
1457
1458 async fn list_edges(
1459 &self,
1460 realm_id: &str,
1461 namespace: &WorkNamespace,
1462 ) -> Result<Vec<WorkEdge>, WorkGraphError> {
1463 self.with_connection(|conn| list_sqlite_edges(conn, realm_id, namespace, None))
1464 }
1465
1466 async fn list_edges_bounded(
1467 &self,
1468 realm_id: &str,
1469 namespace: &WorkNamespace,
1470 limit: usize,
1471 ) -> Result<Vec<WorkEdge>, WorkGraphError> {
1472 self.with_connection(|conn| list_sqlite_edges(conn, realm_id, namespace, Some(limit)))
1473 }
1474
1475 async fn list_events(
1476 &self,
1477 filter: WorkGraphEventFilter,
1478 ) -> Result<Vec<WorkGraphEvent>, WorkGraphError> {
1479 self.with_connection(|conn| list_sqlite_events(conn, &filter))
1480 }
1481
1482 async fn latest_event_seq(
1483 &self,
1484 filter: WorkGraphEventFilter,
1485 ) -> Result<Option<i64>, WorkGraphError> {
1486 self.with_connection(|conn| latest_sqlite_event_seq(conn, &filter))
1487 }
1488}
1489
1490#[cfg(not(target_arch = "wasm32"))]
1491#[cfg(not(target_arch = "wasm32"))]
1498pub const WORKGRAPH_DOMAIN: meerkat_sqlite::SchemaDomain = meerkat_sqlite::SchemaDomain {
1499 name: "workgraph",
1500 migrations: &[
1501 meerkat_sqlite::Migration {
1502 version: 1,
1503 name: "base-schema",
1504 apply: migration_0001_workgraph_schema,
1505 },
1506 meerkat_sqlite::Migration {
1507 version: 2,
1508 name: "attention-query-columns",
1509 apply: migration_0002_attention_query_columns,
1510 },
1511 ],
1512 initialize_current: initialize_current_workgraph_schema,
1513 allowed_existing_versions: &[2],
1514 released_predecessors: &[],
1515 owned_objects: &[
1516 meerkat_sqlite::SchemaObject {
1517 kind: meerkat_sqlite::SchemaObjectKind::Table,
1518 name: "workgraph_items",
1519 },
1520 meerkat_sqlite::SchemaObject {
1521 kind: meerkat_sqlite::SchemaObjectKind::Index,
1522 name: "idx_workgraph_items_realm_namespace_updated",
1523 },
1524 meerkat_sqlite::SchemaObject {
1525 kind: meerkat_sqlite::SchemaObjectKind::Table,
1526 name: "workgraph_attention",
1527 },
1528 meerkat_sqlite::SchemaObject {
1529 kind: meerkat_sqlite::SchemaObjectKind::Index,
1530 name: "idx_workgraph_attention_realm_namespace_updated",
1531 },
1532 meerkat_sqlite::SchemaObject {
1533 kind: meerkat_sqlite::SchemaObjectKind::Index,
1534 name: "idx_workgraph_attention_scope_status",
1535 },
1536 meerkat_sqlite::SchemaObject {
1537 kind: meerkat_sqlite::SchemaObjectKind::Table,
1538 name: "workgraph_edges",
1539 },
1540 meerkat_sqlite::SchemaObject {
1541 kind: meerkat_sqlite::SchemaObjectKind::Table,
1542 name: "workgraph_events",
1543 },
1544 meerkat_sqlite::SchemaObject {
1545 kind: meerkat_sqlite::SchemaObjectKind::Index,
1546 name: "idx_workgraph_events_realm_namespace_seq",
1547 },
1548 ],
1549 retired_objects: &[],
1550};
1551
1552#[cfg(not(target_arch = "wasm32"))]
1553fn initialize_current_workgraph_schema(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
1554 migration_0001_workgraph_schema(tx)?;
1555 migration_0002_attention_query_columns(tx)
1556}
1557
1558#[cfg(not(target_arch = "wasm32"))]
1559fn migration_0001_workgraph_schema(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
1560 tx.execute_batch(
1561 r"
1562 CREATE TABLE IF NOT EXISTS workgraph_items (
1563 realm_id TEXT NOT NULL,
1564 namespace TEXT NOT NULL,
1565 item_id TEXT NOT NULL,
1566 revision INTEGER NOT NULL,
1567 updated_at_utc TEXT NOT NULL,
1568 item_json TEXT NOT NULL,
1569 PRIMARY KEY (realm_id, namespace, item_id)
1570 );
1571 CREATE INDEX IF NOT EXISTS idx_workgraph_items_realm_namespace_updated
1572 ON workgraph_items (realm_id, namespace, updated_at_utc);
1573
1574 CREATE TABLE IF NOT EXISTS workgraph_attention (
1575 realm_id TEXT NOT NULL,
1576 namespace TEXT NOT NULL,
1577 binding_id TEXT NOT NULL,
1578 revision INTEGER NOT NULL,
1579 updated_at_utc TEXT NOT NULL,
1580 attention_json TEXT NOT NULL,
1581 PRIMARY KEY (realm_id, namespace, binding_id)
1582 );
1583 CREATE INDEX IF NOT EXISTS idx_workgraph_attention_realm_namespace_updated
1584 ON workgraph_attention (realm_id, namespace, updated_at_utc);
1585
1586 CREATE TABLE IF NOT EXISTS workgraph_edges (
1587 realm_id TEXT NOT NULL,
1588 namespace TEXT NOT NULL,
1589 edge_kind TEXT NOT NULL,
1590 from_id TEXT NOT NULL,
1591 to_id TEXT NOT NULL,
1592 edge_json TEXT NOT NULL,
1593 PRIMARY KEY (realm_id, namespace, edge_kind, from_id, to_id)
1594 );
1595
1596 CREATE TABLE IF NOT EXISTS workgraph_events (
1597 seq INTEGER PRIMARY KEY AUTOINCREMENT,
1598 realm_id TEXT NOT NULL,
1599 namespace TEXT NOT NULL,
1600 item_id TEXT,
1601 event_kind TEXT NOT NULL,
1602 at_utc TEXT NOT NULL,
1603 event_json TEXT NOT NULL
1604 );
1605 CREATE INDEX IF NOT EXISTS idx_workgraph_events_realm_namespace_seq
1606 ON workgraph_events (realm_id, namespace, seq);
1607 ",
1608 )
1609}
1610
1611#[cfg(not(target_arch = "wasm32"))]
1612fn insert_item_tx(tx: &Transaction<'_>, item: &WorkItem) -> Result<(), WorkGraphError> {
1613 let json = serde_json::to_string(item).map_err(|err| WorkGraphError::Store(err.to_string()))?;
1614 tx.execute(
1615 "INSERT INTO workgraph_items (realm_id, namespace, item_id, revision, updated_at_utc, item_json)
1616 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1617 params![
1618 item.realm_id,
1619 item.namespace.as_str(),
1620 item.id.as_str(),
1621 item.revision,
1622 item.updated_at.to_rfc3339(),
1623 json,
1624 ],
1625 )
1626 .map_err(|err| map_sqlite_insert_item_error(err, item))?;
1627 Ok(())
1628}
1629
1630#[cfg(not(target_arch = "wasm32"))]
1631fn update_item_tx(
1632 tx: &Transaction<'_>,
1633 item: &WorkItem,
1634 expected_previous_revision: u64,
1635) -> Result<usize, WorkGraphError> {
1636 let json = serde_json::to_string(item).map_err(|err| WorkGraphError::Store(err.to_string()))?;
1637 tx.execute(
1638 "UPDATE workgraph_items
1639 SET revision = ?4, updated_at_utc = ?5, item_json = ?6
1640 WHERE realm_id = ?1 AND namespace = ?2 AND item_id = ?3 AND revision = ?7",
1641 params![
1642 item.realm_id,
1643 item.namespace.as_str(),
1644 item.id.as_str(),
1645 item.revision,
1646 item.updated_at.to_rfc3339(),
1647 json,
1648 expected_previous_revision,
1649 ],
1650 )
1651 .map_err(|err| WorkGraphError::Store(err.to_string()))
1652}
1653
1654#[cfg(not(target_arch = "wasm32"))]
1655fn upsert_item_tx(tx: &Transaction<'_>, item: &WorkItem) -> Result<(), WorkGraphError> {
1656 let json = serde_json::to_string(item).map_err(|err| WorkGraphError::Store(err.to_string()))?;
1657 tx.execute(
1658 "INSERT INTO workgraph_items
1659 (realm_id, namespace, item_id, revision, updated_at_utc, item_json)
1660 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
1661 ON CONFLICT(realm_id, namespace, item_id) DO UPDATE SET
1662 revision = excluded.revision,
1663 updated_at_utc = excluded.updated_at_utc,
1664 item_json = excluded.item_json",
1665 params![
1666 item.realm_id,
1667 item.namespace.as_str(),
1668 item.id.as_str(),
1669 item.revision,
1670 item.updated_at.to_rfc3339(),
1671 json,
1672 ],
1673 )
1674 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1675 Ok(())
1676}
1677
1678#[cfg(not(target_arch = "wasm32"))]
1679fn map_sqlite_insert_item_error(err: Error, item: &WorkItem) -> WorkGraphError {
1680 if sqlite_constraint_violation(&err) {
1681 return WorkGraphError::Conflict(format!("work item {} already exists", item.id));
1682 }
1683 WorkGraphError::Store(err.to_string())
1684}
1685
1686#[cfg(not(target_arch = "wasm32"))]
1687fn map_sqlite_insert_attention_error(
1688 err: Error,
1689 attention: &WorkAttentionBinding,
1690) -> WorkGraphError {
1691 if sqlite_constraint_violation(&err) {
1692 return WorkGraphError::Conflict(format!(
1693 "work attention binding {} already exists",
1694 attention.binding_id
1695 ));
1696 }
1697 WorkGraphError::Store(err.to_string())
1698}
1699
1700#[cfg(not(target_arch = "wasm32"))]
1701fn sqlite_constraint_violation(err: &Error) -> bool {
1702 matches!(
1703 err,
1704 Error::SqliteFailure(sqlite_error, _)
1705 if sqlite_error.code == ErrorCode::ConstraintViolation
1706 )
1707}
1708
1709#[cfg(not(target_arch = "wasm32"))]
1710fn current_revision_tx(
1711 tx: &Transaction<'_>,
1712 realm_id: &str,
1713 namespace: &WorkNamespace,
1714 id: &WorkItemId,
1715) -> Result<Option<u64>, WorkGraphError> {
1716 tx.query_row(
1717 "SELECT revision FROM workgraph_items WHERE realm_id = ?1 AND namespace = ?2 AND item_id = ?3",
1718 params![realm_id, namespace.as_str(), id.as_str()],
1719 |row| row.get::<_, u64>(0),
1720 )
1721 .optional()
1722 .map_err(|err| WorkGraphError::Store(err.to_string()))
1723}
1724
1725#[cfg(not(target_arch = "wasm32"))]
1726fn insert_attention_tx(
1727 tx: &Transaction<'_>,
1728 attention: &WorkAttentionBinding,
1729) -> Result<(), WorkGraphError> {
1730 let json =
1731 serde_json::to_string(attention).map_err(|err| WorkGraphError::Store(err.to_string()))?;
1732 tx.execute(
1733 "INSERT INTO workgraph_attention
1734 (realm_id, namespace, binding_id, revision, updated_at_utc, attention_json,
1735 status, target_key)
1736 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1737 params![
1738 attention.work_ref.realm_id,
1739 attention.work_ref.namespace.as_str(),
1740 attention.binding_id.as_str(),
1741 attention.machine_state.revision,
1742 attention.updated_at.to_rfc3339(),
1743 json,
1744 attention.status.status_key(),
1745 attention.target.target_key(),
1746 ],
1747 )
1748 .map_err(|err| map_sqlite_insert_attention_error(err, attention))?;
1749 Ok(())
1750}
1751
1752#[cfg(not(target_arch = "wasm32"))]
1753fn update_attention_tx(
1754 tx: &Transaction<'_>,
1755 attention: &WorkAttentionBinding,
1756 expected_previous_revision: u64,
1757) -> Result<usize, WorkGraphError> {
1758 let json =
1759 serde_json::to_string(attention).map_err(|err| WorkGraphError::Store(err.to_string()))?;
1760 tx.execute(
1761 "UPDATE workgraph_attention
1762 SET revision = ?4, updated_at_utc = ?5, attention_json = ?6,
1763 status = ?8, target_key = ?9
1764 WHERE realm_id = ?1 AND namespace = ?2 AND binding_id = ?3 AND revision = ?7",
1765 params![
1766 attention.work_ref.realm_id,
1767 attention.work_ref.namespace.as_str(),
1768 attention.binding_id.as_str(),
1769 attention.machine_state.revision,
1770 attention.updated_at.to_rfc3339(),
1771 json,
1772 expected_previous_revision,
1773 attention.status.status_key(),
1774 attention.target.target_key(),
1775 ],
1776 )
1777 .map_err(|err| WorkGraphError::Store(err.to_string()))
1778}
1779
1780#[cfg(not(target_arch = "wasm32"))]
1787fn migration_0002_attention_query_columns(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
1788 let existing: Vec<String> = tx
1789 .prepare("PRAGMA table_info(workgraph_attention)")?
1790 .query_map([], |row| row.get::<_, String>(1))?
1791 .collect::<Result<_, _>>()?;
1792 if !existing.iter().any(|name| name == "status") {
1793 tx.execute("ALTER TABLE workgraph_attention ADD COLUMN status TEXT", [])?;
1794 }
1795 if !existing.iter().any(|name| name == "target_key") {
1796 tx.execute(
1797 "ALTER TABLE workgraph_attention ADD COLUMN target_key TEXT",
1798 [],
1799 )?;
1800 }
1801 let backfill: Vec<(String, String, String, WorkAttentionBinding)> = {
1802 let mut stmt = tx.prepare(
1803 "SELECT realm_id, namespace, binding_id, attention_json
1804 FROM workgraph_attention
1805 WHERE status IS NULL OR target_key IS NULL",
1806 )?;
1807 let rows = stmt.query_map([], |row| {
1808 Ok((
1809 row.get::<_, String>(0)?,
1810 row.get::<_, String>(1)?,
1811 row.get::<_, String>(2)?,
1812 row_json::<WorkAttentionBinding>(row, 3)?,
1813 ))
1814 })?;
1815 rows.collect::<Result<_, _>>()?
1816 };
1817 for (realm_id, namespace, binding_id, binding) in backfill {
1818 tx.execute(
1819 "UPDATE workgraph_attention
1820 SET status = ?4, target_key = ?5
1821 WHERE realm_id = ?1 AND namespace = ?2 AND binding_id = ?3",
1822 params![
1823 realm_id,
1824 namespace,
1825 binding_id,
1826 binding.status.status_key(),
1827 binding.target.target_key(),
1828 ],
1829 )?;
1830 }
1831 tx.execute(
1832 "CREATE INDEX IF NOT EXISTS idx_workgraph_attention_scope_status
1833 ON workgraph_attention (realm_id, namespace, status, target_key)",
1834 [],
1835 )?;
1836 Ok(())
1837}
1838
1839#[cfg(not(target_arch = "wasm32"))]
1845fn active_target_occupant_tx(
1846 tx: &Transaction<'_>,
1847 candidate: &WorkAttentionBinding,
1848) -> Result<Option<WorkAttentionBindingId>, WorkGraphError> {
1849 if !matches!(candidate.status, WorkAttentionStatus::Active) {
1850 return Ok(None);
1851 }
1852 let target_key = candidate.target.target_key();
1853 let mut stmt = tx
1854 .prepare(
1855 "SELECT binding_id, attention_json FROM workgraph_attention
1856 WHERE realm_id = ?1 AND namespace = ?2 AND binding_id != ?3
1857 AND (status = 'active' OR status IS NULL)
1858 AND (target_key = ?4 OR target_key IS NULL)",
1859 )
1860 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1861 let rows = stmt
1862 .query_map(
1863 params![
1864 candidate.work_ref.realm_id,
1865 candidate.work_ref.namespace.as_str(),
1866 candidate.binding_id.as_str(),
1867 target_key,
1868 ],
1869 |row| {
1870 Ok((
1871 row.get::<_, String>(0)?,
1872 row_json::<WorkAttentionBinding>(row, 1)?,
1873 ))
1874 },
1875 )
1876 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1877 for row in rows {
1878 let (_, binding) = row.map_err(|err| WorkGraphError::Store(err.to_string()))?;
1879 if matches!(binding.status, WorkAttentionStatus::Active)
1880 && binding.target.target_key() == target_key
1881 {
1882 return Ok(Some(binding.binding_id));
1883 }
1884 }
1885 Ok(None)
1886}
1887
1888fn active_target_conflict(
1891 candidate: &WorkAttentionBinding,
1892 occupant: &WorkAttentionBindingId,
1893) -> WorkGraphError {
1894 WorkGraphError::Conflict(format!(
1895 "active attention binding {occupant} already targets {} in {}/{}",
1896 candidate.target.target_key(),
1897 candidate.work_ref.realm_id,
1898 candidate.work_ref.namespace.as_str(),
1899 ))
1900}
1901
1902fn active_target_occupant_in<'a>(
1905 bindings: impl Iterator<Item = &'a WorkAttentionBinding>,
1906 candidate: &WorkAttentionBinding,
1907) -> Option<WorkAttentionBindingId> {
1908 if !matches!(candidate.status, WorkAttentionStatus::Active) {
1909 return None;
1910 }
1911 let target_key = candidate.target.target_key();
1912 bindings
1913 .filter(|binding| {
1914 binding.binding_id != candidate.binding_id
1915 && binding.work_ref.realm_id == candidate.work_ref.realm_id
1916 && binding.work_ref.namespace == candidate.work_ref.namespace
1917 && matches!(binding.status, WorkAttentionStatus::Active)
1918 && binding.target.target_key() == target_key
1919 })
1920 .map(|binding| binding.binding_id.clone())
1921 .next()
1922}
1923
1924#[cfg(not(target_arch = "wasm32"))]
1925fn upsert_attention_tx(
1926 tx: &Transaction<'_>,
1927 attention: &WorkAttentionBinding,
1928) -> Result<(), WorkGraphError> {
1929 let json =
1930 serde_json::to_string(attention).map_err(|err| WorkGraphError::Store(err.to_string()))?;
1931 tx.execute(
1932 "INSERT INTO workgraph_attention
1933 (realm_id, namespace, binding_id, revision, updated_at_utc, attention_json,
1934 status, target_key)
1935 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
1936 ON CONFLICT(realm_id, namespace, binding_id) DO UPDATE SET
1937 revision = excluded.revision,
1938 updated_at_utc = excluded.updated_at_utc,
1939 attention_json = excluded.attention_json,
1940 status = excluded.status,
1941 target_key = excluded.target_key",
1942 params![
1943 attention.work_ref.realm_id,
1944 attention.work_ref.namespace.as_str(),
1945 attention.binding_id.as_str(),
1946 attention.machine_state.revision,
1947 attention.updated_at.to_rfc3339(),
1948 json,
1949 attention.status.status_key(),
1950 attention.target.target_key(),
1951 ],
1952 )
1953 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
1954 Ok(())
1955}
1956
1957#[cfg(not(target_arch = "wasm32"))]
1958fn current_attention_revision_tx(
1959 tx: &Transaction<'_>,
1960 realm_id: &str,
1961 namespace: &WorkNamespace,
1962 binding_id: &WorkAttentionBindingId,
1963) -> Result<Option<u64>, WorkGraphError> {
1964 tx.query_row(
1965 "SELECT revision FROM workgraph_attention
1966 WHERE realm_id = ?1 AND namespace = ?2 AND binding_id = ?3",
1967 params![realm_id, namespace.as_str(), binding_id.as_str()],
1968 |row| row.get::<_, u64>(0),
1969 )
1970 .optional()
1971 .map_err(|err| WorkGraphError::Store(err.to_string()))
1972}
1973
1974#[cfg(not(target_arch = "wasm32"))]
1975fn insert_edge_tx(tx: &Transaction<'_>, edge: &WorkEdge) -> Result<(), WorkGraphError> {
1976 let json = serde_json::to_string(edge).map_err(|err| WorkGraphError::Store(err.to_string()))?;
1977 tx.execute(
1978 "INSERT INTO workgraph_edges
1979 (realm_id, namespace, edge_kind, from_id, to_id, edge_json)
1980 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1981 params![
1982 edge.realm_id,
1983 edge.namespace.as_str(),
1984 format!("{:?}", edge.kind),
1985 edge.from_id.as_str(),
1986 edge.to_id.as_str(),
1987 json,
1988 ],
1989 )
1990 .map_err(|err| map_sqlite_insert_edge_error(err, edge))?;
1991 Ok(())
1992}
1993
1994fn duplicate_edge_error(edge: &WorkEdge) -> WorkGraphError {
1995 WorkGraphError::Conflict(format!(
1996 "work edge {:?} {} -> {} already exists",
1997 edge.kind, edge.from_id, edge.to_id
1998 ))
1999}
2000
2001#[cfg(not(target_arch = "wasm32"))]
2002fn map_sqlite_insert_edge_error(err: rusqlite::Error, edge: &WorkEdge) -> WorkGraphError {
2003 match err {
2004 rusqlite::Error::SqliteFailure(failure, _)
2005 if failure.code == ErrorCode::ConstraintViolation =>
2006 {
2007 duplicate_edge_error(edge)
2008 }
2009 err => WorkGraphError::Store(err.to_string()),
2010 }
2011}
2012
2013#[cfg(not(target_arch = "wasm32"))]
2014fn insert_event_tx(tx: &Transaction<'_>, event: &WorkGraphEvent) -> Result<(), WorkGraphError> {
2015 let json =
2016 serde_json::to_string(event).map_err(|err| WorkGraphError::Store(err.to_string()))?;
2017 tx.execute(
2018 "INSERT INTO workgraph_events
2019 (realm_id, namespace, item_id, event_kind, at_utc, event_json)
2020 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
2021 params![
2022 event.realm_id,
2023 event.namespace.as_str(),
2024 event.item_id.as_ref().map(WorkItemId::as_str),
2025 format!("{:?}", event.kind),
2026 event.at.to_rfc3339(),
2027 json,
2028 ],
2029 )
2030 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
2031 Ok(())
2032}
2033
2034#[cfg(not(target_arch = "wasm32"))]
2035fn select_item(
2036 conn: &Connection,
2037 realm_id: &str,
2038 namespace: &WorkNamespace,
2039 id: &WorkItemId,
2040) -> Result<Option<WorkItem>, WorkGraphError> {
2041 conn.query_row(
2042 "SELECT item_json FROM workgraph_items WHERE realm_id = ?1 AND namespace = ?2 AND item_id = ?3",
2043 params![realm_id, namespace.as_str(), id.as_str()],
2044 |row| row_json(row, 0),
2045 )
2046 .optional()
2047 .map_err(|err| WorkGraphError::Store(err.to_string()))
2048}
2049
2050#[cfg(not(target_arch = "wasm32"))]
2051fn list_sqlite_items(
2052 conn: &Connection,
2053 filter: &WorkItemFilter,
2054) -> Result<Vec<WorkItem>, WorkGraphError> {
2055 let mut stmt = conn
2056 .prepare("SELECT item_json FROM workgraph_items ORDER BY updated_at_utc ASC, item_id ASC")
2057 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
2058 let rows = stmt
2059 .query_map([], |row| row_json::<WorkItem>(row, 0))
2060 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
2061 let mut items = Vec::new();
2062 for row in rows {
2063 let item = row.map_err(|err| WorkGraphError::Store(err.to_string()))?;
2064 if item_matches_filter(&item, filter) {
2065 items.push(item);
2066 if filter.limit.is_some_and(|limit| items.len() >= limit) {
2067 break;
2068 }
2069 }
2070 }
2071 Ok(items)
2072}
2073
2074#[cfg(not(target_arch = "wasm32"))]
2075fn select_attention(
2076 conn: &Connection,
2077 realm_id: &str,
2078 namespace: &WorkNamespace,
2079 binding_id: &WorkAttentionBindingId,
2080) -> Result<Option<WorkAttentionBinding>, WorkGraphError> {
2081 conn.query_row(
2082 "SELECT attention_json FROM workgraph_attention
2083 WHERE realm_id = ?1 AND namespace = ?2 AND binding_id = ?3",
2084 params![realm_id, namespace.as_str(), binding_id.as_str()],
2085 |row| row_json(row, 0),
2086 )
2087 .optional()
2088 .map_err(|err| WorkGraphError::Store(err.to_string()))
2089}
2090
2091#[cfg(not(target_arch = "wasm32"))]
2092fn list_sqlite_attention(
2093 conn: &Connection,
2094 filter: &AttentionListRequest,
2095 limit: Option<usize>,
2096) -> Result<Vec<WorkAttentionBinding>, WorkGraphError> {
2097 if limit == Some(0) {
2098 return Ok(Vec::new());
2099 }
2100 let mut clauses: Vec<String> = Vec::new();
2105 let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
2106 if let Some(realm_id) = &filter.realm_id {
2107 params.push(Box::new(realm_id.clone()));
2108 clauses.push(format!("realm_id = ?{}", params.len()));
2109 }
2110 if let Some(namespace) = &filter.namespace {
2111 params.push(Box::new(namespace.as_str().to_string()));
2112 clauses.push(format!("namespace = ?{}", params.len()));
2113 }
2114 if let Some(status) = &filter.status {
2115 params.push(Box::new(status.status_key().to_string()));
2116 clauses.push(format!("(status = ?{} OR status IS NULL)", params.len()));
2117 }
2118 if let Some(target) = &filter.target {
2119 params.push(Box::new(target.target_key()));
2120 clauses.push(format!(
2121 "(target_key = ?{} OR target_key IS NULL)",
2122 params.len()
2123 ));
2124 }
2125 let where_clause = if clauses.is_empty() {
2126 String::new()
2127 } else {
2128 format!(" WHERE {}", clauses.join(" AND "))
2129 };
2130 let sql = format!(
2131 "SELECT attention_json FROM workgraph_attention{where_clause}
2132 ORDER BY updated_at_utc ASC, binding_id ASC"
2133 );
2134 let mut stmt = conn
2135 .prepare(&sql)
2136 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
2137 let rows = stmt
2138 .query_map(rusqlite::params_from_iter(params.iter()), |row| {
2139 row_json::<WorkAttentionBinding>(row, 0)
2140 })
2141 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
2142 let mut bindings = Vec::new();
2143 for row in rows {
2144 let binding = row.map_err(|err| WorkGraphError::Store(err.to_string()))?;
2145 if attention_matches_filter(&binding, filter) {
2146 bindings.push(binding);
2147 if limit.is_some_and(|limit| bindings.len() >= limit) {
2148 break;
2149 }
2150 }
2151 }
2152 Ok(bindings)
2153}
2154
2155#[cfg(not(target_arch = "wasm32"))]
2156fn list_sqlite_edges(
2157 conn: &Connection,
2158 realm_id: &str,
2159 namespace: &WorkNamespace,
2160 limit: Option<usize>,
2161) -> Result<Vec<WorkEdge>, WorkGraphError> {
2162 if limit == Some(0) {
2163 return Ok(Vec::new());
2164 }
2165 let mut stmt = conn
2166 .prepare(
2167 "SELECT edge_json FROM workgraph_edges
2168 WHERE realm_id = ?1 AND namespace = ?2
2169 ORDER BY edge_kind ASC, from_id ASC, to_id ASC",
2170 )
2171 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
2172 let rows = stmt
2173 .query_map(params![realm_id, namespace.as_str()], |row| {
2174 row_json::<WorkEdge>(row, 0)
2175 })
2176 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
2177 let mut edges = Vec::new();
2178 for row in rows {
2179 edges.push(row.map_err(|err| WorkGraphError::Store(err.to_string()))?);
2180 if limit.is_some_and(|limit| edges.len() >= limit) {
2181 break;
2182 }
2183 }
2184 Ok(edges)
2185}
2186
2187#[cfg(not(target_arch = "wasm32"))]
2188fn list_sqlite_events(
2189 conn: &Connection,
2190 filter: &WorkGraphEventFilter,
2191) -> Result<Vec<WorkGraphEvent>, WorkGraphError> {
2192 let mut stmt = conn
2193 .prepare("SELECT seq, event_json FROM workgraph_events ORDER BY seq ASC")
2194 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
2195 let rows = stmt
2196 .query_map([], |row| {
2197 let seq = row.get::<_, i64>(0)?;
2198 let mut event = row_json::<WorkGraphEvent>(row, 1)?;
2199 event.seq = Some(seq);
2200 Ok(event)
2201 })
2202 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
2203 let mut events = Vec::new();
2204 for row in rows {
2205 let event = row.map_err(|err| WorkGraphError::Store(err.to_string()))?;
2206 if event_matches_filter(&event, filter) {
2207 events.push(event);
2208 if filter.limit.is_some_and(|limit| events.len() >= limit) {
2209 break;
2210 }
2211 }
2212 }
2213 Ok(events)
2214}
2215
2216#[cfg(not(target_arch = "wasm32"))]
2217fn latest_sqlite_event_seq(
2218 conn: &Connection,
2219 filter: &WorkGraphEventFilter,
2220) -> Result<Option<i64>, WorkGraphError> {
2221 let mut clauses: Vec<String> = Vec::new();
2222 let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
2223 if let Some(realm_id) = &filter.realm_id {
2224 params.push(Box::new(realm_id.clone()));
2225 clauses.push(format!("realm_id = ?{}", params.len()));
2226 }
2227 if !filter.all_namespaces
2228 && let Some(namespace) = &filter.namespace
2229 {
2230 params.push(Box::new(namespace.as_str().to_string()));
2231 clauses.push(format!("namespace = ?{}", params.len()));
2232 }
2233 if let Some(after_seq) = filter.after_seq {
2234 params.push(Box::new(after_seq));
2235 clauses.push(format!("seq > ?{}", params.len()));
2236 }
2237 let where_clause = if clauses.is_empty() {
2238 String::new()
2239 } else {
2240 format!(" WHERE {}", clauses.join(" AND "))
2241 };
2242 conn.query_row(
2243 &format!("SELECT MAX(seq) FROM workgraph_events{where_clause}"),
2244 rusqlite::params_from_iter(params.iter()),
2245 |row| row.get::<_, Option<i64>>(0),
2246 )
2247 .map_err(|error| WorkGraphError::Store(error.to_string()))
2248}
2249
2250#[cfg(not(target_arch = "wasm32"))]
2251fn replay_event_tx(tx: &Transaction<'_>, event: &WorkGraphEvent) -> Result<(), WorkGraphError> {
2252 match event.kind {
2253 WorkGraphEventKind::Linked => {
2254 let edge = payload_field::<WorkEdge>(event, "edge")?;
2255 insert_edge_tx(tx, &edge)
2256 }
2257 WorkGraphEventKind::AttentionCreated | WorkGraphEventKind::AttentionUpdated => {
2258 let attention = payload_field::<WorkAttentionBinding>(event, "attention")?;
2259 upsert_attention_tx(tx, &attention)
2260 }
2261 WorkGraphEventKind::Created
2262 | WorkGraphEventKind::Updated
2263 | WorkGraphEventKind::Claimed
2264 | WorkGraphEventKind::Released
2265 | WorkGraphEventKind::Blocked
2266 | WorkGraphEventKind::Closed
2267 | WorkGraphEventKind::EvidenceAdded => {
2268 let item = payload_field::<WorkItem>(event, "item")?;
2269 upsert_item_tx(tx, &item)
2270 }
2271 }
2272}
2273
2274#[cfg(not(target_arch = "wasm32"))]
2275fn normalize_attention_for_terminal_items_tx(tx: &Transaction<'_>) -> Result<(), WorkGraphError> {
2276 let bindings = {
2277 let mut stmt = tx
2278 .prepare("SELECT attention_json FROM workgraph_attention")
2279 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
2280 let rows = stmt
2281 .query_map([], |row| row_json::<WorkAttentionBinding>(row, 0))
2282 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
2283 let mut bindings = Vec::new();
2284 for row in rows {
2285 bindings.push(row.map_err(|err| WorkGraphError::Store(err.to_string()))?);
2286 }
2287 bindings
2288 };
2289
2290 for binding in bindings {
2291 if matches!(
2292 binding.status,
2293 WorkAttentionStatus::Stopped | WorkAttentionStatus::Superseded
2294 ) {
2295 continue;
2296 }
2297 let item = tx
2298 .query_row(
2299 "SELECT item_json FROM workgraph_items
2300 WHERE realm_id = ?1 AND namespace = ?2 AND item_id = ?3",
2301 params![
2302 binding.work_ref.realm_id,
2303 binding.work_ref.namespace.as_str(),
2304 binding.work_ref.item_id.as_str(),
2305 ],
2306 |row| row_json::<WorkItem>(row, 0),
2307 )
2308 .optional()
2309 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
2310 let Some(item) = item else {
2311 continue;
2312 };
2313 if WorkGraphMachine::classify_terminality(&item)? {
2316 let expected_revision = binding.machine_state.revision;
2317 let stopped = WorkAttentionMachine::stop(binding, expected_revision, item.updated_at)?;
2318 upsert_attention_tx(tx, &stopped)?;
2319 }
2320 }
2321 Ok(())
2322}
2323
2324#[cfg(not(target_arch = "wasm32"))]
2325fn payload_field<T: serde::de::DeserializeOwned>(
2326 event: &WorkGraphEvent,
2327 field: &str,
2328) -> Result<T, WorkGraphError> {
2329 let value = event.payload.get(field).ok_or_else(|| {
2330 WorkGraphError::Store(format!(
2331 "workgraph event {:?} missing payload field `{field}`",
2332 event.kind
2333 ))
2334 })?;
2335 serde_json::from_value(value.clone()).map_err(|err| WorkGraphError::Store(err.to_string()))
2336}
2337
2338#[cfg(not(target_arch = "wasm32"))]
2339fn row_json<T: serde::de::DeserializeOwned>(
2340 row: &rusqlite::Row<'_>,
2341 index: usize,
2342) -> rusqlite::Result<T> {
2343 let json = row.get::<_, String>(index)?;
2344 serde_json::from_str(&json).map_err(|err| {
2345 rusqlite::Error::FromSqlConversionFailure(index, rusqlite::types::Type::Text, Box::new(err))
2346 })
2347}
2348
2349#[cfg(test)]
2350#[allow(clippy::expect_used, clippy::unwrap_used)]
2351mod tests {
2352 use std::collections::BTreeSet;
2353
2354 use chrono::Utc;
2355 use serde_json::json;
2356
2357 use crate::types::WorkEdge;
2358 use crate::{
2359 AttentionDelegatedAuthority, AttentionProjectionPolicy, CreateWorkItemRequest,
2360 GoalAttentionTarget, GoalCreateRequest, GoalRequestCloseRequest, GoalTerminalStatus,
2361 LinkWorkItemsRequest, MemoryWorkGraphStore, WorkAttentionMode, WorkAttentionStatus,
2362 WorkCompletionPolicy, WorkEdgeKind, WorkGraphError, WorkGraphEvent, WorkGraphEventFilter,
2363 WorkGraphEventKind, WorkGraphService, WorkGraphStore, WorkItemFilter, WorkItemId,
2364 WorkNamespace,
2365 };
2366
2367 fn test_edge() -> WorkEdge {
2368 WorkEdge {
2369 realm_id: "realm".to_string(),
2370 namespace: WorkNamespace::default(),
2371 kind: WorkEdgeKind::Blocks,
2372 from_id: WorkItemId::generated(),
2373 to_id: WorkItemId::generated(),
2374 created_at: Utc::now(),
2375 }
2376 }
2377
2378 fn link_event(edge: &WorkEdge) -> WorkGraphEvent {
2379 WorkGraphEvent::graph(
2380 edge.realm_id.clone(),
2381 edge.namespace.clone(),
2382 WorkGraphEventKind::Linked,
2383 edge.created_at,
2384 json!({ "edge": edge }),
2385 )
2386 }
2387
2388 #[tokio::test]
2389 async fn memory_store_namespace_filters_do_not_leak() {
2390 let store = std::sync::Arc::new(MemoryWorkGraphStore::new());
2391 let default_service =
2392 WorkGraphService::with_scope(store.clone(), "realm", WorkNamespace::default());
2393 let other_service = WorkGraphService::with_scope(
2394 store.clone(),
2395 "realm",
2396 WorkNamespace::new("other").expect("namespace"),
2397 );
2398 default_service
2399 .create(CreateWorkItemRequest {
2400 realm_id: None,
2401 namespace: None,
2402 title: "default".to_string(),
2403 description: None,
2404 priority: Default::default(),
2405 completion_policy: Default::default(),
2406 labels: BTreeSet::new(),
2407 due_at: None,
2408 not_before: None,
2409 snoozed_until: None,
2410 external_refs: Vec::new(),
2411 evidence_refs: Vec::new(),
2412 status: None,
2413 })
2414 .await
2415 .expect("create default");
2416 other_service
2417 .create(CreateWorkItemRequest {
2418 realm_id: None,
2419 namespace: None,
2420 title: "other".to_string(),
2421 description: None,
2422 priority: Default::default(),
2423 completion_policy: Default::default(),
2424 labels: BTreeSet::new(),
2425 due_at: None,
2426 not_before: None,
2427 snoozed_until: None,
2428 external_refs: Vec::new(),
2429 evidence_refs: Vec::new(),
2430 status: None,
2431 })
2432 .await
2433 .expect("create other");
2434
2435 let items = store
2436 .list_items(WorkItemFilter {
2437 realm_id: Some("realm".to_string()),
2438 namespace: Some(WorkNamespace::default()),
2439 ..WorkItemFilter::default()
2440 })
2441 .await
2442 .expect("list");
2443 assert_eq!(items.len(), 1);
2444 assert_eq!(items[0].title, "default");
2445 }
2446
2447 #[tokio::test]
2448 async fn memory_store_duplicate_edge_does_not_append_event() {
2449 let store = MemoryWorkGraphStore::new();
2450 let edge = test_edge();
2451 store
2452 .insert_edge(edge.clone(), link_event(&edge))
2453 .await
2454 .expect("insert edge");
2455
2456 let error = store
2457 .insert_edge(edge.clone(), link_event(&edge))
2458 .await
2459 .expect_err("duplicate edge should fail");
2460 assert!(matches!(error, WorkGraphError::Conflict(_)));
2461
2462 let events = store
2463 .list_events(WorkGraphEventFilter {
2464 realm_id: Some(edge.realm_id),
2465 namespace: Some(edge.namespace),
2466 all_namespaces: false,
2467 after_seq: None,
2468 limit: None,
2469 })
2470 .await
2471 .expect("events");
2472 assert_eq!(events.len(), 1);
2473 }
2474
2475 #[cfg(not(target_arch = "wasm32"))]
2479 #[tokio::test]
2480 async fn sqlite_store_duplicate_item_insert_maps_to_conflict() {
2481 let dir = tempfile::tempdir().expect("tempdir");
2482 let path = dir.path().join("workgraph.sqlite3");
2483 let store = std::sync::Arc::new(crate::SqliteWorkGraphStore::open(&path).expect("open"));
2484 let service =
2485 WorkGraphService::with_scope(store.clone(), "realm", WorkNamespace::default());
2486 let item = service
2487 .create(CreateWorkItemRequest {
2488 realm_id: None,
2489 namespace: None,
2490 title: "unique item".to_string(),
2491 description: None,
2492 priority: Default::default(),
2493 completion_policy: Default::default(),
2494 labels: BTreeSet::new(),
2495 due_at: None,
2496 not_before: None,
2497 snoozed_until: None,
2498 external_refs: Vec::new(),
2499 evidence_refs: Vec::new(),
2500 status: None,
2501 })
2502 .await
2503 .expect("create");
2504
2505 let event = WorkGraphEvent::graph(
2506 item.realm_id.clone(),
2507 item.namespace.clone(),
2508 WorkGraphEventKind::Created,
2509 item.created_at,
2510 json!({ "item_id": item.id }),
2511 );
2512 let error = store
2513 .insert_item(item, event)
2514 .await
2515 .expect_err("duplicate item insert must fail");
2516 assert!(
2517 matches!(error, WorkGraphError::Conflict(_)),
2518 "duplicate item insert must map to Conflict, got: {error:?}"
2519 );
2520 }
2521
2522 #[cfg(not(target_arch = "wasm32"))]
2526 #[tokio::test]
2527 async fn sqlite_store_duplicate_attention_insert_maps_to_conflict() {
2528 let dir = tempfile::tempdir().expect("tempdir");
2529 let path = dir.path().join("workgraph.sqlite3");
2530 let store = std::sync::Arc::new(crate::SqliteWorkGraphStore::open(&path).expect("open"));
2531 let service =
2532 WorkGraphService::with_scope(store.clone(), "realm", WorkNamespace::default());
2533 let goal = service
2534 .create_goal(GoalCreateRequest {
2535 realm_id: None,
2536 namespace: None,
2537 title: "unique goal".to_string(),
2538 description: None,
2539 target: GoalAttentionTarget::Session {
2540 session_id: meerkat_core::SessionId::new(),
2541 },
2542 mode: WorkAttentionMode::Coordinate,
2543 completion_policy: WorkCompletionPolicy::SelfAttest,
2544 delegated_authority: AttentionDelegatedAuthority::AddEvidence,
2545 projection_policy: AttentionProjectionPolicy::default(),
2546 })
2547 .await
2548 .expect("create goal");
2549
2550 let mut fresh_item = goal.item.clone();
2551 fresh_item.id = WorkItemId::generated();
2552 let item_event = WorkGraphEvent::graph(
2553 fresh_item.realm_id.clone(),
2554 fresh_item.namespace.clone(),
2555 WorkGraphEventKind::Created,
2556 fresh_item.created_at,
2557 json!({ "item_id": fresh_item.id }),
2558 );
2559 let attention_event = WorkGraphEvent::graph(
2560 goal.attention.work_ref.realm_id.clone(),
2561 goal.attention.work_ref.namespace.clone(),
2562 WorkGraphEventKind::AttentionCreated,
2563 goal.attention.updated_at,
2564 json!({ "binding_id": goal.attention.binding_id }),
2565 );
2566 let error = store
2567 .insert_goal(fresh_item, item_event, goal.attention, attention_event)
2568 .await
2569 .expect_err("duplicate attention insert must fail");
2570 assert!(
2571 matches!(error, WorkGraphError::Conflict(_)),
2572 "duplicate attention insert must map to Conflict, got: {error:?}"
2573 );
2574 }
2575
2576 #[cfg(not(target_arch = "wasm32"))]
2577 #[tokio::test]
2578 async fn sqlite_persistence_survives_restart() {
2579 let dir = tempfile::tempdir().expect("tempdir");
2580 let path = dir.path().join("workgraph.sqlite3");
2581 let store = std::sync::Arc::new(crate::SqliteWorkGraphStore::open(&path).expect("open"));
2582 let service = WorkGraphService::with_scope(store, "realm", WorkNamespace::default());
2583 let item = service
2584 .create(CreateWorkItemRequest {
2585 realm_id: None,
2586 namespace: None,
2587 title: "persist me".to_string(),
2588 description: None,
2589 priority: Default::default(),
2590 completion_policy: Default::default(),
2591 labels: BTreeSet::new(),
2592 due_at: None,
2593 not_before: None,
2594 snoozed_until: None,
2595 external_refs: Vec::new(),
2596 evidence_refs: Vec::new(),
2597 status: None,
2598 })
2599 .await
2600 .expect("create");
2601
2602 let reopened = std::sync::Arc::new(crate::SqliteWorkGraphStore::open(&path).expect("open"));
2603 let service = WorkGraphService::with_scope(reopened, "realm", WorkNamespace::default());
2604 let fetched = service.get(None, None, item.id.clone()).await.expect("get");
2605 assert_eq!(fetched.title, "persist me");
2606 }
2607
2608 #[cfg(not(target_arch = "wasm32"))]
2609 #[tokio::test]
2610 async fn sqlite_item_without_machine_state_fails_closed_on_read() {
2611 let dir = tempfile::tempdir().expect("tempdir");
2612 let path = dir.path().join("workgraph.sqlite3");
2613 let store = std::sync::Arc::new(crate::SqliteWorkGraphStore::open(&path).expect("open"));
2614 let service =
2615 WorkGraphService::with_scope(store.clone(), "realm", WorkNamespace::default());
2616 let item = service
2617 .create(CreateWorkItemRequest {
2618 realm_id: None,
2619 namespace: None,
2620 title: "legacy item".to_string(),
2621 description: None,
2622 priority: Default::default(),
2623 completion_policy: Default::default(),
2624 labels: BTreeSet::new(),
2625 due_at: None,
2626 not_before: None,
2627 snoozed_until: None,
2628 external_refs: Vec::new(),
2629 evidence_refs: Vec::new(),
2630 status: None,
2631 })
2632 .await
2633 .expect("create");
2634
2635 store
2636 .with_connection(|conn| {
2637 let json: String = conn
2638 .query_row(
2639 "SELECT item_json FROM workgraph_items
2640 WHERE realm_id = ?1 AND namespace = ?2 AND item_id = ?3",
2641 rusqlite::params![
2642 &item.realm_id,
2643 item.namespace.as_str(),
2644 item.id.as_str()
2645 ],
2646 |row| row.get(0),
2647 )
2648 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
2649 let mut value = serde_json::from_str::<serde_json::Value>(&json)
2650 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
2651 value
2652 .as_object_mut()
2653 .expect("item json object")
2654 .remove("machine_state");
2655 conn.execute(
2656 "UPDATE workgraph_items
2657 SET item_json = ?4
2658 WHERE realm_id = ?1 AND namespace = ?2 AND item_id = ?3",
2659 rusqlite::params![
2660 &item.realm_id,
2661 item.namespace.as_str(),
2662 item.id.as_str(),
2663 serde_json::to_string(&value)
2664 .map_err(|err| WorkGraphError::Store(err.to_string()))?
2665 ],
2666 )
2667 .map_err(|err| WorkGraphError::Store(err.to_string()))?;
2668 Ok(())
2669 })
2670 .expect("strip machine state");
2671
2672 let reopened = std::sync::Arc::new(crate::SqliteWorkGraphStore::open(&path).expect("open"));
2677 let service = WorkGraphService::with_scope(reopened, "realm", WorkNamespace::default());
2678 let err = service
2679 .get(None, None, item.id)
2680 .await
2681 .expect_err("reading an item with no machine_state must fail closed");
2682 assert!(
2683 matches!(err, WorkGraphError::Store(_)),
2684 "expected a typed Store deserialization error, got: {err:?}"
2685 );
2686 }
2687
2688 #[cfg(not(target_arch = "wasm32"))]
2689 #[tokio::test]
2690 async fn sqlite_event_replay_rebuilds_projection() {
2691 let dir = tempfile::tempdir().expect("tempdir");
2692 let path = dir.path().join("workgraph.sqlite3");
2693 let store = std::sync::Arc::new(crate::SqliteWorkGraphStore::open(&path).expect("open"));
2694 let service =
2695 WorkGraphService::with_scope(store.clone(), "realm", WorkNamespace::default());
2696 let blocker = service
2697 .create(CreateWorkItemRequest {
2698 realm_id: None,
2699 namespace: None,
2700 title: "blocker".to_string(),
2701 description: None,
2702 priority: Default::default(),
2703 completion_policy: Default::default(),
2704 labels: BTreeSet::new(),
2705 due_at: None,
2706 not_before: None,
2707 snoozed_until: None,
2708 external_refs: Vec::new(),
2709 evidence_refs: Vec::new(),
2710 status: None,
2711 })
2712 .await
2713 .expect("create blocker");
2714 let blocked = service
2715 .create(CreateWorkItemRequest {
2716 realm_id: None,
2717 namespace: None,
2718 title: "blocked".to_string(),
2719 description: None,
2720 priority: Default::default(),
2721 completion_policy: Default::default(),
2722 labels: BTreeSet::new(),
2723 due_at: None,
2724 not_before: None,
2725 snoozed_until: None,
2726 external_refs: Vec::new(),
2727 evidence_refs: Vec::new(),
2728 status: None,
2729 })
2730 .await
2731 .expect("create blocked");
2732 service
2733 .link(LinkWorkItemsRequest {
2734 realm_id: None,
2735 namespace: None,
2736 kind: WorkEdgeKind::Blocks,
2737 from_id: blocker.id.clone(),
2738 to_id: blocked.id.clone(),
2739 })
2740 .await
2741 .expect("link");
2742
2743 store
2744 .with_connection(|conn| {
2745 conn.execute("DELETE FROM workgraph_items", [])
2746 .map_err(|err| crate::WorkGraphError::Store(err.to_string()))?;
2747 conn.execute("DELETE FROM workgraph_edges", [])
2748 .map_err(|err| crate::WorkGraphError::Store(err.to_string()))?;
2749 Ok(())
2750 })
2751 .expect("clear projection");
2752
2753 let empty_items = store
2754 .list_items(WorkItemFilter {
2755 realm_id: Some("realm".to_string()),
2756 namespace: Some(WorkNamespace::default()),
2757 ..WorkItemFilter::default()
2758 })
2759 .await
2760 .expect("empty list");
2761 assert!(empty_items.is_empty());
2762
2763 store
2764 .rebuild_projection_from_events()
2765 .expect("rebuild projection");
2766
2767 let rebuilt_items = store
2768 .list_items(WorkItemFilter {
2769 realm_id: Some("realm".to_string()),
2770 namespace: Some(WorkNamespace::default()),
2771 ..WorkItemFilter::default()
2772 })
2773 .await
2774 .expect("rebuilt list");
2775 assert_eq!(rebuilt_items.len(), 2);
2776 let rebuilt_edges = store
2777 .list_edges("realm", &WorkNamespace::default())
2778 .await
2779 .expect("rebuilt edges");
2780 assert_eq!(rebuilt_edges.len(), 1);
2781 }
2782
2783 #[cfg(not(target_arch = "wasm32"))]
2784 #[tokio::test]
2785 async fn sqlite_event_replay_stops_attention_for_terminal_goal_items() {
2786 let dir = tempfile::tempdir().expect("tempdir");
2787 let path = dir.path().join("workgraph.sqlite3");
2788 let store = std::sync::Arc::new(crate::SqliteWorkGraphStore::open(&path).expect("open"));
2789 let service =
2790 WorkGraphService::with_scope(store.clone(), "realm", WorkNamespace::default());
2791 let session_id = meerkat_core::SessionId::parse("019e63c2-0000-7000-8000-000000000045")
2792 .expect("session id");
2793 let goal = service
2794 .create_goal(GoalCreateRequest {
2795 realm_id: None,
2796 namespace: None,
2797 title: "terminal goal".to_string(),
2798 description: None,
2799 target: GoalAttentionTarget::Session { session_id },
2800 mode: WorkAttentionMode::Pursue,
2801 completion_policy: WorkCompletionPolicy::SelfAttest,
2802 delegated_authority: AttentionDelegatedAuthority::CloseIfPolicyAllows,
2803 projection_policy: AttentionProjectionPolicy::default(),
2804 })
2805 .await
2806 .expect("create goal");
2807 service
2808 .goal_request_close(GoalRequestCloseRequest {
2809 binding_id: goal.attention.binding_id.clone(),
2810 realm_id: None,
2811 namespace: None,
2812 expected_revision: goal.item.revision,
2813 status: GoalTerminalStatus::Completed,
2814 })
2815 .await
2816 .expect("close goal");
2817
2818 store
2819 .with_connection(|conn| {
2820 conn.execute("DELETE FROM workgraph_items", [])
2821 .map_err(|err| crate::WorkGraphError::Store(err.to_string()))?;
2822 conn.execute("DELETE FROM workgraph_attention", [])
2823 .map_err(|err| crate::WorkGraphError::Store(err.to_string()))?;
2824 Ok(())
2825 })
2826 .expect("clear projection");
2827
2828 store
2829 .rebuild_projection_from_events()
2830 .expect("rebuild projection");
2831
2832 let binding = store
2833 .get_attention(
2834 "realm",
2835 &WorkNamespace::default(),
2836 &goal.attention.binding_id,
2837 )
2838 .await
2839 .expect("read binding")
2840 .expect("rebuilt binding");
2841 assert_eq!(binding.status, WorkAttentionStatus::Stopped);
2842 }
2843
2844 #[cfg(not(target_arch = "wasm32"))]
2845 #[tokio::test]
2846 async fn sqlite_store_duplicate_edge_does_not_append_event() {
2847 let dir = tempfile::tempdir().expect("tempdir");
2848 let path = dir.path().join("workgraph.sqlite3");
2849 let store = crate::SqliteWorkGraphStore::open(&path).expect("open");
2850 let edge = test_edge();
2851 store
2852 .insert_edge(edge.clone(), link_event(&edge))
2853 .await
2854 .expect("insert edge");
2855
2856 let error = store
2857 .insert_edge(edge.clone(), link_event(&edge))
2858 .await
2859 .expect_err("duplicate edge should fail");
2860 assert!(matches!(error, WorkGraphError::Conflict(_)));
2861
2862 let events = store
2863 .list_events(WorkGraphEventFilter {
2864 realm_id: Some(edge.realm_id),
2865 namespace: Some(edge.namespace),
2866 all_namespaces: false,
2867 after_seq: None,
2868 limit: None,
2869 })
2870 .await
2871 .expect("events");
2872 assert_eq!(events.len(), 1);
2873 }
2874}
2875
2876#[cfg(all(test, not(target_arch = "wasm32")))]
2877#[allow(clippy::expect_used, clippy::unwrap_used)]
2878mod legacy_schema_tests {
2879 use super::*;
2880 use crate::{AttentionDelegatedAuthority, AttentionProjectionPolicy, WorkAttentionMode};
2881 use meerkat_core::SessionId;
2882
2883 #[tokio::test]
2886 async fn unledgered_legacy_attention_rows_are_refused_unmutated() {
2887 let dir = tempfile::tempdir().expect("tempdir");
2888 let path = dir.path().join("workgraph.sqlite3");
2889 let session_id = SessionId::new();
2890
2891 {
2894 let conn = Connection::open(&path).expect("open raw");
2895 conn.execute_batch(
2896 r"
2897 CREATE TABLE workgraph_attention (
2898 realm_id TEXT NOT NULL,
2899 namespace TEXT NOT NULL,
2900 binding_id TEXT NOT NULL,
2901 revision INTEGER NOT NULL,
2902 updated_at_utc TEXT NOT NULL,
2903 attention_json TEXT NOT NULL,
2904 PRIMARY KEY (realm_id, namespace, binding_id)
2905 );
2906 ",
2907 )
2908 .expect("create legacy table");
2909 let legacy = WorkAttentionBinding {
2910 binding_id: WorkAttentionBindingId::new("legacy-binding").expect("binding id"),
2911 work_ref: crate::WorkItemRef {
2912 realm_id: "realm".to_string(),
2913 namespace: WorkNamespace::default(),
2914 item_id: WorkItemId::generated(),
2915 },
2916 target: crate::WorkAttentionTarget::Session { session_id },
2917 mode: WorkAttentionMode::Pursue,
2918 status: WorkAttentionStatus::Active,
2919 machine_state: Default::default(),
2920 delegated_authority: AttentionDelegatedAuthority::AddEvidence,
2921 projection_policy: AttentionProjectionPolicy::default(),
2922 created_at: chrono::Utc::now(),
2923 updated_at: chrono::Utc::now(),
2924 };
2925 conn.execute(
2926 "INSERT INTO workgraph_attention
2927 (realm_id, namespace, binding_id, revision, updated_at_utc, attention_json)
2928 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
2929 params![
2930 legacy.work_ref.realm_id,
2931 legacy.work_ref.namespace.as_str(),
2932 legacy.binding_id.as_str(),
2933 legacy.machine_state.revision,
2934 legacy.updated_at.to_rfc3339(),
2935 serde_json::to_string(&legacy).expect("serialize legacy binding"),
2936 ],
2937 )
2938 .expect("insert legacy row");
2939 }
2940
2941 let error = crate::SqliteWorkGraphStore::open(&path)
2942 .err()
2943 .expect("unledgered owned workgraph schema must be refused");
2944 assert!(
2945 error.to_string().contains("no ledger row"),
2946 "unexpected refusal: {error}"
2947 );
2948 let conn = Connection::open(&path).expect("reopen raw");
2949 let row_count: i64 = conn
2950 .query_row("SELECT COUNT(*) FROM workgraph_attention", [], |row| {
2951 row.get(0)
2952 })
2953 .expect("legacy row remains");
2954 assert_eq!(row_count, 1);
2955 let projected_columns: i64 = conn
2956 .query_row(
2957 "SELECT COUNT(*) FROM pragma_table_info('workgraph_attention')
2958 WHERE name IN ('status', 'target_key')",
2959 [],
2960 |row| row.get(0),
2961 )
2962 .expect("legacy columns");
2963 assert_eq!(projected_columns, 0);
2964 assert_eq!(
2965 meerkat_sqlite::domain_version(&conn, WORKGRAPH_DOMAIN.name).expect("ledger"),
2966 None
2967 );
2968 }
2969}