Class: RailsAiContext::LiveReload

Inherits:
Object
  • Object
show all
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

Constructor Details

#initialize(app, mcp_server) ⇒ LiveReload

Returns a new instance of LiveReload.



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

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.



10
11
12
# File 'lib/rails_ai_context/live_reload.rb', line 10

def app
  @app
end

#mcp_serverObject (readonly)

Returns the value of attribute mcp_server.



10
11
12
# File 'lib/rails_ai_context/live_reload.rb', line 10

def mcp_server
  @mcp_server
end

Instance Method Details

#categorize_changes(paths) ⇒ Object

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



76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
# File 'lib/rails_ai_context/live_reload.rb', line 76

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"
    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 model(s), 1 controller(s).”



102
103
104
105
# File 'lib/rails_ai_context/live_reload.rb', line 102

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

#handle_change(changed_paths = []) ⇒ Object

Process a batch of file changes. Public for testability.



51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
# File 'lib/rails_ai_context/live_reload.rb', line 51

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

  @last_fingerprint = Fingerprinter.compute(app)

  # 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.",
    level: "info",
    logger: "rails-ai-context"
  )

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

#startObject

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



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

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.



46
47
48
# File 'lib/rails_ai_context/live_reload.rb', line 46

def stop
  @listener&.stop
end