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.

Examples:

Woods::UpdateCheck.status_hash
# => { current_version: "1.5.0", latest_version: "1.6.0", update_available: true }

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

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.

Returns:

  • (String, nil)


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.

Parameters:

  • current (String) (defaults to: Woods::VERSION)

    Installed version (defaults to VERSION)

  • cache_path (String) (defaults to: default_cache_path)

    Path to the JSON cache file

  • ttl (Integer) (defaults to: CACHE_TTL)

    Cache lifetime in seconds

  • now (Time) (defaults to: Time.now)

    Injected clock (for deterministic specs)

  • fetcher (#call) (defaults to: method(:fetch_latest_version))

    Callable taking the URL and returning a version string or nil; injectable for tests

Returns:

  • (Hash)

    { current:, latest:, update_available: } (latest may be nil)



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_pathObject

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 -----------------------------------------------------------

Returns:

  • (Boolean)


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.

Returns:

  • (String, nil)

    latest version string, or nil on any failure



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.

Returns:

  • (Boolean)


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

Returns:

  • (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).

Returns:

  • (Hash, nil)

    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.

Returns:

  • (String, nil)

    the latest version, or nil on failure



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.

Returns:

  • (Hash)

    { current_version:, latest_version:, update_available: }



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.

Parameters:

  • tool_name (String)

    The unrecognized tool name

  • current (String) (defaults to: Woods::VERSION)

    Installed version

Returns:

  • (String)

    Guidance an agent can relay to the user



82
83
84
85
86
87
88
89
90
# File 'lib/woods/update_check.rb', line 82

def tool_not_found_message(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