1use std::collections::BTreeMap;
49use std::fs;
50use std::io::{BufRead, BufReader, Write as IoWrite};
51use std::path::{Path, PathBuf};
52
53use kimetsu_core::KimetsuResult;
54use kimetsu_core::event::Event;
55use kimetsu_core::ids::{EventId, RunId};
56use rusqlite::Connection;
57use serde::{Deserialize, Serialize};
58use time::OffsetDateTime;
59use time::format_description::well_known::Rfc3339;
60use ulid::Ulid;
61
62use crate::projector;
63use crate::redact;
64
65const SYNC_ALLOWED_KINDS: &[&str] = &[
71 "memory.accepted",
72 "memory.proposed",
73 "memory.rejected",
74 "memory.invalidated",
75 "memory.cited",
76 "memory.superseded",
77];
78
79pub fn is_sync_allowed(kind: &str) -> bool {
81 SYNC_ALLOWED_KINDS.contains(&kind)
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct SyncEvent {
93 pub event_id: String,
94 pub run_id: String,
95 #[serde(with = "time::serde::rfc3339")]
96 pub ts: OffsetDateTime,
97 pub kind: String,
98 pub schema_version: u32,
99 pub payload: serde_json::Value,
100 #[serde(default)]
104 pub origin: Option<String>,
105 #[serde(default)]
109 pub hlc: Option<String>,
110}
111
112impl From<&Event> for SyncEvent {
113 fn from(e: &Event) -> Self {
114 let payload = redact_event_payload(e);
117 Self {
118 event_id: e.event_id.to_string(),
119 run_id: e.run_id.to_string(),
120 ts: e.ts,
121 kind: e.kind.clone(),
122 schema_version: e.schema_version,
123 payload,
124 origin: e.origin.clone(),
125 hlc: e.hlc.clone(),
126 }
127 }
128}
129
130impl TryFrom<SyncEvent> for Event {
131 type Error = Box<dyn std::error::Error + Send + Sync>;
132
133 fn try_from(s: SyncEvent) -> Result<Self, Self::Error> {
134 let event_id = EventId(
135 Ulid::from_string(&s.event_id)
136 .map_err(|e| format!("invalid event_id {:?}: {e}", s.event_id))?,
137 );
138 let run_id = RunId(
139 Ulid::from_string(&s.run_id)
140 .map_err(|e| format!("invalid run_id {:?}: {e}", s.run_id))?,
141 );
142 let hlc = match s.hlc {
146 Some(h) => {
147 if let Some(parsed) = kimetsu_core::clock::Hlc::parse(&h) {
148 kimetsu_core::clock::observe(&parsed);
149 }
150 Some(h)
151 }
152 None => Some(kimetsu_core::clock::now().to_canonical()),
153 };
154 Ok(Event {
155 event_id,
156 run_id,
157 ts: s.ts,
158 parent_event_id: None,
159 kind: s.kind,
160 schema_version: s.schema_version,
161 payload: s.payload,
162 origin: s.origin,
164 hlc,
165 })
166 }
167}
168
169fn redact_event_payload(event: &Event) -> serde_json::Value {
172 if !matches!(
173 event.kind.as_str(),
174 "memory.accepted" | "memory.proposed" | "memory.cited"
175 ) {
176 return event.payload.clone();
177 }
178 redact_json_strings_owned(&event.payload)
179}
180
181fn redact_json_strings_owned(value: &serde_json::Value) -> serde_json::Value {
182 match value {
183 serde_json::Value::String(text) => {
184 serde_json::Value::String(redact::redact_secrets(text).text)
185 }
186 serde_json::Value::Array(arr) => {
187 serde_json::Value::Array(arr.iter().map(redact_json_strings_owned).collect())
188 }
189 serde_json::Value::Object(map) => {
190 let out = map
191 .iter()
192 .map(|(k, v)| (k.clone(), redact_json_strings_owned(v)))
193 .collect();
194 serde_json::Value::Object(out)
195 }
196 other => other.clone(),
197 }
198}
199
200#[derive(Debug, Clone, Default)]
206pub struct ExportSummary {
207 pub exported: usize,
209 pub next_cursor: i64,
211}
212
213pub fn export_events(
221 conn: &Connection,
222 since_rowid: i64,
223 out_path: Option<&Path>,
224 dry_run: bool,
225) -> KimetsuResult<(ExportSummary, Option<String>)> {
226 let rows = read_durable_events_after(conn, since_rowid)?;
227
228 let mut lines = Vec::new();
229 let mut next_cursor = since_rowid;
230 for (rowid, event) in &rows {
231 if !is_sync_allowed(&event.kind) {
232 continue;
233 }
234 let se = SyncEvent::from(event);
235 let line = serde_json::to_string(&se)
236 .map_err(|e| format!("sync export: serialize event {}: {e}", event.event_id))?;
237 lines.push(line);
238 if *rowid > next_cursor {
239 next_cursor = *rowid;
240 }
241 }
242
243 let summary = ExportSummary {
244 exported: lines.len(),
245 next_cursor,
246 };
247
248 if dry_run {
249 return Ok((summary, None));
250 }
251
252 let jsonl = lines.join("\n");
253 if let Some(path) = out_path {
254 atomic_write(path, jsonl.as_bytes())?;
255 Ok((summary, None))
256 } else {
257 Ok((summary, Some(jsonl)))
258 }
259}
260
261fn read_durable_events_after(conn: &Connection, after: i64) -> KimetsuResult<Vec<(i64, Event)>> {
263 let mut stmt = conn.prepare(
264 "SELECT rowid, event_id, run_id, ts, kind, schema_version, payload_json, origin, hlc
265 FROM events
266 WHERE rowid > ?1
267 ORDER BY rowid",
268 )?;
269 let rows = stmt.query_map(rusqlite::params![after], |row| {
270 let rowid: i64 = row.get(0)?;
271 let event_id_str: String = row.get(1)?;
272 let run_id_str: String = row.get(2)?;
273 let ts_str: String = row.get(3)?;
274 let kind: String = row.get(4)?;
275 let schema_version: u32 = row.get(5)?;
276 let payload_json: String = row.get(6)?;
277 let origin: Option<String> = row.get(7)?;
278 let hlc: Option<String> = row.get(8)?;
279 Ok((
280 rowid,
281 event_id_str,
282 run_id_str,
283 ts_str,
284 kind,
285 schema_version,
286 payload_json,
287 origin,
288 hlc,
289 ))
290 })?;
291
292 let mut out = Vec::new();
293 for row in rows {
294 let (
295 rowid,
296 event_id_str,
297 run_id_str,
298 ts_str,
299 kind,
300 schema_version,
301 payload_json,
302 origin,
303 hlc,
304 ) = row?;
305 let event_id = EventId(
306 Ulid::from_string(&event_id_str)
307 .map_err(|e| format!("invalid event_id {event_id_str:?}: {e}"))?,
308 );
309 let run_id = RunId(
310 Ulid::from_string(&run_id_str)
311 .map_err(|e| format!("invalid run_id {run_id_str:?}: {e}"))?,
312 );
313 let ts = OffsetDateTime::parse(&ts_str, &Rfc3339)
314 .map_err(|e| format!("invalid ts {ts_str:?}: {e}"))?;
315 let payload: serde_json::Value = serde_json::from_str(&payload_json)?;
316 out.push((
317 rowid,
318 Event {
319 event_id,
320 run_id,
321 ts,
322 parent_event_id: None,
323 kind,
324 schema_version,
325 payload,
326 origin,
327 hlc,
328 },
329 ));
330 }
331 Ok(out)
332}
333
334#[derive(Debug, Clone, Default)]
340pub struct ImportSummary {
341 pub applied: usize,
343 pub skipped: usize,
345}
346
347pub fn import_events(
356 conn: &Connection,
357 jsonl: &str,
358 dry_run: bool,
359) -> KimetsuResult<ImportSummary> {
360 let mut summary = ImportSummary::default();
361 for (line_no, line) in jsonl.lines().enumerate() {
362 let line = line.trim();
363 if line.is_empty() {
364 continue;
365 }
366 let se: SyncEvent = serde_json::from_str(line)
367 .map_err(|e| format!("sync import: malformed JSON on line {}: {e}", line_no + 1))?;
368
369 if !is_sync_allowed(&se.kind) {
372 summary.skipped += 1;
374 continue;
375 }
376
377 let event: Event = Event::try_from(se)
378 .map_err(|e| format!("sync import: invalid event on line {}: {e}", line_no + 1))?;
379
380 let exists: bool = conn
382 .query_row(
383 "SELECT 1 FROM events WHERE event_id = ?1",
384 rusqlite::params![event.event_id.to_string()],
385 |_| Ok(true),
386 )
387 .optional()?
388 .unwrap_or(false);
389
390 if exists {
391 summary.skipped += 1;
392 continue;
393 }
394
395 if dry_run {
396 summary.applied += 1;
397 continue;
398 }
399
400 projector::apply_events(conn, &[event])?;
405 summary.applied += 1;
406 }
407 Ok(summary)
408}
409
410pub fn sync_conflict_count(conn: &Connection) -> KimetsuResult<i64> {
414 let n: i64 = conn.query_row("SELECT COUNT(*) FROM sync_conflicts", [], |r| r.get(0))?;
415 Ok(n)
416}
417
418pub fn import_events_from_file(
420 conn: &Connection,
421 path: &Path,
422 dry_run: bool,
423) -> KimetsuResult<ImportSummary> {
424 let file = fs::File::open(path)
425 .map_err(|e| format!("sync import: cannot open {:?}: {e}", path.display()))?;
426 let reader = BufReader::new(file);
427 let mut buf = String::new();
428 for line in reader.lines() {
429 let l = line.map_err(|e| format!("sync import: read error {:?}: {e}", path.display()))?;
430 buf.push_str(&l);
431 buf.push('\n');
432 }
433 import_events(conn, &buf, dry_run)
434}
435
436#[derive(Debug, Clone, Default, Serialize, Deserialize)]
445pub struct SyncCursors {
446 #[serde(default)]
448 pub sources: BTreeMap<String, i64>,
449}
450
451impl SyncCursors {
452 pub fn load(path: &Path) -> KimetsuResult<Self> {
453 if !path.exists() {
454 return Ok(Self::default());
455 }
456 let text = fs::read_to_string(path)
457 .map_err(|e| format!("sync-cursors: cannot read {:?}: {e}", path.display()))?;
458 serde_json::from_str(&text)
459 .map_err(|e| format!("sync-cursors: malformed JSON at {:?}: {e}", path.display()))
460 .map_err(Into::into)
461 }
462
463 pub fn save(&self, path: &Path) -> KimetsuResult<()> {
464 let text = serde_json::to_string_pretty(self)
465 .map_err(|e| format!("sync-cursors: serialize error: {e}"))?;
466 atomic_write(path, text.as_bytes())
467 }
468
469 pub fn cursor_for(&self, machine_id: &str) -> i64 {
470 *self.sources.get(machine_id).unwrap_or(&0)
471 }
472
473 pub fn set_cursor(&mut self, machine_id: &str, rowid: i64) {
474 self.sources.insert(machine_id.to_string(), rowid);
475 }
476}
477
478pub fn max_local_sync_rowid(conn: &Connection) -> KimetsuResult<i64> {
486 let placeholders: String = SYNC_ALLOWED_KINDS
487 .iter()
488 .enumerate()
489 .map(|(i, _)| format!("?{}", i + 1))
490 .collect::<Vec<_>>()
491 .join(", ");
492 let sql = format!("SELECT COALESCE(MAX(rowid), 0) FROM events WHERE kind IN ({placeholders})");
493 let mut stmt = conn.prepare(&sql)?;
494 let params: Vec<Box<dyn rusqlite::ToSql>> = SYNC_ALLOWED_KINDS
495 .iter()
496 .map(|k| -> Box<dyn rusqlite::ToSql> { Box::new(k.to_string()) })
497 .collect();
498 let refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
499 let max: i64 = stmt.query_row(refs.as_slice(), |r| r.get(0))?;
500 Ok(max)
501}
502
503pub fn push_machine_batch(
508 conn: &Connection,
509 sync_dir: &Path,
510 machine_id: &str,
511 since_rowid: i64,
512 dry_run: bool,
513) -> KimetsuResult<ExportSummary> {
514 let (dry_summary, _content) = export_events(conn, since_rowid, None, true)?;
515 if dry_summary.exported == 0 || dry_run {
516 return Ok(dry_summary);
517 }
518
519 let (summary, content) = export_events(conn, since_rowid, None, false)?;
521 let jsonl = content.unwrap_or_default();
522
523 let machine_dir = sync_dir.join(machine_id);
524 fs::create_dir_all(&machine_dir).map_err(|e| {
525 format!(
526 "sync push: cannot create dir {:?}: {e}",
527 machine_dir.display()
528 )
529 })?;
530
531 let batch_name = format!("{}.jsonl", summary.next_cursor);
532 let batch_path = machine_dir.join(&batch_name);
533 atomic_write(&batch_path, jsonl.as_bytes())?;
534
535 Ok(summary)
536}
537
538pub fn pull_machine_batches(
544 conn: &Connection,
545 sync_dir: &Path,
546 source_machine_id: &str,
547 since_cursor: i64,
548 dry_run: bool,
549) -> KimetsuResult<(ImportSummary, i64)> {
550 let machine_dir = sync_dir.join(source_machine_id);
551 if !machine_dir.exists() {
552 return Ok((ImportSummary::default(), since_cursor));
553 }
554
555 let mut batches: Vec<(i64, PathBuf)> = Vec::new();
559 let entries = fs::read_dir(&machine_dir).map_err(|e| {
560 format!(
561 "sync pull: cannot read dir {:?}: {e}",
562 machine_dir.display()
563 )
564 })?;
565 for entry in entries {
566 let entry = entry.map_err(|e| format!("sync pull: dir entry error: {e}"))?;
567 let path = entry.path();
568 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
569 continue;
570 }
571 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
572 if let Ok(cursor_val) = stem.parse::<i64>() {
573 if cursor_val > since_cursor {
574 batches.push((cursor_val, path));
575 }
576 }
577 }
578 }
579 batches.sort_by_key(|(c, _)| *c);
580
581 let mut total = ImportSummary::default();
582 let mut new_cursor = since_cursor;
583 for (cursor_val, batch_path) in &batches {
584 let batch_summary = import_events_from_file(conn, batch_path, dry_run)?;
585 total.applied += batch_summary.applied;
586 total.skipped += batch_summary.skipped;
587 if *cursor_val > new_cursor {
588 new_cursor = *cursor_val;
589 }
590 }
591 Ok((total, new_cursor))
592}
593
594pub fn sync_dir(
601 conn: &Connection,
602 sync_dir: &Path,
603 machine_id: &str,
604 cursors_path: &Path,
605 dry_run: bool,
606) -> KimetsuResult<SyncReport> {
607 let mut cursors = SyncCursors::load(cursors_path)?;
608 let export_since = cursors.cursor_for(machine_id);
609
610 let push_summary = push_machine_batch(conn, sync_dir, machine_id, export_since, dry_run)?;
612 if !dry_run && push_summary.exported > 0 {
613 cursors.set_cursor(machine_id, push_summary.next_cursor);
614 cursors.save(cursors_path)?;
615 }
616
617 let mut total_applied = 0usize;
619 let mut total_skipped = 0usize;
620 let mut machines_pulled: Vec<String> = Vec::new();
621
622 if sync_dir.exists() {
624 let entries = fs::read_dir(sync_dir)
625 .map_err(|e| format!("sync: cannot read sync_dir {:?}: {e}", sync_dir.display()))?;
626 let mut other_machines: Vec<String> = Vec::new();
627 for entry in entries {
628 let entry = entry.map_err(|e| format!("sync: dir entry error: {e}"))?;
629 if entry.path().is_dir() {
630 if let Some(name) = entry.file_name().to_str() {
631 if name != machine_id {
632 other_machines.push(name.to_string());
633 }
634 }
635 }
636 }
637 other_machines.sort(); for other_id in &other_machines {
640 let since = cursors.cursor_for(other_id);
641 let (pull_summary, new_cursor) =
642 pull_machine_batches(conn, sync_dir, other_id, since, dry_run)?;
643 total_applied += pull_summary.applied;
644 total_skipped += pull_summary.skipped;
645 if !dry_run && new_cursor > since {
646 cursors.set_cursor(other_id, new_cursor);
647 machines_pulled.push(other_id.clone());
648 } else if dry_run && (pull_summary.applied + pull_summary.skipped) > 0 {
649 machines_pulled.push(other_id.clone());
650 }
651 }
652
653 if !dry_run && !machines_pulled.is_empty() {
654 cursors.save(cursors_path)?;
655 }
656 }
657
658 if !dry_run && total_applied > 0 {
663 projector::rebuild_in_place(conn)?;
664 }
665
666 Ok(SyncReport {
667 pushed: push_summary.exported,
668 pulled_applied: total_applied,
669 pulled_skipped: total_skipped,
670 machines_pulled,
671 dry_run,
672 })
673}
674
675#[derive(Debug, Clone, Default)]
677pub struct SyncReport {
678 pub pushed: usize,
679 pub pulled_applied: usize,
680 pub pulled_skipped: usize,
681 pub machines_pulled: Vec<String>,
682 pub dry_run: bool,
683}
684
685#[derive(Debug, Clone)]
691pub struct SyncStatus {
692 pub sync_dir: Option<PathBuf>,
693 pub machine_id: String,
694 pub sources: Vec<(String, i64, usize)>,
696 pub local_pending: usize,
697}
698
699pub fn sync_status(
701 conn: &Connection,
702 sync_dir_opt: Option<&Path>,
703 machine_id: &str,
704 cursors_path: &Path,
705) -> KimetsuResult<SyncStatus> {
706 let cursors = SyncCursors::load(cursors_path)?;
707 let export_since = cursors.cursor_for(machine_id);
708
709 let (push_dry, _) = export_events(conn, export_since, None, true)?;
711 let local_pending = push_dry.exported;
712
713 let mut sources: Vec<(String, i64, usize)> = Vec::new();
714 if let Some(sd) = sync_dir_opt {
715 if sd.exists() {
716 let entries = fs::read_dir(sd)
717 .map_err(|e| format!("sync status: cannot read {:?}: {e}", sd.display()))?;
718 let mut other_machines: Vec<String> = Vec::new();
719 for entry in entries {
720 let entry = entry.map_err(|e| format!("sync status: dir entry error: {e}"))?;
721 if entry.path().is_dir() {
722 if let Some(name) = entry.file_name().to_str() {
723 if name != machine_id {
724 other_machines.push(name.to_string());
725 }
726 }
727 }
728 }
729 other_machines.sort();
730 for other_id in &other_machines {
731 let since = cursors.cursor_for(other_id);
732 let (pull_summary, _) = pull_machine_batches(conn, sd, other_id, since, true)?;
733 sources.push((
734 other_id.clone(),
735 since,
736 pull_summary.applied + pull_summary.skipped,
737 ));
738 }
739 }
740 }
741
742 Ok(SyncStatus {
743 sync_dir: sync_dir_opt.map(|p| p.to_path_buf()),
744 machine_id: machine_id.to_string(),
745 sources,
746 local_pending,
747 })
748}
749
750pub fn atomic_write(path: &Path, data: &[u8]) -> KimetsuResult<()> {
756 let parent = path.parent().unwrap_or(Path::new("."));
757 fs::create_dir_all(parent).map_err(|e| {
758 format!(
759 "atomic_write: cannot create dir {:?}: {e}",
760 parent.display()
761 )
762 })?;
763 let tmp_path = path.with_extension("tmp");
764 {
765 let mut file = fs::File::create(&tmp_path).map_err(|e| {
766 format!(
767 "atomic_write: cannot create tmp {:?}: {e}",
768 tmp_path.display()
769 )
770 })?;
771 file.write_all(data)
772 .map_err(|e| format!("atomic_write: write error {:?}: {e}", tmp_path.display()))?;
773 file.flush()
774 .map_err(|e| format!("atomic_write: flush error {:?}: {e}", tmp_path.display()))?;
775 }
776 fs::rename(&tmp_path, path).map_err(|e| {
777 format!(
778 "atomic_write: rename {:?} -> {:?}: {e}",
779 tmp_path.display(),
780 path.display()
781 )
782 })?;
783 Ok(())
784}
785
786trait OptionalExt<T> {
791 fn optional(self) -> KimetsuResult<Option<T>>;
792}
793
794impl<T> OptionalExt<T> for rusqlite::Result<T> {
795 fn optional(self) -> KimetsuResult<Option<T>> {
796 match self {
797 Ok(v) => Ok(Some(v)),
798 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
799 Err(e) => Err(e.into()),
800 }
801 }
802}
803
804#[cfg(test)]
809mod tests {
810 use kimetsu_core::ids::RunId;
811 use rusqlite::Connection;
812 use serde_json::json;
813
814 use super::*;
815 use crate::projector::apply_events;
816 use crate::schema;
817
818 fn make_conn() -> Connection {
819 let conn = Connection::open_in_memory().expect("open_in_memory");
820 schema::initialize(&conn).expect("schema init");
821 conn
822 }
823
824 fn seed_events(conn: &Connection) -> (RunId, String, String) {
825 let run_id = RunId::new();
826 let mem_id_a = format!("mem-{}", ulid::Ulid::new());
827 let mem_id_b = format!("mem-{}", ulid::Ulid::new());
828 apply_events(
829 conn,
830 &[
831 kimetsu_core::event::Event::new(
832 run_id,
833 "run.started",
834 json!({"project_id":"p","task":"t"}),
835 ),
836 kimetsu_core::event::Event::new(
837 run_id,
838 "memory.accepted",
839 json!({"memory_id": mem_id_a, "text": "always use cargo --locked", "scope": "project", "kind": "fact"}),
840 ),
841 kimetsu_core::event::Event::new(
842 run_id,
843 "memory.accepted",
844 json!({"memory_id": mem_id_b, "text": "prefer ripgrep over grep", "scope": "global_user", "kind": "preference"}),
845 ),
846 kimetsu_core::event::Event::new(
847 run_id,
848 "work.episode",
849 json!({"task":"local task","project_id":"p"}),
850 ),
851 kimetsu_core::event::Event::new(
852 run_id,
853 "context.served",
854 json!({"query":"test","results":[]}),
855 ),
856 kimetsu_core::event::Event::new(
857 run_id,
858 "run.finished",
859 json!({"total_cost_usd":0.01}),
860 ),
861 ],
862 )
863 .expect("seed events");
864 (run_id, mem_id_a, mem_id_b)
865 }
866
867 #[test]
872 fn two_brains_converge_after_exchange() {
873 use kimetsu_core::event::Event;
874 let a = make_conn();
875 let b = make_conn();
876 let run = RunId(ulid::Ulid::nil()); let (m1, s1, s2) = ("mem-m1", "mem-s1", "mem-s2");
878
879 let base = vec![
881 Event::new(
882 run,
883 "memory.accepted",
884 json!({"memory_id": m1, "text":"alpha rule", "scope":"project","kind":"fact"}),
885 ),
886 Event::new(
887 run,
888 "memory.accepted",
889 json!({"memory_id": s1, "text":"survivor one", "scope":"project","kind":"fact"}),
890 ),
891 Event::new(
892 run,
893 "memory.accepted",
894 json!({"memory_id": s2, "text":"survivor two", "scope":"project","kind":"fact"}),
895 ),
896 ];
897 apply_events(&a, &base).unwrap();
898 apply_events(&b, &base).unwrap();
899
900 let a_mut = vec![
902 Event::new(run, "memory.cited", json!({"memory_id": m1, "turn": 0})),
903 Event::new(
904 run,
905 "memory.superseded",
906 json!({"memory_id": m1, "survivor_id": s1}),
907 ),
908 ];
909 apply_events(&a, &a_mut).unwrap();
910 let b_mut = vec![
911 Event::new(run, "memory.cited", json!({"memory_id": m1, "turn": 0})),
912 Event::new(
913 run,
914 "memory.superseded",
915 json!({"memory_id": m1, "survivor_id": s2}),
916 ),
917 ];
918 apply_events(&b, &b_mut).unwrap();
919
920 let ax = export_events(&a, 0, None, false).unwrap().1.unwrap();
922 let bx = export_events(&b, 0, None, false).unwrap().1.unwrap();
923 import_events(&b, &ax, false).unwrap();
924 import_events(&a, &bx, false).unwrap();
925 crate::projector::rebuild_in_place(&a).unwrap();
926 crate::projector::rebuild_in_place(&b).unwrap();
927
928 let superseded = |c: &Connection| -> Option<String> {
930 c.query_row(
931 "SELECT superseded_by FROM memories WHERE memory_id = ?1",
932 [m1],
933 |r| r.get::<_, Option<String>>(0),
934 )
935 .unwrap()
936 };
937 assert_eq!(
938 superseded(&a),
939 superseded(&b),
940 "superseded_by must converge"
941 );
942 assert_eq!(
943 superseded(&a),
944 Some(s2.to_string()),
945 "later-HLC supersede wins deterministically"
946 );
947
948 let use_count = |c: &Connection| -> i64 {
950 c.query_row(
951 "SELECT use_count FROM memories WHERE memory_id = ?1",
952 [m1],
953 |r| r.get(0),
954 )
955 .unwrap()
956 };
957 assert_eq!(use_count(&a), use_count(&b), "use_count must converge");
958 assert_eq!(use_count(&a), 2, "both brains' cites counted");
959
960 let confidence = |c: &Connection| -> f64 {
962 c.query_row(
963 "SELECT confidence FROM memories WHERE memory_id = ?1",
964 [m1],
965 |r| r.get(0),
966 )
967 .unwrap()
968 };
969 assert!(
970 (confidence(&a) - confidence(&b)).abs() < 1e-9,
971 "confidence must converge: {} vs {}",
972 confidence(&a),
973 confidence(&b)
974 );
975
976 assert_eq!(sync_conflict_count(&a).unwrap(), 1);
978 assert_eq!(sync_conflict_count(&b).unwrap(), 1);
979 }
980
981 #[test]
983 fn export_excludes_local_only_kinds() {
984 let conn = make_conn();
985 seed_events(&conn);
986 let (summary, content) = export_events(&conn, 0, None, false).expect("export");
987 let jsonl = content.expect("content must be Some when out_path is None");
988 assert!(summary.exported > 0, "must export at least 1 event");
989 assert!(
990 summary.exported <= 2,
991 "only memory.accepted events (2 max); got {}",
992 summary.exported
993 );
994 for line in jsonl.lines() {
995 if line.trim().is_empty() {
996 continue;
997 }
998 let se: SyncEvent = serde_json::from_str(line).expect("valid json");
999 assert!(
1000 is_sync_allowed(&se.kind),
1001 "exported kind {:?} is NOT on the allowlist",
1002 se.kind
1003 );
1004 assert_ne!(
1005 se.kind, "work.episode",
1006 "work.episode must never be exported"
1007 );
1008 assert_ne!(
1009 se.kind, "context.served",
1010 "context.served must never be exported"
1011 );
1012 assert_ne!(
1013 se.kind, "run.started",
1014 "run metadata must never be exported"
1015 );
1016 assert_ne!(
1017 se.kind, "run.finished",
1018 "run metadata must never be exported"
1019 );
1020 }
1021 }
1022
1023 #[test]
1025 fn import_is_idempotent() {
1026 let conn_a = make_conn();
1027 seed_events(&conn_a);
1028 let (_, content) = export_events(&conn_a, 0, None, false).expect("export");
1029 let jsonl = content.expect("content");
1030
1031 let conn_b = make_conn();
1032 let s1 = import_events(&conn_b, &jsonl, false).expect("first import");
1033 assert!(s1.applied > 0, "first import must apply events");
1034 assert_eq!(s1.skipped, 0, "first import must have 0 skipped");
1035
1036 let s2 = import_events(&conn_b, &jsonl, false).expect("second import");
1037 assert_eq!(s2.applied, 0, "re-import must apply 0 (idempotent)");
1038 assert_eq!(
1039 s2.skipped, s1.applied,
1040 "all events must be skipped on re-import"
1041 );
1042 }
1043
1044 #[test]
1046 fn round_trip_export_import() {
1047 let conn_a = make_conn();
1048 let (_, mem_id_a, mem_id_b) = seed_events(&conn_a);
1049 let (_, content) = export_events(&conn_a, 0, None, false).expect("export");
1050 let jsonl = content.expect("content");
1051
1052 let conn_b = make_conn();
1053 let s = import_events(&conn_b, &jsonl, false).expect("import");
1054 assert!(s.applied > 0, "must have applied events");
1055
1056 let count: i64 = conn_b
1058 .query_row("SELECT COUNT(*) FROM memories", [], |r| r.get(0))
1059 .expect("count");
1060 assert!(
1061 count >= 1,
1062 "at least one memory must appear in B after import"
1063 );
1064
1065 for mid in [&mem_id_a, &mem_id_b] {
1067 let exists: i64 = conn_b
1068 .query_row(
1069 "SELECT COUNT(*) FROM memories WHERE memory_id = ?1",
1070 rusqlite::params![mid],
1071 |r| r.get(0),
1072 )
1073 .expect("exists check");
1074 assert_eq!(exists, 1, "memory {} must exist in B after import", mid);
1075 }
1076 }
1077
1078 #[test]
1080 fn cursor_advances_correctly() {
1081 let conn = make_conn();
1082 seed_events(&conn);
1083 let (summary1, _) = export_events(&conn, 0, None, false).expect("export 1");
1084 let cursor_after_first = summary1.next_cursor;
1085
1086 let run_id = RunId::new();
1088 let mem_id_c = format!("mem-c-{}", ulid::Ulid::new());
1089 apply_events(
1090 &conn,
1091 &[kimetsu_core::event::Event::new(
1092 run_id,
1093 "memory.accepted",
1094 json!({"memory_id": mem_id_c, "text": "new after cursor", "scope": "project", "kind": "fact"}),
1095 )],
1096 )
1097 .expect("add new event");
1098
1099 let (summary2, content2) =
1100 export_events(&conn, cursor_after_first, None, false).expect("export 2");
1101 let jsonl2 = content2.expect("content");
1102 assert_eq!(
1103 summary2.exported, 1,
1104 "second export must emit exactly 1 new event"
1105 );
1106 let se: SyncEvent = serde_json::from_str(jsonl2.trim()).expect("parse");
1107 let payload_mid = se
1108 .payload
1109 .get("memory_id")
1110 .and_then(|v| v.as_str())
1111 .unwrap_or("");
1112 assert_eq!(
1113 payload_mid, mem_id_c,
1114 "cursor must only export the new event"
1115 );
1116 }
1117
1118 #[test]
1120 fn export_redacts_secrets() {
1121 let conn = make_conn();
1122 let run_id = RunId::new();
1123 let secret = "sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv0123456789AbCdEf";
1124 apply_events(
1125 &conn,
1126 &[kimetsu_core::event::Event::new(
1127 run_id,
1128 "memory.accepted",
1129 json!({
1130 "memory_id": "mem-secret",
1131 "text": format!("do not use {secret}"),
1132 "scope": "project",
1133 "kind": "fact"
1134 }),
1135 )],
1136 )
1137 .expect("seed");
1138 let (_, content) = export_events(&conn, 0, None, false).expect("export");
1139 let jsonl = content.expect("content");
1140 assert!(
1141 !jsonl.contains(secret),
1142 "exported batch must NOT contain the secret"
1143 );
1144 assert!(
1145 jsonl.contains("[REDACTED:anthropic_oauth]"),
1146 "exported batch must contain the REDACTED placeholder"
1147 );
1148 }
1149
1150 #[test]
1152 fn dry_run_import_does_not_write() {
1153 let conn_a = make_conn();
1154 seed_events(&conn_a);
1155 let (_, content) = export_events(&conn_a, 0, None, false).expect("export");
1156 let jsonl = content.expect("content");
1157
1158 let conn_b = make_conn();
1159 let s = import_events(&conn_b, &jsonl, true).expect("dry-run import");
1160 assert!(s.applied > 0, "dry-run must report events it WOULD apply");
1161
1162 let count: i64 = conn_b
1163 .query_row("SELECT COUNT(*) FROM events", [], |r| r.get(0))
1164 .expect("count");
1165 assert_eq!(count, 0, "dry-run must NOT write any events");
1166 }
1167
1168 #[test]
1170 fn directory_protocol_push_pull() {
1171 let tmp = tempfile::tempdir().expect("tempdir");
1172 let sync_dir = tmp.path().join("sync");
1173 let cursors_path = tmp.path().join("sync-cursors.json");
1174
1175 let conn_a = make_conn();
1177 seed_events(&conn_a);
1178 let machine_a = "machine-a";
1179 let report =
1180 sync_dir_fn(&conn_a, &sync_dir, machine_a, &cursors_path, false).expect("sync A");
1181 assert!(report.pushed > 0, "A must push events");
1182
1183 let conn_b = make_conn();
1185 let cursors_b_path = tmp.path().join("cursors-b.json");
1186 let machine_b = "machine-b";
1187 let report_b =
1188 sync_dir_fn(&conn_b, &sync_dir, machine_b, &cursors_b_path, false).expect("sync B");
1189 assert!(report_b.pulled_applied > 0, "B must import events from A");
1190
1191 let report_b2 = sync_dir_fn(&conn_b, &sync_dir, machine_b, &cursors_b_path, false)
1193 .expect("sync B again");
1194 assert_eq!(
1195 report_b2.pulled_applied, 0,
1196 "second sync B must be idempotent (0 applied)"
1197 );
1198 }
1199
1200 fn sync_dir_fn(
1202 conn: &Connection,
1203 sd: &Path,
1204 mid: &str,
1205 cp: &Path,
1206 dry: bool,
1207 ) -> KimetsuResult<SyncReport> {
1208 sync_dir(conn, sd, mid, cp, dry)
1209 }
1210}