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,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()
|
||||
}
|
||||
Reference in new issue
Block a user