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

+93
View File
@@ -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);
}