talk_to_your_app 0.1.0.pre.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +402 -0
  4. data/lib/generators/talk_to_your_app/custom_tool/custom_tool_generator.rb +39 -0
  5. data/lib/generators/talk_to_your_app/custom_tool/templates/tool.rb.tt +18 -0
  6. data/lib/generators/talk_to_your_app/health_check/health_check_generator.rb +31 -0
  7. data/lib/generators/talk_to_your_app/health_check/templates/check.rb.tt +12 -0
  8. data/lib/generators/talk_to_your_app/install/install_generator.rb +27 -0
  9. data/lib/generators/talk_to_your_app/install/templates/initializer.rb.tt +78 -0
  10. data/lib/talk_to_your_app/audit_logger.rb +115 -0
  11. data/lib/talk_to_your_app/auth/api_key.rb +29 -0
  12. data/lib/talk_to_your_app/auth/basic.rb +24 -0
  13. data/lib/talk_to_your_app/auth/middleware.rb +74 -0
  14. data/lib/talk_to_your_app/configuration.rb +129 -0
  15. data/lib/talk_to_your_app/connection_registry.rb +131 -0
  16. data/lib/talk_to_your_app/current.rb +14 -0
  17. data/lib/talk_to_your_app/custom_tool.rb +40 -0
  18. data/lib/talk_to_your_app/plugin.rb +59 -0
  19. data/lib/talk_to_your_app/plugin_registry.rb +48 -0
  20. data/lib/talk_to_your_app/plugins/custom_tools/plugin.rb +26 -0
  21. data/lib/talk_to_your_app/plugins/db/plugin.rb +57 -0
  22. data/lib/talk_to_your_app/plugins/db/tools/query.rb +126 -0
  23. data/lib/talk_to_your_app/plugins/db/tools/schema.rb +60 -0
  24. data/lib/talk_to_your_app/plugins/db/tools/tables.rb +28 -0
  25. data/lib/talk_to_your_app/plugins/flipper/plugin.rb +132 -0
  26. data/lib/talk_to_your_app/plugins/flipper/tools/disable_flag.rb +41 -0
  27. data/lib/talk_to_your_app/plugins/flipper/tools/enable_flag.rb +42 -0
  28. data/lib/talk_to_your_app/plugins/flipper/tools/enabled_flags.rb +41 -0
  29. data/lib/talk_to_your_app/plugins/flipper/tools/list_flags.rb +23 -0
  30. data/lib/talk_to_your_app/plugins/flipper/tools/read_flag.rb +33 -0
  31. data/lib/talk_to_your_app/plugins/health/plugin.rb +31 -0
  32. data/lib/talk_to_your_app/plugins/health/registry.rb +68 -0
  33. data/lib/talk_to_your_app/plugins/health/tools/list_checks.rb +24 -0
  34. data/lib/talk_to_your_app/plugins/health/tools/run_check.rb +27 -0
  35. data/lib/talk_to_your_app/plugins/jobs/adapters/sidekiq.rb +122 -0
  36. data/lib/talk_to_your_app/plugins/jobs/adapters/solid_queue.rb +90 -0
  37. data/lib/talk_to_your_app/plugins/jobs/interface.rb +38 -0
  38. data/lib/talk_to_your_app/plugins/jobs/plugin.rb +87 -0
  39. data/lib/talk_to_your_app/plugins/jobs/tools/failed_jobs.rb +28 -0
  40. data/lib/talk_to_your_app/plugins/jobs/tools/health.rb +25 -0
  41. data/lib/talk_to_your_app/plugins/jobs/tools/queue_sizes.rb +23 -0
  42. data/lib/talk_to_your_app/plugins/jobs/tools/rate_metrics.rb +30 -0
  43. data/lib/talk_to_your_app/plugins/jobs/tools/recent_jobs.rb +28 -0
  44. data/lib/talk_to_your_app/plugins/rake/plugin.rb +42 -0
  45. data/lib/talk_to_your_app/plugins/rake/tools/run.rb +56 -0
  46. data/lib/talk_to_your_app/railtie.rb +56 -0
  47. data/lib/talk_to_your_app/renderers/html_table.rb +27 -0
  48. data/lib/talk_to_your_app/tool.rb +204 -0
  49. data/lib/talk_to_your_app/transport/rails_mount.rb +46 -0
  50. data/lib/talk_to_your_app/version.rb +5 -0
  51. data/lib/talk_to_your_app.rb +124 -0
  52. metadata +140 -0
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TalkToYourApp
4
+ # The operator-facing health-check registry. Host developers register named
5
+ # checks in Ruby; the Health plugin exposes them over MCP. Checks are plain
6
+ # callables owned by the host app — the gem does no scheduling, aggregation,
7
+ # alerting, or historical storage.
8
+ #
9
+ # Put one check per file under app/talk_to_your_app/health/ (loaded
10
+ # automatically by the :health plugin); each file calls register:
11
+ #
12
+ # TalkToYourApp::Health.register(:video_pipeline, description: "Encoder lag") do
13
+ # { status: :pass, value: encoder_lag_ratio }
14
+ # end
15
+ module Health
16
+ Check = Struct.new(:name, :description, :callable)
17
+
18
+ module_function
19
+
20
+ # Registers a check by block or callable. Re-registering a name replaces it
21
+ # and warns, so a typo'd duplicate is visible rather than silent.
22
+ def register(name, callable = nil, description: nil, &block)
23
+ runnable = block || callable
24
+ raise ArgumentError, "health check #{name.inspect} needs a block or callable" unless runnable
25
+
26
+ key = name.to_sym
27
+ warn("talk_to_your_app: health check #{key.inspect} was already registered; replacing it.") if checks.key?(key)
28
+ checks[key] = Check.new(key, description, runnable)
29
+ end
30
+
31
+ def checks
32
+ @checks ||= {}
33
+ end
34
+
35
+ def registered?(name)
36
+ checks.key?(name.to_sym)
37
+ end
38
+
39
+ def clear!
40
+ @checks = {}
41
+ end
42
+
43
+ # Runs a check and returns a normalized result hash. A raised exception is
44
+ # caught and reported as status "error" rather than crashing the request.
45
+ def run(name)
46
+ check = checks[name.to_sym]
47
+ return nil unless check
48
+
49
+ normalize(check.callable.call)
50
+ rescue StandardError => e
51
+ { status: "error", error_class: e.class.name, message: e.message }
52
+ end
53
+
54
+ # Accepts a bare boolean or a { status:, value:, message: } hash.
55
+ def normalize(result)
56
+ case result
57
+ when true then { status: "pass" }
58
+ when false then { status: "fail" }
59
+ when Hash
60
+ normalized = result.transform_keys(&:to_sym)
61
+ normalized[:status] = normalized[:status].to_s if normalized[:status]
62
+ normalized
63
+ else
64
+ { status: "pass", value: result }
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../tool"
4
+
5
+ module TalkToYourApp
6
+ module Plugins
7
+ module Health
8
+ module Tools
9
+ # Lists the registered health checks with their descriptions.
10
+ class ListChecks < TalkToYourApp::Tool
11
+ name "health.list"
12
+ description "List the registered health checks."
13
+
14
+ def call(_args, _ctx)
15
+ checks = TalkToYourApp::Health.checks.values.map do |check|
16
+ { name: check.name, description: check.description }
17
+ end
18
+ json(checks: checks)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../tool"
4
+
5
+ module TalkToYourApp
6
+ module Plugins
7
+ module Health
8
+ module Tools
9
+ # Runs a named health check and returns its normalized result. An unknown
10
+ # name is a usage error (MCP tool error); a check that runs but fails or
11
+ # raises returns a normal result whose `status` carries the outcome.
12
+ class RunCheck < TalkToYourApp::Tool
13
+ name "health.run"
14
+ description "Run a named health check and return pass/fail plus its value."
15
+ argument :name, :string, required: true, description: "The registered check name."
16
+
17
+ def call(args, _ctx)
18
+ result = TalkToYourApp::Health.run(args[:name])
19
+ return error("Unknown health check: #{args[:name]}") if result.nil?
20
+
21
+ json(result)
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module TalkToYourApp
6
+ module Plugins
7
+ module Jobs
8
+ module Adapters
9
+ # Sidekiq adapter. Reads queue, retry, and dead-set data through
10
+ # Sidekiq's public API. Read-only: it never enqueues, retries, or kills.
11
+ # All four methods return the common shape defined by Jobs::Interface.
12
+ module Sidekiq
13
+ REQUIRED_GEM = { const: "Sidekiq", gem_name: "sidekiq" }.freeze
14
+
15
+ module_function
16
+
17
+ def required_gem
18
+ REQUIRED_GEM
19
+ end
20
+
21
+ def queue_sizes
22
+ ::Sidekiq::Stats.new.queues
23
+ end
24
+
25
+ # Sidekiq has no first-class "recent jobs" API; we scan the queues and
26
+ # the retry set, sort newest-first by enqueued time, and cap at limit.
27
+ # Per-queue fetch is bounded so a many-queue install does not materialize
28
+ # limit*queue_count records to return `limit`; this can miss the very
29
+ # newest jobs when they cluster in one queue, which is acceptable for an
30
+ # inspection tool.
31
+ def recent_jobs(limit:)
32
+ queues = ::Sidekiq::Queue.all
33
+ per_queue = [(limit / [queues.size, 1].max) + 1, limit].min
34
+ queued = queues.flat_map { |queue| queue.first(per_queue) }
35
+ retries = ::Sidekiq::RetrySet.new.first(limit)
36
+ (queued + retries)
37
+ .sort_by { |entry| -raw_enqueued_at(entry) }
38
+ .first(limit)
39
+ .map { |entry| job_hash(entry) }
40
+ end
41
+
42
+ def failed_jobs(limit:)
43
+ ::Sidekiq::DeadSet.new.first(limit).map { |entry| job_hash(entry) }
44
+ end
45
+
46
+ # Worker/queue health. Lists the running Sidekiq processes with their
47
+ # concurrency and busy-thread counts, plus set sizes.
48
+ def health
49
+ require "sidekiq/api"
50
+ stats = ::Sidekiq::Stats.new
51
+ processes = ::Sidekiq::ProcessSet.new.map do |process|
52
+ {
53
+ hostname: process["hostname"],
54
+ pid: process["pid"],
55
+ concurrency: process["concurrency"],
56
+ busy: process["busy"],
57
+ queues: process["queues"],
58
+ started_at: process["started_at"] ? Time.at(process["started_at"].to_f).utc.iso8601 : nil,
59
+ quiet: process["quiet"] == "true",
60
+ }
61
+ end
62
+ {
63
+ adapter: "sidekiq",
64
+ processes: processes,
65
+ process_count: processes.size,
66
+ total_concurrency: processes.sum { |p| p[:concurrency].to_i },
67
+ busy_threads: stats.workers_size,
68
+ enqueued: stats.enqueued,
69
+ scheduled: stats.scheduled_size,
70
+ retries: stats.retry_size,
71
+ dead: stats.dead_size,
72
+ default_queue_latency: ::Sidekiq::Queue.new.latency,
73
+ }
74
+ end
75
+
76
+ # Sidekiq exposes cumulative and day-resolution counts, not arbitrary
77
+ # trailing windows; the response says so via `note`.
78
+ def rate_metrics(window:)
79
+ stats = ::Sidekiq::Stats.new
80
+ {
81
+ window_seconds: window,
82
+ processed: stats.processed,
83
+ failed: stats.failed,
84
+ enqueued: stats.enqueued,
85
+ note: "Sidekiq reports cumulative processed/failed counts; the window is not applied at sub-day resolution.",
86
+ }
87
+ end
88
+
89
+ # Both Sidekiq::JobRecord and Sidekiq::SortedEntry expose the raw job
90
+ # hash via #item. Keys are always present (nil where absent) so the
91
+ # shape is stable across adapters and across recent/failed.
92
+ def job_hash(entry)
93
+ item = entry.respond_to?(:item) ? entry.item : entry
94
+ {
95
+ jid: item["jid"],
96
+ class: item["class"] || item["wrapped"],
97
+ queue: item["queue"],
98
+ args: item["args"],
99
+ enqueued_at: iso8601(item["enqueued_at"] || item["created_at"]),
100
+ error_message: item["error_message"],
101
+ }
102
+ end
103
+
104
+ # Sidekiq stores timestamps as Unix floats; surface ISO-8601 so the
105
+ # field type matches the Solid Queue adapter.
106
+ def iso8601(unix_timestamp)
107
+ return nil unless unix_timestamp
108
+
109
+ Time.at(unix_timestamp.to_f).utc.iso8601
110
+ end
111
+
112
+ def raw_enqueued_at(entry)
113
+ item = entry.respond_to?(:item) ? entry.item : entry
114
+ (item["enqueued_at"] || item["created_at"] || 0).to_f
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
121
+
122
+ TalkToYourApp::Plugins::Jobs.register_adapter(:sidekiq, TalkToYourApp::Plugins::Jobs::Adapters::Sidekiq)
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TalkToYourApp
4
+ module Plugins
5
+ module Jobs
6
+ module Adapters
7
+ # Solid Queue adapter. Reads Solid Queue's ActiveRecord models, which
8
+ # live in the host application's database. By default the queries run on
9
+ # the primary connection (whatever Solid Queue itself is configured to
10
+ # use); operators wanting isolation point Solid Queue at a separate
11
+ # database in their own config. Read-only and returns the common shape.
12
+ module SolidQueue
13
+ REQUIRED_GEM = { const: "SolidQueue", gem_name: "solid_queue" }.freeze
14
+
15
+ module_function
16
+
17
+ def required_gem
18
+ REQUIRED_GEM
19
+ end
20
+
21
+ def queue_sizes
22
+ ::SolidQueue::ReadyExecution.group(:queue_name).count
23
+ end
24
+
25
+ def recent_jobs(limit:)
26
+ ::SolidQueue::Job.order(created_at: :desc).limit(limit).map { |job| job_hash(job) }
27
+ end
28
+
29
+ def failed_jobs(limit:)
30
+ ::SolidQueue::FailedExecution.includes(:job).order(created_at: :desc).limit(limit).map do |failure|
31
+ job_hash(failure.job).merge(error_message: failure.message)
32
+ end
33
+ end
34
+
35
+ # Worker/queue health. Lists the registered Solid Queue processes
36
+ # (supervisor/workers/dispatcher/scheduler) with their last heartbeat,
37
+ # plus pending/claimed/scheduled/failed counts.
38
+ def health
39
+ processes = ::SolidQueue::Process.all.map do |process|
40
+ {
41
+ kind: process.kind,
42
+ hostname: process.hostname,
43
+ pid: process.pid,
44
+ name: process.name,
45
+ last_heartbeat_at: process.last_heartbeat_at&.utc&.iso8601,
46
+ }
47
+ end
48
+ {
49
+ adapter: "solid_queue",
50
+ processes: processes,
51
+ process_count: processes.size,
52
+ workers: processes.count { |p| p[:kind].to_s.include?("Worker") },
53
+ pending: ::SolidQueue::ReadyExecution.count,
54
+ claimed: ::SolidQueue::ClaimedExecution.count,
55
+ scheduled: ::SolidQueue::ScheduledExecution.count,
56
+ failed: ::SolidQueue::FailedExecution.count,
57
+ }
58
+ end
59
+
60
+ def rate_metrics(window:)
61
+ since = Time.now - window.to_i # plain Ruby; no ActiveSupport core-ext dependency
62
+ {
63
+ window_seconds: window,
64
+ enqueued: ::SolidQueue::Job.where(created_at: since..).count,
65
+ processed: ::SolidQueue::Job.where.not(finished_at: nil).where(finished_at: since..).count,
66
+ failed: ::SolidQueue::FailedExecution.where(created_at: since..).count,
67
+ }
68
+ end
69
+
70
+ def job_hash(job)
71
+ # Keep every key present even for an orphaned failure (nil job), so
72
+ # the shape matches the Sidekiq adapter.
73
+ return { jid: nil, class: nil, queue: nil, args: nil, enqueued_at: nil, error_message: nil } unless job
74
+
75
+ {
76
+ jid: job.active_job_id || job.id,
77
+ class: job.class_name,
78
+ queue: job.queue_name,
79
+ args: job.arguments,
80
+ enqueued_at: job.created_at&.utc&.iso8601,
81
+ error_message: nil, # failed_jobs overrides this; present for shape stability
82
+ }
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+
90
+ TalkToYourApp::Plugins::Jobs.register_adapter(:solid_queue, TalkToYourApp::Plugins::Jobs::Adapters::SolidQueue)
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TalkToYourApp
4
+ module Plugins
5
+ module Jobs
6
+ # The contract every jobs adapter must satisfy. Adapters duck-type it —
7
+ # there is no abstract base class and no `include`. Each method returns a
8
+ # plain Ruby structure with a stable shape across adapters, so a client
9
+ # sees the same response whether the backend is Sidekiq or Solid Queue.
10
+ #
11
+ # queue_sizes -> { "queue_name" => Integer, ... }
12
+ # recent_jobs(limit:) -> [ { jid:, class:, queue:, args:, enqueued_at:, error_message: }, ... ]
13
+ # failed_jobs(limit:) -> [ { jid:, class:, queue:, args:, enqueued_at:, error_message: }, ... ]
14
+ # rate_metrics(window:) -> { window_seconds:, processed:, failed:, enqueued:, note? }
15
+ # health -> { adapter:, processes:[...], ... } (adapter-specific)
16
+ #
17
+ # `health` is intentionally adapter-specific: it reports worker/process
18
+ # health (running processes, threads/concurrency, pending/claimed counts),
19
+ # whose meaningful fields differ between backends. Every adapter includes
20
+ # an `adapter:` key so clients can branch on it.
21
+ #
22
+ # Job hashes carry the same keys across adapters and across recent/failed;
23
+ # `enqueued_at` is an ISO-8601 string and `error_message` is nil for jobs
24
+ # that have not failed. `window:` is a number of seconds; adapters that
25
+ # cannot honor sub-day granularity include a `note` explaining the
26
+ # resolution they returned.
27
+ module Interface
28
+ METHODS = %i[queue_sizes recent_jobs failed_jobs rate_metrics health].freeze
29
+
30
+ # True when the adapter responds to every interface method. Checked at
31
+ # boot so an incomplete adapter fails fast rather than at first call.
32
+ def self.satisfied_by?(adapter)
33
+ METHODS.all? { |method| adapter.respond_to?(method) }
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../plugin"
4
+ require_relative "interface"
5
+ require_relative "tools/queue_sizes"
6
+ require_relative "tools/recent_jobs"
7
+ require_relative "tools/failed_jobs"
8
+ require_relative "tools/rate_metrics"
9
+ require_relative "tools/health"
10
+
11
+ module TalkToYourApp
12
+ module Plugins
13
+ # The Jobs plugin exposes read-only background-job metrics through a common
14
+ # interface, backed by an operator-selected adapter. Adapters register
15
+ # themselves (U8 Sidekiq, U9 Solid Queue); the operator declares which one
16
+ # with `config.plugin :jobs, adapter: :sidekiq`. Selection is explicit, never
17
+ # auto-detected — boot fails if no adapter is declared or the named one is
18
+ # unknown or its backing gem is absent.
19
+ module Jobs
20
+ @adapters = {}
21
+
22
+ class << self
23
+ # Registers an adapter class under a name. The class duck-types
24
+ # Jobs::Interface and exposes `required_gem` ({ const:, gem_name: }).
25
+ def register_adapter(name, adapter_class)
26
+ @adapters[name.to_sym] = adapter_class
27
+ end
28
+
29
+ def adapter_for(name)
30
+ @adapters[name&.to_sym]
31
+ end
32
+
33
+ def known_adapter_names
34
+ @adapters.keys
35
+ end
36
+
37
+ # Resolves the adapter selected in the current configuration.
38
+ def configured_adapter
39
+ options = TalkToYourApp.configuration.enabled_plugins[:jobs] || {}
40
+ adapter_for(options[:adapter]) ||
41
+ raise(ConfigurationError, "talk_to_your_app: the jobs plugin has no adapter configured.")
42
+ end
43
+ end
44
+
45
+ class Plugin < TalkToYourApp::Plugin
46
+ tools Tools::QueueSizes, Tools::RecentJobs, Tools::FailedJobs, Tools::RateMetrics, Tools::Health
47
+
48
+ def self.validate_enablement!(options)
49
+ adapter_name = options[:adapter]
50
+ if adapter_name.nil?
51
+ raise ConfigurationError,
52
+ "talk_to_your_app: the jobs plugin requires an `adapter:` option, e.g. " \
53
+ "`config.plugin :jobs, adapter: :sidekiq`."
54
+ end
55
+
56
+ adapter = Jobs.adapter_for(adapter_name)
57
+ unless adapter
58
+ raise ConfigurationError,
59
+ "talk_to_your_app: jobs adapter #{adapter_name.inspect} is not supported. " \
60
+ "Available adapters: #{Jobs.known_adapter_names.inspect}."
61
+ end
62
+
63
+ req = adapter.required_gem
64
+ if req && !Object.const_defined?(req[:const])
65
+ raise ConfigurationError,
66
+ "talk_to_your_app: jobs adapter #{adapter_name.inspect} requires the `#{req[:gem_name]}` gem " \
67
+ "(constant #{req[:const]} is not defined)."
68
+ end
69
+
70
+ unless Interface.satisfied_by?(adapter)
71
+ raise ConfigurationError,
72
+ "talk_to_your_app: jobs adapter #{adapter_name.inspect} does not implement the full interface " \
73
+ "(#{Interface::METHODS.join(", ")})."
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+
81
+ TalkToYourApp.register_plugin(:jobs, TalkToYourApp::Plugins::Jobs::Plugin)
82
+
83
+ # Bundled adapters self-register on load (after Jobs.register_adapter exists).
84
+ # They reference their backing gem's constants only inside method bodies, so
85
+ # loading them never requires the gem.
86
+ require_relative "adapters/sidekiq"
87
+ require_relative "adapters/solid_queue"
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../tool"
4
+
5
+ module TalkToYourApp
6
+ module Plugins
7
+ module Jobs
8
+ module Tools
9
+ # Returns recently failed jobs with their error messages, capped at 500.
10
+ class FailedJobs < TalkToYourApp::Tool
11
+ MAX_LIMIT = 500
12
+
13
+ name "jobs.failed_jobs"
14
+ description "Recently failed jobs and their error messages."
15
+ argument :limit, :integer, default: 50, minimum: 1, maximum: MAX_LIMIT,
16
+ description: "How many failed jobs to return (1-500)."
17
+
18
+ def call(args, _ctx)
19
+ limit = args[:limit].to_i.clamp(1, MAX_LIMIT)
20
+ json(TalkToYourApp::Plugins::Jobs.configured_adapter.failed_jobs(limit: limit))
21
+ rescue StandardError => e
22
+ error("Jobs backend unavailable: #{e.class}: #{e.message}")
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../tool"
4
+
5
+ module TalkToYourApp
6
+ module Plugins
7
+ module Jobs
8
+ module Tools
9
+ # Reports worker/queue health: running processes, threads/concurrency,
10
+ # and pending/claimed/scheduled counts. The shape is adapter-specific
11
+ # (each response carries an `adapter` key to branch on).
12
+ class Health < TalkToYourApp::Tool
13
+ name "jobs.health"
14
+ description "Background-job worker/queue health (processes, threads, pending). Adapter-specific."
15
+
16
+ def call(_args, _ctx)
17
+ json(TalkToYourApp::Plugins::Jobs.configured_adapter.health)
18
+ rescue StandardError => e
19
+ error("Jobs backend unavailable: #{e.class}: #{e.message}")
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../tool"
4
+
5
+ module TalkToYourApp
6
+ module Plugins
7
+ module Jobs
8
+ module Tools
9
+ # Returns the current size of each queue as { queue_name => count }.
10
+ class QueueSizes < TalkToYourApp::Tool
11
+ name "jobs.queue_sizes"
12
+ description "Current size of each background-job queue."
13
+
14
+ def call(_args, _ctx)
15
+ json(TalkToYourApp::Plugins::Jobs.configured_adapter.queue_sizes)
16
+ rescue StandardError => e
17
+ error("Jobs backend unavailable: #{e.class}: #{e.message}")
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../tool"
4
+
5
+ module TalkToYourApp
6
+ module Plugins
7
+ module Jobs
8
+ module Tools
9
+ # Processed/failed/enqueued counts over a trailing window. The window is
10
+ # expressed in seconds (default 30 minutes); adapters that cannot honor
11
+ # sub-day granularity say so in the response `note`.
12
+ class RateMetrics < TalkToYourApp::Tool
13
+ DEFAULT_WINDOW_SECONDS = 1800
14
+
15
+ name "jobs.rate_metrics"
16
+ description "Processed/failed/enqueued counts over a trailing window (seconds)."
17
+ argument :window, :integer, default: DEFAULT_WINDOW_SECONDS, minimum: 1,
18
+ description: "Trailing window in seconds (default 1800 = 30 minutes)."
19
+
20
+ def call(args, _ctx)
21
+ window = args[:window] || DEFAULT_WINDOW_SECONDS
22
+ json(TalkToYourApp::Plugins::Jobs.configured_adapter.rate_metrics(window: window))
23
+ rescue StandardError => e
24
+ error("Jobs backend unavailable: #{e.class}: #{e.message}")
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../tool"
4
+
5
+ module TalkToYourApp
6
+ module Plugins
7
+ module Jobs
8
+ module Tools
9
+ # Returns the most recently enqueued jobs, newest first, capped at 500.
10
+ class RecentJobs < TalkToYourApp::Tool
11
+ MAX_LIMIT = 500
12
+
13
+ name "jobs.recent_jobs"
14
+ description "Most recently enqueued jobs (newest first)."
15
+ argument :limit, :integer, default: 50, minimum: 1, maximum: MAX_LIMIT,
16
+ description: "How many jobs to return (1-500)."
17
+
18
+ def call(args, _ctx)
19
+ limit = args[:limit].to_i.clamp(1, MAX_LIMIT)
20
+ json(TalkToYourApp::Plugins::Jobs.configured_adapter.recent_jobs(limit: limit))
21
+ rescue StandardError => e
22
+ error("Jobs backend unavailable: #{e.class}: #{e.message}")
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../plugin"
4
+ require_relative "tools/run"
5
+
6
+ module TalkToYourApp
7
+ module Plugins
8
+ # The Rake plugin runs operator-approved rake tasks over MCP. It is
9
+ # fail-closed and allow-list-only: it refuses to boot without an explicit
10
+ # `allowed:` list, and refuses any task not on that list. Because rake tasks
11
+ # can do anything, the allow-list is the security boundary — keep it tight,
12
+ # and prefer read-only/reporting tasks.
13
+ #
14
+ # config.plugin :rake, allowed: ["stats", "report:generate"]
15
+ module Rake
16
+ module_function
17
+
18
+ def allowed_tasks
19
+ options = TalkToYourApp.configuration.enabled_plugins[:rake] || {}
20
+ Array(options[:allowed]).map(&:to_s)
21
+ end
22
+
23
+ def allowed?(task)
24
+ allowed_tasks.include?(task.to_s)
25
+ end
26
+
27
+ class Plugin < TalkToYourApp::Plugin
28
+ tools Tools::Run
29
+
30
+ def self.validate_enablement!(options)
31
+ return unless Array(options[:allowed]).empty?
32
+
33
+ raise ConfigurationError,
34
+ "talk_to_your_app: the rake plugin requires a non-empty `allowed:` list of rake task names, " \
35
+ "e.g. `config.plugin :rake, allowed: [\"stats\", \"report:generate\"]`. Tasks not on the list are refused."
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+
42
+ TalkToYourApp.register_plugin(:rake, TalkToYourApp::Plugins::Rake::Plugin)