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
+20
@@ -0,0 +1,20 @@
|
|||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
app/androidApp/build/
|
||||||
|
local.properties
|
||||||
|
.kotlin/
|
||||||
|
*.iml
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
server/target/
|
||||||
|
server/ai-server.log
|
||||||
|
|
||||||
|
# Private key material, regenerated by ./gen-dev-cert.sh.
|
||||||
|
certs/
|
||||||
|
|
||||||
|
# Machine-local state: token hashes and the session list. Nothing here is
|
||||||
|
# shareable, and the token hashes shouldn't be.
|
||||||
|
config.json
|
||||||
|
|
||||||
|
# Per-session transcripts, attachments, and produced images.
|
||||||
|
sessions/
|
||||||
Executable
+105
@@ -0,0 +1,105 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Generates the self-signed dev CA and leaf certificate `server/` serves its
|
||||||
|
# TLS listener with. Run once before the first `cargo run`; the server exits
|
||||||
|
# with a clear message if `certs/` is missing.
|
||||||
|
#
|
||||||
|
# Same scheme as ../local-updater's: nothing on a device trusts this
|
||||||
|
# automatically -- the app embeds the CA certificate verbatim and pins to it
|
||||||
|
# (`PinnedCert.kt`), rather than relying on the device's system trust store.
|
||||||
|
# This server's API *is* remote code execution (it spawns AI sessions on
|
||||||
|
# request), so a MITM on it would be as bad as it gets -- hence pinning.
|
||||||
|
#
|
||||||
|
# The CA is idempotent -- skipped if `certs/ca.pem` already exists, so
|
||||||
|
# re-running this doesn't invalidate the certificate the installed app has
|
||||||
|
# pinned against without a reason to. The leaf is cheap and reissued on
|
||||||
|
# every run (still signed by that same, unchanged CA), so adding another SAN
|
||||||
|
# entry only means rerunning this script, not touching anything pinned.
|
||||||
|
#
|
||||||
|
# The leaf's SANs must cover every address a device reaches this server at.
|
||||||
|
# In production that is exactly one: the backend's WireGuard address, which
|
||||||
|
# the phone uses from everywhere (see PLAN.md's off-network section).
|
||||||
|
# Override with SERVER_IP=... if your wg0 address differs.
|
||||||
|
#
|
||||||
|
# Outputs into `certs/` (gitignored -- private key material, and the whole
|
||||||
|
# thing is trivially regeneratable anyway):
|
||||||
|
# ca.pem the CA certificate (not its private key) -- what the
|
||||||
|
# app embeds and pins against.
|
||||||
|
# ca-key.pem the CA's private key -- only this script needs it, to
|
||||||
|
# sign the leaf below. Never shipped anywhere.
|
||||||
|
# leaf.pem the server's own certificate (CA-signed), presented on
|
||||||
|
# every TLS handshake.
|
||||||
|
# leaf-key.pem the leaf's private key -- what the server loads to
|
||||||
|
# terminate TLS.
|
||||||
|
# ca-sha256.txt the CA certificate's SPKI SHA-256 fingerprint, printed
|
||||||
|
# below too. Informational -- the app embeds the whole
|
||||||
|
# `ca.pem`, not this digest.
|
||||||
|
# leaf-sha256.txt the leaf's SPKI SHA-256 fingerprint. Informational --
|
||||||
|
# nothing pins the leaf; the app pins the CA and
|
||||||
|
# validates the chain.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
CERTS_DIR="$SCRIPT_DIR/certs"
|
||||||
|
# The backend's WireGuard address -- the one address the phone ever dials in
|
||||||
|
# production (PLAN.md: single-path addressing, no home/away distinction).
|
||||||
|
SERVER_IP="${SERVER_IP:-10.66.0.1}"
|
||||||
|
# Also covered so development before the tunnel exists can complete a real
|
||||||
|
# handshake against the same pinned CA:
|
||||||
|
# 127.0.0.1 curl from the machine itself, and tests binding loopback
|
||||||
|
# 10.0.2.2 the Android emulator's alias for the host's loopback
|
||||||
|
# LAN_IP a real phone on the same LAN, pre-WireGuard
|
||||||
|
LOOPBACK_IP="127.0.0.1"
|
||||||
|
EMULATOR_HOST_IP="10.0.2.2"
|
||||||
|
LAN_IP="${LAN_IP:-192.168.1.168}"
|
||||||
|
|
||||||
|
mkdir -p "$CERTS_DIR"
|
||||||
|
cd "$CERTS_DIR"
|
||||||
|
|
||||||
|
if [ -f ca.pem ]; then
|
||||||
|
echo "==> ca.pem already exists, reusing existing CA."
|
||||||
|
else
|
||||||
|
echo "==> Generating CA key + self-signed CA certificate"
|
||||||
|
openssl ecparam -name prime256v1 -genkey -noout -out ca-key.pem
|
||||||
|
openssl req -new -x509 -key ca-key.pem -out ca.pem -days 3650 \
|
||||||
|
-subj "/O=ai-app dev/CN=ai-app dev CA"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Generating leaf key + CSR for $SERVER_IP (+ dev addresses)"
|
||||||
|
openssl ecparam -name prime256v1 -genkey -noout -out leaf-key.pem
|
||||||
|
openssl req -new -key leaf-key.pem -out leaf.csr \
|
||||||
|
-subj "/O=ai-app dev/CN=$SERVER_IP"
|
||||||
|
|
||||||
|
echo "==> Signing leaf certificate with the dev CA"
|
||||||
|
cat > leaf.ext <<EOF
|
||||||
|
subjectAltName = IP:$SERVER_IP,IP:$LOOPBACK_IP,IP:$EMULATOR_HOST_IP,IP:$LAN_IP
|
||||||
|
EOF
|
||||||
|
openssl x509 -req -in leaf.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
|
||||||
|
-out leaf.pem -days 3650 -extfile leaf.ext
|
||||||
|
rm -f leaf.csr leaf.ext ca.srl
|
||||||
|
|
||||||
|
echo "==> Computing certificate fingerprints (SPKI SHA-256)"
|
||||||
|
CA_SHA256=$(openssl x509 -in ca.pem -pubkey -noout \
|
||||||
|
| openssl pkey -pubin -outform der \
|
||||||
|
| openssl dgst -sha256 -binary \
|
||||||
|
| openssl base64)
|
||||||
|
echo "$CA_SHA256" > ca-sha256.txt
|
||||||
|
LEAF_SHA256=$(openssl x509 -in leaf.pem -pubkey -noout \
|
||||||
|
| openssl pkey -pubin -outform der \
|
||||||
|
| openssl dgst -sha256 -binary \
|
||||||
|
| openssl base64)
|
||||||
|
echo "$LEAF_SHA256" > leaf-sha256.txt
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==> Done."
|
||||||
|
echo " CA fingerprint (base64): $CA_SHA256"
|
||||||
|
echo " Leaf fingerprint (base64): $LEAF_SHA256"
|
||||||
|
echo
|
||||||
|
echo " Only relevant if the CA was regenerated just now (i.e. certs/ca.pem"
|
||||||
|
echo " did not already exist): the app embeds PINNED_CA_PEM and needs the"
|
||||||
|
echo " new certs/ca.pem contents pasted in, or it silently stops being able"
|
||||||
|
echo " to reach this server. The app installs via Local Updater, so"
|
||||||
|
echo " recovery is a reinstall through that -- but it's still a one-way"
|
||||||
|
echo " door for the installed copy."
|
||||||
|
echo
|
||||||
|
echo " app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt"
|
||||||
Executable
+11
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Runs this repo's tests. Extra arguments are forwarded to `cargo test`,
|
||||||
|
# e.g. `./run-tests.sh transcript` to run just the transcript tests.
|
||||||
|
#
|
||||||
|
# Only `server/` has tests: it holds all the logic worth testing (event
|
||||||
|
# normalization, transcript cursors, config persistence, token auth), while
|
||||||
|
# the Android app is UI over its HTTP API. Verifying the app means running
|
||||||
|
# it -- see AGENTS.md.
|
||||||
|
set -eu
|
||||||
|
cd "$(dirname "$0")/server"
|
||||||
|
exec cargo test "$@"
|
||||||
Generated
+1375
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,37 @@
|
|||||||
|
[package]
|
||||||
|
name = "ai-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "ai-server"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = { version = "0.8", features = ["json"] }
|
||||||
|
axum-server = { version = "0.8", features = ["tls-rustls"] }
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "time"] }
|
||||||
|
tokio-stream = "0.1"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
anyhow = "1"
|
||||||
|
thiserror = "2"
|
||||||
|
# Token auth: hash for storage, constant-time compare for verification,
|
||||||
|
# CSPRNG-backed generation, base64url for the enrollment string.
|
||||||
|
sha2 = "0.11"
|
||||||
|
subtle = "2"
|
||||||
|
rand = "0.10"
|
||||||
|
base64 = "0.23"
|
||||||
|
# Renders the enrollment QR straight to the terminal; no image output needed.
|
||||||
|
qrcode = { version = "0.14", default-features = false }
|
||||||
|
# The wg0-bound listener needs the interface's address; the stdlib has no
|
||||||
|
# getifaddrs. This is the smallest crate that wraps just that.
|
||||||
|
if-addrs = "0.15"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
# ServiceExt::oneshot, to drive the auth middleware without a socket.
|
||||||
|
tower = { version = "0.5", features = ["util"] }
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
//! Bearer-token auth for the entire HTTP surface.
|
||||||
|
//!
|
||||||
|
//! This server's API *is* remote code execution, so the token gates every
|
||||||
|
//! route with zero unauthenticated endpoints -- the middleware is applied
|
||||||
|
//! once around the whole router (including the fallback) in `main.rs`,
|
||||||
|
//! never per-route, so a new route can't forget it. See PLAN.md's security
|
||||||
|
//! section for the threat model; the short version is that the token gates
|
||||||
|
//! LAN/tunnel-reachable RCE and is rotatable, and WireGuard makes it
|
||||||
|
//! defense in depth rather than the sole gate.
|
||||||
|
//!
|
||||||
|
//! Nothing in this module -- and nothing anywhere else -- may log the
|
||||||
|
//! Authorization header or the token; `token_is_never_logged` below holds a
|
||||||
|
//! tripwire against a logging change silently starting to.
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::extract::{ConnectInfo, Request, State};
|
||||||
|
use axum::http::{StatusCode, header};
|
||||||
|
use axum::middleware::Next;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use base64::Engine;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use subtle::ConstantTimeEq;
|
||||||
|
|
||||||
|
use crate::session::SessionManager;
|
||||||
|
|
||||||
|
/// Applied to every rejection. Not against brute force -- infeasible at 256
|
||||||
|
/// bits -- but so a scanner probing the port shows up as a slow, loggable
|
||||||
|
/// drip rather than a fast one.
|
||||||
|
const REJECT_DELAY: Duration = Duration::from_millis(300);
|
||||||
|
|
||||||
|
/// 256 bits from the OS CSPRNG, base64url. A machine credential carried by
|
||||||
|
/// a QR code, never typed, so unguessable costs nothing.
|
||||||
|
pub fn generate_token() -> String {
|
||||||
|
use rand::Rng;
|
||||||
|
let mut bytes = [0u8; 32];
|
||||||
|
rand::rng().fill_bytes(&mut bytes);
|
||||||
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `config.json` stores instead of the token: hex SHA-256. A plain
|
||||||
|
/// hash is enough for high-entropy random input, and buys that a leaked
|
||||||
|
/// config doesn't leak the credential.
|
||||||
|
pub fn token_hash_hex(token: &str) -> String {
|
||||||
|
Sha256::digest(token.as_bytes())
|
||||||
|
.iter()
|
||||||
|
.map(|byte| format!("{byte:02x}"))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hash-then-constant-time-compare against every enrolled hash. The fold
|
||||||
|
/// visits every entry regardless of match so the timing doesn't say which
|
||||||
|
/// entry (if any) matched.
|
||||||
|
fn token_matches(presented: &str, stored_hashes: &[String]) -> bool {
|
||||||
|
let presented = token_hash_hex(presented);
|
||||||
|
stored_hashes.iter().fold(false, |matched, stored| {
|
||||||
|
matched | bool::from(presented.as_bytes().ct_eq(stored.as_bytes()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn require_token(
|
||||||
|
State(manager): State<Arc<SessionManager>>,
|
||||||
|
request: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
let presented = request
|
||||||
|
.headers()
|
||||||
|
.get(header::AUTHORIZATION)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.and_then(|value| value.strip_prefix("Bearer "));
|
||||||
|
if let Some(token) = presented {
|
||||||
|
let hashes: Vec<String> =
|
||||||
|
manager.tokens().into_iter().map(|entry| entry.sha256).collect();
|
||||||
|
if token_matches(token, &hashes) {
|
||||||
|
return next.run(request).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peer address only -- never the header value. Absent when there is no
|
||||||
|
// real socket (tests driving the router directly).
|
||||||
|
let peer = request
|
||||||
|
.extensions()
|
||||||
|
.get::<ConnectInfo<SocketAddr>>()
|
||||||
|
.map(|ConnectInfo(addr)| addr.to_string())
|
||||||
|
.unwrap_or_else(|| "unknown peer".to_string());
|
||||||
|
tracing::warn!("rejected request from {peer}: missing or invalid bearer token");
|
||||||
|
tokio::time::sleep(REJECT_DELAY).await;
|
||||||
|
(StatusCode::UNAUTHORIZED, "missing or invalid bearer token").into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use axum::Router;
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::routing::get;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
use crate::config::TokenEntry;
|
||||||
|
|
||||||
|
fn manager_with_token(dir: &std::path::Path, token: &str) -> Arc<SessionManager> {
|
||||||
|
let manager = Arc::new(
|
||||||
|
SessionManager::new(dir.join("config.json"), dir.join("sessions"))
|
||||||
|
.expect("manager"),
|
||||||
|
);
|
||||||
|
manager
|
||||||
|
.set_tokens(vec![TokenEntry {
|
||||||
|
name: "phone".to_string(),
|
||||||
|
sha256: token_hash_hex(token),
|
||||||
|
}])
|
||||||
|
.expect("set token");
|
||||||
|
manager
|
||||||
|
}
|
||||||
|
|
||||||
|
fn guarded_router(manager: Arc<SessionManager>) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/probe", get(|| async { "ok" }))
|
||||||
|
.fallback(|| async { StatusCode::NOT_FOUND })
|
||||||
|
.layer(axum::middleware::from_fn_with_state(manager, require_token))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(path: &str, auth: Option<&str>) -> Request {
|
||||||
|
let mut builder = axum::http::Request::builder().uri(path);
|
||||||
|
if let Some(auth) = auth {
|
||||||
|
builder = builder.header(header::AUTHORIZATION, auth);
|
||||||
|
}
|
||||||
|
builder.body(Body::empty()).expect("request")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hashing_is_stable_and_tokens_verify() {
|
||||||
|
let token = generate_token();
|
||||||
|
assert_eq!(token_hash_hex(&token), token_hash_hex(&token));
|
||||||
|
assert_ne!(token, generate_token(), "tokens must not repeat");
|
||||||
|
|
||||||
|
let hashes = vec![token_hash_hex(&token), token_hash_hex("other")];
|
||||||
|
assert!(token_matches(&token, &hashes));
|
||||||
|
assert!(token_matches("other", &hashes));
|
||||||
|
assert!(!token_matches("wrong", &hashes));
|
||||||
|
assert!(!token_matches(&token, &[]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One test rather than separate gating and logging tests,
|
||||||
|
/// deliberately: tracing caches callsite interest process-wide, so a
|
||||||
|
/// test that hits the rejection path with no subscriber installed can
|
||||||
|
/// poison the interest cache for the one that captures logs. Keeping
|
||||||
|
/// every exercise of the middleware under the capturing subscriber
|
||||||
|
/// makes the log assertions deterministic.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gates_every_route_and_never_logs_the_token() {
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct Capture(Arc<Mutex<Vec<u8>>>);
|
||||||
|
impl std::io::Write for Capture {
|
||||||
|
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||||
|
self.0.lock().unwrap().extend_from_slice(buf);
|
||||||
|
Ok(buf.len())
|
||||||
|
}
|
||||||
|
fn flush(&mut self) -> std::io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for Capture {
|
||||||
|
type Writer = Capture;
|
||||||
|
fn make_writer(&'a self) -> Capture {
|
||||||
|
self.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let capture = Capture::default();
|
||||||
|
let subscriber = tracing_subscriber::fmt()
|
||||||
|
.with_max_level(tracing::Level::TRACE)
|
||||||
|
.with_writer(capture.clone())
|
||||||
|
.finish();
|
||||||
|
let _guard = tracing::subscriber::set_default(subscriber);
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let token = generate_token();
|
||||||
|
let router = guarded_router(manager_with_token(dir.path(), &token));
|
||||||
|
|
||||||
|
// No header, wrong token, wrong scheme: 401 everywhere, including
|
||||||
|
// paths that don't exist -- a scanner learns nothing.
|
||||||
|
for (path, auth) in [
|
||||||
|
("/probe", None),
|
||||||
|
("/probe", Some("Bearer wrong".to_string())),
|
||||||
|
("/probe", Some(format!("Basic {token}"))),
|
||||||
|
("/no-such-route", None),
|
||||||
|
] {
|
||||||
|
let response = router
|
||||||
|
.clone()
|
||||||
|
.oneshot(request(path, auth.as_deref()))
|
||||||
|
.await
|
||||||
|
.expect("response");
|
||||||
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{path} {auth:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let ok = router
|
||||||
|
.clone()
|
||||||
|
.oneshot(request("/probe", Some(&format!("Bearer {token}"))))
|
||||||
|
.await
|
||||||
|
.expect("response");
|
||||||
|
assert_eq!(ok.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// The tripwire that keeps a future logging change (e.g. logging
|
||||||
|
// request headers) from silently leaking credentials.
|
||||||
|
let logged = String::from_utf8_lossy(&capture.0.lock().unwrap()).into_owned();
|
||||||
|
assert!(
|
||||||
|
!logged.contains(&token),
|
||||||
|
"the bearer token leaked into the logs: {logged}"
|
||||||
|
);
|
||||||
|
// The rejections themselves do get logged (that's the point).
|
||||||
|
assert!(logged.contains("missing or invalid bearer token"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
//! The server's persistent state: the enrolled token hashes and the
|
||||||
|
//! sessions that exist.
|
||||||
|
//!
|
||||||
|
//! Written whole and atomically (temp file + rename) rather than appended
|
||||||
|
//! to: it is small, and a half-written config would take the server down on
|
||||||
|
//! next start with no obvious way to recover from a phone. Every mutation
|
||||||
|
//! funnels through `SessionManager` (the registry pattern), so in-memory
|
||||||
|
//! and on-disk state can't come apart.
|
||||||
|
//!
|
||||||
|
//! Transcripts do NOT live here -- each session's events are an append-only
|
||||||
|
//! JSONL file in its own directory (see `session::transcript`); this file
|
||||||
|
//! holds only the metadata needed to list and respawn sessions.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct Config {
|
||||||
|
/// Enrolled device tokens, hashes only -- a leaked config doesn't leak
|
||||||
|
/// the credential. A list (of one, today) so per-device tokens with
|
||||||
|
/// individual revocation are a config entry later, not a migration.
|
||||||
|
pub tokens: Vec<TokenEntry>,
|
||||||
|
pub sessions: Vec<SessionConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct TokenEntry {
|
||||||
|
/// Which device this token belongs to, for the human rotating it.
|
||||||
|
pub name: String,
|
||||||
|
/// Hex SHA-256 of the token. A plain hash is enough: the token is 256
|
||||||
|
/// bits from the OS CSPRNG, so there is nothing to dictionary-attack
|
||||||
|
/// and no stretching needed.
|
||||||
|
pub sha256: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which driver a session runs. Phase 2 adds `Claude`, phase 4 adds `Pi`;
|
||||||
|
/// a new kind is a new driver behind the same trait, never a branch in
|
||||||
|
/// shared code.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum SessionKind {
|
||||||
|
/// The phase-1 fake: echoes messages back as streamed events. Proves
|
||||||
|
/// the whole pipe (spawn, SSE, transcript cursors, questions) with no
|
||||||
|
/// AI involved, and stays useful as a connectivity check.
|
||||||
|
Echo,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SessionConfig {
|
||||||
|
/// Stable identifier; names the session's directory and its routes.
|
||||||
|
pub id: String,
|
||||||
|
pub kind: SessionKind,
|
||||||
|
pub title: String,
|
||||||
|
/// Config name of the SSH host to run on; absent means local. Host
|
||||||
|
/// configs arrive in phase 5.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub host: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub model: Option<String>,
|
||||||
|
/// Working directory the session's process runs in.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cwd: Option<PathBuf>,
|
||||||
|
/// Claude permission mode chosen at spawn (default/plan/acceptEdits/
|
||||||
|
/// bypassPermissions). Meaningless for other kinds; kept as a string
|
||||||
|
/// because it is passed through to the CLI, not interpreted here.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub permission_mode: Option<String>,
|
||||||
|
/// Epoch seconds when the session was spawned.
|
||||||
|
pub created: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load(path: &Path) -> Result<Self> {
|
||||||
|
match std::fs::read_to_string(path) {
|
||||||
|
Ok(text) => serde_json::from_str(&text)
|
||||||
|
.with_context(|| format!("{} is not valid config JSON", path.display())),
|
||||||
|
// A first run has no config -- the normal starting state; a
|
||||||
|
// token is generated and saved on that first start.
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
|
||||||
|
Err(err) => Err(err).with_context(|| format!("read {}", path.display())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self, path: &Path) -> Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)
|
||||||
|
.with_context(|| format!("create {}", parent.display()))?;
|
||||||
|
}
|
||||||
|
let text = serde_json::to_string_pretty(self).context("serialize config")?;
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
std::fs::write(&tmp, text).with_context(|| format!("write {}", tmp.display()))?;
|
||||||
|
std::fs::rename(&tmp, path)
|
||||||
|
.with_context(|| format!("replace {} with {}", path.display(), tmp.display()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trips_through_the_config_file() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let path = dir.path().join("config.json");
|
||||||
|
|
||||||
|
// A missing file is the ordinary first-run state, not an error.
|
||||||
|
let first_run = Config::load(&path).expect("load");
|
||||||
|
assert!(first_run.tokens.is_empty());
|
||||||
|
assert!(first_run.sessions.is_empty());
|
||||||
|
|
||||||
|
let config = Config {
|
||||||
|
tokens: vec![TokenEntry {
|
||||||
|
name: "phone".to_string(),
|
||||||
|
sha256: "ab".repeat(32),
|
||||||
|
}],
|
||||||
|
sessions: vec![SessionConfig {
|
||||||
|
id: "abc123".to_string(),
|
||||||
|
kind: SessionKind::Echo,
|
||||||
|
title: "test".to_string(),
|
||||||
|
host: None,
|
||||||
|
model: None,
|
||||||
|
cwd: None,
|
||||||
|
permission_mode: None,
|
||||||
|
created: 1234.5,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
config.save(&path).expect("save");
|
||||||
|
|
||||||
|
let loaded = Config::load(&path).expect("reload");
|
||||||
|
assert_eq!(loaded.tokens[0].name, "phone");
|
||||||
|
assert_eq!(loaded.sessions[0].id, "abc123");
|
||||||
|
assert_eq!(loaded.sessions[0].kind, SessionKind::Echo);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
//! A phone interface to AI coding sessions -- the backend. See PLAN.md for
|
||||||
|
//! the whole picture; this is the entry point: config + session registry,
|
||||||
|
//! token bootstrap, and the one TLS listener.
|
||||||
|
//!
|
||||||
|
//! The listener binds the WireGuard interface's address only, and fails
|
||||||
|
//! closed -- if `wg0` is down the server refuses to start rather than
|
||||||
|
//! falling back to `0.0.0.0`, because this API *is* remote code execution
|
||||||
|
//! and the tunnel is what keeps its pre-auth surface (TLS handshake, HTTP
|
||||||
|
//! parsing, auth middleware) off the open internet. `--bind` overrides
|
||||||
|
//! explicitly for development; that is a deliberate, logged choice, never a
|
||||||
|
//! fallback.
|
||||||
|
//!
|
||||||
|
//! There is no plaintext listener at all, so the bearer token can't travel
|
||||||
|
//! unencrypted by misconfiguration -- even inside the tunnel.
|
||||||
|
|
||||||
|
mod auth;
|
||||||
|
mod config;
|
||||||
|
mod routes;
|
||||||
|
mod session;
|
||||||
|
|
||||||
|
use std::net::{IpAddr, SocketAddr};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
use config::TokenEntry;
|
||||||
|
use session::SessionManager;
|
||||||
|
|
||||||
|
const DEFAULT_PORT: u16 = 8443;
|
||||||
|
const WG_INTERFACE: &str = "wg0";
|
||||||
|
|
||||||
|
/// The repo root, one level above this crate. Everything the server reads
|
||||||
|
/// by default -- the TLS cert, the config, the session data -- resolves
|
||||||
|
/// from here, so there's one definition of it rather than one per caller.
|
||||||
|
fn repo_root() -> &'static Path {
|
||||||
|
static ROOT: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
|
||||||
|
ROOT.get_or_init(|| {
|
||||||
|
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.parent()
|
||||||
|
.expect("CARGO_MANIFEST_DIR has a repo-root parent")
|
||||||
|
.to_path_buf()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serves AI coding sessions (Claude Code, llama.cpp) to the phone app.
|
||||||
|
#[derive(Parser)]
|
||||||
|
struct Args {
|
||||||
|
/// TLS port for the whole API surface.
|
||||||
|
#[arg(long, default_value_t = DEFAULT_PORT)]
|
||||||
|
port: u16,
|
||||||
|
|
||||||
|
/// Address to bind instead of the wg0 interface's -- a development
|
||||||
|
/// override (e.g. 127.0.0.1 for curl, or a LAN address for a phone
|
||||||
|
/// before the tunnel exists). Production runs without it and fails
|
||||||
|
/// closed when wg0 is absent.
|
||||||
|
#[arg(long)]
|
||||||
|
bind: Option<IpAddr>,
|
||||||
|
|
||||||
|
/// Where the token hashes and session list live. Defaults to
|
||||||
|
/// `config.json` beside this repo's `certs/`.
|
||||||
|
#[arg(long)]
|
||||||
|
config: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Directory for per-session data (transcripts, attachments, images).
|
||||||
|
/// Defaults to `sessions/` in the repo root.
|
||||||
|
#[arg(long)]
|
||||||
|
data_dir: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Directory holding `leaf.pem`/`leaf-key.pem`. Defaults to this
|
||||||
|
/// repo's `certs/`, as produced by `gen-dev-cert.sh`.
|
||||||
|
#[arg(long)]
|
||||||
|
certs: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Invalidate every enrolled token, generate a fresh one, and print
|
||||||
|
/// its enrollment QR -- the whole lost-phone story.
|
||||||
|
#[arg(long)]
|
||||||
|
rotate_token: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The IPv4 address on the WireGuard interface, or a refusal to start.
|
||||||
|
/// Failing closed here (rather than falling back to a wider bind) is part
|
||||||
|
/// of the security posture -- see the module doc comment.
|
||||||
|
fn wg_address() -> Result<IpAddr> {
|
||||||
|
let interfaces = if_addrs::get_if_addrs().context("enumerate network interfaces")?;
|
||||||
|
interfaces
|
||||||
|
.into_iter()
|
||||||
|
.find(|iface| iface.name == WG_INTERFACE && iface.ip().is_ipv4())
|
||||||
|
.map(|iface| iface.ip())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"no IPv4 address on interface {WG_INTERFACE} -- this server binds only to the \
|
||||||
|
WireGuard tunnel and refuses to fall back to a wider address. Bring the tunnel \
|
||||||
|
up, or pass --bind <ip> explicitly for development."
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prints the one-time enrollment QR: an `aiapp://enroll` URI carrying
|
||||||
|
/// where to connect and the bearer token. The CA stays embedded in the APK,
|
||||||
|
/// so this carries no trust material -- photographing the terminal leaks
|
||||||
|
/// only the token, which is rotatable (`--rotate-token`). Printed to
|
||||||
|
/// stdout, not the log: it is for the human at the terminal, once.
|
||||||
|
fn print_enrollment(host: IpAddr, port: u16, token: &str) -> Result<()> {
|
||||||
|
let uri = format!("aiapp://enroll?host={host}&port={port}&token={token}");
|
||||||
|
let code = qrcode::QrCode::new(uri.as_bytes()).context("render enrollment QR")?;
|
||||||
|
let rendered = code
|
||||||
|
.render::<qrcode::render::unicode::Dense1x2>()
|
||||||
|
.quiet_zone(true)
|
||||||
|
.build();
|
||||||
|
println!("\n{rendered}\n");
|
||||||
|
println!("Scan with the phone's camera to enroll (or paste into the app's settings):");
|
||||||
|
println!(" {uri}");
|
||||||
|
println!("The token is not stored in the clear and won't be shown again;");
|
||||||
|
println!("a lost phone means `--rotate-token`.\n");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
tracing_subscriber::fmt().with_env_filter("info").init();
|
||||||
|
let args = Args::parse();
|
||||||
|
|
||||||
|
let config_path = args.config.unwrap_or_else(|| repo_root().join("config.json"));
|
||||||
|
let data_dir = args.data_dir.unwrap_or_else(|| repo_root().join("sessions"));
|
||||||
|
let manager = Arc::new(
|
||||||
|
SessionManager::new(config_path.clone(), data_dir)
|
||||||
|
.with_context(|| format!("failed to load {}", config_path.display()))?,
|
||||||
|
);
|
||||||
|
tracing::info!("config: {}", config_path.display());
|
||||||
|
for info in manager.sessions() {
|
||||||
|
tracing::info!(" session {} ({:?}, {:?})", info.id, info.kind, info.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bind_ip = match args.bind {
|
||||||
|
Some(ip) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"binding {ip} by explicit --bind override -- production binds {WG_INTERFACE} only"
|
||||||
|
);
|
||||||
|
ip
|
||||||
|
}
|
||||||
|
None => wg_address()?,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Token bootstrap: first run generates one; --rotate-token replaces
|
||||||
|
// whatever exists. Either way the plaintext appears exactly once, in
|
||||||
|
// the QR printed here.
|
||||||
|
if args.rotate_token || manager.tokens().is_empty() {
|
||||||
|
let rotating = args.rotate_token && !manager.tokens().is_empty();
|
||||||
|
let token = auth::generate_token();
|
||||||
|
manager.set_tokens(vec![TokenEntry {
|
||||||
|
name: "phone".to_string(),
|
||||||
|
sha256: auth::token_hash_hex(&token),
|
||||||
|
}])?;
|
||||||
|
if rotating {
|
||||||
|
tracing::info!("rotated the enrolled token; the previous one is now invalid");
|
||||||
|
}
|
||||||
|
print_enrollment(bind_ip, args.port, &token)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let certs_dir = args.certs.unwrap_or_else(|| repo_root().join("certs"));
|
||||||
|
let leaf_cert = certs_dir.join("leaf.pem");
|
||||||
|
let leaf_key = certs_dir.join("leaf-key.pem");
|
||||||
|
if !leaf_cert.is_file() || !leaf_key.is_file() {
|
||||||
|
bail!(
|
||||||
|
"missing {} / {} -- run ./gen-dev-cert.sh first (the app pins the CA it generates, \
|
||||||
|
and this server refuses to serve without TLS)",
|
||||||
|
leaf_cert.display(),
|
||||||
|
leaf_key.display(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let tls_config = axum_server::tls_rustls::RustlsConfig::from_pem_file(&leaf_cert, &leaf_key)
|
||||||
|
.await
|
||||||
|
.context("failed to load TLS cert/key")?;
|
||||||
|
|
||||||
|
// The bearer-token middleware wraps the entire router -- routes and
|
||||||
|
// fallback alike -- here and only here, so a new route can't forget
|
||||||
|
// auth. Zero unauthenticated endpoints.
|
||||||
|
let app = routes::router(Arc::clone(&manager)).layer(axum::middleware::from_fn_with_state(
|
||||||
|
Arc::clone(&manager),
|
||||||
|
auth::require_token,
|
||||||
|
));
|
||||||
|
|
||||||
|
let addr = SocketAddr::new(bind_ip, args.port);
|
||||||
|
tracing::info!("serving https://{addr}");
|
||||||
|
axum_server::bind_rustls(addr, tls_config)
|
||||||
|
.serve(app.into_make_service_with_connect_info::<SocketAddr>())
|
||||||
|
.await
|
||||||
|
.context("TLS listener failed")?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
//! The HTTP surface -- REST for actions, one SSE stream per open session
|
||||||
|
//! screen for events, all behind the bearer-token middleware `main.rs`
|
||||||
|
//! wraps the whole router in.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! GET /sessions list (id, kind, title, host, model, status, last activity)
|
||||||
|
//! POST /sessions spawn {kind, title?, host?, model?, cwd?, permissionMode?}
|
||||||
|
//! GET /sessions/{id}/events?after=N SSE: transcript replay from N, then live
|
||||||
|
//! POST /sessions/{id}/message {text, attachmentIds?}
|
||||||
|
//! POST /sessions/{id}/answer {questionId, answer} (questions and permissions)
|
||||||
|
//! POST /sessions/{id}/interrupt
|
||||||
|
//! POST /sessions/{id}/model {model}
|
||||||
|
//! POST /sessions/{id}/compact
|
||||||
|
//! DELETE /sessions/{id} kill process, delete transcript + files
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Later phases add: `POST /attachments`, `GET /files/{session}/{id}`,
|
||||||
|
//! `GET /usage`, `GET|PUT /hosts` and `/models` -- see PLAN.md's table.
|
||||||
|
//!
|
||||||
|
//! Everything here works purely in the common event model; nothing may
|
||||||
|
//! branch on the session kind (that's what drivers are for).
|
||||||
|
|
||||||
|
use std::convert::Infallible;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::Router;
|
||||||
|
use axum::extract::{Path as UrlPath, Query, State};
|
||||||
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
|
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::routing::{delete, get, post};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tokio::sync::{broadcast, mpsc};
|
||||||
|
use tokio_stream::StreamExt;
|
||||||
|
use tokio_stream::wrappers::ReceiverStream;
|
||||||
|
|
||||||
|
use crate::session::transcript::{SeqEvent, read_after};
|
||||||
|
use crate::session::{LiveSession, SessionInfo, SessionManager, SpawnSpec};
|
||||||
|
|
||||||
|
pub fn router(manager: Arc<SessionManager>) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/sessions", get(list_sessions).post(spawn_session))
|
||||||
|
.route("/sessions/{id}", delete(delete_session))
|
||||||
|
.route("/sessions/{id}/events", get(events))
|
||||||
|
.route("/sessions/{id}/message", post(message))
|
||||||
|
.route("/sessions/{id}/answer", post(answer))
|
||||||
|
.route("/sessions/{id}/interrupt", post(interrupt))
|
||||||
|
.route("/sessions/{id}/model", post(set_model))
|
||||||
|
.route("/sessions/{id}/compact", post(compact))
|
||||||
|
// An explicit fallback so the auth middleware (layered around the
|
||||||
|
// whole router in main.rs) also covers unknown paths -- a scanner
|
||||||
|
// gets the same 401 everywhere, never a route map.
|
||||||
|
.fallback(|| async { ApiError::UnknownRoute })
|
||||||
|
.with_state(manager)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
enum ApiError {
|
||||||
|
#[error("no session {0}")]
|
||||||
|
UnknownSession(String),
|
||||||
|
#[error("no such route")]
|
||||||
|
UnknownRoute,
|
||||||
|
#[error("{0}")]
|
||||||
|
BadRequest(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for ApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let status = match self {
|
||||||
|
Self::UnknownSession(_) | Self::UnknownRoute => StatusCode::NOT_FOUND,
|
||||||
|
Self::BadRequest(_) => StatusCode::BAD_REQUEST,
|
||||||
|
};
|
||||||
|
(status, self.to_string()).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An `anyhow` error from a session mutation is a message written *for*
|
||||||
|
/// the phone ("no session abc123") -- not an internal fault, so it comes
|
||||||
|
/// back as a 400 with that message rather than a 500 and a log line.
|
||||||
|
fn bad_request(err: anyhow::Error) -> ApiError {
|
||||||
|
ApiError::BadRequest(format!("{err:#}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lookup(manager: &SessionManager, id: &str) -> Result<Arc<LiveSession>, ApiError> {
|
||||||
|
manager.session(id).ok_or_else(|| ApiError::UnknownSession(id.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_sessions(State(manager): State<Arc<SessionManager>>) -> axum::Json<Vec<SessionInfo>> {
|
||||||
|
axum::Json(manager.sessions())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct SpawnRequest {
|
||||||
|
kind: crate::config::SessionKind,
|
||||||
|
#[serde(default)]
|
||||||
|
title: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
host: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
model: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
cwd: Option<PathBuf>,
|
||||||
|
#[serde(default)]
|
||||||
|
permission_mode: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn spawn_session(
|
||||||
|
State(manager): State<Arc<SessionManager>>,
|
||||||
|
axum::Json(body): axum::Json<SpawnRequest>,
|
||||||
|
) -> Result<axum::Json<SessionInfo>, ApiError> {
|
||||||
|
let info = manager
|
||||||
|
.spawn_session(SpawnSpec {
|
||||||
|
kind: body.kind,
|
||||||
|
title: body.title,
|
||||||
|
host: body.host,
|
||||||
|
model: body.model,
|
||||||
|
cwd: body.cwd,
|
||||||
|
permission_mode: body.permission_mode,
|
||||||
|
})
|
||||||
|
.map_err(bad_request)?;
|
||||||
|
tracing::info!("spawned {:?} session {} ({})", info.kind, info.id, info.title);
|
||||||
|
Ok(axum::Json(info))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_session(
|
||||||
|
State(manager): State<Arc<SessionManager>>,
|
||||||
|
UrlPath(id): UrlPath<String>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
manager.delete_session(&id).map_err(bad_request)?;
|
||||||
|
tracing::info!("deleted session {id}");
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct MessageRequest {
|
||||||
|
text: String,
|
||||||
|
/// Ids from `POST /attachments` (phase 2); accepted now so the request
|
||||||
|
/// shape doesn't change under the app.
|
||||||
|
#[serde(default)]
|
||||||
|
attachment_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn message(
|
||||||
|
State(manager): State<Arc<SessionManager>>,
|
||||||
|
UrlPath(id): UrlPath<String>,
|
||||||
|
axum::Json(body): axum::Json<MessageRequest>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
let session = lookup(&manager, &id)?;
|
||||||
|
if body.text.trim().is_empty() && body.attachment_ids.is_empty() {
|
||||||
|
return Err(ApiError::BadRequest("message is empty".to_string()));
|
||||||
|
}
|
||||||
|
session.send_message(body.text, body.attachment_ids);
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct AnswerRequest {
|
||||||
|
question_id: String,
|
||||||
|
answer: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn answer(
|
||||||
|
State(manager): State<Arc<SessionManager>>,
|
||||||
|
UrlPath(id): UrlPath<String>,
|
||||||
|
axum::Json(body): axum::Json<AnswerRequest>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
lookup(&manager, &id)?.answer_question(&body.question_id, &body.answer);
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn interrupt(
|
||||||
|
State(manager): State<Arc<SessionManager>>,
|
||||||
|
UrlPath(id): UrlPath<String>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
lookup(&manager, &id)?.interrupt();
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ModelRequest {
|
||||||
|
model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What happens is the driver's call -- a driver that can't switch in
|
||||||
|
/// place reports how it handled it (or that it can't) as events.
|
||||||
|
async fn set_model(
|
||||||
|
State(manager): State<Arc<SessionManager>>,
|
||||||
|
UrlPath(id): UrlPath<String>,
|
||||||
|
axum::Json(body): axum::Json<ModelRequest>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
lookup(&manager, &id)?.set_model(&body.model);
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn compact(
|
||||||
|
State(manager): State<Arc<SessionManager>>,
|
||||||
|
UrlPath(id): UrlPath<String>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
lookup(&manager, &id)?.compact();
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct EventsQuery {
|
||||||
|
#[serde(default)]
|
||||||
|
after: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The session screen's one data source: replay everything after the
|
||||||
|
/// cursor from the transcript, then live events as they happen. An SSE
|
||||||
|
/// auto-reconnect sends the last event id it saw as `Last-Event-ID`, which
|
||||||
|
/// takes precedence over `after` -- same cursor, native mechanism.
|
||||||
|
async fn events(
|
||||||
|
State(manager): State<Arc<SessionManager>>,
|
||||||
|
UrlPath(id): UrlPath<String>,
|
||||||
|
Query(query): Query<EventsQuery>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Sse<impl tokio_stream::Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
|
||||||
|
let session = lookup(&manager, &id)?;
|
||||||
|
let cursor = headers
|
||||||
|
.get("last-event-id")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.and_then(|value| value.parse().ok())
|
||||||
|
.unwrap_or(query.after);
|
||||||
|
|
||||||
|
// Subscribe before reading the file so nothing can land in the gap
|
||||||
|
// between replay and live; overlap is deduplicated by seq.
|
||||||
|
let live = session.subscribe();
|
||||||
|
let (tx, stream) = mpsc::channel(64);
|
||||||
|
tokio::spawn(stream_session(
|
||||||
|
session.transcript_path().to_path_buf(),
|
||||||
|
cursor,
|
||||||
|
live,
|
||||||
|
tx,
|
||||||
|
));
|
||||||
|
Ok(Sse::new(ReceiverStream::new(stream).map(Ok)).keep_alive(KeepAlive::default()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feeds one SSE subscriber: transcript replay after the cursor, then live
|
||||||
|
/// events, catching back up from the file whenever the broadcast channel
|
||||||
|
/// laps us. Ends when the client disconnects (send fails) or the session
|
||||||
|
/// is deleted (channel closed).
|
||||||
|
async fn stream_session(
|
||||||
|
transcript: PathBuf,
|
||||||
|
mut last: u64,
|
||||||
|
mut live: broadcast::Receiver<SeqEvent>,
|
||||||
|
tx: mpsc::Sender<SseEvent>,
|
||||||
|
) {
|
||||||
|
// Synchronous file reads from an async task: transcript lines are
|
||||||
|
// small and local; revisit if daily use produces transcripts where
|
||||||
|
// this shows (phase 6 territory).
|
||||||
|
let catch_up = |after: u64| match read_after(&transcript, after) {
|
||||||
|
Ok(entries) => Some(entries),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!("transcript replay failed: {err:#}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(replay) = catch_up(last) else { return };
|
||||||
|
for entry in replay {
|
||||||
|
last = entry.seq;
|
||||||
|
if send_event(&tx, &entry).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
match live.recv().await {
|
||||||
|
Ok(entry) => {
|
||||||
|
if entry.seq <= last {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
last = entry.seq;
|
||||||
|
if send_event(&tx, &entry).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(broadcast::error::RecvError::Lagged(_)) => {
|
||||||
|
let Some(missed) = catch_up(last) else { return };
|
||||||
|
for entry in missed {
|
||||||
|
last = entry.seq;
|
||||||
|
if send_event(&tx, &entry).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(broadcast::error::RecvError::Closed) => return,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_event(
|
||||||
|
tx: &mpsc::Sender<SseEvent>,
|
||||||
|
entry: &SeqEvent,
|
||||||
|
) -> Result<(), mpsc::error::SendError<SseEvent>> {
|
||||||
|
let data = serde_json::to_string(entry).expect("events always serialize");
|
||||||
|
tx.send(SseEvent::default().id(entry.seq.to_string()).data(data)).await
|
||||||
|
}
|
||||||
@@ -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