diff --git a/CHANGELOG.md b/CHANGELOG.md index 48c4a63..59fe7b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,9 @@ does. Late in a minute, `check_limits` could say a limit resets "14:00 (in 1h 59m)" above a line that says "14:00 (in 2h)", and `statusline` could say "out in 59m" while `status` said "13:00 (in 1h)". +- **The app** says it's reading your agents' history until the first read + is done, also when it connects while that read runs. It stopped saying so + once the read had begun. ## 0.1.4 diff --git a/contract/error.json b/contract/error.json index 0699e90..2c22bb2 100644 --- a/contract/error.json +++ b/contract/error.json @@ -3,10 +3,10 @@ "code": -32001, "data": { "supported": [ - 1 + 2 ] }, - "message": "this turnscope speaks protocol 1, not 2" + "message": "this turnscope speaks protocol 2, not 3" }, "id": 1, "jsonrpc": "2.0" diff --git a/contract/hello.request.json b/contract/hello.request.json index 5b4fcb7..68cbb7f 100644 --- a/contract/hello.request.json +++ b/contract/hello.request.json @@ -3,6 +3,6 @@ "jsonrpc": "2.0", "method": "hello", "params": { - "protocol": 1 + "protocol": 2 } } diff --git a/contract/hello.result.json b/contract/hello.result.json index 20a8503..1f3e8be 100644 --- a/contract/hello.result.json +++ b/contract/hello.result.json @@ -436,7 +436,7 @@ } ], "at": 1791460800000, - "historyReadAt": 1791460740000, + "historyRead": true, "settings": { "notify": { "reset": true, diff --git a/contract/status.notification.json b/contract/status.notification.json index aaba59b..eadec53 100644 --- a/contract/status.notification.json +++ b/contract/status.notification.json @@ -369,7 +369,7 @@ } ], "at": 1791460800000, - "historyReadAt": 1791460740000, + "historyRead": true, "settings": { "notify": { "reset": true, diff --git a/docs/architecture.md b/docs/architecture.md index c3931a8..9a973bd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -71,7 +71,7 @@ directory. Times are UTC milliseconds and money is dollars. The schema is | `sign_in` | What each agent folder held for each provider, from when: the account, and an API key's fingerprint | | `alert` | Each alert raised, what it says, and whether a client acknowledged it | | `price` | models.dev's prices for each model, each from when it was read | -| `state` | One row: the reader version the history was read with, when history, limits and prices were last read, and the settings | +| `state` | One row: the reader version the history was read with, when its first read finished, when limits and prices were last read, and the settings | `db::open` creates or migrates the database. Each revision is one script: a shipped script is never edited, and a change is a new script at the end. diff --git a/docs/protocol.md b/docs/protocol.md index 1473a0c..f7fb33f 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -38,7 +38,7 @@ Try it by hand, with the app running or after `turnscope serve &`: ```sh nc -U "$(turnscope serve --socket)" -{"jsonrpc":"2.0","id":1,"method":"hello","params":{"protocol":1}} +{"jsonrpc":"2.0","id":1,"method":"hello","params":{"protocol":2}} ``` ## Lifecycle @@ -78,7 +78,7 @@ sync. Until `hello` succeeds, any other request but `shutdown` gets `-32002`. ```json -{"id":1,"jsonrpc":"2.0","method":"hello","params":{"protocol":1}} +{"id":1,"jsonrpc":"2.0","method":"hello","params":{"protocol":2}} ``` ```json @@ -88,7 +88,7 @@ sync. Until `hello` succeeds, any other request but `shutdown` gets A protocol the server doesn't speak gets `-32001` (`error.json`): ```json -{"error":{"code":-32001,"data":{"supported":[1]},"message":"this turnscope speaks protocol 1, not 2"},"id":1,"jsonrpc":"2.0"} +{"error":{"code":-32001,"data":{"supported":[2]},"message":"this turnscope speaks protocol 2, not 3"},"id":1,"jsonrpc":"2.0"} ``` ## Requests @@ -165,7 +165,7 @@ take a message within 2 seconds is dropped. | `-32601` | No such method | `no method ` | | `-32602` | Parameters that don't fit, or an unknown agent | `parameters that don't fit: `, `no agent is ` | | `-32000` | The engine couldn't do it | Why, such as `no limit of ` | -| `-32001` | Unsupported protocol. `data.supported` lists those spoken | `this turnscope speaks protocol 1, not ` | +| `-32001` | Unsupported protocol. `data.supported` lists those spoken | `this turnscope speaks protocol 2, not ` | | `-32002` | A request before `hello` | `send hello first` | ## Conventions @@ -192,7 +192,7 @@ and MCP server use those. | Field | Type | Holds | |---|---|---| | `at` | ms | The minute it was worked out for | -| `historyReadAt` | ms or null | When the agents' history was last read. Null until the first read finishes | +| `historyRead` | bool | Whether the first read of the agents' history finished | | `accounts` | [Account] | Every account, hidden ones too, in the order to list them | | `agents` | [Agent] | Every agent Turnscope reads, installed or not | | `settings` | Settings | The user's settings | @@ -447,7 +447,7 @@ sent, not when it was raised. ## Versioning -`protocol` is an integer, now `1`: `PROTOCOL` in `src/serve.rs` and +`protocol` is an integer, now `2`: `PROTOCOL` in `src/serve.rs` and `protocolVersion` in `Contract.swift`. Within a version, fields may be added, and clients ignore fields they don't know. Anything else needs a new version: a new enum value, a field removed or renamed, or a field with a diff --git a/macos/TurnscopeKit/Sources/TurnscopeKit/Contract.swift b/macos/TurnscopeKit/Sources/TurnscopeKit/Contract.swift index b4826ed..fe98367 100644 --- a/macos/TurnscopeKit/Sources/TurnscopeKit/Contract.swift +++ b/macos/TurnscopeKit/Sources/TurnscopeKit/Contract.swift @@ -10,16 +10,16 @@ import Foundation /// The protocol version this app speaks. -public let protocolVersion = 1 +public let protocolVersion = 2 // MARK: Status /// Everything the app shows, as of `at`. public struct Status: Decodable, Equatable, Sendable { public var at: Date - /// When the agents' history was last read through. Nil while it's being - /// read for the first time. - public var historyReadAt: Date? + /// Whether the agents' history has been read through once. False while + /// it's being read for the first time. + public var historyRead: Bool /// Every account, in the order to list them: accounts in use first, /// most urgent first. public var accounts: [AccountStatus] diff --git a/macos/TurnscopeKit/Sources/TurnscopeKit/Store.swift b/macos/TurnscopeKit/Sources/TurnscopeKit/Store.swift index a0397d8..bed1018 100644 --- a/macos/TurnscopeKit/Sources/TurnscopeKit/Store.swift +++ b/macos/TurnscopeKit/Sources/TurnscopeKit/Store.swift @@ -86,7 +86,7 @@ public final class Store { /// The names of every agent Turnscope reads, installed or not. public var agentNames: [String] { status?.agents.map(\.name) ?? [] } /// Whether the agents' history is still being read for the first time. - public var reading: Bool { status != nil && status?.historyReadAt == nil } + public var reading: Bool { status?.historyRead == false } /// Which notifications are turned on. public var notify: Notify? { status?.settings.notify } diff --git a/macos/TurnscopeKit/Tests/TurnscopeKitTests/EngineTests.swift b/macos/TurnscopeKit/Tests/TurnscopeKitTests/EngineTests.swift index d0045a9..ff1c414 100644 --- a/macos/TurnscopeKit/Tests/TurnscopeKitTests/EngineTests.swift +++ b/macos/TurnscopeKit/Tests/TurnscopeKitTests/EngineTests.swift @@ -27,7 +27,7 @@ func theRealEngineIsStartedSaysHelloAndTakesChanges() async throws { client.start() // Nothing is listening, so the client starts serve, which reads the // history first. - await until { store.status?.historyReadAt != nil } + await until { store.status?.historyRead == true } #expect(client.phase == .connected) #expect(store.status?.agents.isEmpty == false) // A change goes through serve, which then pushes a status that has it. diff --git a/src/alerts.rs b/src/alerts.rs index 6e81dd1..4331e9b 100644 --- a/src/alerts.rs +++ b/src/alerts.rs @@ -294,7 +294,7 @@ mod tests { }; Status { at, - history_read_at: None, + history_read: false, accounts: vec![Account { id: "anthropic:a".to_owned(), provider: "anthropic".to_owned(), diff --git a/src/ingest.rs b/src/ingest.rs index aa52373..536b1f5 100644 --- a/src/ingest.rs +++ b/src/ingest.rs @@ -199,7 +199,10 @@ fn due(db: &Connection, home: &Path) -> Result> { pub fn ingest(db: &mut Connection, home: &Path) -> Result { let _lock = lock(db)?; - let readers: i64 = db.query_row("SELECT readers FROM state", [], |row| row.get(0))?; + let (readers, first): (i64, bool) = + db.query_row("SELECT readers, history_at IS NULL FROM state", [], |row| { + Ok((row.get(0)?, row.get(1)?)) + })?; // An older Turnscope still running, as an agent's MCP server can be, // would read it all again its way, and this one back again. if readers > READERS { @@ -210,8 +213,8 @@ pub fn ingest(db: &mut Connection, home: &Path) -> Result { } let due = due(db, home)?; - db.execute("UPDATE state SET history_at = ?1", [crate::now()])?; - if due.is_empty() { + // The first read ends by saying so, even with nothing to read. + if due.is_empty() && !first { return Ok(false); } @@ -258,6 +261,12 @@ pub fn ingest(db: &mut Connection, home: &Path) -> Result { })?; // Statistics for the query planner, where tables changed enough. db.execute_batch("PRAGMA optimize")?; + // `history_at` is when the first read finished. Only whether it has is + // shown, so it's written once: a catch-up after it writes nothing to say + // it ran. + if first { + db.execute("UPDATE state SET history_at = ?1", [crate::now()])?; + } Ok(wrote) } @@ -697,6 +706,21 @@ mod tests { assert!(!ingest(&mut db, home.path()).unwrap()); } + #[test] + fn a_catch_up_after_the_first_read_that_reads_nothing_writes_nothing() { + let (dir, home, mut db, folder) = store(); + std::fs::write(folder.join("s1.jsonl"), line("Fix the parser.")).unwrap(); + let unread = "SELECT history_at IS NULL FROM state"; + assert_eq!(count(&db, unread), 1); + assert!(ingest(&mut db, home.path()).unwrap()); + assert_eq!(count(&db, unread), 0); + // It moves when another connection writes. + let other = crate::db::open(dir.path()).unwrap(); + let before = count(&other, "PRAGMA data_version"); + assert!(!ingest(&mut db, home.path()).unwrap()); + assert_eq!(count(&other, "PRAGMA data_version"), before); + } + #[test] fn a_gone_files_inode_under_another_agent_isnt_taken_as_moved() { let (_dir, home, mut db, folder) = store(); diff --git a/src/serve.rs b/src/serve.rs index 542a577..d4f60cf 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -32,7 +32,7 @@ use serde_json::{Value, json}; use crate::{Error, Result, agents, alerts, status, usage}; /// The protocol spoken, raised for any change a client can't ignore. -pub const PROTOCOL: u64 = 1; +pub const PROTOCOL: u64 = 2; pub fn socket_path(data: &Path) -> PathBuf { let path = data.join("serve.sock"); @@ -298,12 +298,6 @@ impl Server { let mut shown = serde_json::to_value(&app).unwrap_or_default(); if let Some(fields) = shown.as_object_mut() { fields.remove("at"); - // Set at every catch-up; what's shown is only whether the first - // read is done. - fields.insert( - "historyReadAt".to_owned(), - json!(app.status.history_read_at.is_some()), - ); } if self.sent.as_ref().is_some_and(|(_, sent)| *sent == shown) { return; @@ -591,4 +585,58 @@ mod tests { again::(each); } } + + /// A status is pushed when what it shows changed, and only then: not + /// after a catch-up that read history but changed nothing shown. + #[test] + fn a_status_is_pushed_only_when_what_it_shows_changed() { + use std::io::BufRead as _; + let (data, home) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); + // Claude Code is installed from the start, with no history yet. + let folder = home.path().join(".claude/projects/-work-a"); + std::fs::create_dir_all(&folder).unwrap(); + let (stream, app) = super::UnixStream::pair().unwrap(); + app.set_nonblocking(true).unwrap(); + let mut app = std::io::BufReader::new(app); + let mut server = super::Server { + db: crate::db::open(data.path()).unwrap(), + home: home.path().to_path_buf(), + clients: [( + 1, + super::Client { + stream, + greeted: true, + }, + )] + .into(), + sent: None, + sender: std::sync::mpsc::channel().0, + }; + // Push, and say whether the first read had finished in each status + // the app was sent. + let mut pushed = |server: &mut super::Server| -> Vec { + server.push(); + (&mut app) + .lines() + .map_while(Result::ok) + .map(|line| serde_json::from_str::(&line).unwrap()) + .map(|message| message["params"]["historyRead"].clone()) + .collect() + }; + + assert_eq!(pushed(&mut server), [false]); + // The first read finishes, though there's nothing to read. + crate::ingest::ingest(&mut server.db, home.path()).unwrap(); + assert_eq!(pushed(&mut server), [true]); + // A session that drew on no account: read, and nothing shown changed. + let line = serde_json::json!({"type": "user", "sessionId": "s1", + "timestamp": "2026-09-30T09:00:00Z", "message": {"role": "user", "content": "Hi."}}); + std::fs::write(folder.join("s1.jsonl"), format!("{line}\n")).unwrap(); + assert!(crate::ingest::ingest(&mut server.db, home.path()).unwrap()); + assert!(pushed(&mut server).is_empty()); + let mut settings = crate::alerts::Settings::default(); + settings.notify.reset = false; + crate::alerts::save_settings(&server.db, &settings).unwrap(); + assert_eq!(pushed(&mut server), [true]); + } } diff --git a/src/status.rs b/src/status.rs index 29bab4d..2f55724 100644 --- a/src/status.rs +++ b/src/status.rs @@ -40,8 +40,8 @@ const STALE: i64 = 30 * MINUTE; pub struct Status { /// The minute it's as of. pub at: i64, - /// When the agents' history was last read. - pub history_read_at: Option, + /// Whether the first read of the agents' history finished. + pub history_read: bool, /// In the order to list them: in use first, the most urgent first. pub accounts: Vec, pub settings: crate::alerts::Settings, @@ -492,7 +492,9 @@ pub fn status(db: &rusqlite::Connection, now: i64) -> Result { rank(&mut accounts); Ok(Status { at: now, - history_read_at: db.query_row("SELECT history_at FROM state", [], |row| row.get(0))?, + history_read: db.query_row("SELECT history_at IS NOT NULL FROM state", [], |row| { + row.get(0) + })?, accounts, settings: crate::alerts::settings(db)?, }) diff --git a/tests/cli.rs b/tests/cli.rs index 257b09b..21ad801 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -608,9 +608,9 @@ fn serve_says_hello_with_the_status_and_answers_in_turn() { }; let early = ask(br#"{"jsonrpc":"2.0","id":1,"method":"alerts.ack","params":{"ids":[]}}"#); assert_eq!(early["error"]["code"], -32002); - let other = ask(br#"{"jsonrpc":"2.0","id":2,"method":"hello","params":{"protocol":2}}"#); + let other = ask(br#"{"jsonrpc":"2.0","id":2,"method":"hello","params":{"protocol":1}}"#); assert_eq!(other["error"]["code"], -32001); - let hello = ask(br#"{"jsonrpc":"2.0","id":3,"method":"hello","params":{"protocol":1}}"#); + let hello = ask(br#"{"jsonrpc":"2.0","id":3,"method":"hello","params":{"protocol":2}}"#); assert_eq!( hello["result"]["server"], format!("turnscope {}", env!("CARGO_PKG_VERSION"))