Module: Woods::UpdateCheck
- Defined in:
- lib/woods/update_check.rb
Overview
Best-effort "is a newer Woods release available?" check.
Modeled on grove's background update notifier: it queries RubyGems for the
latest published woods version, caches the answer on disk for 24h, and is
fully non-fatal — any network, parse, or filesystem failure degrades to
"no update signal" rather than raising. It exists so the MCP server can tell
an agent (and, through it, the user) that the installed gem is behind, which
matters because the distributed guide skills operate against whatever version
is installed.
Two surfaces consume this:
* {Woods::MCP::Server.build_status} embeds {status_hash} under
+server.update+ so +woods_status+ reports update availability.
* {Woods::MCP::VersionAwareToolDispatch} uses {tool_not_found_message} to
turn a bare "Tool not found" into version-aware, self-healing guidance.
Disable entirely with WOODS_NO_UPDATE_CHECK=1.
Constant Summary collapse
- RUBYGEMS_LATEST_URL =
RubyGems endpoint returning
{ "version": "x.y.z" }for the latest release. 'https://rubygems.org/api/v1/versions/woods/latest.json'- CACHE_TTL =
How long a successful result is trusted before a re-fetch is attempted.
24 * 60 * 60
- FAILURE_TTL =
A failed probe is cached for a shorter window, so an unreachable or slow RubyGems throttles retries (rather than re-blocking on every call) without hiding a real update for a full day once connectivity returns.
60 * 60
- HTTP_TIMEOUT =
Open/read timeout for the (best-effort) network probe, in seconds.
1.5
Class Method Summary collapse
-
.cached_or_refreshed_latest(cache_path, ttl, now, fetcher) ⇒ String?
Return the cached latest version when the cache entry is still fresh (a shorter window applies to a previously-failed probe), otherwise fetch once and cache the outcome — success or failure.
-
.check(current: Woods::VERSION, cache_path: default_cache_path, ttl: CACHE_TTL, now: Time.now, fetcher: method(:fetch_latest_version)) ⇒ Hash
Resolve update availability, using the on-disk cache when fresh and otherwise fetching once and caching the result.
-
.default_cache_path ⇒ Object
Default per-user cache location, honoring XDG, falling back to tmpdir.
-
.disabled? ⇒ Boolean
--- internals -----------------------------------------------------------.
-
.fetch_latest_version(url) ⇒ String?
Best-effort RubyGems probe.
-
.fresh_entry?(entry, ttl, now) ⇒ Boolean
A success entry is trusted for
ttl; a failure entry (nil latest) only for FAILURE_TTL. - .newer?(latest, current) ⇒ Boolean
-
.read_cache(cache_path) ⇒ Hash?
The parsed cache entry, or nil if absent, unreadable, or not a JSON object (guards the "never raise" contract against a corrupt/tampered cache holding e.g.
[]or42). -
.refresh(cache_path, now, fetcher) ⇒ String?
Fetch and cache the outcome.
- .result(current, latest) ⇒ Object
-
.status_hash(current: Woods::VERSION, **opts) ⇒ Hash
The
server.updatesub-hash forwoods_status. -
.tool_not_found_message(tool_name, current: Woods::VERSION, cache_path: default_cache_path, fetcher: nil) ⇒ String
Version-aware replacement for the MCP layer's bare "Tool not found" message.
- .write_cache(cache_path, latest, now) ⇒ Object
Class Method Details
.cached_or_refreshed_latest(cache_path, ttl, now, fetcher) ⇒ String?
Return the cached latest version when the cache entry is still fresh (a shorter window applies to a previously-failed probe), otherwise fetch once and cache the outcome — success or failure.
113 114 115 116 117 118 |
# File 'lib/woods/update_check.rb', line 113 def cached_or_refreshed_latest(cache_path, ttl, now, fetcher) entry = read_cache(cache_path) return entry['latest'] if entry && fresh_entry?(entry, ttl, now) refresh(cache_path, now, fetcher) end |
.check(current: Woods::VERSION, cache_path: default_cache_path, ttl: CACHE_TTL, now: Time.now, fetcher: method(:fetch_latest_version)) ⇒ Hash
Resolve update availability, using the on-disk cache when fresh and otherwise fetching once and caching the result.
55 56 57 58 59 60 |
# File 'lib/woods/update_check.rb', line 55 def check(current: Woods::VERSION, cache_path: default_cache_path, ttl: CACHE_TTL, now: Time.now, fetcher: method(:fetch_latest_version)) return result(current, nil) if disabled? result(current, cached_or_refreshed_latest(cache_path, ttl, now, fetcher)) end |
.default_cache_path ⇒ Object
Default per-user cache location, honoring XDG, falling back to tmpdir.
163 164 165 166 167 168 169 |
# File 'lib/woods/update_check.rb', line 163 def default_cache_path base = ENV.fetch('XDG_CACHE_HOME', nil) base = File.join(Dir.home, '.cache') if base.nil? || base.empty? File.join(base, 'woods', 'update_check.json') rescue StandardError File.join(Dir.tmpdir, 'woods-update-check.json') end |
.disabled? ⇒ Boolean
--- internals -----------------------------------------------------------
94 95 96 |
# File 'lib/woods/update_check.rb', line 94 def disabled? ENV['WOODS_NO_UPDATE_CHECK'] == '1' end |
.fetch_latest_version(url) ⇒ String?
Best-effort RubyGems probe. Kept tiny and self-contained so the module has no non-stdlib dependencies.
175 176 177 178 179 180 181 182 183 184 185 186 187 188 |
# File 'lib/woods/update_check.rb', line 175 def fetch_latest_version(url) require 'net/http' uri = URI(url) response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: HTTP_TIMEOUT, read_timeout: HTTP_TIMEOUT) do |http| http.get(uri.request_uri) end return nil unless response.is_a?(Net::HTTPSuccess) version = JSON.parse(response.body)['version'] version if version.is_a?(String) && !version.empty? rescue StandardError nil end |
.fresh_entry?(entry, ttl, now) ⇒ Boolean
A success entry is trusted for ttl; a failure entry (nil latest) only
for FAILURE_TTL.
122 123 124 125 126 127 128 |
# File 'lib/woods/update_check.rb', line 122 def fresh_entry?(entry, ttl, now) checked_at = entry['checked_at'] return false unless checked_at.is_a?(Numeric) effective_ttl = entry['latest'] ? ttl : FAILURE_TTL (now.to_i - checked_at) < effective_ttl end |
.newer?(latest, current) ⇒ Boolean
102 103 104 105 106 |
# File 'lib/woods/update_check.rb', line 102 def newer?(latest, current) Gem::Version.new(latest) > Gem::Version.new(current) rescue ArgumentError false end |
.read_cache(cache_path) ⇒ Hash?
Returns the parsed cache entry, or nil if absent, unreadable,
or not a JSON object (guards the "never raise" contract against a
corrupt/tampered cache holding e.g. [] or 42).
147 148 149 150 151 152 153 154 |
# File 'lib/woods/update_check.rb', line 147 def read_cache(cache_path) return nil unless File.exist?(cache_path) parsed = JSON.parse(File.read(cache_path)) parsed.is_a?(Hash) ? parsed : nil rescue StandardError nil end |
.refresh(cache_path, now, fetcher) ⇒ String?
Fetch and cache the outcome. A nil latest (unreachable RubyGems, non-2xx, unparseable body) is cached too, so repeated failures are throttled by FAILURE_TTL instead of re-probing on every call.
135 136 137 138 139 140 141 142 |
# File 'lib/woods/update_check.rb', line 135 def refresh(cache_path, now, fetcher) latest = fetcher.call(RUBYGEMS_LATEST_URL) write_cache(cache_path, latest, now) latest rescue StandardError write_cache(cache_path, nil, now) nil end |
.result(current, latest) ⇒ Object
98 99 100 |
# File 'lib/woods/update_check.rb', line 98 def result(current, latest) { current: current, latest: latest, update_available: latest ? newer?(latest, current) : false } end |
.status_hash(current: Woods::VERSION, **opts) ⇒ Hash
The server.update sub-hash for woods_status. Keys are snake_case to
match the rest of the status payload's JSON shape.
66 67 68 69 70 71 72 73 |
# File 'lib/woods/update_check.rb', line 66 def status_hash(current: Woods::VERSION, **opts) r = check(current: current, **opts) { current_version: r[:current], latest_version: r[:latest], update_available: r[:update_available] } end |
.tool_not_found_message(tool_name, current: Woods::VERSION, cache_path: default_cache_path, fetcher: nil) ⇒ String
Version-aware replacement for the MCP layer's bare "Tool not found" message. Cache-only — it never triggers a network fetch, since it runs on an error path that must stay cheap.
82 83 84 85 86 87 88 89 90 |
# File 'lib/woods/update_check.rb', line 82 def (tool_name, current: Woods::VERSION, cache_path: default_cache_path, fetcher: nil) _ = fetcher # accepted so callers/specs can prove it is never invoked on this path latest = disabled? ? nil : read_cache(cache_path)&.fetch('latest', nil) msg = "Tool not found: #{tool_name}. This tool is not available in the installed " \ "Woods v#{current}. It may require a newer release — advise the user to run " \ '`bundle update woods`, then reconnect the MCP server.' msg << " (latest published: #{latest})" if latest && newer?(latest, current) msg end |
.write_cache(cache_path, latest, now) ⇒ Object
156 157 158 159 160 |
# File 'lib/woods/update_check.rb', line 156 def write_cache(cache_path, latest, now) Woods::AtomicFile.write(cache_path, JSON.generate('latest' => latest, 'checked_at' => now.to_i)) rescue StandardError nil end |