Skip to main content

kimetsu_brain/
sync.rs

1/// Epic S3 — Personal brain sync (event-log replication).
2///
3/// Design insight: `events` is the durable source of truth and the projector
4/// rebuilds everything from it.  Sync = EVENT-LOG REPLICATION, not SQLite file
5/// copying.  We export durable events and import them through the projector with
6/// per-event idempotency.  No server, no merge daemon.
7///
8/// # Allowed (durable memory-lifecycle) kinds — exported
9/// - `memory.accepted`
10/// - `memory.proposed`
11/// - `memory.rejected`
12/// - `memory.invalidated`
13/// - `memory.cited`
14/// - `memory.superseded`
15///
16/// # Excluded kinds — never exported
17/// - `work.episode`         — episodes are LOCAL-ONLY (Flagship 1)
18/// - `context.served`       — local telemetry
19/// - `retrieval.regret`     — local telemetry
20/// - `digest_served`        — local telemetry
21/// - `resume_served`        — local telemetry
22/// - `context.injected`     — raw query bearing
23/// - `run.started`          — local run metadata
24/// - `run.finished`         — local run metadata
25/// - `run.failed`           — local run metadata
26/// - `run.aborted`          — local run metadata
27///
28/// Everything else that is not on the allowlist is also excluded by default.
29///
30/// # Cursor
31/// The monotonic ordering column is `rowid` (the implicit SQLite integer
32/// primary key alias).  A cursor is the last exported `rowid`.  The next
33/// export picks up WHERE rowid > cursor.  Cursor 0 means "from the beginning".
34///
35/// # Idempotency
36/// Import checks `event_id` (ULID) against the local `events` table.
37/// `INSERT OR IGNORE` in `insert_event` already provides this, but we also
38/// count skipped events so the caller can report applied/skipped.
39///
40/// # Directory protocol (3.2)
41/// `<sync_dir>/<machine_id>/<cursor>.jsonl` — each batch file is atomically
42/// written (temp + rename).  A per-source-cursor registry lives at
43/// `.kimetsu/sync-cursors.json`.  `kimetsu brain sync` (no args):
44///   1. Write this machine's new events under `<sync_dir>/<machine_id>/`.
45///   2. For every OTHER subdirectory (= other machine), read batches after
46///      the locally stored cursor for that machine, import them (idempotent),
47///      and advance the cursor.
48use 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
65// ---------------------------------------------------------------------------
66// Allowlist
67// ---------------------------------------------------------------------------
68
69/// Kinds that carry durable memory-lifecycle meaning and SHOULD be replicated.
70const SYNC_ALLOWED_KINDS: &[&str] = &[
71    "memory.accepted",
72    "memory.proposed",
73    "memory.rejected",
74    "memory.invalidated",
75    "memory.cited",
76    "memory.superseded",
77];
78
79/// Returns `true` when `kind` is allowed in a sync batch.
80pub fn is_sync_allowed(kind: &str) -> bool {
81    SYNC_ALLOWED_KINDS.contains(&kind)
82}
83
84// ---------------------------------------------------------------------------
85// Event wire format
86// ---------------------------------------------------------------------------
87
88/// One line in a sync JSONL batch.  Carries the full event so the remote
89/// projector can replay it.  `payload` is the redacted payload (same
90/// redaction the projector applies at ingest).
91#[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    /// v3.0 #3: who/where wrote this event (`<machine_id>/<agent>`). Carried so a
101    /// replicated/team brain can attribute each event. `#[serde(default)]` keeps
102    /// pre-v8 sync batches (no `origin`) importable.
103    #[serde(default)]
104    pub origin: Option<String>,
105    /// v3.0 #3 Slice B: the event's HLC (canonical string) for convergent
106    /// total-order replay. `#[serde(default)]` keeps pre-v9 batches importable
107    /// (the importer synthesizes a local HLC for those).
108    #[serde(default)]
109    pub hlc: Option<String>,
110}
111
112impl From<&Event> for SyncEvent {
113    fn from(e: &Event) -> Self {
114        // Apply the same payload redaction the projector does so sync batches
115        // are never a second secret store (matches projector::redact_memory_event).
116        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        // Preserve the REMOTE HLC; advance the local clock past it so subsequent
143        // LOCAL events sort after everything imported (causality). A pre-v9 peer
144        // sends no HLC → synthesize a current local one so the event still sorts.
145        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            // Preserve the REMOTE origin — do NOT stamp the local process origin.
163            origin: s.origin,
164            hlc,
165        })
166    }
167}
168
169/// Apply export-time redaction to the event payload — same logic as
170/// `projector::redact_memory_event` but returns an owned `Value`.
171fn 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// ---------------------------------------------------------------------------
201// 3.1 — Export
202// ---------------------------------------------------------------------------
203
204/// Summary returned by [`export_events`].
205#[derive(Debug, Clone, Default)]
206pub struct ExportSummary {
207    /// Number of events written to the batch.
208    pub exported: usize,
209    /// The highest rowid included in this batch (= next cursor).
210    pub next_cursor: i64,
211}
212
213/// Export durable events from `conn` after `since_rowid` (exclusive).
214///
215/// Only events whose `kind` is on the sync allowlist are included.
216/// Redaction is applied inline (no workspace paths / secrets leak).
217///
218/// When `out_path` is `None`, returns the JSONL as a `String`.
219/// When `out_path` is `Some(path)`, writes atomically via temp+rename.
220pub 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
261/// Read all (rowid, Event) pairs from the `events` table with rowid > `after`.
262fn 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// ---------------------------------------------------------------------------
335// 3.1 — Import
336// ---------------------------------------------------------------------------
337
338/// Summary returned by [`import_events`].
339#[derive(Debug, Clone, Default)]
340pub struct ImportSummary {
341    /// Events actually applied (projected into derived tables).
342    pub applied: usize,
343    /// Events skipped because their `event_id` already existed locally.
344    pub skipped: usize,
345}
346
347/// Import a JSONL batch (one `SyncEvent` per line) into `conn`.
348///
349/// Per-event idempotency: if the `event_id` already exists in the local
350/// `events` table, the event is skipped (no double-apply).
351/// `INSERT OR IGNORE` in `projector::insert_event` provides the underlying
352/// dedup; we additionally count skips for reporting.
353///
354/// When `dry_run` is true, parse and count but do NOT write anything.
355pub 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        // Validate it's an allowed kind — defence-in-depth (the exporter
370        // already filters, but a hand-crafted batch might not).
371        if !is_sync_allowed(&se.kind) {
372            // Skip silently — telemetry/local kinds should never appear.
373            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        // Check whether event_id already exists.
381        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        // Apply through the projector: inserts into events table + projects
401        // into derived tables.  The projector's `apply_events` wraps in a
402        // transaction; we call it one event at a time to keep the
403        // applied/skipped tally accurate.
404        projector::apply_events(conn, &[event])?;
405        summary.applied += 1;
406    }
407    Ok(summary)
408}
409
410/// Slice B: count unresolved concurrent-supersede conflicts surfaced by team
411/// sync (a member superseded to two different survivors). Deterministic across
412/// brains; shown by `kimetsu brain sync --status`.
413pub 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
418/// Read a JSONL batch file and import it.
419pub 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// ---------------------------------------------------------------------------
437// 3.2 — Sync cursor registry
438// ---------------------------------------------------------------------------
439
440/// Per-source cursor state persisted at `.kimetsu/sync-cursors.json`.
441///
442/// Keys are machine_id strings; values are the last rowid imported from that
443/// machine.  Our OWN machine_id is in here too (last exported rowid).
444#[derive(Debug, Clone, Default, Serialize, Deserialize)]
445pub struct SyncCursors {
446    /// map machine_id → last imported rowid (0 = never imported).
447    #[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
478// ---------------------------------------------------------------------------
479// 3.2 — Directory protocol
480// ---------------------------------------------------------------------------
481
482/// The max rowid among all rows in the local `events` table with an allowed
483/// kind.  This is what we compare against the stored export cursor to decide
484/// whether there's anything new to push.
485pub 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
503/// Write this machine's new events as a JSONL batch under
504/// `<sync_dir>/<machine_id>/<cursor>.jsonl` (atomic write).
505///
506/// Returns the number of events written and the new export cursor.
507pub 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    // Re-run for real (not dry_run) to get the content.
520    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
538/// Pull and import all batches from `<sync_dir>/<source_machine_id>/` that
539/// come AFTER `since_cursor`.  Updates the cursor in the registry.
540///
541/// Batches are files named `<rowid>.jsonl`; we sort numerically and process
542/// only those whose stem > since_cursor.
543pub 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    // Collect batch files, parse their numeric stem (= the export cursor at
556    // the time they were written, i.e. the highest rowid in that batch on
557    // the source machine).
558    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
594/// Full sync cycle:
595/// 1. Push this machine's new events.
596/// 2. Pull every other machine's new batches.
597/// 3. Persist updated cursors.
598///
599/// Returns a summary of what happened.
600pub 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    // --- push ---
611    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    // --- pull ---
618    let mut total_applied = 0usize;
619    let mut total_skipped = 0usize;
620    let mut machines_pulled: Vec<String> = Vec::new();
621
622    // List subdirs (each = one machine's batch directory).
623    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(); // deterministic order
638
639        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    // Slice B: total-order replay. After importing peer events (which were
659    // applied incrementally in arrival order), re-project the merged log in HLC
660    // order so this brain converges to the SAME state every peer reaches,
661    // independent of import order. Skipped when nothing was pulled.
662    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/// Summary of a full sync cycle.
676#[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// ---------------------------------------------------------------------------
686// 3.3 — Doctor / status
687// ---------------------------------------------------------------------------
688
689/// Status of the sync configuration and state.
690#[derive(Debug, Clone)]
691pub struct SyncStatus {
692    pub sync_dir: Option<PathBuf>,
693    pub machine_id: String,
694    /// Per-source: (machine_id, cursor, pending_count)
695    pub sources: Vec<(String, i64, usize)>,
696    pub local_pending: usize,
697}
698
699/// Compute the sync status without performing any writes.
700pub 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    // Count this machine's unpushed events.
710    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
750// ---------------------------------------------------------------------------
751// Atomic write helper
752// ---------------------------------------------------------------------------
753
754/// Write `data` to `path` atomically via a sibling temp file + rename.
755pub 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
786// ---------------------------------------------------------------------------
787// Extension trait for Option with rusqlite
788// ---------------------------------------------------------------------------
789
790trait 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// ---------------------------------------------------------------------------
805// Tests
806// ---------------------------------------------------------------------------
807
808#[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    // Slice B headline: two brains that exchange the same events CONVERGE to an
868    // identical projection regardless of the order edits were made/imported —
869    // including the one genuinely-divergent op (memory.superseded), which an HLC
870    // replay resolves last-writer-wins, plus a surfaced conflict.
871    #[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()); // sentinel → standalone cite outcome
877        let (m1, s1, s2) = ("mem-m1", "mem-s1", "mem-s2");
878
879        // Shared base: identical accepted events on both brains.
880        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        // Divergent edits, created in sequence so B's supersede has a LATER HLC.
901        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        // Cross-exchange the full logs, then converge (rebuild in HLC order).
921        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        // superseded_by converges to the LATER-HLC survivor (s2) on BOTH brains.
929        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        // Additive field (use_count) converges; both cites counted.
949        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        // Even order-sensitive confidence converges (same HLC replay order).
961        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        // The genuine concurrent supersede is surfaced (once) on both brains.
977        assert_eq!(sync_conflict_count(&a).unwrap(), 1);
978        assert_eq!(sync_conflict_count(&b).unwrap(), 1);
979    }
980
981    // S3-1: Export excludes telemetry + work.episode; only memory.* kinds appear.
982    #[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    // S3-2: Import is idempotent — re-importing the same batch is a NO-OP.
1024    #[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    // S3-3: Round-trip — memories exported from brain A appear in brain B.
1045    #[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        // Verify memories are projected in brain B.
1057        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        // Both memory ids should exist.
1066        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    // S3-4: Cursor advance — second export only emits the new event.
1079    #[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        // Add one more memory.accepted event.
1087        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    // S3-5: Redaction — secrets in memory.accepted payloads are redacted in export.
1119    #[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    // S3-6: Dry-run on import reports what WOULD apply without writing.
1151    #[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    // S3-7: Directory protocol — push writes a file; pull reads and imports it.
1169    #[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        // Brain A.
1176        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        // Brain B — pull from A.
1184        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        // Idempotent: sync B again — nothing new to apply.
1192        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    /// Thin wrapper so the test can call the module function by its short name.
1201    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}