Skip to main content

max / makenotwork

Record the SyncKit client version per device The SDK now sends `synckit-client/<version>` as its User-Agent. Store the version on the device row that already exists, refreshed on registration and on every pull, and report the distribution at GET /api/internal/synckit/client-versions for PoM to poll. Only the version is kept, and only per device: no request log, no sweep job, no new retention clock. A request carrying no recognisable version leaves the stored one alone rather than blanking it. The readout is authed with the monitoring-agent bearer, not folded into the public health body -- which versions are live is an operator signal.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-29 19:24 UTC
Signed with PGP, not checked
Commit: bb329dd8d24c92e587532a9d1d2f76208915e851
Parent: b288f01
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 + }