max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
7 files changed,
+241 insertions,
-5 deletions
| @@ -58,6 +58,9 @@ | |||
| 58 | 58 | pub last_seen_at: DateTime<Utc>, | |
| 59 | 59 | /// When this device was first registered. | |
| 60 | 60 | pub created_at: DateTime<Utc>, | |
| 61 | + | /// SyncKit SDK version last seen from this device, off its User-Agent. | |
| 62 | + | /// `None` for a client old enough not to send one. | |
| 63 | + | pub client_version: Option<String>, | |
| 61 | 64 | } | |
| 62 | 65 | ||
| 63 | 66 | /// An entry in the append-only sync change log. |
| @@ -16,6 +16,11 @@ | |||
| 16 | 16 | /// Upserts on the `(app_id, user_id, device_name)` unique constraint. | |
| 17 | 17 | /// On conflict (same device re-registering), updates the platform string | |
| 18 | 18 | /// and bumps `last_seen_at` so stale-device detection stays accurate. | |
| 19 | + | /// | |
| 20 | + | /// `client_version` is the SDK version off the request's User-Agent. `None` | |
| 21 | + | /// means the request carried no recognisable one, and it leaves any previously | |
| 22 | + | /// recorded version in place rather than blanking it: a client that stops | |
| 23 | + | /// sending the header has not downgraded to unknown, it is just quiet. | |
| 19 | 24 | #[tracing::instrument(skip_all)] | |
| 20 | 25 | pub async fn upsert_sync_device( | |
| 21 | 26 | pool: &PgPool, | |
| @@ -23,13 +28,17 @@ | |||
| 23 | 28 | user_id: UserId, | |
| 24 | 29 | device_name: &str, | |
| 25 | 30 | platform: SyncPlatform, | |
| 31 | + | client_version: Option<&str>, | |
| 26 | 32 | ) -> Result<DbSyncDevice> { | |
| 27 | 33 | let device = sqlx::query_as::<_, DbSyncDevice>( | |
| 28 | 34 | r" | |
| 29 | - | INSERT INTO sync_devices (app_id, user_id, device_name, platform) | |
| 30 | - | VALUES ($1, $2, $3, $4) | |
| 35 | + | INSERT INTO sync_devices (app_id, user_id, device_name, platform, client_version) | |
| 36 | + | VALUES ($1, $2, $3, $4, $5) | |
| 31 | 37 | ON CONFLICT (app_id, user_id, device_name) | |
| 32 | - | DO UPDATE SET platform = EXCLUDED.platform, last_seen_at = NOW() | |
| 38 | + | DO UPDATE SET | |
| 39 | + | platform = EXCLUDED.platform, | |
| 40 | + | last_seen_at = NOW(), | |
| 41 | + | client_version = COALESCE(EXCLUDED.client_version, sync_devices.client_version) | |
| 33 | 42 | RETURNING * | |
| 34 | 43 | ", | |
| 35 | 44 | ) | |
| @@ -37,6 +46,7 @@ | |||
| 37 | 46 | .bind(user_id) | |
| 38 | 47 | .bind(device_name) | |
| 39 | 48 | .bind(platform) | |
| 49 | + | .bind(client_version) | |
| 40 | 50 | .fetch_one(pool) | |
| 41 | 51 | .await?; | |
| 42 | 52 | ||
| @@ -86,19 +96,28 @@ | |||
| 86 | 96 | /// Mark a device seen and advance its pull cursor in one statement, the pull | |
| 87 | 97 | /// hot path used to issue a separate touch and a separate cursor update; this | |
| 88 | 98 | /// folds both into a single UPDATE. `GREATEST` keeps the cursor monotonic. | |
| 99 | + | /// | |
| 100 | + | /// Also refreshes `client_version` from the request that triggered the pull, so | |
| 101 | + | /// a device that upgrades reports its new version on its next sync instead of | |
| 102 | + | /// waiting for a re-registration that may never come. `None` leaves the stored | |
| 103 | + | /// value alone (see [`upsert_sync_device`]). | |
| 89 | 104 | #[tracing::instrument(skip_all)] | |
| 90 | 105 | pub async fn touch_and_advance_cursor( | |
| 91 | 106 | pool: &PgPool, | |
| 92 | 107 | device_id: SyncDeviceId, | |
| 93 | 108 | new_cursor: i64, | |
| 109 | + | client_version: Option<&str>, | |
| 94 | 110 | ) -> Result<()> { | |
| 95 | 111 | sqlx::query( | |
| 96 | 112 | "UPDATE sync_devices | |
| 97 | - | SET last_seen_at = NOW(), last_pulled_seq = GREATEST(last_pulled_seq, $2) | |
| 113 | + | SET last_seen_at = NOW(), | |
| 114 | + | last_pulled_seq = GREATEST(last_pulled_seq, $2), | |
| 115 | + | client_version = COALESCE($3, client_version) | |
| 98 | 116 | WHERE id = $1", | |
| 99 | 117 | ) | |
| 100 | 118 | .bind(device_id) | |
| 101 | 119 | .bind(new_cursor) | |
| 120 | + | .bind(client_version) | |
| 102 | 121 | .execute(pool) | |
| 103 | 122 | .await?; | |
| 104 | 123 | Ok(()) | |
| @@ -123,6 +142,44 @@ | |||
| 123 | 142 | Ok(result.rows_affected() > 0) | |
| 124 | 143 | } | |
| 125 | 144 | ||
| 145 | + | /// One row of the field-version readout: how many devices last synced on a | |
| 146 | + | /// given SDK version, and when the most recent of them was seen. | |
| 147 | + | #[derive(Debug, Clone, sqlx::FromRow, serde::Serialize)] | |
| 148 | + | pub struct ClientVersionCount { | |
| 149 | + | /// SDK version, or `None` for devices that never reported one. | |
| 150 | + | pub client_version: Option<String>, | |
| 151 | + | /// Devices last seen on that version within the window. | |
| 152 | + | pub devices: i64, | |
| 153 | + | /// Most recent sync from any device on that version. | |
| 154 | + | pub last_seen_at: chrono::DateTime<chrono::Utc>, | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | /// Distribution of SyncKit versions across devices that synced within the last | |
| 158 | + | /// `window_days`. Answers "which SyncKit is in the field", which is a different | |
| 159 | + | /// question from "which SyncKit did we publish". | |
| 160 | + | /// | |
| 161 | + | /// The window matters: without it, a laptop that synced once in 2025 and was | |
| 162 | + | /// never opened again would keep an ancient version in the readout forever and | |
| 163 | + | /// make the fleet look more stale than it is. | |
| 164 | + | #[tracing::instrument(skip_all)] | |
| 165 | + | pub async fn client_version_distribution( | |
| 166 | + | pool: &PgPool, | |
| 167 | + | window_days: i32, | |
| 168 | + | ) -> Result<Vec<ClientVersionCount>> { | |
| 169 | + | let rows = sqlx::query_as::<_, ClientVersionCount>( | |
| 170 | + | "SELECT client_version, COUNT(*) AS devices, MAX(last_seen_at) AS last_seen_at | |
| 171 | + | FROM sync_devices | |
| 172 | + | WHERE last_seen_at > NOW() - make_interval(days => $1) | |
| 173 | + | GROUP BY client_version | |
| 174 | + | ORDER BY devices DESC, client_version DESC NULLS LAST", | |
| 175 | + | ) | |
| 176 | + | .bind(window_days) | |
| 177 | + | .fetch_all(pool) | |
| 178 | + | .await?; | |
| 179 | + | ||
| 180 | + | Ok(rows) | |
| 181 | + | } | |
| 182 | + | ||
| 126 | 183 | /// Touch last_seen_at for a device. | |
| 127 | 184 | #[tracing::instrument(skip_all)] | |
| 128 | 185 | pub async fn touch_sync_device(pool: &PgPool, device_id: SyncDeviceId) -> Result<()> { |
| @@ -46,6 +46,37 @@ | |||
| 46 | 46 | "synckit server-to-server: keys-endpoint app_secret auth, no session"; | |
| 47 | 47 | const SYNCKIT_JWT_SKIP: &str = "synckit JWT bearer auth (SyncUser), no session"; | |
| 48 | 48 | ||
| 49 | + | /// Longest client version string we will store. Matches the column width in | |
| 50 | + | /// migration 180; a longer value is a client we don't recognise, so it is | |
| 51 | + | /// dropped rather than truncated into something that reads like a real version. | |
| 52 | + | const CLIENT_VERSION_MAX_LENGTH: usize = 32; | |
| 53 | + | ||
| 54 | + | /// The SDK version out of a `synckit-client/<version>` User-Agent, if the | |
| 55 | + | /// request carries one. | |
| 56 | + | /// | |
| 57 | + | /// Only the version is kept. A request from anything that is not the SDK (a | |
| 58 | + | /// browser, curl, an older client that sends no such header) yields `None`, and | |
| 59 | + | /// `None` is stored as-is: "syncing, version unknown" is a real answer and | |
| 60 | + | /// guessing would corrupt the field-version readout this exists to produce. | |
| 61 | + | /// The version is checked for shape, not parsed as semver, so a client that | |
| 62 | + | /// adds a pre-release suffix still reports. | |
| 63 | + | pub(crate) fn client_version(headers: &axum::http::HeaderMap) -> Option<String> { | |
| 64 | + | let version = headers | |
| 65 | + | .get(axum::http::header::USER_AGENT)? | |
| 66 | + | .to_str() | |
| 67 | + | .ok()? | |
| 68 | + | .split_whitespace() | |
| 69 | + | .next()? | |
| 70 | + | .strip_prefix("synckit-client/")?; | |
| 71 | + | let ok = !version.is_empty() | |
| 72 | + | && version.len() <= CLIENT_VERSION_MAX_LENGTH | |
| 73 | + | && version.starts_with(|c: char| c.is_ascii_digit()) | |
| 74 | + | && version | |
| 75 | + | .chars() | |
| 76 | + | .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+' | '_')); | |
| 77 | + | ok.then(|| version.to_string()) | |
| 78 | + | } | |
| 79 | + | ||
| 49 | 80 | // ── Request/Response types ── | |
| 50 | 81 | ||
| 51 | 82 | #[derive(Deserialize, utoipa::ToSchema)] | |
| @@ -1113,3 +1144,59 @@ | |||
| 1113 | 1144 | ||
| 1114 | 1145 | auth_routes.merge(sync_routes).merge(app_routes) | |
| 1115 | 1146 | } | |
| 1147 | + | ||
| 1148 | + | #[cfg(test)] | |
| 1149 | + | mod tests { | |
| 1150 | + | use super::client_version; | |
| 1151 | + | use axum::http::{HeaderMap, HeaderValue, header::USER_AGENT}; | |
| 1152 | + | ||
| 1153 | + | fn ua(value: &str) -> HeaderMap { | |
| 1154 | + | let mut headers = HeaderMap::new(); | |
| 1155 | + | headers.insert(USER_AGENT, HeaderValue::from_str(value).unwrap()); | |
| 1156 | + | headers | |
| 1157 | + | } | |
| 1158 | + | ||
| 1159 | + | #[test] | |
| 1160 | + | fn reads_the_sdk_version() { | |
| 1161 | + | assert_eq!( | |
| 1162 | + | client_version(&ua("synckit-client/0.6.0")).as_deref(), | |
| 1163 | + | Some("0.6.0") | |
| 1164 | + | ); | |
| 1165 | + | // Pre-release and build suffixes are real versions, keep them whole. | |
| 1166 | + | assert_eq!( | |
| 1167 | + | client_version(&ua("synckit-client/1.0.0-rc.2")).as_deref(), | |
| 1168 | + | Some("1.0.0-rc.2") | |
| 1169 | + | ); | |
| 1170 | + | // A consumer app appending its own product token must not break the read. | |
| 1171 | + | assert_eq!( | |
| 1172 | + | client_version(&ua("synckit-client/0.6.0 audiofiles/0.9.1")).as_deref(), | |
| 1173 | + | Some("0.6.0") | |
| 1174 | + | ); | |
| 1175 | + | } | |
| 1176 | + | ||
| 1177 | + | #[test] | |
| 1178 | + | fn ignores_anything_that_is_not_the_sdk() { | |
| 1179 | + | assert_eq!(client_version(&HeaderMap::new()), None); | |
| 1180 | + | assert_eq!(client_version(&ua("curl/8.5.0")), None); | |
| 1181 | + | assert_eq!(client_version(&ua("Mozilla/5.0 (X11; Linux x86_64)")), None); | |
| 1182 | + | // Right prefix, no version. | |
| 1183 | + | assert_eq!(client_version(&ua("synckit-client/")), None); | |
| 1184 | + | // Prefix match must be exact, not a substring of some other product. | |
| 1185 | + | assert_eq!(client_version(&ua("evil-synckit-client/9.9.9")), None); | |
| 1186 | + | } | |
| 1187 | + | ||
| 1188 | + | #[test] | |
| 1189 | + | fn rejects_junk_rather_than_storing_it() { | |
| 1190 | + | // Over the column width: dropped, not truncated into a plausible-looking | |
| 1191 | + | // version. | |
| 1192 | + | let long = format!("synckit-client/{}", "9".repeat(64)); | |
| 1193 | + | assert_eq!(client_version(&ua(&long)), None); | |
| 1194 | + | // A version has to start with a digit, so a free-text string cannot | |
| 1195 | + | // smuggle itself into the readout. | |
| 1196 | + | assert_eq!(client_version(&ua("synckit-client/not-a-version")), None); | |
| 1197 | + | assert_eq!(client_version(&ua("synckit-client/../../etc/passwd")), None); | |
| 1198 | + | // At the boundary it is kept. | |
| 1199 | + | let at_max = format!("synckit-client/1{}", "0".repeat(31)); | |
| 1200 | + | assert!(client_version(&ua(&at_max)).is_some()); | |
| 1201 | + | } | |
| 1202 | + | } |
| @@ -127,6 +127,7 @@ | |||
| 127 | 127 | pub(super) async fn sync_pull( | |
| 128 | 128 | State(db): State<PgPool>, | |
| 129 | 129 | sync_user: SyncUser, | |
| 130 | + | headers: axum::http::HeaderMap, | |
| 130 | 131 | Json(req): Json<PullRequest>, | |
| 131 | 132 | ) -> Result<impl IntoResponse> { | |
| 132 | 133 | let app_id = sync_user.app_id; | |
| @@ -169,7 +170,13 @@ | |||
| 169 | 170 | ||
| 170 | 171 | // Mark the device seen and advance its compaction cursor in one statement. | |
| 171 | 172 | // GREATEST keeps the cursor monotonic even if `new_cursor == req.cursor`. | |
| 172 | - | db::synckit::touch_and_advance_cursor(&db, req.device_id, new_cursor).await?; | |
| 173 | + | db::synckit::touch_and_advance_cursor( | |
| 174 | + | &db, | |
| 175 | + | req.device_id, | |
| 176 | + | new_cursor, | |
| 177 | + | super::client_version(&headers).as_deref(), | |
| 178 | + | ) | |
| 179 | + | .await?; | |
| 173 | 180 | ||
| 174 | 181 | let changes: Vec<PullChangeEntry> = entries | |
| 175 | 182 | .into_iter() | |
| @@ -468,6 +475,7 @@ | |||
| 468 | 475 | pub(super) async fn register_device( | |
| 469 | 476 | State(db): State<PgPool>, | |
| 470 | 477 | sync_user: SyncUser, | |
| 478 | + | headers: axum::http::HeaderMap, | |
| 471 | 479 | Json(req): Json<RegisterDeviceRequest>, | |
| 472 | 480 | ) -> Result<impl IntoResponse> { | |
| 473 | 481 | validation::validate_sync_device_name(&req.device_name)?; | |
| @@ -506,6 +514,7 @@ | |||
| 506 | 514 | sync_user.user_id, | |
| 507 | 515 | &req.device_name, | |
| 508 | 516 | req.platform, | |
| 517 | + | super::client_version(&headers).as_deref(), | |
| 509 | 518 | ) | |
| 510 | 519 | .await?; | |
| 511 | 520 |
| @@ -9,6 +9,7 @@ | |||
| 9 | 9 | mod creators; | |
| 10 | 10 | mod git; | |
| 11 | 11 | mod items; | |
| 12 | + | mod synckit; | |
| 12 | 13 | mod uploads; | |
| 13 | 14 | ||
| 14 | 15 | pub(super) use git::restart_status; | |
| @@ -29,6 +30,10 @@ | |||
| 29 | 30 | pub(super) fn internal_routes() -> CsrfRouter<AppState> { | |
| 30 | 31 | CsrfRouter::new() | |
| 31 | 32 | .route_get("/api/internal/ssh-key-lookup", get(git::ssh_key_lookup)) | |
| 33 | + | .route_get( | |
| 34 | + | "/api/internal/synckit/client-versions", | |
| 35 | + | get(synckit::client_versions), | |
| 36 | + | ) | |
| 32 | 37 | .route( | |
| 33 | 38 | "/api/internal/creator/projects", | |
| 34 | 39 | with_csrf_skip( |
| @@ -1,0 +1,18 @@ | |||
| 1 | + | -- Which SyncKit is actually in the field. | |
| 2 | + | -- | |
| 3 | + | -- SyncKit is a client-side SDK linked into mnw-cli, balanced_breakfast and | |
| 4 | + | -- audiofiles. Nothing about it is deployed, so there is no version to poll and | |
| 5 | + | -- "which SyncKit is running out there" had no answer. Reading a number out of a | |
| 6 | + | -- lockfile at release time answers a different question: what we shipped, not | |
| 7 | + | -- what people are still syncing with. | |
| 8 | + | -- | |
| 9 | + | -- The client now sends `synckit-client/<version>` as its User-Agent. This is | |
| 10 | + | -- where that lands: one column on the device row that already exists, carrying | |
| 11 | + | -- the version seen on that device's last request. Deliberately NOT a per-request | |
| 12 | + | -- log -- no new table, no sweep job, and no new retention clock, since the value | |
| 13 | + | -- lives and dies with the device registration the user can already see and | |
| 14 | + | -- delete. Only the version is stored; the rest of the User-Agent is discarded. | |
| 15 | + | -- | |
| 16 | + | -- Nullable with no backfill: an older client sends no such User-Agent, and NULL | |
| 17 | + | -- is the honest reading of "syncing, version unknown" rather than a guess. | |
| 18 | + | ALTER TABLE sync_devices ADD COLUMN IF NOT EXISTS client_version VARCHAR(32); |
| @@ -1,0 +1,57 @@ | |||
| 1 | + | //! SyncKit fleet readout for monitoring agents. | |
| 2 | + | //! | |
| 3 | + | //! `GET /api/internal/synckit/client-versions` reports which SyncKit SDK | |
| 4 | + | //! versions are actually syncing, aggregated from `sync_devices.client_version`. | |
| 5 | + | //! SyncKit is client-side only, linked into mnw-cli, balanced_breakfast and | |
| 6 | + | //! audiofiles, so there is no deployed version for PoM to poll the usual way; | |
| 7 | + | //! this is the substitute, and it answers the better question anyway. | |
| 8 | + | //! | |
| 9 | + | //! Authed by [`AlertsAuth`], the same bearer PoM already carries. That | |
| 10 | + | //! credential exists for monitoring agents running on other hosts, which is | |
| 11 | + | //! exactly what polls this; `ServiceAuth` is the CLI's on-host token and reusing | |
| 12 | + | //! it here would widen a blast radius for no reason. Deliberately not on the | |
| 13 | + | //! public health body: which versions are in the field (including old, unpatched | |
| 14 | + | //! ones) is an operator signal, not a public one. | |
| 15 | + | ||
| 16 | + | use axum::{ | |
| 17 | + | Json, | |
| 18 | + | extract::{Query, State}, | |
| 19 | + | response::IntoResponse, | |
| 20 | + | }; | |
| 21 | + | use serde::Deserialize; | |
| 22 | + | use sqlx::PgPool; | |
| 23 | + | ||
| 24 | + | use crate::{auth::AlertsAuth, db, error::Result}; | |
| 25 | + | ||
| 26 | + | /// Default activity window. A month is long enough that a laptop opened weekly | |
| 27 | + | /// still counts, short enough that a machine retired last year stops dragging an | |
| 28 | + | /// ancient version through the readout. | |
| 29 | + | const DEFAULT_WINDOW_DAYS: i32 = 30; | |
| 30 | + | ||
| 31 | + | /// Widest window we will aggregate over. Bounds the scan, and a year-wide | |
| 32 | + | /// reading stops meaning "in the field" anyway. | |
| 33 | + | const MAX_WINDOW_DAYS: i32 = 365; | |
| 34 | + | ||
| 35 | + | #[derive(Deserialize)] | |
| 36 | + | pub(super) struct WindowQuery { | |
| 37 | + | /// Count devices last seen within this many days. Clamped to | |
| 38 | + | /// `1..=MAX_WINDOW_DAYS`. | |
| 39 | + | days: Option<i32>, | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | pub(super) async fn client_versions( | |
| 43 | + | State(db): State<PgPool>, | |
| 44 | + | _auth: AlertsAuth, | |
| 45 | + | Query(q): Query<WindowQuery>, | |
| 46 | + | ) -> Result<impl IntoResponse> { | |
| 47 | + | let days = q | |
| 48 | + | .days | |
| 49 | + | .unwrap_or(DEFAULT_WINDOW_DAYS) | |
| 50 | + | .clamp(1, MAX_WINDOW_DAYS); | |
| 51 | + | let versions = db::synckit::client_version_distribution(&db, days).await?; | |
| 52 | + | Ok(Json(serde_json::json!({ | |
| 53 | + | "window_days": days, | |
| 54 | + | "devices": versions.iter().map(|v| v.devices).sum::<i64>(), | |
| 55 | + | "versions": versions, | |
| 56 | + | }))) | |
| 57 | + | } |