use crate::util::random_string;
use eyre::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
pub(crate) const SESSIONS_DIR: &str = "sessions/v1";
const EVENT_VERSION: u8 = 1;
const MAX_EVENT_FILE_BYTES: u64 = 16 * 1024 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum SessionEvent {
SessionStarted {
v: u8,
ts_ms: u64,
session: String,
pid: u32,
mbx_version: String,
workspace_root: PathBuf,
command: Vec<String>,
},
Action {
v: u8,
ts_ms: u64,
outcome: ActionOutcome,
#[serde(default, skip_serializing_if = "Option::is_none")]
crate_name: Option<String>,
duration_ns: u64,
#[serde(default, skip_serializing_if = "ActionDetail::is_empty")]
detail: ActionDetail,
},
Truncated { v: u8, ts_ms: u64 },
SessionFinished {
v: u8,
ts_ms: u64,
stats: serde_json::Value,
},
}
impl SessionEvent {
#[cfg(test)]
pub(crate) fn outcome_label(&self) -> Option<&str> {
match self {
Self::Action { outcome, .. } => Some(outcome.label()),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub(crate) enum ActionOutcome {
Hit,
Miss,
Unconsulted,
Verification {
matched: bool,
},
Bypass {
reason: String,
},
}
impl ActionOutcome {
pub(crate) fn label(&self) -> &str {
match self {
Self::Hit => "hit",
Self::Miss => "miss",
Self::Unconsulted => "unconsulted",
Self::Verification { .. } => "verification",
Self::Bypass { reason } => reason,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub(crate) struct ActionDetail {
pub avoided_compiler_ns: u64,
pub output_files: u64,
pub output_bytes: u64,
pub reflinked_output_bytes: u64,
pub copied_output_bytes: u64,
}
impl ActionDetail {
fn is_empty(&self) -> bool {
*self == Self::default()
}
}
pub(crate) struct SessionPaths {
pub events: PathBuf,
pub lock: PathBuf,
}
pub(crate) fn session_paths(store: &Path, id: &str) -> SessionPaths {
let dir = store.join(SESSIONS_DIR);
SessionPaths {
events: dir.join(format!("{id}.jsonl")),
lock: dir.join(format!("{id}.lock")),
}
}
fn new_session_id() -> String {
format!(
"{}-{}-{}",
now_ms(),
std::process::id(),
random_string(4).to_lowercase()
)
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|since| u64::try_from(since.as_millis()).unwrap_or(u64::MAX))
.unwrap_or(0)
}
struct Open {
file: File,
written: u64,
truncated: bool,
_lock: fslock::LockFile,
}
pub(crate) struct EventWriter {
id: String,
paths: SessionPaths,
cap: u64,
state: Mutex<Option<Open>>,
disabled: Mutex<bool>,
}
impl EventWriter {
pub(crate) fn new(store: &Path) -> Self {
Self::with_cap(store, MAX_EVENT_FILE_BYTES)
}
#[cfg(test)]
pub(crate) fn with_cap_for_test(store: &Path, cap: u64) -> Self {
Self::with_cap(store, cap)
}
fn with_cap(store: &Path, cap: u64) -> Self {
let id = new_session_id();
Self {
paths: session_paths(store, &id),
id,
cap,
state: Mutex::new(None),
disabled: Mutex::new(false),
}
}
#[cfg(test)]
pub(crate) fn id(&self) -> &str {
&self.id
}
pub(crate) fn write(&self, event: &SessionEvent) {
if let Err(error) = self.try_write(event) {
let mut disabled = self.disabled.lock().unwrap();
if !*disabled {
*disabled = true;
log::warn!(
"session events are not being recorded to {}: {error:#}",
self.paths.events.display()
);
}
*self.state.lock().unwrap() = None;
}
}
fn try_write(&self, event: &SessionEvent) -> Result<()> {
if *self.disabled.lock().unwrap() {
return Ok(());
}
let mut state = self.state.lock().unwrap();
let open = match state.as_mut() {
Some(open) => open,
None => state.insert(self.open()?),
};
let terminal = matches!(event, SessionEvent::SessionFinished { .. });
if open.written >= self.cap && !terminal {
if open.truncated {
return Ok(());
}
open.truncated = true;
return append(
open,
&SessionEvent::Truncated {
v: EVENT_VERSION,
ts_ms: now_ms(),
},
);
}
append(open, event)
}
fn open(&self) -> Result<Open> {
let dir = self
.paths
.events
.parent()
.expect("a session event path has a parent");
std::fs::create_dir_all(dir)
.wrap_err_with(|| format!("failed to create {}", dir.display()))?;
let mut lock = fslock::LockFile::open(&self.paths.lock)?;
if !lock.try_lock()? {
eyre::bail!("another process holds {}", self.paths.lock.display());
}
let file = File::options()
.create(true)
.append(true)
.open(&self.paths.events)
.wrap_err_with(|| format!("failed to open {}", self.paths.events.display()))?;
Ok(Open {
file,
written: 0,
truncated: false,
_lock: lock,
})
}
pub(crate) fn started(&self, workspace_root: &Path, command: &[String]) {
self.write(&SessionEvent::SessionStarted {
v: EVENT_VERSION,
ts_ms: now_ms(),
session: self.id.clone(),
pid: std::process::id(),
mbx_version: env!("CARGO_PKG_VERSION").to_string(),
workspace_root: workspace_root.to_path_buf(),
command: command.to_vec(),
});
}
pub(crate) fn action(
&self,
outcome: ActionOutcome,
crate_name: Option<String>,
duration_ns: u64,
detail: ActionDetail,
) {
self.write(&SessionEvent::Action {
v: EVENT_VERSION,
ts_ms: now_ms(),
outcome,
crate_name,
duration_ns,
detail,
});
}
pub(crate) fn finished(&self, stats: serde_json::Value) {
self.write(&SessionEvent::SessionFinished {
v: EVENT_VERSION,
ts_ms: now_ms(),
stats,
});
}
}
fn append(open: &mut Open, event: &SessionEvent) -> Result<()> {
let mut line = serde_json::to_vec(event)?;
line.push(b'\n');
open.file.write_all(&line)?;
open.written = open.written.saturating_add(line.len() as u64);
Ok(())
}
pub(crate) fn parse_events(contents: &str) -> Vec<SessionEvent> {
contents
.lines()
.filter_map(|line| serde_json::from_str(line).ok())
.collect()
}
pub(crate) fn stat(stats: &serde_json::Value, key: &str) -> u64 {
stats
.get(key)
.and_then(serde_json::Value::as_u64)
.unwrap_or(0)
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) enum SessionState {
#[default]
Live,
Finished,
Abandoned,
}
impl SessionState {
pub(crate) fn label(self) -> &'static str {
match self {
Self::Live => "live",
Self::Finished => "finished",
Self::Abandoned => "abandoned",
}
}
}
#[derive(Debug)]
pub(crate) struct SessionTail {
id: String,
paths_events: PathBuf,
paths_lock: PathBuf,
offset: u64,
partial: String,
finished: bool,
}
impl SessionTail {
fn new(store: &Path, id: String) -> Self {
let paths = session_paths(store, &id);
Self {
id,
paths_events: paths.events,
paths_lock: paths.lock,
offset: 0,
partial: String::new(),
finished: false,
}
}
pub(crate) fn id(&self) -> &str {
&self.id
}
pub(crate) fn state(&self) -> SessionState {
if self.finished {
SessionState::Finished
} else if locked(&self.paths_lock) {
SessionState::Live
} else {
SessionState::Abandoned
}
}
pub(crate) fn read(&mut self) -> Vec<SessionEvent> {
use std::io::{Read, Seek, SeekFrom};
let Ok(mut file) = File::open(&self.paths_events) else {
return Vec::new();
};
if file.seek(SeekFrom::Start(self.offset)).is_err() {
return Vec::new();
}
let mut appended = Vec::new();
if file.read_to_end(&mut appended).is_err() {
return Vec::new();
}
self.offset = self.offset.saturating_add(appended.len() as u64);
let Ok(appended) = String::from_utf8(appended) else {
return Vec::new();
};
self.partial.push_str(&appended);
let complete = match self.partial.rfind('\n') {
Some(end) => self.partial.drain(..=end).collect::<String>(),
None => return Vec::new(),
};
let events = parse_events(&complete);
if events
.iter()
.any(|event| matches!(event, SessionEvent::SessionFinished { .. }))
{
self.finished = true;
}
events
}
}
fn locked(lock: &Path) -> bool {
match fslock::LockFile::open(lock) {
Ok(mut lock) => match lock.try_lock() {
Ok(true) => {
let _ = lock.unlock();
false
}
Ok(false) => true,
Err(_) => false,
},
Err(_) => false,
}
}
pub(crate) fn session_ids(store: &Path) -> Vec<String> {
let Ok(entries) = std::fs::read_dir(store.join(SESSIONS_DIR)) else {
return Vec::new();
};
let mut ids: Vec<String> = entries
.flatten()
.filter_map(|entry| {
let name = entry.file_name().to_string_lossy().into_owned();
name.strip_suffix(".jsonl").map(str::to_string)
})
.collect();
ids.sort();
ids
}
pub(crate) fn open_tails(store: &Path, limit: usize) -> Vec<SessionTail> {
let mut ids = session_ids(store);
if ids.len() > limit {
ids.drain(..ids.len() - limit);
}
ids.reverse();
ids.into_iter()
.map(|id| SessionTail::new(store, id))
.collect()
}
#[cfg(test)]
#[path = "events_tests.rs"]
mod tests;