Files
link/sidecar/src/main.rs
wtclaude d0c2e7d6e1
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m55s
feat(sidecar): protocol 5
PROTOCOL_VERSION 4 -> 5, and nothing else.

That is the whole change, and it is worth saying why. Protocol 5 adds fields to
house.decay and vendor.listing and one new kind, account.login.result — and the
sidecar needs no code for any of it. Every frame is persisted whole, the board
tables index only the columns they already had, and there is no kind allowlist, so
the new fields ride inside the stored JSON and the new kind lands in `events` like
any other.

No store migration this time, unlike v4. v4 needed one because it added a column to
a board table that already existed; nothing here does. A bump that touches one
constant is the EXPECTED cost of an additive protocol version in a dumb forwarder —
the sidecar defines no schema for a frame's contents, so it needs no change when
they grow. v4 was the exception.

The doc comment records the three enrichments and why they were bumped together: a
protocol bump costs a sidecar release, a republished bundle and an operator update
on every shard, so a field left out costs a whole second round of that rather than a
follow-up commit.

Verified against the real shard: GET /health reports "protocol": 5, and all three
enrichments arrived through the generic forward path — the decay schedule (with
estimatedCollapse present only on the IDOC frame), the vendor fee block, and both
outcomes of account.login.result.

cargo fmt --check clean, clippy -D warnings clean, 39 tests passing.

Docs: RunicGateway/docs link/v5.md.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 19:19:48 -05:00

128 lines
5.9 KiB
Rust

//! uo-link sidecar.
//!
//! Terminates the loopback link to the ServUO shard and exposes a website-facing HTTP surface: the
//! shard link (bidirectional), a WebSocket live feed, and REST queries backed by SQLite.
//!
//! # Layout
//!
//! `main` does argument handling and nothing else; the sidecar proper lives in [`app`] and is the
//! same code on every platform. Only *how the process is started and stopped* is
//! platform-specific:
//!
//! ```text
//! systemd ──▶ main ──▶ unix::run ─────────────────────────┐
//! ├──▶ app::run
//! SCM ─────▶ main ──▶ windows::run ──▶ ServiceMain ───────┘
//! └─▶ console fallback ──┘
//! ```
//!
//! The Windows half is not optional politeness: the SCM refuses to supervise a program that does
//! not speak its startup handshake (see [`windows`]). The platform modules are gated with `#[cfg]`
//! and their dependencies are declared per target, so none of it reaches a Linux build.
mod app;
mod cli;
mod config;
mod rpc;
mod shard;
mod store;
#[cfg(unix)]
mod unix;
mod web;
#[cfg(windows)]
mod windows;
use tracing_subscriber::EnvFilter;
/// Wire-protocol version between the website and the sidecar. Bump this whenever an event or
/// endpoint's shape changes so a mismatched client is detected immediately (409 / health) instead
/// of failing in confusing ways.
///
/// v2 (Protocol 2.0): adds the account-provisioning verbs/endpoints (`POST /accounts/create`,
/// `DELETE /link/:account`) and their events. Outbound event kinds are additive, so a v1 website
/// keeps working against the live feed; the new *endpoints* require a v2 sidecar.
///
/// v3 (Protocol 3.0): adds `world.ruleset`, `points.board` and `vendor.listing` /
/// `vendor.listing.remove`, with the `GET /ruleset`, `/points` and `/market` reads that serve them
/// from the store. Same shape as the v2 bump — the kinds are additive, the endpoints are not — and
/// there is deliberately no feature-negotiation array: v3 implies all three kinds.
///
/// v4 (Protocol 4): adds `guild.roster` and `guild.leave`, giving the guild board a real member list
/// instead of the member *count* that was all v2 could express. Additive in the same way again — the
/// kinds are new, `GET /guilds` grows a `roster` key, and nothing existing changed shape. This is the
/// first bump that also needed a **store migration** (`guilds.members`), because it is the first to
/// add a column to a table that already exists rather than a whole new table; see `store::migrate`.
///
/// v5 (Protocol 5): three enrichments that are additive in the same way again, bumped together
/// rather than one at a time because a protocol bump is not cheap here — it costs a sidecar
/// release, a republished bundle and an operator update on every shard, so a field left out costs
/// a whole second round of that rather than a follow-up commit. They are:
///
/// * `house.decay` gains `ownerName` and a decay SCHEDULE — `nextStage`, `decayPeriodSec`,
/// `dynamicDecay`, and `estimatedCollapse` only where it is exactly knowable (at IDOC under
/// dynamic decay; at any stage under static decay, which has no randomness to wait out).
/// * `vendor.listing` gains `ownerAcct` — without which the frame names an owner nobody can
/// resolve to a person — and a `fees` object carrying the charge, the funds, the pay interval
/// and the resolved `dismissalAt`.
/// * `account.login.result` is a NEW kind: the verdict of a login, which the pre-existing
/// `account.login.attempt` structurally cannot carry (its EventSink fires before the auth
/// decision is made).
///
/// **No store migration this time**, unlike v4. Every frame is persisted whole and the board tables
/// index only the columns they already had, so the new fields ride inside the stored JSON and the
/// new kind lands in `events` like any other. That is the dumb-forwarder property doing its job:
/// the sidecar defines no schema for a frame's contents and so needs no change when they grow.
pub const PROTOCOL_VERSION: u32 = 5;
// Not `#[tokio::main]`: on Windows the SCM dispatcher takes over this thread and starts the runtime
// itself, on its own thread, once the service actually begins. The runtime is built by whichever
// platform module ends up running.
fn main() -> anyhow::Result<()> {
let args = match cli::parse(std::env::args().skip(1)) {
Ok(args) => args,
Err(msg) => {
eprintln!("uo-link-sidecar: {msg}\n\n{}", cli::USAGE);
std::process::exit(2);
}
};
match args.mode {
cli::Mode::Help => {
print!("{}", cli::USAGE);
return Ok(());
}
cli::Mode::Version => {
println!(
"uo-link-sidecar {} (protocol {})",
env!("CARGO_PKG_VERSION"),
PROTOCOL_VERSION
);
return Ok(());
}
// Tracing stays uninitialized here on purpose: the subscriber writes to stdout, and stdout
// is the document. Config::load's messages are dropped rather than interleaved into JSON
// an installer is about to parse — everything they would have said is in the document.
cli::Mode::PrintConfig => {
let loaded = config::Config::load(args.config.as_deref())?;
println!("{:#}", config::describe(&loaded));
return Ok(());
}
cli::Mode::Run => {}
}
#[cfg(windows)]
return windows::run(args.config.as_deref());
#[cfg(unix)]
return unix::run(args.config.as_deref());
}
/// Logging for a foreground run: human-readable, on stdout.
pub fn init_console_tracing() {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.init();
}