Skip to content

Commit d6b66e6

Browse files
committed
feat: add background version check with local cache
Non-blocking background version check that queries crates.io on every CLI invocation and displays a one-time update hint when a newer version is available. Runs on a detached background thread with 24h cache TTL. - Background thread spawned before CLI parsing, never blocks execution - Cache at ~/.cache/agentsync/update-check.json with 24h TTL - Hint printed to stderr only on TTY, once per new version - Opt-out via AGENTSYNC_NO_UPDATE_CHECK or CI environment variables - All network/parse errors are silent Closes #242
1 parent abfaca4 commit d6b66e6

12 files changed

Lines changed: 1649 additions & 0 deletions

File tree

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ lazy_static = "1.4"
6161
# Semantic versioning for skill update resolution
6262
semver = "1.0"
6363

64+
# Terminal detection for update check
65+
is-terminal = "0.4"
66+
6467
# Temp directories for skill archive extraction
6568
tempfile = "3.15"
6669
urlencoding = "2.1"
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Archive Manifest: background-version-check
2+
3+
**Date Archived:** 2026-03-22
4+
**Archived By:** opencode (SDD ARCHIVE sub-agent)
5+
6+
---
7+
8+
## Verdict from Verify
9+
10+
**PASS**
11+
12+
All 12 acceptance criteria satisfied. 333 tests pass (290 lib + 34 binary + 9 integration), 0 failures. cargo clippy and fmt clean. Minor naming discrepancies had no behavioral impact.
13+
14+
---
15+
16+
## Summary of Implementation
17+
18+
Implemented a non-blocking background version check that:
19+
20+
1. Spawns a detached daemon thread (`"agentsync-update-check"`) before CLI parsing in `src/main.rs:119`
21+
2. Queries `https://crates.io/api/v1/crates/agentsync` with a 3s timeout via `reqwest::blocking::Client`
22+
3. Caches results at `~/.cache/agentsync/update-check.json` with 24h TTL
23+
4. Respects `AGENTSYNC_NO_UPDATE_CHECK=1` and `CI=true` env var opt-outs
24+
5. Only prints to stderr when connected to a TTY
25+
6. Compares versions via `semver::Version`, ignoring pre-releases
26+
7. Prints a one-time hint per new version (tracked via `notified_for_version`)
27+
8. All errors silently dropped — zero impact on CLI execution
28+
29+
### Files Added
30+
- `src/update_check.rs` — module with `Cache`, `CheckedVersion`, and `spawn()` function
31+
32+
### Files Modified
33+
- `Cargo.toml` — added `is-terminal = "0.4"` dependency
34+
- `src/lib.rs` — added `pub(crate) mod update_check;`
35+
- `src/main.rs` — added `agentsync::update_check::spawn()` call
36+
37+
### Tests
38+
- Unit tests for cache load/save, version comparison, pre-release skipping
39+
- All 333 tests pass
40+
41+
---
42+
43+
## Delta Spec Location
44+
`openspec/changes/archive/2026-03-22-background-version-check/specs/version-check/spec.md`
45+
46+
## Main Spec Location
47+
`openspec/specs/version-check/spec.md`
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
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.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
## Exploration: background-version-check
2+
3+
### Current State
4+
The CLI entry point is `src/main.rs``fn main()`. It:
5+
1. Initializes tracing subscriber
6+
2. Parses CLI via `clap::Parser`
7+
3. Dispatches to command handlers (`run_skill`, `run_status`, `run_doctor`, etc.)
8+
4. Returns `Result<()>` — on `Ok`, process exits cleanly
9+
10+
Exit flow is clean; no threading exists (only one `thread::sleep` in `linker.rs:2825`).
11+
12+
### Affected Areas
13+
- `src/main.rs` — Spawn the background thread immediately after `tracing_subscriber::fmt::init()`, before CLI parsing (so it runs regardless of command)
14+
- `Cargo.toml` — Add `is-terminal = "0.4"` for TTY detection (or use `atty` crate)
15+
- `src/` — New module `src/update_check.rs` + expose in `src/lib.rs`
16+
17+
### Dependencies Analysis
18+
| Dependency | Available? | Notes |
19+
|---|---|---|
20+
| reqwest (blocking) | Yes | Already has `blocking` feature (Cargo.toml:54) |
21+
| semver | Yes | Already available (Cargo.toml:62) |
22+
| dirs | No | Not in deps — need to use `dirs_next` or implement manually via `home_dir()` + `.cache` |
23+
| is-terminal | No | Not in deps — use `is-terminal = "0.4"` or `atty` |
24+
| colored | Yes | Already available (Cargo.toml:44) |
25+
26+
### Approach
27+
Create `src/update_check.rs` with:
28+
- `CheckedVersion` struct (version, checked_at timestamp, notified_for_version)
29+
- `spawn_version_check()` — spawns `std::thread::Builder::spawn` with `"agentsync-update-check"` name, detached (no `.join()`), uses `reqwest::blocking::Client` with 3s timeout to GET `https://crates.io/api/v1/crates/agentsync`
30+
- Cache file: `~/.cache/agentsync/update-check.json`
31+
- Logic:
32+
1. Check `AGENTSYNC_NO_UPDATE_CHECK` / `CI` env vars → skip
33+
2. Check `stderr.is_terminal()` → skip if not TTY
34+
3. Read cache; if fresh (24h TTL) and already notified for current cached version → skip HTTP
35+
4. Fetch crates.io API; compare with current binary version via `semver::Version::parse`
36+
5. If newer → write cache + print hint to stderr (once per version via `notified_for_version` field)
37+
- Import `VERSION` from crate or use `env!("CARGO_PKG_VERSION")`
38+
39+
Module should be `pub(crate)` in lib.rs. Call `update_check::spawn_version_check()` at top of `main()` before any CLI work.
40+
41+
### Risks
42+
- Thread may outlive main process on very fast exits — acceptable since output goes to stderr only
43+
- Cache dir may not exist — use `dirs::cache_dir()` or `home_dir()` with fallback
44+
- crates.io rate limiting — catch errors silently in background thread
45+
- Version string from crates.io may include pre-release (e.g., `1.33.0-beta.1`) — handle with `semver::Version::parse` or fallback gracefully
46+
47+
### Ready for Proposal
48+
Yes. The implementation is straightforward: ~150-200 LOC in a new module, minimal deps, follows existing patterns (blocking HTTP, serde JSON, colored output, chrono timestamps). No breaking changes.

0 commit comments

Comments
 (0)