Phase 1 server: TLS + token auth, session registry, EchoDriver, SSE with cursors

The whole pipe behind one Driver trait and a common event model:
spawn/list/delete sessions, message + question answering, append-only
JSONL transcripts whose sequence numbers are the phone's resume cursor
(surviving backend restarts), bearer-token middleware wrapping every
route including the fallback, wg0-only binding that fails closed, and
first-run token enrollment via a terminal QR.

Verified: cargo test (10), clippy clean, and curl end-to-end over pinned
TLS -- auth rejection, spawn, streamed SSE replay/resume, /question
round trip, restart continuing seq numbers, delete removing everything.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
irisandClaude Fable 5 committed 2026-08-24 20:51:34 -04:00
1 parent a6ece28344
commit 967fc814ab
13 files changed
+3349

No files matched your search

+175
View File
@@ -0,0 +1,175 @@
//! 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::{BufRead, BufReader, Write};
use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use super::driver::Event;
/// 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,
}
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> {
let last_seq = last_seq(path)?;
let file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.with_context(|| format!("open transcript {}", path.display()))?;
Ok(Self { file, next_seq: last_seq + 1 })
}
/// 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)
}
}
/// 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 file = match File::open(path) {
Ok(file) => file,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(err) => return Err(err).with_context(|| format!("read transcript {}", path.display())),
};
let mut events = Vec::new();
for line in BufReader::new(file).lines() {
let line = line.context("read transcript line")?;
if line.trim().is_empty() {
continue;
}
let entry: SeqEvent = serde_json::from_str(&line)
.with_context(|| format!("bad transcript line in {}", path.display()))?;
if entry.seq > after {
events.push(entry);
}
}
Ok(events)
}
fn last_seq(path: &Path) -> Result<u64> {
Ok(read_after(path, 0)?.last().map(|entry| entry.seq).unwrap_or(0))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::driver::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_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 { text: "hi".into() },
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() },
Event::Question {
id: "q1".into(),
prompt: "Allow?".into(),
options: vec!["Yes".into(), "No".into()],
},
Event::Answered { id: "q1".into(), answer: "Yes".into() },
Event::Status { state: SessionStatus::Idle },
Event::UsageDelta { tokens: 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);
}
}