pgbus 0.19.3 → 0.20.0

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 (143) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +8 -0
  3. data/README.md +104 -9
  4. data/Rakefile +6 -1
  5. data/app/controllers/pgbus/dashboard_controller.rb +2 -0
  6. data/app/controllers/pgbus/workflow_definitions_controller.rb +28 -0
  7. data/app/controllers/pgbus/workflows_controller.rb +197 -0
  8. data/app/frontend/pgbus/modules/charts.js +130 -4
  9. data/app/frontend/pgbus/modules/charts.min.js +1 -1
  10. data/app/frontend/pgbus/modules/charts.min.js.map +1 -1
  11. data/app/frontend/pgbus/style.css +1 -1
  12. data/app/helpers/pgbus/button_helper.rb +4 -2
  13. data/app/helpers/pgbus/workflow_insights_helper.rb +129 -0
  14. data/app/helpers/pgbus/workflows_helper.rb +165 -0
  15. data/app/models/pgbus/workflow_entry.rb +172 -0
  16. data/app/models/pgbus/workflow_event.rb +43 -0
  17. data/app/models/pgbus/workflow_signal.rb +77 -0
  18. data/app/models/pgbus/workflow_step.rb +142 -0
  19. data/app/views/layouts/pgbus/application.html.erb +2 -0
  20. data/app/views/pgbus/dashboard/_stats_cards.html.erb +17 -0
  21. data/app/views/pgbus/workflows/_actions.html.erb +59 -0
  22. data/app/views/pgbus/workflows/_events_table.html.erb +24 -0
  23. data/app/views/pgbus/workflows/_filters.html.erb +29 -0
  24. data/app/views/pgbus/workflows/_signal_form.html.erb +31 -0
  25. data/app/views/pgbus/workflows/_signals_table.html.erb +40 -0
  26. data/app/views/pgbus/workflows/_status_filter.html.erb +12 -0
  27. data/app/views/pgbus/workflows/_steps_table.html.erb +79 -0
  28. data/app/views/pgbus/workflows/_timeline.html.erb +39 -0
  29. data/app/views/pgbus/workflows/_workflows_table.html.erb +59 -0
  30. data/app/views/pgbus/workflows/attention.html.erb +88 -0
  31. data/app/views/pgbus/workflows/definitions/_by_revision.html.erb +52 -0
  32. data/app/views/pgbus/workflows/definitions/_duration_table.html.erb +18 -0
  33. data/app/views/pgbus/workflows/definitions/_funnel.html.erb +49 -0
  34. data/app/views/pgbus/workflows/definitions/_pipeline.html.erb +16 -0
  35. data/app/views/pgbus/workflows/definitions/_step_card.html.erb +31 -0
  36. data/app/views/pgbus/workflows/definitions/_throughput_table.html.erb +32 -0
  37. data/app/views/pgbus/workflows/definitions/_window.html.erb +17 -0
  38. data/app/views/pgbus/workflows/definitions/index.html.erb +71 -0
  39. data/app/views/pgbus/workflows/definitions/not_found.html.erb +5 -0
  40. data/app/views/pgbus/workflows/definitions/show.html.erb +80 -0
  41. data/app/views/pgbus/workflows/index.html.erb +15 -0
  42. data/app/views/pgbus/workflows/not_found.html.erb +5 -0
  43. data/app/views/pgbus/workflows/show.html.erb +93 -0
  44. data/config/i18n-tasks.yml +6 -0
  45. data/config/locales/da.yml +442 -0
  46. data/config/locales/de.yml +442 -0
  47. data/config/locales/en.yml +442 -0
  48. data/config/locales/es.yml +442 -0
  49. data/config/locales/fi.yml +442 -0
  50. data/config/locales/fr.yml +442 -0
  51. data/config/locales/it.yml +442 -0
  52. data/config/locales/ja.yml +442 -0
  53. data/config/locales/nb.yml +442 -0
  54. data/config/locales/nl.yml +442 -0
  55. data/config/locales/pt.yml +442 -0
  56. data/config/locales/sv.yml +442 -0
  57. data/config/routes.rb +26 -0
  58. data/lib/generators/pgbus/add_workflow_signals_generator.rb +56 -0
  59. data/lib/generators/pgbus/add_workflows_generator.rb +54 -0
  60. data/lib/generators/pgbus/templates/add_workflow_signals.rb.erb +51 -0
  61. data/lib/generators/pgbus/templates/add_workflows.rb.erb +150 -0
  62. data/lib/generators/pgbus/templates/migration.rb.erb +127 -0
  63. data/lib/pgbus/active_job/adapter.rb +67 -26
  64. data/lib/pgbus/active_job/batch_id.rb +23 -1
  65. data/lib/pgbus/active_job/delivery.rb +49 -0
  66. data/lib/pgbus/active_job/executor.rb +3 -1
  67. data/lib/pgbus/batch/builder.rb +1 -0
  68. data/lib/pgbus/batch.rb +35 -3
  69. data/lib/pgbus/configuration/workflow_settings.rb +99 -0
  70. data/lib/pgbus/configuration.rb +21 -3
  71. data/lib/pgbus/generators/migration_detector.rb +40 -0
  72. data/lib/pgbus/instrumentation.rb +32 -0
  73. data/lib/pgbus/integrations/appsignal/dashboard.json +128 -1
  74. data/lib/pgbus/integrations/appsignal/event_span.rb +43 -0
  75. data/lib/pgbus/integrations/appsignal/job_span.rb +47 -0
  76. data/lib/pgbus/integrations/appsignal/span_subscriber.rb +128 -0
  77. data/lib/pgbus/integrations/appsignal/subscriber.rb +50 -100
  78. data/lib/pgbus/mcp/rack_app.rb +7 -2
  79. data/lib/pgbus/mcp/runner.rb +2 -1
  80. data/lib/pgbus/mcp/serialization.rb +25 -0
  81. data/lib/pgbus/mcp/server.rb +53 -10
  82. data/lib/pgbus/mcp/tools/deploy_tasks_tool.rb +1 -10
  83. data/lib/pgbus/mcp/tools/workflow_cancel_tool.rb +23 -0
  84. data/lib/pgbus/mcp/tools/workflow_definitions_tool.rb +36 -0
  85. data/lib/pgbus/mcp/tools/workflow_detail_tool.rb +38 -0
  86. data/lib/pgbus/mcp/tools/workflow_resume_at_tool.rb +32 -0
  87. data/lib/pgbus/mcp/tools/workflow_resume_tool.rb +24 -0
  88. data/lib/pgbus/mcp/tools/workflow_run_now_tool.rb +23 -0
  89. data/lib/pgbus/mcp/tools/workflow_signal_tool.rb +77 -0
  90. data/lib/pgbus/mcp/tools/workflow_skip_step_tool.rb +23 -0
  91. data/lib/pgbus/mcp/tools/workflow_stats_tool.rb +42 -0
  92. data/lib/pgbus/mcp/tools/workflows_attention_tool.rb +42 -0
  93. data/lib/pgbus/mcp/tools/workflows_tool.rb +60 -0
  94. data/lib/pgbus/mcp/workflow_insights_payload.rb +15 -0
  95. data/lib/pgbus/mcp/workflow_payloads.rb +53 -0
  96. data/lib/pgbus/mcp/write_tool.rb +134 -0
  97. data/lib/pgbus/mcp.rb +15 -0
  98. data/lib/pgbus/metrics/backends/statsd.rb +6 -0
  99. data/lib/pgbus/metrics/subscriber.rb +14 -1
  100. data/lib/pgbus/metrics/workflow_metrics.rb +65 -0
  101. data/lib/pgbus/process/dispatcher.rb +26 -0
  102. data/lib/pgbus/version.rb +1 -1
  103. data/lib/pgbus/web/data_source/workflow_actions.rb +109 -0
  104. data/lib/pgbus/web/data_source/workflow_insights.rb +99 -0
  105. data/lib/pgbus/web/data_source/workflow_list.rb +248 -0
  106. data/lib/pgbus/web/data_source.rb +3 -0
  107. data/lib/pgbus/web/workflow_action_plan.rb +62 -0
  108. data/lib/pgbus/web/workflow_insights_queries.rb +250 -0
  109. data/lib/pgbus/web/workflow_insights_report.rb +171 -0
  110. data/lib/pgbus/web/workflow_payload.rb +36 -0
  111. data/lib/pgbus/web/workflow_query.rb +82 -0
  112. data/lib/pgbus/web/workflow_state.rb +76 -0
  113. data/lib/pgbus/web/workflow_timeline.rb +113 -0
  114. data/lib/pgbus/web/workflow_window.rb +51 -0
  115. data/lib/pgbus/workflow/batch_finished_job.rb +21 -0
  116. data/lib/pgbus/workflow/dsl.rb +105 -0
  117. data/lib/pgbus/workflow/error_capture.rb +63 -0
  118. data/lib/pgbus/workflow/exception_policy.rb +170 -0
  119. data/lib/pgbus/workflow/fan_out.rb +157 -0
  120. data/lib/pgbus/workflow/fan_out_definition.rb +41 -0
  121. data/lib/pgbus/workflow/flow_control.rb +94 -0
  122. data/lib/pgbus/workflow/instance.rb +183 -0
  123. data/lib/pgbus/workflow/outside_verbs.rb +138 -0
  124. data/lib/pgbus/workflow/payload.rb +53 -0
  125. data/lib/pgbus/workflow/registry.rb +53 -0
  126. data/lib/pgbus/workflow/removed_step.rb +14 -0
  127. data/lib/pgbus/workflow/runner.rb +218 -0
  128. data/lib/pgbus/workflow/signal_matcher.rb +53 -0
  129. data/lib/pgbus/workflow/signals.rb +132 -0
  130. data/lib/pgbus/workflow/states.rb +119 -0
  131. data/lib/pgbus/workflow/step_context.rb +58 -0
  132. data/lib/pgbus/workflow/step_definition.rb +131 -0
  133. data/lib/pgbus/workflow/step_execution.rb +242 -0
  134. data/lib/pgbus/workflow/step_job.rb +28 -0
  135. data/lib/pgbus/workflow/step_outcome.rb +151 -0
  136. data/lib/pgbus/workflow/steps.rb +103 -0
  137. data/lib/pgbus/workflow/sweep.rb +156 -0
  138. data/lib/pgbus/workflow/test_helpers.rb +152 -0
  139. data/lib/pgbus/workflow/wait_timeout.rb +73 -0
  140. data/lib/pgbus/workflow/waiting.rb +82 -0
  141. data/lib/pgbus/workflow.rb +183 -0
  142. data/sig/pgbus/configuration.rbs +22 -0
  143. metadata +95 -1
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "time"
3
+ require "pgbus/integrations/appsignal/job_span"
4
+ require "pgbus/integrations/appsignal/event_span"
4
5
 
