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:
1 parent
a6ece28344
commit
967fc814ab
13 files changed
+3349
No files matched your search
@@ -0,0 +1,93 @@
|
||||
//! The common event model and the `Driver` trait -- the one abstraction
|
||||
//! everything hangs off (see PLAN.md).
|
||||
//!
|
||||
//! A driver translates its child process's JSONL dialect into [`Event`]s
|
||||
//! and accepts the small inbound vocabulary below. The transcript, the SSE
|
||||
//! stream, and the phone UI work purely in this model; nothing downstream
|
||||
//! of a driver may branch on the session kind.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// Attachment id of an uploaded image, as returned by `POST /attachments`
|
||||
/// (arrives in phase 2; the vocabulary is fixed now so the trait doesn't
|
||||
/// change under the first two drivers).
|
||||
pub type ImageRef = String;
|
||||
|
||||
/// Everything a session can tell the outside world. Every event is
|
||||
/// appended to the session's transcript with a sequence number, then fanned
|
||||
/// out to SSE subscribers; the phone renders purely from this stream, so
|
||||
/// reconnecting is just "events after seq N" -- no separate history path
|
||||
/// to drift from the live one.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum Event {
|
||||
/// What the user sent, echoed into the transcript by the manager (not
|
||||
/// by drivers) so every device renders the full conversation from the
|
||||
/// one stream.
|
||||
UserMessage { text: String },
|
||||
/// Streaming assistant text; the phone renders the concatenation as
|
||||
/// markdown.
|
||||
AssistantText { delta: String },
|
||||
ToolStart {
|
||||
id: String,
|
||||
tool: String,
|
||||
input: serde_json::Value,
|
||||
},
|
||||
ToolUpdate { id: String, output: String },
|
||||
ToolEnd { id: String, output: String },
|
||||
/// An image the session produced, saved under the session dir and
|
||||
/// referenced by id; the phone fetches it by URL (phase 2).
|
||||
Image {
|
||||
#[serde(rename = "ref")]
|
||||
image: ImageRef,
|
||||
},
|
||||
/// Anything the session needs a human for: AskUserQuestion, and
|
||||
/// permission requests, are the same shape with different options.
|
||||
Question {
|
||||
id: String,
|
||||
prompt: String,
|
||||
options: Vec<String>,
|
||||
},
|
||||
/// The manager's record of a question being answered, so a rendered
|
||||
/// question card resolves on every device, not just the one that
|
||||
/// answered it.
|
||||
Answered { id: String, answer: String },
|
||||
Status { state: SessionStatus },
|
||||
/// Per-turn token counts, where the dialect reports them.
|
||||
UsageDelta { tokens: u64 },
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SessionStatus {
|
||||
Idle,
|
||||
Running,
|
||||
AwaitingInput,
|
||||
Compacting,
|
||||
Exited,
|
||||
}
|
||||
|
||||
/// Where a driver reports events. Unbounded because producers are child
|
||||
/// processes a slow phone must never be able to stall; the transcript file
|
||||
/// is the backpressure-free buffer of record.
|
||||
pub type EventSink = mpsc::UnboundedSender<Event>;
|
||||
|
||||
/// The inbound half of a session. Deliberately small; see PLAN.md for the
|
||||
/// per-driver mapping of each method onto its dialect.
|
||||
///
|
||||
/// `send_user_message` during a run is the point of the whole app: both
|
||||
/// real dialects queue it for injection at the next tool boundary rather
|
||||
/// than the end of the turn.
|
||||
pub trait Driver: Send + Sync {
|
||||
fn send_user_message(&self, text: String, images: Vec<ImageRef>);
|
||||
fn answer_question(&self, id: &str, answer: &str);
|
||||
/// Stop mid-run; the session survives.
|
||||
fn interrupt(&self);
|
||||
fn set_model(&self, model: &str);
|
||||
/// pi: native compaction; claude: `/compact`.
|
||||
fn compact(&self);
|
||||
/// Graceful process exit.
|
||||
fn shutdown(&self);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
//! The phase-1 fake driver: no child process, just events. It exists to
|
||||
//! prove the whole pipe -- spawn, transcript, SSE cursors, questions,
|
||||
//! interrupts -- before any AI is involved, and stays useful afterwards as
|
||||
//! a connectivity check that costs no tokens.
|
||||
//!
|
||||
//! Behavior: every message is echoed back as a few streamed text deltas. A
|
||||
//! message starting with `/tool` also emits a fake tool run, and one
|
||||
//! starting with `/question` asks one (exercising the answer path). This is
|
||||
//! exactly the event vocabulary the real drivers produce, so a UI that
|
||||
//! renders echo sessions correctly renders the real thing.
|
||||
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::driver::{Driver, Event, EventSink, ImageRef, SessionStatus};
|
||||
|
||||
/// Delay between streamed deltas -- long enough that streaming is visibly
|
||||
/// streaming in the UI, short enough that tests waiting on a full turn
|
||||
/// stay fast.
|
||||
const DELTA_DELAY: Duration = Duration::from_millis(50);
|
||||
|
||||
pub struct EchoDriver {
|
||||
sink: EventSink,
|
||||
/// Id of the question currently awaiting an answer, if any. One at a
|
||||
/// time is all the echo behavior ever produces.
|
||||
pending_question: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
impl EchoDriver {
|
||||
pub fn new(sink: EventSink) -> Self {
|
||||
let driver = Self { sink, pending_question: Mutex::new(None) };
|
||||
driver.emit(Event::Status { state: SessionStatus::Idle });
|
||||
driver
|
||||
}
|
||||
|
||||
/// Sends are infallible from the driver's point of view: a closed sink
|
||||
/// means the session is being torn down, and there is nobody left to
|
||||
/// report to.
|
||||
fn emit(&self, event: Event) {
|
||||
let _ = self.sink.send(event);
|
||||
}
|
||||
}
|
||||
|
||||
impl Driver for EchoDriver {
|
||||
fn send_user_message(&self, text: String, _images: Vec<ImageRef>) {
|
||||
let sink = self.sink.clone();
|
||||
|
||||
if let Some(rest) = text.strip_prefix("/question") {
|
||||
let id = format!("q-{}", rand_id());
|
||||
let prompt = if rest.trim().is_empty() {
|
||||
"Echo asks: proceed?".to_string()
|
||||
} else {
|
||||
format!("Echo asks: {}", rest.trim())
|
||||
};
|
||||
*self.pending_question.lock().unwrap() = Some(id.clone());
|
||||
self.emit(Event::Status { state: SessionStatus::Running });
|
||||
self.emit(Event::Question {
|
||||
id,
|
||||
prompt,
|
||||
options: vec!["Yes".to_string(), "No".to_string()],
|
||||
});
|
||||
self.emit(Event::Status { state: SessionStatus::AwaitingInput });
|
||||
return;
|
||||
}
|
||||
|
||||
let run_tool = text.strip_prefix("/tool").map(|rest| rest.trim().to_string());
|
||||
tokio::spawn(async move {
|
||||
let send = |event: Event| {
|
||||
let _ = sink.send(event);
|
||||
};
|
||||
send(Event::Status { state: SessionStatus::Running });
|
||||
|
||||
if let Some(input) = run_tool {
|
||||
let id = format!("t-{}", rand_id());
|
||||
send(Event::ToolStart {
|
||||
id: id.clone(),
|
||||
tool: "echo-tool".to_string(),
|
||||
input: serde_json::json!({ "input": input }),
|
||||
});
|
||||
tokio::time::sleep(DELTA_DELAY).await;
|
||||
send(Event::ToolUpdate { id: id.clone(), output: "working...".to_string() });
|
||||
tokio::time::sleep(DELTA_DELAY).await;
|
||||
send(Event::ToolEnd { id, output: format!("echoed: {input}") });
|
||||
}
|
||||
|
||||
// Word-at-a-time so streaming is visibly streaming.
|
||||
for word in format!("You said: {text}").split_inclusive(' ') {
|
||||
send(Event::AssistantText { delta: word.to_string() });
|
||||
tokio::time::sleep(DELTA_DELAY).await;
|
||||
}
|
||||
send(Event::UsageDelta { tokens: text.split_whitespace().count() as u64 });
|
||||
send(Event::Status { state: SessionStatus::Idle });
|
||||
});
|
||||
}
|
||||
|
||||
fn answer_question(&self, id: &str, answer: &str) {
|
||||
let mut pending = self.pending_question.lock().unwrap();
|
||||
match pending.as_deref() {
|
||||
Some(expected) if expected == id => {
|
||||
*pending = None;
|
||||
self.emit(Event::AssistantText {
|
||||
delta: format!("You answered: {answer}"),
|
||||
});
|
||||
self.emit(Event::Status { state: SessionStatus::Idle });
|
||||
}
|
||||
_ => self.emit(Event::Error {
|
||||
message: format!("no question {id} is awaiting an answer"),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn interrupt(&self) {
|
||||
// Nothing real to stop; a pending question is abandoned so the
|
||||
// session isn't stuck awaiting input forever.
|
||||
*self.pending_question.lock().unwrap() = None;
|
||||
self.emit(Event::Status { state: SessionStatus::Idle });
|
||||
}
|
||||
|
||||
fn set_model(&self, model: &str) {
|
||||
self.emit(Event::Error {
|
||||
message: format!("echo sessions have no model to change to {model}"),
|
||||
});
|
||||
}
|
||||
|
||||
fn compact(&self) {
|
||||
self.emit(Event::Error {
|
||||
message: "echo sessions have nothing to compact".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
fn shutdown(&self) {
|
||||
self.emit(Event::Status { state: SessionStatus::Exited });
|
||||
}
|
||||
}
|
||||
|
||||
/// Short random suffix for tool/question ids -- unique within a session is
|
||||
/// all that's needed.
|
||||
fn rand_id() -> String {
|
||||
use rand::Rng;
|
||||
let mut bytes = [0u8; 4];
|
||||
rand::rng().fill_bytes(&mut bytes);
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
//! The live session registry. Every session mutation -- spawn, delete,
|
||||
//! token changes -- funnels through [`SessionManager`] under one lock, so
|
||||
//! in-memory state and `config.json` can't come apart (the same pattern as
|
||||
//! local-updater's `registry.rs`).
|
||||
//!
|
||||
//! A live session is a driver plus one event pump: the driver reports
|
||||
//! [`Event`]s into an mpsc channel; the pump assigns each a sequence
|
||||
//! number, appends it to the session's transcript file, and fans it out to
|
||||
//! SSE subscribers. The transcript is the source of truth -- subscribers
|
||||
//! that fall behind or reconnect catch up from the file by cursor.
|
||||
|
||||
pub mod driver;
|
||||
pub mod echo;
|
||||
pub mod transcript;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use serde::Serialize;
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
|
||||
use crate::config::{Config, SessionConfig, SessionKind, TokenEntry};
|
||||
use driver::{Driver, Event, ImageRef, SessionStatus};
|
||||
use echo::EchoDriver;
|
||||
use transcript::{SeqEvent, Transcript};
|
||||
|
||||
/// Fan-out buffer per session. A subscriber that falls further behind than
|
||||
/// this is caught up from the transcript file instead (see `routes`), so
|
||||
/// the size only bounds memory, not correctness.
|
||||
const EVENT_BUFFER: usize = 256;
|
||||
|
||||
pub fn now() -> f64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs_f64()
|
||||
}
|
||||
|
||||
/// What the phone needs to spawn a session -- the spawn screen's fields.
|
||||
pub struct SpawnSpec {
|
||||
pub kind: SessionKind,
|
||||
pub title: Option<String>,
|
||||
pub host: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub cwd: Option<PathBuf>,
|
||||
pub permission_mode: Option<String>,
|
||||
}
|
||||
|
||||
/// One row of `GET /sessions`.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionInfo {
|
||||
pub id: String,
|
||||
pub kind: SessionKind,
|
||||
pub title: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub host: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cwd: Option<PathBuf>,
|
||||
pub status: SessionStatus,
|
||||
pub last_activity: f64,
|
||||
pub created: f64,
|
||||
}
|
||||
|
||||
/// A running session: its driver plus the shared state the event pump
|
||||
/// keeps current. Cheap to clone-by-`Arc` into request handlers.
|
||||
pub struct LiveSession {
|
||||
meta: SessionConfig,
|
||||
driver: Box<dyn Driver>,
|
||||
/// The same channel the driver reports into; the manager injects
|
||||
/// `UserMessage`/`Answered` here so they take a sequence number in
|
||||
/// order with everything else.
|
||||
sink: mpsc::UnboundedSender<Event>,
|
||||
events: broadcast::Sender<SeqEvent>,
|
||||
transcript_path: PathBuf,
|
||||
shared: Arc<Shared>,
|
||||
}
|
||||
|
||||
/// The pump-maintained view of a session, read by the list endpoint.
|
||||
struct Shared {
|
||||
status: Mutex<SessionStatus>,
|
||||
last_activity: Mutex<f64>,
|
||||
}
|
||||
|
||||
impl LiveSession {
|
||||
/// Records the user's message in the transcript, then hands it to the
|
||||
/// driver -- which queues it for injection mid-run rather than at the
|
||||
/// end of the turn (the point of the whole app).
|
||||
pub fn send_message(&self, text: String, images: Vec<ImageRef>) {
|
||||
let _ = self.sink.send(Event::UserMessage { text: text.clone() });
|
||||
self.driver.send_user_message(text, images);
|
||||
}
|
||||
|
||||
pub fn answer_question(&self, question_id: &str, answer: &str) {
|
||||
let _ = self.sink.send(Event::Answered {
|
||||
id: question_id.to_string(),
|
||||
answer: answer.to_string(),
|
||||
});
|
||||
self.driver.answer_question(question_id, answer);
|
||||
}
|
||||
|
||||
pub fn interrupt(&self) {
|
||||
self.driver.interrupt();
|
||||
}
|
||||
|
||||
/// Hands the change to the driver. The persisted `model` field follows
|
||||
/// when a driver that actually honors this lands (phase 2) -- echo
|
||||
/// sessions just report the request as an error event.
|
||||
pub fn set_model(&self, model: &str) {
|
||||
self.driver.set_model(model);
|
||||
}
|
||||
|
||||
pub fn compact(&self) {
|
||||
self.driver.compact();
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<SeqEvent> {
|
||||
self.events.subscribe()
|
||||
}
|
||||
|
||||
pub fn transcript_path(&self) -> &Path {
|
||||
&self.transcript_path
|
||||
}
|
||||
|
||||
fn info(&self) -> SessionInfo {
|
||||
SessionInfo {
|
||||
id: self.meta.id.clone(),
|
||||
kind: self.meta.kind,
|
||||
title: self.meta.title.clone(),
|
||||
host: self.meta.host.clone(),
|
||||
model: self.meta.model.clone(),
|
||||
cwd: self.meta.cwd.clone(),
|
||||
status: *self.shared.status.lock().unwrap(),
|
||||
last_activity: *self.shared.last_activity.lock().unwrap(),
|
||||
created: self.meta.created,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
config: Config,
|
||||
live: HashMap<String, Arc<LiveSession>>,
|
||||
}
|
||||
|
||||
pub struct SessionManager {
|
||||
config_path: PathBuf,
|
||||
/// Per-session directories (transcript, attachments, produced images)
|
||||
/// live under here, each named by session id.
|
||||
data_dir: PathBuf,
|
||||
inner: RwLock<Inner>,
|
||||
}
|
||||
|
||||
impl SessionManager {
|
||||
/// Loads the config and relaunches a driver for every persisted
|
||||
/// session -- for the real drivers that is the `--resume`/session-file
|
||||
/// crash-recovery story; the echo driver just starts fresh over the
|
||||
/// same transcript. Must be called inside a tokio runtime (each
|
||||
/// session spawns its event pump).
|
||||
pub fn new(config_path: PathBuf, data_dir: PathBuf) -> Result<Self> {
|
||||
let config = Config::load(&config_path)?;
|
||||
std::fs::create_dir_all(&data_dir)
|
||||
.with_context(|| format!("create {}", data_dir.display()))?;
|
||||
|
||||
let mut live = HashMap::new();
|
||||
for meta in &config.sessions {
|
||||
// One unlaunchable session (e.g. a corrupt transcript) shows as
|
||||
// exited rather than taking the whole server down with it; it
|
||||
// can still be deleted from the phone.
|
||||
match launch(meta.clone(), &data_dir) {
|
||||
Ok(session) => {
|
||||
live.insert(meta.id.clone(), session);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!("couldn't relaunch session {}: {err:#}", meta.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
config_path,
|
||||
data_dir,
|
||||
inner: RwLock::new(Inner { config, live }),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tokens(&self) -> Vec<TokenEntry> {
|
||||
self.inner.read().unwrap().config.tokens.clone()
|
||||
}
|
||||
|
||||
/// Replaces the enrolled token list. With one device this is rotation:
|
||||
/// the old hash is invalidated the moment the new config is saved.
|
||||
pub fn set_tokens(&self, tokens: Vec<TokenEntry>) -> Result<()> {
|
||||
let mut inner = self.inner.write().unwrap();
|
||||
let mut candidate = inner.config.clone();
|
||||
candidate.tokens = tokens;
|
||||
candidate.save(&self.config_path)?;
|
||||
inner.config = candidate;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every session, in config order, with live status joined in. A
|
||||
/// session that failed to relaunch reports as exited.
|
||||
pub fn sessions(&self) -> Vec<SessionInfo> {
|
||||
let inner = self.inner.read().unwrap();
|
||||
inner
|
||||
.config
|
||||
.sessions
|
||||
.iter()
|
||||
.map(|meta| match inner.live.get(&meta.id) {
|
||||
Some(session) => session.info(),
|
||||
None => SessionInfo {
|
||||
id: meta.id.clone(),
|
||||
kind: meta.kind,
|
||||
title: meta.title.clone(),
|
||||
host: meta.host.clone(),
|
||||
model: meta.model.clone(),
|
||||
cwd: meta.cwd.clone(),
|
||||
status: SessionStatus::Exited,
|
||||
last_activity: meta.created,
|
||||
created: meta.created,
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn session(&self, id: &str) -> Option<Arc<LiveSession>> {
|
||||
self.inner.read().unwrap().live.get(id).cloned()
|
||||
}
|
||||
|
||||
pub fn spawn_session(&self, spec: SpawnSpec) -> Result<SessionInfo> {
|
||||
let mut inner = self.inner.write().unwrap();
|
||||
let id = unique_id(&inner.config);
|
||||
let title = spec
|
||||
.title
|
||||
.filter(|title| !title.trim().is_empty())
|
||||
.unwrap_or_else(|| default_title(spec.kind));
|
||||
let meta = SessionConfig {
|
||||
id: id.clone(),
|
||||
kind: spec.kind,
|
||||
title,
|
||||
host: spec.host,
|
||||
model: spec.model,
|
||||
cwd: spec.cwd,
|
||||
permission_mode: spec.permission_mode,
|
||||
created: now(),
|
||||
};
|
||||
|
||||
let session = launch(meta.clone(), &self.data_dir)?;
|
||||
let mut candidate = inner.config.clone();
|
||||
candidate.sessions.push(meta);
|
||||
if let Err(err) = candidate.save(&self.config_path) {
|
||||
// The path out of everything the launch created, taken in the
|
||||
// same change: drop the session and its directory so a failed
|
||||
// save leaves no orphan.
|
||||
drop(session);
|
||||
let _ = std::fs::remove_dir_all(self.data_dir.join(&id));
|
||||
return Err(err);
|
||||
}
|
||||
inner.config = candidate;
|
||||
let info = session.info();
|
||||
inner.live.insert(id, session);
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// Kills the process, releases everything the spawn created, and
|
||||
/// deletes the transcript and files -- the complete path out.
|
||||
pub fn delete_session(&self, id: &str) -> Result<()> {
|
||||
let mut inner = self.inner.write().unwrap();
|
||||
if !inner.config.sessions.iter().any(|meta| meta.id == id) {
|
||||
bail!("no session {id}");
|
||||
}
|
||||
let mut candidate = inner.config.clone();
|
||||
candidate.sessions.retain(|meta| meta.id != id);
|
||||
candidate.save(&self.config_path)?;
|
||||
inner.config = candidate;
|
||||
if let Some(session) = inner.live.remove(id) {
|
||||
session.driver.shutdown();
|
||||
}
|
||||
let dir = self.data_dir.join(id);
|
||||
if dir.exists() {
|
||||
std::fs::remove_dir_all(&dir).with_context(|| format!("remove {}", dir.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn default_title(kind: SessionKind) -> String {
|
||||
match kind {
|
||||
SessionKind::Echo => "Echo session".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 8 random bytes, hex -- short enough for a URL, unique enough forever at
|
||||
/// this scale. Still checked against the existing list out of caution.
|
||||
fn unique_id(config: &Config) -> String {
|
||||
use rand::Rng;
|
||||
loop {
|
||||
let mut bytes = [0u8; 8];
|
||||
rand::rng().fill_bytes(&mut bytes);
|
||||
let id: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
|
||||
if !config.sessions.iter().any(|meta| meta.id == id) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the session directory, opens its transcript (continuing the
|
||||
/// sequence numbering if one exists), starts the driver, and spawns the
|
||||
/// event pump connecting them.
|
||||
fn launch(meta: SessionConfig, data_dir: &Path) -> Result<Arc<LiveSession>> {
|
||||
let dir = data_dir.join(&meta.id);
|
||||
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
|
||||
let transcript_path = dir.join("transcript.jsonl");
|
||||
let transcript = Transcript::open(&transcript_path)?;
|
||||
|
||||
let (sink, source) = mpsc::unbounded_channel();
|
||||
let (events, _) = broadcast::channel(EVENT_BUFFER);
|
||||
let shared = Arc::new(Shared {
|
||||
status: Mutex::new(SessionStatus::Idle),
|
||||
last_activity: Mutex::new(now()),
|
||||
});
|
||||
|
||||
let driver: Box<dyn Driver> = match meta.kind {
|
||||
SessionKind::Echo => Box::new(EchoDriver::new(sink.clone())),
|
||||
};
|
||||
|
||||
tokio::spawn(pump(transcript, source, Arc::clone(&shared), events.clone()));
|
||||
|
||||
Ok(Arc::new(LiveSession {
|
||||
meta,
|
||||
driver,
|
||||
sink,
|
||||
events,
|
||||
transcript_path,
|
||||
shared,
|
||||
}))
|
||||
}
|
||||
|
||||
/// The one writer of a session's transcript: assigns sequence numbers,
|
||||
/// appends, updates the shared status/activity view, fans out. Ends when
|
||||
/// every sender is dropped -- i.e. when the session is deleted and its
|
||||
/// last in-flight task finishes.
|
||||
///
|
||||
/// The appends are synchronous file writes from an async task,
|
||||
/// deliberately: each is one small line on a local disk, and funneling
|
||||
/// them through one task is what makes the sequence numbering safe.
|
||||
async fn pump(
|
||||
mut transcript: Transcript,
|
||||
mut source: mpsc::UnboundedReceiver<Event>,
|
||||
shared: Arc<Shared>,
|
||||
events: broadcast::Sender<SeqEvent>,
|
||||
) {
|
||||
while let Some(event) = source.recv().await {
|
||||
let ts = now();
|
||||
match transcript.append(event, ts) {
|
||||
Ok(entry) => {
|
||||
if let Event::Status { state } = &entry.event {
|
||||
*shared.status.lock().unwrap() = *state;
|
||||
}
|
||||
*shared.last_activity.lock().unwrap() = ts;
|
||||
// No subscribers is fine; the transcript already has it.
|
||||
let _ = events.send(entry);
|
||||
}
|
||||
Err(err) => tracing::error!("transcript append failed: {err:#}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
fn echo_spec() -> SpawnSpec {
|
||||
SpawnSpec {
|
||||
kind: SessionKind::Echo,
|
||||
title: None,
|
||||
host: None,
|
||||
model: None,
|
||||
cwd: None,
|
||||
permission_mode: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads events from `rx` until `stop` matches one (returning all seen
|
||||
/// so far) or five seconds pass (panicking with what was seen).
|
||||
async fn collect_until(
|
||||
rx: &mut broadcast::Receiver<SeqEvent>,
|
||||
mut stop: impl FnMut(&Event) -> bool,
|
||||
) -> Vec<SeqEvent> {
|
||||
let mut seen = Vec::new();
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
let entry = tokio::time::timeout_at(deadline, rx.recv())
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("timed out; events so far: {seen:?}"))
|
||||
.expect("event stream closed");
|
||||
let done = stop(&entry.event);
|
||||
seen.push(entry);
|
||||
if done {
|
||||
return seen;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_idle(event: &Event) -> bool {
|
||||
matches!(event, Event::Status { state: SessionStatus::Idle })
|
||||
}
|
||||
|
||||
/// Collects one full echo turn: everything up to the idle that follows
|
||||
/// the turn's `UsageDelta`. Stopping at the first idle would be racy --
|
||||
/// the driver emits an idle at construction, and a subscriber attached
|
||||
/// just before the pump processes it would stop there, mid-spawn.
|
||||
async fn collect_turn(rx: &mut broadcast::Receiver<SeqEvent>) -> Vec<SeqEvent> {
|
||||
let mut saw_usage = false;
|
||||
collect_until(rx, |event| {
|
||||
saw_usage |= matches!(event, Event::UsageDelta { .. });
|
||||
saw_usage && is_idle(event)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_message_and_delete_round_trip() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let config_path = dir.path().join("config.json");
|
||||
let data_dir = dir.path().join("sessions");
|
||||
let manager = SessionManager::new(config_path.clone(), data_dir.clone()).expect("manager");
|
||||
|
||||
let info = manager.spawn_session(echo_spec()).expect("spawn");
|
||||
assert_eq!(info.title, "Echo session");
|
||||
// Persisted: a fresh load of the config file knows the session.
|
||||
let persisted = Config::load(&config_path).expect("reload config");
|
||||
assert_eq!(persisted.sessions.len(), 1);
|
||||
assert_eq!(persisted.sessions[0].id, info.id);
|
||||
|
||||
let session = manager.session(&info.id).expect("live session");
|
||||
let mut rx = session.subscribe();
|
||||
session.send_message("hello there".to_string(), Vec::new());
|
||||
let seen = collect_turn(&mut rx).await;
|
||||
|
||||
// The user's message is in the stream, before the echoed reply.
|
||||
let user_at = seen
|
||||
.iter()
|
||||
.position(|entry| {
|
||||
matches!(&entry.event, Event::UserMessage { text } if text == "hello there")
|
||||
})
|
||||
.expect("user message in the stream");
|
||||
let echoed: String = seen[user_at..]
|
||||
.iter()
|
||||
.filter_map(|entry| match &entry.event {
|
||||
Event::AssistantText { delta } => Some(delta.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(echoed, "You said: hello there");
|
||||
|
||||
// The transcript replays the same events by cursor.
|
||||
let replay = transcript::read_after(session.transcript_path(), 0).expect("replay");
|
||||
assert!(replay.len() >= seen.len());
|
||||
let cursor = seen[user_at].seq;
|
||||
let after = transcript::read_after(session.transcript_path(), cursor).expect("replay");
|
||||
assert_eq!(after.first().map(|entry| entry.seq), Some(cursor + 1));
|
||||
|
||||
// Delete is the complete path out: config, registry, and files.
|
||||
manager.delete_session(&info.id).expect("delete");
|
||||
assert!(manager.sessions().is_empty());
|
||||
assert!(manager.session(&info.id).is_none());
|
||||
assert!(!data_dir.join(&info.id).exists());
|
||||
assert!(Config::load(&config_path).expect("reload").sessions.is_empty());
|
||||
assert!(manager.delete_session(&info.id).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn questions_round_trip_through_answer() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let manager = SessionManager::new(
|
||||
dir.path().join("config.json"),
|
||||
dir.path().join("sessions"),
|
||||
)
|
||||
.expect("manager");
|
||||
let info = manager.spawn_session(echo_spec()).expect("spawn");
|
||||
let session = manager.session(&info.id).expect("live session");
|
||||
|
||||
let mut rx = session.subscribe();
|
||||
session.send_message("/question deploy?".to_string(), Vec::new());
|
||||
let seen = collect_until(&mut rx, |event| {
|
||||
matches!(event, Event::Status { state: SessionStatus::AwaitingInput })
|
||||
})
|
||||
.await;
|
||||
let question_id = seen
|
||||
.iter()
|
||||
.find_map(|entry| match &entry.event {
|
||||
Event::Question { id, .. } => Some(id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("question event");
|
||||
|
||||
session.answer_question(&question_id, "Yes");
|
||||
let seen = collect_until(&mut rx, is_idle).await;
|
||||
assert!(seen.iter().any(|entry| matches!(
|
||||
&entry.event,
|
||||
Event::Answered { id, answer } if *id == question_id && answer == "Yes"
|
||||
)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_restart_relaunches_sessions_and_continues_the_numbering() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let config_path = dir.path().join("config.json");
|
||||
let data_dir = dir.path().join("sessions");
|
||||
|
||||
let manager = SessionManager::new(config_path.clone(), data_dir.clone()).expect("manager");
|
||||
let info = manager.spawn_session(echo_spec()).expect("spawn");
|
||||
let session = manager.session(&info.id).expect("live session");
|
||||
let mut rx = session.subscribe();
|
||||
session.send_message("first".to_string(), Vec::new());
|
||||
let seen = collect_turn(&mut rx).await;
|
||||
let last_seq = seen.last().expect("events").seq;
|
||||
drop(rx);
|
||||
drop(session);
|
||||
drop(manager);
|
||||
|
||||
// A new manager over the same state: the session is back, and new
|
||||
// events continue the sequence rather than restarting it -- which
|
||||
// is what makes a phone's cursor survive a backend restart.
|
||||
let manager = SessionManager::new(config_path, data_dir).expect("manager restart");
|
||||
let listed = manager.sessions();
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].id, info.id);
|
||||
let session = manager.session(&info.id).expect("relaunched session");
|
||||
let mut rx = session.subscribe();
|
||||
session.send_message("second".to_string(), Vec::new());
|
||||
let seen = collect_turn(&mut rx).await;
|
||||
assert!(seen.first().expect("events").seq > last_seq);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user