Class: RailsAiContext::LiveReload

Inherits:
Object
  • Object
show all
Includes:
CountPhrase
Defined in:
lib/rails_ai_context/live_reload.rb

Overview

Watches for file changes and automatically invalidates MCP tool caches, sending notifications to connected AI clients so they re-query fresh data. Runs a background thread alongside the MCP server (stdio or HTTP).

Constant Summary collapse

WATCH_DIRS =
(Watcher::WATCH_PATTERNS | Fingerprinter::WATCHED_DIRS).freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods included from CountPhrase

call

Constructor Details

#initialize(app, mcp_server) ⇒ LiveReload

Returns a new instance of LiveReload.



14
15
16
17
18
# File 'lib/rails_ai_context/live_reload.rb', line 14

def initialize(app, mcp_server)
  @app = app
  @mcp_server = mcp_server
  @last_fingerprint = Fingerprinter.compute(app)
end

Instance Attribute Details

#appObject (readonly)

Returns the value of attribute app.



12
13
14
# File 'lib/rails_ai_context/live_reload.rb', line 12

def app
  @app
end

#mcp_serverObject (readonly)

Returns the value of attribute mcp_server.



12
13
14
# File 'lib/rails_ai_context/live_reload.rb', line 12

def mcp_server
  @mcp_server
end

Instance Method Details

#categorize_changes(paths) ⇒ Object

Group changed file paths by category (model, controller, etc.)



83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
# File 'lib/rails_ai_context/live_reload.rb', line 83

def categorize_changes(paths)
  categories = Hash.new(0)

  paths.each do |path|
    category = case path
    when %r{app/models}          then "model"
    when %r{app/controllers}     then "controller"
    when %r{app/views}           then "view"
    when %r{app/jobs}            then "job"
    when %r{app/mailers}         then "mailer"
    when %r{app/javascript}      then "JavaScript file"
    when %r{config/routes}       then "route"
    when %r{config/}             then "config"
    when %r{db/migrate}          then "migration"
    when %r{db/}                 then "database"
    when %r{lib/tasks}           then "rake task"
    else                              "file"
    end

    categories[category] += 1
  end

  categories
end

#format_change_message(categories) ⇒ Object

Build a readable summary like "Files changed: 2 models, 1 controller."



109
110
111
112
# File 'lib/rails_ai_context/live_reload.rb', line 109

def format_change_message(categories)
  parts = categories.map { |cat, count| count_phrase(count, cat) }
  "Files changed: #{parts.join(", ")}."
end

#handle_change(changed_paths = []) ⇒ Object

Process a batch of file changes. Public for testability.



53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
# File 'lib/rails_ai_context/live_reload.rb', line 53

def handle_change(changed_paths = [])
  return unless Fingerprinter.changed?(app, @last_fingerprint)

  @last_fingerprint = Fingerprinter.compute(app)

  # Order matters: reload the app's code first, then drop the caches built
  # from the old constants. Clearing caches alone left the server answering
  # from whatever Rails autoloaded at boot.
  reloaded = CodeReloader.reload!

  # Invalidate all tool caches (includes AstCache.clear)
  Tools::BaseTool.reset_all_caches!

  # Build a human-readable change summary
  message = format_change_message(categorize_changes(changed_paths))

  # Notify connected MCP clients
  mcp_server.notify_resources_list_changed
  mcp_server.notify_log_message(
    data: "#{message} Tool caches invalidated#{reloaded ? " and app code reloaded" : ""}.",
    level: "info",
    logger: "rails-ai-context"
  )

  $stderr.puts "[rails-ai-context] #{message} Tool caches invalidated#{reloaded ? " and app code reloaded" : ""}."
rescue => e
  $stderr.puts "[rails-ai-context] Live reload error: #{e.message}"
end

#startObject

Start the file watcher in a background thread. Non-blocking.



21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# File 'lib/rails_ai_context/live_reload.rb', line 21

def start
  require "listen"

  root = app.root.to_s
  debounce = RailsAiContext.configuration.live_reload_debounce
  dirs = WATCH_DIRS.map { |p| File.join(root, p) }.select { |d| Dir.exist?(d) }

  if dirs.empty?
    $stderr.puts "[rails-ai-context] Live reload: no watchable directories found"
    return
  end

  $stderr.puts "[rails-ai-context] Live reload enabled (debounce: #{debounce}s)"
  $stderr.puts "[rails-ai-context] Watching: #{dirs.map { |d| d.sub("#{root}/", "") }.join(", ")}"

  listener = Listen.to(*dirs, wait_for_delay: debounce) do |modified, added, removed|
    all_changes = modified + added + removed
    next if all_changes.empty?

    handle_change(all_changes)
  end

  listener.start
  @listener = listener
end

#stopObject

Stop the background listener thread.



48
49
50
# File 'lib/rails_ai_context/live_reload.rb', line 48

def stop
  @listener&.stop
end