5
6
  module Pgbus
6
7
  module Integrations
@@ -10,16 +11,23 @@ module Pgbus
10
11
  #
11
12
  # Job and event-handler events open a BACKGROUND_JOB transaction so they
12
13
  # appear under AppSignal's "Performance > Background jobs" view, with
13
- # action `<JobClass>#perform` or `<HandlerClass>#handle`. All other
14
- # events are reported as counters or distributions only.
14
+ # action `<JobClass>#perform` or `<HandlerClass>#handle`. The transaction
15
+ # is opened when pgbus.executor.execute / pgbus.event_processed starts and
16
+ # completed when it finishes (JobSpan, EventSpan), so it is current while the
17
+ # job or handler runs and carries the error the execution raised. All
18
+ # other events are reported as counters or distributions only.
15
19
  #
16
20
  # All metric names are prefixed `pgbus_`. Tag keys avoid high-cardinality
17
21
  # values (no msg_id, no event_id) so AppSignal's metric storage stays
18
22
  # efficient.
19
23
  module Subscriber
20
- BACKGROUND_JOB = "background_job"
21
24
  METRIC_PREFIX = "pgbus_"
22
- private_constant :BACKGROUND_JOB, :METRIC_PREFIX
25
+
26
+ # Built once, at load: a span keeps its state in a fiber-local frame,
27
+ # never in itself.
28
+ JOB_SPAN = JobSpan.new.freeze
29
+ EVENT_SPAN = EventSpan.new.freeze
30
+ private_constant :METRIC_PREFIX, :JOB_SPAN, :EVENT_SPAN
23
31
 
