Prune commentary and stale Rust port notes
This commit is contained in:
1 parent
5428cd75c9
commit
25370731d0
193 files changed
+693
-16219
No files matched your search
@@ -1,25 +1,3 @@
|
||||
//! What a tool printed, with its terminal styling applied and everything
|
||||
//! else taken out. Ported from `app/.../Ansi.kt`, module for module: the
|
||||
//! Kotlin version builds a Compose `AnnotatedString`, which does not exist
|
||||
//! here, so a [`StyledText`] of plain text plus non-overlapping
|
||||
//! `(Range, Style)` spans stands in for it -- a future UI layer maps
|
||||
//! [`Style`] onto whatever it draws with.
|
||||
//!
|
||||
//! Bash output arrives exactly as the program wrote it, escape sequences
|
||||
//! included, and drawn verbatim those are line noise in the middle of the
|
||||
//! thing being read. Stripping them all would be the other half-answer --
|
||||
//! colour is often the whole of what a diff or a test run is saying.
|
||||
//!
|
||||
//! So the sequences that decide how text *looks* become spans, and every
|
||||
//! other one is dropped rather than shown: the rest move a cursor around a
|
||||
//! grid this is not, and "go to column 40" has no meaning in a scrolling
|
||||
//! document.
|
||||
//!
|
||||
//! A carriage return is honoured the way a terminal honours it: what was
|
||||
//! written since the last line break is thrown away and the line starts
|
||||
//! again. That is what makes a progress bar show its final state rather
|
||||
//! than every state it passed through.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
/// An RGB colour, the same shape wherever this crate names one -- no alpha,
|
||||
@@ -38,18 +16,10 @@ impl Rgb {
|
||||
}
|
||||
}
|
||||
|
||||
/// The sixteen colours a terminal program names, and the two it assumes.
|
||||
///
|
||||
/// Its own palette rather than the syntax one: a program that prints in red
|
||||
/// has chosen red, where a highlighter's colours are this app's reading of
|
||||
/// somebody else's code.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AnsiPalette {
|
||||
/// Indexes 0-7, then 8-15 bright, in the terminal's own order.
|
||||
pub colours: [Rgb; 16],
|
||||
/// What uncoloured text is, needed only where a style has to state a colour.
|
||||
pub foreground: Rgb,
|
||||
/// What the text sits on, needed for reverse video.
|
||||
pub background: Rgb,
|
||||
}
|
||||
|
||||
@@ -67,8 +37,6 @@ pub struct Style {
|
||||
pub strikethrough: bool,
|
||||
}
|
||||
|
||||
/// Plain text plus the non-overlapping, ordered spans that style parts of it
|
||||
/// -- this crate's stand-in for Compose's `AnnotatedString`.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct StyledText {
|
||||
pub text: String,
|
||||
@@ -87,11 +55,7 @@ impl StyledText {
|
||||
const ESC: char = '\u{1B}';
|
||||
const BELL: char = '\u{7}';
|
||||
|
||||
/// [text] with its terminal styling applied and everything else taken out;
|
||||
/// see the module doc.
|
||||
pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText {
|
||||
// The common case by a long way -- nothing to do, and nothing allocated
|
||||
// to find that out.
|
||||
if !text.contains(ESC) && !text.contains('\r') {
|
||||
return StyledText::plain(text.to_string());
|
||||
}
|
||||
@@ -118,19 +82,12 @@ pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText {
|
||||
}
|
||||
});
|
||||
} else if c == '\r' && chars.get(at + 1) != Some(&'\n') {
|
||||
// A bare carriage return rewrites the line. One before a newline
|
||||
// is the other half of a Windows line ending: it rewrites
|
||||
// nothing, and it is dropped rather than kept, since that pair
|
||||
// is one line break.
|
||||
flush(&mut plain, sgr, &mut runs);
|
||||
drop_line(&mut runs);
|
||||
at += 1;
|
||||
} else if c == '\r' {
|
||||
at += 1;
|
||||
} else if c >= ' ' || c == '\n' || c == '\t' {
|
||||
// Everything printable, plus the two control characters that are
|
||||
// layout rather than terminal commands. A stray bell or
|
||||
// backspace goes for the same reason a cursor move does.
|
||||
plain.push(c);
|
||||
at += 1;
|
||||
} else {
|
||||
@@ -151,8 +108,6 @@ pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText {
|
||||
StyledText { text: out, spans }
|
||||
}
|
||||
|
||||
/// Throws away everything written since the last line break, as a carriage
|
||||
/// return does.
|
||||
fn drop_line(runs: &mut Vec<(String, Option<Style>)>) {
|
||||
while let Some((text, style)) = runs.pop() {
|
||||
if let Some(break_at) = text.rfind('\n') {
|
||||
@@ -162,7 +117,6 @@ fn drop_line(runs: &mut Vec<(String, Option<Style>)>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes that end a CSI sequence.
|
||||
fn is_csi_final(c: char) -> bool {
|
||||
('@'..='~').contains(&c)
|
||||
}
|
||||
@@ -184,10 +138,6 @@ fn skip_escape(chars: &[char], at: usize, mut on_csi: impl FnMut(&str, char)) ->
|
||||
end += 1;
|
||||
}
|
||||
if end >= chars.len() {
|
||||
// Cut off mid-sequence, which is what a stream that has not
|
||||
// finished arriving looks like: drop the fragment rather
|
||||
// than printing it, and the whole sequence arrives with the
|
||||
// next delta.
|
||||
chars.len()
|
||||
} else {
|
||||
let params: String = chars[at + 2..end].iter().collect();
|
||||
@@ -196,8 +146,6 @@ fn skip_escape(chars: &[char], at: usize, mut on_csi: impl FnMut(&str, char)) ->
|
||||
}
|
||||
}
|
||||
']' | 'P' | 'X' | '^' | '_' => {
|
||||
// Runs to a string terminator: `ESC \`, or the bell that xterm
|
||||
// allows after an OSC.
|
||||
let mut end = at + 2;
|
||||
while end < chars.len() {
|
||||
if chars[end] == BELL {
|
||||
@@ -214,7 +162,6 @@ fn skip_escape(chars: &[char], at: usize, mut on_csi: impl FnMut(&str, char)) ->
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything an SGR sequence can turn on, as the terminal tracks it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
struct Sgr {
|
||||
fg: Option<Rgb>,
|
||||
@@ -227,7 +174,6 @@ struct Sgr {
|
||||
reverse: bool,
|
||||
}
|
||||
|
||||
/// How much of its colour dim text keeps: enough to read, little enough to recede.
|
||||
const DIM_ALPHA: f32 = 0.65;
|
||||
|
||||
impl Sgr {
|
||||
@@ -242,7 +188,6 @@ impl Sgr {
|
||||
reverse: false,
|
||||
};
|
||||
|
||||
/// `None` while nothing is set, so unstyled output costs no spans at all.
|
||||
fn span(&self, palette: &AnsiPalette) -> Option<Style> {
|
||||
if *self == Sgr::PLAIN {
|
||||
return None;
|
||||
@@ -275,15 +220,7 @@ impl Sgr {
|
||||
})
|
||||
}
|
||||
|
||||
/// This state with `params` applied -- one `ESC[...m`, which carries any
|
||||
/// number of them.
|
||||
///
|
||||
/// A code this does not model is ignored rather than reset from: the
|
||||
/// program meant something by it, and starting again would also drop
|
||||
/// the codes beside it that are understood.
|
||||
fn apply(&self, params: &str, palette: &AnsiPalette) -> Sgr {
|
||||
// `ESC[m` means `ESC[0m`, and an empty parameter inside a list is a
|
||||
// zero too.
|
||||
let codes: Vec<i64> = params
|
||||
.split(';')
|
||||
.map(|p| p.trim().parse::<i64>().unwrap_or(0))
|
||||
@@ -377,12 +314,6 @@ impl Sgr {
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour named by a `38`/`48` at `at`, and the index of that colour's
|
||||
/// last parameter.
|
||||
///
|
||||
/// Two forms: `5;n` for the 256-colour table and `2;r;g;b` for a literal
|
||||
/// one. The first sixteen of that table are the palette's own, so a program
|
||||
/// asking for "colour 1" through either spelling gets the same red.
|
||||
fn extended_colour(codes: &[i64], at: usize, palette: &AnsiPalette) -> (Option<Rgb>, usize) {
|
||||
match codes.get(at + 1) {
|
||||
Some(&5) => match codes.get(at + 2) {
|
||||
@@ -409,11 +340,8 @@ fn extended_colour(codes: &[i64], at: usize, palette: &AnsiPalette) -> (Option<R
|
||||
}
|
||||
}
|
||||
|
||||
/// The six levels of each channel in the 256-colour cube, as xterm defines them.
|
||||
const CUBE: [u8; 6] = [0, 95, 135, 175, 215, 255];
|
||||
|
||||
/// One of the 256 colours: the palette's sixteen, then a 6x6x6 cube, then a
|
||||
/// grey ramp.
|
||||
fn indexed_colour(n: i64, palette: &AnsiPalette) -> Rgb {
|
||||
if n < 0 {
|
||||
palette.foreground
|
||||
@@ -434,8 +362,6 @@ fn indexed_colour(n: i64, palette: &AnsiPalette) -> Rgb {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A palette matching the Kotlin test's: `colours[i] = Rgb(i, 0, 0)`,
|
||||
/// white foreground, black background.
|
||||
fn palette() -> AnsiPalette {
|
||||
let mut colours = [Rgb::new(0, 0, 0); 16];
|
||||
for (i, c) in colours.iter_mut().enumerate() {
|
||||
@@ -452,8 +378,6 @@ mod tests {
|
||||
ansi_styled(text, &palette())
|
||||
}
|
||||
|
||||
/// The style covering the first character of `word`, or `None` where
|
||||
/// nothing styles it.
|
||||
fn style_over(text: &str, word: &str) -> Option<Style> {
|
||||
let out = styled(text);
|
||||
let at = out
|
||||
@@ -509,8 +433,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn everything_that_is_not_styling_is_dropped_rather_than_printed() {
|
||||
// A cursor move, an erase, an OSC window title with its bell, and a
|
||||
// bare two-character escape.
|
||||
let text = format!("a{ESC}[2Jb{ESC}[Kc{ESC}]0;a title{BELL}d{ESC}=e");
|
||||
assert_eq!(styled(&text).text, "abcde");
|
||||
}
|
||||
|
||||
@@ -1,22 +1,9 @@
|
||||
//! The REST half of the backend's surface (see `server/src/routes.rs`'s
|
||||
//! module doc for the table); the SSE half is [`crate::client::event_stream`].
|
||||
//! Ported from `app/.../Api.kt`, but **not at full parity yet** -- see
|
||||
//! `CLIENT_CORE.md` for exactly which routes have a typed method here and
|
||||
//! which do not.
|
||||
//!
|
||||
//! Network I/O sits behind the [`Transport`] trait so the rest of this
|
||||
//! crate, and anything built on it, can be tested against a fake one with
|
||||
//! no server involved. [`UreqTransport`] is the only real implementation.
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use event_model::SeqEvent;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
/// A request that did not produce what it asked for, carrying the server's
|
||||
/// own wording where it sent some.
|
||||
///
|
||||
/// `status` is the HTTP status where there was a response at all, and
|
||||
/// `None` where the server was never reached -- mirroring `ApiException` in
|
||||
/// `Api.kt`.
|
||||
@@ -33,8 +20,6 @@ impl std::fmt::Display for ApiError {
|
||||
}
|
||||
impl std::error::Error for ApiError {}
|
||||
|
||||
/// A request body to send, in whichever of the two shapes the surface
|
||||
/// takes: `Api.kt`'s `jsonBody` and `streamBody`.
|
||||
pub enum Body {
|
||||
Json(Value),
|
||||
Bytes {
|
||||
@@ -51,10 +36,7 @@ pub struct RawResponse {
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The network boundary this crate's pure logic is kept out from behind.
|
||||
/// `server/src/routes.rs`'s module doc is the surface this drives.
|
||||
pub trait Transport: Send + Sync {
|
||||
/// One request/response call -- everything but the long-lived SSE GETs.
|
||||
fn request(
|
||||
&self,
|
||||
method: &str,
|
||||
@@ -62,10 +44,6 @@ pub trait Transport: Send + Sync {
|
||||
body: Option<Body>,
|
||||
) -> Result<RawResponse, ApiError>;
|
||||
|
||||
/// Opens `path` and answers a reader over the response body, for a
|
||||
/// caller that reads it as a stream rather than all at once (the SSE
|
||||
/// connections in [`crate::client::event_stream`]). Fails the same way
|
||||
/// [`Transport::request`] does for a non-2xx response.
|
||||
fn stream(&self, path: &str) -> Result<Box<dyn Read + Send>, ApiError>;
|
||||
}
|
||||
|
||||
@@ -104,10 +82,6 @@ fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// A client-core equivalent of `requestFromServer` plus the typed calls
|
||||
/// built on it. Holds no state of its own beyond the transport -- the
|
||||
/// session id or setup id a call is about is a parameter, per this
|
||||
/// project's "ask for the least you need".
|
||||
pub struct ApiClient<T: Transport> {
|
||||
transport: T,
|
||||
}
|
||||
@@ -282,19 +256,6 @@ impl<T: Transport> ApiClient<T> {
|
||||
)
|
||||
}
|
||||
|
||||
/// A page of transcript history, each line handed back paired with the
|
||||
/// exact text it came from, and bounded below by `after` -- the shape
|
||||
/// `crate::client::transcript_source::TranscriptSource` needs to store what it
|
||||
/// fetched in the transcript cache without a second round trip to fetch
|
||||
/// the raw text separately. Ported from `Api.kt`'s `fetchTranscript`.
|
||||
///
|
||||
/// Uses [`serde_json::value::RawValue`] rather than re-serializing a
|
||||
/// parsed [`Value`], so the stored line is the exact bytes the server
|
||||
/// sent (key order and float literal included) rather than this
|
||||
/// crate's own idea of how to write them back out -- the cache and a
|
||||
/// live SSE frame must agree byte-for-byte on the same event, which is
|
||||
/// exactly what caught the `serde_json` float-rounding bug this
|
||||
/// project's `AGENTS.md` records.
|
||||
pub fn fetch_transcript_lines(
|
||||
&self,
|
||||
session_id: &str,
|
||||
@@ -320,9 +281,6 @@ impl<T: Transport> ApiClient<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The query string shared by [`ApiClient::fetch_transcript_page`] and
|
||||
/// [`ApiClient::fetch_transcript_lines`], so the two agree on how each
|
||||
/// parameter is written rather than keeping two copies to drift.
|
||||
fn transcript_path(
|
||||
session_id: &str,
|
||||
before: Option<u64>,
|
||||
@@ -343,12 +301,6 @@ fn transcript_path(
|
||||
path
|
||||
}
|
||||
|
||||
/// The blocking [`Transport`] backed by `ureq`, the same crate `server/`
|
||||
/// already depends on for its own outbound HTTPS (`usage.rs`'s Anthropic
|
||||
/// poll). Verifies the server's leaf against a single pinned CA, the way
|
||||
/// `ServerConfig.kt`'s `applyPinnedTls` does, rather than the system trust
|
||||
/// store -- the server's certificate is self-signed on purpose (see
|
||||
/// `wg-app-link`).
|
||||
pub struct UreqTransport {
|
||||
agent: ureq::Agent,
|
||||
base_url: String,
|
||||
@@ -356,8 +308,6 @@ pub struct UreqTransport {
|
||||
}
|
||||
|
||||
impl UreqTransport {
|
||||
/// `ca_pem` is the CA certificate `wg-app-link`'s `enroll` minted,
|
||||
/// exactly as read from `certs/ca.pem`.
|
||||
pub fn new(
|
||||
base_url: impl Into<String>,
|
||||
token: impl Into<String>,
|
||||
@@ -372,10 +322,6 @@ impl UreqTransport {
|
||||
.build();
|
||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||
.tls_config(tls_config)
|
||||
// Read the body ourselves on every status, the way
|
||||
// `requestFromServer` does: the server's own error wording is
|
||||
// in the body of a 4xx/5xx, and the default behaviour throws
|
||||
// it away before this code can read it.
|
||||
.http_status_as_error(false)
|
||||
.timeout_connect(Some(std::time::Duration::from_secs(5)))
|
||||
.build()
|
||||
@@ -453,9 +399,6 @@ impl Transport for UreqTransport {
|
||||
.get(&url)
|
||||
.header("Authorization", &auth)
|
||||
.header("Accept", "text/event-stream")
|
||||
// No read timeout: between events there is nothing to read for
|
||||
// as long as the thing being followed is idle, mirroring
|
||||
// `EventStream.kt`'s `readTimeout = 0`.
|
||||
.config()
|
||||
.timeout_recv_response(None)
|
||||
.build()
|
||||
@@ -481,9 +424,6 @@ fn transport_error(base_url: &str, path: &str, e: ureq::Error) -> ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
/// The 401 wording matches `Api.kt`'s, since that message is instructions
|
||||
/// for the reader rather than a diagnostic -- see this project's UI rule
|
||||
/// about shortening a failure in one place rather than at each display site.
|
||||
fn response_error(status: u16, body: &[u8], path: &str) -> ApiError {
|
||||
let detail = String::from_utf8_lossy(body).trim().to_string();
|
||||
let message = if status == 401 {
|
||||
@@ -507,8 +447,6 @@ mod tests {
|
||||
use std::io::Cursor;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// A transport with no network at all, for the pure-logic tests this
|
||||
/// module can run without a server.
|
||||
#[derive(Default)]
|
||||
struct FakeTransport {
|
||||
responses: Mutex<Vec<(String, String, RawResponse)>>,
|
||||
@@ -569,7 +507,6 @@ mod tests {
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert_eq!(sessions[0].id, "s1");
|
||||
assert_eq!(sessions[0].setup_name, "desktop");
|
||||
// Defaults for fields the server omits.
|
||||
assert!(sessions[0].notify);
|
||||
assert_eq!(sessions[0].model, None);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,8 @@
|
||||
//! What a Rust client needs to reach one enrolled server: host, port and
|
||||
//! bearer token. Mirrors the shape `ServerConfig.kt`/`Api.kt`'s
|
||||
//! `handleEnrollment` parses out of an `aiapp://enroll?host=H&port=P&token=T`
|
||||
//! deep link -- the exact link `wg-app-link`'s `enroll` module mints and
|
||||
//! `app/ui-sandbox.sh`'s banner prints, so any Rust client can enrol from
|
||||
//! the same text a phone would scan as a QR, with no second format
|
||||
//! invented for it (RUST.md's E4).
|
||||
//!
|
||||
//! [`EnrollmentStore`] persists one of these as JSON, owner-only, in a
|
||||
//! directory the caller names -- `$XDG_CONFIG_HOME/ai-app-desktop` for the
|
||||
//! desktop app, the app-private files directory on Android. **Which**
|
||||
//! directory is the only part left to the platform: the format, the file
|
||||
//! mode and the "nothing saved yet is not an error" answer are the same on
|
||||
//! both, and were written twice before this.
|
||||
//!
|
||||
//! JSON rather than the project's usual RON: `wg-app-link`'s RON house
|
||||
//! rules (`format`) are for configs a person hand-edits, and this file
|
||||
//! never is one -- only the app itself writes or reads it.
|
||||
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// One enrolled server: reachable at `https://{host}:{port}`, authenticated
|
||||
/// with `token` as a bearer header.
|
||||
///
|
||||
/// `ca_pem` is the trust anchor to pin, when the link carried one (the
|
||||
/// `ca` parameter, `wg_app_link::enroll::ca_param`). It is optional
|
||||
/// because an app built on the machine its server runs on pins the CA at
|
||||
@@ -37,19 +15,11 @@ pub struct EnrolledServer {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub token: String,
|
||||
/// `#[serde(default)]` so an enrollment saved before this field
|
||||
/// existed still loads, as the enrolled server it always was.
|
||||
#[serde(default)]
|
||||
pub ca_pem: Option<String>,
|
||||
}
|
||||
|
||||
impl EnrolledServer {
|
||||
/// Parses `aiapp://enroll?host=H&port=P&token=T[&ca=B]` (query order
|
||||
/// does not matter; unrecognised keys are ignored). `token` is
|
||||
/// percent-decoded, since `ui-sandbox.sh` encodes it precisely because
|
||||
/// a raw token can contain `+`, which turns into a space if left to a
|
||||
/// naive splitter.
|
||||
///
|
||||
/// `ca` is base64url of the certificate's DER and is rebuilt into PEM
|
||||
/// here, because that is what every consumer of it wants
|
||||
/// (`UreqTransport::new`, and the file a person points `curl --cacert`
|
||||
@@ -99,13 +69,11 @@ impl EnrolledServer {
|
||||
})
|
||||
}
|
||||
|
||||
/// Where a `crate::client::api::UreqTransport` reaches this server.
|
||||
pub fn base_url(&self) -> String {
|
||||
format!("https://{}:{}", self.host, self.port)
|
||||
}
|
||||
}
|
||||
|
||||
/// The `ca` parameter (base64url of DER, unpadded) as a PEM certificate.
|
||||
fn pem_from_link_param(ca: &str) -> Result<String, String> {
|
||||
let der = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(ca.as_bytes())
|
||||
@@ -140,9 +108,6 @@ impl EnrollmentStore {
|
||||
self.dir.join("enrollment.json")
|
||||
}
|
||||
|
||||
/// Writes `server` under `dir`, creating it if needed, and sets the
|
||||
/// file owner-only -- it carries a bearer token, the same reason
|
||||
/// `server/`'s own token store is 0600.
|
||||
pub fn save(&self, server: &EnrolledServer) -> io::Result<()> {
|
||||
std::fs::create_dir_all(&self.dir)?;
|
||||
let path = self.file();
|
||||
@@ -157,10 +122,6 @@ impl EnrollmentStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `Ok(None)` when nothing has been enrolled yet, rather than an error
|
||||
/// -- "not enrolled" is an ordinary first-run state, not a failure
|
||||
/// (UI_RULES' "a deliberate choice is not a problem to report" applies
|
||||
/// just as well to a file that simply hasn't been written yet).
|
||||
pub fn load(&self) -> io::Result<Option<EnrolledServer>> {
|
||||
let path = self.file();
|
||||
match std::fs::read(&path) {
|
||||
@@ -232,8 +193,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_percent_encoded_token_is_decoded() {
|
||||
// ui-sandbox.sh's own reason for encoding: a raw '+' would
|
||||
// otherwise arrive as a space.
|
||||
let server =
|
||||
EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=a%2Bb%2Fc").unwrap();
|
||||
assert_eq!(server.token, "a+b/c");
|
||||
@@ -248,9 +207,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The CA travels as base64url of the DER and comes back out as the
|
||||
/// PEM every consumer of it wants -- the same round trip
|
||||
/// `wg_app_link::enroll::ca_param` mints.
|
||||
#[test]
|
||||
fn a_ca_in_the_link_comes_back_as_pem() {
|
||||
let der = [0x30u8, 0x82, 0x01, 0xfb, 0x3e, 0x7f];
|
||||
@@ -276,16 +232,12 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A link with no `ca` is an ordinary link, not a broken one: an app
|
||||
/// that pins at build time mints and reads exactly these.
|
||||
#[test]
|
||||
fn no_ca_parameter_is_none_not_an_error() {
|
||||
let server = EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=t").unwrap();
|
||||
assert_eq!(server.ca_pem, None);
|
||||
}
|
||||
|
||||
/// The half that cannot be noticed later: a `ca` that does not decode
|
||||
/// must fail the link rather than enrolling with nothing pinned.
|
||||
#[test]
|
||||
fn a_ca_that_does_not_decode_fails_the_link() {
|
||||
let err =
|
||||
@@ -314,7 +266,6 @@ mod tests {
|
||||
assert_eq!(EnrollmentStore::new(dir.path()).load().unwrap(), None);
|
||||
}
|
||||
|
||||
/// An enrollment written before `ca_pem` existed still loads.
|
||||
#[test]
|
||||
fn an_enrollment_without_a_ca_still_loads() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
//! A span of milliseconds, written the way somebody reads it -- the port
|
||||
//! of `Durations.kt`'s `formatMillis`/`formatMillisText`, with its tests.
|
||||
//!
|
||||
//! Only the tool-timeout half is here. `formatSpan` (the usage
|
||||
//! countdown's rounding-up rule) belongs with whatever draws the usage
|
||||
//! bar, and nothing in this crate needs it yet.
|
||||
|
||||
/// A span of milliseconds, written the way somebody reads it.
|
||||
///
|
||||
/// A tool's timeout arrives as `480000`, which nobody reads as eight
|
||||
/// minutes. The rule has two halves, because a short span and a long one
|
||||
/// are read for different things. Under a minute the question is "roughly
|
||||
@@ -14,9 +5,6 @@
|
||||
/// rest -- `2.5s`. At a minute or more the question is "how long exactly",
|
||||
/// so every unit with something in it is written out -- `5d 12h 4m`. Empty
|
||||
/// units are left out rather than written as zero.
|
||||
///
|
||||
/// Sub-second precision is dropped past a minute: nothing that takes days
|
||||
/// is measured in milliseconds.
|
||||
pub fn format_millis(ms: i64) -> String {
|
||||
if ms < 0 {
|
||||
return format!("-{}", format_millis(-ms));
|
||||
@@ -47,8 +35,6 @@ pub fn format_millis(ms: i64) -> String {
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// `text` as a span when it is a whole number of milliseconds, and
|
||||
/// unchanged when it is not.
|
||||
pub fn format_millis_text(text: &str) -> String {
|
||||
match text.trim().parse::<i64>() {
|
||||
Ok(ms) => format_millis(ms),
|
||||
@@ -60,40 +46,28 @@ pub fn format_millis_text(text: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The two ways a span of time is written here, and the rule each of
|
||||
/// them follows -- ported from `DurationsTest.kt`, whose doc says why:
|
||||
/// both are read off a screen to make a decision, so what matters is
|
||||
/// that the shortest form that answers the question is what appears.
|
||||
#[test]
|
||||
fn under_a_minute_is_the_largest_unit_alone() {
|
||||
assert_eq!(format_millis(30), "30ms");
|
||||
assert_eq!(format_millis(999), "999ms");
|
||||
assert_eq!(format_millis(1000), "1s");
|
||||
assert_eq!(format_millis(2500), "2.5s");
|
||||
// One decimal, rounded rather than cut: 2.46s is nearer two and a
|
||||
// half than two and four.
|
||||
assert_eq!(format_millis(2460), "2.5s");
|
||||
assert_eq!(format_millis(59_900), "59.9s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_minute_or_more_is_every_unit_that_has_something_in_it() {
|
||||
// The figure this rule was written for: a tool timeout, which
|
||||
// arrives as milliseconds and is unreadable as 480000.
|
||||
assert_eq!(format_millis(480_000), "8m");
|
||||
assert_eq!(format_millis(60_000), "1m");
|
||||
assert_eq!(format_millis(90_000), "1m 30s");
|
||||
assert_eq!(format_millis(475_440_000), "5d 12h 4m");
|
||||
// Empty units are left out rather than written as zero: the labels
|
||||
// say which is which, and "5d 0h 4m" is only longer.
|
||||
assert_eq!(format_millis(432_240_000), "5d 4m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_whole_number_of_milliseconds_is_rewritten() {
|
||||
assert_eq!(format_millis_text(" 480000 "), "8m");
|
||||
// A timeout a tool expressed some other way is its own words,
|
||||
// passed through rather than guessed at.
|
||||
assert_eq!(format_millis_text("2 minutes"), "2 minutes");
|
||||
assert_eq!(format_millis_text(""), "");
|
||||
}
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
//! The SSE half of the API: one long-lived GET per open session screen,
|
||||
//! replaying the transcript after a cursor and then following it live.
|
||||
//! Ported from `app/.../EventStream.kt`; the framing itself is
|
||||
//! [`crate::client::sse`].
|
||||
|
||||
use std::io::{BufRead, BufReader};
|
||||
|
||||
use event_model::SeqEvent;
|
||||
@@ -19,19 +14,8 @@ const RESET_EVENT: &str = "reset";
|
||||
/// callbacks were for, as a single enum instead, since Rust has no
|
||||
/// equivalent of handing three closures to one blocking call.
|
||||
pub enum StreamItem {
|
||||
/// The connection was accepted; the measured moment the stream is live
|
||||
/// (see `EventStream.kt`'s doc on `onOpen` for why this, not the first
|
||||
/// event, is what clears a previous failure on screen).
|
||||
Open,
|
||||
/// The cursor was too far behind to continue from: everything already
|
||||
/// displayed is stale, and the events that follow are a fresh window.
|
||||
/// Arrives before those events, so a caller that clears on it stays in
|
||||
/// order.
|
||||
Reset,
|
||||
/// One event, as both the raw line the transcript cache stores and the
|
||||
/// parsed [`SeqEvent`] the fold works from -- they have to be the same
|
||||
/// line, so both travel together rather than being parsed twice from
|
||||
/// two call sites.
|
||||
Event { raw: String, event: SeqEvent },
|
||||
}
|
||||
|
||||
@@ -59,7 +43,6 @@ pub fn follow_session_events(
|
||||
let Some(frame) = reader.feed_line(&line) else {
|
||||
continue;
|
||||
};
|
||||
// A named frame carries no payload and a data frame has no name.
|
||||
if frame.name.as_deref() == Some(RESET_EVENT) {
|
||||
if !on_item(StreamItem::Reset) {
|
||||
return Ok(());
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
//! A language the highlighter can colour, and the data-driven [`Rules`] each
|
||||
//! one scans by. Ported from `app/.../Languages.kt`; see that file's doc for
|
||||
//! why nearly every language is a row of data read by one shared scanner,
|
||||
//! with Markdown the one exception (`super::markdown`).
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
@@ -32,8 +27,6 @@ pub enum Language {
|
||||
}
|
||||
|
||||
impl Language {
|
||||
/// Every value, for the same exhaustiveness check the Kotlin test runs
|
||||
/// (`Language.entries`).
|
||||
pub const ALL: [Language; 22] = [
|
||||
Language::C,
|
||||
Language::Coffeescript,
|
||||
@@ -60,24 +53,14 @@ impl Language {
|
||||
];
|
||||
}
|
||||
|
||||
/// What [`super::scan`] needs to know about one language -- data, not code,
|
||||
/// so that adding a language is a row here rather than a branch anywhere.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Rules {
|
||||
/// Words drawn as keywords. Only plain words; the scanner cannot reach
|
||||
/// anything else.
|
||||
pub keywords: HashSet<&'static str>,
|
||||
/// Tokens that open a comment running to the end of the line.
|
||||
pub line_comments: Vec<&'static str>,
|
||||
/// Whether `line_comments` count only at the start of a word. The shells
|
||||
/// need it: `$#`, `${#x}` and `a#b` are not comments.
|
||||
pub line_comments_at_word_start: bool,
|
||||
pub block_comment: Option<BlockComment>,
|
||||
/// The string forms. The longest opener that matches wins, so `"""` is
|
||||
/// tried before `"`.
|
||||
pub quotes: Vec<Quote>,
|
||||
pub attributes: Attributes,
|
||||
/// Rust and RON: an optional `b`, `r`, n hashes, `"`, closing at `"` and n hashes.
|
||||
pub raw_strings: bool,
|
||||
/// Rust: `'` opens a character literal only when a backslash or one
|
||||
/// character and a `'` follow. Otherwise it is a lifetime or a label.
|
||||
@@ -91,8 +74,6 @@ pub struct BlockComment {
|
||||
pub nests: bool,
|
||||
}
|
||||
|
||||
/// One string form. `escapes` is whether a backslash escapes the closer
|
||||
/// (and itself).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Quote {
|
||||
pub open: &'static str,
|
||||
@@ -100,18 +81,13 @@ pub struct Quote {
|
||||
pub escapes: bool,
|
||||
}
|
||||
|
||||
/// What opens a metadata span, of the shapes that exist across these languages.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub enum Attributes {
|
||||
#[default]
|
||||
None,
|
||||
/// `@` and a word: Kotlin and Java annotations, Python decorators.
|
||||
AtWord,
|
||||
/// `#[` or `#![` through the matching `]`: Rust and RON attributes.
|
||||
HashBracket,
|
||||
/// `#` at the start of a line, to the end of it: the C preprocessor.
|
||||
HashLine,
|
||||
/// `[` at the start of a line through the matching `]`: a TOML table header.
|
||||
LineBracket,
|
||||
}
|
||||
|
||||
@@ -151,10 +127,6 @@ fn words(list: &'static str) -> HashSet<&'static str> {
|
||||
list.split_whitespace().collect()
|
||||
}
|
||||
|
||||
/// The rules for one language. A `match` rather than a lazily-built map --
|
||||
/// there is no once-per-process cost worth paying for in a language table
|
||||
/// this small, and it sidesteps the Kotlin version's own workaround for
|
||||
/// property initialization order.
|
||||
pub fn rules_for(language: Language) -> Rules {
|
||||
match language {
|
||||
Language::C => Rules {
|
||||
@@ -180,8 +152,6 @@ pub fn rules_for(language: Language) -> Rules {
|
||||
quotes: vec![DOUBLE, SINGLE],
|
||||
..Default::default()
|
||||
},
|
||||
// `###` opens and closes a block comment and `#` opens a line one,
|
||||
// which is why the scanner tries the block opener first.
|
||||
Language::Coffeescript => Rules {
|
||||
keywords: words(KEYWORDS_COFFEESCRIPT),
|
||||
line_comments: vec!["#"],
|
||||
@@ -318,7 +288,6 @@ pub fn rules_for(language: Language) -> Rules {
|
||||
keywords: words(KEYWORDS_SHELL),
|
||||
line_comments: vec!["#"],
|
||||
line_comments_at_word_start: true,
|
||||
// A shell's single quotes are literal: `'a\'` is not one string.
|
||||
quotes: vec![
|
||||
DOUBLE,
|
||||
Quote {
|
||||
@@ -373,16 +342,10 @@ pub fn rules_for(language: Language) -> Rules {
|
||||
attributes: Attributes::AtWord,
|
||||
..Default::default()
|
||||
},
|
||||
// Markdown has no token rules; see `super::markdown::scan_markdown`.
|
||||
Language::Markdown => Rules::default(),
|
||||
}
|
||||
}
|
||||
|
||||
// The keyword sets. Every list below other than RON, TOML, fish and JSON
|
||||
// came from dev.snipme:highlights 1.1.0 (Apache-2.0), the library the
|
||||
// Kotlin scanner replaced, so that no fence which was coloured there turns
|
||||
// plain here either.
|
||||
|
||||
const KEYWORDS_C: &str =
|
||||
"auto break case char const continue default do double else enum extern float for goto if
|
||||
int long register return short signed sizeof static struct switch typedef union unsigned
|
||||
@@ -416,9 +379,6 @@ const KEYWORDS_DART: &str =
|
||||
required rethrow return sealed set show static super switch this throw true try var void
|
||||
when with while yield";
|
||||
|
||||
/// fish is not in the library at all, so its fences are drawn plain today.
|
||||
/// The list is the shell's own words, which is what a fish fence is mostly
|
||||
/// made of.
|
||||
const KEYWORDS_FISH: &str =
|
||||
"and begin break builtin case command continue else end exec for function if in not or
|
||||
return switch while set echo test string math read source";
|
||||
@@ -466,7 +426,6 @@ const KEYWORDS_PYTHON: &str =
|
||||
for from global if import in is lambda nonlocal not or pass raise return try while with
|
||||
yield";
|
||||
|
||||
/// RON is not in the library either; these are the words a RON file can hold.
|
||||
const KEYWORDS_RON: &str = "true false Some None inf NaN";
|
||||
|
||||
const KEYWORDS_RUBY: &str =
|
||||
@@ -495,8 +454,6 @@ const KEYWORDS_SWIFT: &str =
|
||||
nonmutating optional override postfix precedence prefix Protocol required right set some Type
|
||||
unowned weak willSet";
|
||||
|
||||
/// TOML is not in the library; `inf` and `nan` are values rather than
|
||||
/// names, like the booleans.
|
||||
const KEYWORDS_TOML: &str = "true false inf nan";
|
||||
|
||||
const KEYWORDS_TYPESCRIPT: &str =
|
||||
@@ -518,8 +475,6 @@ pub fn fence_language(name: Option<&str>) -> Option<Language> {
|
||||
.map(|(_, language)| *language)
|
||||
}
|
||||
|
||||
/// The highlighter's language for a *file*, from its name.
|
||||
///
|
||||
/// The extension is the part after the *last* dot, which is what makes
|
||||
/// `build.gradle.kts` Kotlin. A leading dot is not one: `.bashrc` has no
|
||||
/// extension, it has a name that starts with a dot. A name with no dot at
|
||||
|
||||
@@ -1,26 +1,8 @@
|
||||
//! Markdown read into the spans that carry a colour -- a ```markdown fence
|
||||
//! in a reply, and a `.md` file in the viewer. Ported from
|
||||
//! `app/.../MarkdownSyntax.kt`; see that file's doc for why this is its own
|
||||
//! scanner rather than a row of [`super::Rules`] (what a character means
|
||||
//! depends on where it sits, not on what it is) and why an indented code
|
||||
//! block is deliberately not recognised.
|
||||
//!
|
||||
//! Structure is read a line at a time and each line's prose left to right,
|
||||
//! except the two decisions that are not: a fenced block is state carried
|
||||
//! forward, and a table is found by its delimiter row, which comes after
|
||||
//! the header it belongs to (the one place here that looks ahead).
|
||||
|
||||
use super::{Kind, Span};
|
||||
|
||||
/// The characters an unordered list may be bulleted with.
|
||||
const BULLETS: &str = "-*+";
|
||||
/// The characters a thematic break, or a setext heading's underline, can be
|
||||
/// drawn with.
|
||||
const RULE_MARKERS: &str = "-*_=";
|
||||
/// The characters that can open emphasis, strong emphasis or a strikethrough.
|
||||
const EMPHASIS: &str = "*_~";
|
||||
/// Characters that end a bare URL wherever they appear, and ones only
|
||||
/// trimmed off the end.
|
||||
const URL_STOPS: &str = "<>\"'`|";
|
||||
const URL_TRAILING: &str = ".,:;!?";
|
||||
|
||||
@@ -46,15 +28,10 @@ impl MarkdownScanner {
|
||||
// The delimiter run that opened the fenced block we are inside, or
|
||||
// None between them.
|
||||
let mut fence: Option<Vec<char>> = None;
|
||||
// Whether the row above was part of a table, which is what makes
|
||||
// this one a body row.
|
||||
let mut table = false;
|
||||
loop {
|
||||
let end = self.line_end(at);
|
||||
if let Some(open) = fence.clone() {
|
||||
// The content and the closing line alike: a fence is one
|
||||
// block of code, and its own delimiters belong to it the
|
||||
// way a string's quotes belong to the string.
|
||||
self.emit(at, end, Kind::String);
|
||||
if self.closes_fence(at, end, &open) {
|
||||
fence = None;
|
||||
@@ -76,7 +53,6 @@ impl MarkdownScanner {
|
||||
self.spans
|
||||
}
|
||||
|
||||
/// The end of the line beginning at `at`: the newline, or the end of the text.
|
||||
fn line_end(&self, at: usize) -> usize {
|
||||
self.code[at..]
|
||||
.iter()
|
||||
@@ -85,8 +61,6 @@ impl MarkdownScanner {
|
||||
.unwrap_or(self.code.len())
|
||||
}
|
||||
|
||||
/// One line that is not inside a fence, and whether the table it may be
|
||||
/// part of is still open.
|
||||
fn row(&mut self, start: usize, end: usize, table: bool) -> bool {
|
||||
if self.table_delimiter(start, end) {
|
||||
let indented = self.indented(start, end);
|
||||
@@ -102,8 +76,6 @@ impl MarkdownScanner {
|
||||
false
|
||||
}
|
||||
|
||||
/// A line of nothing but pipes, dashes, alignment colons and space, with
|
||||
/// one of each needed.
|
||||
fn table_delimiter(&self, start: usize, end: usize) -> bool {
|
||||
let mut dashes = false;
|
||||
let mut pipes = false;
|
||||
@@ -132,7 +104,6 @@ impl MarkdownScanner {
|
||||
false
|
||||
}
|
||||
|
||||
/// A table row: the pipes are the structure, and what is between them is prose.
|
||||
fn table_row(&mut self, start: usize, end: usize) {
|
||||
let mut at = self.indented(start, end);
|
||||
let mut cell = at;
|
||||
@@ -151,7 +122,6 @@ impl MarkdownScanner {
|
||||
self.inline(cell, end);
|
||||
}
|
||||
|
||||
/// Spans, coalesced with the one before when they touch and agree.
|
||||
fn emit(&mut self, start: usize, end: usize, kind: Kind) {
|
||||
if end <= start {
|
||||
return;
|
||||
@@ -166,7 +136,6 @@ impl MarkdownScanner {
|
||||
self.spans.push(Span { start, end, kind });
|
||||
}
|
||||
|
||||
/// The first character of the line at or after `start` that is not indentation.
|
||||
fn indented(&self, start: usize, end: usize) -> usize {
|
||||
let mut at = start;
|
||||
while at < end && (self.code[at] == ' ' || self.code[at] == '\t') {
|
||||
@@ -175,8 +144,6 @@ impl MarkdownScanner {
|
||||
at
|
||||
}
|
||||
|
||||
/// The run of backticks or tildes that could open or close a fence on
|
||||
/// this line, or `None`.
|
||||
fn fence_run(&self, start: usize, end: usize) -> Option<(usize, usize)> {
|
||||
let at = self.indented(start, end);
|
||||
if at == end {
|
||||
@@ -193,20 +160,14 @@ impl MarkdownScanner {
|
||||
if run - at >= 3 { Some((at, run)) } else { None }
|
||||
}
|
||||
|
||||
/// Draws an opening fence line and answers its delimiter, or `None` if
|
||||
/// this is not one.
|
||||
fn opens_fence(&mut self, start: usize, end: usize) -> Option<Vec<char>> {
|
||||
let (run_start, run_end) = self.fence_run(start, end)?;
|
||||
self.emit(run_start, run_end, Kind::String);
|
||||
// The info word is what the fence is a fence *of*, which is
|
||||
// metadata about the block rather than part of it.
|
||||
let indented = self.indented(run_end, end);
|
||||
self.emit(indented, end, Kind::Metadata);
|
||||
Some(self.code[run_start..run_end].to_vec())
|
||||
}
|
||||
|
||||
/// Whether this line closes a fence opened by `open`: the same
|
||||
/// character, at least as many of them, and nothing else on the line.
|
||||
fn closes_fence(&self, start: usize, end: usize, open: &[char]) -> bool {
|
||||
let Some((run_start, run_end)) = self.fence_run(start, end) else {
|
||||
return false;
|
||||
@@ -217,12 +178,8 @@ impl MarkdownScanner {
|
||||
self.indented(run_end, end) == end
|
||||
}
|
||||
|
||||
/// One ordinary line: what its opening characters make it, and then its prose.
|
||||
fn structure(&mut self, start: usize, end: usize) {
|
||||
let mut at = start;
|
||||
// Quote markers come before everything else and can be several
|
||||
// deep, and what follows one is an ordinary line again -- a heading
|
||||
// inside a quote is still a heading.
|
||||
while at < end && self.code[at] == '>' {
|
||||
at += 1;
|
||||
self.emit(at - 1, at, Kind::Mark);
|
||||
@@ -238,8 +195,6 @@ impl MarkdownScanner {
|
||||
self.inline(text_start, end);
|
||||
}
|
||||
|
||||
/// `#` to `######` and a space. Without the space it is a word
|
||||
/// beginning with a hash.
|
||||
fn heading(&mut self, start: usize, end: usize) -> bool {
|
||||
let mut at = start;
|
||||
while at < end && self.code[at] == '#' {
|
||||
@@ -256,7 +211,6 @@ impl MarkdownScanner {
|
||||
true
|
||||
}
|
||||
|
||||
/// A line made of one repeated rule character and nothing else.
|
||||
fn thematic_break(&mut self, start: usize, end: usize) -> bool {
|
||||
let marker = self.code[start];
|
||||
if !RULE_MARKERS.contains(marker) {
|
||||
@@ -277,8 +231,6 @@ impl MarkdownScanner {
|
||||
true
|
||||
}
|
||||
|
||||
/// Draws a list marker if the line opens with one, and answers where
|
||||
/// the item's text starts.
|
||||
fn bullet(&mut self, start: usize, end: usize) -> usize {
|
||||
let marker = self.code[start];
|
||||
if BULLETS.contains(marker) && self.space_or_end(start + 1, end) {
|
||||
@@ -304,16 +256,11 @@ impl MarkdownScanner {
|
||||
at >= end || self.code[at] == ' ' || self.code[at] == '\t'
|
||||
}
|
||||
|
||||
/// The inline forms, left to right. Every branch answers a position
|
||||
/// strictly after `start` of its call, so this terminates.
|
||||
fn inline(&mut self, start: usize, end: usize) {
|
||||
let mut at = start;
|
||||
while at < end {
|
||||
let c = self.code[at];
|
||||
at = if c == '\\' {
|
||||
// A backslash takes the character after it out of the
|
||||
// running entirely, which is how `\*` stays an asterisk
|
||||
// rather than opening emphasis.
|
||||
at + 2
|
||||
} else if c == '`' {
|
||||
self.code_span(at, end)
|
||||
@@ -331,7 +278,6 @@ impl MarkdownScanner {
|
||||
}
|
||||
}
|
||||
|
||||
/// `` `code` ``, closed by a run of exactly as many backticks as opened it.
|
||||
fn code_span(&mut self, start: usize, end: usize) -> usize {
|
||||
let mut open = start;
|
||||
while open < end && self.code[open] == '`' {
|
||||
@@ -354,11 +300,9 @@ impl MarkdownScanner {
|
||||
}
|
||||
at = close;
|
||||
}
|
||||
// Nothing closes it on this line, so those were ordinary backticks.
|
||||
open
|
||||
}
|
||||
|
||||
/// `[text](destination)`, and the same with a leading `!` for an image.
|
||||
fn link(&mut self, start: usize, bracket: usize, end: usize) -> usize {
|
||||
let mut depth = 0i32;
|
||||
let mut close = bracket;
|
||||
@@ -397,8 +341,6 @@ impl MarkdownScanner {
|
||||
paren + 1
|
||||
}
|
||||
|
||||
/// `<https://example.com>` and `<name@example.com>`, drawn as the
|
||||
/// destination they are.
|
||||
fn autolink(&mut self, start: usize, end: usize) -> usize {
|
||||
let mut at = start + 1;
|
||||
let mut addressed = false;
|
||||
@@ -422,8 +364,6 @@ impl MarkdownScanner {
|
||||
start + 1
|
||||
}
|
||||
|
||||
/// A bare `scheme://...` written in prose, or `None` if one does not
|
||||
/// start here.
|
||||
fn url(&mut self, start: usize, end: usize) -> Option<usize> {
|
||||
if start > 0 && is_word(self.code[start - 1]) {
|
||||
return None;
|
||||
@@ -465,8 +405,6 @@ impl MarkdownScanner {
|
||||
Some(at)
|
||||
}
|
||||
|
||||
/// `*emph*`, `**strong**`, `_emph_` and `~~struck~~`, drawn markers and
|
||||
/// all.
|
||||
fn emphasis(&mut self, start: usize, end: usize) -> usize {
|
||||
let marker = self.code[start];
|
||||
let mut open = start;
|
||||
|
||||
@@ -1,24 +1,3 @@
|
||||
//! `code` read once, left to right, into the spans that carry a colour.
|
||||
//! Ported from `app/.../Highlighter.kt`.
|
||||
//!
|
||||
//! One pass with a small state -- in a comment, in a string, or in ordinary
|
||||
//! code -- rather than a locator per token kind over the whole text, which
|
||||
//! is what the library this replaced did and is why it found comments
|
||||
//! before it knew the language: a `#` inside a shell string, a `//` inside
|
||||
//! a URL and a block-comment opener inside a shell glob each commented out
|
||||
//! the rest of a line that was nothing of the sort.
|
||||
//!
|
||||
//! Every span is produced by advancing an index forward, so the result is
|
||||
//! ordered, non-overlapping and inside the code by construction. Nothing
|
||||
//! here panics: an unterminated string or comment runs to the end of the
|
||||
//! code, which is also what it looks like while a fence is still being
|
||||
//! written.
|
||||
//!
|
||||
//! **Indices are char offsets, not byte offsets** -- the scanner works over
|
||||
//! `Vec<char>`, mirroring the Kotlin original's `Char`-indexed strings, so
|
||||
//! [`span_text`] is how a caller (and every test here) turns a [`Span`]
|
||||
//! back into the text it covers.
|
||||
|
||||
pub mod languages;
|
||||
pub mod markdown;
|
||||
|
||||
@@ -26,7 +5,6 @@ pub use languages::{
|
||||
Attributes, BlockComment, Language, Quote, Rules, fence_language, file_language, rules_for,
|
||||
};
|
||||
|
||||
/// What a span of code is, in the terms a palette has a colour for.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Kind {
|
||||
Keyword,
|
||||
@@ -38,7 +16,6 @@ pub enum Kind {
|
||||
Mark,
|
||||
}
|
||||
|
||||
/// A run of [`Kind`] in the code, as a half-open range of **char** indices.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Span {
|
||||
pub start: usize,
|
||||
@@ -70,8 +47,6 @@ pub fn scan(code: &str, rules: &Rules) -> Vec<Span> {
|
||||
Scanner::new(code, rules).run()
|
||||
}
|
||||
|
||||
/// Characters coloured as punctuation, and as marks. Both sets are the ones
|
||||
/// the library this replaced used.
|
||||
const PUNCTUATION: &str = ",.:;";
|
||||
const MARKS: &str = "()={}<>-+[]|&";
|
||||
|
||||
@@ -94,8 +69,6 @@ impl<'a> Scanner<'a> {
|
||||
|
||||
fn run(mut self) -> Vec<Span> {
|
||||
while self.at < self.code.len() {
|
||||
// Every branch that answers true has advanced `self.at`, so
|
||||
// this terminates.
|
||||
let consumed = self.block_comment()
|
||||
|| self.line_comment()
|
||||
|| self.raw_string()
|
||||
@@ -126,15 +99,12 @@ impl<'a> Scanner<'a> {
|
||||
starts_with_at(&self.code, self.at, token)
|
||||
}
|
||||
|
||||
/// Whether a line comment token here opens one; see
|
||||
/// [`Rules::line_comments_at_word_start`].
|
||||
fn at_word_start(&self) -> bool {
|
||||
self.at == 0
|
||||
|| self.code[self.at - 1].is_whitespace()
|
||||
|| ";|&(".contains(self.code[self.at - 1])
|
||||
}
|
||||
|
||||
/// Whether only whitespace stands between the start of this line and here.
|
||||
fn at_line_start(&self) -> bool {
|
||||
let mut back = self.at as isize - 1;
|
||||
while back >= 0 && self.code[back as usize] != '\n' {
|
||||
@@ -152,8 +122,6 @@ impl<'a> Scanner<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// From an open bracket through the one that matches it, or to the end
|
||||
/// if none does.
|
||||
fn advance_to_matching_bracket(&mut self) {
|
||||
let mut depth = 0i32;
|
||||
while self.at < self.code.len() {
|
||||
@@ -180,9 +148,6 @@ impl<'a> Scanner<'a> {
|
||||
self.at += comment.open.chars().count();
|
||||
let mut depth = 1i32;
|
||||
while self.at < self.code.len() && depth > 0 {
|
||||
// The closer is tried first so that a language whose two
|
||||
// delimiters are the same string -- CoffeeScript's `###` --
|
||||
// closes rather than nesting forever.
|
||||
if self.starts(comment.close) {
|
||||
depth -= 1;
|
||||
self.at += comment.close.chars().count();
|
||||
@@ -210,7 +175,6 @@ impl<'a> Scanner<'a> {
|
||||
true
|
||||
}
|
||||
|
||||
/// Rust and RON: `b`? `r` `#`* `"` ... `"` `#`*, with no escapes inside.
|
||||
fn raw_string(&mut self) -> bool {
|
||||
if !self.rules.raw_strings {
|
||||
return false;
|
||||
@@ -267,8 +231,6 @@ impl<'a> Scanner<'a> {
|
||||
}
|
||||
|
||||
fn string(&mut self) -> bool {
|
||||
// Longest opener wins, so Kotlin's `"""` is one delimiter rather
|
||||
// than an empty string followed by a quote.
|
||||
let mut quote: Option<Quote> = None;
|
||||
for candidate in &self.rules.quotes {
|
||||
let current_len = quote.map(|q| q.open.chars().count()).unwrap_or(0);
|
||||
@@ -346,9 +308,6 @@ impl<'a> Scanner<'a> {
|
||||
true
|
||||
}
|
||||
|
||||
/// A number is a run starting with a digit and carrying on through
|
||||
/// letters, digits, `_` and `.` -- which covers `0xFF`, `1_000`, `1u32`
|
||||
/// and `3.14` without a grammar for any of them.
|
||||
fn number(&mut self) -> bool {
|
||||
if !self.code[self.at].is_ascii_digit() {
|
||||
return false;
|
||||
@@ -403,7 +362,6 @@ fn is_word_part(c: char) -> bool {
|
||||
c.is_alphanumeric() || c == '_'
|
||||
}
|
||||
|
||||
/// Whether `code[at..]` starts with `token`, both read as chars.
|
||||
fn starts_with_at(code: &[char], at: usize, token: &str) -> bool {
|
||||
let token: Vec<char> = token.chars().collect();
|
||||
if at + token.len() > code.len() {
|
||||
@@ -412,8 +370,6 @@ fn starts_with_at(code: &[char], at: usize, token: &str) -> bool {
|
||||
code[at..at + token.len()] == token[..]
|
||||
}
|
||||
|
||||
/// The first index at or after `from` where `code` contains `needle`, or
|
||||
/// `None`.
|
||||
fn find_from(code: &[char], from: usize, needle: &[char]) -> Option<usize> {
|
||||
if needle.is_empty() || from > code.len() {
|
||||
return None;
|
||||
@@ -640,10 +596,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The scanner must never panic and must never answer a span the code
|
||||
/// does not contain: the library this replaced answered a reversed
|
||||
/// range here, which crashed a card, and a fence still being written is
|
||||
/// an unterminated string or comment on every keystroke.
|
||||
#[test]
|
||||
fn spans_stay_inside_the_code_for_every_language_and_every_nasty_input() {
|
||||
let nasty = [
|
||||
|
||||
@@ -1,33 +1,7 @@
|
||||
//! The app's own recent log, held in memory so it can be read back
|
||||
//! without `logcat`.
|
||||
//!
|
||||
//! **Why this exists**: Iris tests iris builds on a GrapheneOS phone with
|
||||
//! no `adb`, and Android forbids one app reading another's logcat, so
|
||||
//! nothing outside the process can recover what it wrote. The only way a
|
||||
//! line reaches her is for the app to carry its own copy. This is that
|
||||
//! copy: a bounded ring every `log::info!` in the process lands in, on top
|
||||
//! of whichever platform logger was already installed (`android_logger`,
|
||||
//! `env_logger`) rather than instead of it -- see [`RingLogger`].
|
||||
//!
|
||||
//! Three consumers, all reading the same ring rather than each keeping
|
||||
//! their own: whatever hands the log out of the process -- on Android, the
|
||||
//! `DevLogProvider` Dev Updater queries, which reads [`LogRing::since`]
|
||||
//! and [`LogRing::newest_seq`] -- the bench app's diagnostics pane, which
|
||||
//! only counts it ([`LogRing::summary`]), and the panic hook
|
||||
//! ([`LogRing::try_tail_text`]). That is why reading does not consume: a
|
||||
//! line already handed over must still be readable, and a report taken
|
||||
//! twice must say the same thing.
|
||||
//!
|
||||
//! Nothing inlines the log into a copied report any more (2026-09-08):
|
||||
//! Dev Updater reads it directly, so a second copy on the clipboard was
|
||||
//! the same lines twice.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// How many lines a default ring holds, and how many bytes of message.
|
||||
///
|
||||
/// Both bounds apply -- whichever bites first -- because the two failure
|
||||
/// modes are different: a flood of short lines exhausts the count, and one
|
||||
/// pathological line (a stack trace, a pretty-printed JSON body) exhausts
|
||||
@@ -36,10 +10,6 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
pub const DEFAULT_MAX_LINES: usize = 2000;
|
||||
pub const DEFAULT_MAX_BYTES: usize = 256 * 1024;
|
||||
|
||||
/// One recorded line. `seq` is assigned by the ring and only ever
|
||||
/// increases, so a reader that remembers where it got to can ask for what
|
||||
/// came after -- and a gap in the sequence is exactly the lines the bound
|
||||
/// dropped.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LogLine {
|
||||
pub seq: u64,
|
||||
@@ -53,9 +23,6 @@ pub struct LogLine {
|
||||
}
|
||||
|
||||
impl LogLine {
|
||||
/// Roughly what the line costs the ring. The two `String`s dominate;
|
||||
/// the fixed fields are counted as a flat overhead so a ring of empty
|
||||
/// messages still has a bound.
|
||||
fn weight(&self) -> usize {
|
||||
self.target.len() + self.message.len() + 32
|
||||
}
|
||||
@@ -74,11 +41,6 @@ impl LogLine {
|
||||
}
|
||||
}
|
||||
|
||||
/// `HH:MM:SS.mmm` in UTC from a unix millisecond count, without a date
|
||||
/// library: the only field this needs is the time of day, and dividing out
|
||||
/// the day is the whole calculation. Deliberately not local time -- the
|
||||
/// phone's offset is not knowable here, and a report that says UTC is
|
||||
/// comparable with the server's log, which is what it gets read against.
|
||||
fn clock_time(at_ms: u64) -> String {
|
||||
let ms = at_ms % 1000;
|
||||
let secs_of_day = (at_ms / 1000) % 86_400;
|
||||
@@ -108,15 +70,9 @@ struct Inner {
|
||||
max_lines: usize,
|
||||
max_bytes: usize,
|
||||
next_seq: u64,
|
||||
/// How many lines the bounds have discarded since the ring was made.
|
||||
/// Reported rather than inferred, so "the log starts here" and "the
|
||||
/// log was cut off here" are distinguishable -- the unknown state the
|
||||
/// UI rules ask for.
|
||||
dropped: u64,
|
||||
}
|
||||
|
||||
/// A bounded, shareable ring of recent log lines. Cloning shares the ring;
|
||||
/// there is one per process and every holder sees the same lines.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LogRing(Arc<Mutex<Inner>>);
|
||||
|
||||
@@ -136,8 +92,6 @@ impl LogRing {
|
||||
})))
|
||||
}
|
||||
|
||||
/// The bounds this project ships with: [`DEFAULT_MAX_LINES`] and
|
||||
/// [`DEFAULT_MAX_BYTES`].
|
||||
pub fn with_defaults() -> Self {
|
||||
Self::new(DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES)
|
||||
}
|
||||
@@ -153,7 +107,6 @@ impl LogRing {
|
||||
f(&mut guard)
|
||||
}
|
||||
|
||||
/// Records a line, evicting the oldest until both bounds hold again.
|
||||
pub fn push(&self, level: log::Level, target: &str, message: String) {
|
||||
self.with(|inner| {
|
||||
let line = LogLine {
|
||||
@@ -180,14 +133,10 @@ impl LogRing {
|
||||
})
|
||||
}
|
||||
|
||||
/// Every line held, oldest first.
|
||||
pub fn snapshot(&self) -> Vec<LogLine> {
|
||||
self.with(|inner| inner.lines.iter().cloned().collect())
|
||||
}
|
||||
|
||||
/// The lines with a sequence number at or after `seq`, oldest first,
|
||||
/// and the sequence to ask from next time. Does not consume: see this
|
||||
/// module's doc for why.
|
||||
pub fn since(&self, seq: u64) -> (Vec<LogLine>, u64) {
|
||||
self.with(|inner| {
|
||||
let lines: Vec<LogLine> = inner
|
||||
@@ -234,8 +183,6 @@ impl LogRing {
|
||||
self.with(|inner| inner.lines.back().map(|line| line.at_ms))
|
||||
}
|
||||
|
||||
/// Every line held, formatted one per line -- what `Copy report`
|
||||
/// appends.
|
||||
pub fn to_text(&self) -> String {
|
||||
self.snapshot()
|
||||
.iter()
|
||||
@@ -258,8 +205,6 @@ impl LogRing {
|
||||
pub fn try_tail_text(&self, max_lines: usize) -> Option<String> {
|
||||
let guard = match self.0.try_lock() {
|
||||
Ok(guard) => guard,
|
||||
// A poisoned lock is uncontended, so its contents are still
|
||||
// readable -- the same judgement as `with`.
|
||||
Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
|
||||
Err(std::sync::TryLockError::WouldBlock) => return None,
|
||||
};
|
||||
@@ -317,9 +262,6 @@ fn is_own_target(target: &str) -> bool {
|
||||
|| target.starts_with("ai_app::")
|
||||
}
|
||||
|
||||
/// Whether a line at `level` from `target` belongs in the ring, given
|
||||
/// whether tracing is on right now.
|
||||
///
|
||||
/// This is the one filter docs/IRIS_TODO.md's "logs way too big" entry
|
||||
/// asked for, applied once here rather than at each `debug!` call site:
|
||||
/// Info and above always ring, from anything, because a real warning or
|
||||
@@ -336,24 +278,9 @@ fn ring_accepts(level: log::Level, target: &str, trace_enabled: bool) -> bool {
|
||||
level <= log::Level::Info || (trace_enabled && is_own_target(target))
|
||||
}
|
||||
|
||||
/// A `log` backend that records into a [`LogRing`] **and** forwards to the
|
||||
/// logger the platform already installs, so nothing that reads the
|
||||
/// platform's log (`logcat`, a terminal) changes.
|
||||
///
|
||||
/// The inner logger is passed in rather than chosen here: `client-core`
|
||||
/// has no business depending on `android_logger` or `env_logger`, and
|
||||
/// which one is right is exactly what differs between the two platforms
|
||||
/// (the sharing rule in AGENTS.md).
|
||||
pub struct RingLogger {
|
||||
ring: LogRing,
|
||||
inner: Box<dyn log::Log>,
|
||||
/// Whether `iris::input`/`iris::frame`-style tracing is switched on
|
||||
/// right now, consulted by [`ring_accepts`]. A plain fn pointer rather
|
||||
/// than a dependency on `iris::diagnostics::trace_enabled` directly:
|
||||
/// `client-core` sits below `iris` (AGENTS.md's "dependencies flow one
|
||||
/// direction"), so the platform crate that depends on both is the one
|
||||
/// that wires this closure through, the same way it already supplies
|
||||
/// `inner`.
|
||||
trace_enabled: fn() -> bool,
|
||||
}
|
||||
|
||||
@@ -368,10 +295,6 @@ impl RingLogger {
|
||||
}
|
||||
|
||||
impl log::Log for RingLogger {
|
||||
/// True for anything `log`'s own max level lets through: the ring
|
||||
/// wants everything the *inner* logger might also want, even where the
|
||||
/// platform logger would filter it out. Which lines the ring itself
|
||||
/// keeps is decided in [`Self::log`] by [`ring_accepts`].
|
||||
fn enabled(&self, _metadata: &log::Metadata) -> bool {
|
||||
true
|
||||
}
|
||||
@@ -391,9 +314,6 @@ impl log::Log for RingLogger {
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs a [`RingLogger`] as the process logger and answers the ring it
|
||||
/// records into.
|
||||
///
|
||||
/// Fails only if a logger is already installed, which is a programmer
|
||||
/// error (two initialisation paths) rather than a recoverable condition --
|
||||
/// the caller is named in the error so it is findable.
|
||||
@@ -408,8 +328,6 @@ pub fn install(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The one ring this process records into.
|
||||
///
|
||||
/// **A deliberate process-global, where this project's rules otherwise say
|
||||
/// pass context explicitly.** What is being modelled is already one: `log`
|
||||
/// has exactly one backend per process, set once, and every `log::info!`
|
||||
@@ -421,20 +339,10 @@ pub fn install(
|
||||
/// testable.
|
||||
static PROCESS_RING: OnceLock<LogRing> = OnceLock::new();
|
||||
|
||||
/// The process's ring, created on first use with the default bounds.
|
||||
/// Safe to call before [`install_process_logger`] -- it will simply be
|
||||
/// empty.
|
||||
pub fn process_ring() -> &'static LogRing {
|
||||
PROCESS_RING.get_or_init(LogRing::with_defaults)
|
||||
}
|
||||
|
||||
/// Installs [`process_ring`] as the recording half of the process logger,
|
||||
/// forwarding to `inner` (the platform's own logger, already configured).
|
||||
/// The platform half of AGENTS.md's sharing rule is `inner`; everything
|
||||
/// else is shared. `trace_enabled` is the platform's own trace toggle
|
||||
/// (`iris::diagnostics::trace_enabled` on Android) -- see
|
||||
/// [`ring_accepts`] and the field doc on `RingLogger` for why it is
|
||||
/// passed in rather than called directly.
|
||||
pub fn install_process_logger(
|
||||
inner: Box<dyn log::Log>,
|
||||
max_level: log::LevelFilter,
|
||||
@@ -474,7 +382,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn the_byte_bound_bites_before_the_line_bound_when_lines_are_large() {
|
||||
// Room for 1000 lines but only a few hundred bytes.
|
||||
let ring = LogRing::new(1000, 300);
|
||||
for n in 0..10 {
|
||||
ring.push(Level::Info, "t", format!("{n}{}", "x".repeat(100)));
|
||||
@@ -491,9 +398,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The case the `len() > 1` guard exists for: one line larger than the
|
||||
/// whole bound must still be readable, or a ring that is over budget
|
||||
/// reads as a ring nothing was written to.
|
||||
#[test]
|
||||
fn one_oversized_line_is_kept_rather_than_leaving_the_ring_empty() {
|
||||
let ring = LogRing::new(100, 64);
|
||||
@@ -529,8 +433,6 @@ mod tests {
|
||||
assert_eq!(cursor, 4);
|
||||
}
|
||||
|
||||
/// The restart signal: a reader that saw sequence 4 and is now told
|
||||
/// the newest is 0 knows the process is not the one it was reading.
|
||||
#[test]
|
||||
fn the_newest_sequence_says_where_the_ring_is_and_nothing_for_an_empty_one() {
|
||||
let ring = LogRing::new(100, 1 << 20);
|
||||
@@ -568,9 +470,6 @@ mod tests {
|
||||
assert!(lines[79].ends_with("line 199"), "{}", lines[79]);
|
||||
}
|
||||
|
||||
/// The whole point of the `try_`: the panic hook calls this from a
|
||||
/// thread that may already hold the ring's lock, and a blocking read
|
||||
/// there would hang the process instead of aborting it.
|
||||
#[test]
|
||||
fn try_tail_text_answers_none_rather_than_blocking_on_a_held_lock() {
|
||||
let ring = LogRing::new(10, 1 << 20);
|
||||
@@ -602,8 +501,6 @@ mod tests {
|
||||
fn a_line_formats_as_time_level_target_message() {
|
||||
let line = LogLine {
|
||||
seq: 0,
|
||||
// 1970-01-01T12:34:56.789Z, so the arithmetic is checkable by
|
||||
// hand rather than against another clock.
|
||||
at_ms: (12 * 3600 + 34 * 60 + 56) * 1000 + 789,
|
||||
level: Level::Info,
|
||||
target: "iris::android".into(),
|
||||
@@ -613,9 +510,6 @@ mod tests {
|
||||
assert_eq!(line, "12:34:56.789 INFO iris::android: surface created");
|
||||
}
|
||||
|
||||
/// The forwarding half: a line reaches the ring *and* the logger the
|
||||
/// platform already had, and one the inner logger filters out is still
|
||||
/// in the ring.
|
||||
#[test]
|
||||
fn the_ring_logger_forwards_to_the_inner_logger() {
|
||||
use log::Log;
|
||||
@@ -632,9 +526,6 @@ mod tests {
|
||||
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let ring = LogRing::with_defaults();
|
||||
// Own target, tracing on: this is the case where the ring and the
|
||||
// inner logger disagree, which is the thing under test -- a
|
||||
// foreign target is covered separately below.
|
||||
let logger = RingLogger::new(
|
||||
ring.clone(),
|
||||
Box::new(Collect(seen.clone(), Level::Info)),
|
||||
@@ -668,11 +559,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The bug this filter fixes: `naga`/`wgpu_core`/`jni` log at Debug
|
||||
/// unconditionally, and used to flood the ring even though nothing in
|
||||
/// this app asked for their Debug output. A foreign target's Debug
|
||||
/// line must not ring even while tracing is on -- tracing controls
|
||||
/// this app's own diagnostics, not a dependency's chatter.
|
||||
#[test]
|
||||
fn a_foreign_targets_debug_line_never_rings_even_while_tracing_is_on() {
|
||||
use log::Log;
|
||||
|
||||
@@ -1,33 +1,3 @@
|
||||
//! Split a markdown message into its top-level **blocks** -- one
|
||||
//! paragraph, heading, fenced code block, list, table or quote each, as a
|
||||
//! byte slice of the original source.
|
||||
//!
|
||||
//! This exists for streaming. A transcript row used to be one text widget
|
||||
//! holding the whole message, so a single streamed delta re-shaped every
|
||||
//! paragraph of it through the text engine again; the phone's bench v2 put
|
||||
//! the stream phase at p50 18.2ms against Compose's 13.4ms for exactly
|
||||
//! that reason (docs/IRIS_TODO.md). A row is a column of one widget per
|
||||
//! block now, and a delta that lands in the last block leaves every
|
||||
//! earlier block's layout alone. the 2026-09-06 decision has
|
||||
//! what that rejected and why the split lives here rather than in the UI
|
||||
//! crate: `docs/CLIENT_CORE.md` already wanted a block model for P1, and
|
||||
//! keeping it here means iris stays a text renderer that knows nothing
|
||||
//! about markdown.
|
||||
//!
|
||||
//! **Blocks only.** Inline styling (bold, links, inline code) is still the
|
||||
//! renderer's own job, per block -- this deliberately does not build a
|
||||
//! full AST, because nothing needs one yet.
|
||||
//!
|
||||
//! ## Appending is not guaranteed to leave earlier blocks alone
|
||||
//!
|
||||
//! It nearly always does, which is what makes the fast path worth having,
|
||||
//! but markdown has no such rule: appending a "```" line can turn text
|
||||
//! that was three paragraphs into one fenced block, and appending "---"
|
||||
//! under a paragraph turns that paragraph into a heading. So a caller
|
||||
//! taking the O(last block) path **must compare the prefix it is about to
|
||||
//! keep** rather than assume it. [`common_prefix`] is that comparison, and
|
||||
//! it is cheap next to laying the text out again.
|
||||
|
||||
use pulldown_cmark::{Event, Options, Parser, Tag};
|
||||
|
||||
/// What a block is, for a renderer that wants to style or space blocks
|
||||
@@ -40,20 +10,13 @@ use pulldown_cmark::{Event, Options, Parser, Tag};
|
||||
pub enum BlockKind {
|
||||
Paragraph,
|
||||
Heading,
|
||||
/// A fenced or indented code block.
|
||||
Code,
|
||||
List,
|
||||
Table,
|
||||
Quote,
|
||||
/// A thematic break, raw HTML, a footnote -- anything with no
|
||||
/// distinguished treatment here.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// One top-level block: its kind and the exact source that produced it.
|
||||
/// `source` is a slice of the input with trailing whitespace removed, so
|
||||
/// two splits of the same prefix compare equal even when one of them had a
|
||||
/// delta arriving after it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Block {
|
||||
pub kind: BlockKind,
|
||||
@@ -73,16 +36,9 @@ fn kind_of(tag: &Tag) -> BlockKind {
|
||||
}
|
||||
|
||||
fn options() -> Options {
|
||||
// The same set `transcript-ui`'s renderer parses with, so a block
|
||||
// boundary here and the styling there cannot disagree about what the
|
||||
// source means.
|
||||
Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS
|
||||
}
|
||||
|
||||
/// Split `src` into its top-level blocks, in source order. An empty or
|
||||
/// whitespace-only input gives no blocks; text the parser does not put
|
||||
/// inside any block (a stray fence marker mid-stream) still comes back,
|
||||
/// as `Other`, rather than being dropped.
|
||||
pub fn split_blocks(src: &str) -> Vec<Block> {
|
||||
let mut out: Vec<Block> = Vec::new();
|
||||
let mut depth = 0usize;
|
||||
@@ -101,9 +57,6 @@ pub fn split_blocks(src: &str) -> Vec<Block> {
|
||||
push(&mut out, kind, &src[range]);
|
||||
}
|
||||
}
|
||||
// A top-level event that is not part of any block -- a
|
||||
// thematic break, a block of raw HTML. Inside one, it is the
|
||||
// enclosing block's business and this does nothing.
|
||||
_ => {
|
||||
if depth == 0 {
|
||||
push(&mut out, BlockKind::Other, &src[range]);
|
||||
@@ -163,9 +116,6 @@ mod tests {
|
||||
assert!(split_blocks(" \n\n ").is_empty());
|
||||
}
|
||||
|
||||
/// The property the streaming fast path rests on, in its ordinary
|
||||
/// shape: a delta landing in the last paragraph must leave every
|
||||
/// earlier block byte-identical.
|
||||
#[test]
|
||||
fn a_delta_into_the_last_paragraph_leaves_earlier_blocks_untouched() {
|
||||
let before = split_blocks("# Title\n\nFirst para.\n\nSecond par");
|
||||
@@ -176,9 +126,6 @@ mod tests {
|
||||
assert_ne!(before[2], after[2]);
|
||||
}
|
||||
|
||||
/// A delta that starts a *new* block keeps every old block, including
|
||||
/// the one that was last -- so the fast path appends rather than
|
||||
/// replacing.
|
||||
#[test]
|
||||
fn a_delta_that_starts_a_new_block_keeps_every_old_one() {
|
||||
let before = split_blocks("First para.\n\nSecond para.");
|
||||
@@ -187,10 +134,6 @@ mod tests {
|
||||
assert_eq!(after.len(), 3);
|
||||
}
|
||||
|
||||
/// A code fence arrives one delta at a time and is unterminated for
|
||||
/// most of its life. It must still be *one* block the whole way, or
|
||||
/// every delta would re-split the message into a different number of
|
||||
/// pieces.
|
||||
#[test]
|
||||
fn an_unterminated_fence_is_one_block_while_it_streams() {
|
||||
for src in [
|
||||
@@ -206,11 +149,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The half the fast path had no reason to touch, and the reason
|
||||
/// `common_prefix` is a comparison rather than an assumption:
|
||||
/// appending can rewrite what came before. `---` under a paragraph
|
||||
/// turns that paragraph into a setext heading, so the block that was
|
||||
/// already laid out is not the block it is now.
|
||||
#[test]
|
||||
fn appending_can_rewrite_an_earlier_block_and_the_prefix_says_so() {
|
||||
let before = split_blocks("Not a heading\n\nsecond");
|
||||
@@ -232,12 +170,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The shapes a real transcript actually contains, each checked for
|
||||
/// the one property the streaming fast path needs: the *number* of
|
||||
/// blocks and every earlier block's source stay put while the message
|
||||
/// grows. A fence's own blank lines, a `---` inside one, a nested
|
||||
/// list and a table are all places where a naive line-based split
|
||||
/// would break the message into more pieces than there are blocks.
|
||||
#[test]
|
||||
fn the_transcripts_own_block_shapes_survive_a_split() {
|
||||
let fence_with_blanks = "Intro.\n\n```rust\nfn a() {}\n\nfn b() {}\n```\n\nAfter.";
|
||||
@@ -271,19 +203,9 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `apply_delta`'s precondition, stated as the property rather than
|
||||
/// the arithmetic: for every prefix of a realistic streamed message,
|
||||
/// the blocks before the last one must be exactly the blocks the
|
||||
/// previous prefix had. Where markdown breaks that (the `---` case
|
||||
/// above), `common_prefix` has to *say* so -- which is what the
|
||||
/// `>= len - 1` assertion below checks: the split may rewrite the
|
||||
/// last block, never an earlier one, or `RowBlocks::apply_delta`
|
||||
/// would keep a widget whose text is no longer what it holds.
|
||||
#[test]
|
||||
fn every_prefix_of_a_streamed_message_keeps_all_but_its_last_block() {
|
||||
let full = "# Report\n\nFirst finding, at some length.\n\n```rust\nfn main() {\n\n println!(\"hi\");\n}\n```\n\n- one\n - nested\n- two\n\n| a | b |\n |---|---|\n| 1 | 2 |\n\n> and a closing quote.";
|
||||
// Every character boundary, so a delta landing mid-word and one
|
||||
// landing exactly on a fence's closing backtick are both covered.
|
||||
let mut prev = Vec::new();
|
||||
for end in full.char_indices().map(|(i, _)| i).chain([full.len()]) {
|
||||
let now = split_blocks(&full[..end]);
|
||||
@@ -297,9 +219,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The half a growing message cannot show: a fence that never closes.
|
||||
/// The stream ends there and the block must still be the code block
|
||||
/// it has been all along, not re-split into paragraphs.
|
||||
#[test]
|
||||
fn a_stream_that_ends_inside_a_fence_still_ends_with_one_code_block() {
|
||||
let src = "Here is the patch:\n\n```diff\n- old line\n+ new line";
|
||||
@@ -311,9 +230,6 @@ mod tests {
|
||||
assert_eq!(blocks[1].source, "```diff\n- old line\n+ new line");
|
||||
}
|
||||
|
||||
/// A delta that closes a fence changes the *last* block only, so the
|
||||
/// fast path takes it -- the case the module doc says is the reason
|
||||
/// `common_prefix` is a comparison.
|
||||
#[test]
|
||||
fn the_delta_that_closes_a_fence_changes_only_the_last_block() {
|
||||
let before = split_blocks("Text.\n\n```\ncode\n");
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
//! The app's pure logic, shared between the server and any Rust client --
|
||||
//! see `docs/CLIENT_CORE.md` for what lives here and what does
|
||||
//! not yet.
|
||||
|
||||
pub mod ansi;
|
||||
pub mod api;
|
||||
pub mod config;
|
||||
|
||||
@@ -4,14 +4,6 @@
|
||||
//! ([`crate::client::sse`]) and the wire shape ([`SessionNotification`],
|
||||
//! [`NotificationKind`], mirroring `server/src/session/mod.rs`'s
|
||||
//! `Notification`/`NotificationKind`).
|
||||
//!
|
||||
//! What is deliberately **not** here, because it is a decision rather than
|
||||
//! logic: whether a given notification is shown at all (the session on
|
||||
//! screen gets nothing), handed to the app as a banner, or posted to the
|
||||
//! platform's own notification drawer. That three-way choice reads
|
||||
//! process-wide state (what screen is open, whether the app is in front)
|
||||
//! that has no meaning to a pure crate with no UI and no Android in it --
|
||||
//! see `android-shell` for where it lives for this port.
|
||||
|
||||
use std::io::{BufRead, BufReader};
|
||||
|
||||
@@ -20,8 +12,6 @@ use serde::Deserialize;
|
||||
use crate::client::api::{ApiError, Transport};
|
||||
use crate::client::sse::SseReader;
|
||||
|
||||
/// One frame of `GET /notifications`, matching `server/src/session/mod.rs`'s
|
||||
/// `Notification` field for field.
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionNotification {
|
||||
@@ -32,9 +22,6 @@ pub struct SessionNotification {
|
||||
pub at: f64,
|
||||
}
|
||||
|
||||
/// Mirrors `server/src/session/mod.rs`'s `NotificationKind` -- serialized
|
||||
/// the same way, so this deserializes the wire's `"awaitingInput"` /
|
||||
/// `"finished"` directly rather than through a string match.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum NotificationKind {
|
||||
|
||||
@@ -1,15 +1,3 @@
|
||||
//! Server-sent-events framing, ported from `app/.../Sse.kt`: `data:` and
|
||||
//! `event:` lines accumulate until a blank line ends the frame, comments
|
||||
//! start with `:`, and a frame is either named with no payload or a payload
|
||||
//! with no name.
|
||||
//!
|
||||
//! Pure and line-at-a-time, unlike the Kotlin original which also owned the
|
||||
//! socket: `server/routes.rs`'s SSE bodies are one event per line, so a
|
||||
//! caller here feeds lines from wherever they came from (a real connection,
|
||||
//! a test fixture) and gets frames back with no I/O of its own -- which is
|
||||
//! what lets this be tested with no server, per RUST.md's "pure logic
|
||||
//! first" for this crate.
|
||||
|
||||
/// One SSE frame: its name (`None` for an ordinary data frame) and its payload.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Frame {
|
||||
@@ -17,10 +5,6 @@ pub struct Frame {
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
/// Accumulates lines into [`Frame`]s. One instance per connection --
|
||||
/// `feed_line` is called for every line the transport reads (with line
|
||||
/// endings already stripped), and answers a frame when a blank line closes
|
||||
/// one.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SseReader {
|
||||
data: String,
|
||||
@@ -32,8 +16,6 @@ impl SseReader {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Feeds one line (no trailing `\n`). Answers the frame this line
|
||||
/// completed, if any.
|
||||
pub fn feed_line(&mut self, line: &str) -> Option<Frame> {
|
||||
if line.is_empty() {
|
||||
if self.name.is_some() || !self.data.is_empty() {
|
||||
@@ -50,7 +32,6 @@ impl SseReader {
|
||||
} else if let Some(rest) = line.strip_prefix("event:") {
|
||||
self.name = Some(rest.trim().to_string());
|
||||
}
|
||||
// `id:`, comments -- nothing to do.
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +1,17 @@
|
||||
//! How much of a long thing a transcript draws before offering the rest
|
||||
//! behind a tap.
|
||||
//!
|
||||
//! One rule, four surfaces: a tool call's input, its output, and a user or
|
||||
//! assistant message. It lives here rather than at any one of them because
|
||||
//! four copies would eventually disagree about what "too long" is, and
|
||||
//! because the Compose app has to answer the same question the same way --
|
||||
//! `TextCap.kt` is the Kotlin half, and the two are checked against the
|
||||
//! same numbers so a benchmark comparing the apps is comparing renderers
|
||||
//! rather than policies.
|
||||
//!
|
||||
//! **Lines and bytes both, whichever runs out first**, because they run
|
||||
//! out on different things: a diff is thousands of short lines, a minified
|
||||
//! file or a base64 blob is one enormous one, and a cap that only counted
|
||||
//! one of them draws the whole of the other.
|
||||
//!
|
||||
//! **Cut at the head, keeping the beginning.** A tool's output is read
|
||||
//! from the top and the line saying what went wrong is nearly always the
|
||||
//! first; a message is read from the top for the obvious reason. (A path
|
||||
//! is identified by its other end -- none of these is a path.)
|
||||
|
||||
/// The default bound on a verbatim block -- a tool call's input or its
|
||||
/// output. Short, because this text is a machine's and the reader is
|
||||
/// looking for one line of it.
|
||||
pub const VERBATIM_LINES: usize = 80;
|
||||
pub const VERBATIM_BYTES: usize = 4096;
|
||||
|
||||
/// The bound on a message, a person's or the model's. Larger than a
|
||||
/// verbatim block's in bytes and smaller in lines: prose is read whole and
|
||||
/// wraps, so a screenful of it is far fewer lines than a screenful of a
|
||||
/// log, and cutting a reply at 80 lines would cut most long answers that
|
||||
/// nobody would call long.
|
||||
pub const MESSAGE_LINES: usize = 200;
|
||||
pub const MESSAGE_BYTES: usize = 16 * 1024;
|
||||
|
||||
/// A cap of nothing would draw an empty panel and a "Show all" for
|
||||
/// everything there is, which reads as a rendering fault rather than as a
|
||||
/// cap. Checked at compile time, since all four are constants.
|
||||
const _: () = assert!(VERBATIM_LINES > 0 && VERBATIM_BYTES > 0);
|
||||
const _: () = assert!(MESSAGE_LINES > 0 && MESSAGE_BYTES > 0);
|
||||
|
||||
/// `text` cut to `max_lines` lines and `max_bytes` bytes, with the line
|
||||
/// count it was cut *from*; `None` when the whole of it fits.
|
||||
///
|
||||
/// The count is the whole text's, not the shown part's -- it is what the
|
||||
/// "Show all N lines" offer says, and a reader deciding whether to ask for
|
||||
/// the rest wants to know how much the rest is.
|
||||
pub fn cut(text: &str, max_lines: usize, max_bytes: usize) -> Option<(&str, usize)> {
|
||||
debug_assert!(
|
||||
max_lines > 0 && max_bytes > 0,
|
||||
@@ -72,8 +39,6 @@ pub fn cut(text: &str, max_lines: usize, max_bytes: usize) -> Option<(&str, usiz
|
||||
Some((&text[..cut], text.lines().count()))
|
||||
}
|
||||
|
||||
/// What a "Show all" offer says, so the wording is one string rather than
|
||||
/// one per surface.
|
||||
pub fn show_all_label(lines: usize) -> String {
|
||||
format!("Show all {lines} lines")
|
||||
}
|
||||
@@ -98,8 +63,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The half the line bound cannot catch: one enormous line, which is
|
||||
/// what a minified file or an embedded image arrives as.
|
||||
#[test]
|
||||
fn the_byte_bound_cuts_one_long_line() {
|
||||
let text = "x".repeat(5000);
|
||||
@@ -108,7 +71,6 @@ mod tests {
|
||||
assert_eq!(lines, 1);
|
||||
}
|
||||
|
||||
/// Whichever bites first, rather than whichever was checked first.
|
||||
#[test]
|
||||
fn the_tighter_of_the_two_bounds_wins() {
|
||||
let text = "aaaa\n".repeat(100);
|
||||
@@ -118,9 +80,6 @@ mod tests {
|
||||
assert_eq!(shown, "aaaa\naaaa\naaaa\naaaa");
|
||||
}
|
||||
|
||||
/// A cut that lands inside a multi-byte character has to back up to
|
||||
/// the boundary; slicing there would panic, and a transcript carries
|
||||
/// em dashes and box drawing in every other line.
|
||||
#[test]
|
||||
fn a_cut_inside_a_multibyte_character_backs_up_to_the_boundary() {
|
||||
let text = "é".repeat(100);
|
||||
|
||||
@@ -1,31 +1,11 @@
|
||||
//! A tool call's input, read rather than dumped -- the port of
|
||||
//! `ToolInput.kt`'s `parseToolInput`, which is what both the collapsed
|
||||
//! card's one-line summary and the expanded card's key/value list are
|
||||
//! derived from.
|
||||
//!
|
||||
//! Every tool's input arrives as JSON, and showing it raw makes the reader
|
||||
//! parse `{"command":"…","timeout":120000}` themselves to find the one
|
||||
//! line they care about. So the fields that carry the meaning are pulled
|
||||
//! out, and anything left over is still shown, because dropping a field
|
||||
//! would be claiming the tool has no other input when it might.
|
||||
//!
|
||||
//! Pure, and here rather than in the widget crate, for the reason the rest
|
||||
//! of this crate exists: the derivation is the same on a phone and on a
|
||||
//! desktop, and it is testable without a renderer.
|
||||
|
||||
use crate::client::durations::format_millis_text;
|
||||
use crate::client::highlight::Language;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// A tool call's input, split into the parts a card draws separately.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct ToolInput {
|
||||
/// The thing that will actually be run or read, if this tool has one.
|
||||
pub subject: Option<String>,
|
||||
/// The language [`ToolInput::subject`] is written in, for
|
||||
/// highlighting.
|
||||
pub language: Option<Language>,
|
||||
/// The tool's own one-line summary, when it wrote one.
|
||||
pub description: Option<String>,
|
||||
/// How long the call may take, in the largest units it fits. Shown
|
||||
/// apart because it is a limit on the call rather than part of what
|
||||
@@ -36,25 +16,14 @@ pub struct ToolInput {
|
||||
}
|
||||
|
||||
impl ToolInput {
|
||||
/// The one line to show when there is only room for one: what this
|
||||
/// call is for.
|
||||
pub fn title(&self) -> Option<&str> {
|
||||
self.description
|
||||
.as_deref()
|
||||
.or(self.subject.as_deref())
|
||||
// A subject that is only whitespace would draw as an empty
|
||||
// summary line, which reads as a tool with nothing to say
|
||||
// rather than as one whose subject was blank.
|
||||
.filter(|t| !t.trim().is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// Which field of which tool is the subject.
|
||||
///
|
||||
/// A table rather than a chain of `if`s: adding a tool is a row, and the
|
||||
/// shape stops any of them from being the special case that gets its own
|
||||
/// code path. Unknown tools fall through to "no subject, everything is
|
||||
/// rest".
|
||||
const SUBJECTS: &[(&str, &str, Option<Language>)] = &[
|
||||
("Bash", "command", Some(Language::Shell)),
|
||||
("Read", "file_path", None),
|
||||
@@ -65,13 +34,8 @@ const SUBJECTS: &[(&str, &str, Option<Language>)] = &[
|
||||
("WebFetch", "url", None),
|
||||
];
|
||||
|
||||
/// Fields that are the tool's own prose about itself rather than input to
|
||||
/// it.
|
||||
const DESCRIPTIONS: &[&str] = &["description", "prompt"];
|
||||
|
||||
/// One JSON value as the Kotlin's `JSONObject.optString`/`get` wrote it: a
|
||||
/// string is its own characters, anything else is its JSON form.
|
||||
///
|
||||
/// One function rather than two, because the same coercion decides both
|
||||
/// what a subject reads as and what a leftover field's value reads as, and
|
||||
/// two copies would eventually disagree about a number.
|
||||
@@ -87,11 +51,6 @@ fn non_blank(value: Option<&Value>) -> Option<String> {
|
||||
(!text.trim().is_empty()).then_some(text)
|
||||
}
|
||||
|
||||
/// Split `input` (a tool call's JSON) into the parts a card draws.
|
||||
///
|
||||
/// Input that is not a JSON object -- older transcripts and some tools
|
||||
/// send a bare string -- is still the input, so it is still shown, as the
|
||||
/// whole of `rest`.
|
||||
pub fn parse_tool_input(tool: &str, input: &str) -> ToolInput {
|
||||
let Ok(Value::Object(json)) = serde_json::from_str::<Value>(input) else {
|
||||
return ToolInput {
|
||||
@@ -117,10 +76,6 @@ fn parse_object(tool: &str, json: &Map<String, Value>) -> ToolInput {
|
||||
.find_map(|key| non_blank(json.get(*key)));
|
||||
let timeout = non_blank(json.get("timeout")).map(|t| format_millis_text(&t));
|
||||
|
||||
// Sorted, so the leftovers are in the same order every time this call
|
||||
// is drawn rather than in whatever order the JSON happened to arrive
|
||||
// in. A field is left out only when it is already drawn somewhere
|
||||
// else on the card.
|
||||
let mut keys: Vec<&String> = json
|
||||
.keys()
|
||||
.filter(|k| Some(k.as_str()) != subject_key || subject.is_none())
|
||||
@@ -148,9 +103,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn each_tool_in_the_table_has_its_own_subject() {
|
||||
// One assertion per row of `SUBJECTS`, because the table is the
|
||||
// whole of the rule and a row lost in an edit would otherwise
|
||||
// only show up as a card with no summary line.
|
||||
let cases = [
|
||||
("Bash", r#"{"command":"ls -la"}"#, "ls -la"),
|
||||
("Read", r#"{"file_path":"/tmp/x.rs"}"#, "/tmp/x.rs"),
|
||||
@@ -175,9 +127,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_tools_own_description_is_what_the_one_line_says() {
|
||||
// The description wins over the subject: it is the tool's own
|
||||
// prose about what this call is for, which is what a reader
|
||||
// scanning a collapsed run is looking for.
|
||||
let parsed = parse_tool_input(
|
||||
"Bash",
|
||||
r#"{"command":"cargo test -p iris","description":"Run the iris tests"}"#,
|
||||
@@ -196,9 +145,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn every_field_not_drawn_elsewhere_is_still_shown() {
|
||||
// The half the "never dropped" promise is about: a tool this
|
||||
// build has never heard of has no subject, so *everything* is
|
||||
// rest -- and a known tool's extra fields are too.
|
||||
let parsed = parse_tool_input(
|
||||
"Edit",
|
||||
r#"{"file_path":"/a.rs","old_string":"x","new_string":"y","replace_all":true}"#,
|
||||
@@ -219,8 +165,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn input_that_is_not_an_object_is_still_the_input() {
|
||||
// Older transcripts and some tools send a bare string; a card
|
||||
// that dropped it would claim the call had no input at all.
|
||||
assert_eq!(
|
||||
parse_tool_input("Bash", "just a string").rest,
|
||||
vec!["just a string".to_string()]
|
||||
@@ -234,8 +178,6 @@ mod tests {
|
||||
let parsed = parse_tool_input("Bash", r#"{"command":" ","other":1}"#);
|
||||
assert_eq!(parsed.subject, None);
|
||||
assert_eq!(parsed.title(), None);
|
||||
// Not dropped just because it was blank -- it is still a field
|
||||
// the call carried.
|
||||
assert_eq!(
|
||||
parsed.rest,
|
||||
vec!["command: ".to_string(), "other: 1".to_string()]
|
||||
|
||||
@@ -1,53 +1,17 @@
|
||||
//! This phone's copy of the transcripts it has already been sent, so
|
||||
//! reopening a session does not download it again. Ported from
|
||||
//! `app/.../TranscriptCache.kt`; see `docs/TRANSCRIPT_CACHE.md`
|
||||
//! for the design and `docs/CLIENT_CORE.md` for how this file corresponds to it.
|
||||
//!
|
||||
//! What is stored is the server's own JSON for one event per line, in
|
||||
//! transcript order. Reading the cache means running the same [`seq_of`]
|
||||
//! the network path runs, so a cached transcript and a fetched one cannot
|
||||
//! draw differently, and an event type this build does not know keeps
|
||||
//! every field it arrived with for the build that will. Rows are
|
||||
//! deliberately *not* what is stored: a row is a rendering, and a cache of
|
||||
//! rows would need throwing away on every update that touched the fold.
|
||||
//!
|
||||
//! Four rules run through all of it:
|
||||
//! 1. what is on screen is what the server's transcript says, in order,
|
||||
//! with nothing missing -- the cache is a copy and is never inferred,
|
||||
//! folded or edited here;
|
||||
//! 2. a cached line is never ahead of the live cursor, and the cursor never
|
||||
//! ahead of the cache;
|
||||
//! 3. the cache is never load-bearing -- missing, evicted, damaged or
|
||||
//! unwritable all degrade to a cold open, never to a blank or a wrong
|
||||
//! screen;
|
||||
//! 4. a line already on the phone is not fetched again.
|
||||
//!
|
||||
//! No JSON parser here: what it needs off a line is the sequence number and
|
||||
//! whether the line is a streamed delta, both read with a regex-free scan
|
||||
//! (see [`seq_of`] and [`is_delta`]). A line it cannot read that way is
|
||||
//! treated as damage.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// How much of this phone's cache directory all of one server's transcripts
|
||||
/// may take. A dozen of the largest transcripts seen in the dev VM (21 MB
|
||||
/// for 24,000 events) and a small fraction of a phone. A number to revisit
|
||||
/// against real use rather than a measurement of anything.
|
||||
pub const CACHE_BUDGET_BYTES: u64 = 256_000_000;
|
||||
|
||||
/// What the newest cached line says, which is what the probe checks against
|
||||
/// the server.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CachedTail {
|
||||
pub seq: u64,
|
||||
pub line: String,
|
||||
}
|
||||
|
||||
/// This phone's cache root for one server, holding one directory per session.
|
||||
pub struct TranscriptCache {
|
||||
root: PathBuf,
|
||||
warn: std::sync::Arc<dyn Fn(&str) + Send + Sync>,
|
||||
@@ -68,8 +32,6 @@ impl TranscriptCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// The cache for one session, whether or not anything has been stored
|
||||
/// for it yet.
|
||||
pub fn session(&self, id: &str) -> SessionCache {
|
||||
SessionCache::new(self.root.join(id), self.warn.clone())
|
||||
}
|
||||
@@ -164,36 +126,10 @@ fn dir_size(path: &Path) -> u64 {
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// One session's cached lines, as a directory of chunks.
|
||||
///
|
||||
/// A chunk is a set of lines *and a claim about what they cover*, and the
|
||||
/// two are not the same thing: a coalesced page joins each run of streamed
|
||||
/// deltas into one event carrying the seq of the run's oldest delta, so a
|
||||
/// page whose newest event is seq 1,200 may cover everything up to the
|
||||
/// 1,650 it was fetched with, and nothing in the lines says so. So coverage
|
||||
/// is the half-open range in the file's name:
|
||||
/// `<first>-<end>.rows.jsonl` (a coalesced page; `end` is the `before` it
|
||||
/// was fetched with) or `<first>-<end>.raw.jsonl` (an uncoalesced page, or a
|
||||
/// closed live run); `<first>-open.raw.jsonl` is the live run, whose end is
|
||||
/// its last line's seq + 1.
|
||||
///
|
||||
/// Two chunks are adjacent when one's `end` is the other's `first`. Only
|
||||
/// the contiguous run ending at the newest chunk -- the **suffix** -- is
|
||||
/// ever served: chunks behind a gap are kept, because the gap is usually
|
||||
/// closed by paging back through it, but nothing is served across one.
|
||||
///
|
||||
/// **The newest chunk is always raw**, which is what makes the stream
|
||||
/// cursor and the probe well defined.
|
||||
///
|
||||
/// Nothing here is load-bearing. Every operation that touches the disk
|
||||
/// answers as though the cache were empty when it cannot, and a write
|
||||
/// failure disables writing for the rest of this instance's life so that a
|
||||
/// full disk costs one log line rather than one per delta.
|
||||
///
|
||||
/// A `Mutex` around the writer state stands in for Kotlin's `@Synchronized`:
|
||||
/// the stream appends live events from its own thread while a reader
|
||||
/// scrolling back reads pages from another, and this is what keeps the open
|
||||
/// chunk's name, its end and its writer from being read half-rotated.
|
||||
pub struct SessionCache {
|
||||
dir: PathBuf,
|
||||
warn: std::sync::Arc<dyn Fn(&str) + Send + Sync>,
|
||||
@@ -202,8 +138,6 @@ pub struct SessionCache {
|
||||
|
||||
#[derive(Default)]
|
||||
struct WriterState {
|
||||
/// Set by the first write that fails: a second would fail the same way,
|
||||
/// once per delta.
|
||||
disabled: bool,
|
||||
writer: Option<fs::File>,
|
||||
open_file: Option<PathBuf>,
|
||||
@@ -238,7 +172,6 @@ impl SessionCache {
|
||||
})
|
||||
}
|
||||
|
||||
/// The newest `limit` lines of the suffix, oldest first -- the opening window.
|
||||
pub fn newest(&self, limit: usize) -> Vec<String> {
|
||||
self.guard(Vec::new(), |this, state| {
|
||||
let mut taken: VecDeque<String> = VecDeque::new();
|
||||
@@ -262,11 +195,6 @@ impl SessionCache {
|
||||
/// below `before` -- and means the server has to be asked. Deliberately
|
||||
/// not an empty list: an empty page is how the screen is told it has
|
||||
/// reached the start of the conversation.
|
||||
///
|
||||
/// With `rows` the count is rows rather than lines, mirroring the
|
||||
/// server's `parse_coalesced`. The deltas are not joined here -- the
|
||||
/// fold does that, and the joined row keeps the seq of its first delta
|
||||
/// either way.
|
||||
pub fn page(&self, before: u64, limit: usize, rows: bool) -> Option<Vec<String>> {
|
||||
self.guard(None, |this, state| {
|
||||
let suffix = this.suffix(state)?;
|
||||
@@ -291,18 +219,12 @@ impl SessionCache {
|
||||
continue;
|
||||
}
|
||||
this.each_line(state, chunk, |line| {
|
||||
// The page is what is *before* the cursor; the rows at
|
||||
// or above it are already on screen.
|
||||
let seq = seq_of(line).expect("chunk lines are checked in each_line");
|
||||
if seq >= before {
|
||||
return true;
|
||||
}
|
||||
if rows {
|
||||
let delta = is_delta(line);
|
||||
// Stop only between rows: a delta continuing the
|
||||
// run being gathered is part of a row already
|
||||
// counted, and breaking on it would drop the half
|
||||
// of that row already taken.
|
||||
if counted >= limit && !(delta && in_run) {
|
||||
wanting = false;
|
||||
} else {
|
||||
@@ -338,9 +260,6 @@ impl SessionCache {
|
||||
})
|
||||
}
|
||||
|
||||
/// Stores a fetched page covering `[first, end)`; `false` when it was
|
||||
/// not stored.
|
||||
///
|
||||
/// Refused when it overlaps a chunk already here, because there is no
|
||||
/// clean cut: a coalesced event cannot be split at a seq inside its own
|
||||
/// delta run. The caller keeps that from arising by bounding what it
|
||||
@@ -377,13 +296,6 @@ impl SessionCache {
|
||||
})
|
||||
}
|
||||
|
||||
/// Appends one live event, which is also how a freshly fetched opening
|
||||
/// window is stored.
|
||||
///
|
||||
/// A seq equal to the open chunk's end extends it. A larger one is a
|
||||
/// gap -- which is what a `reset` looks like from here -- and closes
|
||||
/// the open chunk under the end it turned out to have. A smaller one is
|
||||
/// already covered and is ignored; the SSE contract is `seq > after`.
|
||||
pub fn append(&self, line: &str, seq: u64) {
|
||||
self.guard((), |this, state| {
|
||||
if state.disabled {
|
||||
@@ -392,13 +304,6 @@ impl SessionCache {
|
||||
let Some(writer) = this.writer_for(state, seq)? else {
|
||||
return Ok(());
|
||||
};
|
||||
// Written as it arrived. A newline inside it would split one
|
||||
// event into two unreadable halves. No source here can produce
|
||||
// one -- an SSE `data:` field cannot hold a raw newline, and a
|
||||
// fetched line is one element of a compact JSON array -- but
|
||||
// that is a fact about the *server's* serializer rather than
|
||||
// anything this file controls, so it is checked rather than
|
||||
// trusted.
|
||||
debug_assert!(
|
||||
!line.contains('\n'),
|
||||
"a cached transcript line must be one line: {line}"
|
||||
@@ -411,7 +316,6 @@ impl SessionCache {
|
||||
});
|
||||
}
|
||||
|
||||
/// Flushes what [`Self::append`] has buffered.
|
||||
pub fn flush(&self) {
|
||||
self.guard((), |_this, state| {
|
||||
if let Some(writer) = state.writer.as_mut() {
|
||||
@@ -422,12 +326,10 @@ impl SessionCache {
|
||||
});
|
||||
}
|
||||
|
||||
/// What [`Self::purge`] would discard, for the reload row in session settings.
|
||||
pub fn bytes(&self) -> u64 {
|
||||
self.guard(0, |this, _state| Ok(dir_size(&this.dir)))
|
||||
}
|
||||
|
||||
/// Marks this session as visited, which is what eviction ranks by.
|
||||
pub fn touch(&self) {
|
||||
self.guard((), |this, _state| {
|
||||
if this.dir.is_dir() {
|
||||
@@ -448,8 +350,6 @@ impl SessionCache {
|
||||
});
|
||||
}
|
||||
|
||||
// -- chunks ------------------------------------------------------------------------------
|
||||
|
||||
/// Every chunk on disk, oldest first. A name this does not recognise is
|
||||
/// not ours and is ignored. Recomputed per operation rather than kept:
|
||||
/// another operation may have changed the directory.
|
||||
@@ -475,9 +375,6 @@ impl SessionCache {
|
||||
} else {
|
||||
end_str.parse::<u64>().ok()
|
||||
};
|
||||
// A chunk covering nothing is one that was created and never
|
||||
// written to -- an append whose very first write failed. It
|
||||
// says nothing, so it is not a chunk.
|
||||
if let Some(end) = end
|
||||
&& end > first
|
||||
{
|
||||
@@ -494,13 +391,6 @@ impl SessionCache {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The open chunk's end: its last line's seq plus one, or the in-memory
|
||||
/// end while this instance is the one writing it.
|
||||
///
|
||||
/// An open chunk whose last line cannot be read is this app having died
|
||||
/// mid-write. That line is dropped and the file truncated to the last
|
||||
/// good one, which is the one place damage is repaired rather than
|
||||
/// discarded.
|
||||
fn open_end_of(&self, state: &WriterState, file: &Path, first: u64) -> Option<u64> {
|
||||
if state.open_file.as_deref() == Some(file) && state.open_end > 0 {
|
||||
return Some(state.open_end);
|
||||
@@ -516,12 +406,6 @@ impl SessionCache {
|
||||
Some(end)
|
||||
}
|
||||
|
||||
/// The contiguous run of adjacent chunks ending at the newest one,
|
||||
/// oldest first.
|
||||
///
|
||||
/// A newest chunk that is not raw cannot happen while this code is the
|
||||
/// only writer, and means the directory is not to be trusted -- so the
|
||||
/// session is discarded.
|
||||
fn suffix(&self, state: &mut WriterState) -> io::Result<Vec<Chunk>> {
|
||||
let all = self.chunks(state)?;
|
||||
let Some(newest) = all.last() else {
|
||||
@@ -540,8 +424,6 @@ impl SessionCache {
|
||||
Ok(run.into_iter().collect())
|
||||
}
|
||||
|
||||
/// Each line of `chunk`, newest first, until `take` says stop.
|
||||
///
|
||||
/// Damage anywhere but at the tail of the open chunk was not written by
|
||||
/// this code, and there is no honest way to say what a chunk covers
|
||||
/// with a line of it unreadable -- so it is treated as damage rather
|
||||
@@ -565,10 +447,6 @@ impl SessionCache {
|
||||
});
|
||||
}
|
||||
|
||||
// -- writing -----------------------------------------------------------------------------
|
||||
|
||||
/// The writer for the chunk `seq` belongs in, opening or rotating one
|
||||
/// as it has to.
|
||||
fn writer_for<'s>(
|
||||
&self,
|
||||
state: &'s mut WriterState,
|
||||
@@ -581,14 +459,10 @@ impl SessionCache {
|
||||
if seq < state.open_end {
|
||||
return Ok(None);
|
||||
}
|
||||
// A gap: what this instance has written covers up to `open_end`,
|
||||
// and that is the name the chunk gets before a new one starts
|
||||
// at the arriving seq.
|
||||
let end = state.open_end;
|
||||
self.close_open_chunk(state, end);
|
||||
}
|
||||
fs::create_dir_all(&self.dir)?;
|
||||
// An open chunk left by an earlier instance, or by an earlier screen.
|
||||
let existing = self.chunks(state)?.into_iter().rfind(|c| c.open);
|
||||
if let Some(existing) = existing {
|
||||
if seq < existing.end {
|
||||
@@ -632,8 +506,6 @@ impl SessionCache {
|
||||
Ok(state.writer.as_mut())
|
||||
}
|
||||
|
||||
/// Renames the open chunk to the range it turned out to cover, so it
|
||||
/// stops being open.
|
||||
fn close_open_chunk(&self, state: &mut WriterState, end: u64) {
|
||||
let file = state.open_file.clone();
|
||||
close_writer(state);
|
||||
@@ -647,31 +519,17 @@ impl SessionCache {
|
||||
}
|
||||
}
|
||||
|
||||
// -- failure -----------------------------------------------------------------------------
|
||||
|
||||
/// Runs `body`, answering `if_broken` when the directory cannot give a
|
||||
/// real answer. None of this is reported on screen: every read here has
|
||||
/// a network path beside it producing the same result, and the reader
|
||||
/// has nothing to do about it. Damage discards this session's cache,
|
||||
/// which makes the next open an ordinary cold one.
|
||||
fn guard<T>(
|
||||
&self,
|
||||
if_broken: T,
|
||||
body: impl FnOnce(&Self, &mut WriterState) -> io::Result<T>,
|
||||
) -> T {
|
||||
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// A disk that refused once will refuse again, once per delta, so
|
||||
// the first refusal is also the last.
|
||||
if state.disabled {
|
||||
return if_broken;
|
||||
}
|
||||
DAMAGED.with(|cell| *cell.borrow_mut() = None);
|
||||
let result = body(self, &mut state);
|
||||
// Damage takes priority over whatever `body` returned, `Ok` or
|
||||
// `Err`: `suffix` signals it by returning `Err(damaged(..))`
|
||||
// precisely so this check catches it before the branch below
|
||||
// mistakes it for a real I/O failure and disables the whole cache
|
||||
// over one corrupt session.
|
||||
if let Some(file) = DAMAGED.with(|cell| cell.borrow_mut().take()) {
|
||||
(self.warn)(&format!(
|
||||
"transcript cache damaged at {}; discarding {}",
|
||||
@@ -695,11 +553,6 @@ impl SessionCache {
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// How [`SessionCache::each_line`] reports a line it cannot make sense
|
||||
/// of back up to [`SessionCache::guard`], since the callback it hands
|
||||
/// `each_line_backwards` cannot itself return a `Result`. Thread-local
|
||||
/// rather than a field: the guard that reads it always runs on the same
|
||||
/// call stack that could have set it, one `guard` call at a time.
|
||||
static DAMAGED: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
@@ -727,8 +580,6 @@ fn rename_chunk(file: &Path, dir: &Path, first: u64, end: u64) {
|
||||
let _ = fs::rename(file, dir.join(format!("{first}-{end}.raw.jsonl")));
|
||||
}
|
||||
|
||||
/// `<first>-<end|open>.<rows|raw>.jsonl`; anything else in the directory is
|
||||
/// not ours.
|
||||
fn parse_chunk_name(name: &str) -> Option<(u64, &str, &str)> {
|
||||
let rest = name.strip_suffix(".jsonl")?;
|
||||
let (rest, kind) = rest.rsplit_once('.')?;
|
||||
@@ -744,22 +595,14 @@ fn parse_chunk_name(name: &str) -> Option<(u64, &str, &str)> {
|
||||
}
|
||||
|
||||
/// One line's sequence number, or `None` when the line is not one of ours.
|
||||
///
|
||||
/// A hand-rolled scan rather than a JSON parse, so this module carries no
|
||||
/// parser and stays testable with no server: the seq is the first field the
|
||||
/// server writes, so the first match is the top-level one.
|
||||
pub fn seq_of(line: &str) -> Option<u64> {
|
||||
find_number_field(line, "seq")
|
||||
}
|
||||
|
||||
/// Whether a line is one streamed piece of a reply, which is what makes a
|
||||
/// run of them one row.
|
||||
pub fn is_delta(line: &str) -> bool {
|
||||
find_string_field(line, "type").as_deref() == Some("assistantText")
|
||||
}
|
||||
|
||||
/// The value of `"key":N` (any amount of whitespace around the colon), or
|
||||
/// `None`. Mirrors `Regex(""""seq"\s*:\s*(\d+)""")`'s first match.
|
||||
fn find_number_field(line: &str, key: &str) -> Option<u64> {
|
||||
let pattern = format!("\"{key}\"");
|
||||
let at = line.find(&pattern)?;
|
||||
@@ -777,8 +620,6 @@ fn find_number_field(line: &str, key: &str) -> Option<u64> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The value of `"key":"..."`, or `None`. Mirrors
|
||||
/// `Regex(""""type"\s*:\s*"([^"]*)"""")`'s first match.
|
||||
fn find_string_field(line: &str, key: &str) -> Option<String> {
|
||||
let pattern = format!("\"{key}\"");
|
||||
let at = line.find(&pattern)?;
|
||||
@@ -789,18 +630,11 @@ fn find_string_field(line: &str, key: &str) -> Option<String> {
|
||||
Some(after_quote[..end].to_string())
|
||||
}
|
||||
|
||||
/// How much of a file is read at a time when walking it backwards. One
|
||||
/// block covers a page of a transcript comfortably, and the walk stops as
|
||||
/// soon as the caller has what it asked for.
|
||||
const READ_BLOCK: usize = 64 * 1024;
|
||||
|
||||
/// Calls `on_line` with each non-blank line of `file`, **newest first**,
|
||||
/// along with the byte offset it starts at, until `on_line` answers false.
|
||||
///
|
||||
/// Every question the cache is asked is about the newest end of a chunk,
|
||||
/// and a live run reaches the size of the conversation, so reading forwards
|
||||
/// means reading a transcript to answer with the last eighty lines of it.
|
||||
///
|
||||
/// Splitting on bytes is safe because the separator is `\n`, which cannot
|
||||
/// occur inside a multi-byte UTF-8 sequence; each line is decoded whole. A
|
||||
/// missing file yields nothing.
|
||||
@@ -823,7 +657,6 @@ fn each_line_backwards(file: &Path, mut on_line: impl FnMut(u64, &str) -> bool)
|
||||
}
|
||||
let mut buffer = block;
|
||||
buffer.extend_from_slice(&pending);
|
||||
// `buffer` is now `block` followed by `pending`; walk it backwards.
|
||||
let mut line_end = buffer.len();
|
||||
let mut at = buffer.len() as isize - 1;
|
||||
while at >= 0 {
|
||||
@@ -840,20 +673,12 @@ fn each_line_backwards(file: &Path, mut on_line: impl FnMut(u64, &str) -> bool)
|
||||
pending = buffer[..line_end].to_vec();
|
||||
unread = start;
|
||||
}
|
||||
// The first line of a file has no newline before it to be found.
|
||||
let first = String::from_utf8_lossy(&pending);
|
||||
if !first.trim().is_empty() {
|
||||
on_line(0, &first);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops a final line that is not one of ours, by truncating the file to
|
||||
/// where it starts.
|
||||
///
|
||||
/// This app having died mid-write is the one kind of damage that is
|
||||
/// repaired rather than discarded: the tail of an append-only file is the
|
||||
/// only place a partial line can be. A second bad line is not this, and is
|
||||
/// left for the read path to notice.
|
||||
fn repair_tail(file: &Path) -> io::Result<()> {
|
||||
let mut truncate_to: Option<u64> = None;
|
||||
each_line_backwards(file, |offset, line| {
|
||||
@@ -869,9 +694,6 @@ fn repair_tail(file: &Path) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Runs `body`, translating an I/O or permission failure into `if_broken`
|
||||
/// and a warning -- the disk half of [`SessionCache::guard`], shared with
|
||||
/// [`TranscriptCache`]'s own maintenance.
|
||||
fn guard_io<T>(
|
||||
if_broken: T,
|
||||
warn: &(impl Fn(&str) + ?Sized),
|
||||
@@ -886,10 +708,6 @@ fn guard_io<T>(
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets a path's modified time, without pulling in a crate for it: a single
|
||||
/// `utimensat`-backed call would be one more platform-specific dependency
|
||||
/// for one call site, so this touches the file instead, which every
|
||||
/// filesystem this runs on updates the mtime for.
|
||||
fn filetime_set_modified(path: &Path, _when: std::time::SystemTime) -> io::Result<()> {
|
||||
use std::io::Write;
|
||||
// Rewriting a marker file's contents (rather than the directory itself,
|
||||
@@ -920,7 +738,6 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
/// Like `cache`, but also hands back the messages it warned with.
|
||||
fn cache_with_log(temp: &Path) -> (TranscriptCache, std::sync::Arc<Mutex<Vec<String>>>) {
|
||||
let said: std::sync::Arc<Mutex<Vec<String>>> = Default::default();
|
||||
let said2 = said.clone();
|
||||
@@ -996,8 +813,6 @@ mod tests {
|
||||
})
|
||||
);
|
||||
assert_eq!(session.newest(2), vec![tool_line(2), tool_line(3)]);
|
||||
// More than there is is what there is, which is a short opening
|
||||
// window and not a failure.
|
||||
assert_eq!(session.newest(80).len(), 3);
|
||||
}
|
||||
|
||||
@@ -1009,8 +824,6 @@ mod tests {
|
||||
for seq in 1..=3u64 {
|
||||
session.append(&tool_line(seq), seq);
|
||||
}
|
||||
// What a `reset` looks like from here: the next event is not the
|
||||
// one after the last.
|
||||
session.append(&tool_line(90), 90);
|
||||
session.flush();
|
||||
|
||||
@@ -1052,14 +865,11 @@ mod tests {
|
||||
}
|
||||
session.flush();
|
||||
|
||||
// Adjacent: its end is the open chunk's first.
|
||||
let page: Vec<String> = (60..100u64).map(tool_line).collect();
|
||||
assert!(session.store_page(&page, 60, 100, true));
|
||||
assert_eq!(seqs(&session.page(100, 2, false)), Some(vec![98, 99]));
|
||||
assert_eq!(seqs_vec(&session.newest(80)).first(), Some(&60));
|
||||
|
||||
// Behind a gap: kept on disk, because paging usually closes the
|
||||
// gap, but never served across it.
|
||||
let page2: Vec<String> = (1..10u64).map(tool_line).collect();
|
||||
assert!(session.store_page(&page2, 1, 10, true));
|
||||
assert_eq!(session.page(10, 5, false), None);
|
||||
@@ -1095,8 +905,6 @@ mod tests {
|
||||
}
|
||||
session.flush();
|
||||
|
||||
// At or below where the run starts, so what the reader is
|
||||
// scrolling into is the server's.
|
||||
assert_eq!(session.page(100, 40, true), None);
|
||||
assert_eq!(session.page(40, 40, true), None);
|
||||
assert_eq!(cache.session("never-visited").page(100, 40, true), None);
|
||||
@@ -1121,8 +929,6 @@ mod tests {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let cache = cache(temp.path());
|
||||
let session = cache.session("s");
|
||||
// Two replies of three deltas each, split by a tool call: the same
|
||||
// fixture as the server's `coalescing_counts_rows_and_joins_delta_runs`.
|
||||
let lines = vec![
|
||||
delta(1),
|
||||
delta(2),
|
||||
@@ -1137,14 +943,8 @@ mod tests {
|
||||
session.append(&tool_line(9), 9);
|
||||
session.flush();
|
||||
|
||||
// Three rows: the tool call at 8, the run 5..7, and the tool call
|
||||
// at 4. The cut lands between rows, so the older run is not
|
||||
// started.
|
||||
assert_eq!(seqs(&session.page(9, 3, true)), Some(vec![4, 5, 6, 7, 8]));
|
||||
// One row is one whole run, however many deltas it is made of.
|
||||
assert_eq!(seqs(&session.page(9, 1, true)), Some(vec![8]));
|
||||
// A page of lines counts lines, which is what the anchor restore
|
||||
// asks for.
|
||||
assert_eq!(seqs(&session.page(9, 2, false)), Some(vec![7, 8]));
|
||||
}
|
||||
|
||||
@@ -1163,10 +963,7 @@ mod tests {
|
||||
session.append(&tool_line(10), 10);
|
||||
session.flush();
|
||||
|
||||
// A run straddling the boundary is one row, as it will be once folded.
|
||||
assert_eq!(seqs(&session.page(11, 2, true)), Some(vec![8, 9, 10]));
|
||||
// Asking for more rows than the suffix holds is a short page, not a
|
||||
// failure and not a claim that the conversation starts here.
|
||||
assert_eq!(seqs(&session.page(11, 40, true)), Some((5..=10).collect()));
|
||||
}
|
||||
|
||||
@@ -1190,13 +987,9 @@ mod tests {
|
||||
session.append(&tool_line(90), 90);
|
||||
session.flush();
|
||||
|
||||
// The run behind the gap, which is what makes the fetched page
|
||||
// adjacent to it.
|
||||
assert_eq!(session.covered_up_to(90), Some(40));
|
||||
assert_eq!(session.covered_up_to(41), Some(40));
|
||||
assert_eq!(session.covered_up_to(10), Some(10));
|
||||
// Nothing at or below the oldest chunk's start, so the page is
|
||||
// bounded only by its limit.
|
||||
assert_eq!(session.covered_up_to(9), None);
|
||||
}
|
||||
|
||||
@@ -1212,9 +1005,6 @@ mod tests {
|
||||
&(1..10u64).map(tool_line).collect::<Vec<_>>(),
|
||||
);
|
||||
|
||||
// Only reachable by dying between closing one live run and opening
|
||||
// the next, and there is no cursor to be read off a coalesced line
|
||||
// -- so the open is a cold one.
|
||||
assert_eq!(session.tail(), None);
|
||||
assert!(!dir_of(temp.path(), "s").exists());
|
||||
}
|
||||
@@ -1243,7 +1033,6 @@ mod tests {
|
||||
fs::read_to_string(dir.join("1-open.raw.jsonl")).unwrap(),
|
||||
format!("{}\n{}\n", tool_line(1), tool_line(2))
|
||||
);
|
||||
// And the run continues from where the good tail left off.
|
||||
session.append(&tool_line(3), 3);
|
||||
session.flush();
|
||||
assert_eq!(seqs_vec(&session.newest(80)), vec![1, 2, 3]);
|
||||
@@ -1261,7 +1050,6 @@ mod tests {
|
||||
&[tool_line(1), "not ours".to_string(), tool_line(3)],
|
||||
);
|
||||
|
||||
// Not seen by the tail, which reads the newest line and stops.
|
||||
assert_eq!(
|
||||
session.tail(),
|
||||
Some(CachedTail {
|
||||
@@ -1269,8 +1057,6 @@ mod tests {
|
||||
line: tool_line(3)
|
||||
})
|
||||
);
|
||||
// Reached by a read that walks past it: what is served is nothing,
|
||||
// and the session opens cold from here on.
|
||||
assert_eq!(session.newest(80), Vec::<String>::new());
|
||||
assert!(!dir_of(temp.path(), "s").exists());
|
||||
assert!(said.lock().unwrap().iter().any(|m| m.contains("damaged")));
|
||||
@@ -1298,9 +1084,6 @@ mod tests {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let cache = cache(temp.path());
|
||||
let session = cache.session("s");
|
||||
// Well past the 64 kB block the backwards reader takes at a time,
|
||||
// so a page has to be stitched across several of them -- including
|
||||
// a line that straddles a boundary.
|
||||
let padding = "x".repeat(300);
|
||||
let lines: Vec<String> = (1..=500u64)
|
||||
.map(|seq| format!(r#"{{"seq":{seq},"ts":1.5,"type":"toolStart","id":"{padding}"}}"#))
|
||||
@@ -1310,8 +1093,6 @@ mod tests {
|
||||
assert_eq!(session.tail().unwrap().seq, 500);
|
||||
assert_eq!(session.newest(80), lines[420..].to_vec());
|
||||
assert_eq!(session.page(401, 999, false), Some(lines[0..400].to_vec()));
|
||||
// And a non-ASCII line, whose bytes a naive split could cut through
|
||||
// a character.
|
||||
let accented =
|
||||
r#"{"seq":501,"ts":1.5,"type":"assistantText","delta":"héllo — ok"}"#.to_string();
|
||||
session.append(&accented, 501);
|
||||
@@ -1333,15 +1114,10 @@ mod tests {
|
||||
let when =
|
||||
std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000 + at as u64);
|
||||
filetime_set_modified(&dir_of(temp.path(), id), when).unwrap();
|
||||
// The mtime touch above always sets "now", not `when` (see its
|
||||
// own doc) -- space the three writes out in real time instead,
|
||||
// since only relative order matters to eviction.
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
let each = dir_size(&dir_of(temp.path(), "old"));
|
||||
|
||||
// Room for two of the three, so the oldest goes -- and the session
|
||||
// being read never does, however long ago it was last touched.
|
||||
cache.evict_to_budget("open", each * 2);
|
||||
let mut remaining = fs::read_dir(temp.path().join("v1/host_8443"))
|
||||
.unwrap()
|
||||
@@ -1394,8 +1170,6 @@ mod tests {
|
||||
session.purge();
|
||||
assert_eq!(session.bytes(), 0);
|
||||
assert_eq!(session.tail(), None);
|
||||
// And the session is usable again straight afterwards, which is
|
||||
// what a reload does next.
|
||||
session.append(&tool_line(9), 9);
|
||||
session.flush();
|
||||
assert_eq!(seqs_vec(&session.newest(80)), vec![9]);
|
||||
|
||||
@@ -1,32 +1,5 @@
|
||||
//! What the transcript renders: the event stream folded into displayable
|
||||
//! rows. Ported from `app/.../TranscriptItems.kt` and `ToolRows.kt`'s
|
||||
//! non-Compose half (`TranscriptRow`, `groupToolRuns`).
|
||||
//!
|
||||
//! Events are the only data source, and there is deliberately no second
|
||||
//! shape for history to drift from: a page fetched backwards, a live
|
||||
//! frame, and a line read out of the transcript cache are all the same
|
||||
//! events through the same fold.
|
||||
//!
|
||||
//! **Not ported**: `TranscriptUnits.kt`'s further flatten of a row into
|
||||
//! Compose list units (`TranscriptUnit`, `transcriptUnits`) -- that layer
|
||||
//! exists to bound how much a lazy list composes per frame, which is a
|
||||
//! fact about the UI framework drawing it, not about the transcript. See
|
||||
//! `CLIENT_CORE.md`.
|
||||
//!
|
||||
//! **Known gap**: unlike `Events.kt`'s hand-kept mirror, this crate
|
||||
//! deserializes straight into [`event_model::Event`], which has no
|
||||
//! `Unknown` catch-all -- an event type this build does not recognise
|
||||
//! fails to parse rather than degrading to a placeholder row. Closing that
|
||||
//! gap means giving `event_model::Event` its own forward-compatible
|
||||
//! variant, which is a shared-model decision for both sides of the wire
|
||||
//! and is deliberately left for whoever picks this up next (see
|
||||
//! `CLIENT_CORE.md`).
|
||||
|
||||
use event_model::{Event, QuestionOption, SeqEvent, SessionStatus};
|
||||
|
||||
/// A question this build has already asked the reader about, with what was
|
||||
/// answered so far -- distinct from [`QuestionOption`], which is what could
|
||||
/// be chosen.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct QuestionCard {
|
||||
pub seq: u64,
|
||||
@@ -38,24 +11,14 @@ pub struct QuestionCard {
|
||||
pub answers: Vec<String>,
|
||||
}
|
||||
|
||||
/// A tool call cannot be recognised as `AskUserQuestion` from a bare
|
||||
/// `ToolEnd` (its name is not carried), so `runIdFor` and the run-adoption
|
||||
/// logic name it explicitly.
|
||||
pub const ASK_USER_QUESTION: &str = "AskUserQuestion";
|
||||
|
||||
/// This item's identity in the list: a `Seq` for everything with no
|
||||
/// identity of its own, `RunId` for a tool call (which keeps one across
|
||||
/// however many calls join or leave its run), matching `TranscriptItem.key`
|
||||
/// in the Kotlin original.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum ItemKey {
|
||||
Seq(u64),
|
||||
RunId(String),
|
||||
}
|
||||
|
||||
/// One row of the transcript, folded from [`Event`]s. See each variant's
|
||||
/// Kotlin counterpart in `TranscriptItem` for the fuller rationale; this
|
||||
/// doc only says what changed in translation.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum TranscriptItem {
|
||||
UserMsg {
|
||||
@@ -66,8 +29,6 @@ pub enum TranscriptItem {
|
||||
AssistantMsg {
|
||||
seq: u64,
|
||||
text: String,
|
||||
/// Whether this reply is finished -- see `AssistantMsg.settled`'s
|
||||
/// Kotlin doc for why the split it licenses matters.
|
||||
settled: bool,
|
||||
},
|
||||
ToolRun {
|
||||
@@ -95,9 +56,6 @@ pub enum TranscriptItem {
|
||||
seq: u64,
|
||||
r#ref: String,
|
||||
},
|
||||
/// A message from another agent. `arrived` is this row's own identity
|
||||
/// ([`TranscriptItem::key`]); `seq` is where it *sorts*, which
|
||||
/// [`place_peer_note`] may set to the turn's opening seq instead.
|
||||
PeerNote {
|
||||
seq: u64,
|
||||
from: String,
|
||||
@@ -108,8 +66,6 @@ pub enum TranscriptItem {
|
||||
seq: u64,
|
||||
text: String,
|
||||
},
|
||||
/// Placeholder for an event kind this build could not fold -- see the
|
||||
/// module doc's "known gap".
|
||||
Note {
|
||||
seq: u64,
|
||||
text: String,
|
||||
@@ -201,14 +157,10 @@ fn update_tool(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether a status means the session is still doing something, mirroring
|
||||
/// `sessionWorking` in `Events.kt`.
|
||||
pub fn session_working(status: SessionStatus) -> bool {
|
||||
matches!(status, SessionStatus::Running | SessionStatus::Compacting)
|
||||
}
|
||||
|
||||
/// A status saying the session stopped working is the moment its newest
|
||||
/// reply is finished.
|
||||
fn settle_reply(items: &[TranscriptItem], status: SessionStatus) -> Vec<TranscriptItem> {
|
||||
if session_working(status) {
|
||||
return items.to_vec();
|
||||
@@ -223,9 +175,6 @@ fn settle_reply(items: &[TranscriptItem], status: SessionStatus) -> Vec<Transcri
|
||||
items
|
||||
}
|
||||
|
||||
/// A peer message goes above the turn it started, not where it happened to
|
||||
/// arrive. See the Kotlin `placePeerNote`'s doc for the full reasoning;
|
||||
/// `turn_start` is `Event::PeerMessage`'s own field of that name.
|
||||
fn place_peer_note(
|
||||
items: &[TranscriptItem],
|
||||
seq: u64,
|
||||
@@ -264,8 +213,6 @@ fn place_peer_note(
|
||||
out
|
||||
}
|
||||
|
||||
/// The calls the note now sits in front of, renamed if they were sharing a
|
||||
/// run with the calls behind it. See the Kotlin `splitRun`'s doc.
|
||||
fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptItem> {
|
||||
let Some(TranscriptItem::ToolRun {
|
||||
run_id: first_run_id,
|
||||
@@ -299,14 +246,6 @@ fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptIte
|
||||
out
|
||||
}
|
||||
|
||||
/// Puts a page of older items in front of the ones already loaded, healing
|
||||
/// whatever the page boundary cut in two. Ported from `TranscriptItems.kt`'s
|
||||
/// `joinPages`.
|
||||
///
|
||||
/// Two things straddle a boundary: a tool call separated from its result,
|
||||
/// and a message separated from the rest of itself. Both were one thing
|
||||
/// before the transcript was cut into pages.
|
||||
///
|
||||
/// A boundary lands wherever it lands, and roughly half the time that is
|
||||
/// between a call and its result. The newer page then holds a `ToolEnd`
|
||||
/// whose start it never saw, which `fold_event` draws as a row of its own
|
||||
@@ -319,23 +258,12 @@ fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptIte
|
||||
/// exactly what a page boundary destroys. The older row wins on what a
|
||||
/// start knows and the newer on what an end knows, which is the only way
|
||||
/// round that loses nothing.
|
||||
///
|
||||
/// The third thing is the *run*, and it is the one the Kotlin original used
|
||||
/// to miss (AGENTS.md's "things that have bitten"): every page ends up
|
||||
/// here, but `adopt_run` must run on *every* join, not only the one where a
|
||||
/// split call was found -- a boundary landing cleanly between two finished
|
||||
/// calls, which is most of them, would otherwise leave the older page's
|
||||
/// calls under the run name they were folded with. On screen: one run of
|
||||
/// tool calls drawn as two groups, with the seam wherever the reader
|
||||
/// happened to have paged.
|
||||
pub fn join_pages(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<TranscriptItem> {
|
||||
let (older, newer) = heal_split_message(earlier, later);
|
||||
let started_earlier: std::collections::HashSet<&str> = older
|
||||
.iter()
|
||||
.filter_map(TranscriptItem::as_tool_run)
|
||||
.collect();
|
||||
// Owned rather than borrowed from `newer`: `kept` below needs to consume `newer` by
|
||||
// value, and a map borrowing it would keep that alive.
|
||||
let ended_later: std::collections::HashMap<String, TranscriptItem> = newer
|
||||
.iter()
|
||||
.filter_map(|item| item.as_tool_run().map(|id| (id.to_string(), item.clone())))
|
||||
@@ -374,9 +302,6 @@ pub fn join_pages(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<T
|
||||
output: output.clone(),
|
||||
done,
|
||||
failed,
|
||||
// Kept from both halves: a question or an image can be
|
||||
// attached to either, depending on which side of the
|
||||
// boundary its event fell.
|
||||
asks: row_asks.into_iter().chain(half_asks.clone()).collect(),
|
||||
images: row_images.into_iter().chain(half_images.clone()).collect(),
|
||||
}
|
||||
@@ -411,18 +336,10 @@ pub fn join_pages(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<T
|
||||
out
|
||||
}
|
||||
|
||||
/// Rejoins a message the page boundary cut, and hands back the two pages to
|
||||
/// concatenate. Ported from `TranscriptItems.kt`'s `healSplitMessage`.
|
||||
///
|
||||
/// `fold_event` never leaves two assistant messages next to each other
|
||||
/// inside one page, so two meeting at a join are always the two halves of
|
||||
/// one reply, and leaving them apart drew a single answer as two with a
|
||||
/// paragraph break through the middle of a sentence.
|
||||
///
|
||||
/// The newer half keeps its identity, for the reason `adopt_run`'s doc
|
||||
/// gives. It grows by what the older half brings, which is safe here and
|
||||
/// nowhere else -- the join is at the oldest end of what is loaded, so the
|
||||
/// growth extends off the top of the screen.
|
||||
fn heal_split_message(
|
||||
earlier: &[TranscriptItem],
|
||||
later: &[TranscriptItem],
|
||||
@@ -450,21 +367,10 @@ fn heal_split_message(
|
||||
(earlier[..earlier.len() - 1].to_vec(), newer)
|
||||
}
|
||||
|
||||
/// Hands the older calls at the join the name of the run they are joining.
|
||||
/// Ported from `TranscriptItems.kt`'s `adoptRun`.
|
||||
///
|
||||
/// The two pages were folded separately, so a run split by the boundary
|
||||
/// came back as two runs with two names. Naming the joined run after the
|
||||
/// *older* half would be the obvious way round and is wrong: the newer half
|
||||
/// is the part already on screen, and renaming it is renaming the row the
|
||||
/// reader is looking at, which is how a list loses its anchor.
|
||||
fn adopt_run(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<TranscriptItem> {
|
||||
let Some(TranscriptItem::ToolRun { run_id, tool, .. }) = later.first() else {
|
||||
return earlier.to_vec();
|
||||
};
|
||||
// A question is in a run of its own on both sides of the join, the same as it would be
|
||||
// had the two pages been folded as one. Without this the heal would merge a group
|
||||
// straight through the row the reader was asked something on.
|
||||
if tool == ASK_USER_QUESTION {
|
||||
return earlier.to_vec();
|
||||
}
|
||||
@@ -494,10 +400,6 @@ fn adopt_run(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<Transc
|
||||
out
|
||||
}
|
||||
|
||||
/// Folds one transcript event onto `items`, the way `foldEvent` does in
|
||||
/// `TranscriptItems.kt`. Every wire event has a case; see the module doc
|
||||
/// for the one difference from the Kotlin original (no `Unknown` fallback
|
||||
/// at the parse layer).
|
||||
pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptItem> {
|
||||
let seq = entry.seq;
|
||||
match &entry.event {
|
||||
@@ -512,9 +414,6 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
||||
});
|
||||
items
|
||||
}
|
||||
// `MessageTaken` is folded into `UserMessage` by the manager before
|
||||
// it reaches a phone (see `PLAN.md`); if one arrives here anyway
|
||||
// (a raw transcript line, say), it reads the same way.
|
||||
Event::MessageTaken {
|
||||
text, attachments, ..
|
||||
} => {
|
||||
@@ -527,12 +426,6 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
||||
items
|
||||
}
|
||||
Event::AssistantText { delta } => {
|
||||
// Deltas accumulate into the message they're streaming, which
|
||||
// keeps the seq of the *first* of them: a row whose identity
|
||||
// changed with every delta would be a new row every frame.
|
||||
// "A message growing again is not finished" -- whatever a
|
||||
// status said in between -- is why this always clears
|
||||
// `settled` rather than preserving it.
|
||||
if let Some(TranscriptItem::AssistantMsg {
|
||||
seq: first_seq,
|
||||
text,
|
||||
@@ -704,7 +597,6 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
||||
});
|
||||
items
|
||||
}
|
||||
// Screen-level state, not transcript rows.
|
||||
Event::CommandQueued { .. }
|
||||
| Event::MessageQueued { .. }
|
||||
| Event::MessageDropped { .. }
|
||||
@@ -769,9 +661,6 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
||||
}
|
||||
}
|
||||
|
||||
/// What became of one tool call -- every state a card has to be able to
|
||||
/// draw, including the two that are not answers.
|
||||
///
|
||||
/// The pair this enum exists for is [`ToolState::Succeeded`] against
|
||||
/// [`ToolState::NoResult`]. A call that finished having printed nothing
|
||||
/// and a call whose result never arrived both leave an empty `output`,
|
||||
@@ -780,34 +669,18 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
||||
/// turn ended before anything came back.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ToolState {
|
||||
/// Started, no result yet, and the session is still working -- the
|
||||
/// ordinary state of a call in flight.
|
||||
Running,
|
||||
/// Stopped on the reader: a permission or question this call carries
|
||||
/// has not been answered, so nothing is happening until somebody
|
||||
/// answers it. Distinct from [`Self::Running`] because whose move it
|
||||
/// is differs, which is the Compose card's "your turn".
|
||||
Deciding,
|
||||
/// A result arrived and the tool did not report a failure.
|
||||
Succeeded,
|
||||
/// A result arrived and the tool reported that the call failed
|
||||
/// (`is_error`).
|
||||
Failed,
|
||||
/// No result ever arrived and the session is not working any more --
|
||||
/// the turn was interrupted, or the process went away. Not a verdict
|
||||
/// on the call: it says only that nobody found out.
|
||||
NoResult,
|
||||
}
|
||||
|
||||
impl ToolState {
|
||||
/// The state of one call. `session_working` is
|
||||
/// [`session_working`]'s answer for the session this call is in --
|
||||
/// the only thing here that is not a property of the call itself, and
|
||||
/// what separates "still running" from "never came back".
|
||||
///
|
||||
/// Written once, over the fields rather than per call site, because
|
||||
/// the five states are decided by four conditions and every place
|
||||
/// that re-derived a subset of them got a different subset.
|
||||
pub fn of(item: &TranscriptItem, session_working: bool) -> Option<Self> {
|
||||
let TranscriptItem::ToolRun {
|
||||
done, failed, asks, ..
|
||||
@@ -820,9 +693,6 @@ impl ToolState {
|
||||
"a call cannot have failed before its result arrived"
|
||||
);
|
||||
Some(if asks.iter().any(|ask| ask.answers.is_empty()) {
|
||||
// Ahead of `done`: a call waiting on permission has not
|
||||
// finished either, and which of the two the reader is being
|
||||
// told about is the one they can act on.
|
||||
Self::Deciding
|
||||
} else if !*done {
|
||||
match session_working {
|
||||
@@ -837,14 +707,9 @@ impl ToolState {
|
||||
}
|
||||
}
|
||||
|
||||
/// One row as the transcript draws it: a run of consecutive tool calls, or
|
||||
/// anything else. Ported from `ToolRows.kt`'s `TranscriptRow` and
|
||||
/// `groupToolRuns` -- the Compose card rendering in that file is not part
|
||||
/// of this crate.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum TranscriptRow {
|
||||
Single(TranscriptItem),
|
||||
/// Two or more calls with nothing between them.
|
||||
Tools(Vec<TranscriptItem>),
|
||||
}
|
||||
|
||||
@@ -864,9 +729,6 @@ impl TranscriptRow {
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs of adjacent tool calls become one row; everything else passes
|
||||
/// through. See the Kotlin `groupRuns`'s doc for why grouping is by the
|
||||
/// run each call names rather than by adjacency worked out here.
|
||||
pub fn group_tool_runs(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
|
||||
let mut rows = Vec::new();
|
||||
let mut run: Vec<TranscriptItem> = Vec::new();
|
||||
@@ -902,15 +764,6 @@ pub fn group_tool_runs(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
|
||||
rows
|
||||
}
|
||||
|
||||
/// Folds a page of raw transcript lines (`ApiClient::fetch_transcript_page`'s
|
||||
/// `Vec<Value>`) into the flat item list this module works over. A line
|
||||
/// this build can't parse fails the whole page rather than being skipped --
|
||||
/// CODE_RULES's "an enumeration must be able to say 'it broke'" -- since
|
||||
/// silently dropping one event could hide, say, a user message that then
|
||||
/// looks like it was never sent. Moved here from `desktop-app`'s `app.rs`
|
||||
/// (RUST.md's E4) when the Android transcript client (I5) needed the same
|
||||
/// fold: "write the logic once" applies to any caller embedding
|
||||
/// `transcript-ui` against a live server, not just the first one.
|
||||
pub fn fold_page(values: &[serde_json::Value]) -> Result<Vec<TranscriptItem>, String> {
|
||||
let mut items = Vec::new();
|
||||
for value in values {
|
||||
@@ -922,13 +775,6 @@ pub fn fold_page(values: &[serde_json::Value]) -> Result<Vec<TranscriptItem>, St
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// The wire `seq` a raw transcript line carries -- the live-stream resume
|
||||
/// cursor after loading a page must be this, not a folded item's `seq()`.
|
||||
/// A folded `AssistantMsg` keeps the seq of the *first* delta it
|
||||
/// accumulated (`fold_event`'s own doc), so resuming from that seq would
|
||||
/// re-deliver every delta already folded into it, duplicating the tail of
|
||||
/// a reply that was mid-stream when the page was fetched -- found via a
|
||||
/// real screenshot in E4 (RUST.md), where the assistant's line doubled.
|
||||
pub fn raw_seq(value: &serde_json::Value) -> Option<u64> {
|
||||
value.get("seq")?.as_u64()
|
||||
}
|
||||
@@ -1161,12 +1007,6 @@ mod tests {
|
||||
obj
|
||||
}
|
||||
|
||||
/// The regression for a bug a real `run-headless.sh` screenshot found
|
||||
/// in `desktop-app` (E4, RUST.md): resuming the live stream from the
|
||||
/// last *item's* seq re-delivers the deltas already folded into a
|
||||
/// still-open assistant message, doubling its tail. `raw_seq` of the
|
||||
/// last wire line must be the true high-water mark instead, which for a
|
||||
/// run of deltas is higher than every item's own `seq()`.
|
||||
#[test]
|
||||
fn the_resume_cursor_is_the_last_wire_seq_not_the_last_items_seq() {
|
||||
let values = vec![
|
||||
@@ -1260,13 +1100,6 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// AGENTS.md's "things that have bitten": `joinPages` used to run
|
||||
/// `adoptRun` only on the path where a *split* call was found, so a
|
||||
/// boundary landing cleanly between two already-finished calls -- most
|
||||
/// of them -- left the older page's calls under the run name they were
|
||||
/// folded with, drawing one run of tool calls as two groups. Two
|
||||
/// finished, unrelated calls (no id in common) must still end up under
|
||||
/// one run name after the join.
|
||||
#[test]
|
||||
fn a_clean_boundary_between_two_finished_runs_is_still_healed_into_one_run() {
|
||||
let older = fold_all(&[tool_start(1, "a", "Bash"), tool_end(2, "a", "old output")]);
|
||||
@@ -1346,9 +1179,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A question is in a run of its own on both sides of a join -- healing
|
||||
/// must never rename the run of calls the reader was asked something
|
||||
/// on, the same rule `splitRun` enforces for a live turn boundary.
|
||||
#[test]
|
||||
fn adopt_run_never_renames_into_a_question_row() {
|
||||
let older = fold_all(&[tool_start(1, "a", "Bash"), tool_end(2, "a", "done")]);
|
||||
@@ -1372,10 +1202,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`ToolState`] is what a card colours itself by, so each of its five
|
||||
/// states is asserted from the events that actually produce it rather than
|
||||
/// from a hand-built item -- a mapping that agreed with a fixture and
|
||||
/// disagreed with the fold would be invisible until it was on screen.
|
||||
#[cfg(test)]
|
||||
mod tool_state_tests {
|
||||
use super::*;
|
||||
@@ -1433,10 +1259,6 @@ mod tool_state_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The pair this enum exists for. Both calls have an empty `output`
|
||||
/// and nothing else distinguishes them, so a card that only looked at
|
||||
/// the text would draw the interrupted one as a call that ran fine and
|
||||
/// printed nothing.
|
||||
#[test]
|
||||
fn a_call_that_printed_nothing_is_not_a_call_that_never_answered() {
|
||||
assert_eq!(
|
||||
@@ -1451,9 +1273,6 @@ mod tool_state_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The same call, mid-turn: still running rather than abandoned. The
|
||||
/// only thing separating the two is the session's own status, which is
|
||||
/// why `of` takes it.
|
||||
#[test]
|
||||
fn no_result_while_the_session_works_is_still_running() {
|
||||
assert_eq!(state_of(&[start("a")], true), ToolState::Running);
|
||||
@@ -1483,8 +1302,6 @@ mod tool_state_tests {
|
||||
answers: vec!["Allow".to_string()],
|
||||
},
|
||||
);
|
||||
// Ahead of both "still running" and "no result": the reader can
|
||||
// act on this one, and cannot act on either of those.
|
||||
assert_eq!(
|
||||
state_of(&[start("a"), asking.clone()], true),
|
||||
ToolState::Deciding
|
||||
|
||||
@@ -1,33 +1,9 @@
|
||||
//! Where a session screen gets a transcript from: this phone's copy first,
|
||||
//! the server for the rest. Ported from `app/.../TranscriptSource.kt`; see
|
||||
//! `docs/TRANSCRIPT_CACHE.md` for the design this implements and
|
||||
//! `docs/CLIENT_CORE.md` for how this file corresponds to the Kotlin.
|
||||
//!
|
||||
//! One seam rather than a cache the screen has to remember to consult.
|
||||
//! Everything fetched before is asked of this, and everything the server
|
||||
//! sends is written into the cache on the way past, so a caller never
|
||||
//! learns which side answered. The one rule worth keeping in mind: the
|
||||
//! cache is never load-bearing. Every read here has a network path beside
|
||||
//! it producing the same result.
|
||||
//!
|
||||
//! **Not ported**: `EventStream.kt`'s reconnect-with-backoff loop and the
|
||||
//! ability to close a live stream from another thread. Both are wall-clock
|
||||
//! and thread-lifetime concerns that belong to whatever runtime the caller
|
||||
//! embeds this crate in (a Tokio task, an iris timer, a Kotlin coroutine
|
||||
//! scope) rather than to this pure logic -- `follow` below is the same
|
||||
//! decorator shape `iris/desktop-app/src/app.rs` and
|
||||
//! `iris/android-app/src/transcript_client.rs` already hand-wrote around
|
||||
//! `event_stream::follow_session_events`, just with the cache write built
|
||||
//! in so a future caller does not have to repeat it a third time.
|
||||
|
||||
use event_model::SeqEvent;
|
||||
|
||||
use crate::client::api::{ApiClient, ApiError, Transport};
|
||||
use crate::client::event_stream::{self, StreamItem};
|
||||
use crate::client::transcript_cache::SessionCache;
|
||||
|
||||
/// How many events a session screen opens with, cached or fetched.
|
||||
///
|
||||
/// The server's own default page size, named here because the cached
|
||||
/// opening has to be the same size as the fetched one -- a reader must not
|
||||
/// get a shorter first screen for having been here before (`OPENING_WINDOW`
|
||||
@@ -49,9 +25,6 @@ impl std::fmt::Display for ParseError {
|
||||
}
|
||||
impl std::error::Error for ParseError {}
|
||||
|
||||
/// Either half of what can go wrong asking for a page: the network, or a
|
||||
/// line neither the cache's nor the server's copy of `parseSeqEvent` could
|
||||
/// read.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PageError {
|
||||
Api(ApiError),
|
||||
@@ -70,23 +43,9 @@ impl From<ParseError> for PageError {
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`TranscriptSource::page`] found, kept as two states rather than
|
||||
/// one possibly-empty list.
|
||||
///
|
||||
/// The difference is the whole of AGENTS.md's `loadOlderPage` incident: an
|
||||
/// empty [`Self::Events`] means "this conversation has no more history",
|
||||
/// which a caller is meant to latch, and [`Self::NothingLoaded`] means the
|
||||
/// question could not be asked yet, which it must not. Collapsing the two
|
||||
/// into an empty `Vec` puts the bug back, because the caller cannot tell
|
||||
/// them apart -- and `unwrap_or_default()` on an `Option` would do the
|
||||
/// same silently.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum OlderPage {
|
||||
/// The events before the cursor, oldest first. Empty means the start
|
||||
/// of the conversation has been reached.
|
||||
Events(Vec<SeqEvent>),
|
||||
/// Nothing is loaded, so there was no cursor to page back from
|
||||
/// (`before == 0`). Not an answer about the conversation at all.
|
||||
NothingLoaded,
|
||||
}
|
||||
|
||||
@@ -94,8 +53,6 @@ fn parse_line(line: &str) -> Result<SeqEvent, ParseError> {
|
||||
serde_json::from_str(line).map_err(|e| ParseError(format!("{e}")))
|
||||
}
|
||||
|
||||
/// This phone's copy of one session's transcript, plus the server it
|
||||
/// falls back to. Ported from the Kotlin `TranscriptSource` class.
|
||||
pub struct TranscriptSource<T: Transport> {
|
||||
api: ApiClient<T>,
|
||||
session_id: String,
|
||||
@@ -113,11 +70,6 @@ impl<T: Transport> TranscriptSource<T> {
|
||||
|
||||
/// The cached opening window, or `None` when there is nothing usable
|
||||
/// to draw.
|
||||
///
|
||||
/// Meant to be drawn *before* [`Self::probe`] returns, which is the
|
||||
/// whole point of the feature: the rows are on screen while the check
|
||||
/// that they are still the server's rows is in flight, and a failed
|
||||
/// check replaces them exactly as a reset does.
|
||||
pub fn cached_opening(&self, limit: usize) -> Option<Vec<SeqEvent>> {
|
||||
self.cache.tail()?;
|
||||
let lines = self.cache.newest(limit);
|
||||
@@ -126,9 +78,6 @@ impl<T: Transport> TranscriptSource<T> {
|
||||
}
|
||||
match lines.iter().map(|l| parse_line(l)).collect() {
|
||||
Ok(events) => Some(events),
|
||||
// A line this build cannot read at all, which the cache's own checks cannot
|
||||
// see: it reads a seq off a line, not an event. Nothing to serve, so a cold
|
||||
// open.
|
||||
Err(ParseError(_)) => {
|
||||
self.cache.purge();
|
||||
None
|
||||
@@ -136,9 +85,6 @@ impl<T: Transport> TranscriptSource<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the server's event at the cached cursor is still the cached
|
||||
/// one.
|
||||
///
|
||||
/// A caller must not resume a live stream from a cached seq unless it
|
||||
/// is the same conversation: a transcript is append-only in ordinary
|
||||
/// use, but the file backing it can be replaced or truncated (a
|
||||
@@ -151,15 +97,10 @@ impl<T: Transport> TranscriptSource<T> {
|
||||
/// `Ok(false)` purges the cache and means "open cold". `Err` is the
|
||||
/// server not being askable, which is neither: the cached rows stay
|
||||
/// on screen and the caller tries again on its own reconnect schedule.
|
||||
///
|
||||
/// What this cannot see is a line changed in the middle of the file
|
||||
/// with the tail intact -- that is what a full reload is for.
|
||||
pub fn probe(&self) -> Result<bool, ApiError> {
|
||||
let Some(tail) = self.cache.tail() else {
|
||||
return Ok(false);
|
||||
};
|
||||
// `before = seq + 1` is the newest event with seq <= the cursor, which is the
|
||||
// event *at* the cursor when the server still has one there.
|
||||
let page = self.api.fetch_transcript_lines(
|
||||
&self.session_id,
|
||||
Some(tail.seq + 1),
|
||||
@@ -177,9 +118,6 @@ impl<T: Transport> TranscriptSource<T> {
|
||||
Ok(matches)
|
||||
}
|
||||
|
||||
/// Today's opening fetch, kept as the start of the live run. Only
|
||||
/// called when the cache has nothing to open with, or when
|
||||
/// [`Self::probe`] said what it had was not the server's.
|
||||
pub fn fetch_opening(&self) -> Result<Vec<SeqEvent>, ApiError> {
|
||||
let page =
|
||||
self.api
|
||||
@@ -193,21 +131,6 @@ impl<T: Transport> TranscriptSource<T> {
|
||||
|
||||
/// The page before `before`: from the cache when it holds it,
|
||||
/// otherwise from the server bounded by what the cache already has.
|
||||
///
|
||||
/// The server bound (`after`) is what keeps the cache worth having. A
|
||||
/// coalesced page reaches back as far as its row count takes it -- a
|
||||
/// single reply is hundreds of lines -- so a page fetched after the
|
||||
/// reader has been away could run straight past the cached run and
|
||||
/// overlap it, and an overlapping page cannot be stored. Told where
|
||||
/// this phone's copy starts, the server stops there instead.
|
||||
///
|
||||
/// `before == 0` answers [`OlderPage::NothingLoaded`] without asking
|
||||
/// the cache or the server anything -- see AGENTS.md's "things that
|
||||
/// have bitten": there is no event before the first one, so the
|
||||
/// request is not a harmless no-op, and its empty answer is
|
||||
/// indistinguishable from having reached the start of history.
|
||||
/// Guarded here rather than left to every caller, because it is a fact
|
||||
/// about the question, not about who is asking it.
|
||||
pub fn page(&self, before: u64, limit: u32, coalesce: bool) -> Result<OlderPage, PageError> {
|
||||
if before == 0 {
|
||||
return Ok(OlderPage::NothingLoaded);
|
||||
@@ -228,9 +151,6 @@ impl<T: Transport> TranscriptSource<T> {
|
||||
after,
|
||||
)?;
|
||||
if let Some((_, first_event)) = page.first() {
|
||||
// `before` rather than the newest line's seq: a coalesced page covers
|
||||
// everything up to the cursor it was asked with, and nothing in its lines
|
||||
// says so.
|
||||
let lines: Vec<String> = page.iter().map(|(line, _)| line.clone()).collect();
|
||||
self.cache
|
||||
.store_page(&lines, first_event.seq, before, coalesce);
|
||||
@@ -240,9 +160,6 @@ impl<T: Transport> TranscriptSource<T> {
|
||||
))
|
||||
}
|
||||
|
||||
/// [`event_stream::follow_session_events`], with every frame written to
|
||||
/// the cache before `on_item` sees it.
|
||||
///
|
||||
/// Before, so that an event held back for a reader who is scrolled
|
||||
/// away is already on disk -- what the cache holds is what the server
|
||||
/// sent, not what a screen has got round to drawing. Flushed on each
|
||||
@@ -289,11 +206,6 @@ mod tests {
|
||||
use std::io::Read;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// A transport that answers fixed bodies in call order, and records
|
||||
/// every path it was asked for -- so a test can assert *how many*
|
||||
/// requests a method made, which is the point for the `before == 0`
|
||||
/// guard (AGENTS.md's regression: the guard must stop the request
|
||||
/// before it happens, not merely tolerate the empty answer).
|
||||
#[derive(Default)]
|
||||
struct ScriptedTransport {
|
||||
responses: Mutex<VecDeque<(u16, String)>>,
|
||||
@@ -371,7 +283,6 @@ mod tests {
|
||||
let opening = source.fetch_opening().unwrap();
|
||||
assert_eq!(opening.len(), 1);
|
||||
assert_eq!(opening[0].seq, 1);
|
||||
// The fetch wrote through: reopening the same cache now has something to show.
|
||||
assert!(source.cache.tail().is_some());
|
||||
}
|
||||
|
||||
@@ -399,8 +310,6 @@ mod tests {
|
||||
let source = source(transport, dir.path());
|
||||
source.fetch_opening().unwrap();
|
||||
|
||||
// The server now answers with a different event at the same seq -- the file
|
||||
// behind this session was replaced.
|
||||
let transport2 = ScriptedTransport::default();
|
||||
let different = r#"{"seq":1,"ts":1.0,"type":"status","state":"running"}"#.to_string();
|
||||
transport2.respond(200, format!("[{different}]"));
|
||||
@@ -429,10 +338,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The regression this module exists to close: `before == 0` must
|
||||
/// never reach the network or the cache, because an empty answer there
|
||||
/// is indistinguishable from "there is genuinely no more history" --
|
||||
/// AGENTS.md's `loadOlderPage` incident.
|
||||
#[test]
|
||||
fn paging_before_the_first_event_makes_no_request_at_all() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -463,8 +368,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// With nothing older cached there is no floor to give the server, so
|
||||
/// the request carries no `after` at all.
|
||||
#[test]
|
||||
fn a_server_page_with_nothing_older_cached_carries_no_bound() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -484,17 +387,10 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The half the test above cannot show: when the cache *does* hold an
|
||||
/// older run, the fetch is floored at its end, or the page would run
|
||||
/// straight past it and overlap -- which `store_page` then refuses,
|
||||
/// silently costing the phone the page it just paid for.
|
||||
#[test]
|
||||
fn a_server_page_is_floored_at_the_end_of_the_cached_run() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||
// A stored page covering [3, 6) and two live events above it, so the run this
|
||||
// phone holds is [3, 8) -- the newest chunk has to be an appended one, or the
|
||||
// cache reads the directory as damaged and discards it.
|
||||
let lines: Vec<String> = (3..6).map(status_line).collect();
|
||||
assert!(cache.store_page(&lines, 3, 6, true));
|
||||
cache.append(&status_line(6), 6);
|
||||
@@ -512,9 +408,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A page the server could not answer is an error, never an empty
|
||||
/// page: the caller would read the second as "this conversation has no
|
||||
/// more history" and stop paging for good.
|
||||
#[test]
|
||||
fn a_failing_server_page_is_an_error_rather_than_an_empty_one() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -524,8 +417,6 @@ mod tests {
|
||||
assert!(matches!(source.page(9, 10, true), Err(PageError::Api(_)),));
|
||||
}
|
||||
|
||||
/// A cached line this build cannot read is told apart from the network
|
||||
/// failing, for the same reason: neither is "no more history".
|
||||
#[test]
|
||||
fn an_unreadable_cached_page_is_a_parse_error_rather_than_an_empty_one() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
Reference in new issue
Block a user