1pub mod harness_adapt;
7pub mod harness_metrics;
8pub mod observability;
9pub mod tool_receipts;
10
11pub use observability::{
12 evaluate_alerts, summarize, summarize_log, Alert, AlertKind, AlertThresholds, MetricsSummary,
13};
14
15use car_secrets::{
16 atomic_replace_private_file, create_private_file, open_private_append, revalidate_private_file,
17 revalidate_private_path,
18};
19use chrono::{DateTime, Utc};
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22use std::collections::{HashMap, HashSet, VecDeque};
23use std::fs;
24use std::future::Future;
25use std::io::{BufRead, BufReader, BufWriter, Read, Seek, SeekFrom, Write};
26use std::path::{Path, PathBuf};
27use std::pin::Pin;
28use std::sync::{mpsc, Arc, Condvar, Mutex, Weak};
29use std::task::{Context, Poll, Waker};
30use std::thread;
31use std::time::{Duration, Instant};
32use uuid::Uuid;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase")]
36pub struct EventLogStats {
37 pub events: usize,
38 pub spans: usize,
39 pub approx_event_bytes: usize,
40 pub approx_span_bytes: usize,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum EventKind {
47 RunStarted,
49 RunCancellationRequested,
51 RunCancellationResult,
53 ProposalReceived,
54 ProposalCompleted,
57 ActionValidated,
58 ActionRejected,
59 ActionExecuting,
60 ActionSucceeded,
61 ActionFailed,
62 ActionSkipped,
63 ActionRetrying,
64 ActionDeduplicated,
65 PolicyViolation,
66 StateChanged,
67 StateSnapshot,
68 StateCommitted,
72 StateRollback,
73 SkillDistilled,
75 SkillEvolved,
76 SkillDeprecated,
77 EvolutionTriggered,
78 CandidatePromoted,
82 CandidateRejected,
85 Consolidated,
87 ProactiveMemoryMaintained,
92 ProactiveMemoryIntervention,
93 ReplanAttempted,
95 ReplanProposalReceived,
96 ReplanRejected,
97 ReplanExhausted,
98 VoiceFastTurnStarted,
102 VoiceFastTurnEnded,
103 VoiceSidecarResolved,
104 VoiceSidecarFailed,
105 VoiceSidecarTimedOut,
106 VoiceTurnCancelled,
107 VoiceBridgePlayed,
108 GateAccepted,
114 GateRejected,
115 ModelFallback,
141 SessionScope,
148 PermissionDecision,
165 ApprovalRecorded,
171 BranchDecision,
178 AlternativeRejected,
184 InferenceMetered,
189 TransactionConflict,
196 AdmissionGateDecision,
206 ToolReceiptHallucination,
212 GoalEvaluated,
219 TurnCompleted,
227 RunCompleted,
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
234#[serde(rename_all = "snake_case")]
235pub enum SpanStatus {
236 Ok,
237 Error,
238 Unset,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct Span {
244 pub trace_id: String,
245 pub span_id: String,
246 pub parent_span_id: Option<String>,
247 pub name: String,
248 pub start_time: DateTime<Utc>,
249 pub end_time: Option<DateTime<Utc>>,
250 pub status: SpanStatus,
251 pub attributes: HashMap<String, Value>,
252}
253
254pub mod metric_keys {
259 pub const DURATION_MS: &str = "duration_ms";
261 pub const TOKENS_IN: &str = "tokens_in";
263 pub const TOKENS_OUT: &str = "tokens_out";
265 pub const COST_USD: &str = "cost_usd";
267}
268
269#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
275pub struct Metrics {
276 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub duration_ms: Option<f64>,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub tokens_in: Option<u64>,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub tokens_out: Option<u64>,
282 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub cost_usd: Option<f64>,
284}
285
286impl Metrics {
287 pub fn latency(duration_ms: f64) -> Self {
289 Self {
290 duration_ms: Some(duration_ms),
291 ..Default::default()
292 }
293 }
294
295 pub fn inference(tokens_in: u64, tokens_out: u64, cost_usd: Option<f64>) -> Self {
297 Self {
298 duration_ms: None,
299 tokens_in: Some(tokens_in),
300 tokens_out: Some(tokens_out),
301 cost_usd,
302 }
303 }
304
305 pub fn with_duration(mut self, duration_ms: f64) -> Self {
306 self.duration_ms = Some(duration_ms);
307 self
308 }
309
310 fn merge_into(&self, data: &mut HashMap<String, Value>) {
312 if let Some(d) = self.duration_ms {
313 data.insert(metric_keys::DURATION_MS.into(), Value::from(d));
314 }
315 if let Some(t) = self.tokens_in {
316 data.insert(metric_keys::TOKENS_IN.into(), Value::from(t));
317 }
318 if let Some(t) = self.tokens_out {
319 data.insert(metric_keys::TOKENS_OUT.into(), Value::from(t));
320 }
321 if let Some(c) = self.cost_usd {
322 data.insert(metric_keys::COST_USD.into(), Value::from(c));
323 }
324 }
325}
326
327#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
329pub struct Event {
330 pub kind: EventKind,
331 #[serde(default, skip_serializing_if = "Option::is_none")]
334 pub run_id: Option<String>,
335 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub client_id: Option<String>,
339 #[serde(default, skip_serializing_if = "Option::is_none")]
343 pub policy_session_id: Option<String>,
344 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub action_id: Option<String>,
346 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub proposal_id: Option<String>,
348 #[serde(default)]
349 pub data: HashMap<String, Value>,
350 #[serde(default = "Utc::now")]
351 pub timestamp: DateTime<Utc>,
352 #[serde(default, skip_serializing_if = "Option::is_none")]
357 pub prev_hash: Option<String>,
358 #[serde(default, skip_serializing_if = "Option::is_none")]
361 pub hash: Option<String>,
362}
363
364impl Event {
365 pub fn duration_ms(&self) -> Option<f64> {
367 self.data
368 .get(metric_keys::DURATION_MS)
369 .and_then(Value::as_f64)
370 }
371
372 pub fn tokens_in(&self) -> Option<u64> {
374 self.data
375 .get(metric_keys::TOKENS_IN)
376 .and_then(Value::as_u64)
377 }
378
379 pub fn tokens_out(&self) -> Option<u64> {
381 self.data
382 .get(metric_keys::TOKENS_OUT)
383 .and_then(Value::as_u64)
384 }
385
386 pub fn cost_usd(&self) -> Option<f64> {
388 self.data.get(metric_keys::COST_USD).and_then(Value::as_f64)
389 }
390
391 pub fn metrics(&self) -> Metrics {
393 Metrics {
394 duration_ms: self.duration_ms(),
395 tokens_in: self.tokens_in(),
396 tokens_out: self.tokens_out(),
397 cost_usd: self.cost_usd(),
398 }
399 }
400}
401
402#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
406pub struct MetricsTotals {
407 pub duration_ms: f64,
408 pub tokens_in: u64,
409 pub tokens_out: u64,
410 pub tokens: u64,
411 pub cost_usd: f64,
412 pub metered_events: usize,
414}
415
416pub fn metrics_totals_of(events: &[Event]) -> MetricsTotals {
421 let mut totals = MetricsTotals::default();
422 for ev in events {
423 let m = ev.metrics();
424 let mut metered = false;
425 if let Some(d) = m.duration_ms {
426 totals.duration_ms += d;
427 metered = true;
428 }
429 if let Some(t) = m.tokens_in {
430 totals.tokens_in = totals.tokens_in.saturating_add(t);
431 metered = true;
432 }
433 if let Some(t) = m.tokens_out {
434 totals.tokens_out = totals.tokens_out.saturating_add(t);
435 metered = true;
436 }
437 if let Some(c) = m.cost_usd {
438 totals.cost_usd += c;
439 metered = true;
440 }
441 if metered {
442 totals.metered_events += 1;
443 }
444 }
445 totals.tokens = totals.tokens_in.saturating_add(totals.tokens_out);
446 totals
447}
448
449#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
452pub struct AgentCost {
453 pub agent: String,
454 pub calls: u64,
456 pub tokens_in: u64,
457 pub tokens_out: u64,
458 pub cost_usd: f64,
459}
460
461pub fn cost_by_agent_of(events: &[Event]) -> Vec<AgentCost> {
469 use std::collections::BTreeMap;
470 let mut map: BTreeMap<String, AgentCost> = BTreeMap::new();
471 for e in events {
472 if e.kind != EventKind::InferenceMetered {
473 continue;
474 }
475 let agent = e
476 .data
477 .get("agent")
478 .and_then(|v| v.as_str())
479 .unwrap_or("unknown")
480 .to_string();
481 let entry = map.entry(agent.clone()).or_insert_with(|| AgentCost {
482 agent,
483 ..Default::default()
484 });
485 entry.calls += 1;
486 entry.tokens_in = entry.tokens_in.saturating_add(e.tokens_in().unwrap_or(0));
487 entry.tokens_out = entry.tokens_out.saturating_add(e.tokens_out().unwrap_or(0));
488 entry.cost_usd += e.cost_usd().unwrap_or(0.0);
489 }
490 map.into_values().collect()
491}
492
493enum JournalMessage {
512 Async(String),
513 Critical {
514 line: String,
515 known_existing: bool,
516 ack: JournalAcknowledgement,
517 },
518 #[cfg(test)]
519 Shutdown,
520}
521
522pub const MAX_CRITICAL_ACKNOWLEDGEMENT_TIMEOUT: Duration = Duration::from_secs(30);
526
527const DEFAULT_CRITICAL_ACKNOWLEDGEMENT_CAPACITY: usize = 64;
528
529#[derive(Debug, Clone, Copy, PartialEq, Eq)]
533pub enum JournalFailurePoint {
534 AsyncWrite,
535 Write,
536 Flush,
537 Fsync,
538 HoldAcknowledgement,
542}
543
544#[derive(Debug, Clone, Default)]
545pub struct JournalFailureInjector {
546 failures: Arc<Mutex<VecDeque<JournalFailurePoint>>>,
547 held_acknowledgements: Arc<Mutex<Vec<JournalAcknowledgement>>>,
548}
549
550impl JournalFailureInjector {
551 pub fn fail_next(&self, point: JournalFailurePoint) {
552 self.failures
553 .lock()
554 .expect("journal failure injector mutex poisoned")
555 .push_back(point);
556 }
557
558 fn take(&self, point: JournalFailurePoint) -> bool {
559 let mut failures = self
560 .failures
561 .lock()
562 .expect("journal failure injector mutex poisoned");
563 if failures.front() == Some(&point) {
564 failures.pop_front();
565 true
566 } else {
567 false
568 }
569 }
570
571 fn hold_acknowledgement(&self, ack: JournalAcknowledgement) {
572 self.held_acknowledgements
573 .lock()
574 .expect("journal held-acknowledgement mutex poisoned")
575 .push(ack);
576 }
577
578 #[doc(hidden)]
581 pub fn held_acknowledgement_count(&self) -> usize {
582 self.held_acknowledgements
583 .lock()
584 .expect("journal held-acknowledgement mutex poisoned")
585 .len()
586 }
587
588 #[doc(hidden)]
591 pub fn release_held_acknowledgements(&self) {
592 let acknowledgements: Vec<_> = self
593 .held_acknowledgements
594 .lock()
595 .expect("journal held-acknowledgement mutex poisoned")
596 .drain(..)
597 .collect();
598 for acknowledgement in acknowledgements {
599 acknowledgement.send(Ok(()));
600 }
601 }
602}
603
604#[derive(Debug)]
605enum CriticalPreAcceptanceError {
606 WriterUnavailable,
607 WriterStopped,
608 CoordinatorUnavailable(String),
609 CapacityExhausted { capacity: usize },
610 InvalidAcknowledgementTimeout { requested: Duration },
611}
612
613impl std::fmt::Display for CriticalPreAcceptanceError {
614 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
615 match self {
616 Self::WriterUnavailable => write!(formatter, "journal writer thread is unavailable"),
617 Self::WriterStopped => {
618 write!(
619 formatter,
620 "journal writer stopped before accepting critical append"
621 )
622 }
623 Self::CoordinatorUnavailable(reason) => write!(
624 formatter,
625 "critical acknowledgement coordinator is unavailable: {reason}"
626 ),
627 Self::CapacityExhausted { capacity } => write!(
628 formatter,
629 "critical acknowledgement capacity is exhausted ({capacity} in flight)"
630 ),
631 Self::InvalidAcknowledgementTimeout { requested } => write!(
632 formatter,
633 "critical acknowledgement timeout must be between 1ns and {}ms, got {}ms",
634 MAX_CRITICAL_ACKNOWLEDGEMENT_TIMEOUT.as_millis(),
635 requested.as_millis()
636 ),
637 }
638 }
639}
640
641#[derive(Debug)]
642enum CriticalPostAcceptanceError {
643 DurabilityFailure(String),
644 AcknowledgementTimedOut { timeout: Duration },
645 CoordinatorStopped,
646}
647
648impl std::fmt::Display for CriticalPostAcceptanceError {
649 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
650 match self {
651 Self::DurabilityFailure(reason) => write!(formatter, "{reason}"),
652 Self::AcknowledgementTimedOut { timeout } => write!(
653 formatter,
654 "journal writer did not acknowledge within {}ms",
655 timeout.as_millis()
656 ),
657 Self::CoordinatorStopped => {
658 write!(
659 formatter,
660 "acknowledgement coordinator stopped after enqueue"
661 )
662 }
663 }
664 }
665}
666
667struct AsyncAcknowledgementState {
668 terminal: bool,
669 result: Option<Result<(), CriticalPostAcceptanceError>>,
670 waker: Option<Waker>,
671}
672
673struct AsyncAcknowledgementEntry {
674 id: u64,
675 deadline: Instant,
676 timeout: Duration,
677 manager: Weak<AsyncAcknowledgementManagerInner>,
678 state: Mutex<AsyncAcknowledgementState>,
679}
680
681impl AsyncAcknowledgementEntry {
682 fn complete(&self, result: Result<(), CriticalPostAcceptanceError>) {
683 self.complete_deciding(|| result);
684 }
685
686 fn complete_writer(&self, result: std::io::Result<()>) {
687 self.complete_deciding(|| {
688 if Instant::now() >= self.deadline {
689 Err(CriticalPostAcceptanceError::AcknowledgementTimedOut {
690 timeout: self.timeout,
691 })
692 } else {
693 result.map_err(|error| {
694 CriticalPostAcceptanceError::DurabilityFailure(error.to_string())
695 })
696 }
697 });
698 }
699
700 fn complete_deciding(&self, decide: impl FnOnce() -> Result<(), CriticalPostAcceptanceError>) {
701 let waker = {
702 let mut state = self
703 .state
704 .lock()
705 .expect("journal async-acknowledgement mutex poisoned");
706 if state.terminal {
707 return;
708 }
709 state.terminal = true;
710 state.result = Some(decide());
711 state.waker.take()
712 };
713 if let Some(manager) = self.manager.upgrade() {
714 manager.remove(self.id);
715 }
716 if let Some(waker) = waker {
717 waker.wake();
718 }
719 }
720
721 fn cancel_preacceptance(&self) {
722 {
723 let mut state = self
724 .state
725 .lock()
726 .expect("journal async-acknowledgement mutex poisoned");
727 if state.terminal {
728 return;
729 }
730 state.terminal = true;
731 }
732 if let Some(manager) = self.manager.upgrade() {
733 manager.remove(self.id);
734 }
735 }
736}
737
738struct AsyncAcknowledgement {
739 entry: Arc<AsyncAcknowledgementEntry>,
740}
741
742impl Future for AsyncAcknowledgement {
743 type Output = Result<(), CriticalPostAcceptanceError>;
744
745 fn poll(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Self::Output> {
746 let mut state = self
747 .entry
748 .state
749 .lock()
750 .expect("journal async-acknowledgement mutex poisoned");
751 match state.result.take() {
752 Some(result) => Poll::Ready(result),
753 None => {
754 state.waker = Some(context.waker().clone());
755 Poll::Pending
756 }
757 }
758 }
759}
760
761struct AsyncAcknowledgementManagerState {
762 entries: HashMap<u64, Arc<AsyncAcknowledgementEntry>>,
763 next_id: u64,
764 shutting_down: bool,
765}
766
767struct AsyncAcknowledgementManagerInner {
768 capacity: usize,
769 state: Mutex<AsyncAcknowledgementManagerState>,
770 changed: Condvar,
771 #[cfg(test)]
772 expiry_barrier: Mutex<Option<AsyncAcknowledgementExpiryBarrier>>,
773}
774
775#[cfg(test)]
776#[derive(Clone)]
777struct AsyncAcknowledgementExpiryBarrier {
778 removed: Arc<std::sync::Barrier>,
779 release: Arc<std::sync::Barrier>,
780}
781
782#[cfg(test)]
783impl AsyncAcknowledgementExpiryBarrier {
784 fn new() -> Self {
785 Self {
786 removed: Arc::new(std::sync::Barrier::new(2)),
787 release: Arc::new(std::sync::Barrier::new(2)),
788 }
789 }
790
791 fn pause_after_removal(&self) {
792 self.removed.wait();
793 self.release.wait();
794 }
795
796 fn wait_until_removed(&self) {
797 self.removed.wait();
798 }
799
800 fn allow_timeout_completion(&self) {
801 self.release.wait();
802 }
803}
804
805impl AsyncAcknowledgementManagerInner {
806 fn remove(&self, id: u64) {
807 let removed = self
808 .state
809 .lock()
810 .expect("journal acknowledgement-manager mutex poisoned")
811 .entries
812 .remove(&id)
813 .is_some();
814 if removed {
815 self.changed.notify_all();
816 }
817 }
818}
819
820struct AsyncAcknowledgementManager {
821 inner: Arc<AsyncAcknowledgementManagerInner>,
822 worker: Mutex<Option<thread::JoinHandle<()>>>,
823}
824
825impl AsyncAcknowledgementManager {
826 fn new(capacity: usize) -> Self {
827 Self {
828 inner: Arc::new(AsyncAcknowledgementManagerInner {
829 capacity,
830 state: Mutex::new(AsyncAcknowledgementManagerState {
831 entries: HashMap::new(),
832 next_id: 0,
833 shutting_down: false,
834 }),
835 changed: Condvar::new(),
836 #[cfg(test)]
837 expiry_barrier: Mutex::new(None),
838 }),
839 worker: Mutex::new(None),
840 }
841 }
842
843 #[cfg(test)]
844 fn pause_next_expiry_after_removal(&self, barrier: AsyncAcknowledgementExpiryBarrier) {
845 *self
846 .inner
847 .expiry_barrier
848 .lock()
849 .expect("journal acknowledgement expiry-barrier mutex poisoned") = Some(barrier);
850 }
851
852 fn ensure_worker(&self) -> Result<(), CriticalPreAcceptanceError> {
853 let mut worker = self
854 .worker
855 .lock()
856 .expect("journal acknowledgement-worker mutex poisoned");
857 if worker.is_some() {
858 return Ok(());
859 }
860 let inner = self.inner.clone();
861 let handle = thread::Builder::new()
862 .name("car-eventlog-critical-ack".into())
863 .spawn(move || async_acknowledgement_timer_loop(inner))
864 .map_err(|error| {
865 CriticalPreAcceptanceError::CoordinatorUnavailable(error.to_string())
866 })?;
867 *worker = Some(handle);
868 Ok(())
869 }
870
871 fn reserve(
872 &self,
873 timeout: Duration,
874 ) -> Result<AsyncAcknowledgementReservation, CriticalPreAcceptanceError> {
875 if timeout.is_zero() || timeout > MAX_CRITICAL_ACKNOWLEDGEMENT_TIMEOUT {
876 return Err(CriticalPreAcceptanceError::InvalidAcknowledgementTimeout {
877 requested: timeout,
878 });
879 }
880 self.ensure_worker()?;
881 let mut state = self
882 .inner
883 .state
884 .lock()
885 .expect("journal acknowledgement-manager mutex poisoned");
886 if state.shutting_down {
887 return Err(CriticalPreAcceptanceError::CoordinatorUnavailable(
888 "coordinator is shutting down".to_string(),
889 ));
890 }
891 if state.entries.len() >= self.inner.capacity {
892 return Err(CriticalPreAcceptanceError::CapacityExhausted {
893 capacity: self.inner.capacity,
894 });
895 }
896 let id = loop {
897 let candidate = state.next_id;
898 state.next_id = state.next_id.wrapping_add(1);
899 if !state.entries.contains_key(&candidate) {
900 break candidate;
901 }
902 };
903 let entry = Arc::new(AsyncAcknowledgementEntry {
904 id,
905 deadline: Instant::now() + timeout,
906 timeout,
907 manager: Arc::downgrade(&self.inner),
908 state: Mutex::new(AsyncAcknowledgementState {
909 terminal: false,
910 result: None,
911 waker: None,
912 }),
913 });
914 state.entries.insert(id, entry.clone());
915 drop(state);
916 self.inner.changed.notify_all();
917 Ok(AsyncAcknowledgementReservation {
918 entry,
919 preaccepted: true,
920 })
921 }
922
923 fn shutdown(&self) {
924 let entries = {
925 let mut state = self
926 .inner
927 .state
928 .lock()
929 .expect("journal acknowledgement-manager mutex poisoned");
930 state.shutting_down = true;
931 let entries = state
932 .entries
933 .drain()
934 .map(|(_, entry)| entry)
935 .collect::<Vec<_>>();
936 self.inner.changed.notify_all();
937 entries
938 };
939 for entry in entries {
940 entry.complete(Err(CriticalPostAcceptanceError::CoordinatorStopped));
941 }
942 if let Some(worker) = self
943 .worker
944 .lock()
945 .expect("journal acknowledgement-worker mutex poisoned")
946 .take()
947 {
948 let _ = worker.join();
949 }
950 }
951}
952
953fn async_acknowledgement_timer_loop(inner: Arc<AsyncAcknowledgementManagerInner>) {
954 loop {
955 let expired = {
956 let mut state = inner
957 .state
958 .lock()
959 .expect("journal acknowledgement-manager mutex poisoned");
960 loop {
961 if state.shutting_down {
962 return;
963 }
964 let now = Instant::now();
965 let expired_ids: Vec<_> = state
966 .entries
967 .iter()
968 .filter_map(|(id, entry)| (entry.deadline <= now).then_some(*id))
969 .collect();
970 if !expired_ids.is_empty() {
971 break expired_ids
972 .into_iter()
973 .filter_map(|id| state.entries.remove(&id))
974 .collect::<Vec<_>>();
975 }
976 if let Some(deadline) = state.entries.values().map(|entry| entry.deadline).min() {
977 let wait = deadline.saturating_duration_since(now);
978 let (next, _) = inner
979 .changed
980 .wait_timeout(state, wait)
981 .expect("journal acknowledgement-manager mutex poisoned");
982 state = next;
983 } else {
984 state = inner
985 .changed
986 .wait(state)
987 .expect("journal acknowledgement-manager mutex poisoned");
988 }
989 }
990 };
991 #[cfg(test)]
992 if !expired.is_empty() {
993 if let Some(barrier) = inner
994 .expiry_barrier
995 .lock()
996 .expect("journal acknowledgement expiry-barrier mutex poisoned")
997 .take()
998 {
999 barrier.pause_after_removal();
1000 }
1001 }
1002 for entry in expired {
1003 entry.complete(Err(CriticalPostAcceptanceError::AcknowledgementTimedOut {
1004 timeout: entry.timeout,
1005 }));
1006 }
1007 }
1008}
1009
1010struct AsyncAcknowledgementReservation {
1011 entry: Arc<AsyncAcknowledgementEntry>,
1012 preaccepted: bool,
1013}
1014
1015impl AsyncAcknowledgementReservation {
1016 fn sender(&self) -> AsyncAcknowledgementSender {
1017 AsyncAcknowledgementSender {
1018 entry: self.entry.clone(),
1019 }
1020 }
1021
1022 fn into_future(mut self) -> AsyncAcknowledgement {
1023 self.preaccepted = false;
1024 AsyncAcknowledgement {
1025 entry: self.entry.clone(),
1026 }
1027 }
1028}
1029
1030impl Drop for AsyncAcknowledgementReservation {
1031 fn drop(&mut self) {
1032 if self.preaccepted {
1033 self.entry.cancel_preacceptance();
1034 }
1035 }
1036}
1037
1038struct AsyncAcknowledgementSender {
1039 entry: Arc<AsyncAcknowledgementEntry>,
1040}
1041
1042impl AsyncAcknowledgementSender {
1043 fn send(&self, result: std::io::Result<()>) {
1044 self.entry.complete_writer(result);
1045 }
1046}
1047
1048enum JournalAcknowledgement {
1049 Sync(mpsc::SyncSender<std::io::Result<()>>),
1050 Async(AsyncAcknowledgementSender),
1051}
1052
1053impl JournalAcknowledgement {
1054 fn send(&self, result: std::io::Result<()>) {
1055 match self {
1056 Self::Sync(sender) => {
1057 let _ = sender.send(result);
1058 }
1059 Self::Async(sender) => sender.send(result),
1060 }
1061 }
1062}
1063
1064impl std::fmt::Debug for JournalAcknowledgement {
1065 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1066 match self {
1067 Self::Sync(_) => formatter.write_str("JournalAcknowledgement::Sync"),
1068 Self::Async(_) => formatter.write_str("JournalAcknowledgement::Async"),
1069 }
1070 }
1071}
1072
1073#[derive(Debug, Clone, PartialEq, Eq)]
1077pub enum CriticalAppendError {
1078 Rejected { reason: String },
1080 DurabilityUnknown { reason: String },
1083}
1084
1085impl CriticalAppendError {
1086 pub fn is_retry_safe(&self) -> bool {
1087 matches!(self, Self::DurabilityUnknown { .. })
1088 }
1089}
1090
1091impl std::fmt::Display for CriticalAppendError {
1092 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1093 match self {
1094 Self::Rejected { reason } => {
1095 write!(formatter, "critical journal append rejected: {reason}")
1096 }
1097 Self::DurabilityUnknown { reason } => write!(
1098 formatter,
1099 "critical journal durability is unknown; retry the exact event safely: {reason}"
1100 ),
1101 }
1102 }
1103}
1104
1105impl std::error::Error for CriticalAppendError {}
1106
1107struct JournalWriter {
1108 tx: Option<mpsc::Sender<JournalMessage>>,
1111 handle: Option<thread::JoinHandle<()>>,
1112 acknowledgements: AsyncAcknowledgementManager,
1113}
1114
1115impl JournalWriter {
1116 fn spawn(path: PathBuf) -> Self {
1117 Self::spawn_with_injectors(path, JournalFailureInjector::default(), None)
1118 }
1119
1120 fn spawn_with_injector(path: PathBuf, failures: JournalFailureInjector) -> Self {
1121 Self::spawn_with_injectors(path, failures, None)
1122 }
1123
1124 fn spawn_with_private_path_injector(
1125 path: PathBuf,
1126 failures: car_secrets::PrivatePathDurabilityFailureInjector,
1127 ) -> Self {
1128 Self::spawn_with_injectors(path, JournalFailureInjector::default(), Some(failures))
1129 }
1130
1131 fn spawn_with_injectors(
1132 path: PathBuf,
1133 failures: JournalFailureInjector,
1134 private_path_failures: Option<car_secrets::PrivatePathDurabilityFailureInjector>,
1135 ) -> Self {
1136 Self::spawn_with_injectors_and_ack_capacity(
1137 path,
1138 failures,
1139 private_path_failures,
1140 DEFAULT_CRITICAL_ACKNOWLEDGEMENT_CAPACITY,
1141 )
1142 }
1143
1144 fn spawn_with_injectors_and_ack_capacity(
1145 path: PathBuf,
1146 failures: JournalFailureInjector,
1147 private_path_failures: Option<car_secrets::PrivatePathDurabilityFailureInjector>,
1148 acknowledgement_capacity: usize,
1149 ) -> Self {
1150 let (tx, rx) = mpsc::channel::<JournalMessage>();
1151 let acknowledgements = AsyncAcknowledgementManager::new(acknowledgement_capacity);
1152 match thread::Builder::new()
1153 .name("car-eventlog-journal".into())
1154 .spawn(move || journal_loop(path, rx, failures, private_path_failures))
1155 {
1156 Ok(handle) => Self {
1157 tx: Some(tx),
1158 handle: Some(handle),
1159 acknowledgements,
1160 },
1161 Err(e) => {
1163 tracing::warn!(error = %e, "car-eventlog: failed to spawn journal writer thread — journaling disabled for this log");
1164 Self {
1165 tx: None,
1166 handle: None,
1167 acknowledgements,
1168 }
1169 }
1170 }
1171 }
1172
1173 fn send(&self, line: String) {
1174 if let Some(tx) = &self.tx {
1175 let _ = tx.send(JournalMessage::Async(line));
1177 }
1178 }
1179
1180 fn enqueue_critical_sync(
1181 &self,
1182 line: String,
1183 known_existing: bool,
1184 ) -> Result<mpsc::Receiver<std::io::Result<()>>, CriticalPreAcceptanceError> {
1185 let tx = self
1186 .tx
1187 .as_ref()
1188 .ok_or(CriticalPreAcceptanceError::WriterUnavailable)?;
1189 let (ack_tx, ack_rx) = mpsc::sync_channel(0);
1190 tx.send(JournalMessage::Critical {
1191 line,
1192 known_existing,
1193 ack: JournalAcknowledgement::Sync(ack_tx),
1194 })
1195 .map_err(|_| CriticalPreAcceptanceError::WriterStopped)?;
1196 Ok(ack_rx)
1197 }
1198
1199 fn reserve_async_acknowledgement(
1200 &self,
1201 acknowledgement_timeout: Duration,
1202 ) -> Result<AsyncAcknowledgementReservation, CriticalPreAcceptanceError> {
1203 if self.tx.is_none() {
1204 return Err(CriticalPreAcceptanceError::WriterUnavailable);
1205 }
1206 self.acknowledgements.reserve(acknowledgement_timeout)
1207 }
1208
1209 fn enqueue_critical_async(
1210 &self,
1211 line: String,
1212 known_existing: bool,
1213 reservation: AsyncAcknowledgementReservation,
1214 ) -> Result<AsyncAcknowledgement, CriticalPreAcceptanceError> {
1215 let tx = self
1216 .tx
1217 .as_ref()
1218 .ok_or(CriticalPreAcceptanceError::WriterUnavailable)?;
1219 tx.send(JournalMessage::Critical {
1220 line,
1221 known_existing,
1222 ack: JournalAcknowledgement::Async(reservation.sender()),
1223 })
1224 .map_err(|_| CriticalPreAcceptanceError::WriterStopped)?;
1225 Ok(reservation.into_future())
1226 }
1227
1228 #[cfg(test)]
1229 fn remove_sender_for_test(&mut self) {
1230 self.tx.take();
1231 if let Some(handle) = self.handle.take() {
1232 let _ = handle.join();
1233 }
1234 }
1235
1236 #[cfg(test)]
1237 fn stop_receiver_for_test(&mut self) {
1238 if let Some(tx) = &self.tx {
1239 let _ = tx.send(JournalMessage::Shutdown);
1240 }
1241 if let Some(handle) = self.handle.take() {
1242 let _ = handle.join();
1243 }
1244 }
1245}
1246
1247impl Drop for JournalWriter {
1248 fn drop(&mut self) {
1249 self.acknowledgements.shutdown();
1250 self.tx.take();
1253 if let Some(handle) = self.handle.take() {
1254 let _ = handle.join();
1255 }
1256 }
1257}
1258
1259fn journal_loop(
1270 path: PathBuf,
1271 rx: mpsc::Receiver<JournalMessage>,
1272 failures: JournalFailureInjector,
1273 private_path_failures: Option<car_secrets::PrivatePathDurabilityFailureInjector>,
1274) {
1275 let mut writer: Option<std::fs::File> = None;
1276 let mut written_critical_lines = HashSet::new();
1277 let mut blocked_critical: Option<String> = None;
1278 let mut failed_async = VecDeque::new();
1283 let mut after_blocked_critical = VecDeque::new();
1284 while let Ok(message) = rx.recv() {
1285 let existed_before_open = path.exists();
1286 let open = || match private_path_failures.as_ref() {
1287 Some(failures) => {
1288 car_secrets::open_private_append_with_failure_injector(&path, failures)
1289 }
1290 None => open_private_append(&path),
1291 };
1292 match message {
1293 #[cfg(test)]
1294 JournalMessage::Shutdown => break,
1295 JournalMessage::Async(line) => {
1296 if blocked_critical.is_some() {
1297 after_blocked_critical.push_back(line);
1298 continue;
1299 }
1300 if !failed_async.is_empty() {
1301 failed_async.push_back(line);
1302 continue;
1303 }
1304 if writer.is_none() {
1305 writer = open().ok();
1306 }
1307 let result = match writer.as_mut() {
1308 Some(file) => append_journal_line(
1309 &path,
1310 file,
1311 &line,
1312 false,
1313 false,
1314 existed_before_open,
1315 &failures,
1316 &mut written_critical_lines,
1317 ),
1318 None => Err(std::io::Error::other("cannot open journal file")),
1319 };
1320 if let Err(error) = result {
1321 failed_async.push_back(line);
1322 tracing::warn!(path = %path.display(), %error, "car-eventlog: asynchronous journal append failed and is awaiting ordered retry");
1323 }
1324 continue;
1325 }
1326 JournalMessage::Critical {
1327 line,
1328 known_existing,
1329 ack,
1330 } => {
1331 if blocked_critical
1332 .as_deref()
1333 .is_some_and(|pending| pending != line)
1334 {
1335 ack.send(Err(std::io::Error::new(
1336 std::io::ErrorKind::WouldBlock,
1337 "another critical journal row is awaiting durability",
1338 )));
1339 continue;
1340 }
1341 if writer.is_none() {
1342 writer = open().ok();
1343 }
1344
1345 while let Some(pending) = failed_async.front() {
1350 let replay = match writer.as_mut() {
1351 Some(file) => append_journal_line(
1352 &path,
1353 file,
1354 pending,
1355 false,
1356 false,
1357 existed_before_open,
1358 &failures,
1359 &mut written_critical_lines,
1360 ),
1361 None => Err(std::io::Error::other("cannot open journal file")),
1362 };
1363 match replay {
1364 Ok(()) => {
1365 failed_async.pop_front();
1366 }
1367 Err(error) => {
1368 blocked_critical = Some(line.clone());
1369 ack.send(Err(std::io::Error::new(
1370 error.kind(),
1371 format!("prior asynchronous journal row is not durable: {error}"),
1372 )));
1373 break;
1374 }
1375 }
1376 }
1377 if !failed_async.is_empty() {
1378 continue;
1379 }
1380 let result = match writer.as_mut() {
1381 Some(file) => append_journal_line(
1382 &path,
1383 file,
1384 &line,
1385 true,
1386 known_existing,
1387 existed_before_open,
1388 &failures,
1389 &mut written_critical_lines,
1390 ),
1391 None => Err(std::io::Error::other("cannot open journal file")),
1392 };
1393 match result {
1394 Ok(()) => {
1395 blocked_critical = None;
1396 while let Some(queued) = after_blocked_critical.pop_front() {
1397 if let Some(file) = writer.as_mut() {
1398 if let Err(error) = append_journal_line(
1399 &path,
1400 file,
1401 &queued,
1402 false,
1403 false,
1404 true,
1405 &failures,
1406 &mut written_critical_lines,
1407 ) {
1408 failed_async.push_back(queued);
1409 failed_async.append(&mut after_blocked_critical);
1410 tracing::warn!(path = %path.display(), %error, "car-eventlog: queued asynchronous append failed after critical recovery and is awaiting ordered retry");
1411 break;
1412 }
1413 }
1414 }
1415 if failures.take(JournalFailurePoint::HoldAcknowledgement) {
1416 failures.hold_acknowledgement(ack);
1417 } else {
1418 ack.send(Ok(()));
1419 }
1420 }
1421 Err(error) => {
1422 blocked_critical = Some(line);
1423 ack.send(Err(error));
1424 }
1425 }
1426 continue;
1427 }
1428 };
1429 }
1430 if let Some(mut writer) = writer {
1431 let _ = writer.flush();
1432 }
1433}
1434
1435fn append_journal_line(
1436 path: &Path,
1437 file: &mut std::fs::File,
1438 line: &str,
1439 critical: bool,
1440 known_existing: bool,
1441 existed_before_open: bool,
1442 failures: &JournalFailureInjector,
1443 written_critical_lines: &mut HashSet<String>,
1444) -> std::io::Result<()> {
1445 revalidate_private_path(path, file)?;
1446 let already_written = known_existing || written_critical_lines.contains(line);
1447 if !already_written {
1448 if (!critical && failures.take(JournalFailurePoint::AsyncWrite))
1449 || (critical && failures.take(JournalFailurePoint::Write))
1450 {
1451 return Err(std::io::Error::from_raw_os_error(28)); }
1453 let mut bytes = Vec::with_capacity(line.len() + 2);
1454 let len = file.seek(SeekFrom::End(0))?;
1455 if len > 0 {
1456 file.seek(SeekFrom::End(-1))?;
1457 let mut tail = [0u8; 1];
1458 file.read_exact(&mut tail)?;
1459 if tail[0] != b'\n' {
1460 bytes.push(b'\n');
1461 }
1462 }
1463 bytes.extend_from_slice(line.as_bytes());
1464 bytes.push(b'\n');
1465 if let Err(error) = file.write_all(&bytes) {
1466 let _ = file.set_len(len);
1470 let _ = file.seek(SeekFrom::End(0));
1471 return Err(error);
1472 }
1473 if critical {
1474 written_critical_lines.insert(line.to_string());
1475 }
1476 }
1477 if critical && failures.take(JournalFailurePoint::Flush) {
1478 return Err(std::io::Error::other("injected journal flush failure"));
1479 }
1480 file.flush()?;
1481 if critical {
1482 if failures.take(JournalFailurePoint::Fsync) {
1483 return Err(std::io::Error::other("injected journal fsync failure"));
1484 }
1485 file.sync_all()?;
1486 if !existed_before_open {
1487 sync_journal_parent(path)?;
1488 }
1489 }
1490 revalidate_private_path(path, file)
1491}
1492
1493#[cfg(not(target_os = "windows"))]
1494fn sync_journal_parent(path: &Path) -> std::io::Result<()> {
1495 if let Some(parent) = path.parent() {
1496 std::fs::File::open(parent)?.sync_all()?;
1497 }
1498 Ok(())
1499}
1500
1501#[cfg(target_os = "windows")]
1502fn sync_journal_parent(_path: &Path) -> std::io::Result<()> {
1503 Ok(())
1508}
1509
1510pub struct EventLog {
1512 events: Vec<Event>,
1513 spans: Vec<Span>,
1514 journal: Option<JournalWriter>,
1515 hash_chaining: bool,
1520 last_hash: Option<String>,
1523 retention: Option<RetentionPolicy>,
1528 journal_path: Option<PathBuf>,
1531 journal_lines: usize,
1536 trimmed_events: u64,
1542 cumulative_cost_usd: f64,
1548 active_binding: Option<EventBinding>,
1552 critical_pending: HashSet<String>,
1556}
1557
1558#[derive(Debug, Clone, PartialEq, Eq)]
1559struct EventBinding {
1560 run_id: String,
1561 client_id: String,
1562 policy_session_id: Option<String>,
1563}
1564
1565struct PreparedCriticalAppend {
1566 existing_index: Option<usize>,
1567 event: Option<Event>,
1568 line: String,
1569 known_existing: bool,
1570}
1571
1572const JOURNAL_COMPACT_MIN_EXCESS: usize = 1024;
1578
1579fn event_digest(
1591 prev_hash: &str,
1592 kind: &EventKind,
1593 run_id: Option<&str>,
1594 client_id: Option<&str>,
1595 policy_session_id: Option<&str>,
1596 action_id: Option<&str>,
1597 proposal_id: Option<&str>,
1598 data: &HashMap<String, Value>,
1599 timestamp: &DateTime<Utc>,
1600) -> String {
1601 use sha2::{Digest, Sha256};
1602 let mut sorted: Vec<(&String, &Value)> = data.iter().collect();
1603 sorted.sort_by(|a, b| a.0.cmp(b.0));
1604 let data_canon: String = sorted
1605 .iter()
1606 .map(|(k, v)| format!("{k}={}", v))
1607 .collect::<Vec<_>>()
1608 .join("\u{1f}");
1609 let kind_str = serde_json::to_string(kind).unwrap_or_default();
1610 let mut hasher = Sha256::new();
1611 hasher.update(prev_hash.as_bytes());
1612 hasher.update(b"\x1e");
1613 hasher.update(kind_str.as_bytes());
1614 if run_id.is_some() || client_id.is_some() || policy_session_id.is_some() {
1617 hasher.update(b"\x1d");
1618 hasher.update(run_id.unwrap_or("").as_bytes());
1619 hasher.update(b"\x1f");
1620 hasher.update(client_id.unwrap_or("").as_bytes());
1621 hasher.update(b"\x1f");
1622 hasher.update(policy_session_id.unwrap_or("").as_bytes());
1623 }
1624 hasher.update(b"\x1e");
1625 hasher.update(action_id.unwrap_or("").as_bytes());
1626 hasher.update(b"\x1e");
1627 hasher.update(proposal_id.unwrap_or("").as_bytes());
1628 hasher.update(b"\x1e");
1629 hasher.update(data_canon.as_bytes());
1630 hasher.update(b"\x1e");
1631 hasher.update(timestamp.to_rfc3339().as_bytes());
1632 let digest = hasher.finalize();
1633 digest.iter().map(|b| format!("{b:02x}")).collect()
1634}
1635
1636#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1641pub struct RetentionPolicy {
1642 #[serde(default)]
1644 pub max_events: Option<usize>,
1645 #[serde(default)]
1647 pub max_age_secs: Option<i64>,
1648}
1649
1650#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1655pub struct EventQuery {
1656 #[serde(default)]
1658 pub kinds: Vec<EventKind>,
1659 #[serde(default)]
1661 pub action_id: Option<String>,
1662 #[serde(default)]
1664 pub proposal_id: Option<String>,
1665 #[serde(default)]
1667 pub since: Option<DateTime<Utc>>,
1668 #[serde(default)]
1670 pub until: Option<DateTime<Utc>>,
1671 #[serde(default)]
1675 pub data_matches: std::collections::HashMap<String, String>,
1676 #[serde(default)]
1678 pub limit: Option<usize>,
1679}
1680
1681fn data_value_matches(v: &Value, want: &str) -> bool {
1684 match v {
1685 Value::String(s) => s == want,
1686 Value::Null => false,
1687 other => *other == want,
1688 }
1689}
1690
1691impl EventQuery {
1692 pub fn matches(&self, e: &Event) -> bool {
1694 if !self.kinds.is_empty() && !self.kinds.contains(&e.kind) {
1695 return false;
1696 }
1697 if let Some(aid) = &self.action_id {
1698 if e.action_id.as_deref() != Some(aid.as_str()) {
1699 return false;
1700 }
1701 }
1702 if let Some(pid) = &self.proposal_id {
1703 if e.proposal_id.as_deref() != Some(pid.as_str()) {
1704 return false;
1705 }
1706 }
1707 if let Some(since) = self.since {
1708 if e.timestamp < since {
1709 return false;
1710 }
1711 }
1712 if let Some(until) = self.until {
1713 if e.timestamp >= until {
1714 return false;
1715 }
1716 }
1717 for (k, want) in &self.data_matches {
1718 match e.data.get(k) {
1719 Some(v) if data_value_matches(v, want) => {}
1720 _ => return false,
1721 }
1722 }
1723 true
1724 }
1725}
1726
1727impl EventLog {
1728 pub fn new() -> Self {
1729 Self {
1730 events: Vec::new(),
1731 spans: Vec::new(),
1732 journal: None,
1733 hash_chaining: false,
1734 last_hash: None,
1735 retention: None,
1736 journal_path: None,
1737 journal_lines: 0,
1738 trimmed_events: 0,
1739 cumulative_cost_usd: 0.0,
1740 active_binding: None,
1741 critical_pending: HashSet::new(),
1742 }
1743 }
1744
1745 pub fn with_journal(path: PathBuf) -> Self {
1746 Self {
1747 events: Vec::new(),
1748 spans: Vec::new(),
1749 journal: Some(JournalWriter::spawn(path.clone())),
1750 hash_chaining: false,
1751 last_hash: None,
1752 retention: None,
1753 journal_path: Some(path),
1754 journal_lines: 0,
1755 trimmed_events: 0,
1756 cumulative_cost_usd: 0.0,
1757 active_binding: None,
1758 critical_pending: HashSet::new(),
1759 }
1760 }
1761
1762 pub fn with_journal_failure_injector(path: PathBuf, failures: JournalFailureInjector) -> Self {
1764 let mut log = Self::with_journal(path.clone());
1765 log.journal = Some(JournalWriter::spawn_with_injector(path, failures));
1766 log
1767 }
1768
1769 #[cfg(test)]
1770 fn with_journal_failure_injector_and_ack_capacity(
1771 path: PathBuf,
1772 failures: JournalFailureInjector,
1773 acknowledgement_capacity: usize,
1774 ) -> Self {
1775 let mut log = Self::with_journal(path.clone());
1776 log.journal = Some(JournalWriter::spawn_with_injectors_and_ack_capacity(
1777 path,
1778 failures,
1779 None,
1780 acknowledgement_capacity,
1781 ));
1782 log
1783 }
1784
1785 pub fn with_private_path_failure_injector(
1787 path: PathBuf,
1788 failures: car_secrets::PrivatePathDurabilityFailureInjector,
1789 ) -> Self {
1790 let mut log = Self::with_journal(path.clone());
1791 log.journal = Some(JournalWriter::spawn_with_private_path_injector(
1792 path, failures,
1793 ));
1794 log
1795 }
1796
1797 pub fn bind_run(&mut self, run_id: &str, client_id: &str) -> Result<(), String> {
1801 if run_id.is_empty() || client_id.is_empty() {
1802 return Err("active journal binding requires non-empty run_id and client_id".into());
1803 }
1804 match &self.active_binding {
1805 Some(binding) if binding.run_id == run_id && binding.client_id == client_id => Ok(()),
1806 Some(binding) => Err(format!(
1807 "journal is already bound to run_id `{}` and client_id `{}`",
1808 binding.run_id, binding.client_id
1809 )),
1810 None => {
1811 self.active_binding = Some(EventBinding {
1812 run_id: run_id.to_string(),
1813 client_id: client_id.to_string(),
1814 policy_session_id: None,
1815 });
1816 Ok(())
1817 }
1818 }
1819 }
1820
1821 pub fn bind_policy_session(&mut self, policy_session_id: &str) -> Result<(), String> {
1823 if policy_session_id.is_empty() {
1824 return Err("policy_session_id must be non-empty".into());
1825 }
1826 let binding = self
1827 .active_binding
1828 .as_mut()
1829 .ok_or_else(|| "cannot bind a policy session without an active run".to_string())?;
1830 match binding.policy_session_id.as_deref() {
1831 Some(existing) if existing != policy_session_id => Err(format!(
1832 "journal proposal is already bound to policy_session_id `{existing}`"
1833 )),
1834 _ => {
1835 binding.policy_session_id = Some(policy_session_id.to_string());
1836 Ok(())
1837 }
1838 }
1839 }
1840
1841 pub fn clear_policy_session(&mut self, policy_session_id: &str) -> Result<(), String> {
1842 let binding = self
1843 .active_binding
1844 .as_mut()
1845 .ok_or_else(|| "cannot clear a policy session without an active run".to_string())?;
1846 if binding.policy_session_id.as_deref() != Some(policy_session_id) {
1847 return Err("policy_session_id does not match the active journal binding".into());
1848 }
1849 binding.policy_session_id = None;
1850 Ok(())
1851 }
1852
1853 pub fn clear_run_binding(&mut self, run_id: &str, client_id: &str) -> Result<(), String> {
1854 let binding = self
1855 .active_binding
1856 .as_ref()
1857 .ok_or_else(|| "journal has no active run binding".to_string())?;
1858 if binding.run_id != run_id || binding.client_id != client_id {
1859 return Err("run_id/client_id does not match the active journal binding".into());
1860 }
1861 if binding.policy_session_id.is_some() {
1862 return Err(
1863 "cannot clear an active run while a proposal policy session is bound".into(),
1864 );
1865 }
1866 self.active_binding = None;
1867 Ok(())
1868 }
1869
1870 pub fn active_run_binding(&self) -> Option<(&str, &str, Option<&str>)> {
1871 self.active_binding.as_ref().map(|binding| {
1872 (
1873 binding.run_id.as_str(),
1874 binding.client_id.as_str(),
1875 binding.policy_session_id.as_deref(),
1876 )
1877 })
1878 }
1879
1880 pub fn with_hash_chaining(mut self) -> Self {
1885 self.enable_hash_chaining();
1886 self
1887 }
1888
1889 pub fn enable_hash_chaining(&mut self) {
1891 self.hash_chaining = true;
1892 if self.last_hash.is_none() {
1894 self.last_hash = self.events.last().and_then(|e| e.hash.clone());
1895 }
1896 }
1897
1898 pub fn hash_chaining_enabled(&self) -> bool {
1900 self.hash_chaining
1901 }
1902
1903 pub fn append(
1904 &mut self,
1905 kind: EventKind,
1906 action_id: Option<&str>,
1907 proposal_id: Option<&str>,
1908 data: HashMap<String, Value>,
1909 ) -> &Event {
1910 let timestamp = Utc::now();
1911 let (prev_hash, hash) = if self.hash_chaining {
1912 let prev = self.last_hash.clone().unwrap_or_default();
1913 let binding = self.active_binding.as_ref();
1914 let h = event_digest(
1915 &prev,
1916 &kind,
1917 binding.map(|b| b.run_id.as_str()),
1918 binding.map(|b| b.client_id.as_str()),
1919 binding.and_then(|b| b.policy_session_id.as_deref()),
1920 action_id,
1921 proposal_id,
1922 &data,
1923 ×tamp,
1924 );
1925 self.last_hash = Some(h.clone());
1926 (Some(prev), Some(h))
1927 } else {
1928 (None, None)
1929 };
1930 let event = Event {
1931 kind,
1932 run_id: self.active_binding.as_ref().map(|b| b.run_id.clone()),
1933 client_id: self.active_binding.as_ref().map(|b| b.client_id.clone()),
1934 policy_session_id: self
1935 .active_binding
1936 .as_ref()
1937 .and_then(|b| b.policy_session_id.clone()),
1938 action_id: action_id.map(|s| s.to_string()),
1939 proposal_id: proposal_id.map(|s| s.to_string()),
1940 data,
1941 timestamp,
1942 prev_hash,
1943 hash,
1944 };
1945
1946 if let Some(journal) = &self.journal {
1949 if let Ok(json) = serde_json::to_string(&event) {
1950 journal.send(json);
1951 self.journal_lines += 1;
1952 }
1953 }
1954
1955 if let Some(c) = event.cost_usd() {
1958 self.cumulative_cost_usd += c;
1959 }
1960
1961 self.events.push(event);
1962 if let Some(max) = self.retention.as_ref().and_then(|p| p.max_events) {
1967 if self.events.len() > max {
1968 let removed = truncate_vec_keep_last(&mut self.events, max);
1969 self.trimmed_events += removed as u64;
1970 self.maybe_compact_journal();
1973 }
1974 }
1975 self.events.last().unwrap()
1976 }
1977
1978 fn prepare_critical_append(
1979 &mut self,
1980 kind: EventKind,
1981 action_id: Option<&str>,
1982 proposal_id: Option<&str>,
1983 data: HashMap<String, Value>,
1984 ) -> Result<PreparedCriticalAppend, String> {
1985 let binding = self.active_binding.as_ref().ok_or_else(|| {
1986 "critical lifecycle event requires an authenticated run binding".to_string()
1987 })?;
1988 let existing = self.events.iter().position(|event| {
1989 event.kind == kind
1990 && event.run_id.as_deref() == Some(binding.run_id.as_str())
1991 && event.client_id.as_deref() == Some(binding.client_id.as_str())
1992 && event.policy_session_id.as_deref() == binding.policy_session_id.as_deref()
1993 && event.action_id.as_deref() == action_id
1994 && event.proposal_id.as_deref() == proposal_id
1995 && event.data == data
1996 });
1997 if existing.is_none() && !self.critical_pending.is_empty() {
1998 return Err(
1999 "another critical lifecycle event is awaiting an exact durability retry".into(),
2000 );
2001 }
2002
2003 if let Some(index) = existing {
2004 let line = serde_json::to_string(&self.events[index]).map_err(|e| e.to_string())?;
2005 return Ok(PreparedCriticalAppend {
2006 existing_index: Some(index),
2007 event: None,
2008 known_existing: !self.critical_pending.contains(&line),
2009 line,
2010 });
2011 }
2012
2013 let timestamp = Utc::now();
2014 let (prev_hash, hash) = if self.hash_chaining {
2015 let prev = self.last_hash.clone().unwrap_or_default();
2016 let hash = event_digest(
2017 &prev,
2018 &kind,
2019 Some(binding.run_id.as_str()),
2020 Some(binding.client_id.as_str()),
2021 binding.policy_session_id.as_deref(),
2022 action_id,
2023 proposal_id,
2024 &data,
2025 ×tamp,
2026 );
2027 (Some(prev), Some(hash))
2028 } else {
2029 (None, None)
2030 };
2031 let event = Event {
2032 kind,
2033 run_id: Some(binding.run_id.clone()),
2034 client_id: Some(binding.client_id.clone()),
2035 policy_session_id: binding.policy_session_id.clone(),
2036 action_id: action_id.map(str::to_string),
2037 proposal_id: proposal_id.map(str::to_string),
2038 data,
2039 timestamp,
2040 prev_hash,
2041 hash,
2042 };
2043 let line = serde_json::to_string(&event).map_err(|error| error.to_string())?;
2044 Ok(PreparedCriticalAppend {
2045 existing_index: None,
2046 event: Some(event),
2047 line,
2048 known_existing: false,
2049 })
2050 }
2051
2052 fn commit_prepared_critical(&mut self, prepared: PreparedCriticalAppend) -> (usize, String) {
2053 let index = match prepared.existing_index {
2054 Some(index) => index,
2055 None => {
2056 let event = prepared
2057 .event
2058 .expect("new critical append must carry its prepared event");
2059 if self.hash_chaining {
2060 self.last_hash = event.hash.clone();
2061 }
2062 self.events.push(event);
2063 self.journal_lines += 1;
2064 self.events.len() - 1
2065 }
2066 };
2067 (index, prepared.line)
2068 }
2069
2070 pub fn append_critical(
2081 &mut self,
2082 kind: EventKind,
2083 action_id: Option<&str>,
2084 proposal_id: Option<&str>,
2085 data: HashMap<String, Value>,
2086 ) -> Result<&Event, String> {
2087 if self.journal.is_none() {
2088 return Err("critical lifecycle event requires an enabled journal".to_string());
2089 }
2090 let prepared = self.prepare_critical_append(kind, action_id, proposal_id, data)?;
2091 let acknowledgement = self
2092 .journal
2093 .as_ref()
2094 .expect("journal presence checked above")
2095 .enqueue_critical_sync(prepared.line.clone(), prepared.known_existing)
2096 .map_err(|error| error.to_string())?;
2097 let (index, line) = self.commit_prepared_critical(prepared);
2098 self.critical_pending.insert(line.clone());
2099 let result = acknowledgement
2100 .recv()
2101 .map_err(|_| {
2102 std::io::Error::new(
2103 std::io::ErrorKind::BrokenPipe,
2104 "journal writer stopped before critical acknowledgement",
2105 )
2106 })
2107 .and_then(|result| result);
2108 match result {
2109 Ok(()) => {
2110 self.critical_pending.remove(&line);
2111 Ok(&self.events[index])
2112 }
2113 Err(error) => {
2114 self.critical_pending.insert(line);
2115 Err(format!("critical journal append was not durable: {error}"))
2116 }
2117 }
2118 }
2119
2120 pub fn append_critical_bounded(
2128 &mut self,
2129 kind: EventKind,
2130 action_id: Option<&str>,
2131 proposal_id: Option<&str>,
2132 data: HashMap<String, Value>,
2133 acknowledgement_timeout: Duration,
2134 ) -> Result<&Event, CriticalAppendError> {
2135 if acknowledgement_timeout.is_zero()
2136 || acknowledgement_timeout > MAX_CRITICAL_ACKNOWLEDGEMENT_TIMEOUT
2137 {
2138 return Err(CriticalAppendError::Rejected {
2139 reason: CriticalPreAcceptanceError::InvalidAcknowledgementTimeout {
2140 requested: acknowledgement_timeout,
2141 }
2142 .to_string(),
2143 });
2144 }
2145 let prepared = self
2146 .prepare_critical_append(kind, action_id, proposal_id, data)
2147 .map_err(|reason| CriticalAppendError::Rejected { reason })?;
2148 let acknowledgement = self
2149 .journal
2150 .as_ref()
2151 .ok_or_else(|| CriticalAppendError::Rejected {
2152 reason: "critical lifecycle event requires an enabled journal".to_string(),
2153 })?
2154 .enqueue_critical_sync(prepared.line.clone(), prepared.known_existing)
2155 .map_err(|error| CriticalAppendError::Rejected {
2156 reason: error.to_string(),
2157 })?;
2158 let (index, line) = self.commit_prepared_critical(prepared);
2159 self.critical_pending.insert(line.clone());
2160 let result = match acknowledgement.recv_timeout(acknowledgement_timeout) {
2161 Ok(result) => result.map_err(|error| error.to_string()),
2162 Err(mpsc::RecvTimeoutError::Timeout) => Err(format!(
2163 "journal writer did not acknowledge within {}ms",
2164 acknowledgement_timeout.as_millis()
2165 )),
2166 Err(mpsc::RecvTimeoutError::Disconnected) => {
2167 Err("journal writer stopped before critical acknowledgement".to_string())
2168 }
2169 };
2170 match result {
2171 Ok(()) => {
2172 self.critical_pending.remove(&line);
2173 Ok(&self.events[index])
2174 }
2175 Err(reason) => Err(CriticalAppendError::DurabilityUnknown { reason }),
2176 }
2177 }
2178
2179 pub async fn append_critical_async(
2191 &mut self,
2192 kind: EventKind,
2193 action_id: Option<&str>,
2194 proposal_id: Option<&str>,
2195 data: HashMap<String, Value>,
2196 acknowledgement_timeout: Duration,
2197 ) -> Result<&Event, CriticalAppendError> {
2198 let reservation = self
2199 .journal
2200 .as_ref()
2201 .ok_or_else(|| CriticalAppendError::Rejected {
2202 reason: "critical lifecycle event requires an enabled journal".to_string(),
2203 })?
2204 .reserve_async_acknowledgement(acknowledgement_timeout)
2205 .map_err(|error| CriticalAppendError::Rejected {
2206 reason: error.to_string(),
2207 })?;
2208 let prepared = self
2209 .prepare_critical_append(kind, action_id, proposal_id, data)
2210 .map_err(|reason| CriticalAppendError::Rejected { reason })?;
2211 let acknowledgement = self
2212 .journal
2213 .as_ref()
2214 .expect("journal presence checked before reservation")
2215 .enqueue_critical_async(prepared.line.clone(), prepared.known_existing, reservation)
2216 .map_err(|error| CriticalAppendError::Rejected {
2217 reason: error.to_string(),
2218 })?;
2219 let (index, line) = self.commit_prepared_critical(prepared);
2220
2221 self.critical_pending.insert(line.clone());
2224 match acknowledgement.await {
2225 Ok(()) => {
2226 self.critical_pending.remove(&line);
2227 Ok(&self.events[index])
2228 }
2229 Err(error) => Err(CriticalAppendError::DurabilityUnknown {
2230 reason: error.to_string(),
2231 }),
2232 }
2233 }
2234
2235 pub fn verify_chain(&self) -> Result<usize, usize> {
2256 let mut prev = String::new();
2257 let mut verified = 0usize;
2258 let mut chain_started = false;
2259 for (i, ev) in self.events.iter().enumerate() {
2260 let Some(stored) = &ev.hash else {
2261 if chain_started {
2263 return Err(i);
2264 }
2265 continue;
2266 };
2267 let recorded_prev = ev.prev_hash.clone().unwrap_or_default();
2269 if chain_started && recorded_prev != prev {
2270 return Err(i);
2271 }
2272 let recomputed = event_digest(
2273 &recorded_prev,
2274 &ev.kind,
2275 ev.run_id.as_deref(),
2276 ev.client_id.as_deref(),
2277 ev.policy_session_id.as_deref(),
2278 ev.action_id.as_deref(),
2279 ev.proposal_id.as_deref(),
2280 &ev.data,
2281 &ev.timestamp,
2282 );
2283 if &recomputed != stored {
2284 return Err(i);
2285 }
2286 prev = stored.clone();
2287 chain_started = true;
2288 verified += 1;
2289 }
2290 Ok(verified)
2291 }
2292
2293 pub fn append_metered(
2300 &mut self,
2301 kind: EventKind,
2302 action_id: Option<&str>,
2303 proposal_id: Option<&str>,
2304 mut data: HashMap<String, Value>,
2305 metrics: Metrics,
2306 ) -> &Event {
2307 metrics.merge_into(&mut data);
2308 self.append(kind, action_id, proposal_id, data)
2309 }
2310
2311 pub fn metrics_totals(&self) -> MetricsTotals {
2323 metrics_totals_of(&self.events)
2324 }
2325
2326 pub fn cost_by_agent(&self) -> Vec<AgentCost> {
2328 cost_by_agent_of(&self.events)
2329 }
2330
2331 pub fn events(&self) -> &[Event] {
2332 &self.events
2333 }
2334
2335 pub fn len(&self) -> usize {
2336 self.events.len()
2337 }
2338
2339 pub fn span_len(&self) -> usize {
2340 self.spans.len()
2341 }
2342
2343 pub fn is_empty(&self) -> bool {
2344 self.events.is_empty()
2345 }
2346
2347 pub fn stats(&self) -> EventLogStats {
2348 EventLogStats {
2349 events: self.events.len(),
2350 spans: self.spans.len(),
2351 approx_event_bytes: approx_json_bytes(&self.events),
2352 approx_span_bytes: approx_json_bytes(&self.spans),
2353 }
2354 }
2355
2356 pub fn truncate_events_keep_last(&mut self, keep_last: usize) -> usize {
2357 let removed = truncate_vec_keep_last(&mut self.events, keep_last);
2358 self.trimmed_events += removed as u64;
2359 if removed > 0 {
2360 self.maybe_compact_journal();
2361 }
2362 removed
2363 }
2364
2365 pub fn truncate_spans_keep_last(&mut self, keep_last: usize) -> usize {
2366 truncate_vec_keep_last(&mut self.spans, keep_last)
2367 }
2368
2369 pub fn clear(&mut self) -> EventLogStats {
2374 let removed = self.stats();
2375 self.trimmed_events += removed.events as u64;
2376 self.events.clear();
2377 self.events.shrink_to_fit();
2378 self.spans.clear();
2379 self.spans.shrink_to_fit();
2380 removed
2381 }
2382
2383 pub fn trimmed_events(&self) -> u64 {
2389 self.trimmed_events
2390 }
2391
2392 pub fn cumulative_cost_usd(&self) -> f64 {
2399 self.cumulative_cost_usd
2400 }
2401
2402 pub fn journal_size_bytes(&self) -> Option<u64> {
2406 let path = self.journal_path.as_ref()?;
2407 fs::metadata(path).ok().map(|m| m.len())
2408 }
2409
2410 fn maybe_compact_journal(&mut self) {
2416 if self.journal_path.is_none() {
2417 return;
2418 }
2419 let excess = self.journal_lines.saturating_sub(self.events.len());
2420 if excess >= JOURNAL_COMPACT_MIN_EXCESS && excess.saturating_mul(4) >= self.journal_lines {
2421 self.compact_journal();
2422 }
2423 }
2424
2425 pub fn compact_journal(&mut self) -> bool {
2443 let Some(path) = self.journal_path.clone() else {
2444 return false;
2445 };
2446 let compacted_lines: HashSet<String> = self
2447 .events
2448 .iter()
2449 .filter_map(|event| serde_json::to_string(event).ok())
2450 .collect();
2451 self.journal = None;
2453 let file_name = path
2454 .file_name()
2455 .and_then(|name| name.to_str())
2456 .unwrap_or("journal");
2457 let tmp = path.with_file_name(format!(".{file_name}.compact-{}.tmp", Uuid::new_v4()));
2458 let rewrite = (|| -> std::io::Result<()> {
2459 let file = create_private_file(&tmp)?;
2460 let mut writer = BufWriter::new(file);
2461 for ev in &self.events {
2462 let line = serde_json::to_string(ev).map_err(std::io::Error::other)?;
2463 writeln!(writer, "{line}")?;
2464 }
2465 writer.flush()?;
2466 let file = writer.into_inner().map_err(|error| error.into_error())?;
2467 file.sync_all()?;
2468 revalidate_private_file(&file)?;
2469 drop(file);
2470 atomic_replace_private_file(&tmp, &path)
2471 })();
2472 let ok = match rewrite {
2473 Ok(()) => {
2474 self.journal_lines = self.events.len();
2475 self.critical_pending
2481 .retain(|line| !compacted_lines.contains(line));
2482 true
2483 }
2484 Err(e) => {
2485 let _ = fs::remove_file(&tmp);
2486 tracing::warn!(
2487 path = %path.display(), error = %e,
2488 "car-eventlog: journal compaction failed — journal keeps growing until the next successful compaction"
2489 );
2490 false
2491 }
2492 };
2493 self.journal = Some(JournalWriter::spawn(path));
2494 ok
2495 }
2496
2497 pub fn query(&self, query: &EventQuery) -> Vec<&Event> {
2500 let mut out: Vec<&Event> = self.events.iter().filter(|e| query.matches(e)).collect();
2501 out.reverse(); if let Some(limit) = query.limit.filter(|l| *l > 0) {
2503 out.truncate(limit);
2504 }
2505 out
2506 }
2507
2508 pub fn set_retention(&mut self, policy: Option<RetentionPolicy>) {
2512 self.retention = policy;
2513 }
2514
2515 pub fn retention(&self) -> Option<&RetentionPolicy> {
2517 self.retention.as_ref()
2518 }
2519
2520 pub fn enforce_retention(&mut self, policy: &RetentionPolicy, now: DateTime<Utc>) -> usize {
2528 let before = self.events.len();
2529 if let Some(age) = policy.max_age_secs {
2530 let cutoff = now - chrono::Duration::seconds(age);
2531 self.events.retain(|e| e.timestamp >= cutoff);
2532 }
2533 if let Some(max) = policy.max_events {
2534 truncate_vec_keep_last(&mut self.events, max);
2535 }
2536 let removed = before.saturating_sub(self.events.len());
2537 self.trimmed_events += removed as u64;
2538 if removed > 0 {
2539 self.maybe_compact_journal();
2540 }
2541 removed
2542 }
2543
2544 pub fn filter(&self, kind: Option<&EventKind>, action_id: Option<&str>) -> Vec<&Event> {
2545 self.events
2546 .iter()
2547 .filter(|e| {
2548 if let Some(k) = kind {
2549 if &e.kind != k {
2550 return false;
2551 }
2552 }
2553 if let Some(aid) = action_id {
2554 if e.action_id.as_deref() != Some(aid) {
2555 return false;
2556 }
2557 }
2558 true
2559 })
2560 .collect()
2561 }
2562
2563 pub fn begin_span(
2565 &mut self,
2566 name: &str,
2567 trace_id: &str,
2568 parent_span_id: Option<&str>,
2569 attributes: HashMap<String, Value>,
2570 ) -> String {
2571 let span_id = Uuid::new_v4().to_string();
2572 let span = Span {
2573 trace_id: trace_id.to_string(),
2574 span_id: span_id.clone(),
2575 parent_span_id: parent_span_id.map(|s| s.to_string()),
2576 name: name.to_string(),
2577 start_time: Utc::now(),
2578 end_time: None,
2579 status: SpanStatus::Unset,
2580 attributes,
2581 };
2582 self.spans.push(span);
2583 span_id
2584 }
2585
2586 pub fn end_span(&mut self, span_id: &str, status: SpanStatus) {
2588 if let Some(span) = self.spans.iter_mut().find(|s| s.span_id == span_id) {
2589 span.end_time = Some(Utc::now());
2590 span.status = status;
2591 }
2592 }
2593
2594 pub fn spans(&self) -> Vec<Span> {
2596 self.spans.clone()
2597 }
2598
2599 pub fn export_traces(&self) -> String {
2601 let mut traces: HashMap<&str, Vec<&Span>> = HashMap::new();
2603 for span in &self.spans {
2604 traces.entry(span.trace_id.as_str()).or_default().push(span);
2605 }
2606
2607 let resource_spans: Vec<Value> = traces.into_values().map(|spans| {
2608 let scope_spans = spans
2609 .iter()
2610 .map(|s| {
2611 let mut span_obj = serde_json::json!({
2612 "traceId": s.trace_id,
2613 "spanId": s.span_id,
2614 "name": s.name,
2615 "startTimeUnixNano": s.start_time.timestamp_nanos_opt().unwrap_or(0).to_string(),
2616 "status": {
2617 "code": match s.status {
2618 SpanStatus::Ok => 1,
2619 SpanStatus::Error => 2,
2620 SpanStatus::Unset => 0,
2621 }
2622 },
2623 "attributes": s.attributes.iter().map(|(k, v)| {
2624 serde_json::json!({
2625 "key": k,
2626 "value": { "stringValue": v.to_string() }
2627 })
2628 }).collect::<Vec<_>>(),
2629 });
2630
2631 if let Some(ref parent) = s.parent_span_id {
2632 span_obj.as_object_mut().unwrap().insert(
2633 "parentSpanId".to_string(),
2634 Value::from(parent.as_str()),
2635 );
2636 }
2637 if let Some(end) = s.end_time {
2638 span_obj.as_object_mut().unwrap().insert(
2639 "endTimeUnixNano".to_string(),
2640 Value::from(end.timestamp_nanos_opt().unwrap_or(0).to_string()),
2641 );
2642 }
2643
2644 span_obj
2645 })
2646 .collect::<Vec<_>>();
2647
2648 serde_json::json!({
2649 "resource": {
2650 "attributes": [
2651 { "key": "service.name", "value": { "stringValue": "car-runtime" } }
2652 ]
2653 },
2654 "scopeSpans": [{
2655 "scope": { "name": "car-eventlog" },
2656 "spans": scope_spans
2657 }]
2658 })
2659 })
2660 .collect();
2661
2662 serde_json::to_string(&serde_json::json!({
2663 "resourceSpans": resource_spans
2664 }))
2665 .unwrap_or_else(|_| "{}".to_string())
2666 }
2667
2668 pub fn load(path: &Path) -> std::io::Result<Self> {
2670 Self::load_with_writer(path, JournalWriter::spawn(path.to_path_buf()))
2671 }
2672
2673 pub fn load_read_only(path: &Path) -> std::io::Result<Self> {
2678 Self::load_from_journal(path, None, false)
2679 }
2680
2681 #[doc(hidden)]
2684 pub fn load_with_journal_failure_injector(
2685 path: &Path,
2686 failures: JournalFailureInjector,
2687 ) -> std::io::Result<Self> {
2688 Self::load_with_writer(
2689 path,
2690 JournalWriter::spawn_with_injector(path.to_path_buf(), failures),
2691 )
2692 }
2693
2694 fn load_with_writer(path: &Path, writer: JournalWriter) -> std::io::Result<Self> {
2695 Self::load_from_journal(path, Some(writer), true)
2696 }
2697
2698 fn load_from_journal(
2699 path: &Path,
2700 writer: Option<JournalWriter>,
2701 repair_torn_tail: bool,
2702 ) -> std::io::Result<Self> {
2703 let file = fs::File::open(path)?;
2704 let mut reader = BufReader::new(file);
2705 let mut events = Vec::new();
2706 let mut event_lines = Vec::new();
2707 let mut line_bytes = Vec::new();
2708 let mut line_number = 0usize;
2709 let mut torn_tail_line = None;
2710
2711 loop {
2712 line_bytes.clear();
2713 let bytes_read = reader.read_until(b'\n', &mut line_bytes)?;
2714 if bytes_read == 0 {
2715 break;
2716 }
2717 line_number += 1;
2718 let terminated = line_bytes.ends_with(b"\n");
2719 if line_bytes.iter().all(|byte| byte.is_ascii_whitespace()) {
2720 if !terminated {
2721 torn_tail_line = Some(line_number);
2722 }
2723 continue;
2724 }
2725 match serde_json::from_slice::<Event>(&line_bytes) {
2726 Ok(event) => {
2727 events.push(event);
2728 event_lines.push(line_number);
2729 if !terminated {
2730 torn_tail_line = Some(line_number);
2731 }
2732 }
2733 Err(_) if !terminated => {
2734 torn_tail_line = Some(line_number);
2739 }
2740 Err(error) => {
2741 return Err(invalid_journal_data(path, line_number, error));
2742 }
2743 }
2744 if !terminated {
2745 break;
2746 }
2747 }
2748 drop(reader);
2749
2750 let last_hash = events.last().and_then(|e| e.hash.clone());
2756 let hash_chaining = last_hash.is_some();
2757 let cumulative_cost_usd = events.iter().filter_map(Event::cost_usd).sum();
2762 let journal_lines = events.len();
2763 let loaded = Self {
2764 events,
2765 spans: Vec::new(),
2766 journal: writer,
2769 hash_chaining,
2770 last_hash,
2771 retention: None,
2772 journal_path: repair_torn_tail.then(|| path.to_path_buf()),
2773 journal_lines,
2774 trimmed_events: 0,
2775 cumulative_cost_usd,
2776 active_binding: None,
2777 critical_pending: HashSet::new(),
2778 };
2779 if let Err(index) = loaded.verify_chain() {
2780 let source_line = event_lines.get(index).copied().unwrap_or(index + 1);
2781 return Err(invalid_journal_data(
2782 path,
2783 source_line,
2784 "hash chain integrity check failed",
2785 ));
2786 }
2787 if let Some(line_number) = torn_tail_line {
2788 if !repair_torn_tail {
2789 return Err(torn_journal_tail(path, line_number));
2790 }
2791 rewrite_loaded_journal(path, &loaded.events)?;
2792 }
2793 Ok(loaded)
2794 }
2795}
2796
2797fn torn_journal_tail(path: &Path, line_number: usize) -> std::io::Error {
2798 std::io::Error::new(
2799 std::io::ErrorKind::UnexpectedEof,
2800 format!(
2801 "event journal torn tail: path={} line={} reason=unterminated final record",
2802 path.display(),
2803 line_number
2804 ),
2805 )
2806}
2807
2808fn invalid_journal_data(
2809 path: &Path,
2810 line_number: usize,
2811 reason: impl std::fmt::Display,
2812) -> std::io::Error {
2813 std::io::Error::new(
2814 std::io::ErrorKind::InvalidData,
2815 format!(
2816 "event journal corruption: path={} line={} reason={reason}",
2817 path.display(),
2818 line_number
2819 ),
2820 )
2821}
2822
2823fn rewrite_loaded_journal(path: &Path, events: &[Event]) -> std::io::Result<()> {
2824 let file_name = path
2825 .file_name()
2826 .and_then(|name| name.to_str())
2827 .unwrap_or("journal");
2828 let temp = path.with_file_name(format!(".{file_name}.recover-{}.tmp", Uuid::new_v4()));
2829 let result = (|| {
2830 let file = create_private_file(&temp)?;
2831 let mut output = BufWriter::new(file);
2832 for event in events {
2833 let line = serde_json::to_string(event).map_err(std::io::Error::other)?;
2834 writeln!(output, "{line}")?;
2835 }
2836 output.flush()?;
2837 let file = output.into_inner().map_err(|error| error.into_error())?;
2838 file.sync_all()?;
2839 revalidate_private_file(&file)?;
2840 drop(file);
2841 atomic_replace_private_file(&temp, path)
2842 })();
2843 if result.is_err() {
2844 let _ = fs::remove_file(&temp);
2845 }
2846 result
2847}
2848
2849fn approx_json_bytes<T: Serialize>(value: &T) -> usize {
2850 serde_json::to_vec(value)
2851 .map(|bytes| bytes.len())
2852 .unwrap_or(0)
2853}
2854
2855fn truncate_vec_keep_last<T>(items: &mut Vec<T>, keep_last: usize) -> usize {
2856 let len = items.len();
2857 if len <= keep_last {
2858 return 0;
2859 }
2860 let removed = len - keep_last;
2861 items.drain(..removed);
2862 items.shrink_to_fit();
2863 removed
2864}
2865
2866impl Default for EventLog {
2867 fn default() -> Self {
2868 Self::new()
2869 }
2870}
2871
2872#[cfg(test)]
2873mod tests {
2874 use super::*;
2875
2876 struct ThreadWake(std::thread::Thread);
2877
2878 impl std::task::Wake for ThreadWake {
2879 fn wake(self: Arc<Self>) {
2880 self.0.unpark();
2881 }
2882
2883 fn wake_by_ref(self: &Arc<Self>) {
2884 self.0.unpark();
2885 }
2886 }
2887
2888 fn test_waker() -> std::task::Waker {
2889 std::task::Waker::from(Arc::new(ThreadWake(std::thread::current())))
2890 }
2891
2892 fn block_on_test_future<F: std::future::Future>(future: F) -> F::Output {
2893 let waker = test_waker();
2894 let mut context = std::task::Context::from_waker(&waker);
2895 let mut future = Box::pin(future);
2896 loop {
2897 match future.as_mut().poll(&mut context) {
2898 std::task::Poll::Ready(output) => return output,
2899 std::task::Poll::Pending => std::thread::park(),
2900 }
2901 }
2902 }
2903
2904 #[test]
2905 fn append_and_read() {
2906 let mut log = EventLog::new();
2907 log.append(
2908 EventKind::ProposalReceived,
2909 None,
2910 Some("p1"),
2911 [("source".to_string(), Value::from("test"))].into(),
2912 );
2913 assert_eq!(log.len(), 1);
2914 assert_eq!(log.events()[0].kind, EventKind::ProposalReceived);
2915 }
2916
2917 #[test]
2918 fn query_filters_by_kind_data_and_time() {
2919 let mut log = EventLog::new();
2920 log.append(
2921 EventKind::PermissionDecision,
2922 Some("a1"),
2923 None,
2924 [
2925 ("caller".to_string(), Value::from("alice")),
2926 ("tool".to_string(), Value::from("shell")),
2927 ]
2928 .into(),
2929 );
2930 log.append(
2931 EventKind::PermissionDecision,
2932 Some("a2"),
2933 None,
2934 [
2935 ("caller".to_string(), Value::from("bob")),
2936 ("tool".to_string(), Value::from("shell")),
2937 ]
2938 .into(),
2939 );
2940 log.append(
2941 EventKind::StateChanged,
2942 Some("a3"),
2943 None,
2944 Default::default(),
2945 );
2946
2947 let q = EventQuery {
2949 kinds: vec![EventKind::PermissionDecision],
2950 ..Default::default()
2951 };
2952 assert_eq!(log.query(&q).len(), 2);
2953
2954 let q = EventQuery {
2956 data_matches: [("caller".to_string(), "alice".to_string())].into(),
2957 ..Default::default()
2958 };
2959 let hits = log.query(&q);
2960 assert_eq!(hits.len(), 1);
2961 assert_eq!(hits[0].action_id.as_deref(), Some("a1"));
2962
2963 let q = EventQuery {
2965 kinds: vec![EventKind::PermissionDecision],
2966 data_matches: [("tool".to_string(), "shell".to_string())].into(),
2967 limit: Some(1),
2968 ..Default::default()
2969 };
2970 let hits = log.query(&q);
2972 assert_eq!(hits.len(), 1);
2973 assert_eq!(hits[0].action_id.as_deref(), Some("a2"));
2974 }
2975
2976 #[test]
2977 fn cost_by_agent_folds_metered_events() {
2978 let mut log = EventLog::new();
2979 log.append_metered(
2980 EventKind::InferenceMetered,
2981 None,
2982 None,
2983 [("agent".to_string(), Value::from("researcher"))].into(),
2984 Metrics {
2985 tokens_in: Some(100),
2986 tokens_out: Some(50),
2987 cost_usd: Some(2.0),
2988 ..Default::default()
2989 },
2990 );
2991 log.append_metered(
2992 EventKind::InferenceMetered,
2993 None,
2994 None,
2995 [("agent".to_string(), Value::from("researcher"))].into(),
2996 Metrics {
2997 tokens_in: Some(10),
2998 tokens_out: Some(5),
2999 cost_usd: Some(0.2),
3000 ..Default::default()
3001 },
3002 );
3003 log.append_metered(
3004 EventKind::InferenceMetered,
3005 None,
3006 None,
3007 [("agent".to_string(), Value::from("coordinator"))].into(),
3008 Metrics {
3009 cost_usd: Some(0.5),
3010 ..Default::default()
3011 },
3012 );
3013 let report = log.cost_by_agent();
3014 assert_eq!(report.len(), 2);
3015 assert_eq!(report[0].agent, "coordinator");
3017 assert_eq!(report[0].cost_usd, 0.5);
3018 assert_eq!(report[1].agent, "researcher");
3019 assert_eq!(report[1].calls, 2);
3020 assert_eq!(report[1].tokens_in, 110);
3021 assert_eq!(report[1].tokens_out, 55);
3022 assert!((report[1].cost_usd - 2.2).abs() < 1e-9);
3023 }
3024
3025 #[test]
3026 fn auto_retention_caps_event_count() {
3027 let mut log = EventLog::new();
3028 log.set_retention(Some(RetentionPolicy {
3029 max_events: Some(3),
3030 max_age_secs: None,
3031 }));
3032 for i in 0..10 {
3033 log.append(
3034 EventKind::StateChanged,
3035 Some(&format!("a{i}")),
3036 None,
3037 Default::default(),
3038 );
3039 }
3040 assert_eq!(log.len(), 3);
3042 assert_eq!(log.events()[0].action_id.as_deref(), Some("a7"));
3043 assert_eq!(log.events()[2].action_id.as_deref(), Some("a9"));
3044 }
3045
3046 #[test]
3047 fn enforce_retention_drops_old_by_age() {
3048 let mut log = EventLog::new();
3049 log.append(
3051 EventKind::StateChanged,
3052 Some("old"),
3053 None,
3054 Default::default(),
3055 );
3056 log.events[0].timestamp = Utc::now() - chrono::Duration::seconds(3600);
3057 log.append(
3058 EventKind::StateChanged,
3059 Some("fresh"),
3060 None,
3061 Default::default(),
3062 );
3063
3064 let removed = log.enforce_retention(
3065 &RetentionPolicy {
3066 max_events: None,
3067 max_age_secs: Some(60),
3068 },
3069 Utc::now(),
3070 );
3071 assert_eq!(removed, 1);
3072 assert_eq!(log.len(), 1);
3073 assert_eq!(log.events()[0].action_id.as_deref(), Some("fresh"));
3074 }
3075
3076 #[test]
3077 fn retention_trims_are_counted() {
3078 let mut log = EventLog::new();
3079 log.set_retention(Some(RetentionPolicy {
3080 max_events: Some(2),
3081 max_age_secs: None,
3082 }));
3083 for i in 0..5 {
3084 log.append(
3085 EventKind::StateChanged,
3086 Some(&format!("a{i}")),
3087 None,
3088 Default::default(),
3089 );
3090 }
3091 assert_eq!(log.trimmed_events(), 3);
3092 assert_eq!(log.truncate_events_keep_last(1), 1);
3093 assert_eq!(log.trimmed_events(), 4);
3094 log.clear();
3095 assert_eq!(log.trimmed_events(), 5);
3096 }
3097
3098 #[test]
3099 fn cumulative_cost_is_monotonic_across_trims_and_reload() {
3100 let dir = tempfile::tempdir().unwrap();
3101 let journal = dir.path().join("cost.jsonl");
3102 {
3103 let mut log = EventLog::with_journal(journal.clone());
3104 log.set_retention(Some(RetentionPolicy {
3105 max_events: Some(1),
3106 max_age_secs: None,
3107 }));
3108 for _ in 0..4 {
3109 log.append_metered(
3110 EventKind::InferenceMetered,
3111 None,
3112 None,
3113 Default::default(),
3114 Metrics {
3115 cost_usd: Some(2.5),
3116 ..Default::default()
3117 },
3118 );
3119 }
3120 assert_eq!(log.len(), 1);
3122 assert!((log.cumulative_cost_usd() - 10.0).abs() < 1e-9);
3123 }
3124 let reloaded = EventLog::load(&journal).unwrap();
3127 assert!((reloaded.cumulative_cost_usd() - 10.0).abs() < 1e-9);
3128 }
3129
3130 #[test]
3131 fn journal_compaction_rewrites_to_retained_set() {
3132 let dir = tempfile::tempdir().unwrap();
3137 let journal = dir.path().join("compact.jsonl");
3138 let keep = 16usize;
3139 let total = keep + JOURNAL_COMPACT_MIN_EXCESS + 8;
3140 {
3141 let mut log = EventLog::with_journal(journal.clone());
3142 log.set_retention(Some(RetentionPolicy {
3143 max_events: Some(keep),
3144 max_age_secs: None,
3145 }));
3146 for i in 0..total {
3147 log.append(
3148 EventKind::ActionSucceeded,
3149 Some(&format!("a{i}")),
3150 None,
3151 HashMap::new(),
3152 );
3153 }
3154 assert_eq!(log.len(), keep);
3155 assert!(log.journal_size_bytes().unwrap_or(0) > 0);
3156 } let reloaded = EventLog::load(&journal).unwrap();
3160 assert!(
3161 reloaded.len() < total,
3162 "journal must have been compacted (got {} lines)",
3163 reloaded.len()
3164 );
3165 assert_eq!(
3167 reloaded.events().last().unwrap().action_id.as_deref(),
3168 Some(format!("a{}", total - 1).as_str())
3169 );
3170 }
3171
3172 #[test]
3173 fn compact_journal_preserves_hash_chain_of_retained_tail() {
3174 let dir = tempfile::tempdir().unwrap();
3178 let journal = dir.path().join("chained.jsonl");
3179 {
3180 let mut log = EventLog::with_journal(journal.clone()).with_hash_chaining();
3181 for i in 0..20 {
3182 log.append(
3183 EventKind::ActionSucceeded,
3184 Some(&format!("a{i}")),
3185 None,
3186 HashMap::new(),
3187 );
3188 }
3189 log.truncate_events_keep_last(5);
3191 assert!(log.compact_journal(), "compaction must succeed");
3192 log.append(
3195 EventKind::ActionSucceeded,
3196 Some("post"),
3197 None,
3198 HashMap::new(),
3199 );
3200 }
3201 let reloaded = EventLog::load(&journal).unwrap();
3202 assert_eq!(reloaded.len(), 6);
3203 assert_eq!(reloaded.verify_chain(), Ok(6), "retained tail must verify");
3204 assert_eq!(reloaded.events()[0].action_id.as_deref(), Some("a15"));
3205 assert_eq!(reloaded.events()[5].action_id.as_deref(), Some("post"));
3206 }
3207
3208 #[test]
3209 fn compact_journal_without_journal_is_noop() {
3210 let mut log = EventLog::new();
3211 log.append(EventKind::StateChanged, Some("a"), None, Default::default());
3212 assert!(!log.compact_journal());
3213 assert_eq!(log.journal_size_bytes(), None);
3214 }
3215
3216 #[test]
3217 fn chaining_off_by_default_no_hashes() {
3218 let mut log = EventLog::new();
3219 log.append(
3220 EventKind::ActionSucceeded,
3221 Some("a1"),
3222 Some("p1"),
3223 HashMap::new(),
3224 );
3225 assert!(!log.hash_chaining_enabled());
3226 assert!(log.events()[0].hash.is_none());
3227 assert!(log.events()[0].prev_hash.is_none());
3228 assert_eq!(log.verify_chain(), Ok(0));
3230 }
3231
3232 #[test]
3233 fn hash_chain_verifies_clean_log() {
3234 let mut log = EventLog::new().with_hash_chaining();
3235 for i in 0..5 {
3236 log.append(
3237 EventKind::ActionSucceeded,
3238 Some(&format!("a{i}")),
3239 Some("p"),
3240 [("i".to_string(), Value::from(i))].into(),
3241 );
3242 }
3243 assert!(log.events().iter().all(|e| e.hash.is_some()));
3245 assert_eq!(log.verify_chain(), Ok(5));
3246 assert_eq!(log.events()[0].prev_hash.as_deref(), Some(""));
3248 for w in log.events().windows(2) {
3250 assert_eq!(w[1].prev_hash, w[0].hash);
3251 }
3252 }
3253
3254 #[test]
3255 fn tampering_with_data_breaks_chain() {
3256 let mut log = EventLog::new().with_hash_chaining();
3257 for i in 0..4 {
3258 log.append(
3259 EventKind::ActionSucceeded,
3260 Some(&format!("a{i}")),
3261 Some("p"),
3262 [("v".to_string(), Value::from(i))].into(),
3263 );
3264 }
3265 assert_eq!(log.verify_chain(), Ok(4));
3266 log.events[2].data.insert("v".to_string(), Value::from(999));
3268 assert_eq!(log.verify_chain(), Err(2));
3270 }
3271
3272 #[test]
3273 fn deleting_an_event_breaks_chain() {
3274 let mut log = EventLog::new().with_hash_chaining();
3275 for i in 0..4 {
3276 log.append(
3277 EventKind::ActionSucceeded,
3278 Some(&format!("a{i}")),
3279 Some("p"),
3280 HashMap::new(),
3281 );
3282 }
3283 log.events.remove(1);
3286 assert_eq!(log.verify_chain(), Err(1));
3287 }
3288
3289 #[test]
3290 fn chain_survives_serialize_roundtrip() {
3291 let mut log = EventLog::new().with_hash_chaining();
3292 for i in 0..3 {
3293 log.append(
3294 EventKind::PermissionDecision,
3295 Some(&format!("a{i}")),
3296 Some("p"),
3297 [
3298 ("decision".to_string(), Value::from("allow")),
3299 ("nested".to_string(), serde_json::json!({"z": 1, "a": 2})),
3300 ]
3301 .into(),
3302 );
3303 }
3304 let lines: Vec<String> = log
3307 .events()
3308 .iter()
3309 .map(|e| serde_json::to_string(e).unwrap())
3310 .collect();
3311 let mut rebuilt = EventLog::new();
3312 for line in &lines {
3313 rebuilt.events.push(serde_json::from_str(line).unwrap());
3314 }
3315 assert_eq!(rebuilt.verify_chain(), Ok(3));
3316 }
3317
3318 #[test]
3319 fn chain_survives_journal_load_and_append() {
3320 let dir = tempfile::tempdir().unwrap();
3325 let journal = dir.path().join("chain.jsonl");
3326 {
3327 let mut log = EventLog::with_journal(journal.clone());
3328 log.enable_hash_chaining();
3329 log.append(EventKind::ActionSucceeded, Some("a1"), None, HashMap::new());
3330 log.append(EventKind::ActionSucceeded, Some("a2"), None, HashMap::new());
3331 } {
3334 let mut log = EventLog::load(&journal).unwrap();
3335 assert!(
3336 log.hash_chaining_enabled(),
3337 "loading a chained tail re-enables chaining"
3338 );
3339 log.append(EventKind::ActionSucceeded, Some("a3"), None, HashMap::new());
3340 assert_eq!(log.verify_chain(), Ok(3), "post-load append stays chained");
3341 }
3342
3343 let reloaded = EventLog::load(&journal).unwrap();
3345 assert_eq!(reloaded.len(), 3);
3346 assert_eq!(reloaded.verify_chain(), Ok(3));
3347
3348 let plain = dir.path().join("plain.jsonl");
3350 {
3351 let mut log = EventLog::with_journal(plain.clone());
3352 log.append(EventKind::ActionSucceeded, Some("a1"), None, HashMap::new());
3353 }
3354 let loaded = EventLog::load(&plain).unwrap();
3355 assert!(!loaded.hash_chaining_enabled(), "unchained tail stays off");
3356 }
3357
3358 #[test]
3359 fn metered_event_carries_metrics_in_data() {
3360 let mut log = EventLog::new();
3361 log.append_metered(
3362 EventKind::ActionSucceeded,
3363 Some("a1"),
3364 Some("p1"),
3365 [("tool".to_string(), Value::from("search"))].into(),
3366 Metrics::inference(120, 45, Some(0.0012)).with_duration(83.0),
3367 );
3368 let ev = &log.events()[0];
3369 assert_eq!(ev.data.get("tool").unwrap(), "search");
3371 assert_eq!(ev.duration_ms(), Some(83.0));
3372 assert_eq!(ev.tokens_in(), Some(120));
3373 assert_eq!(ev.tokens_out(), Some(45));
3374 assert_eq!(ev.cost_usd(), Some(0.0012));
3375 }
3376
3377 #[test]
3378 fn metrics_totals_sum_across_events() {
3379 let mut log = EventLog::new();
3380 log.append_metered(
3381 EventKind::ActionSucceeded,
3382 Some("a1"),
3383 None,
3384 HashMap::new(),
3385 Metrics::latency(50.0),
3386 );
3387 log.append_metered(
3388 EventKind::ActionSucceeded,
3389 Some("a2"),
3390 None,
3391 HashMap::new(),
3392 Metrics::inference(100, 20, Some(0.5)).with_duration(70.0),
3393 );
3394 log.append(EventKind::ProposalReceived, None, None, HashMap::new());
3396
3397 let t = log.metrics_totals();
3398 assert_eq!(t.duration_ms, 120.0);
3399 assert_eq!(t.tokens_in, 100);
3400 assert_eq!(t.tokens_out, 20);
3401 assert_eq!(t.tokens, 120);
3402 assert_eq!(t.cost_usd, 0.5);
3403 assert_eq!(t.metered_events, 2);
3404 }
3405
3406 #[test]
3407 fn metrics_totals_counts_raw_appended_duration_key() {
3408 let mut log = EventLog::new();
3413 log.append(
3414 EventKind::ActionSucceeded,
3415 Some("a1"),
3416 None,
3417 [(metric_keys::DURATION_MS.to_string(), Value::from(42.0))].into(),
3418 );
3419 let t = log.metrics_totals();
3420 assert_eq!(t.duration_ms, 42.0);
3421 assert_eq!(t.metered_events, 1);
3422 }
3423
3424 #[test]
3425 fn new_telemetry_event_kinds_serialize_snake_case() {
3426 let json = serde_json::to_string(&EventKind::BranchDecision).unwrap();
3428 assert_eq!(json, "\"branch_decision\"");
3429 let json = serde_json::to_string(&EventKind::AlternativeRejected).unwrap();
3430 assert_eq!(json, "\"alternative_rejected\"");
3431 let json = serde_json::to_string(&EventKind::InferenceMetered).unwrap();
3432 assert_eq!(json, "\"inference_metered\"");
3433 }
3434
3435 #[test]
3436 fn filter_by_kind() {
3437 let mut log = EventLog::new();
3438 log.append(
3439 EventKind::ProposalReceived,
3440 None,
3441 Some("p1"),
3442 HashMap::new(),
3443 );
3444 log.append(
3445 EventKind::ActionValidated,
3446 Some("a1"),
3447 Some("p1"),
3448 HashMap::new(),
3449 );
3450 log.append(
3451 EventKind::ActionSucceeded,
3452 Some("a1"),
3453 Some("p1"),
3454 HashMap::new(),
3455 );
3456
3457 let validated = log.filter(Some(&EventKind::ActionValidated), None);
3458 assert_eq!(validated.len(), 1);
3459 }
3460
3461 #[test]
3462 fn filter_by_action_id() {
3463 let mut log = EventLog::new();
3464 log.append(EventKind::ActionValidated, Some("a1"), None, HashMap::new());
3465 log.append(EventKind::ActionValidated, Some("a2"), None, HashMap::new());
3466
3467 let a1_events = log.filter(None, Some("a1"));
3468 assert_eq!(a1_events.len(), 1);
3469 }
3470
3471 #[test]
3472 fn journal_write_and_reload() {
3473 let dir = tempfile::tempdir().unwrap();
3474 let journal = dir.path().join("events.jsonl");
3475
3476 {
3477 let mut log = EventLog::with_journal(journal.clone());
3478 log.append(
3479 EventKind::ProposalReceived,
3480 None,
3481 Some("p1"),
3482 HashMap::new(),
3483 );
3484 log.append(
3485 EventKind::ActionSucceeded,
3486 Some("a1"),
3487 Some("p1"),
3488 HashMap::new(),
3489 );
3490 }
3491
3492 assert!(journal.exists());
3493
3494 let reloaded = EventLog::load(&journal).unwrap();
3495 assert_eq!(reloaded.len(), 2);
3496 assert_eq!(reloaded.events()[0].kind, EventKind::ProposalReceived);
3497 assert_eq!(reloaded.events()[1].kind, EventKind::ActionSucceeded);
3498 }
3499
3500 #[test]
3501 fn load_rejects_newline_terminated_corrupt_middle_before_later_terminal() {
3502 let dir = tempfile::tempdir().unwrap();
3503 let journal = dir.path().join("corrupt-middle.jsonl");
3504 let started = serde_json::json!({
3505 "kind": "run_started",
3506 "run_id": "run-corrupt",
3507 "client_id": "client-1",
3508 "data": {"agent_id": "daily-continuity-newsroom"},
3509 "timestamp": "2026-08-30T09:30:00Z"
3510 });
3511 let completed = serde_json::json!({
3512 "kind": "run_completed",
3513 "run_id": "run-corrupt",
3514 "client_id": "client-1",
3515 "data": {
3516 "completion_digest": "must-not-be-trusted",
3517 "termination": {"kind": "outcome", "status": "success", "outcome": {}}
3518 },
3519 "timestamp": "2026-08-30T10:00:00Z"
3520 });
3521 fs::write(
3522 &journal,
3523 format!("{started}\n{{this-is-not-json}}\n{completed}\n"),
3524 )
3525 .unwrap();
3526
3527 let error = match EventLog::load(&journal) {
3528 Ok(_) => panic!("newline-terminated middle corruption must fail closed"),
3529 Err(error) => error,
3530 };
3531 assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
3532 assert!(error.to_string().contains("line=2"), "{error}");
3533 }
3534
3535 #[test]
3536 fn load_repairs_crash_torn_final_record_before_append() {
3537 let dir = tempfile::tempdir().unwrap();
3538 let journal = dir.path().join("torn-tail.jsonl");
3539 let started = serde_json::json!({
3540 "kind": "run_started",
3541 "run_id": "run-torn",
3542 "client_id": "client-1",
3543 "data": {"agent_id": "daily-continuity-newsroom"},
3544 "timestamp": "2026-08-30T09:30:00Z"
3545 });
3546 let mut journal_file = create_private_file(&journal).unwrap();
3547 write!(journal_file, "{started}\n{{\"kind\":\"run_completed\"").unwrap();
3548 drop(journal_file);
3549
3550 {
3551 let mut loaded = EventLog::load(&journal).expect("torn final row is recoverable");
3552 assert_eq!(loaded.len(), 1);
3553 loaded.append(
3554 EventKind::ProposalReceived,
3555 None,
3556 Some("proposal-after-recovery"),
3557 HashMap::new(),
3558 );
3559 }
3560
3561 let bytes = fs::read_to_string(&journal).unwrap();
3562 assert!(!bytes.contains("{\"kind\":\"run_completed\""), "{bytes}");
3563 assert!(bytes.ends_with('\n'));
3564 let reloaded = EventLog::load(&journal).unwrap();
3565 assert_eq!(reloaded.len(), 2);
3566 assert_eq!(reloaded.events()[0].kind, EventKind::RunStarted);
3567 assert_eq!(reloaded.events()[1].kind, EventKind::ProposalReceived);
3568 }
3569
3570 #[test]
3571 fn load_read_only_rejects_a_torn_tail_without_modifying_the_journal() {
3572 let dir = tempfile::tempdir().unwrap();
3573 let journal = dir.path().join("read-only-torn-tail.jsonl");
3574 let started = serde_json::json!({
3575 "kind": "run_started",
3576 "run_id": "run-torn",
3577 "client_id": "client-1",
3578 "data": {"agent_id": "daily-continuity-newsroom"},
3579 "timestamp": "2026-08-30T09:30:00Z"
3580 });
3581 fs::write(&journal, format!("{started}\n{{\"kind\":\"run_completed\"")).unwrap();
3582 let before = fs::read(&journal).unwrap();
3583
3584 let error = match EventLog::load_read_only(&journal) {
3585 Ok(_) => panic!("read-only loading must expose a torn final row"),
3586 Err(error) => error,
3587 };
3588
3589 assert_eq!(error.kind(), std::io::ErrorKind::UnexpectedEof);
3590 assert!(error.to_string().contains("event journal torn tail"));
3591 assert!(error.to_string().contains("line=2"));
3592 assert_eq!(fs::read(&journal).unwrap(), before);
3593 }
3594
3595 #[test]
3596 fn load_rejects_existing_hash_chain_tampering() {
3597 let dir = tempfile::tempdir().unwrap();
3598 let journal = dir.path().join("tampered-chain.jsonl");
3599 {
3600 let mut log = EventLog::with_journal(journal.clone()).with_hash_chaining();
3601 log.append(
3602 EventKind::RunStarted,
3603 None,
3604 None,
3605 [(
3606 "agent_id".to_string(),
3607 Value::from("daily-continuity-newsroom"),
3608 )]
3609 .into(),
3610 );
3611 log.append(EventKind::RunCompleted, None, None, HashMap::new());
3612 }
3613 let mut rows: Vec<Value> = fs::read_to_string(&journal)
3614 .unwrap()
3615 .lines()
3616 .map(|line| serde_json::from_str(line).unwrap())
3617 .collect();
3618 rows[0]["data"]["agent_id"] = Value::from("tampered-agent");
3619 fs::write(
3620 &journal,
3621 rows.iter()
3622 .map(Value::to_string)
3623 .collect::<Vec<_>>()
3624 .join("\n")
3625 + "\n",
3626 )
3627 .unwrap();
3628
3629 let error = match EventLog::load(&journal) {
3630 Ok(_) => panic!("hash-chain tampering must fail closed during load"),
3631 Err(error) => error,
3632 };
3633 assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
3634 assert!(error.to_string().contains("hash chain"), "{error}");
3635 assert!(error.to_string().contains("line=1"), "{error}");
3636 }
3637
3638 #[cfg(unix)]
3639 fn unix_mode(path: &Path) -> u32 {
3640 use std::os::unix::fs::PermissionsExt;
3641 fs::symlink_metadata(path).unwrap().permissions().mode() & 0o777
3642 }
3643
3644 #[cfg(unix)]
3645 #[test]
3646 fn car_owned_journal_and_created_parents_are_private() {
3647 let root = tempfile::tempdir().unwrap();
3648 let parent = root.path().join("eventlogs").join("session");
3649 let journal = parent.join("events.jsonl");
3650 {
3651 let mut log = EventLog::with_journal(journal.clone());
3652 log.append(EventKind::StateChanged, Some("a1"), None, HashMap::new());
3653 }
3654
3655 assert_eq!(unix_mode(&root.path().join("eventlogs")), 0o700);
3656 assert_eq!(unix_mode(&parent), 0o700);
3657 assert_eq!(unix_mode(&journal), 0o600);
3658 }
3659
3660 #[cfg(unix)]
3661 #[test]
3662 fn append_hardens_preexisting_owned_permissive_journal() {
3663 use std::os::unix::fs::PermissionsExt;
3664
3665 let dir = tempfile::tempdir().unwrap();
3666 let journal = dir.path().join("events.jsonl");
3667 fs::write(&journal, b"").unwrap();
3668 fs::set_permissions(&journal, fs::Permissions::from_mode(0o644)).unwrap();
3669 {
3670 let mut log = EventLog::with_journal(journal.clone());
3671 log.append(EventKind::StateChanged, Some("a1"), None, HashMap::new());
3672 }
3673 assert_eq!(unix_mode(&journal), 0o600);
3674 }
3675
3676 #[cfg(unix)]
3677 #[test]
3678 fn journal_refuses_symlink_and_hardlink_destinations() {
3679 use std::os::unix::fs::symlink;
3680
3681 let dir = tempfile::tempdir().unwrap();
3682 let victim = dir.path().join("victim");
3683 fs::write(&victim, b"unchanged").unwrap();
3684
3685 for journal in [dir.path().join("symlink"), dir.path().join("hardlink")] {
3686 if journal.ends_with("symlink") {
3687 symlink(&victim, &journal).unwrap();
3688 } else {
3689 fs::hard_link(&victim, &journal).unwrap();
3690 }
3691 {
3692 let mut log = EventLog::with_journal(journal);
3693 log.append(EventKind::StateChanged, Some("a1"), None, HashMap::new());
3694 }
3695 assert_eq!(fs::read(&victim).unwrap(), b"unchanged");
3696 }
3697 }
3698
3699 #[cfg(unix)]
3700 #[test]
3701 fn journal_stops_if_the_opened_path_is_substituted() {
3702 let dir = tempfile::tempdir().unwrap();
3703 let journal = dir.path().join("events.jsonl");
3704 let moved = dir.path().join("moved.jsonl");
3705 let mut log = EventLog::with_journal(journal.clone());
3706 log.append(EventKind::StateChanged, Some("first"), None, HashMap::new());
3707
3708 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
3709 while fs::metadata(&journal).map_or(true, |metadata| metadata.len() == 0) {
3710 assert!(
3711 std::time::Instant::now() < deadline,
3712 "first event was not persisted"
3713 );
3714 std::thread::yield_now();
3715 }
3716 fs::rename(&journal, &moved).unwrap();
3717 let _substitute = create_private_file(&journal).unwrap();
3718 log.append(
3719 EventKind::StateChanged,
3720 Some("second"),
3721 None,
3722 HashMap::new(),
3723 );
3724 drop(log);
3725
3726 assert_eq!(EventLog::load(&moved).unwrap().len(), 1);
3727 assert_eq!(fs::metadata(&journal).unwrap().len(), 0);
3728 }
3729
3730 #[cfg(unix)]
3731 #[test]
3732 fn compaction_preserves_private_mode_and_leaves_no_temp_name() {
3733 let dir = tempfile::tempdir().unwrap();
3734 let journal = dir.path().join("events.jsonl");
3735 let mut log = EventLog::with_journal(journal.clone());
3736 for index in 0..4 {
3737 log.append(
3738 EventKind::StateChanged,
3739 Some(&format!("a{index}")),
3740 None,
3741 HashMap::new(),
3742 );
3743 }
3744 log.truncate_events_keep_last(2);
3745 assert!(log.compact_journal());
3746 drop(log);
3747
3748 assert_eq!(unix_mode(&journal), 0o600);
3749 let names: Vec<_> = fs::read_dir(dir.path())
3750 .unwrap()
3751 .map(|entry| entry.unwrap().file_name())
3752 .collect();
3753 assert_eq!(names, vec![journal.file_name().unwrap()]);
3754 }
3755
3756 #[cfg(unix)]
3757 #[test]
3758 fn historical_world_readable_journal_can_be_loaded_without_mutation() {
3759 use std::os::unix::fs::PermissionsExt;
3760
3761 let dir = tempfile::tempdir().unwrap();
3762 let journal = dir.path().join("historical.jsonl");
3763 let event = Event {
3764 kind: EventKind::StateChanged,
3765 run_id: None,
3766 client_id: None,
3767 policy_session_id: None,
3768 action_id: Some("historical".into()),
3769 proposal_id: None,
3770 data: HashMap::new(),
3771 timestamp: Utc::now(),
3772 prev_hash: None,
3773 hash: None,
3774 };
3775 fs::write(
3776 &journal,
3777 format!("{}\n", serde_json::to_string(&event).unwrap()),
3778 )
3779 .unwrap();
3780 fs::set_permissions(&journal, fs::Permissions::from_mode(0o644)).unwrap();
3781
3782 let loaded = EventLog::load(&journal).unwrap();
3783 assert_eq!(loaded.len(), 1);
3784 drop(loaded);
3785 assert_eq!(unix_mode(&journal), 0o644);
3786 }
3787
3788 #[test]
3789 fn journal_not_created_without_appends() {
3790 let dir = tempfile::tempdir().unwrap();
3794 let journal = dir.path().join("no-events.jsonl");
3795 {
3796 let _log = EventLog::with_journal(journal.clone());
3797 }
3800 assert!(
3801 !journal.exists(),
3802 "journal file must not be created when nothing is appended"
3803 );
3804 }
3805
3806 #[test]
3807 fn journal_preserves_order_and_count_under_burst() {
3808 let dir = tempfile::tempdir().unwrap();
3812 let journal = dir.path().join("burst.jsonl");
3813 {
3814 let mut log = EventLog::with_journal(journal.clone());
3815 for i in 0..500 {
3816 log.append(
3817 EventKind::ActionSucceeded,
3818 Some(&format!("a{i}")),
3819 None,
3820 HashMap::new(),
3821 );
3822 }
3823 } let reloaded = EventLog::load(&journal).unwrap();
3826 assert_eq!(reloaded.len(), 500, "no events lost");
3827 for (i, event) in reloaded.events().iter().enumerate() {
3828 assert_eq!(
3829 event.action_id.as_deref(),
3830 Some(format!("a{i}").as_str()),
3831 "order preserved at {i}"
3832 );
3833 }
3834 }
3835
3836 #[test]
3837 fn unopenable_journal_is_best_effort_not_fatal() {
3838 let dir = tempfile::tempdir().unwrap();
3842 let journal = dir.path().join("a-directory");
3843 fs::create_dir(&journal).unwrap(); let mut log = EventLog::with_journal(journal);
3846 log.append(
3847 EventKind::ProposalReceived,
3848 None,
3849 Some("p1"),
3850 HashMap::new(),
3851 );
3852 log.append(EventKind::ActionSucceeded, Some("a1"), None, HashMap::new());
3853 assert_eq!(
3854 log.len(),
3855 2,
3856 "in-memory log unaffected by an unwritable journal"
3857 );
3858 }
3860
3861 #[test]
3862 fn load_then_append_preserves_existing_and_adds() {
3863 let dir = tempfile::tempdir().unwrap();
3864 let journal = dir.path().join("resume.jsonl");
3865 {
3866 let mut log = EventLog::with_journal(journal.clone());
3867 log.append(
3868 EventKind::ProposalReceived,
3869 None,
3870 Some("p1"),
3871 HashMap::new(),
3872 );
3873 }
3874 {
3876 let mut log = EventLog::load(&journal).unwrap();
3877 assert_eq!(log.len(), 1);
3878 log.append(EventKind::ActionSucceeded, Some("a1"), None, HashMap::new());
3879 }
3880 let reloaded = EventLog::load(&journal).unwrap();
3881 assert_eq!(reloaded.len(), 2, "append-mode preserved the loaded line");
3882 assert_eq!(reloaded.events()[0].kind, EventKind::ProposalReceived);
3883 assert_eq!(reloaded.events()[1].kind, EventKind::ActionSucceeded);
3884 }
3885
3886 #[test]
3887 fn event_kind_serializes_snake_case() {
3888 assert_eq!(
3889 serde_json::to_string(&EventKind::ProposalReceived).unwrap(),
3890 "\"proposal_received\""
3891 );
3892 assert_eq!(
3893 serde_json::to_string(&EventKind::StateSnapshot).unwrap(),
3894 "\"state_snapshot\""
3895 );
3896 }
3897
3898 #[test]
3899 fn stats_truncate_and_clear_release_retained_entries() {
3900 let mut log = EventLog::new();
3901 for idx in 0..5 {
3902 log.append(
3903 EventKind::ActionSucceeded,
3904 Some(&format!("a{idx}")),
3905 Some("p1"),
3906 [("payload".to_string(), Value::from("x".repeat(16)))].into(),
3907 );
3908 log.begin_span("action.tool_call", "trace", None, HashMap::new());
3909 }
3910
3911 let stats = log.stats();
3912 assert_eq!(stats.events, 5);
3913 assert_eq!(stats.spans, 5);
3914 assert!(stats.approx_event_bytes > 0);
3915 assert!(stats.approx_span_bytes > 0);
3916
3917 assert_eq!(log.truncate_events_keep_last(2), 3);
3918 assert_eq!(log.truncate_spans_keep_last(1), 4);
3919 assert_eq!(log.len(), 2);
3920 assert_eq!(log.span_len(), 1);
3921 assert_eq!(log.events()[0].action_id.as_deref(), Some("a3"));
3922
3923 let removed = log.clear();
3924 assert_eq!(removed.events, 2);
3925 assert_eq!(removed.spans, 1);
3926 assert_eq!(log.len(), 0);
3927 assert_eq!(log.span_len(), 0);
3928 }
3929
3930 #[test]
3931 fn span_begin_end_lifecycle() {
3932 let mut log = EventLog::new();
3933 let trace_id = "trace-1".to_string();
3934
3935 let span_id = log.begin_span(
3936 "test.operation",
3937 &trace_id,
3938 None,
3939 [("key".to_string(), Value::from("value"))].into(),
3940 );
3941
3942 let spans = log.spans();
3943 assert_eq!(spans.len(), 1);
3944 assert_eq!(spans[0].name, "test.operation");
3945 assert_eq!(spans[0].trace_id, "trace-1");
3946 assert!(spans[0].parent_span_id.is_none());
3947 assert!(spans[0].end_time.is_none());
3948 assert_eq!(spans[0].status, SpanStatus::Unset);
3949
3950 log.end_span(&span_id, SpanStatus::Ok);
3951
3952 let spans = log.spans();
3953 assert!(spans[0].end_time.is_some());
3954 assert_eq!(spans[0].status, SpanStatus::Ok);
3955 }
3956
3957 #[test]
3958 fn span_parent_child_relationship() {
3959 let mut log = EventLog::new();
3960 let trace_id = "trace-2".to_string();
3961
3962 let parent_id = log.begin_span("parent.op", &trace_id, None, HashMap::new());
3963 let child_id = log.begin_span("child.op", &trace_id, Some(&parent_id), HashMap::new());
3964
3965 let spans = log.spans();
3966 assert_eq!(spans.len(), 2);
3967
3968 let child = spans.iter().find(|s| s.span_id == child_id).unwrap();
3969 assert_eq!(child.parent_span_id.as_deref(), Some(parent_id.as_str()));
3970 assert_eq!(child.trace_id, trace_id);
3971
3972 let parent = spans.iter().find(|s| s.span_id == parent_id).unwrap();
3973 assert!(parent.parent_span_id.is_none());
3974 }
3975
3976 #[test]
3977 fn export_traces_produces_valid_json() {
3978 let mut log = EventLog::new();
3979 let trace_id = "trace-3".to_string();
3980
3981 let root = log.begin_span(
3982 "proposal.execute",
3983 &trace_id,
3984 None,
3985 [("proposal_id".to_string(), Value::from("p1"))].into(),
3986 );
3987 let child = log.begin_span(
3988 "action.tool_call",
3989 &trace_id,
3990 Some(&root),
3991 [("tool".to_string(), Value::from("read_file"))].into(),
3992 );
3993 log.end_span(&child, SpanStatus::Ok);
3994 log.end_span(&root, SpanStatus::Ok);
3995
3996 let json_str = log.export_traces();
3997 let parsed: Value =
3998 serde_json::from_str(&json_str).expect("export_traces must produce valid JSON");
3999
4000 let resource_spans = parsed["resourceSpans"].as_array().unwrap();
4001 assert_eq!(resource_spans.len(), 1);
4002
4003 let scope_spans = &resource_spans[0]["scopeSpans"][0]["spans"];
4004 let spans_arr = scope_spans.as_array().unwrap();
4005 assert_eq!(spans_arr.len(), 2);
4006
4007 for span in spans_arr {
4009 assert!(span.get("traceId").is_some());
4010 assert!(span.get("spanId").is_some());
4011 assert!(span.get("name").is_some());
4012 assert!(span.get("startTimeUnixNano").is_some());
4013 assert!(span.get("endTimeUnixNano").is_some());
4014 assert!(span.get("status").is_some());
4015 }
4016
4017 let child_span = spans_arr
4019 .iter()
4020 .find(|s| s["name"] == "action.tool_call")
4021 .unwrap();
4022 assert!(child_span.get("parentSpanId").is_some());
4023 }
4024
4025 #[test]
4026 fn span_status_set_on_error() {
4027 let mut log = EventLog::new();
4028 let trace_id = "trace-4".to_string();
4029
4030 let span_id = log.begin_span("failing.op", &trace_id, None, HashMap::new());
4031 log.end_span(&span_id, SpanStatus::Error);
4032
4033 let spans = log.spans();
4034 assert_eq!(spans[0].status, SpanStatus::Error);
4035 assert!(spans[0].end_time.is_some());
4036 }
4037
4038 #[test]
4039 fn active_run_binding_stamps_every_new_event_and_rejects_conflicts() {
4040 let mut log = EventLog::new();
4041 log.bind_run("run-a", "client-a")
4042 .expect("first active run binds");
4043 log.bind_policy_session("policy-session-a")
4044 .expect("CAR-minted policy session binds inside the run");
4045
4046 log.append(
4047 EventKind::ProposalReceived,
4048 None,
4049 Some("same-proposal"),
4050 HashMap::new(),
4051 );
4052 log.append(
4053 EventKind::ActionSucceeded,
4054 Some("action-a"),
4055 Some("same-proposal"),
4056 HashMap::new(),
4057 );
4058
4059 for event in log.events() {
4060 assert_eq!(event.run_id.as_deref(), Some("run-a"));
4061 assert_eq!(event.client_id.as_deref(), Some("client-a"));
4062 assert_eq!(event.policy_session_id.as_deref(), Some("policy-session-a"));
4063 }
4064 assert!(log.bind_run("run-b", "client-a").is_err());
4065 assert!(log.bind_run("run-a", "client-b").is_err());
4066 assert!(log.clear_run_binding("run-b", "client-a").is_err());
4067 assert_eq!(
4068 log.active_run_binding(),
4069 Some(("run-a", "client-a", Some("policy-session-a")))
4070 );
4071
4072 log.clear_policy_session("policy-session-a")
4073 .expect("exact policy session clears");
4074 log.clear_run_binding("run-a", "client-a")
4075 .expect("exact active run clears");
4076 log.append(
4077 EventKind::ProposalReceived,
4078 None,
4079 Some("unbound-legacy"),
4080 HashMap::new(),
4081 );
4082 let legacy = log.events().last().unwrap();
4083 assert!(legacy.run_id.is_none());
4084 assert!(legacy.client_id.is_none());
4085 assert!(legacy.policy_session_id.is_none());
4086 }
4087
4088 #[test]
4089 fn historical_event_without_binding_fields_still_deserializes() {
4090 let historical = r#"{"kind":"proposal_received","proposal_id":"p-old","data":{},"timestamp":"2026-01-02T03:04:05Z"}"#;
4091 let event: Event = serde_json::from_str(historical).expect("historical event replays");
4092 assert!(event.run_id.is_none());
4093 assert!(event.client_id.is_none());
4094 assert!(event.policy_session_id.is_none());
4095 }
4096
4097 #[test]
4098 fn async_acknowledgement_timeout_wins_after_expiry_removal() {
4099 let manager = AsyncAcknowledgementManager::new(1);
4100 let barrier = AsyncAcknowledgementExpiryBarrier::new();
4101 manager.pause_next_expiry_after_removal(barrier.clone());
4102 let reservation = manager.reserve(Duration::from_millis(10)).unwrap();
4103 let sender = reservation.sender();
4104 let acknowledgement = reservation.into_future();
4105
4106 barrier.wait_until_removed();
4107 sender.send(Ok(()));
4108 let result = block_on_test_future(acknowledgement);
4109 barrier.allow_timeout_completion();
4110 manager.shutdown();
4111
4112 assert!(matches!(
4113 result,
4114 Err(CriticalPostAcceptanceError::AcknowledgementTimedOut { .. })
4115 ));
4116 }
4117
4118 #[test]
4119 fn async_acknowledgement_capacity_is_atomic_under_concurrent_reservation() {
4120 let manager = Arc::new(AsyncAcknowledgementManager::new(1));
4121 let barrier = Arc::new(std::sync::Barrier::new(3));
4122 let handles: Vec<_> = (0..2)
4123 .map(|_| {
4124 let manager = manager.clone();
4125 let barrier = barrier.clone();
4126 std::thread::spawn(move || {
4127 let reservation = manager.reserve(MAX_CRITICAL_ACKNOWLEDGEMENT_TIMEOUT);
4128 barrier.wait();
4129 reservation
4130 })
4131 })
4132 .collect();
4133
4134 barrier.wait();
4135 let results: Vec<_> = handles
4136 .into_iter()
4137 .map(|handle| handle.join().unwrap())
4138 .collect();
4139 assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
4140 assert_eq!(
4141 results
4142 .iter()
4143 .filter(|result| {
4144 matches!(
4145 result,
4146 Err(CriticalPreAcceptanceError::CapacityExhausted { capacity: 1 })
4147 )
4148 })
4149 .count(),
4150 1
4151 );
4152 drop(results);
4153 manager.shutdown();
4154 }
4155
4156 #[test]
4157 fn async_acknowledgement_completed_before_future_construction_is_observed() {
4158 let manager = AsyncAcknowledgementManager::new(1);
4159 let reservation = manager.reserve(Duration::from_secs(1)).unwrap();
4160 reservation.sender().send(Ok(()));
4161 let acknowledgement = reservation.into_future();
4162
4163 assert!(block_on_test_future(acknowledgement).is_ok());
4164 manager.shutdown();
4165 }
4166
4167 #[test]
4168 fn journal_writer_drop_completes_pending_async_acknowledgement() {
4169 let dir = tempfile::tempdir().unwrap();
4170 let failures = JournalFailureInjector::default();
4171 failures.fail_next(JournalFailurePoint::HoldAcknowledgement);
4172 let writer = JournalWriter::spawn_with_injector(
4173 dir.path().join("pending-ack-shutdown.jsonl"),
4174 failures.clone(),
4175 );
4176 let reservation = writer
4177 .reserve_async_acknowledgement(MAX_CRITICAL_ACKNOWLEDGEMENT_TIMEOUT)
4178 .unwrap();
4179 let acknowledgement = writer
4180 .enqueue_critical_async("{}".to_string(), false, reservation)
4181 .unwrap();
4182 let deadline = Instant::now() + Duration::from_secs(2);
4183 while failures.held_acknowledgement_count() == 0 {
4184 assert!(
4185 Instant::now() < deadline,
4186 "writer never retained the pending acknowledgement"
4187 );
4188 std::thread::yield_now();
4189 }
4190
4191 drop(writer);
4192 let result = block_on_test_future(acknowledgement);
4193 assert!(matches!(
4194 result,
4195 Err(CriticalPostAcceptanceError::CoordinatorStopped)
4196 ));
4197 failures.release_held_acknowledgements();
4198 }
4199
4200 #[test]
4201 fn prepare_failure_releases_async_acknowledgement_capacity() {
4202 let dir = tempfile::tempdir().unwrap();
4203 let mut log = EventLog::with_journal_failure_injector_and_ack_capacity(
4204 dir.path().join("prepare-failure-capacity.jsonl"),
4205 JournalFailureInjector::default(),
4206 1,
4207 );
4208 let data = HashMap::from([("completion_digest".to_string(), Value::from("7".repeat(64)))]);
4209
4210 let error = block_on_test_future(log.append_critical_async(
4211 EventKind::RunCompleted,
4212 None,
4213 None,
4214 data.clone(),
4215 Duration::from_secs(1),
4216 ))
4217 .expect_err("an unbound critical event must fail during preparation");
4218 assert!(matches!(error, CriticalAppendError::Rejected { .. }));
4219
4220 log.bind_run("run-after-prepare-failure", "client-after-prepare-failure")
4221 .unwrap();
4222 block_on_test_future(log.append_critical_async(
4223 EventKind::RunCompleted,
4224 None,
4225 None,
4226 data,
4227 Duration::from_secs(1),
4228 ))
4229 .expect("the failed preparation must release the only acknowledgement slot");
4230 }
4231
4232 #[test]
4233 fn async_critical_preacceptance_failures_reject_without_fabricating_events() {
4234 let data = HashMap::from([("completion_digest".to_string(), Value::from("f".repeat(64)))]);
4235
4236 let mut no_journal = EventLog::new();
4237 no_journal
4238 .bind_run("run-no-writer", "client-no-writer")
4239 .unwrap();
4240 let error = block_on_test_future(no_journal.append_critical_async(
4241 EventKind::RunCompleted,
4242 None,
4243 None,
4244 data.clone(),
4245 Duration::from_millis(100),
4246 ))
4247 .expect_err("a missing writer must reject before acceptance");
4248 assert!(matches!(error, CriticalAppendError::Rejected { .. }));
4249 assert!(!error.is_retry_safe());
4250 assert!(no_journal.events().is_empty());
4251 assert!(no_journal.critical_pending.is_empty());
4252
4253 let dir = tempfile::tempdir().unwrap();
4254 let unavailable_path = dir.path().join("sender-missing.jsonl");
4255 let mut unavailable = EventLog::with_journal(unavailable_path.clone());
4256 unavailable
4257 .bind_run("run-sender-missing", "client-sender-missing")
4258 .unwrap();
4259 unavailable
4260 .journal
4261 .as_mut()
4262 .unwrap()
4263 .remove_sender_for_test();
4264 let error = block_on_test_future(unavailable.append_critical_async(
4265 EventKind::RunCompleted,
4266 None,
4267 None,
4268 data.clone(),
4269 Duration::from_millis(100),
4270 ))
4271 .expect_err("a missing writer sender must reject before event construction");
4272 assert!(matches!(error, CriticalAppendError::Rejected { .. }));
4273 assert!(unavailable.events().is_empty());
4274 assert!(unavailable.critical_pending.is_empty());
4275 assert!(!unavailable_path.exists());
4276
4277 let stopped_path = dir.path().join("receiver-stopped.jsonl");
4278 let mut stopped = EventLog::with_journal(stopped_path.clone());
4279 stopped
4280 .bind_run("run-receiver-stopped", "client-receiver-stopped")
4281 .unwrap();
4282 stopped.journal.as_mut().unwrap().stop_receiver_for_test();
4283 let error = block_on_test_future(stopped.append_critical_async(
4284 EventKind::RunCompleted,
4285 None,
4286 None,
4287 data,
4288 Duration::from_millis(100),
4289 ))
4290 .expect_err("a stopped writer receiver must reject a failed enqueue");
4291 assert!(matches!(error, CriticalAppendError::Rejected { .. }));
4292 assert!(stopped.events().is_empty());
4293 assert!(stopped.critical_pending.is_empty());
4294 assert!(!stopped_path.exists());
4295 }
4296
4297 #[test]
4298 fn async_critical_rejects_excessive_acknowledgement_duration_before_enqueue() {
4299 let dir = tempfile::tempdir().unwrap();
4300 let path = dir.path().join("excessive-ack-duration.jsonl");
4301 let mut log = EventLog::with_journal(path.clone());
4302 log.bind_run("run-excessive-ack", "client-excessive-ack")
4303 .unwrap();
4304
4305 let error = block_on_test_future(log.append_critical_async(
4306 EventKind::RunCompleted,
4307 None,
4308 None,
4309 HashMap::new(),
4310 MAX_CRITICAL_ACKNOWLEDGEMENT_TIMEOUT + Duration::from_millis(1),
4311 ))
4312 .expect_err("an excessive acknowledgement duration must reject");
4313
4314 assert!(matches!(error, CriticalAppendError::Rejected { .. }));
4315 assert!(log.events().is_empty());
4316 assert!(log.critical_pending.is_empty());
4317 assert!(!path.exists());
4318 }
4319
4320 #[test]
4321 fn async_critical_capacity_exhaustion_rejects_before_enqueue_and_exact_retry_unblocks() {
4322 let dir = tempfile::tempdir().unwrap();
4323 let path = dir.path().join("critical-ack-capacity.jsonl");
4324 let failures = JournalFailureInjector::default();
4325 failures.fail_next(JournalFailurePoint::HoldAcknowledgement);
4326 let mut log = EventLog::with_journal_failure_injector_and_ack_capacity(
4327 path.clone(),
4328 failures.clone(),
4329 1,
4330 );
4331 log.bind_run("run-capacity", "client-capacity").unwrap();
4332 let first_data =
4333 HashMap::from([("completion_digest".to_string(), Value::from("1".repeat(64)))]);
4334 let second_data =
4335 HashMap::from([("completion_digest".to_string(), Value::from("2".repeat(64)))]);
4336
4337 let mut first = Box::pin(log.append_critical_async(
4338 EventKind::RunCompleted,
4339 None,
4340 None,
4341 first_data.clone(),
4342 MAX_CRITICAL_ACKNOWLEDGEMENT_TIMEOUT,
4343 ));
4344 let waker = test_waker();
4345 let mut context = std::task::Context::from_waker(&waker);
4346 assert!(matches!(
4347 first.as_mut().poll(&mut context),
4348 std::task::Poll::Pending
4349 ));
4350 drop(first);
4351 assert_eq!(log.events().len(), 1);
4352 assert_eq!(log.critical_pending.len(), 1);
4353
4354 for attempt in 0..100 {
4355 let error = block_on_test_future(log.append_critical_async(
4356 EventKind::ProposalCompleted,
4357 None,
4358 Some("capacity-rejected"),
4359 second_data.clone(),
4360 Duration::from_millis(100),
4361 ))
4362 .expect_err("exhausted acknowledgement capacity must reject");
4363 let CriticalAppendError::Rejected { reason } = error else {
4364 panic!("attempt {attempt} was not a pre-enqueue rejection");
4365 };
4366 assert!(
4367 reason.contains("acknowledgement capacity is exhausted"),
4368 "attempt {attempt} bypassed capacity admission: {reason}"
4369 );
4370 assert_eq!(log.events().len(), 1);
4371 assert_eq!(log.critical_pending.len(), 1);
4372 }
4373
4374 let hold_deadline = std::time::Instant::now() + Duration::from_secs(2);
4375 while failures.held_acknowledgement_count() == 0 {
4376 assert!(
4377 std::time::Instant::now() < hold_deadline,
4378 "writer never reached the held acknowledgement"
4379 );
4380 std::thread::yield_now();
4381 }
4382 let pending_line = serde_json::to_string(&log.events()[0]).unwrap();
4383 assert_eq!(
4384 fs::read_to_string(&path).unwrap(),
4385 format!("{pending_line}\n")
4386 );
4387 failures.release_held_acknowledgements();
4388
4389 let original_timestamp = log.events()[0].timestamp;
4390 let retried = block_on_test_future(log.append_critical_async(
4391 EventKind::RunCompleted,
4392 None,
4393 None,
4394 first_data,
4395 Duration::from_millis(500),
4396 ))
4397 .expect("the exact cancelled row must reconcile pending state");
4398 assert_eq!(retried.timestamp, original_timestamp);
4399 assert!(log.critical_pending.is_empty());
4400
4401 block_on_test_future(log.append_critical_async(
4402 EventKind::ProposalCompleted,
4403 None,
4404 Some("capacity-rejected"),
4405 second_data,
4406 Duration::from_millis(500),
4407 ))
4408 .expect("a distinct row is allowed after exact reconciliation");
4409 drop(log);
4410
4411 let loaded = EventLog::load(&path).unwrap();
4412 assert_eq!(loaded.events().len(), 2);
4413 assert_eq!(loaded.events()[0].kind, EventKind::RunCompleted);
4414 assert_eq!(loaded.events()[1].kind, EventKind::ProposalCompleted);
4415 }
4416
4417 #[test]
4418 fn async_critical_never_acknowledged_row_remains_exactly_retryable() {
4419 let dir = tempfile::tempdir().unwrap();
4420 let path = dir.path().join("critical-never-acknowledged.jsonl");
4421 let failures = JournalFailureInjector::default();
4422 failures.fail_next(JournalFailurePoint::HoldAcknowledgement);
4423 let mut log = EventLog::with_journal_failure_injector(path.clone(), failures.clone());
4424 log.bind_run("run-never-ack", "client-never-ack").unwrap();
4425 let data = HashMap::from([("completion_digest".to_string(), Value::from("9".repeat(64)))]);
4426
4427 let error = block_on_test_future(log.append_critical_async(
4428 EventKind::RunCompleted,
4429 None,
4430 None,
4431 data.clone(),
4432 Duration::from_millis(20),
4433 ))
4434 .expect_err("the first acknowledgement is retained forever");
4435 assert!(matches!(
4436 error,
4437 CriticalAppendError::DurabilityUnknown { .. }
4438 ));
4439 let original = serde_json::to_string(&log.events()[0]).unwrap();
4440 assert!(log.critical_pending.contains(&original));
4441
4442 let retried = block_on_test_future(log.append_critical_async(
4443 EventKind::RunCompleted,
4444 None,
4445 None,
4446 data,
4447 Duration::from_millis(500),
4448 ))
4449 .expect("the exact row must reconcile without the first acknowledgement");
4450 assert_eq!(serde_json::to_string(retried).unwrap(), original);
4451 assert!(log.critical_pending.is_empty());
4452 assert_eq!(
4453 failures.held_acknowledgement_count(),
4454 1,
4455 "the original acknowledgement must remain unsent"
4456 );
4457 drop(log);
4458
4459 assert_eq!(fs::read_to_string(&path).unwrap(), format!("{original}\n"));
4460 assert_eq!(EventLog::load(&path).unwrap().events().len(), 1);
4461 }
4462
4463 #[test]
4464 fn bounded_sync_critical_timeout_is_exactly_retryable() {
4465 let dir = tempfile::tempdir().unwrap();
4466 let path = dir.path().join("critical-bounded-sync-timeout.jsonl");
4467 let failures = JournalFailureInjector::default();
4468 failures.fail_next(JournalFailurePoint::HoldAcknowledgement);
4469 let mut log = EventLog::with_journal_failure_injector(path.clone(), failures.clone());
4470 log.bind_run("run-bounded-sync", "client-bounded-sync")
4471 .unwrap();
4472 let data = HashMap::from([("completion_digest".to_string(), Value::from("7".repeat(64)))]);
4473
4474 let error = log
4475 .append_critical_bounded(
4476 EventKind::RunCompleted,
4477 None,
4478 None,
4479 data.clone(),
4480 Duration::from_millis(20),
4481 )
4482 .expect_err("the retained acknowledgement must hit the exact sync bound");
4483 assert_eq!(
4484 error,
4485 CriticalAppendError::DurabilityUnknown {
4486 reason: "journal writer did not acknowledge within 20ms".to_string()
4487 }
4488 );
4489 assert!(error.is_retry_safe());
4490 let pending = log
4491 .critical_pending
4492 .iter()
4493 .next()
4494 .expect("the exact timed-out row remains pending")
4495 .clone();
4496 assert_eq!(pending, serde_json::to_string(&log.events()[0]).unwrap());
4497
4498 let retried = log
4499 .append_critical_bounded(
4500 EventKind::RunCompleted,
4501 None,
4502 None,
4503 data,
4504 Duration::from_millis(500),
4505 )
4506 .expect("an exact retry must reconcile without the held acknowledgement");
4507 assert_eq!(serde_json::to_string(retried).unwrap(), pending);
4508 assert!(log.critical_pending.is_empty());
4509 assert_eq!(failures.held_acknowledgement_count(), 1);
4510 drop(log);
4511 assert_eq!(fs::read_to_string(&path).unwrap(), format!("{pending}\n"));
4512 }
4513
4514 #[test]
4515 fn async_critical_append_bounds_unknown_ack_and_preserves_exact_retry() {
4516 let dir = tempfile::tempdir().unwrap();
4517 let path = dir.path().join("critical-unknown-ack.jsonl");
4518 let failures = JournalFailureInjector::default();
4519 failures.fail_next(JournalFailurePoint::HoldAcknowledgement);
4520 let mut log = EventLog::with_journal_failure_injector(path.clone(), failures.clone());
4521 log.bind_run("run-unknown-ack", "client-unknown-ack")
4522 .unwrap();
4523 let data = HashMap::from([("completion_digest".to_string(), Value::from("e".repeat(64)))]);
4524
4525 let started = std::time::Instant::now();
4526 let error = block_on_test_future(log.append_critical_async(
4527 EventKind::RunCompleted,
4528 None,
4529 None,
4530 data.clone(),
4531 std::time::Duration::from_millis(40),
4532 ))
4533 .expect_err("a retained acknowledgement must become durability-unknown");
4534 assert!(
4535 started.elapsed() < std::time::Duration::from_millis(500),
4536 "the async acknowledgement wait exceeded its bounded allowance"
4537 );
4538 assert!(matches!(
4539 error,
4540 CriticalAppendError::DurabilityUnknown { .. }
4541 ));
4542 assert!(error.is_retry_safe());
4543 assert!(error
4544 .to_string()
4545 .contains("did not acknowledge within 40ms"));
4546
4547 let pending = log
4548 .critical_pending
4549 .iter()
4550 .next()
4551 .expect("the exact unacknowledged row remains pending")
4552 .clone();
4553 assert_eq!(log.critical_pending.len(), 1);
4554 assert_eq!(pending, serde_json::to_string(&log.events()[0]).unwrap());
4555 let disk_row = format!("{pending}\n");
4556 let writer_deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
4557 while fs::read_to_string(&path).unwrap_or_default() != disk_row {
4558 assert!(
4559 std::time::Instant::now() < writer_deadline,
4560 "the writer never durably accepted the held-acknowledgement row"
4561 );
4562 std::thread::yield_now();
4563 }
4564 while failures.held_acknowledgement_count() == 0 {
4565 assert!(
4566 std::time::Instant::now() < writer_deadline,
4567 "the writer never retained the late acknowledgement"
4568 );
4569 std::thread::yield_now();
4570 }
4571 assert_eq!(failures.held_acknowledgement_count(), 1);
4572 failures.release_held_acknowledgements();
4573 let original_timestamp = log.events()[0].timestamp;
4574
4575 let distinct_error = block_on_test_future(log.append_critical_async(
4576 EventKind::ProposalCompleted,
4577 None,
4578 Some("different-terminal"),
4579 HashMap::new(),
4580 Duration::from_millis(100),
4581 ))
4582 .expect_err("a different critical row must not bypass exact retry");
4583 assert!(matches!(
4584 distinct_error,
4585 CriticalAppendError::Rejected { .. }
4586 ));
4587 assert_eq!(log.events().len(), 1);
4588
4589 let retried = block_on_test_future(log.append_critical_async(
4590 EventKind::RunCompleted,
4591 None,
4592 None,
4593 data,
4594 std::time::Duration::from_secs(1),
4595 ))
4596 .expect("an identical retry must finish the retained row");
4597 assert_eq!(retried.timestamp, original_timestamp);
4598 assert!(log.critical_pending.is_empty());
4599
4600 block_on_test_future(log.append_critical_async(
4601 EventKind::ProposalCompleted,
4602 None,
4603 Some("different-terminal"),
4604 HashMap::new(),
4605 Duration::from_millis(500),
4606 ))
4607 .expect("a different row is allowed after exact retry reconciliation");
4608 drop(log);
4609
4610 let loaded = EventLog::load(&path).unwrap();
4611 assert_eq!(loaded.events().len(), 2);
4612 assert_eq!(serde_json::to_string(&loaded.events()[0]).unwrap(), pending);
4613 }
4614
4615 #[test]
4616 fn critical_append_failures_retry_same_row_and_fsync_prior_async_events() {
4617 for point in [
4618 JournalFailurePoint::Write,
4619 JournalFailurePoint::Flush,
4620 JournalFailurePoint::Fsync,
4621 ] {
4622 let dir = tempfile::tempdir().unwrap();
4623 let path = dir.path().join(format!("critical-{point:?}.jsonl"));
4624 let failures = JournalFailureInjector::default();
4625 failures.fail_next(point);
4626 let mut log = EventLog::with_journal_failure_injector(path.clone(), failures);
4627 log.bind_run("run-critical", "client-critical").unwrap();
4628 log.append(
4629 EventKind::ProposalReceived,
4630 None,
4631 Some("proposal-critical"),
4632 HashMap::new(),
4633 );
4634 let data =
4635 HashMap::from([("completion_digest".to_string(), Value::from("a".repeat(64)))]);
4636 assert!(
4637 log.append_critical(EventKind::RunCompleted, None, None, data.clone())
4638 .is_err(),
4639 "{point:?} failure must not acknowledge"
4640 );
4641 log.append(
4642 EventKind::ActionSucceeded,
4643 Some("after-pending-terminal"),
4644 Some("proposal-critical"),
4645 HashMap::new(),
4646 );
4647 log.append_critical(EventKind::RunCompleted, None, None, data)
4648 .expect("retry finishes the exact critical row");
4649 drop(log);
4650
4651 let loaded = EventLog::load(&path).unwrap();
4652 let events = loaded.events();
4653 assert_eq!(events[0].kind, EventKind::ProposalReceived);
4654 assert_eq!(events[1].kind, EventKind::RunCompleted);
4655 assert_eq!(events[2].kind, EventKind::ActionSucceeded);
4656 assert_eq!(
4657 events
4658 .iter()
4659 .filter(|event| event.kind == EventKind::RunCompleted)
4660 .count(),
4661 1,
4662 "{point:?} retry must not duplicate a terminal"
4663 );
4664 }
4665 }
4666
4667 #[test]
4668 fn compacting_a_failed_critical_row_makes_its_retry_idempotent() {
4669 let dir = tempfile::tempdir().unwrap();
4670 let path = dir.path().join("critical-compact-retry.jsonl");
4671 let failures = JournalFailureInjector::default();
4672 failures.fail_next(JournalFailurePoint::Fsync);
4673 let mut log =
4674 EventLog::with_journal_failure_injector(path.clone(), failures).with_hash_chaining();
4675 log.bind_run("run-critical-compact", "client-critical-compact")
4676 .unwrap();
4677 log.append(
4678 EventKind::ProposalReceived,
4679 None,
4680 Some("proposal-critical-compact"),
4681 HashMap::new(),
4682 );
4683 let data = HashMap::from([("completion_digest".to_string(), Value::from("c".repeat(64)))]);
4684
4685 assert!(
4686 log.append_critical(EventKind::RunCompleted, None, None, data.clone())
4687 .is_err(),
4688 "the injected fsync failure must leave the exact terminal pending"
4689 );
4690 assert!(
4691 log.compact_journal(),
4692 "compaction persists the in-memory pending terminal"
4693 );
4694 log.append_critical(EventKind::RunCompleted, None, None, data)
4695 .expect("identical retry recognizes the compacted terminal as durable");
4696 drop(log);
4697
4698 let loaded = EventLog::load(&path).unwrap();
4699 assert_eq!(
4700 loaded
4701 .events()
4702 .iter()
4703 .filter(|event| event.kind == EventKind::RunCompleted)
4704 .count(),
4705 1,
4706 "writer respawn must not duplicate the compacted terminal"
4707 );
4708 assert_eq!(loaded.events()[0].kind, EventKind::ProposalReceived);
4709 assert_eq!(loaded.events()[1].kind, EventKind::RunCompleted);
4710 assert_eq!(loaded.verify_chain(), Ok(2));
4711 }
4712
4713 #[test]
4714 fn critical_append_cannot_ack_until_failed_prior_async_row_is_replayed() {
4715 let dir = tempfile::tempdir().unwrap();
4716 let path = dir.path().join("prior-async-failure.jsonl");
4717 let failures = JournalFailureInjector::default();
4718 failures.fail_next(JournalFailurePoint::AsyncWrite);
4721 failures.fail_next(JournalFailurePoint::AsyncWrite);
4722 let mut log = EventLog::with_journal_failure_injector(path.clone(), failures);
4723 log.bind_run("run-ordered", "client-ordered").unwrap();
4724 log.append(
4725 EventKind::ActionSucceeded,
4726 Some("action-ordered"),
4727 Some("proposal-ordered"),
4728 HashMap::new(),
4729 );
4730 let data = HashMap::from([("completion_digest".to_string(), Value::from("b".repeat(64)))]);
4731
4732 assert!(
4733 log.append_critical(
4734 EventKind::ProposalCompleted,
4735 None,
4736 Some("proposal-ordered"),
4737 data.clone()
4738 )
4739 .is_err(),
4740 "a terminal must not acknowledge while an earlier row is still missing"
4741 );
4742 log.append_critical(
4743 EventKind::ProposalCompleted,
4744 None,
4745 Some("proposal-ordered"),
4746 data,
4747 )
4748 .expect("retry repairs the prior row before acknowledging the terminal");
4749 drop(log);
4750
4751 let loaded = EventLog::load(&path).unwrap();
4752 assert_eq!(loaded.events().len(), 2);
4753 assert_eq!(loaded.events()[0].kind, EventKind::ActionSucceeded);
4754 assert_eq!(loaded.events()[1].kind, EventKind::ProposalCompleted);
4755 }
4756
4757 #[test]
4758 fn first_use_parent_sync_failure_blocks_terminal_until_prior_async_replays() {
4759 let dir = tempfile::tempdir().unwrap();
4760 let path = dir.path().join("nested").join("first-use.jsonl");
4761 let failures = car_secrets::PrivatePathDurabilityFailureInjector::default();
4762 failures.fail_next(car_secrets::PrivatePathDurabilityFailurePoint::ParentDirectorySync);
4763 failures.fail_next(car_secrets::PrivatePathDurabilityFailurePoint::ParentDirectorySync);
4764 let mut log = EventLog::with_private_path_failure_injector(path.clone(), failures);
4765 log.bind_run("run-first-use", "client-first-use").unwrap();
4766 log.append(
4767 EventKind::ActionSucceeded,
4768 Some("action-first-use"),
4769 Some("proposal-first-use"),
4770 HashMap::new(),
4771 );
4772 let data = HashMap::from([("completion_digest".to_string(), Value::from("d".repeat(64)))]);
4773
4774 assert!(log
4775 .append_critical(
4776 EventKind::ProposalCompleted,
4777 None,
4778 Some("proposal-first-use"),
4779 data.clone(),
4780 )
4781 .is_err());
4782 log.append_critical(
4783 EventKind::ProposalCompleted,
4784 None,
4785 Some("proposal-first-use"),
4786 data,
4787 )
4788 .expect("retry durably replays async row then exact terminal");
4789 drop(log);
4790
4791 let loaded = EventLog::load(&path).unwrap();
4792 assert_eq!(loaded.events().len(), 2);
4793 assert_eq!(loaded.events()[0].kind, EventKind::ActionSucceeded);
4794 assert_eq!(loaded.events()[1].kind, EventKind::ProposalCompleted);
4795 }
4796}