24
32
  # Tracked so we can detach in reset! (used by specs). Installed once
25
33
  # at boot (the engine's after_initialize); @install_lock makes the
@@ -54,11 +62,11 @@ module Pgbus
54
62
 
55
63
  def subscribe_all
56
64
  [
57
- subscribe("pgbus.executor.execute") { |event| on_executor_execute(event) },
65
+ ActiveSupport::Notifications.subscribe("pgbus.executor.execute", JOB_SPAN),
58
66
  subscribe("pgbus.job_completed") { |event| on_job_completed(event) },
59
67
  subscribe("pgbus.job_failed") { |event| on_job_failed(event) },
60
68
  subscribe("pgbus.job_dead_lettered") { |event| on_job_dead_lettered(event) },
61
- subscribe("pgbus.event_processed") { |event| on_event_processed(event) },
69
+ ActiveSupport::Notifications.subscribe("pgbus.event_processed", EVENT_SPAN),
62
70
  subscribe("pgbus.event_failed") { |event| on_event_failed(event) },
63
71
  subscribe("pgbus.event_dead_lettered") { |event| on_event_dead_lettered(event) },
64
72
  subscribe("pgbus.client.send_message") { |event| on_send_message(event) },
@@ -68,7 +76,8 @@ module Pgbus
68
76
  subscribe("pgbus.outbox.publish") { |event| on_outbox_publish(event) },
69
77
  subscribe("pgbus.recurring.enqueue") { |event| on_recurring_enqueue(event) },
70
78
  subscribe("pgbus.worker.recycle") { |event| on_worker_recycle(event) },
71
- subscribe("pgbus.consumer.recycle") { |event| on_consumer_recycle(event) }
79
+ subscribe("pgbus.consumer.recycle") { |event| on_consumer_recycle(event) },
80
+ *Pgbus::Metrics::WorkflowMetrics::EVENTS.map { |name| subscribe(name) { |event| on_workflow(event) } }
72
81
  # pgbus.client.pool is deliberately NOT subscribed here — the
73
82
  # minutely Probe#track_pool already reports pgbus_pool_size /
74
83
  # pgbus_pool_available gauges for AppSignal (see probe.rb).
@@ -98,22 +107,6 @@ module Pgbus
98
107
 
99
108
  # ── Job execution ───────────────────────────────────────────────
100
109
 
101
- def on_executor_execute(event)
102
- payload = event.payload
103
- transaction = ::Appsignal::Transaction.create(BACKGROUND_JOB)
104
- transaction.set_action_if_nil("#{payload[:job_class] || "UnknownJob"}#perform")
105
- apply_queue_start(transaction, payload[:enqueued_at])
106
- transaction.add_tags(job_tags(payload))
107
- transaction.add_params_if_nil { { arguments: payload[:arguments] } }
108
- ::Appsignal.add_distribution_value(
109
- "#{METRIC_PREFIX}job_duration_ms",
110
- event.duration,
111
- { queue: payload[:queue], job_class: payload[:job_class] }
112
- )
113
- ensure
114
- ::Appsignal::Transaction.complete_current!
115
- end
116
-
117
110
  def on_job_completed(event)
118
111
  payload = event.payload
119
112
  ::Appsignal.increment_counter(
@@ -123,15 +116,18 @@ module Pgbus
123
116
  )
124
117
  end
125
118
 
119
+ # The counter has its own guard so a raise there cannot keep the
120
+ # error from being reported.
126
121
  def on_job_failed(event)
127
122
  payload = event.payload
128
- ::Appsignal.increment_counter(
129
- "#{METRIC_PREFIX}queue_job_count",
130
- 1,
131
- { queue: payload[:queue], job_class: payload[:job_class], status: "failed" }
132
- )
133
- err = payload[:exception_object]
134
- ::Appsignal.set_error(err) if err && ::Appsignal.respond_to?(:set_error)
123
+ safely do
124
+ ::Appsignal.increment_counter(
125
+ "#{METRIC_PREFIX}queue_job_count",
126
+ 1,
127
+ { queue: payload[:queue], job_class: payload[:job_class], status: "failed" }
128
+ )
129
+ end
130
+ JOB_SPAN.report_unattached(payload[:exception_object], payload)
135
131
  end
136
132
 
137
133
  def on_job_dead_lettered(event)
@@ -145,40 +141,23 @@ module Pgbus
145
141
 
146
142
  # ── Event handler ───────────────────────────────────────────────
147
143
 
148
- def on_event_processed(event)
149
- payload = event.payload
150
- transaction = ::Appsignal::Transaction.create(BACKGROUND_JOB)
151
- transaction.set_action_if_nil("#{payload[:handler] || "UnknownHandler"}#handle")
152
- apply_queue_start(transaction, payload[:published_at])
153
- transaction.add_tags(handler_tags(payload))
154
- ::Appsignal.add_distribution_value(
155
- "#{METRIC_PREFIX}event_duration_ms",
156
- event.duration,
157
- { handler: payload[:handler], routing_key: payload[:routing_key] }
158
- )
159
- ::Appsignal.increment_counter(
160
- "#{METRIC_PREFIX}event_count",
161
- 1,
162
- { handler: payload[:handler], routing_key: payload[:routing_key], status: "processed" }
163
- )
164
- ensure
165
- ::Appsignal::Transaction.complete_current!
166
- end
167
-
144
+ # The counter has its own guard so a raise there cannot keep the
145
+ # error from being reported.
168
146
  def on_event_failed(event)
169
147
  payload = event.payload
170
- ::Appsignal.increment_counter(
171
- "#{METRIC_PREFIX}event_count",
172
- 1,
173
- { handler: payload[:handler], routing_key: payload[:routing_key], status: "failed" }
174
- )
175
- err = payload[:exception_object]
176
- ::Appsignal.set_error(err) if err && ::Appsignal.respond_to?(:set_error)
148
+ safely do
149
+ ::Appsignal.increment_counter(
150
+ "#{METRIC_PREFIX}event_count",
151
+ 1,
152
+ { handler: payload[:handler], routing_key: payload[:routing_key], status: "failed" }
153
+ )
154
+ end
155
+ EVENT_SPAN.report_unattached(payload[:exception_object], payload)
177
156
  end
178
157
 
179
158
  # A counter only, like on_job_dead_lettered: nothing is raised at
180
- # move time, and the handler's error already went out through
181
- # pgbus.event_failed on the attempt that raised (issue #543). A body
159
+ # move time, and the handler's error already went out on the
160
+ # attempt that raised (issues #543, #552). A body
182
161
  # that is not JSON leaves handler and routing_key nil; compact drops
183
162
  # those tags, as Metrics::Subscriber does.
184
163
  def on_event_dead_lettered(event)
@@ -296,47 +275,18 @@ module Pgbus
296
275
  )
