feat(sidecar): protocol 2 — file by type, a cursor feed, and bounded history
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 3m14s

The sidecar now files a frame by its `type` and never by its `kind`. That is the
dumb-forwarder property made structural: `event` is appended to history,
`snapshot` replaces the board of its kind, `reply` is routed by `reqId`,
`control` is broadcast and kept nowhere. Ten new event kinds are no change here
at all, which is the whole point when the thing that grows fastest is the
catalogue.

A frame whose `type` this build does not know is dropped and counted, never
guessed at. Defaulting an absent one to `event` would file a BOARD as history —
the presence board appended a few thousand times, which nothing reports. The
count is on `/health` as `untyped_frames`, because the failure it diagnoses (a
plugin and a sidecar on different protocol versions, which the game link has no
handshake to catch) otherwise presents as a website showing nothing while the
game is plainly up. It caught exactly that within three seconds of first running,
against a protocol 1 plugin still live on a retired rig.

`boards` generalises protocol 1's single `server_state` row, and a database made
by protocol 1 is migrated in place: the two indexed columns are added by a
guarded `ALTER`, and the old board is carried across. Without that carry-over an
upgraded sidecar answers `204` until the game next connects, and the website
reads that as "never heard from" — losing a server it has rendered for weeks at
the exact moment somebody upgraded the bridge.

`GET /feed` is the ingest cursor: oldest first, strictly after an id, with
`lastId` and `more`. It is a separate route rather than a flag on `/events`
because one route with two orderings serves the other one to every caller that
forgets the parameter — and for the ingesting caller that means advancing its
cursor past rows it never read. Omitting `since` asks where the END is; `since=0`
is the other question entirely, and the two must not be separated by whether
somebody typed a parameter.

`[store].retain_days` (default 14) prunes events hourly. Boards are never pruned:
history grows and the present does not, and a pruned board is a server that has
never connected.

The repository also had no CI. `pr-checks.yml` runs the fmt, clippy and test
gates phases 1 and 3 have both been running by hand — a guard nothing invokes is
a guard whose state nobody knows.

44 tests pass, clippy clean at `-D warnings`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
2026-09-16 08:18:43 -05:00
parent c526c55c36
commit 06fa5d7330
8 changed files with 910 additions and 116 deletions

View File

