pgbus 0.9.4 → 0.9.5

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +115 -0
  3. data/app/helpers/pgbus/application_helper.rb +12 -2
  4. data/app/views/pgbus/dashboard/_processes_table.html.erb +1 -1
  5. data/app/views/pgbus/processes/_processes_table.html.erb +1 -1
  6. data/config/locales/da.yml +1 -0
  7. data/config/locales/de.yml +1 -0
  8. data/config/locales/en.yml +1 -0
  9. data/config/locales/es.yml +1 -0
  10. data/config/locales/fi.yml +1 -0
  11. data/config/locales/fr.yml +1 -0
  12. data/config/locales/it.yml +1 -0
  13. data/config/locales/ja.yml +1 -0
  14. data/config/locales/nb.yml +1 -0
  15. data/config/locales/nl.yml +1 -0
  16. data/config/locales/pt.yml +1 -0
  17. data/config/locales/sv.yml +1 -0
  18. data/lib/pgbus/cli.rb +16 -0
  19. data/lib/pgbus/client.rb +34 -7
  20. data/lib/pgbus/configuration.rb +16 -0
  21. data/lib/pgbus/mcp/base_tool.rb +79 -0
  22. data/lib/pgbus/mcp/health_analyzer.rb +159 -0
  23. data/lib/pgbus/mcp/rack_app.rb +97 -0
  24. data/lib/pgbus/mcp/redactor.rb +72 -0
  25. data/lib/pgbus/mcp/runner.rb +73 -0
  26. data/lib/pgbus/mcp/server.rb +62 -0
  27. data/lib/pgbus/mcp/tools/dlq_detail_tool.rb +72 -0
  28. data/lib/pgbus/mcp/tools/dlq_tool.rb +48 -0
  29. data/lib/pgbus/mcp/tools/health_tool.rb +33 -0
  30. data/lib/pgbus/mcp/tools/job_detail_tool.rb +42 -0
  31. data/lib/pgbus/mcp/tools/jobs_tool.rb +56 -0
  32. data/lib/pgbus/mcp/tools/locks_tool.rb +28 -0
  33. data/lib/pgbus/mcp/tools/processes_tool.rb +32 -0
  34. data/lib/pgbus/mcp/tools/queue_detail_tool.rb +46 -0
  35. data/lib/pgbus/mcp/tools/queues_tool.rb +28 -0
  36. data/lib/pgbus/mcp/tools/recurring_tool.rb +27 -0
  37. data/lib/pgbus/mcp/tools/stats_tool.rb +40 -0
  38. data/lib/pgbus/mcp/tools/throughput_tool.rb +35 -0
  39. data/lib/pgbus/mcp.rb +55 -0
  40. data/lib/pgbus/process/heartbeat.rb +8 -2
  41. data/lib/pgbus/process/supervisor.rb +44 -0
  42. data/lib/pgbus/process/worker.rb +12 -1
  43. data/lib/pgbus/version.rb +1 -1
  44. data/lib/pgbus/web/data_source.rb +19 -2
  45. data/lib/pgbus.rb +15 -1
  46. metadata +20 -1
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module MCP
5
+ # Computes the top-level pgbus health verdict (OK / DEGRADED / STALLED)
6
+ # from the existing DataSource read layer. This is the single signal that
7
+ # catches the silent-worker-wedge class of incident (#179, #174, #181):
8
+ # a queue with visible messages and no claim progress while a subscribing
9
+ # worker is heart-beating with idle capacity.
10
+ #
11
+ # Verdict semantics (issue #180 acceptance criteria):
12
+ # STALLED — backlog (visible > 0) AND at least one worker is heart-beating
13
+ # but its claim loop has stopped advancing (status :stalled),
14
+ # OR backlog with live-but-idle workers and zero claim progress.
15
+ # DEGRADED — something is wrong but not the wedge: stale processes, a
16
+ # paused queue holding a backlog, growing DLQ, or MVCC horizon
17
+ # pinned by a long-running transaction.
18
+ # OK — draining normally / nothing actionable.
19
+ class HealthAnalyzer
20
+ # A worker is considered to have idle capacity unless its metadata
21
+ # explicitly reports it is saturated. We treat the presence of any live
22
+ # worker as "has capacity" because a wedged worker reports healthy
23
+ # heartbeats while doing no work — exactly the case we must catch.
24
+ WORKER_KIND = "worker"
25
+
26
+ def initialize(data_source)
27
+ @data_source = data_source
28
+ end
29
+
30
+ # Returns a machine-readable verdict hash suitable for both interactive
31
+ # agent use and automated alerting.
32
+ def verdict
33
+ queues = @data_source.queues_with_metrics
34
+ processes = @data_source.processes
35
+ health, health_error = safe_queue_health
36
+
37
+ # Partition queues once: non-DLQ (the operational set) and the subset
38
+ # of those with visible, claimable backlog. Paused queues are removed
39
+ # from the STALLED backlog (an intentional pause is not the wedge —
40
+ # it's reported under DEGRADED), but kept in `non_dlq` for the summary.
41
+ non_dlq = queues.reject { |q| dlq?(q) }
42
+ backlog = non_dlq.select { |q| q[:queue_visible_length].to_i.positive? }
43
+ active_backlog = backlog.reject { |q| q[:paused] }
44
+
45
+ stalled = stalled_reasons(active_backlog, processes)
46
+ degraded = degraded_reasons(queues, backlog, processes, health, health_error)
47
+
48
+ status = if stalled.any?
49
+ "STALLED"
50
+ elsif degraded.any?
51
+ "DEGRADED"
52
+ else
53
+ "OK"
54
+ end
55
+
56
+ {
57
+ status: status,
58
+ reasons: stalled + degraded,
59
+ checked_at: Time.now.utc.iso8601,
60
+ summary: build_summary(queues, non_dlq, processes, health)
61
+ }
62
+ end
63
+
64
+ private
65
+
66
+ def dlq?(queue)
67
+ queue[:name].to_s.end_with?(Pgbus::DEAD_LETTER_SUFFIX)
68
+ end
69
+
70
+ # Returns [health_hash, error_or_nil]. We never raise — pgbus_health
71
+ # must always produce a verdict — but we DO surface the failure so the
72
+ # caller knows the verdict was built from partial data, and we log it
73
+ # for operators (per the project's no-silent-errors rule).
74
+ def safe_queue_health
75
+ [@data_source.queue_health_stats, nil]
76
+ rescue StandardError => e
77
+ Pgbus.logger.warn { "[Pgbus::MCP] queue_health_stats failed: #{e.class}: #{e.message}" }
78
+ [{}, e]
79
+ end
80
+
81
+ # STALLED detection: an active (non-paused) backed-up queue while a
82
+ # worker is in the :stalled state, or backed up with live-but-idle
83
+ # workers present.
84
+ def stalled_reasons(backlog, processes)
85
+ return [] if backlog.empty?
86
+
87
+ workers = processes.select { |p| p[:kind] == WORKER_KIND }
88
+ return [] if workers.empty?
89
+
90
+ stalled_workers = workers.select { |w| w[:status].to_s == "stalled" }
91
+ live_workers = workers.select { |w| %w[healthy stalled].include?(w[:status].to_s) }
92
+
93
+ reasons = []
94
+ if stalled_workers.any?
95
+ reasons << "#{stalled_workers.size} worker(s) stalled (heart-beating but claim loop not advancing) " \
96
+ "while #{backlog.size} queue(s) have visible backlog: #{backlog_names(backlog)}"
97
+ elsif live_workers.any? && all_unread?(backlog)
98
+ reasons << "#{backlog.size} queue(s) have visible messages with read_ct=0 (never claimed) " \
99
+ "while #{live_workers.size} worker(s) are alive: #{backlog_names(backlog)}"
100
+ end
101
+ reasons
102
+ end
103
+
104
+ # DEGRADED detection: conditions worth surfacing that are not the wedge.
105
+ def degraded_reasons(queues, backlog, processes, health, health_error)
106
+ reasons = []
107
+
108
+ reasons << "queue health stats unavailable: #{health_error.class}: #{health_error.message}" if health_error
109
+
110
+ stale = processes.select { |p| p[:status].to_s == "stale" }
111
+ reasons << "#{stale.size} process(es) stale (no recent heartbeat)" if stale.any?
112
+
113
+ paused_backlog = backlog.select { |q| q[:paused] }
114
+ reasons << "#{paused_backlog.size} paused queue(s) holding a backlog: #{backlog_names(paused_backlog)}" if paused_backlog.any?
115
+
116
+ dlq = queues.select { |q| dlq?(q) && q[:queue_length].to_i.positive? }
117
+ reasons << "#{dlq.size} dead-letter queue(s) hold messages" if dlq.any?
118
+
119
+ age = health[:oldest_transaction_age_sec]
120
+ reasons << "oldest open transaction is #{age}s old (MVCC horizon pinning risk)" if age && age > 300
121
+
122
+ reasons
123
+ end
124
+
125
+ # The strongest wedge signal: visible messages that have never been read.
126
+ # When queue metrics don't expose read_ct we conservatively treat the
127
+ # backlog as unread (the wedge default) so we don't miss the condition.
128
+ def all_unread?(backlog)
129
+ backlog.all? { |q| !q.key?(:max_read_ct) || q[:max_read_ct].to_i.zero? }
130
+ end
131
+
132
+ def backlog_names(queues)
133
+ queues.map { |q| q[:name] }.join(", ")
134
+ end
135
+
136
+ def build_summary(queues, non_dlq, processes, health)
137
+ workers = stale = stalled = 0
138
+ processes.each do |p|
139
+ status = p[:status].to_s
140
+ stale += 1 if status == "stale"
141
+ next unless p[:kind] == WORKER_KIND
142
+
143
+ workers += 1
144
+ stalled += 1 if status == "stalled"
145
+ end
146
+
147
+ {
148
+ queues: queues.size,
149
+ total_visible: non_dlq.sum { |q| q[:queue_visible_length].to_i },
150
+ total_depth: non_dlq.sum { |q| q[:queue_length].to_i },
151
+ workers: workers,
152
+ stalled_workers: stalled,
153
+ stale_processes: stale,
154
+ oldest_transaction_age_sec: health[:oldest_transaction_age_sec]
155
+ }
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module MCP
5
+ # A turnkey, gated Rack app that serves the read-only pgbus diagnostic MCP
6
+ # server over HTTP. Mount it inside your existing Rails app — no second
7
+ # process, same DB credentials, same connection pool:
8
+ #
9
+ # # config/routes.rb
10
+ # mount Pgbus::MCP.rack_app(token: ENV["PGBUS_MCP_TOKEN"]) => "/pgbus/mcp"
11
+ #
12
+ # The underlying transport runs in stateless + JSON-response mode, which:
13
+ # * makes every request a self-contained POST returning a single JSON
14
+ # object (no in-memory session, no SSE stream, no reaper thread), so it
15
+ # is safe behind multiple Puma/Falcon workers — any worker can answer
16
+ # any request;
17
+ # * is exactly what a read-only diagnostic server needs (it never pushes
18
+ # server-initiated messages to the client).
19
+ #
20
+ # Security: requests are rejected with 401 unless they carry the configured
21
+ # token (or pass the supplied auth callable). Run it on an internal network
22
+ # / behind your VPN, never internet-exposed.
23
+ class RackApp
24
+ BEARER_PREFIX = "Bearer "
25
+ UNAUTHORIZED = [
26
+ 401,
27
+ { "Content-Type" => "application/json", "WWW-Authenticate" => "Bearer" },
28
+ [{ jsonrpc: "2.0", id: nil, error: { code: -32_001, message: "Unauthorized" } }.to_json]
29
+ ].freeze
30
+
31
+ # @param data_source [Pgbus::Web::DataSource] read layer the tools query.
32
+ # Built once and shared: DataSource acquires Pgbus::BusRecord.connection
33
+ # from the ActiveRecord pool on each call, so a single instance is
34
+ # request-safe across threads/workers.
35
+ # @param allow_payloads [Boolean] when true, tools honor a per-call
36
+ # include_payloads flag; otherwise message bodies are always redacted.
37
+ # @param token [String, nil] shared secret. When present, requests must
38
+ # send `Authorization: Bearer <token>`. nil disables the built-in gate.
39
+ # @param auth [#call, nil] custom authenticator taking a Rack::Request and
40
+ # returning truthy to allow. Mirrors Pgbus.configuration.web_auth. Takes
41
+ # precedence over +token+ when both are given.
42
+ def initialize(data_source: Pgbus::Web::DataSource.new, allow_payloads: false, token: nil, auth: nil)
43
+ @token = token
44
+ @auth = auth
45
+ @server = Server.build(data_source: data_source, allow_payloads: allow_payloads)
46
+ @transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(
47
+ @server, stateless: true, enable_json_response: true
48
+ )
49
+ warn_unauthenticated! if @token.nil? && @auth.nil?
50
+ end
51
+
52
+ # Mount THIS object, never the bare transport. The auth gate lives here
53
+ # and runs before handle_request for *every* HTTP method (POST/GET/
54
+ # DELETE/...). Mounting @transport directly would skip authorization —
55
+ # the gem's transport has no auth of its own.
56
+ def call(env)
57
+ request = Rack::Request.new(env)
58
+ return UNAUTHORIZED unless authorized?(request)
59
+
60
+ @transport.handle_request(request)
61
+ end
62
+
63
+ private
64
+
65
+ # An explicit auth callable wins; otherwise fall back to the bearer token;
66
+ # otherwise (neither configured) allow — the constructor already warned.
67
+ def authorized?(request)
68
+ return !!@auth.call(request) if @auth
69
+ return bearer_matches?(request) if @token
70
+
71
+ true
72
+ end
73
+
74
+ def bearer_matches?(request)
75
+ header = request.get_header("HTTP_AUTHORIZATION").to_s
76
+ return false unless header.start_with?(BEARER_PREFIX)
77
+
78
+ Runner.secure_compare?(@token, header.delete_prefix(BEARER_PREFIX))
79
+ end
80
+
81
+ def warn_unauthenticated!
82
+ Pgbus.logger.warn do
83
+ "[Pgbus::MCP] HTTP diagnostic server mounted without authentication. " \
84
+ "Pass token: or auth: to Pgbus::MCP.rack_app, and keep it on an internal network."
85
+ end
86
+ end
87
+ end
88
+
89
+ module_function
90
+
91
+ # Build a gated Rack app serving the read-only diagnostic tools over HTTP.
92
+ # See {RackApp} for the parameters and deployment guidance.
93
+ def rack_app(data_source: Pgbus::Web::DataSource.new, allow_payloads: false, token: nil, auth: nil)
94
+ RackApp.new(data_source: data_source, allow_payloads: allow_payloads, token: token, auth: auth)
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module MCP
5
+ # Strips message bodies / headers / job arguments from tool responses
6
+ # before they leave the process. Job payloads can carry PII or secrets, so
7
+ # the diagnostic tools return metadata (ids, counts, ages, read_ct, vt,
8
+ # queue names, job_class) by default and only include the raw payload when
9
+ # an explicit, separately-gated flag is set.
10
+ #
11
+ # +deep_redact+ is applied centrally at the response boundary
12
+ # (BaseTool.json_response), so redaction is fail-safe: a tool author cannot
13
+ # leak a payload by forgetting to redact — they have to opt in.
14
+ #
15
+ # See issue #180: "No sensitive payload leakage — diagnostic tools should
16
+ # return metadata ... and redact or omit message payloads unless an
17
+ # explicit, separately-gated flag is set."
18
+ module Redactor
19
+ module_function
20
+
21
+ # Keys whose values are message bodies / headers and must never be
22
+ # returned unless payloads are explicitly allowed.
23
+ PAYLOAD_KEYS = %i[message headers payload arguments].freeze
24
+
25
+ # Replacement marker so the agent can see a field exists but was hidden,
26
+ # rather than the field silently vanishing.
27
+ REDACTED = "[redacted]"
28
+
29
+ # True if +key+ names a payload-bearing field. Tolerates string or symbol
30
+ # keys and never raises on a non-symbolizable key.
31
+ def payload_key?(key)
32
+ PAYLOAD_KEYS.include?(key.respond_to?(:to_sym) ? key.to_sym : key)
33
+ end
34
+
35
+ # Recursively redact payload-bearing keys anywhere in a nested structure
36
+ # of hashes and arrays. This is the fail-safe applied at the response
37
+ # boundary (BaseTool.json_response): a tool that returns a payload key —
38
+ # at any depth, even one its author forgot about — has it stripped unless
39
+ # payloads are explicitly allowed for the call.
40
+ #
41
+ # A payload key is redacted only when its value is a leaf (the serialized
42
+ # message body / headers string). When the value is a Hash or Array the
43
+ # key is an envelope (e.g. a {message: {...}} detail wrapper, not a raw
44
+ # body), so we descend into it instead of blanking the whole subtree.
45
+ def deep_redact(value, include_payloads: false)
46
+ return value if include_payloads
47
+
48
+ case value
49
+ when Hash
50
+ value.each_with_object({}) do |(key, nested), result|
51
+ result[key] =
52
+ if payload_key?(key) && leaf?(nested) && !nested.nil?
53
+ REDACTED
54
+ else
55
+ deep_redact(nested, include_payloads: include_payloads)
56
+ end
57
+ end
58
+ when Array
59
+ value.map { |element| deep_redact(element, include_payloads: include_payloads) }
60
+ else
61
+ value
62
+ end
63
+ end
64
+
65
+ # A leaf is anything that isn't a structure we recurse into — i.e. the
66
+ # actual serialized payload value rather than an envelope hash/array.
67
+ def leaf?(value)
68
+ !value.is_a?(Hash) && !value.is_a?(Array)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/security_utils"
4
+
5
+ module Pgbus
6
+ module MCP
7
+ # Boots the pgbus diagnostic MCP server over stdio. This is what
8
+ # `pgbus mcp` invokes. Separated from Server so the server assembly stays
9
+ # transport-agnostic and unit-testable without touching $stdin/$stdout.
10
+ #
11
+ # Security posture (issue #180):
12
+ # * Never starts unless explicitly invoked (`pgbus mcp`).
13
+ # * Reuses the app's existing DB connection/config — no new privileged
14
+ # path is opened.
15
+ # * Payloads are redacted unless PGBUS_MCP_ALLOW_PAYLOADS is truthy.
16
+ # * Optional token gate: when PGBUS_MCP_TOKEN is set, the same value must
17
+ # be present in PGBUS_MCP_AUTH_TOKEN at boot or the server refuses to
18
+ # start. stdio is a local, parent-spawned channel, so the gate is a
19
+ # boot-time precondition rather than a per-message header.
20
+ module Runner
21
+ TRUTHY = %w[1 true yes on].freeze
22
+
23
+ module_function
24
+
25
+ # Build the server, enforce the optional token gate, and drive the stdio
26
+ # transport until the client disconnects.
27
+ #
28
+ # @param env [Hash] environment to read flags from (injectable for tests).
29
+ def run(env: ENV)
30
+ authorize!(env)
31
+
32
+ allow_payloads = truthy?(env["PGBUS_MCP_ALLOW_PAYLOADS"])
33
+ server = Server.build(allow_payloads: allow_payloads)
34
+ transport = ::MCP::Server::Transports::StdioTransport.new(server)
35
+ log_start(allow_payloads)
36
+ transport.open
37
+ end
38
+
39
+ # Raise unless the configured token matches. No-op when PGBUS_MCP_TOKEN
40
+ # is unset (token gating is opt-in).
41
+ def authorize!(env)
42
+ expected = env["PGBUS_MCP_TOKEN"]
43
+ return if expected.nil? || expected.empty?
44
+
45
+ provided = env["PGBUS_MCP_AUTH_TOKEN"]
46
+ return if secure_compare?(expected, provided)
47
+
48
+ raise Pgbus::Error,
49
+ "pgbus MCP: authentication failed. PGBUS_MCP_TOKEN is set; provide a matching PGBUS_MCP_AUTH_TOKEN."
50
+ end
51
+
52
+ def truthy?(value)
53
+ TRUTHY.include?(value.to_s.strip.downcase)
54
+ end
55
+
56
+ # Constant-time comparison to avoid leaking the token via timing.
57
+ # Delegates to ActiveSupport's vetted implementation (railties already
58
+ # pulls in active_support). secure_compare handles unequal lengths
59
+ # safely — it compares fixed-length SHA256 digests, then verifies the
60
+ # raw values match — so it never raises on a length mismatch.
61
+ def secure_compare?(expected, provided)
62
+ return false if provided.nil?
63
+
64
+ ActiveSupport::SecurityUtils.secure_compare(expected, provided)
65
+ end
66
+
67
+ def log_start(allow_payloads)
68
+ mode = allow_payloads ? "payloads ALLOWED" : "payloads redacted"
69
+ Pgbus.logger.info { "[Pgbus::MCP] starting read-only diagnostic server over stdio (#{mode})" }
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module MCP
5
+ # Assembles the read-only pgbus diagnostic MCP server: registers every
6
+ # diagnostic tool and wires a DataSource (plus the payload gate) into the
7
+ # server context so tools never touch the database directly or leak
8
+ # payloads by default.
9
+ #
10
+ # This class only builds the +MCP::Server+ instance — transport is the
11
+ # caller's concern (Runner drives stdio). Keeping them separate means the
12
+ # same server can later be mounted over HTTP without changing tool code.
13
+ module Server
14
+ # The fixed, curated tool set. No arbitrary-SQL tool, no mutation tool —
15
+ # adding one here would violate the issue #180 security contract.
16
+ TOOLS = [
17
+ Tools::HealthTool,
18
+ Tools::QueuesTool,
19
+ Tools::QueueDetailTool,
20
+ Tools::ProcessesTool,
21
+ Tools::JobsTool,
22
+ Tools::JobDetailTool,
23
+ Tools::DlqTool,
24
+ Tools::DlqDetailTool,
25
+ Tools::LocksTool,
26
+ Tools::ThroughputTool,
27
+ Tools::StatsTool,
28
+ Tools::RecurringTool
29
+ ].freeze
30
+
31
+ INSTRUCTIONS = <<~TEXT
32
+ Read-only diagnostic tools for a pgbus (PostgreSQL/PGMQ) deployment.
33
+ Start with pgbus_health for a one-call OK/DEGRADED/STALLED verdict,
34
+ then drill in with pgbus_queues, pgbus_processes, pgbus_jobs,
35
+ pgbus_dlq, and pgbus_locks. No tool mutates state. Message payloads
36
+ are redacted unless the server was started with payloads explicitly
37
+ allowed.
38
+ TEXT
39
+
40
+ module_function
41
+
42
+ # Build an MCP::Server with all diagnostic tools registered.
43
+ #
44
+ # @param data_source [Pgbus::Web::DataSource] read layer the tools query.
45
+ # @param allow_payloads [Boolean] when true, tools honor a per-call
46
+ # include_payloads flag; otherwise payloads are always redacted.
47
+ def build(data_source: Pgbus::Web::DataSource.new, allow_payloads: false)
48
+ ::MCP::Server.new(
49
+ name: "pgbus",
50
+ title: "Pgbus Diagnostics",
51
+ version: Pgbus::VERSION,
52
+ instructions: INSTRUCTIONS,
53
+ tools: TOOLS.dup,
54
+ server_context: {
55
+ data_source: data_source,
56
+ allow_payloads: allow_payloads
57
+ }
58
+ )
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module MCP
5
+ module Tools
6
+ # Inspect a single dead-letter message. Maps to DataSource#job_detail
7
+ # against the named DLQ queue when +queue+ is supplied; falls back to a
8
+ # cross-DLQ scan via DataSource#dlq_message_detail when it is not.
9
+ # Payload redacted by default.
10
+ class DlqDetailTool < BaseTool
11
+ tool_name "pgbus_dlq_detail"
12
+ title "Pgbus Dead-Letter Detail"
13
+ description <<~DESC
14
+ Inspect one dead-letter message: read_ct, vt, enqueued_at, source
15
+ queue. Pass the full physical DLQ queue name (e.g.
16
+ "pgbus_default_dlq") in +queue+ to disambiguate — msg_ids are only
17
+ unique within a single DLQ table, so the same id can exist in more
18
+ than one. Without +queue+ the tool scans every "_dlq" table and
19
+ returns the FIRST match, which is ambiguous when multiple DLQs
20
+ contain the id and is reported as an error. The message body and
21
+ headers are redacted unless the server allows payloads and
22
+ include_payloads is set.
23
+ DESC
24
+
25
+ input_schema(
26
+ properties: {
27
+ queue: {
28
+ type: "string",
29
+ description: "Full physical DLQ queue name (e.g. \"pgbus_default_dlq\"). Strongly recommended."
30
+ },
31
+ msg_id: { type: "integer", description: "Numeric PGMQ message id." },
32
+ include_payloads: {
33
+ type: "boolean",
34
+ description: "Include the raw message body/headers. Only honored when the server allows payloads."
35
+ }
36
+ },
37
+ required: %w[msg_id]
38
+ )
39
+
40
+ def self.call(msg_id:, queue: nil, include_payloads: false, server_context: nil)
41
+ data_source = data_source_from(server_context)
42
+ detail =
43
+ if queue
44
+ data_source.job_detail(queue, msg_id)
45
+ else
46
+ ambiguity_check(data_source, msg_id) || data_source.dlq_message_detail(msg_id)
47
+ end
48
+ return error_response("Dead-letter message #{msg_id} not found") unless detail
49
+ return detail if detail.is_a?(::MCP::Tool::Response)
50
+
51
+ json_response({ message: detail }, server_context: server_context, include_payloads: include_payloads)
52
+ end
53
+
54
+ # When the caller didn't specify a queue, scan every DLQ for the id and
55
+ # refuse to guess if more than one matches. Returns an error response
56
+ # on ambiguity, nil otherwise (so the caller proceeds with the normal
57
+ # first-match lookup).
58
+ def self.ambiguity_check(data_source, msg_id)
59
+ dlq_suffix = Pgbus::DEAD_LETTER_SUFFIX
60
+ dlqs = data_source.queues_with_metrics.select { |q| q[:name].to_s.end_with?(dlq_suffix) }
61
+ matches = dlqs.map { |q| q[:name] }.select { |name| data_source.job_detail(name, msg_id) }
62
+ return nil if matches.size <= 1
63
+
64
+ error_response(
65
+ "Dead-letter message #{msg_id} is ambiguous — present in #{matches.size} DLQs " \
66
+ "(#{matches.join(", ")}). Pass `queue:` to disambiguate."
67
+ )
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module MCP
5
+ module Tools
6
+ # Paginated dead-letter queue inspection across all *_dlq queues.
7
+ # Maps to DataSource#dlq_messages. Payloads redacted by default.
8
+ class DlqTool < BaseTool
9
+ tool_name "pgbus_dlq"
10
+ title "Pgbus Dead-Letter Queue"
11
+ description <<~DESC
12
+ List messages sitting in dead-letter queues (queues whose name ends
13
+ in "_dlq") with read_ct, vt and enqueued_at. Paginated (default 25,
14
+ max 100). Message bodies and headers are redacted unless the server
15
+ allows payloads and include_payloads is set.
16
+ DESC
17
+
18
+ MAX_PER_PAGE = 100
19
+ MAX_PAGE = 1_000
20
+
21
+ input_schema(
22
+ properties: {
23
+ page: { type: "integer", description: "1-based page number (default 1).", minimum: 1 },
24
+ per_page: { type: "integer", description: "Rows per page (default 25, max 100).", minimum: 1 },
25
+ include_payloads: {
26
+ type: "boolean",
27
+ description: "Include raw message bodies/headers. Only honored when the server allows payloads."
28
+ }
29
+ },
30
+ required: []
31
+ )
32
+
33
+ def self.call(page: 1, per_page: 25, include_payloads: false, server_context: nil)
34
+ data_source = data_source_from(server_context)
35
+ per_page = per_page.to_i.clamp(1, MAX_PER_PAGE)
36
+ page = page.to_i.clamp(1, MAX_PAGE)
37
+ rows = data_source.dlq_messages(page: page, per_page: per_page)
38
+
39
+ json_response(
40
+ { page: page, per_page: per_page, messages: rows },
41
+ server_context: server_context,
42
+ include_payloads: include_payloads
43
+ )
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module MCP
5
+ module Tools
6
+ # Top-level health verdict: OK / DEGRADED / STALLED, with the reasons
7
+ # that drove it. Delegates the actual analysis to HealthAnalyzer, which
8
+ # reads the same DataSource surface the dashboard uses. This is the
9
+ # single tool that catches the silent-worker-wedge class of incident.
10
+ class HealthTool < BaseTool
11
+ tool_name "pgbus_health"
12
+ title "Pgbus Health"
13
+ description <<~DESC
14
+ Top-level pgbus health verdict. Returns OK, DEGRADED, or STALLED with
15
+ the specific reasons. STALLED means the silent-wedge condition:
16
+ queues have visible backlog while workers are heart-beating but not
17
+ claiming (or a worker's claim loop has stalled). DEGRADED covers
18
+ stale processes, paused queues holding a backlog, dead-letter
19
+ buildup, or a long-running transaction pinning the MVCC horizon. Use
20
+ this first when triaging — it summarizes the whole system in one call
21
+ and is suitable for automated alerting.
22
+ DESC
23
+
24
+ input_schema(properties: {}, required: [])
25
+
26
+ def self.call(server_context: nil)
27
+ data_source = data_source_from(server_context)
28
+ json_response(HealthAnalyzer.new(data_source).verdict)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module MCP
5
+ module Tools
6
+ # Inspect a single enqueued message by queue + msg_id. Returns its
7
+ # metadata (read_ct, vt, enqueued_at, last_read_at); the body and headers
8
+ # are redacted unless payloads are explicitly allowed. Maps to
9
+ # DataSource#job_detail.
10
+ class JobDetailTool < BaseTool
11
+ tool_name "pgbus_job_detail"
12
+ title "Pgbus Job Detail"
13
+ description <<~DESC
14
+ Inspect one enqueued message: read_ct, visibility timeout (vt),
15
+ enqueued_at, last_read_at. Pass the full physical queue name and the
16
+ numeric msg_id. The message body and headers are redacted unless the
17
+ server allows payloads and include_payloads is set.
18
+ DESC
19
+
20
+ input_schema(
21
+ properties: {
22
+ queue: { type: "string", description: "Full physical queue name (e.g. \"pgbus_default\")." },
23
+ msg_id: { type: "integer", description: "Numeric PGMQ message id." },
24
+ include_payloads: {
25
+ type: "boolean",
26
+ description: "Include the raw message body/headers. Only honored when the server allows payloads."
27
+ }
28
+ },
29
+ required: %w[queue msg_id]
30
+ )
31
+
32
+ def self.call(queue:, msg_id:, include_payloads: false, server_context: nil)
33
+ data_source = data_source_from(server_context)
34
+ detail = data_source.job_detail(queue, msg_id)
35
+ return error_response("Message #{msg_id} not found in #{queue}") unless detail
36
+
37
+ json_response({ job: detail }, server_context: server_context, include_payloads: include_payloads)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end