297
276
  end
298
277
 
299
- # ── Helpers ─────────────────────────────────────────────────────
300
-
301
- # AppSignal expects queue-start as Unix epoch milliseconds. Pgbus
302
- # carries it as either an ISO-8601 String or a Time — both happen
303
- # in practice (executor passes the JSON string, handler passes a
304
- # parsed Time).
305
- def apply_queue_start(transaction, value)
306
- return unless value
307
-
308
- millis =
309
- case value
310
- when Time
311
- (value.to_f * 1_000).to_i
312
- when String
313
- (Time.parse(value).to_f * 1_000).to_i
314
- when Numeric
315
- value.to_i
316
- end
317
- transaction.set_queue_start(millis) if millis
318
- rescue ArgumentError
319
- # Unparseable timestamp — skip rather than blow up.
320
- end
321
-
322
- def job_tags(payload)
323
- tags = {
324
- "queue" => payload[:queue],
325
- "job_class" => payload[:job_class],
326
- "attempts" => payload[:read_ct]
327
- }
328
- tags["active_job_id"] = payload[:job_id] if payload[:job_id]
329
- tags["provider_job_id"] = payload[:provider_job_id] if payload[:provider_job_id]
330
- tags["request_id"] = payload[:provider_job_id] || payload[:job_id]
331
- tags.compact
332
- end
278
+ # ── Workflows (issue #540) ──────────────────────────────────────
333
279
 
