ai-app: a phone interface to Claude Code and llama.cpp sessions
A Rust backend that owns the sessions and an Android app that reads them. The server spawns and adopts CLI processes, normalises everything they emit into one event model, keeps the transcript, and serves it over pinned TLS on a WireGuard interface; the phone streams that, replies, sends images, and imports conversations the machine already has. `AGENTS.md` is the working guide -- what runs where, what has been measured, and the faults that were expensive to find. `PLAN.md` is the design record. History before this point was squashed away. It was a personal project's running commentary and carried a name and a couple of machine paths that have no business in a public repository; the tree is what mattered and the tree is here.
This commit is contained in:
commit
b172c464ea
100 files changed
+31795
No files matched your search
@@ -0,0 +1,550 @@
|
||||
//! Append-only JSONL event log, one per session, with monotonically
|
||||
//! increasing sequence numbers -- the phone's resume cursor.
|
||||
//!
|
||||
//! One line per event: `{"seq":N,"ts":...,"type":...,...}`. The writer
|
||||
//! assigns sequence numbers; readers replay everything after a cursor.
|
||||
//! Reopening an existing file continues the numbering, which is what makes
|
||||
//! a backend restart invisible to a phone holding a cursor.
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::ops::Range;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::driver::{Event, SessionStatus, context_after};
|
||||
|
||||
/// One transcript line: an [`Event`] plus its position and time. The event
|
||||
/// is flattened so the wire shape stays one flat object.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SeqEvent {
|
||||
pub seq: u64,
|
||||
/// Epoch seconds.
|
||||
pub ts: f64,
|
||||
#[serde(flatten)]
|
||||
pub event: Event,
|
||||
}
|
||||
|
||||
pub struct Transcript {
|
||||
file: File,
|
||||
next_seq: u64,
|
||||
last_status: Option<SessionStatus>,
|
||||
last_activity: Option<f64>,
|
||||
context_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
impl Transcript {
|
||||
/// Opens (or creates) the log at `path`, continuing the sequence from
|
||||
/// the last line if one exists.
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
// One pass for all three answers. They are wanted at the same moment
|
||||
// by the same caller, and reading the file again for each doubled
|
||||
// the cost of starting every session -- which is paid per session,
|
||||
// at the point a restart is trying to be quick.
|
||||
let existing = read_after(path, 0)?;
|
||||
let last_seq = existing.last().map(|entry| entry.seq).unwrap_or(0);
|
||||
let last_status = existing.iter().rev().find_map(|entry| match entry.event {
|
||||
Event::Status { state } => Some(state),
|
||||
_ => None,
|
||||
});
|
||||
// Owner-only: a transcript is the whole conversation, including
|
||||
// whatever the session read, wrote, or was told.
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
.with_context(|| format!("open transcript {}", path.display()))?;
|
||||
Ok(Self {
|
||||
file,
|
||||
next_seq: last_seq + 1,
|
||||
last_status,
|
||||
last_activity: existing.last().map(|entry| entry.ts),
|
||||
// Folded rather than read off the newest usage entry: a clear
|
||||
// or a compaction after it is what the answer is, and those
|
||||
// events carry no usage of their own.
|
||||
context_tokens: existing
|
||||
.iter()
|
||||
.fold(None, |current, entry| context_after(current, &entry.event)),
|
||||
})
|
||||
}
|
||||
|
||||
/// The state the session was last reported to be in, as of opening.
|
||||
///
|
||||
/// Read from the file rather than assumed, because a server that has
|
||||
/// just restarted has been told nothing yet and this is the only thing
|
||||
/// it knows. Assuming idle claimed a session was waiting for you when
|
||||
/// it had exited hours earlier, and would now also claim it of one
|
||||
/// whose process is still mid-turn.
|
||||
///
|
||||
/// `None` for a transcript that never carried a status, which is a new
|
||||
/// session and genuinely has no prior state.
|
||||
pub fn last_status(&self) -> Option<SessionStatus> {
|
||||
self.last_status
|
||||
}
|
||||
|
||||
/// When this session last did anything, as of opening.
|
||||
///
|
||||
/// Read from the file for the same reason [`Transcript::last_status`]
|
||||
/// is, and it is the same mistake in the other direction: a restarting
|
||||
/// server has been told nothing, and taking the clock instead said every
|
||||
/// session it relaunched had been active this second. On the phone that
|
||||
/// is every row reading "just now" and the list -- which is sorted by
|
||||
/// this -- coming back in an order that means nothing, with the
|
||||
/// conversation somebody was in the middle of buried among sessions
|
||||
/// untouched for days.
|
||||
///
|
||||
/// `None` for a transcript with no lines in it, which is a session that
|
||||
/// genuinely has not done anything yet. Its caller answers that with
|
||||
/// when the session was created -- not with the clock, which would say
|
||||
/// a session nobody has ever sent anything to was active a moment ago,
|
||||
/// every time this server started.
|
||||
pub fn last_activity(&self) -> Option<f64> {
|
||||
self.last_activity
|
||||
}
|
||||
|
||||
/// How much context the session was holding, as of opening.
|
||||
///
|
||||
/// `None` for a transcript nothing has been measured in -- a new
|
||||
/// session, one whose dialect never reported usage, or one whose last
|
||||
/// word on the subject was a clear. That is not zero, and it is why
|
||||
/// this is an option: a server that has just restarted has been told
|
||||
/// nothing, and answering zero would draw an empty context for a
|
||||
/// conversation that may be nearly full.
|
||||
pub fn context_tokens(&self) -> Option<u64> {
|
||||
self.context_tokens
|
||||
}
|
||||
|
||||
/// Appends `event`, assigning it the next sequence number. Flushed per
|
||||
/// event: each line is tiny, and the transcript is the source of truth
|
||||
/// a crash must not lose the tail of.
|
||||
pub fn append(&mut self, event: Event, ts: f64) -> Result<SeqEvent> {
|
||||
let entry = SeqEvent {
|
||||
seq: self.next_seq,
|
||||
ts,
|
||||
event,
|
||||
};
|
||||
let mut line = serde_json::to_string(&entry).context("serialize event")?;
|
||||
line.push('\n');
|
||||
self.file
|
||||
.write_all(line.as_bytes())
|
||||
.context("append to transcript")?;
|
||||
self.next_seq += 1;
|
||||
Ok(entry)
|
||||
}
|
||||
}
|
||||
|
||||
/// A window of the transcript ending just before `before`, newest-biased.
|
||||
///
|
||||
/// The screen opens on the end of a conversation, not the start of it, and
|
||||
/// the end is all it can show at once. Replaying the whole file to get
|
||||
/// there costs one network frame per event -- on an 863-event import that
|
||||
/// was several seconds of messages arriving oldest-first, which reads as
|
||||
/// the app loading top-down because that is exactly what it was doing.
|
||||
///
|
||||
/// `before` pages backwards for history somebody actually scrolls to. Only
|
||||
/// the window is parsed; see [`Indexed`] for why that is the whole cost of
|
||||
/// this call.
|
||||
pub fn read_window(path: &Path, before: Option<u64>, limit: usize) -> Result<Vec<SeqEvent>> {
|
||||
let Some(indexed) = Indexed::read(path)? else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let end = match before {
|
||||
Some(before) => indexed.first_at_or_after(before)?,
|
||||
None => indexed.lines.len(),
|
||||
};
|
||||
indexed.parse(end.saturating_sub(limit)..end)
|
||||
}
|
||||
|
||||
/// How far behind a reconnecting subscriber can be and still be handed the
|
||||
/// backlog one event at a time.
|
||||
///
|
||||
/// Past this it is served better by rebuilding its view from the newest
|
||||
/// window than by receiving everything it missed. The events are the same
|
||||
/// either way; what differs is that one arrives as a single window and the
|
||||
/// other as thousands of frames a screen renders one by one. Set well
|
||||
/// above a screenful (`transcript`'s page is 80) so an ordinary blip -- a
|
||||
/// phone asleep, a tunnel reconnecting, a backend restart -- still streams
|
||||
/// continuously, and only a genuine backlog changes mode.
|
||||
pub const CATCH_UP_LIMIT: usize = 200;
|
||||
|
||||
/// What a subscriber asking for "everything after my cursor" gets back.
|
||||
///
|
||||
/// Two answers rather than one list, because they mean different things to
|
||||
/// the screen holding the cursor: one continues what it already has, the
|
||||
/// other replaces it. Collapsing them into a list would leave the client
|
||||
/// splicing a window onto rows it has no way to know are no longer
|
||||
/// adjacent to it -- a seam that looks exactly like ordinary output.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum CatchUp {
|
||||
/// The events after the cursor, continuing what the subscriber holds.
|
||||
Continue(Vec<SeqEvent>),
|
||||
/// The subscriber was further behind than [`CATCH_UP_LIMIT`]: the
|
||||
/// newest window, replacing whatever it holds. Earlier history is
|
||||
/// still there to be paged backwards through, exactly as it is when a
|
||||
/// session is first opened.
|
||||
Restart(Vec<SeqEvent>),
|
||||
}
|
||||
|
||||
/// Everything after `after`, or the newest `limit` when that is more than
|
||||
/// `limit` events.
|
||||
///
|
||||
/// The window is chosen before anything is parsed, which matters most in
|
||||
/// the case that looks least interesting: a subscriber with no cursor at
|
||||
/// all asks for the whole conversation and is going to be handed the last
|
||||
/// [`CATCH_UP_LIMIT`] events of it. Parsing the discarded prefix first is
|
||||
/// the whole file's worth of work to produce a screenful.
|
||||
pub fn catch_up(path: &Path, after: u64, limit: usize) -> Result<CatchUp> {
|
||||
let Some(indexed) = Indexed::read(path)? else {
|
||||
return Ok(CatchUp::Continue(Vec::new()));
|
||||
};
|
||||
let end = indexed.lines.len();
|
||||
let start = indexed.first_at_or_after(after.saturating_add(1))?;
|
||||
if end - start > limit {
|
||||
return Ok(CatchUp::Restart(indexed.parse(end - limit..end)?));
|
||||
}
|
||||
Ok(CatchUp::Continue(indexed.parse(start..end)?))
|
||||
}
|
||||
|
||||
/// Replays every event with `seq > after`, oldest first. A missing file is
|
||||
/// an empty transcript, not an error -- the session just hasn't produced an
|
||||
/// event yet.
|
||||
pub fn read_after(path: &Path, after: u64) -> Result<Vec<SeqEvent>> {
|
||||
let Some(indexed) = Indexed::read(path)? else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let start = indexed.first_at_or_after(after.saturating_add(1))?;
|
||||
indexed.parse(start..indexed.lines.len())
|
||||
}
|
||||
|
||||
/// The transcript's lines located but not read, so that a reader can find
|
||||
/// the range it wants and parse only that.
|
||||
///
|
||||
/// Both readers above want a *range* of the file -- everything after a
|
||||
/// cursor, or the window before one -- and both used to reach it by parsing
|
||||
/// every line and discarding the ones outside it. That is the cost that
|
||||
/// grows with the conversation rather than with the answer: measured on a
|
||||
/// 21 MB, 24,000-event transcript, one page took **500 ms of server time to
|
||||
/// return 600 KB**, and it took the same 500 ms whichever page was asked
|
||||
/// for, since the work was the file rather than the window. A phone paging
|
||||
/// back through history pays it per page, and every stream reconnect pays
|
||||
/// it again to discover there is nothing new.
|
||||
///
|
||||
/// Sequence numbers only ever increase -- the writer assigns them, one per
|
||||
/// appended line, continuing from the last on reopen -- so the boundary of
|
||||
/// a range is a bisection. This parses one line per halving, and the caller
|
||||
/// parses only what it is going to return. The file is still read whole,
|
||||
/// which is a deliberate stop: finding the tail without reading forwards
|
||||
/// means a chunked backwards reader, and locating a line is not what the
|
||||
/// half-second was going to.
|
||||
struct Indexed<'a> {
|
||||
path: &'a Path,
|
||||
text: String,
|
||||
/// Byte range of each non-blank line, in the order they were written.
|
||||
lines: Vec<Range<usize>>,
|
||||
}
|
||||
|
||||
impl<'a> Indexed<'a> {
|
||||
/// `None` for a file that isn't there, which is a session that has not
|
||||
/// produced an event yet rather than a failure.
|
||||
fn read(path: &'a Path) -> Result<Option<Self>> {
|
||||
let text = match std::fs::read_to_string(path) {
|
||||
Ok(text) => text,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(err) => {
|
||||
return Err(err).with_context(|| format!("read transcript {}", path.display()));
|
||||
}
|
||||
};
|
||||
let mut lines = Vec::new();
|
||||
let mut start = 0;
|
||||
while start < text.len() {
|
||||
let end = text[start..]
|
||||
.find('\n')
|
||||
.map(|at| start + at)
|
||||
.unwrap_or(text.len());
|
||||
if !text[start..end].trim().is_empty() {
|
||||
lines.push(start..end);
|
||||
}
|
||||
start = end + 1;
|
||||
}
|
||||
Ok(Some(Self { path, text, lines }))
|
||||
}
|
||||
|
||||
/// The index of the first line numbered `seq` or higher, or the end
|
||||
/// when every line is older than that.
|
||||
///
|
||||
/// A bisection, which is only correct because the file is in sequence
|
||||
/// order; it is append-only and nothing else writes it. A line that
|
||||
/// cannot be read is reported here rather than silently treated as
|
||||
/// out of range, because the answer would be a window off by however
|
||||
/// much of the file the bad line hid.
|
||||
fn first_at_or_after(&self, seq: u64) -> Result<usize> {
|
||||
let (mut low, mut high) = (0, self.lines.len());
|
||||
while low < high {
|
||||
let middle = (low + high) / 2;
|
||||
if self.seq_at(middle)? < seq {
|
||||
low = middle + 1;
|
||||
} else {
|
||||
high = middle;
|
||||
}
|
||||
}
|
||||
Ok(low)
|
||||
}
|
||||
|
||||
/// One line's sequence number, without building the event on it.
|
||||
fn seq_at(&self, index: usize) -> Result<u64> {
|
||||
#[derive(Deserialize)]
|
||||
struct JustSeq {
|
||||
seq: u64,
|
||||
}
|
||||
let line = &self.text[self.lines[index].clone()];
|
||||
let entry: JustSeq = serde_json::from_str(line)
|
||||
.with_context(|| format!("bad transcript line in {}", self.path.display()))?;
|
||||
Ok(entry.seq)
|
||||
}
|
||||
|
||||
fn parse(&self, range: Range<usize>) -> Result<Vec<SeqEvent>> {
|
||||
self.lines[range]
|
||||
.iter()
|
||||
.map(|at| {
|
||||
serde_json::from_str(&self.text[at.clone()])
|
||||
.with_context(|| format!("bad transcript line in {}", self.path.display()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::session::driver::{QuestionOption, SessionStatus};
|
||||
|
||||
fn text(delta: &str) -> Event {
|
||||
Event::AssistantText {
|
||||
delta: delta.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assigns_increasing_seqs_and_replays_after_a_cursor() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("transcript.jsonl");
|
||||
|
||||
let mut transcript = Transcript::open(&path).expect("open");
|
||||
assert_eq!(transcript.append(text("a"), 1.0).expect("append").seq, 1);
|
||||
assert_eq!(transcript.append(text("b"), 2.0).expect("append").seq, 2);
|
||||
assert_eq!(transcript.append(text("c"), 3.0).expect("append").seq, 3);
|
||||
|
||||
let replay = read_after(&path, 1).expect("read");
|
||||
assert_eq!(replay.len(), 2);
|
||||
assert_eq!(replay[0].seq, 2);
|
||||
assert_eq!(replay[0].event, text("b"));
|
||||
assert_eq!(replay[1].seq, 3);
|
||||
|
||||
// A cursor at or past the end replays nothing.
|
||||
assert!(read_after(&path, 3).expect("read").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopening_continues_the_numbering() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("transcript.jsonl");
|
||||
|
||||
let mut transcript = Transcript::open(&path).expect("open");
|
||||
transcript.append(text("a"), 1.0).expect("append");
|
||||
transcript.append(text("b"), 2.0).expect("append");
|
||||
drop(transcript);
|
||||
|
||||
let mut reopened = Transcript::open(&path).expect("reopen");
|
||||
assert_eq!(reopened.append(text("c"), 3.0).expect("append").seq, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_short_backlog_continues_and_a_long_one_restarts() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("transcript.jsonl");
|
||||
let mut transcript = Transcript::open(&path).expect("open");
|
||||
for n in 0..10 {
|
||||
transcript
|
||||
.append(text(&n.to_string()), 0.0)
|
||||
.expect("append");
|
||||
}
|
||||
|
||||
// Within the limit the subscriber keeps what it has.
|
||||
let CatchUp::Continue(events) = catch_up(&path, 7, 5).expect("catch up") else {
|
||||
panic!("a backlog of 3 should continue");
|
||||
};
|
||||
assert_eq!(events.len(), 3);
|
||||
assert_eq!(events[0].seq, 8);
|
||||
|
||||
// Past it, the newest window replaces what it has -- and it is the
|
||||
// newest, not the oldest, that survives the trim.
|
||||
let CatchUp::Restart(events) = catch_up(&path, 0, 5).expect("catch up") else {
|
||||
panic!("a backlog of 10 should restart");
|
||||
};
|
||||
assert_eq!(events.len(), 5);
|
||||
assert_eq!(events[0].seq, 6);
|
||||
assert_eq!(events[4].seq, 10);
|
||||
|
||||
// Exactly at the limit is still a continuation: the boundary
|
||||
// belongs to the cheaper answer, so a client is not reset for
|
||||
// being one event behind the threshold.
|
||||
assert!(matches!(
|
||||
catch_up(&path, 5, 5).expect("catch up"),
|
||||
CatchUp::Continue(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopening_reports_the_state_it_was_last_left_in() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("transcript.jsonl");
|
||||
|
||||
// Nothing recorded yet: no prior state to report, which is not the
|
||||
// same as reporting idle.
|
||||
assert_eq!(Transcript::open(&path).expect("open").last_status(), None);
|
||||
|
||||
let mut transcript = Transcript::open(&path).expect("open");
|
||||
transcript
|
||||
.append(
|
||||
Event::Status {
|
||||
state: SessionStatus::Running,
|
||||
},
|
||||
1.0,
|
||||
)
|
||||
.expect("append");
|
||||
transcript
|
||||
.append(
|
||||
Event::Status {
|
||||
state: SessionStatus::Exited,
|
||||
},
|
||||
2.0,
|
||||
)
|
||||
.expect("append");
|
||||
// Events after the last status must not hide it.
|
||||
transcript.append(text("trailing"), 3.0).expect("append");
|
||||
drop(transcript);
|
||||
|
||||
let reopened = Transcript::open(&path).expect("reopen");
|
||||
assert_eq!(reopened.last_status(), Some(SessionStatus::Exited));
|
||||
// And the same pass still continues the numbering.
|
||||
assert_eq!(reopened.next_seq, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_is_the_events_before_a_cursor_and_nothing_else() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("transcript.jsonl");
|
||||
let mut transcript = Transcript::open(&path).expect("open");
|
||||
for n in 1..=10 {
|
||||
transcript
|
||||
.append(text(&n.to_string()), 0.0)
|
||||
.expect("append");
|
||||
}
|
||||
|
||||
// No cursor is the newest page, which is what opening a session asks for.
|
||||
let newest = read_window(&path, None, 3).expect("window");
|
||||
assert_eq!(
|
||||
newest.iter().map(|entry| entry.seq).collect::<Vec<_>>(),
|
||||
[8, 9, 10]
|
||||
);
|
||||
|
||||
// Then backwards from the oldest of those, exclusive: the page a phone
|
||||
// scrolling up asks for must not repeat the row it is scrolling from.
|
||||
let older = read_window(&path, Some(8), 3).expect("window");
|
||||
assert_eq!(
|
||||
older.iter().map(|entry| entry.seq).collect::<Vec<_>>(),
|
||||
[5, 6, 7]
|
||||
);
|
||||
|
||||
// Asking for more than there is gives what there is, rather than failing.
|
||||
assert_eq!(read_window(&path, None, 100).expect("window").len(), 10);
|
||||
|
||||
// Nothing before the first event, which is how the phone learns to stop
|
||||
// paging. An empty answer here is the end of the history, not a fault.
|
||||
assert!(read_window(&path, Some(1), 3).expect("window").is_empty());
|
||||
assert!(
|
||||
read_window(&dir.path().join("nope.jsonl"), None, 3)
|
||||
.expect("window")
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_file_reads_as_empty() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
assert!(
|
||||
read_after(&dir.path().join("nope.jsonl"), 0)
|
||||
.expect("read")
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_every_event_shape() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("transcript.jsonl");
|
||||
let events = vec![
|
||||
Event::UserMessage {
|
||||
id: None,
|
||||
text: "hi".into(),
|
||||
images: Vec::new(),
|
||||
},
|
||||
text("hello"),
|
||||
Event::ToolStart {
|
||||
id: "t1".into(),
|
||||
tool: "bash".into(),
|
||||
input: serde_json::json!({"command": "ls"}),
|
||||
},
|
||||
Event::ToolUpdate {
|
||||
id: "t1".into(),
|
||||
output: "partial".into(),
|
||||
},
|
||||
Event::ToolEnd {
|
||||
id: "t1".into(),
|
||||
output: "done".into(),
|
||||
},
|
||||
Event::Image {
|
||||
image: "img1".into(),
|
||||
about: None,
|
||||
},
|
||||
Event::Question {
|
||||
id: "q1".into(),
|
||||
prompt: "Allow?".into(),
|
||||
header: None,
|
||||
options: vec![QuestionOption::plain("Yes"), QuestionOption::plain("No")],
|
||||
multi_select: false,
|
||||
about: None,
|
||||
},
|
||||
Event::Answered {
|
||||
id: "q1".into(),
|
||||
answers: vec!["Yes".into()],
|
||||
},
|
||||
Event::Status {
|
||||
state: SessionStatus::Idle,
|
||||
},
|
||||
Event::UsageDelta {
|
||||
tokens: 42,
|
||||
context: Some(42),
|
||||
},
|
||||
Event::Error {
|
||||
message: "boom".into(),
|
||||
},
|
||||
];
|
||||
|
||||
let mut transcript = Transcript::open(&path).expect("open");
|
||||
for event in &events {
|
||||
transcript.append(event.clone(), 0.0).expect("append");
|
||||
}
|
||||
|
||||
let replayed: Vec<Event> = read_after(&path, 0)
|
||||
.expect("read")
|
||||
.into_iter()
|
||||
.map(|entry| entry.event)
|
||||
.collect();
|
||||
assert_eq!(replayed, events);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user