|
| 1 | +# Technical Design: background-version-check |
| 2 | + |
| 3 | +## 1. Architecture |
| 4 | + |
| 5 | +### Module Structure |
| 6 | + |
| 7 | +``` |
| 8 | +src/ |
| 9 | + update_check.rs # New module (pub(crate)) |
| 10 | + lib.rs # Add: pub(crate) mod update_check |
| 11 | + main.rs # Add: update_check::spawn() call |
| 12 | +``` |
| 13 | + |
| 14 | +**Public API surface** (all `pub(crate)`): |
| 15 | + |
| 16 | +- `update_check::spawn()` — Call site: `main()`, fires and forgets |
| 17 | +- No other public items; all internals are `pub(super)` or private |
| 18 | + |
| 19 | +### Spawn Point |
| 20 | + |
| 21 | +`srC/main.rs:118` — after `tracing_subscriber::fmt::init()`, before `Cli::parse()`. |
| 22 | + |
| 23 | +**Rationale**: The check must run for every command invocation regardless of which subcommand fires. Spawning before CLI parsing also means it starts as early as possible, maximizing the window for the background thread to complete before the process exits. Tracing must be initialized first so the background thread can use `tracing` for any diagnostics. |
| 24 | + |
| 25 | +--- |
| 26 | + |
| 27 | +## 2. Code Structure |
| 28 | + |
| 29 | +### `src/update_check.rs` |
| 30 | + |
| 31 | +#### `CheckedVersion` struct |
| 32 | + |
| 33 | +```rust |
| 34 | +struct CheckedVersion { |
| 35 | + last_checked: DateTime<Utc>, |
| 36 | + latest_version: String, |
| 37 | + notified_for_version: Option<String>, |
| 38 | +} |
| 39 | +``` |
| 40 | + |
| 41 | +Fields: |
| 42 | +- `last_checked`: when the cache was written |
| 43 | +- `latest_version`: the version string returned by crates.io (e.g. `"1.33.0"`) |
| 44 | +- `notified_for_version`: the version the user was already notified about (`None` = never notified) |
| 45 | + |
| 46 | +**Rationale**: `notified_for_version` is a separate field so the "once per version" behavior survives across cache refreshes. The hint only re-appears when a *new* version is detected. |
| 47 | + |
| 48 | +#### `Cache` struct |
| 49 | + |
| 50 | +```rust |
| 51 | +struct Cache { |
| 52 | + path: PathBuf, |
| 53 | +} |
| 54 | +impl Cache { |
| 55 | + fn load(&self) -> Option<CheckedVersion>; |
| 56 | + fn save(&self, v: &CheckedVersion) -> anyhow::Result<()>; |
| 57 | +} |
| 58 | +``` |
| 59 | + |
| 60 | +- `load()` returns `None` on any I/O or parse error (silent drop) |
| 61 | +- `save()` creates parent directories via `fs::create_dir_all`; wraps errors in `anyhow` |
| 62 | +- File path: `~/.cache/agentsync/update-check.json` |
| 63 | + |
| 64 | +#### `spawn()` — Entry Point |
| 65 | + |
| 66 | +```rust |
| 67 | +pub(crate) fn spawn() |
| 68 | +``` |
| 69 | + |
| 70 | +1. Checks opt-out conditions |
| 71 | +2. Spawns a detached thread named `"agentsync-update-check"` |
| 72 | +3. Returns immediately; no `JoinHandle` kept |
| 73 | + |
| 74 | +### Thread Work Flow |
| 75 | + |
| 76 | +``` |
| 77 | +1. Opt-out guard |
| 78 | + └─ AGENTSYNC_NO_UPDATE_CHECK=1 → return |
| 79 | + └─ CI=true → return |
| 80 | + └─ !stderr.is_terminal() → return |
| 81 | + |
| 82 | +2. Load cache |
| 83 | + ├─ Read ~/.cache/agentsync/update-check.json |
| 84 | + ├─ Parse → CheckedVersion |
| 85 | + ├─ If fresh (< 24h) AND notified_for_version == latest_version → return (already told) |
| 86 | + └─ If stale or absent → proceed |
| 87 | + |
| 88 | +3. HTTP fetch (reqwest blocking, 3s timeout) |
| 89 | + ├─ GET https://crates.io/api/v1/crates/agentsync |
| 90 | + ├─ Extract JSON: `crate.newest_version` (semver string) |
| 91 | + └─ On error → return silently |
| 92 | + |
| 93 | +4. Compare versions |
| 94 | + ├─ Parse current: `env!("CARGO_PKG_VERSION")` |
| 95 | + ├─ Parse remote: `semver::Version::parse` |
| 96 | + ├─ Skip pre-releases (`.pre.is_empty()`) |
| 97 | + └─ If remote > current → proceed |
| 98 | + |
| 99 | +5. Write cache + emit hint |
| 100 | + ├─ Update: notified_for_version = remote version |
| 101 | + ├─ Write ~/.cache/agentsync/update-check.json |
| 102 | + └─ eprintln!(yellow bold hint) |
| 103 | +``` |
| 104 | +
|
| 105 | +--- |
| 106 | +
|
| 107 | +## 3. Data Flow |
| 108 | +
|
| 109 | +``` |
| 110 | +main() |
| 111 | + └─ update_check::spawn() |
| 112 | + └─ std::thread::Builder::spawn (detached) |
| 113 | + ├─ opt-out check (env + TTY) |
| 114 | + ├─ cache load ─────────────────────────────────┐ |
| 115 | + │ └─ ~/.cache/agentsync/update-check.json │ |
| 116 | + ├─ reqwest::blocking::Client (3s timeout) │ |
| 117 | + │ └─ GET crates.io/api/v1/crates/agentsync │ |
| 118 | + ├─ semver::Version::parse comparison │ |
| 119 | + ├─ cache save ────────────────────────────────┘ |
| 120 | + └─ eprintln!(hint) → stderr (TTY-only) |
| 121 | +``` |
| 122 | +
|
| 123 | +--- |
| 124 | +
|
| 125 | +## 4. Key Decisions |
| 126 | +
|
| 127 | +| Decision | Rationale | |
| 128 | +|---|---| |
| 129 | +| **Daemon thread (no `join()`)** | The thread is purely advisory; it must not block or delay process exit. Process termination kills the thread automatically. | |
| 130 | +| **`is_terminal` on `stderr`** | The hint must not pollute redirected output in scripts/CI. Using `stderr` (not `stdout`) also avoids interfering with command output. | |
| 131 | +| **Skip pre-releases** | Avoids noisy hints for beta/alpha users when a stable release is behind a pre-release. | |
| 132 | +| **`CheckedVersion.notified_for_version`** | Enables "once per new version" without a separate flag file. The cache itself records what was already seen. | |
| 133 | +| **Silent error handling** | All errors in the background thread are swallowed. No logging, no user-facing errors. The feature is purely advisory. | |
| 134 | +| **Cache dir creation via `create_dir_all`** | Avoids failure if `~/.cache` doesn't exist on first run. | |
| 135 | +| **`chrono::DateTime<Utc>` for timestamps** | Already a dependency (used elsewhere in the crate), provides reliable UTC timestamps for cache freshness checks. | |
| 136 | +| **`reqwest::blocking::Client`** | Simpler than async for a single short-lived HTTP call in a background thread. Avoids bringing async runtime concerns into `main`. | |
| 137 | +| **`env!("CARGO_PKG_VERSION")`** | Compile-time constant — no need to pass the version at runtime. Works reliably in any build. | |
| 138 | +
|
| 139 | +--- |
| 140 | +
|
| 141 | +## 5. Dependencies |
| 142 | +
|
| 143 | +| Dependency | Change | Purpose | |
| 144 | +|---|---|---| |
| 145 | +| `is-terminal = "0.4"` | **Add** | `is_terminal(Stderr)` guard | |
| 146 | +| `reqwest` (with `blocking`) | Existing | crates.io HTTP fetch | |
| 147 | +| `semver` | Existing | Version parse + compare | |
| 148 | +| `chrono` (with `serde`) | Existing | UTC timestamp for cache TTL | |
| 149 | +| `serde` + `serde_json` | Existing | Cache serialization | |
| 150 | +| `colored` | Existing | Yellow bold hint formatting | |
| 151 | +| `anyhow` | Existing | Error wrapping in `Cache::save` | |
| 152 | +
|
| 153 | +**No new crates needed.** `is-terminal` is the only addition. |
| 154 | +
|
| 155 | +--- |
| 156 | +
|
| 157 | +## 6. Integration Points |
| 158 | +
|
| 159 | +### `src/main.rs` |
| 160 | +
|
| 161 | +```rust |
| 162 | +fn main() -> Result<()> { |
| 163 | + tracing_subscriber::fmt::init(); |
| 164 | + update_check::spawn(); // <— insert here |
| 165 | + let cli = Cli::parse(); |
| 166 | + // ... |
| 167 | +} |
| 168 | +``` |
| 169 | + |
| 170 | +### `src/lib.rs` |
| 171 | + |
| 172 | +```rust |
| 173 | +pub(crate) mod update_check; |
| 174 | +``` |
| 175 | + |
| 176 | +### `Cargo.toml` |
| 177 | + |
| 178 | +```toml |
| 179 | +# Add to [dependencies] |
| 180 | +is-terminal = "0.4" |
| 181 | +``` |
| 182 | + |
| 183 | +--- |
| 184 | + |
| 185 | +## 7. Cache File Format |
| 186 | + |
| 187 | +`~/.cache/agentsync/update-check.json`: |
| 188 | + |
| 189 | +```json |
| 190 | +{ |
| 191 | + "last_checked": "2026-03-22T10:00:00Z", |
| 192 | + "latest_version": "1.33.0", |
| 193 | + "notified_for_version": "1.33.0" |
| 194 | +} |
| 195 | +``` |
| 196 | + |
| 197 | +All fields are strings. Missing or malformed cache files are treated as absent cache. |
| 198 | + |
| 199 | +--- |
| 200 | + |
| 201 | +## 8. Hint Output |
| 202 | + |
| 203 | +Format (written to stderr, yellow, bold): |
| 204 | + |
| 205 | +``` |
| 206 | +⚡ A new version of agentsync is available: 1.33.0 (you have 1.32.0). Run `cargo install agentsync` to update. |
| 207 | +``` |
| 208 | + |
| 209 | +Only emitted once per new version. Subsequent runs with the same cached `latest_version` are silent. |
| 210 | + |
| 211 | +--- |
| 212 | + |
| 213 | +## 9. Testing Approach |
| 214 | + |
| 215 | +- **Unit tests** in `src/update_check.rs`: mock the cache path via a `Cache` constructor that accepts a `PathBuf`; test TTL logic, pre-release skipping, once-per-version logic. |
| 216 | +- **Integration tests** in `tests/`: use `cargo test --test all_tests` with a temporary cache dir to verify the spawn doesn't panic and doesn't block. |
| 217 | +- **Contract test**: verify the hint output format matches the expected emoji + version pattern. |
0 commit comments