334
- def handler_tags(payload)
335
- {
336
- "handler" => payload[:handler],
337
- "routing_key" => payload[:routing_key],
338
- "attempts" => payload[:read_ct]
339
- }.compact
280
+ # The same names and labels as Metrics::Subscriber
281
+ # (Pgbus::Metrics::WorkflowMetrics), as AppSignal custom metrics.
282
+ def on_workflow(event)
283
+ Pgbus::Metrics::WorkflowMetrics.samples(event.name, event.payload).each do |kind, name, value, tags|
284
+ case kind
285
+ when :histogram then ::Appsignal.add_distribution_value(name, value, tags)
286
+ when :counter then ::Appsignal.increment_counter(name, value, tags)
287
+ when :gauge then ::Appsignal.set_gauge(name, value, tags)
288
+ end
289
+ end
340
290
  end
341
291
  end
342
292
  end
@@ -19,7 +19,8 @@ module Pgbus
19
19
  #
20
20
  # Security: requests are rejected with 401 unless they carry the configured
21
21
  # token (or pass the supplied auth callable). Run it on an internal network
22
- # / behind your VPN, never internet-exposed.
22
+ # / behind your VPN, never internet-exposed. With config.mcp_write_tools on
23
+ # it also serves the workflow write tools (issue #550): gate it.
23
24
  #
24
25
  # DNS-rebinding protection: since mcp 0.23 the transport validates the Host
25
26
  # header (loopback only, by default) and the Origin header (same-origin
@@ -123,9 +124,13 @@ module Pgbus
123
124
  @token.nil? && @auth.nil?
124
125
  end
125
126
 
127
+ # With config.mcp_write_tools on, an unauthenticated mount can change
128
+ # workflow runs. Warned, not refused: a mount inside the app's own
129
+ # authenticated routes is gated where this class cannot see it.
126
130
  def warn_unauthenticated!
127
131
  Pgbus.logger.warn do
128
- "[Pgbus::MCP] HTTP diagnostic server mounted without authentication. " \
132
+ writes = Server.write_tools? ? " config.mcp_write_tools is on: anyone who reaches it can change workflow runs." : ""
133
+ "[Pgbus::MCP] HTTP diagnostic server mounted without authentication.#{writes} " \
129
134
  "Pass token: or auth: to Pgbus::MCP.rack_app, and keep it on an internal network."
130
135
  end
131
136
  end
@@ -66,7 +66,8 @@ module Pgbus
66
66
 
67
67
  def log_start(allow_payloads)
68
68
  mode = allow_payloads ? "payloads ALLOWED" : "payloads redacted"
69
- Pgbus.logger.info { "[Pgbus::MCP] starting read-only diagnostic server over stdio (#{mode})" }
69
+ kind = Server.write_tools? ? "diagnostic server with the workflow write tools ON (mcp_write_tools)" : "read-only diagnostic server"
70
+ Pgbus.logger.info { "[Pgbus::MCP] starting #{kind} over stdio (#{mode})" }
70
71
  end
71
72
  end
72
73
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+ require "active_support/core_ext/time"
6
+
7
+ module Pgbus
8
+ module MCP
9
+ # How MCP tools write a data-source row as JSON: times as ISO 8601 UTC,
10
+ # symbols as strings, keys as strings, recursively.
11
+ module Serialization
12
+ module_function
13
+
14
+ def serialize(value)
15
+ case value
16
+ when Hash then value.to_h { |key, nested| [key.to_s, serialize(nested)] }
17
+ when Array then value.map { |nested| serialize(nested) }
18
+ when Symbol then value.to_s
19
+ when Time, DateTime, ActiveSupport::TimeWithZone then value.utc.iso8601
20
+ else value
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -5,7 +5,8 @@ module Pgbus
5
5
  # Assembles the read-only pgbus diagnostic MCP server: registers every
6
6
  # diagnostic tool and wires a DataSource (plus the payload gate) into the
7
7
  # server context so tools never touch the database directly or leak
8
- # payloads by default.
8
+ # payloads by default. The workflow write tools (WRITE_TOOLS, issue #550)
9
+ # join only when config.mcp_write_tools is true when the server is built.
9
10
  #
10
11
  # This class only builds the +MCP::Server+ instance — transport is the
11
12
  # caller's concern (Runner drives stdio). Keeping them separate means the
@@ -27,38 +28,80 @@ module Pgbus
27
28
  Tools::ThroughputTool,
28
29
  Tools::StatsTool,
29
30
  Tools::RecurringTool,
30
- Tools::DeployTasksTool
31
+ Tools::DeployTasksTool,
32
+ Tools::WorkflowsTool,
33
+ Tools::WorkflowDetailTool,
34
+ Tools::WorkflowsAttentionTool,
35
+ Tools::WorkflowDefinitionsTool,
36
+ Tools::WorkflowStatsTool
31
37
  ].freeze
32
38
 
