Skip to main content

ito_core/
event_forwarder.rs

1//! Client-side forwarding of locally produced audit events to the backend.
2//!
3//! The forwarder reads new events from the local JSONL audit log, batches
4//! them, and submits each batch to the backend event ingest endpoint with
5//! an idempotency key so retries are safe. A checkpoint file under
6//! `.ito/.state/` tracks the last forwarded line offset to avoid
7//! re-sending the entire log on each invocation.
8
9use std::path::Path;
10use std::thread;
11use std::time::Duration;
12
13use ito_domain::audit::event::AuditEvent;
14use ito_domain::backend::{BackendError, BackendEventIngestClient, EventBatch};
15
16use crate::backend_client::{idempotency_key, is_retriable_status};
17use crate::errors::{CoreError, CoreResult};
18
19/// Maximum events per batch submission.
20const DEFAULT_BATCH_SIZE: usize = 100;
21
22/// Checkpoint file name within `.ito/.state/`.
23const CHECKPOINT_FILE: &str = "event-forward-offset";
24
25/// Result of a forwarding run, used for CLI diagnostics.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct ForwardResult {
28    /// Total events forwarded in this run.
29    pub forwarded: usize,
30    /// Total events skipped as duplicates by the backend.
31    pub duplicates: usize,
32    /// Number of batches that failed after exhausting retries.
33    pub failed_batches: usize,
34    /// Total events in the local log.
35    pub total_local: usize,
36    /// Offset after forwarding (line count forwarded so far).
37    pub new_offset: usize,
38}
39
40/// Configuration for the event forwarder.
41#[derive(Debug, Clone)]
42pub struct ForwarderConfig {
43    /// Maximum events per batch.
44    pub batch_size: usize,
45    /// Maximum retry attempts per batch on transient failure.
46    pub max_retries: u32,
47    /// Base delay between retries (doubles on each attempt).
48    pub retry_base_delay: Duration,
49}
50
51impl Default for ForwarderConfig {
52    fn default() -> Self {
53        Self {
54            batch_size: DEFAULT_BATCH_SIZE,
55            max_retries: 3,
56            retry_base_delay: Duration::from_millis(500),
57        }
58    }
59}
60
61/// Forward new local audit events to the backend.
62///
63/// Reads the audit log, skips events already forwarded (tracked by offset),
64/// batches new events, and submits each batch with an idempotency key.
65/// Updates the checkpoint file on success.
66///
67/// This function is designed to be called best-effort from the CLI after
68/// command completion in backend mode.
69pub fn forward_events(
70    ingest_client: &dyn BackendEventIngestClient,
71    ito_path: &Path,
72    config: &ForwarderConfig,
73) -> CoreResult<ForwardResult> {
74    let all_events = read_all_events(ito_path);
75    let total_local = all_events.len();
76
77    let mut current_offset = read_checkpoint(ito_path);
78    if current_offset > total_local {
79        current_offset = total_local;
80    }
81
82    if current_offset >= total_local {
83        return Ok(ForwardResult {
84            forwarded: 0,
85            duplicates: 0,
86            failed_batches: 0,
87            total_local,
88            new_offset: current_offset,
89        });
90    }
91
92    let batch_size = config.batch_size.max(1);
93    let new_events = &all_events[current_offset..];
94    let mut forwarded = 0usize;
95    let mut duplicates = 0usize;
96    let mut failed_batches = 0usize;
97    let mut offset = current_offset;
98
99    for chunk in new_events.chunks(batch_size) {
100        let batch = EventBatch {
101            events: chunk.to_vec(),
102            idempotency_key: idempotency_key("event-forward"),
103        };
104
105        match submit_with_retry(ingest_client, &batch, config) {
106            Ok(result) => {
107                forwarded += result.accepted;
108                duplicates += result.duplicates;
109                offset += chunk.len();
110                // Persist offset after each successful batch
111                if let Err(e) = write_checkpoint(ito_path, offset) {
112                    tracing::warn!("failed to write forwarding checkpoint: {e}");
113                }
114            }
115            Err(e) => {
116                tracing::warn!("event forwarding batch failed: {e}");
117                failed_batches += 1;
118                // Stop forwarding on failure to preserve ordering
119                break;
120            }
121        }
122    }
123
124    Ok(ForwardResult {
125        forwarded,
126        duplicates,
127        failed_batches,
128        total_local,
129        new_offset: offset,
130    })
131}
132
133/// Submit a batch with bounded retries on transient failures.
134fn submit_with_retry(
135    client: &dyn BackendEventIngestClient,
136    batch: &EventBatch,
137    config: &ForwarderConfig,
138) -> CoreResult<ito_domain::backend::EventIngestResult> {
139    let mut attempts = 0u32;
140    loop {
141        match client.ingest(batch) {
142            Ok(result) => return Ok(result),
143            Err(err) => {
144                attempts += 1;
145                if !is_retriable_backend_error(&err) || attempts > config.max_retries {
146                    return Err(backend_ingest_error_to_core(err));
147                }
148                // Exponential backoff
149                let delay = config.retry_base_delay * 2u32.saturating_pow(attempts - 1);
150                thread::sleep(delay);
151            }
152        }
153    }
154}
155
156/// Check if a backend error is transient and worth retrying.
157fn is_retriable_backend_error(err: &BackendError) -> bool {
158    match err {
159        BackendError::Unavailable(_) => true,
160        BackendError::Other(msg) => {
161            // Check for HTTP status codes embedded in the message
162            if let Some(code_str) = msg.strip_prefix("HTTP ")
163                && let Ok(code) = code_str
164                    .chars()
165                    .take_while(|c| c.is_ascii_digit())
166                    .collect::<String>()
167                    .parse::<u16>()
168            {
169                return is_retriable_status(code);
170            }
171            false
172        }
173        BackendError::Unauthorized(_) => false,
174        BackendError::NotFound(_) => false,
175        BackendError::LeaseConflict(_) => false,
176        BackendError::RevisionConflict(_) => false,
177    }
178}
179
180/// Convert a backend ingest error to a `CoreError`.
181fn backend_ingest_error_to_core(err: BackendError) -> CoreError {
182    match err {
183        BackendError::Unavailable(msg) => CoreError::process(format!(
184            "Backend unavailable during event forwarding: {msg}"
185        )),
186        BackendError::Unauthorized(msg) => CoreError::validation(format!(
187            "Backend auth failed during event forwarding: {msg}"
188        )),
189        BackendError::NotFound(msg) => {
190            CoreError::not_found(format!("Backend ingest endpoint not found: {msg}"))
191        }
192        BackendError::Other(msg) => {
193            CoreError::process(format!("Backend error during event forwarding: {msg}"))
194        }
195        BackendError::LeaseConflict(c) => CoreError::process(format!(
196            "Unexpected lease conflict during event forwarding: {}",
197            c.change_id
198        )),
199        BackendError::RevisionConflict(c) => CoreError::process(format!(
200            "Unexpected revision conflict during event forwarding: {}",
201            c.change_id
202        )),
203    }
204}
205
206// ── Checkpoint persistence ─────────────────────────────────────────
207
208/// Path to the forwarding checkpoint file.
209fn checkpoint_path(ito_path: &Path) -> std::path::PathBuf {
210    ito_path.join(".state").join(CHECKPOINT_FILE)
211}
212
213/// Read the current forwarding offset from the checkpoint file.
214///
215/// Returns 0 if the file does not exist or is malformed.
216fn read_checkpoint(ito_path: &Path) -> usize {
217    let path = checkpoint_path(ito_path);
218    let Ok(content) = std::fs::read_to_string(&path) else {
219        return 0;
220    };
221    content.trim().parse::<usize>().unwrap_or(0)
222}
223
224/// Write the forwarding offset to the checkpoint file.
225fn write_checkpoint(ito_path: &Path, offset: usize) -> CoreResult<()> {
226    let path = checkpoint_path(ito_path);
227    if let Some(parent) = path.parent() {
228        std::fs::create_dir_all(parent)
229            .map_err(|e| CoreError::io("creating checkpoint directory", e))?;
230    }
231    std::fs::write(&path, offset.to_string())
232        .map_err(|e| CoreError::io("writing forwarding checkpoint", e))
233}
234
235// ── Event reading ──────────────────────────────────────────────────
236
237/// Read all audit events from the routed local audit store.
238fn read_all_events(ito_path: &Path) -> Vec<AuditEvent> {
239    crate::audit::default_audit_store(ito_path).read_all()
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use ito_domain::audit::event::{EventContext, SCHEMA_VERSION};
246    use ito_domain::backend::{BackendError, EventIngestResult};
247    use std::path::Path;
248    use std::sync::Mutex;
249    use std::sync::atomic::{AtomicUsize, Ordering};
250
251    fn run_git(repo: &Path, args: &[&str]) {
252        let output = std::process::Command::new("git")
253            .args(args)
254            .current_dir(repo)
255            .env_remove("GIT_DIR")
256            .env_remove("GIT_WORK_TREE")
257            .output()
258            .expect("git should run");
259        assert!(
260            output.status.success(),
261            "git command failed: git {}\nstdout:\n{}\nstderr:\n{}",
262            args.join(" "),
263            String::from_utf8_lossy(&output.stdout),
264            String::from_utf8_lossy(&output.stderr)
265        );
266    }
267
268    fn init_git_repo(repo: &Path) {
269        run_git(repo, &["init"]);
270        run_git(repo, &["config", "user.email", "test@example.com"]);
271        run_git(repo, &["config", "user.name", "Test User"]);
272        run_git(repo, &["config", "commit.gpgsign", "false"]);
273        std::fs::write(repo.join("README.md"), "hi\n").expect("write readme");
274        run_git(repo, &["add", "README.md"]);
275        run_git(repo, &["commit", "-m", "initial"]);
276    }
277
278    fn make_event(id: &str) -> AuditEvent {
279        AuditEvent {
280            v: SCHEMA_VERSION,
281            ts: "2026-02-28T10:00:00.000Z".to_string(),
282            entity: "task".to_string(),
283            entity_id: id.to_string(),
284            scope: Some("test-change".to_string()),
285            op: "create".to_string(),
286            from: None,
287            to: Some("pending".to_string()),
288            actor: "cli".to_string(),
289            by: "@test".to_string(),
290            meta: None,
291            count: 1,
292            ctx: EventContext {
293                session_id: "test-sid".to_string(),
294                harness_session_id: None,
295                branch: None,
296                worktree: None,
297                commit: None,
298            },
299        }
300    }
301
302    /// A fake ingest client that records calls and returns configurable results.
303    struct FakeIngestClient {
304        call_count: AtomicUsize,
305        results: Mutex<Vec<Result<EventIngestResult, BackendError>>>,
306    }
307
308    impl FakeIngestClient {
309        fn always_ok() -> Self {
310            Self {
311                call_count: AtomicUsize::new(0),
312                results: Mutex::new(Vec::new()),
313            }
314        }
315
316        fn with_results(results: Vec<Result<EventIngestResult, BackendError>>) -> Self {
317            Self {
318                call_count: AtomicUsize::new(0),
319                results: Mutex::new(results),
320            }
321        }
322
323        fn calls(&self) -> usize {
324            self.call_count.load(Ordering::SeqCst)
325        }
326    }
327
328    impl BackendEventIngestClient for FakeIngestClient {
329        fn ingest(&self, batch: &EventBatch) -> Result<EventIngestResult, BackendError> {
330            let idx = self.call_count.fetch_add(1, Ordering::SeqCst);
331            let results = self.results.lock().unwrap();
332            if idx < results.len() {
333                return results[idx].clone();
334            }
335            // Default: accept all
336            Ok(EventIngestResult {
337                accepted: batch.events.len(),
338                duplicates: 0,
339            })
340        }
341    }
342
343    fn write_events_to_log(ito_path: &Path, events: &[AuditEvent]) {
344        let writer = crate::audit::default_audit_store(ito_path);
345        for event in events {
346            crate::audit::AuditWriter::append(writer.as_ref(), event).unwrap();
347        }
348    }
349
350    #[test]
351    fn forward_reads_events_from_routed_local_store() {
352        let tmp = tempfile::tempdir().unwrap();
353        init_git_repo(tmp.path());
354        let ito_path = tmp.path().join(".ito");
355        std::fs::create_dir_all(&ito_path).unwrap();
356
357        let store = crate::audit::default_audit_store(&ito_path);
358        crate::audit::AuditWriter::append(store.as_ref(), &make_event("1.1")).unwrap();
359
360        let client = FakeIngestClient::always_ok();
361        let config = ForwarderConfig::default();
362        let result = forward_events(&client, &ito_path, &config).unwrap();
363
364        assert_eq!(result.forwarded, 1);
365        assert_eq!(result.total_local, 1);
366        assert_eq!(client.calls(), 1);
367    }
368
369    #[test]
370    fn forward_no_events_returns_zero() {
371        let tmp = tempfile::tempdir().unwrap();
372        let ito_path = tmp.path().join(".ito");
373        let client = FakeIngestClient::always_ok();
374        let config = ForwarderConfig::default();
375
376        let result = forward_events(&client, &ito_path, &config).unwrap();
377        assert_eq!(result.forwarded, 0);
378        assert_eq!(result.total_local, 0);
379        assert_eq!(result.new_offset, 0);
380        assert_eq!(result.failed_batches, 0);
381        assert_eq!(client.calls(), 0);
382    }
383
384    #[test]
385    fn forward_sends_all_new_events() {
386        let tmp = tempfile::tempdir().unwrap();
387        let ito_path = tmp.path().join(".ito");
388
389        let events: Vec<AuditEvent> = (0..5).map(|i| make_event(&format!("1.{i}"))).collect();
390        write_events_to_log(&ito_path, &events);
391
392        let client = FakeIngestClient::always_ok();
393        let config = ForwarderConfig {
394            batch_size: 10,
395            ..ForwarderConfig::default()
396        };
397
398        let result = forward_events(&client, &ito_path, &config).unwrap();
399        assert_eq!(result.forwarded, 5);
400        assert_eq!(result.total_local, 5);
401        assert_eq!(result.new_offset, 5);
402        assert_eq!(result.failed_batches, 0);
403        assert_eq!(client.calls(), 1); // All in one batch
404    }
405
406    #[test]
407    fn forward_respects_checkpoint() {
408        let tmp = tempfile::tempdir().unwrap();
409        let ito_path = tmp.path().join(".ito");
410
411        let events: Vec<AuditEvent> = (0..5).map(|i| make_event(&format!("1.{i}"))).collect();
412        write_events_to_log(&ito_path, &events);
413
414        // Pre-set checkpoint at 3 (already forwarded 3 events)
415        write_checkpoint(&ito_path, 3).unwrap();
416
417        let client = FakeIngestClient::always_ok();
418        let config = ForwarderConfig::default();
419
420        let result = forward_events(&client, &ito_path, &config).unwrap();
421        assert_eq!(result.forwarded, 2); // Only events 3 and 4
422        assert_eq!(result.new_offset, 5);
423    }
424
425    #[test]
426    fn forward_skips_when_fully_forwarded() {
427        let tmp = tempfile::tempdir().unwrap();
428        let ito_path = tmp.path().join(".ito");
429
430        let events: Vec<AuditEvent> = (0..3).map(|i| make_event(&format!("1.{i}"))).collect();
431        write_events_to_log(&ito_path, &events);
432        write_checkpoint(&ito_path, 3).unwrap();
433
434        let client = FakeIngestClient::always_ok();
435        let config = ForwarderConfig::default();
436
437        let result = forward_events(&client, &ito_path, &config).unwrap();
438        assert_eq!(result.forwarded, 0);
439        assert_eq!(result.new_offset, 3);
440        assert_eq!(client.calls(), 0);
441    }
442
443    #[test]
444    fn forward_batches_correctly() {
445        let tmp = tempfile::tempdir().unwrap();
446        let ito_path = tmp.path().join(".ito");
447
448        let events: Vec<AuditEvent> = (0..7).map(|i| make_event(&format!("1.{i}"))).collect();
449        write_events_to_log(&ito_path, &events);
450
451        let client = FakeIngestClient::always_ok();
452        let config = ForwarderConfig {
453            batch_size: 3,
454            ..ForwarderConfig::default()
455        };
456
457        let result = forward_events(&client, &ito_path, &config).unwrap();
458        assert_eq!(result.forwarded, 7);
459        assert_eq!(client.calls(), 3); // 3 + 3 + 1
460    }
461
462    #[test]
463    fn forward_stops_on_permanent_failure() {
464        let tmp = tempfile::tempdir().unwrap();
465        let ito_path = tmp.path().join(".ito");
466
467        let events: Vec<AuditEvent> = (0..6).map(|i| make_event(&format!("1.{i}"))).collect();
468        write_events_to_log(&ito_path, &events);
469
470        let client = FakeIngestClient::with_results(vec![
471            Ok(EventIngestResult {
472                accepted: 3,
473                duplicates: 0,
474            }),
475            Err(BackendError::Unauthorized("bad token".to_string())),
476        ]);
477        let config = ForwarderConfig {
478            batch_size: 3,
479            max_retries: 0,
480            retry_base_delay: Duration::from_millis(1),
481        };
482
483        let result = forward_events(&client, &ito_path, &config).unwrap();
484        assert_eq!(result.forwarded, 3); // First batch succeeded
485        assert_eq!(result.failed_batches, 1);
486        assert_eq!(result.new_offset, 3); // Checkpoint at first batch end
487    }
488
489    #[test]
490    fn forward_retries_transient_failure() {
491        let tmp = tempfile::tempdir().unwrap();
492        let ito_path = tmp.path().join(".ito");
493
494        let events: Vec<AuditEvent> = (0..2).map(|i| make_event(&format!("1.{i}"))).collect();
495        write_events_to_log(&ito_path, &events);
496
497        let client = FakeIngestClient::with_results(vec![
498            Err(BackendError::Unavailable("timeout".to_string())),
499            Ok(EventIngestResult {
500                accepted: 2,
501                duplicates: 0,
502            }),
503        ]);
504        let config = ForwarderConfig {
505            batch_size: 10,
506            max_retries: 3,
507            retry_base_delay: Duration::from_millis(1),
508        };
509
510        let result = forward_events(&client, &ito_path, &config).unwrap();
511        assert_eq!(result.forwarded, 2);
512        assert_eq!(result.failed_batches, 0);
513        assert_eq!(client.calls(), 2); // 1 retry + 1 success
514    }
515
516    #[test]
517    fn forward_reports_duplicates() {
518        let tmp = tempfile::tempdir().unwrap();
519        let ito_path = tmp.path().join(".ito");
520
521        let events: Vec<AuditEvent> = (0..3).map(|i| make_event(&format!("1.{i}"))).collect();
522        write_events_to_log(&ito_path, &events);
523
524        let client = FakeIngestClient::with_results(vec![Ok(EventIngestResult {
525            accepted: 1,
526            duplicates: 2,
527        })]);
528        let config = ForwarderConfig::default();
529
530        let result = forward_events(&client, &ito_path, &config).unwrap();
531        assert_eq!(result.forwarded, 1);
532        assert_eq!(result.duplicates, 2);
533    }
534
535    #[test]
536    fn checkpoint_roundtrip() {
537        let tmp = tempfile::tempdir().unwrap();
538        let ito_path = tmp.path().join(".ito");
539
540        assert_eq!(read_checkpoint(&ito_path), 0);
541
542        write_checkpoint(&ito_path, 42).unwrap();
543        assert_eq!(read_checkpoint(&ito_path), 42);
544
545        write_checkpoint(&ito_path, 100).unwrap();
546        assert_eq!(read_checkpoint(&ito_path), 100);
547    }
548
549    #[test]
550    fn checkpoint_missing_returns_zero() {
551        let tmp = tempfile::tempdir().unwrap();
552        let ito_path = tmp.path().join(".ito");
553        assert_eq!(read_checkpoint(&ito_path), 0);
554    }
555
556    #[test]
557    fn is_retriable_backend_error_checks() {
558        assert!(is_retriable_backend_error(&BackendError::Unavailable(
559            "timeout".to_string()
560        )));
561        assert!(!is_retriable_backend_error(&BackendError::Unauthorized(
562            "bad".to_string()
563        )));
564        assert!(!is_retriable_backend_error(&BackendError::NotFound(
565            "nope".to_string()
566        )));
567    }
568
569    #[test]
570    fn forward_persists_checkpoint_per_batch() {
571        let tmp = tempfile::tempdir().unwrap();
572        let ito_path = tmp.path().join(".ito");
573
574        let events: Vec<AuditEvent> = (0..4).map(|i| make_event(&format!("1.{i}"))).collect();
575        write_events_to_log(&ito_path, &events);
576
577        let client = FakeIngestClient::always_ok();
578        let config = ForwarderConfig {
579            batch_size: 2,
580            ..ForwarderConfig::default()
581        };
582
583        forward_events(&client, &ito_path, &config).unwrap();
584        // Checkpoint should be at 4 after all batches
585        assert_eq!(read_checkpoint(&ito_path), 4);
586    }
587
588    #[test]
589    fn forward_result_equality() {
590        let a = ForwardResult {
591            forwarded: 5,
592            duplicates: 0,
593            failed_batches: 0,
594            total_local: 5,
595            new_offset: 5,
596        };
597        let b = a.clone();
598        assert_eq!(a, b);
599    }
600}