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