33
- INSTRUCTIONS = <<~TEXT
34
- Read-only diagnostic tools for a pgbus (PostgreSQL/PGMQ) deployment.
39
+ # The opt-in write surface (issue #180's rule: separate, explicitly
40
+ # opt-in, off by default). Never merged into TOOLS: with
41
+ # config.mcp_write_tools false these are not constructed at all.
42
+ WRITE_TOOLS = [
43
+ Tools::WorkflowResumeTool,
44
+ Tools::WorkflowCancelTool,
45
+ Tools::WorkflowSkipStepTool,
46
+ Tools::WorkflowResumeAtTool,
47
+ Tools::WorkflowRunNowTool,
48
+ Tools::WorkflowSignalTool
49
+ ].freeze
50
+
51
+ DIAGNOSTICS = <<~TEXT
52
+ Diagnostic tools for a pgbus (PostgreSQL/PGMQ) deployment.
35
53
  Start with pgbus_health for a one-call OK/DEGRADED/STALLED verdict,
36
54
  then drill in with pgbus_queues, pgbus_processes, pgbus_jobs,
37
- pgbus_dlq, pgbus_locks, and pgbus_concurrency. No tool mutates state. Message payloads
38
- are redacted unless the server was started with payloads explicitly
39
- allowed.
55
+ pgbus_dlq, pgbus_locks, and pgbus_concurrency; workflow runs with
56
+ pgbus_workflows_attention, pgbus_workflows and pgbus_workflow, and how
57
+ each workflow class performs with pgbus_workflow_definitions and
58
+ pgbus_workflow_stats. Message payloads are redacted unless the server
59
+ was started with payloads explicitly allowed.
60
+ TEXT
61
+
62
+ INSTRUCTIONS = "#{DIAGNOSTICS}No tool mutates state.\n".freeze
63
+
64
+ WRITE_INSTRUCTIONS = <<~TEXT
65
+ The tools above do not mutate state. This server was also built with
66
+ config.mcp_write_tools, so pgbus_workflow_resume, pgbus_workflow_cancel,
67
+ pgbus_workflow_skip_step, pgbus_workflow_resume_at,
68
+ pgbus_workflow_run_now and pgbus_workflow_signal change one workflow
69
+ run each. Read the run with pgbus_workflow first, then pass a reason
70
+ (recorded on the run's audit trail) and expected_status (the status
71
+ you read): a run that has moved on by the time the call starts
72
+ answers ok: false and nothing is done (the check runs just before the
73
+ action, whose transition is guarded again under the run's row lock).
74
+ There are no bulk write tools: pgbus_workflows_attention groups the
75
+ stuck runs; list a group's runs with pgbus_workflows (status,
76
+ workflow_class, step, error_class) and call once per run.
40
77
  TEXT
41
78
 
42
79
  module_function
43
80
 
44
- # Build an MCP::Server with all diagnostic tools registered.
81
+ # Build an MCP::Server with all diagnostic tools registered, plus the
82
+ # write tools when config.mcp_write_tools is true (read here, once).
45
83
  #
46
84
  # @param data_source [Pgbus::Web::DataSource] read layer the tools query.
47
85
  # @param allow_payloads [Boolean] when true, tools honor a per-call
48
86
  # include_payloads flag; otherwise payloads are always redacted.
49
87
  def build(data_source: Pgbus::Web::DataSource.new, allow_payloads: false)
88
+ write = write_tools?
50
89
  ::MCP::Server.new(
51
90
  name: "pgbus",
52
91
  title: "Pgbus Diagnostics",
53
92
  version: Pgbus::VERSION,
54
- instructions: INSTRUCTIONS,
55
- tools: TOOLS.dup,
93
+ instructions: write ? DIAGNOSTICS + WRITE_INSTRUCTIONS : INSTRUCTIONS,
94
+ tools: write ? TOOLS + WRITE_TOOLS : TOOLS.dup,
56
95
  server_context: {
57
96
  data_source: data_source,
58
97
  allow_payloads: allow_payloads
59
98
  }
60
99
  )
61
100
  end
101
+
102
+ # Only a literal true turns the write tools on (validate! rejects
103
+ # anything else, but an app may skip validation).
104
+ def write_tools? = Pgbus.configuration.mcp_write_tools == true
62
105
  end
63
106
  end
64
107
  end
@@ -69,16 +69,7 @@ module Pgbus
69
69
  end
70
70
  private_class_method :task_hash
71
71
 
72
- # Times as ISO 8601 UTC, symbols as strings, string keys throughout.
73
- def self.serialize(value)
74
- case value
75
- when Hash then value.to_h { |key, val| [key.to_s, serialize(val)] }
76
- when Array then value.map { |val| serialize(val) }
77
- when Symbol then value.to_s
78
- when Time, DateTime, ActiveSupport::TimeWithZone then value.utc.iso8601
79
- else value
80
- end
81
- end
72
+ def self.serialize(value) = Serialization.serialize(value)
82
73
  private_class_method :serialize
83
74
  end
