(deps: UpdateCheckDeps = {})
| 166 | * backoff) and return the current notice, or null. Never throws. |
| 167 | */ |
| 168 | export async function refreshUpdateCheck(deps: UpdateCheckDeps = {}): Promise<string | null> { |
| 169 | const d = resolveDeps(deps); |
| 170 | if (updateCheckDisabled(d.env)) return null; |
| 171 | |
| 172 | const cached = readUpdateCheckCache(d.dir); |
| 173 | const nowMs = d.now(); |
| 174 | if (cacheIsFresh(cached, nowMs)) return noticeFrom(cached, d); |
| 175 | |
| 176 | try { |
| 177 | const latest = canonicalVersionTag(await d.resolveLatest()); |
| 178 | // A response that isn't version-shaped is a failure, not a result — |
| 179 | // fall through to the backoff path and keep the previous known-good tag. |
| 180 | if (!latest) throw new Error('release feed returned a non-version tag'); |
| 181 | const next: UpdateCheckCacheFile = { lastAttemptAt: nowMs, lastSuccessAt: nowMs, latest }; |
| 182 | writeUpdateCheckCache(d.dir, next); |
| 183 | return noticeFrom(next, d); |
| 184 | } catch { |
| 185 | // Record the attempt (starts the backoff) but KEEP the previous latest — |
| 186 | // a transient outage must not hide an already-known update. |
| 187 | const next: UpdateCheckCacheFile = { |
| 188 | lastAttemptAt: nowMs, |
| 189 | lastSuccessAt: cached?.lastSuccessAt, |
| 190 | latest: cached?.latest, |
| 191 | }; |
| 192 | writeUpdateCheckCache(d.dir, next); |
| 193 | return noticeFrom(next, d); |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | // Per-process memo so the sync read path (MCP initialize, codegraph_status) |
| 198 | // touches the disk at most once a minute, not once per handshake. |
no test coverage detected