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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +402 -0
- data/lib/generators/talk_to_your_app/custom_tool/custom_tool_generator.rb +39 -0
- data/lib/generators/talk_to_your_app/custom_tool/templates/tool.rb.tt +18 -0
- data/lib/generators/talk_to_your_app/health_check/health_check_generator.rb +31 -0
- data/lib/generators/talk_to_your_app/health_check/templates/check.rb.tt +12 -0
- data/lib/generators/talk_to_your_app/install/install_generator.rb +27 -0
- data/lib/generators/talk_to_your_app/install/templates/initializer.rb.tt +78 -0
- data/lib/talk_to_your_app/audit_logger.rb +115 -0
- data/lib/talk_to_your_app/auth/api_key.rb +29 -0
- data/lib/talk_to_your_app/auth/basic.rb +24 -0
- data/lib/talk_to_your_app/auth/middleware.rb +74 -0
- data/lib/talk_to_your_app/configuration.rb +129 -0
- data/lib/talk_to_your_app/connection_registry.rb +131 -0
- data/lib/talk_to_your_app/current.rb +14 -0
- data/lib/talk_to_your_app/custom_tool.rb +40 -0
- data/lib/talk_to_your_app/plugin.rb +59 -0
- data/lib/talk_to_your_app/plugin_registry.rb +48 -0
- data/lib/talk_to_your_app/plugins/custom_tools/plugin.rb +26 -0
- data/lib/talk_to_your_app/plugins/db/plugin.rb +57 -0
- data/lib/talk_to_your_app/plugins/db/tools/query.rb +126 -0
- data/lib/talk_to_your_app/plugins/db/tools/schema.rb +60 -0
- data/lib/talk_to_your_app/plugins/db/tools/tables.rb +28 -0
- data/lib/talk_to_your_app/plugins/flipper/plugin.rb +132 -0
- data/lib/talk_to_your_app/plugins/flipper/tools/disable_flag.rb +41 -0
- data/lib/talk_to_your_app/plugins/flipper/tools/enable_flag.rb +42 -0
- data/lib/talk_to_your_app/plugins/flipper/tools/enabled_flags.rb +41 -0
- data/lib/talk_to_your_app/plugins/flipper/tools/list_flags.rb +23 -0
- data/lib/talk_to_your_app/plugins/flipper/tools/read_flag.rb +33 -0
- data/lib/talk_to_your_app/plugins/health/plugin.rb +31 -0
- data/lib/talk_to_your_app/plugins/health/registry.rb +68 -0
- data/lib/talk_to_your_app/plugins/health/tools/list_checks.rb +24 -0
- data/lib/talk_to_your_app/plugins/health/tools/run_check.rb +27 -0
- data/lib/talk_to_your_app/plugins/jobs/adapters/sidekiq.rb +122 -0
- data/lib/talk_to_your_app/plugins/jobs/adapters/solid_queue.rb +90 -0
- data/lib/talk_to_your_app/plugins/jobs/interface.rb +38 -0
- data/lib/talk_to_your_app/plugins/jobs/plugin.rb +87 -0
- data/lib/talk_to_your_app/plugins/jobs/tools/failed_jobs.rb +28 -0
- data/lib/talk_to_your_app/plugins/jobs/tools/health.rb +25 -0
- data/lib/talk_to_your_app/plugins/jobs/tools/queue_sizes.rb +23 -0
- data/lib/talk_to_your_app/plugins/jobs/tools/rate_metrics.rb +30 -0
- data/lib/talk_to_your_app/plugins/jobs/tools/recent_jobs.rb +28 -0
- data/lib/talk_to_your_app/plugins/rake/plugin.rb +42 -0
- data/lib/talk_to_your_app/plugins/rake/tools/run.rb +56 -0
- data/lib/talk_to_your_app/railtie.rb +56 -0
- data/lib/talk_to_your_app/renderers/html_table.rb +27 -0
- data/lib/talk_to_your_app/tool.rb +204 -0
- data/lib/talk_to_your_app/transport/rails_mount.rb +46 -0
- data/lib/talk_to_your_app/version.rb +5 -0
- data/lib/talk_to_your_app.rb +124 -0
- 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)
|