84
75
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module MCP
5
+ module Tools
6
+ # Cancels an ongoing workflow run (issue #550). DataSource#cancel_workflow.
7
+ class WorkflowCancelTool < WriteTool
8
+ tool_name "pgbus_workflow_cancel"
9
+ title "Pgbus Workflow: Cancel"
10
+ description verb_description(:cancel, "Cancel an ongoing Pgbus::Workflow run. A cancelled run does not come back.")
11
+ idempotent!
12
+ input_schema(**workflow_input_schema)
13
+
14
+ def self.call(workflow_id:, reason:, expected_status: nil, server_context: nil)
15
+ perform(:cancel, workflow_id: workflow_id, reason: reason, expected_status: expected_status,
16
+ server_context: server_context) do |data_source, **who|
17
+ data_source.cancel_workflow(workflow_id, **who)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module MCP
5
+ module Tools
6
+ # Every workflow class with its runs going on now, the runs that ended
7
+ # in a window and the end-to-end percentiles (issue #549). Maps to
8
+ # DataSource#workflow_definitions, the dashboard's
9
+ # /pgbus/workflows/definitions.
10
+ class WorkflowDefinitionsTool < BaseTool
11
+ tool_name "pgbus_workflow_definitions"
12
+ title "Pgbus Workflow Definitions"
13
+ description <<~DESC
14
+ One row per Pgbus::Workflow class (every registered subclass and every class name still in pgbus_workflows): loaded (false when the class no longer loads in this app), ongoing and paused runs now, attention (runs that need a person, as pgbus_workflows_attention groups them), finished and cancelled runs that ended in the window, e2e_ms (p50/p95 of created → finished over the finished ones, with samples) and removed_in_flight ([{step, runs}] of ongoing runs still on a removed_step). window is 1h, 24h (default), 7d or 30d, capped at workflow_retention; the answer says what it read. Drill into one class with pgbus_workflow_stats.
15
+ DESC
16
+
17
+ input_schema(
18
+ properties: {
19
+ window: { type: "string", enum: Web::WorkflowWindow::CHOICES.keys,
20
+ description: "1h, 24h (default), 7d or 30d; capped at workflow_retention." }
21
+ },
22
+ required: []
23
+ )
24
+
25
+ def self.call(window: nil, server_context: nil)
26
+ parsed = Web::WorkflowWindow.parse(window)
27
+ definitions = data_source_from(server_context).workflow_definitions(window: parsed)
28
+ rows = definitions.map { |row| WorkflowInsightsPayload.row(row) }
29
+ json_response(Serialization.serialize(window: parsed.to_h, definitions: rows))
30
+ rescue Web::WorkflowWindow::Invalid => e
31
+ error_response(e.message)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module MCP
5
+ module Tools
6
+ # One workflow run (issue #542): every attempt with its durations, the
7
+ # signals, the audit trail and the outside verbs its status allows.
8
+ # Maps to DataSource#workflow_detail.
9
+ class WorkflowDetailTool < BaseTool
10
+ tool_name "pgbus_workflow"
11
+ title "Pgbus Workflow"
12
+ description <<~DESC
13
+ One Pgbus::Workflow run by workflow_id: status, current step, every step attempt (newest first, at most 100) with status, outcome, scheduled/started/finished times, the waited/queued/ran/waiting durations in seconds, worker, app revision and error class; the signals delivered to it; the audit trail (event, actor, reason; newest first); available_actions (the outside verbs its status allows) and resume_action (what resume would do). Arguments, step outputs, signal payloads and error messages/backtraces are redacted unless the server allows payloads and include_payloads is set.
14
+ DESC
15
+
16
+ input_schema(
17
+ properties: {
18
+ workflow_id: { type: "string", description: "The run's workflow_id (a UUID)." },
19
+ include_payloads: {
20
+ type: "boolean",
21
+ description: "Include arguments, outputs, signal payloads and error text. Only honored when the server allows payloads."
22
+ }
23
+ },
24
+ required: %w[workflow_id]
25
+ )
26
+
27
+ def self.call(workflow_id:, include_payloads: false, server_context: nil)
28
+ detail = data_source_from(server_context).workflow_detail(workflow_id)
29
+ return error_response("Workflow #{workflow_id} not found") unless detail
30
+
31
+ allowed = payloads_allowed?(server_context, include_payloads)
32
+ json_response({ workflow: WorkflowPayloads.detail(detail, allowed: allowed) },
33
+ server_context: server_context, include_payloads: include_payloads)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module MCP
5
+ module Tools
6
+ # Resumes a paused workflow run at a named step (issue #550).
7
+ # DataSource#resume_workflow_at.
8
+ class WorkflowResumeAtTool < WriteTool
9
+ tool_name "pgbus_workflow_resume_at"
10
+ title "Pgbus Workflow: Resume At"
11
+ description verb_description(
12
+ :resume_at, "Resume a paused Pgbus::Workflow run at the named step (pgbus_workflow lists its step_names)."
13
+ )
14
+ input_schema(
15
+ **workflow_input_schema(
16
+ properties: { step: { type: "string", minLength: 1, description: "The step to run next, e.g. capture." } },
17
+ required: %w[step]
18
+ )
19
+ )
20
+
21
+ def self.call(workflow_id:, reason:, step:, expected_status: nil, server_context: nil)
22
+ step = step.to_s.strip
23
+ perform(:resume_at, workflow_id: workflow_id, reason: reason, expected_status: expected_status,
24
+ server_context: server_context,
25
+ invalid: step.empty? ? "step must name a step" : nil) do |data_source, **who|
26
+ data_source.resume_workflow_at(workflow_id, step: step, **who)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module MCP
5
+ module Tools
6
+ # Resumes a paused workflow run (issue #550). DataSource#resume_workflow.
7
+ class WorkflowResumeTool < WriteTool
8
+ tool_name "pgbus_workflow_resume"
9
+ title "Pgbus Workflow: Resume"
10
+ description verb_description(
11
+ :resume, "Resume a paused Pgbus::Workflow run (pgbus_workflow's resume_action says what runs next)."
12
+ )
13
+ input_schema(**workflow_input_schema)
14
+
15
+ def self.call(workflow_id:, reason:, expected_status: nil, server_context: nil)
16
+ perform(:resume, workflow_id: workflow_id, reason: reason, expected_status: expected_status,
17
+ server_context: server_context) do |data_source, **who|
18
+ data_source.resume_workflow(workflow_id, **who)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module MCP
5
+ module Tools
6
+ # Sends a ready run's scheduled step now (issue #550).
7
+ # DataSource#run_workflow_step_now.
8
+ class WorkflowRunNowTool < WriteTool
9
+ tool_name "pgbus_workflow_run_now"
10
+ title "Pgbus Workflow: Run Now"
11
+ description verb_description(:run_now, "Run a ready Pgbus::Workflow run's scheduled step now instead of after its wait.")
12
+ input_schema(**workflow_input_schema)
13
+
14
+ def self.call(workflow_id:, reason:, expected_status: nil, server_context: nil)
15
+ perform(:run_now, workflow_id: workflow_id, reason: reason, expected_status: expected_status,
16
+ server_context: server_context) do |data_source, **who|
17
+ data_source.run_workflow_step_now(workflow_id, **who)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Pgbus
6
+ module MCP
7
+ module Tools
8
+ # Delivers a signal to an ongoing workflow run (issue #550).
9
+ # DataSource#signal_workflow.
10
+ class WorkflowSignalTool < WriteTool
11
+ # Both columns are indexed; an index entry has a size limit.
12
+ KEY_LIMIT = 255
13
+
14
+ tool_name "pgbus_workflow_signal"
15
+ title "Pgbus Workflow: Signal"
16
+ description verb_description(
17
+ :signal, "Send a named signal, with an optional JSON object payload, to an ongoing Pgbus::Workflow run. " \
18
+ "Pass an idempotency_key when the call may be retried: a redelivery with the same key stores " \
19
+ "nothing and answers duplicate: true, but only while the run is ongoing (once it finished or " \
20
+ "was cancelled, a retry answers ok: false: signal is not allowed)."
21
+ )
22
+ input_schema(
23
+ **workflow_input_schema(
24
+ properties: {
25
+ name: { type: "string", minLength: 1, maxLength: KEY_LIMIT,
26
+ description: "The signal's name, e.g. payment_confirmed." },
27
+ payload: {
28
+ type: "object",
29
+ description: "The signal's payload, a JSON object of at most workflow_max_payload_size bytes."
30
+ },
31
+ idempotency_key: { type: "string", maxLength: KEY_LIMIT,
32
+ description: "Makes a retried call deliver the signal once. Blank means none." }
33
+ },
34
+ required: %w[name]
35
+ )
36
+ )
37
+
38
+ def self.call(workflow_id:, reason:, name:, payload: nil, idempotency_key: nil, expected_status: nil,
39
+ server_context: nil)
40
+ name = name.to_s.strip
41
+ idempotency_key = idempotency_key.to_s.presence # blank is no key, as in Signals.deliver
42
+ payload = payload_object(payload)
43
+ perform(:signal, workflow_id: workflow_id, reason: reason, expected_status: expected_status,
44
+ server_context: server_context,
45
+ invalid: signal_refusal(name, idempotency_key, payload)) do |data_source, **who|
46
+ data_source.signal_workflow(workflow_id, name: name, payload: payload, idempotency_key: idempotency_key, **who)
47
+ end
48
+ end
49
+
50
+ # Why the signal cannot be sent as given, or nil.
51
+ def self.signal_refusal(name, idempotency_key, payload)
52
+ return "name must name a signal" if name.empty?
53
+ return "name must be at most #{KEY_LIMIT} characters" if name.length > KEY_LIMIT
54
+ return "idempotency_key must be at most #{KEY_LIMIT} characters" if idempotency_key && idempotency_key.length > KEY_LIMIT
55
+
56
+ payload if payload.is_a?(String)
57
+ end
58
+ private_class_method :signal_refusal
59
+
60
+ # The payload with string keys (as the dashboard's form sends it), or
61
+ # why it cannot be sent. Workflow::Payload enforces the cap again on
62
+ # the serialized form.
63
+ def self.payload_object(payload)
64
+ return {} if payload.nil?
65
+ return "payload must be a JSON object" unless payload.is_a?(Hash)
66
+
67
+ json = JSON.generate(payload)
68
+ limit = Pgbus.configuration.workflow_max_payload_size
69
+ return "payload is #{json.bytesize} bytes, over workflow_max_payload_size (#{limit})" if json.bytesize > limit
70
+
71
+ JSON.parse(json)
72
+ end
73
+ private_class_method :payload_object
74
+ end
75
+ end
76
+ end
77
+ end