1use std::collections::BTreeMap;
46
47use crate::{
48 Database, ExportedFact, FactId, HostError, IfMissing, RecallQuery, RememberInput, Workspace,
49};
50
51use super::{DbName, WorkspaceError};
52
53pub const SELF_ENTITY: &str = "plugmem workspace self";
61
62pub const ENTRY_TAG: &str = "plugmem-db";
65
66pub const ARCHIVED_TAG: &str = "archived";
69
70const NAME_KEY: &str = "name";
72
73const OWNER_KEY: &str = "owner";
75
76const OWNED_BY_REL: &str = "owned-by";
78
79const ANCHOR_K: usize = 4;
83
84#[derive(Clone, Copy, Debug, Default)]
86pub struct Description<'a> {
87 pub text: &'a str,
90 pub tags: &'a [&'a str],
92 pub owner: Option<&'a str>,
94}
95
96#[derive(Clone, Debug, PartialEq, Eq)]
98pub struct DbEntry {
99 pub name: DbName,
101 pub description: String,
103 pub tags: Vec<String>,
105 pub owner: Option<String>,
107}
108
109impl DbEntry {
110 pub fn is_archived(&self) -> bool {
112 self.tags.iter().any(|t| t == ARCHIVED_TAG)
113 }
114}
115
116#[derive(Clone, Debug, Default, PartialEq, Eq)]
118pub struct ReindexReport {
119 pub indexed: Vec<DbName>,
121 pub undescribed: Vec<DbName>,
124 pub busy: Vec<DbName>,
127}
128
129#[derive(Clone, Debug, PartialEq, Eq)]
133#[non_exhaustive]
134pub enum WorkspaceIssue {
135 Missing {
137 name: DbName,
139 },
140 Undescribed {
143 name: DbName,
145 },
146 Stale {
148 name: DbName,
150 },
151 Unreadable {
153 name: DbName,
155 why: String,
157 },
158 AmbiguousSelf {
160 name: DbName,
162 facts: usize,
164 },
165}
166
167impl Workspace {
168 pub fn describe(
184 &self,
185 name: &DbName,
186 now_ms: u64,
187 desc: Description<'_>,
188 ) -> Result<(), WorkspaceError> {
189 let db = self.get(name, now_ms, IfMissing::Create)?;
190 write_self(&db, now_ms, desc).map_err(|e| self.blame(name, e))?;
191 drop(db);
192
193 let registry = self.registry()?;
194 write_entry(®istry, now_ms, name, desc)?;
195 Ok(())
196 }
197
198 pub fn archive(&self, name: &DbName, now_ms: u64) -> Result<bool, WorkspaceError> {
210 let Some(entry) = self.entry(name)? else {
211 return Err(WorkspaceError::NoSuchDatabase {
212 name: name.clone(),
213 path: self.layout().path_of(name),
214 });
215 };
216 if entry.is_archived() {
217 return Ok(false);
218 }
219 let mut tags: Vec<&str> = entry.tags.iter().map(String::as_str).collect();
220 tags.push(ARCHIVED_TAG);
221 self.describe(
222 name,
223 now_ms,
224 Description {
225 text: &entry.description,
226 tags: &tags,
227 owner: entry.owner.as_deref(),
228 },
229 )?;
230 Ok(true)
231 }
232
233 pub fn entry(&self, name: &DbName) -> Result<Option<DbEntry>, WorkspaceError> {
239 Ok(self.entries()?.into_iter().find(|e| &e.name == name))
240 }
241
242 pub fn entries(&self) -> Result<Vec<DbEntry>, WorkspaceError> {
252 let registry = self.registry()?;
253 let mut out: Vec<DbEntry> = registry.export().iter().filter_map(entry_of).collect();
254 out.sort_by(|a, b| a.name.cmp(&b.name));
255 Ok(out)
256 }
257
258 pub fn find(&self, query: &str, k: usize, now_ms: u64) -> Result<Vec<DbEntry>, WorkspaceError> {
276 let registry = self.registry()?;
277 let hits = registry.recall(RecallQuery {
278 entities: &[query],
279 k,
280 ..RecallQuery::text(now_ms, query)
281 })?;
282 let mut by_name: BTreeMap<String, DbEntry> = self
283 .entries()?
284 .into_iter()
285 .map(|e| (e.name.to_string(), e))
286 .collect();
287
288 let mut out = Vec::with_capacity(hits.facts.len());
292 for hit in &hits.facts {
293 if let Some(snap) = registry.get(hit.id)
294 && let Some(name) = snap.metadata.get(NAME_KEY)
295 && let Some(entry) = by_name.remove(name)
296 {
297 out.push(entry);
298 }
299 }
300 Ok(out)
301 }
302
303 pub fn reindex(&self, now_ms: u64) -> Result<ReindexReport, WorkspaceError> {
320 let mut report = ReindexReport::default();
321 for name in self.layout().list()? {
322 let db = match self.get(&name, now_ms, IfMissing::Fail) {
323 Ok(db) => db,
324 Err(WorkspaceError::Busy { .. }) => {
325 report.busy.push(name);
326 continue;
327 }
328 Err(e) => return Err(e),
329 };
330 let found = self_description(&db, now_ms).map_err(|e| self.blame(&name, e))?;
331 drop(db);
332
333 match found {
334 Some(desc) => {
335 let registry = self.registry()?;
336 let tags: Vec<&str> = desc.tags.iter().map(String::as_str).collect();
337 write_entry(
338 ®istry,
339 now_ms,
340 &name,
341 Description {
342 text: &desc.text,
343 tags: &tags,
344 owner: desc.owner.as_deref(),
345 },
346 )?;
347 report.indexed.push(name);
348 }
349 None => report.undescribed.push(name),
350 }
351 }
352 Ok(report)
353 }
354
355 pub fn verify(&self, now_ms: u64) -> Result<Vec<WorkspaceIssue>, WorkspaceError> {
363 let on_disk = self.layout().list()?;
364 let recorded = self.entries()?;
365 let mut issues = Vec::new();
366
367 for entry in &recorded {
368 if !self.layout().exists(&entry.name) {
369 issues.push(WorkspaceIssue::Missing {
370 name: entry.name.clone(),
371 });
372 }
373 }
374
375 for name in on_disk {
376 let db = match self.get(&name, now_ms, IfMissing::Fail) {
377 Ok(db) => db,
378 Err(e) => {
379 issues.push(WorkspaceIssue::Unreadable {
380 name,
381 why: e.to_string(),
382 });
383 continue;
384 }
385 };
386 let anchored = anchored_facts(&db, now_ms).map_err(|e| self.blame(&name, e))?;
387 let own = anchored.first().map(|(id, snap)| SelfDescription {
388 text: snap.text.clone(),
389 tags: db.tags_of(*id),
390 owner: snap.metadata.get(OWNER_KEY).cloned(),
391 });
392 let anchored = anchored.len();
393 drop(db);
394
395 if anchored > 1 {
396 issues.push(WorkspaceIssue::AmbiguousSelf {
397 name: name.clone(),
398 facts: anchored,
399 });
400 }
401 match (own, recorded.iter().find(|e| e.name == name)) {
402 (None, None) => issues.push(WorkspaceIssue::Undescribed { name }),
405 (Some(own), Some(record)) if own.agrees_with(record) => {}
409 _ => issues.push(WorkspaceIssue::Stale { name }),
410 }
411 }
412 Ok(issues)
413 }
414
415 fn blame(&self, name: &DbName, e: HostError) -> WorkspaceError {
418 match e {
419 HostError::Locked { .. } => WorkspaceError::Busy { name: name.clone() },
420 other => WorkspaceError::Host(other),
421 }
422 }
423}
424
425fn write_self(db: &Database, now_ms: u64, desc: Description<'_>) -> Result<(), HostError> {
427 let owner = desc.owner.map(|o| [(OWNER_KEY, o)]);
428 let input = RememberInput {
429 entity: Some(SELF_ENTITY),
430 tags: desc.tags,
431 metadata: owner.as_ref().map(|m| m.as_slice()),
432 ..RememberInput::text(now_ms, desc.text)
433 };
434 match anchored_facts(db, now_ms)?.first() {
435 Some((id, _)) => db.revise(*id, input)?,
436 None => db.remember(input)?,
437 };
438 Ok(())
439}
440
441fn write_entry(
443 registry: &Database,
444 now_ms: u64,
445 name: &DbName,
446 desc: Description<'_>,
447) -> Result<(), WorkspaceError> {
448 let mut tags: Vec<&str> = Vec::with_capacity(desc.tags.len() + 1);
449 tags.push(ENTRY_TAG);
450 tags.extend(desc.tags.iter().copied().filter(|t| *t != ENTRY_TAG));
451
452 let mut metadata: Vec<(&str, &str)> = vec![(NAME_KEY, name.as_str())];
453 if let Some(owner) = desc.owner {
454 metadata.push((OWNER_KEY, owner));
455 }
456 let links: Vec<(&str, &str)> = desc
457 .owner
458 .map(|owner| vec![(OWNED_BY_REL, owner)])
459 .unwrap_or_default();
460
461 let input = RememberInput {
462 entity: Some(name.as_str()),
467 tags: &tags,
468 links: &links,
469 metadata: Some(&metadata),
470 ..RememberInput::text(now_ms, desc.text)
471 };
472
473 match existing_record(registry, now_ms, name)? {
474 Some(id) => registry.revise(id, input)?,
475 None => registry.remember(input)?,
476 };
477 Ok(())
478}
479
480fn existing_record(
482 registry: &Database,
483 now_ms: u64,
484 name: &DbName,
485) -> Result<Option<FactId>, HostError> {
486 let hits = registry.recall(RecallQuery {
487 entities: &[name.as_str()],
488 k: ANCHOR_K,
489 ..blank(now_ms)
490 })?;
491 for hit in &hits.facts {
494 if let Some(snap) = registry.get(hit.id)
495 && snap.metadata.get(NAME_KEY).map(String::as_str) == Some(name.as_str())
496 {
497 return Ok(Some(hit.id));
498 }
499 }
500 Ok(None)
501}
502
503fn anchored_facts(
505 db: &Database,
506 now_ms: u64,
507) -> Result<Vec<(FactId, crate::FactSnapshot)>, HostError> {
508 let hits = db.recall(RecallQuery {
509 entities: &[SELF_ENTITY],
510 k: ANCHOR_K,
511 ..blank(now_ms)
512 })?;
513 Ok(hits
514 .facts
515 .iter()
516 .filter_map(|hit| db.get(hit.id).map(|snap| (hit.id, snap)))
517 .collect())
518}
519
520struct SelfDescription {
524 text: String,
525 tags: Vec<String>,
526 owner: Option<String>,
527}
528
529impl SelfDescription {
530 fn agrees_with(&self, record: &DbEntry) -> bool {
533 self.text == record.description && self.owner == record.owner && self.tags == record.tags
534 }
535}
536
537fn self_description(db: &Database, now_ms: u64) -> Result<Option<SelfDescription>, HostError> {
539 let Some((id, snap)) = anchored_facts(db, now_ms)?.into_iter().next() else {
540 return Ok(None);
541 };
542 Ok(Some(SelfDescription {
543 text: snap.text,
544 tags: db.tags_of(id),
545 owner: snap.metadata.get(OWNER_KEY).cloned(),
546 }))
547}
548
549fn blank(now_ms: u64) -> RecallQuery<'static> {
553 RecallQuery {
554 text: None,
555 ..RecallQuery::text(now_ms, "")
556 }
557}
558
559fn entry_of(fact: &ExportedFact) -> Option<DbEntry> {
562 if !fact.tags.iter().any(|t| t == ENTRY_TAG) {
563 return None;
564 }
565 entry_from(&fact.text, &fact.tags, &fact.metadata)
566}
567
568fn entry_from(text: &str, tags: &[String], metadata: &BTreeMap<String, String>) -> Option<DbEntry> {
570 let name = DbName::parse(metadata.get(NAME_KEY)?).ok()?;
571 Some(DbEntry {
572 name,
573 description: text.to_string(),
574 tags: tags.iter().filter(|t| *t != ENTRY_TAG).cloned().collect(),
575 owner: metadata.get(OWNER_KEY).cloned(),
576 })
577}
578
579#[cfg(test)]
580mod tests {
581 use super::*;
582 use crate::workspace::testkit::{TempDir, name, workspace};
583 use crate::{RememberInput, WorkspaceLimits};
584
585 fn about(text: &str) -> Description<'_> {
587 Description {
588 text,
589 ..Description::default()
590 }
591 }
592
593 fn names(entries: &[DbEntry]) -> Vec<&str> {
594 entries.iter().map(|e| e.name.as_str()).collect()
595 }
596
597 #[test]
598 fn the_registry_is_not_opened_until_something_needs_it() {
599 let tmp = TempDir::new("registry-lazy");
600 let (ws, _) = workspace(&tmp, WorkspaceLimits::default());
601
602 ws.get(&name("chat-42"), 1_000, IfMissing::Create).unwrap();
605 assert!(!ws.layout().registry_path().exists());
606
607 ws.entries().unwrap();
608 assert!(crate::storage::database_exists(
609 &ws.layout().registry_path()
610 ));
611 assert!(ws.close_registry());
612 assert!(!ws.close_registry());
613 }
614
615 #[test]
616 fn describing_twice_revises_one_record_rather_than_adding_a_second() {
617 let tmp = TempDir::new("registry-revise");
618 let (ws, _) = workspace(&tmp, WorkspaceLimits::default());
619 let chat = name("chat-42");
620
621 ws.describe(&chat, 1_000, about("work chat about plugmem"))
622 .unwrap();
623 ws.describe(&chat, 2_000, about("work chat about releases"))
624 .unwrap();
625
626 let entries = ws.entries().unwrap();
627 assert_eq!(names(&entries), ["chat-42"]);
628 assert_eq!(entries[0].description, "work chat about releases");
629
630 let db = ws.get(&chat, 3_000, IfMissing::Fail).unwrap();
633 let own = self_description(&db, 3_000).unwrap().unwrap();
634 assert_eq!(own.text, "work chat about releases");
635 }
636
637 #[test]
638 fn describing_a_name_that_has_no_database_creates_it() {
639 let tmp = TempDir::new("registry-create");
640 let (ws, _) = workspace(&tmp, WorkspaceLimits::default());
641 let chat = name("chat-42");
642 assert!(!ws.layout().exists(&chat));
643 ws.describe(&chat, 1_000, about("a brand new chat"))
644 .unwrap();
645 assert!(ws.layout().exists(&chat));
646 }
647
648 #[test]
649 fn a_database_is_found_by_what_it_is_for_and_by_who_owns_it() {
650 let tmp = TempDir::new("registry-find");
651 let (ws, _) = workspace(&tmp, WorkspaceLimits::default());
652
653 ws.describe(
654 &name("chat-42"),
655 1_000,
656 Description {
657 text: "release planning and performance work on the engine",
658 tags: &["kind:chat"],
659 owner: Some("ann"),
660 },
661 )
662 .unwrap();
663 ws.describe(
664 &name("recipes"),
665 1_000,
666 Description {
667 text: "dinner ideas and shopping lists",
668 tags: &["kind:notes"],
669 owner: Some("bob"),
670 },
671 )
672 .unwrap();
673
674 let hits = ws.find("release planning", 4, 2_000).unwrap();
676 assert_eq!(hits.first().map(|e| e.name.as_str()), Some("chat-42"));
677 let hits = ws.find("shopping lists", 4, 2_000).unwrap();
678 assert_eq!(hits.first().map(|e| e.name.as_str()), Some("recipes"));
679
680 let ann = ws.find("ann", 4, 2_000).unwrap();
683 assert_eq!(names(&ann), ["chat-42"]);
684
685 let entry = ws.entry(&name("chat-42")).unwrap().unwrap();
687 assert_eq!(entry.tags, ["kind:chat"]);
688 assert_eq!(entry.owner.as_deref(), Some("ann"));
689 assert!(!entry.is_archived());
690 assert!(ws.entry(&name("nope")).unwrap().is_none());
691 }
692
693 #[test]
694 fn archiving_is_a_label_and_is_idempotent() {
695 let tmp = TempDir::new("registry-archive");
696 let (ws, _) = workspace(&tmp, WorkspaceLimits::default());
697 let chat = name("chat-42");
698
699 assert!(matches!(
701 ws.archive(&chat, 1_000),
702 Err(WorkspaceError::NoSuchDatabase { .. })
703 ));
704
705 ws.describe(
706 &chat,
707 1_000,
708 Description {
709 text: "an old project",
710 tags: &["kind:chat"],
711 owner: Some("ann"),
712 },
713 )
714 .unwrap();
715
716 assert!(ws.archive(&chat, 2_000).unwrap());
717 let entry = ws.entry(&chat).unwrap().unwrap();
718 assert!(entry.is_archived());
719 assert_eq!(entry.description, "an old project");
721 assert_eq!(entry.owner.as_deref(), Some("ann"));
722 assert!(entry.tags.contains(&"kind:chat".to_string()));
723
724 assert!(!ws.archive(&chat, 3_000).unwrap());
726 assert!(ws.layout().exists(&chat));
728 }
729
730 #[test]
731 fn a_deleted_registry_is_rebuilt_from_the_databases_themselves() {
732 let tmp = TempDir::new("registry-reindex");
733 let (ws, _) = workspace(&tmp, WorkspaceLimits::default());
734
735 for (db, text) in [("chat-42", "release planning"), ("recipes", "dinner ideas")] {
736 ws.describe(
737 &name(db),
738 1_000,
739 Description {
740 text,
741 tags: &["kind:chat"],
742 owner: Some("ann"),
743 },
744 )
745 .unwrap();
746 }
747 ws.get(&name("scratch"), 1_000, IfMissing::Create).unwrap();
749
750 ws.close_registry();
752 for entry in std::fs::read_dir(ws.layout().root()).unwrap() {
753 let path = entry.unwrap().path();
754 if path.is_file() {
755 std::fs::remove_file(path).unwrap();
756 }
757 }
758 assert!(ws.entries().unwrap().is_empty());
759
760 let report = ws.reindex(2_000).unwrap();
761 assert_eq!(
762 report
763 .indexed
764 .iter()
765 .map(DbName::to_string)
766 .collect::<Vec<_>>(),
767 ["chat-42", "recipes"]
768 );
769 assert_eq!(
770 report
771 .undescribed
772 .iter()
773 .map(DbName::to_string)
774 .collect::<Vec<_>>(),
775 ["scratch"]
776 );
777 assert!(report.busy.is_empty());
778
779 let entry = ws.entry(&name("chat-42")).unwrap().unwrap();
781 assert_eq!(entry.description, "release planning");
782 assert_eq!(entry.tags, ["kind:chat"]);
783 assert_eq!(entry.owner.as_deref(), Some("ann"));
784 }
785
786 #[test]
787 fn reindex_names_the_databases_it_could_not_read() {
788 let tmp = TempDir::new("registry-reindex-busy");
789 let (ws, _) = workspace(&tmp, WorkspaceLimits::default());
790 let held = name("chat-42");
791 ws.describe(&held, 1_000, about("release planning"))
792 .unwrap();
793 ws.describe(&name("recipes"), 1_000, about("dinner ideas"))
794 .unwrap();
795
796 ws.close_all();
799 let outsider = Database::open(ws.layout().path_of(&held), crate::Config::default())
800 .unwrap()
801 .0;
802
803 let report = ws.reindex(2_000).unwrap();
804 assert_eq!(report.busy, std::slice::from_ref(&held));
805 assert_eq!(report.indexed, [name("recipes")]);
806
807 drop(outsider);
808 assert_eq!(ws.reindex(3_000).unwrap().busy, []);
809 }
810
811 #[test]
812 fn verify_reports_every_way_the_registry_can_disagree() {
813 let tmp = TempDir::new("registry-verify");
814 let (ws, _) = workspace(&tmp, WorkspaceLimits::default());
815
816 let agreed = name("chat-42");
818 ws.describe(&agreed, 1_000, about("release planning"))
819 .unwrap();
820 assert_eq!(ws.verify(2_000).unwrap(), []);
821
822 let plain = name("scratch");
824 ws.get(&plain, 1_000, IfMissing::Create).unwrap();
825
826 let unlisted = name("orphan");
829 let db = ws.get(&unlisted, 1_000, IfMissing::Create).unwrap();
830 write_self(&db, 1_000, about("known only to itself")).unwrap();
831 drop(db);
832
833 let registry = ws.registry().unwrap();
835 write_entry(®istry, 1_000, &name("ghost"), about("gone")).unwrap();
836 drop(registry);
837
838 let two = name("twins");
841 let db = ws.get(&two, 1_000, IfMissing::Create).unwrap();
842 for text in ["first claim", "second claim"] {
843 db.remember(RememberInput {
844 entity: Some(SELF_ENTITY),
845 ..RememberInput::text(1_000, text)
846 })
847 .unwrap();
848 }
849 drop(db);
850
851 let issues = ws.verify(2_000).unwrap();
852 assert!(issues.contains(&WorkspaceIssue::Missing {
853 name: name("ghost")
854 }));
855 assert!(issues.contains(&WorkspaceIssue::Undescribed { name: plain }));
856 assert!(issues.contains(&WorkspaceIssue::Stale { name: unlisted }));
857 assert!(issues.iter().any(|i| matches!(
858 i,
859 WorkspaceIssue::AmbiguousSelf { name, facts: 2 } if name == &two
860 )));
861 assert!(!issues.iter().any(|i| matches!(
863 i,
864 WorkspaceIssue::Stale { name } | WorkspaceIssue::Undescribed { name } if name == &agreed
865 )));
866 }
867
868 #[test]
869 fn verify_reports_a_database_it_could_not_open() {
870 let tmp = TempDir::new("registry-verify-busy");
871 let (ws, _) = workspace(&tmp, WorkspaceLimits::default());
872 let held = name("chat-42");
873 ws.describe(&held, 1_000, about("release planning"))
874 .unwrap();
875 ws.close_all();
876 let _outsider = Database::open(ws.layout().path_of(&held), crate::Config::default())
877 .unwrap()
878 .0;
879
880 let issues = ws.verify(2_000).unwrap();
881 assert!(issues.iter().any(|i| matches!(
882 i,
883 WorkspaceIssue::Unreadable { name, why } if name == &held && why.contains("chat-42")
884 )));
885 }
886
887 #[test]
888 fn a_stray_fact_in_the_registry_file_is_not_read_as_a_record() {
889 let tmp = TempDir::new("registry-stray");
893 let (ws, _) = workspace(&tmp, WorkspaceLimits::default());
894 ws.describe(&name("chat-42"), 1_000, about("planning"))
895 .unwrap();
896
897 let registry = ws.registry().unwrap();
898 registry
899 .remember(RememberInput::text(1_000, "somebody's shopping list"))
900 .unwrap();
901 drop(registry);
902
903 assert_eq!(names(&ws.entries().unwrap()), ["chat-42"]);
904 }
905
906 #[test]
907 fn a_failure_that_is_not_a_lock_stops_a_reindex_instead_of_being_counted() {
908 let tmp = TempDir::new("registry-reindex-err");
911 let (seed, _) = workspace(&tmp, WorkspaceLimits::default());
912 seed.get(&name("chat-42"), 1_000, IfMissing::Create)
913 .unwrap();
914 drop(seed);
915
916 let broken: crate::Opener = Box::new(|_| Err(HostError::Embed("no provider".into())));
917 let ws = Workspace::new(
918 crate::WorkspaceLayout::new(&tmp.0),
919 broken,
920 WorkspaceLimits::default(),
921 );
922 assert!(matches!(
923 ws.reindex(2_000),
924 Err(WorkspaceError::Host(HostError::Embed(_)))
925 ));
926 }
927
928 #[test]
929 fn a_host_failure_is_attributed_to_the_database_it_came_from() {
930 let tmp = TempDir::new("registry-blame");
931 let (ws, _) = workspace(&tmp, WorkspaceLimits::default());
932 let chat = name("chat-42");
933
934 let locked = HostError::Locked {
937 path: ws.layout().path_of(&chat),
938 };
939 assert!(matches!(
940 ws.blame(&chat, locked),
941 WorkspaceError::Busy { name } if name == chat
942 ));
943 assert!(matches!(
944 ws.blame(&chat, HostError::Embed("no".into())),
945 WorkspaceError::Host(HostError::Embed(_))
946 ));
947 }
948
949 #[test]
950 fn a_stale_record_is_one_a_reindex_would_change() {
951 let tmp = TempDir::new("registry-stale");
952 let (ws, _) = workspace(&tmp, WorkspaceLimits::default());
953 let chat = name("chat-42");
954 ws.describe(&chat, 1_000, about("release planning"))
955 .unwrap();
956
957 let registry = ws.registry().unwrap();
959 write_entry(®istry, 2_000, &chat, about("something else")).unwrap();
960 drop(registry);
961 assert_eq!(
962 ws.verify(3_000).unwrap(),
963 [WorkspaceIssue::Stale { name: chat.clone() }]
964 );
965
966 ws.reindex(4_000).unwrap();
968 assert_eq!(ws.verify(5_000).unwrap(), []);
969 assert_eq!(
970 ws.entry(&chat).unwrap().unwrap().description,
971 "release planning"
972 );
973 }
974}