@@ -15,7 +15,7 @@
//! route that fails when the game is down — which is the honest answer to "what is it doing *right
//! now*".
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
@@ -57,6 +57,8 @@ pub struct AppState {
pub started: Instant,
/// Epoch ms of the last line received from the plugin, 0 if none yet.
pub last_event: Arc<AtomicI64>,
/// Frames this build could not file, because they carried no usable `type`. See `/health`.
pub untyped: Arc<AtomicU64>,
}
pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
@@ -64,8 +66,14 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
.route(WS_PATH, get(ws_upgrade))
// The board: the last `server.hello`. Store-backed, so it answers while the game is off.
.route("/server", get(server_board))
// Event history, newest first, optionally filtered by kind.
// Every board at once — what a consumer reads on connect to know the present before it
// starts following the story.
.route("/boards", get(boards))
// Event history, newest first, optionally filtered by kind and wipe. For a human.
.route("/events", get(events))
// The ingest cursor: oldest first, strictly after an id. For a consumer that must not miss
// a row. Deliberately a second route rather than a flag on the first — see `feed`.
.route("/feed", get(feed))
// Live: a correlated round trip to the plugin. Fails when the game is down, by design.
.route("/status", get(status))
.route_layer(middleware::from_fn_with_state(state.clone(), gate));
@@ -95,6 +103,8 @@ async fn health(State(st): State<AppState>) -> impl IntoResponse {
let status = if plugin && db_ok { "ok" } else { "degraded" };
let untyped = st.untyped.load(Ordering::Relaxed);
Json(json!({
"status": status,
"protocol": PROTOCOL_VERSION,
@@ -102,6 +112,10 @@ async fn health(State(st): State<AppState>) -> impl IntoResponse {
"database": if db_ok { "ok" } else { "error" },
"uptime": format_uptime(st.started.elapsed()),
"last_event": iso_ms(last_ms),
// Non-zero means the plugin is speaking a protocol this build cannot file. It is reported
// here rather than only in the log because the symptom an operator sees — a website that
// shows nothing while the game is plainly up — names nothing at all.
"untyped_frames": untyped,
}))
}
@@ -133,7 +147,7 @@ fn iso_ms(ms: i64) -> Option<String> {
/// reports nothing" are different answers, and a client that cannot tell them apart renders a
/// server that does not exist.
async fn server_board(State(st): State<AppState>) -> Response {
match st.store.server_state().await {
match st.store.board(crate::store::SERVER_BOARD).await {
Ok(Some(v)) => Json(v).into_response(),
Ok(None) => StatusCode::NO_CONTENT.into_response(),
Err(e) => {
@@ -143,16 +157,30 @@ async fn server_board(State(st): State<AppState>) -> Response {
}
}
/// Every board, keyed by kind. `200` with an empty object when the game has never connected —
/// unlike `/server`, which answers `204`, because "no boards yet" is a complete answer to "give me
/// all of them" and an empty map renders correctly where a `204` has to be special-cased.
async fn boards(State(st): State<AppState>) -> Response {
match st.store.all_boards().await {
Ok(map) => Json(json!({ "boards": map })).into_response(),
Err(e) => {
warn!(error = %e, "board read failed");
store_error()
}
}
}
#[derive(Debug, Deserialize)]
struct EventsQuery {
kind: Option<String>,
wipe: Option<String>,
limit: Option<i64>,
}
async fn events(State(st): State<AppState>, Query(q): Query<EventsQuery>) -> Response {
match st
.store
.recent(q.kind.as_deref(), q.limit.unwrap_or(50))
.recent(q.kind.as_deref(), q.wipe.as_deref(), q.limit.unwrap_or(50))
.await
{
Ok(rows) => Json(json!({ "events": rows })).into_response(),
@@ -163,6 +191,55 @@ async fn events(State(st): State<AppState>, Query(q): Query<EventsQuery>) -> Res
}
}
#[derive(Debug, Deserialize)]
struct FeedQuery {
since: Option<i64>,
limit: Option<i64>,
}
/// The ingest cursor: everything after `since`, oldest first.
///
/// **Omitting `since` asks where the end is** — it answers with no rows and the current `lastId`,
/// which is what a consumer with no cursor of its own needs. `since=0` is the other question, and
/// the one nobody should ask by accident: replay everything retained. A module installed today
/// against a sidecar that has been running a month wants what happens next, not a fortnight of
/// deaths it has no rollups for, and the difference between those two intentions must not be the
/// difference between typing a parameter and forgetting it.
///
/// The response always carries `lastId`, so a caller advances without inspecting the rows, and
/// `more`, so one that has fallen behind comes straight back rather than waiting out its poll
/// interval.
async fn feed(State(st): State<AppState>, Query(q): Query<FeedQuery>) -> Response {
let since = match q.since {
Some(n) => n.max(0),
None => match st.store.last_event_id().await {
Ok(id) => {
return Json(json!({ "items": [], "lastId": id, "more": false })).into_response()
}
Err(e) => {
warn!(error = %e, "feed tail read failed");
return store_error();
}
},
};
match st.store.feed(since, q.limit.unwrap_or(200)).await {
Ok((items, more)) => {
// The cursor a caller should send next. When the page is empty that is the cursor it
// sent — never 0, which would silently replay the whole retained history on the next
// poll of a quiet server.
let last_id = items.last().map(|i| i.id).unwrap_or(since);
let rows: Vec<Value> = items.iter().map(|i| i.to_json()).collect();
Json(json!({ "items": rows, "lastId": last_id, "more": more })).into_response()
}
Err(e) => {
warn!(error = %e, "feed read failed");
store_error()
}
}
}
fn store_error() -> Response {
(
StatusCode::INTERNAL_SERVER_ERROR,