pgbus 0.19.4 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/README.md +97 -7
  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 +148 -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/blocked_execution.rb +12 -0
  16. data/app/models/pgbus/uniqueness_key.rb +88 -3
  17. data/app/models/pgbus/workflow_entry.rb +172 -0
  18. data/app/models/pgbus/workflow_event.rb +43 -0
  19. data/app/models/pgbus/workflow_signal.rb +77 -0
  20. data/app/models/pgbus/workflow_step.rb +149 -0
  21. data/app/views/layouts/pgbus/application.html.erb +2 -0
  22. data/app/views/pgbus/dashboard/_stats_cards.html.erb +17 -0
  23. data/app/views/pgbus/workflows/_actions.html.erb +59 -0
  24. data/app/views/pgbus/workflows/_events_table.html.erb +24 -0
  25. data/app/views/pgbus/workflows/_filters.html.erb +29 -0
  26. data/app/views/pgbus/workflows/_signal_form.html.erb +31 -0
  27. data/app/views/pgbus/workflows/_signals_table.html.erb +40 -0
  28. data/app/views/pgbus/workflows/_status_filter.html.erb +12 -0
  29. data/app/views/pgbus/workflows/_steps_table.html.erb +79 -0
  30. data/app/views/pgbus/workflows/_timeline.html.erb +49 -0
  31. data/app/views/pgbus/workflows/_workflows_table.html.erb +59 -0
  32. data/app/views/pgbus/workflows/attention.html.erb +88 -0
  33. data/app/views/pgbus/workflows/definitions/_by_revision.html.erb +52 -0
  34. data/app/views/pgbus/workflows/definitions/_duration_table.html.erb +18 -0
  35. data/app/views/pgbus/workflows/definitions/_funnel.html.erb +49 -0
  36. data/app/views/pgbus/workflows/definitions/_pipeline.html.erb +16 -0
  37. data/app/views/pgbus/workflows/definitions/_step_card.html.erb +31 -0
  38. data/app/views/pgbus/workflows/definitions/_throughput_table.html.erb +32 -0
  39. data/app/views/pgbus/workflows/definitions/_window.html.erb +17 -0
  40. data/app/views/pgbus/workflows/definitions/index.html.erb +71 -0
  41. data/app/views/pgbus/workflows/definitions/not_found.html.erb +5 -0
  42. data/app/views/pgbus/workflows/definitions/show.html.erb +80 -0
  43. data/app/views/pgbus/workflows/index.html.erb +15 -0
  44. data/app/views/pgbus/workflows/not_found.html.erb +5 -0
  45. data/app/views/pgbus/workflows/show.html.erb +93 -0
  46. data/config/i18n-tasks.yml +6 -0
  47. data/config/locales/da.yml +445 -0
  48. data/config/locales/de.yml +445 -0
  49. data/config/locales/en.yml +445 -0
  50. data/config/locales/es.yml +445 -0
  51. data/config/locales/fi.yml +445 -0
  52. data/config/locales/fr.yml +445 -0
  53. data/config/locales/it.yml +445 -0
  54. data/config/locales/ja.yml +445 -0
  55. data/config/locales/nb.yml +445 -0
  56. data/config/locales/nl.yml +445 -0
  57. data/config/locales/pt.yml +445 -0
  58. data/config/locales/sv.yml +445 -0
  59. data/config/routes.rb +26 -0
  60. data/lib/generators/pgbus/add_workflow_signals_generator.rb +56 -0
  61. data/lib/generators/pgbus/add_workflows_generator.rb +54 -0
  62. data/lib/generators/pgbus/templates/add_workflow_signals.rb.erb +51 -0
  63. data/lib/generators/pgbus/templates/add_workflows.rb.erb +150 -0
  64. data/lib/generators/pgbus/templates/migration.rb.erb +127 -0
  65. data/lib/pgbus/active_job/adapter.rb +81 -3
  66. data/lib/pgbus/active_job/delivery.rb +63 -0
  67. data/lib/pgbus/active_job/executor.rb +20 -8
  68. data/lib/pgbus/concurrency/blocked_execution.rb +14 -6
  69. data/lib/pgbus/configuration/workflow_settings.rb +99 -0
  70. data/lib/pgbus/configuration.rb +64 -4
  71. data/lib/pgbus/dead_letter_header.rb +9 -1
  72. data/lib/pgbus/generators/migration_detector.rb +40 -0
  73. data/lib/pgbus/instrumentation.rb +33 -0
  74. data/lib/pgbus/integrations/appsignal/dashboard.json +157 -1
  75. data/lib/pgbus/integrations/appsignal/subscriber.rb +16 -1
  76. data/lib/pgbus/mcp/rack_app.rb +7 -2
  77. data/lib/pgbus/mcp/runner.rb +2 -1
  78. data/lib/pgbus/mcp/serialization.rb +25 -0
  79. data/lib/pgbus/mcp/server.rb +53 -10
  80. data/lib/pgbus/mcp/tools/deploy_tasks_tool.rb +1 -10
  81. data/lib/pgbus/mcp/tools/workflow_cancel_tool.rb +23 -0
  82. data/lib/pgbus/mcp/tools/workflow_definitions_tool.rb +36 -0
  83. data/lib/pgbus/mcp/tools/workflow_detail_tool.rb +38 -0
  84. data/lib/pgbus/mcp/tools/workflow_resume_at_tool.rb +32 -0
  85. data/lib/pgbus/mcp/tools/workflow_resume_tool.rb +24 -0
  86. data/lib/pgbus/mcp/tools/workflow_run_now_tool.rb +23 -0
  87. data/lib/pgbus/mcp/tools/workflow_signal_tool.rb +77 -0
  88. data/lib/pgbus/mcp/tools/workflow_skip_step_tool.rb +23 -0
  89. data/lib/pgbus/mcp/tools/workflow_stats_tool.rb +42 -0
  90. data/lib/pgbus/mcp/tools/workflows_attention_tool.rb +42 -0
  91. data/lib/pgbus/mcp/tools/workflows_tool.rb +60 -0
  92. data/lib/pgbus/mcp/workflow_insights_payload.rb +15 -0
  93. data/lib/pgbus/mcp/workflow_payloads.rb +53 -0
  94. data/lib/pgbus/mcp/write_tool.rb +134 -0
  95. data/lib/pgbus/mcp.rb +15 -0
  96. data/lib/pgbus/metrics/backends/statsd.rb +6 -0
  97. data/lib/pgbus/metrics/subscriber.rb +14 -1
  98. data/lib/pgbus/metrics/workflow_metrics.rb +75 -0
  99. data/lib/pgbus/process/consumer.rb +2 -1
  100. data/lib/pgbus/process/dispatcher.rb +39 -3
  101. data/lib/pgbus/process/worker.rb +10 -1
  102. data/lib/pgbus/recurring/schedule.rb +1 -1
  103. data/lib/pgbus/streams/presence.rb +65 -36
  104. data/lib/pgbus/uniqueness.rb +23 -6
  105. data/lib/pgbus/version.rb +1 -1
  106. data/lib/pgbus/web/data_source/workflow_actions.rb +109 -0
  107. data/lib/pgbus/web/data_source/workflow_insights.rb +99 -0
  108. data/lib/pgbus/web/data_source/workflow_list.rb +291 -0
  109. data/lib/pgbus/web/data_source.rb +126 -15
  110. data/lib/pgbus/web/workflow_action_plan.rb +62 -0
  111. data/lib/pgbus/web/workflow_insights_queries.rb +250 -0
  112. data/lib/pgbus/web/workflow_insights_report.rb +171 -0
  113. data/lib/pgbus/web/workflow_payload.rb +36 -0
  114. data/lib/pgbus/web/workflow_query.rb +82 -0
  115. data/lib/pgbus/web/workflow_state.rb +89 -0
  116. data/lib/pgbus/web/workflow_timeline.rb +130 -0
  117. data/lib/pgbus/web/workflow_window.rb +51 -0
  118. data/lib/pgbus/workflow/batch_finished_job.rb +21 -0
  119. data/lib/pgbus/workflow/dsl.rb +105 -0
  120. data/lib/pgbus/workflow/error_capture.rb +63 -0
  121. data/lib/pgbus/workflow/exception_policy.rb +170 -0
  122. data/lib/pgbus/workflow/fan_out.rb +157 -0
  123. data/lib/pgbus/workflow/fan_out_definition.rb +41 -0
  124. data/lib/pgbus/workflow/flow_control.rb +94 -0
  125. data/lib/pgbus/workflow/instance.rb +183 -0
  126. data/lib/pgbus/workflow/outside_verbs.rb +138 -0
  127. data/lib/pgbus/workflow/payload.rb +53 -0
  128. data/lib/pgbus/workflow/registry.rb +53 -0
  129. data/lib/pgbus/workflow/removed_step.rb +14 -0
  130. data/lib/pgbus/workflow/runner.rb +218 -0
  131. data/lib/pgbus/workflow/signal_matcher.rb +53 -0
  132. data/lib/pgbus/workflow/signals.rb +132 -0
  133. data/lib/pgbus/workflow/states.rb +119 -0
  134. data/lib/pgbus/workflow/step_context.rb +58 -0
  135. data/lib/pgbus/workflow/step_definition.rb +133 -0
  136. data/lib/pgbus/workflow/step_execution.rb +242 -0
  137. data/lib/pgbus/workflow/step_job.rb +28 -0
  138. data/lib/pgbus/workflow/step_outcome.rb +151 -0
  139. data/lib/pgbus/workflow/steps.rb +103 -0
  140. data/lib/pgbus/workflow/sweep.rb +156 -0
  141. data/lib/pgbus/workflow/test_helpers.rb +152 -0
  142. data/lib/pgbus/workflow/wait_timeout.rb +94 -0
  143. data/lib/pgbus/workflow/waiting.rb +82 -0
  144. data/lib/pgbus/workflow.rb +183 -0
  145. data/sig/pgbus/configuration.rbs +23 -0
  146. metadata +92 -1
@@ -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
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module MCP
5
+ module Tools
6
+ # Skips the step a waiting workflow run waits on (issue #550).
7
+ # DataSource#skip_workflow_step.
8
+ class WorkflowSkipStepTool < WriteTool
9
+ tool_name "pgbus_workflow_skip_step"
10
+ title "Pgbus Workflow: Skip Step"
11
+ description verb_description(:skip, "Skip the step a waiting Pgbus::Workflow run waits on (a signal or a fan-out batch).")
12
+ input_schema(**workflow_input_schema)
13
+
14
+ def self.call(workflow_id:, reason:, expected_status: nil, server_context: nil)
15
+ perform(:skip, workflow_id: workflow_id, reason: reason, expected_status: expected_status,
16
+ server_context: server_context) do |data_source, **who|
17
+ data_source.skip_workflow_step(workflow_id, **who)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module MCP
5
+ module Tools
6
+ # One workflow class's insights over a window (issue #549): its
7
+ # pipeline with live counts and step performance, throughput, durations,
8
+ # funnel and per-revision numbers. Maps to DataSource#workflow_definition,
9
+ # the dashboard's /pgbus/workflows/definitions/:workflow_class.
10
+ class WorkflowStatsTool < BaseTool
11
+ tool_name "pgbus_workflow_stats"
12
+ title "Pgbus Workflow Stats"
13
+ description <<~DESC
14
+ One Pgbus::Workflow class over a window (1h, 24h default, 7d or 30d, capped at workflow_retention). steps, in declaration order (gravestones as kind "removed"; names only the rows hold as declared: false): counts (runs sitting at the step now by run status ready/performing/waiting/paused), run_ms (p50/p95/p99 of finished_at - started_at over completed or failed attempts) and pickup_ms (p50/p95 of scheduled → started), rates (failed: an error was recorded, reattempted, skipped) and outcomes over the sample_size attempts that finished in the window; percentiles over fewer than 20 samples are noisy. Also: started/finished/cancelled, e2e_ms, duration_histogram (finished runs per created → finished range), throughput (runs started/finished/cancelled per bucket_seconds), funnel (of the runs started in the window: reached = runs with a row for the step, stopped = where each run is now by status) and by_revision (the five app revisions that finished an attempt last, newest first, totals and per step).
15
+ DESC
16
+
17
+ input_schema(
18
+ properties: {
19
+ workflow_class: { type: "string", description: "The class name, e.g. OrderWorkflow or Billing::OrderWorkflow." },
20
+ window: { type: "string", enum: Web::WorkflowWindow::CHOICES.keys,
21
+ description: "1h, 24h (default), 7d or 30d; capped at workflow_retention." }
22
+ },
23
+ required: %w[workflow_class]
24
+ )
25
+
26
+ def self.call(workflow_class:, window: nil, server_context: nil)
27
+ name = workflow_class.to_s
28
+ unless Web::DataSource::WorkflowInsights::CLASS_NAME.match?(name)
29
+ return error_response("#{name.inspect} is not a class name (OrderWorkflow or Billing::OrderWorkflow)")
30
+ end
31
+
32
+ detail = data_source_from(server_context).workflow_definition(name, window: Web::WorkflowWindow.parse(window))
33
+ return error_response("No workflow class or run named #{name}") unless detail
34
+
35
+ json_response(Serialization.serialize(workflow: WorkflowInsightsPayload.row(detail)))
36
+ rescue Web::WorkflowWindow::Invalid => e
37
+ error_response(e.message)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module MCP
5
+ module Tools
6
+ # The dashboard's needs-attention view (issue #542): ongoing runs that
7
+ # wait on a person, grouped. Maps to DataSource#workflow_attention_groups.
8
+ class WorkflowsAttentionTool < BaseTool
9
+ tool_name "pgbus_workflows_attention"
10
+ title "Pgbus Workflows Needing Attention"
11
+ description <<~DESC
12
+ Ongoing Pgbus::Workflow runs that need a person, grouped by (workflow_class, step_name, reason, error_class): reason is paused_error, stalled, paused_manual, overdue (due but not picked up past workflow_stall_threshold) or waiting_overdue (waiting past workflow_waiting_visibility_threshold without a wait_timeout). Each group has its run count, first_seen_at/last_seen_at, the status its runs are in and the actions that status allows; total_runs sums the groups (at most 200 groups, biggest first). workflow_class narrows them to one class. The newest error message is redacted unless the server allows payloads and include_payloads is set.
13
+ DESC
14
+
15
+ input_schema(
16
+ properties: {
17
+ workflow_class: { type: "string", description: "Only this class's groups, e.g. OrderWorkflow." },
18
+ include_payloads: {
19
+ type: "boolean", description: "Include error messages. Only honored when the server allows payloads."
20
+ }
21
+ },
22
+ required: []
23
+ )
24
+
25
+ def self.call(workflow_class: nil, include_payloads: false, server_context: nil)
26
+ if workflow_class && !Web::DataSource::WorkflowInsights::CLASS_NAME.match?(workflow_class.to_s)
27
+ return error_response("#{workflow_class.inspect} is not a class name (OrderWorkflow or Billing::OrderWorkflow)")
28
+ end
29
+
30
+ groups = data_source_from(server_context).workflow_attention_groups(workflow_class: workflow_class&.to_s)
31
+ allowed = payloads_allowed?(server_context, include_payloads)
32
+
33
+ json_response(
34
+ { groups: groups.map { |group| WorkflowPayloads.group(group, allowed: allowed) },
35
+ total_runs: groups.sum { |group| group[:count] } },
36
+ server_context: server_context, include_payloads: include_payloads
37
+ )
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module MCP
5
+ module Tools
6
+ # Workflow runs, filtered like the dashboard's /pgbus/workflows (issue
7
+ # #542). Maps to DataSource#workflows and #workflows_count; the filters
8
+ # are checked by the same Web::WorkflowQuery the index uses.
9
+ class WorkflowsTool < BaseTool
10
+ tool_name "pgbus_workflows"
11
+ title "Pgbus Workflows"
12
+ description <<~DESC
13
+ List Pgbus::Workflow runs, newest first: class, subject, status, current step and attempt, what a waiting step waits for (signal name, fan-out batch progress), when a scheduled step runs, and the error class of a paused one. status is "ongoing" (default: ready, performing, waiting or paused), one state, or "all"; the other filters combine with AND. created_from/created_to and ended_from/ended_to (when a run finished or was cancelled) take an ISO 8601 date or time. Paginated (default 25, max 100); total is capped at 10000. The error message is redacted unless the server allows payloads and include_payloads is set.
14
+ DESC
15
+
16
+ MAX_PER_PAGE = 100
17
+ MAX_PAGE = 1_000
18
+ TEXT = %w[workflow_class step error_class subject_type subject_id workflow_id].freeze
19
+
20
+ input_schema(
21
+ properties: {
22
+ status: { type: "string", enum: Web::DataSource::WorkflowList::STATUSES,
23
+ description: "ongoing (default), ready, performing, waiting, paused, finished, cancelled or all." },
24
+ **TEXT.to_h { |name| [name.to_sym, { type: "string", description: "Exact #{name.tr("_", " ")}." }] },
25
+ created_from: { type: "string", description: "Created at or after this ISO 8601 date or time." },
26
+ created_to: { type: "string", description: "Created at or before this ISO 8601 date (the whole day) or time." },
27
+ ended_from: { type: "string", description: "Finished or cancelled at or after this ISO 8601 date or time." },
28
+ ended_to: {
29
+ type: "string", description: "Finished or cancelled at or before this ISO 8601 date (the whole day) or time."
30
+ },
31
+ page: { type: "integer", description: "1-based page number (default 1, max 1000).", minimum: 1 },
32
+ per_page: { type: "integer", description: "Rows per page (default 25, max 100).", minimum: 1 },
33
+ include_payloads: {
34
+ type: "boolean", description: "Include error messages. Only honored when the server allows payloads."
35
+ }
36
+ },
37
+ required: []
38
+ )
39
+
40
+ def self.call(page: 1, per_page: 25, include_payloads: false, server_context: nil, **filters)
41
+ query = Web::WorkflowQuery.new(filters)
42
+ data_source = data_source_from(server_context)
43
+ per_page = per_page.to_i.clamp(1, MAX_PER_PAGE)
44
+ page = page.to_i.clamp(1, MAX_PAGE)
45
+ rows = data_source.workflows(status: query.status, page: page, per_page: per_page, **query.filters)
46
+ count = data_source.workflows_count(status: query.status, **query.filters)
47
+ allowed = payloads_allowed?(server_context, include_payloads)
48
+
49
+ json_response(
50
+ { workflows: rows.map { |row| WorkflowPayloads.row(row, allowed: allowed) }, page: page, per_page: per_page,
51
+ total: count.total, has_more: more_pages?(count, page: page, per_page: per_page, shown: rows.size) },
52
+ server_context: server_context, include_payloads: include_payloads
53
+ )
54
+ rescue Web::WorkflowQuery::Invalid => e
55
+ error_response(e.message)
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module MCP
5
+ # The workflow insights' hashes as the MCP tools send them (issue #549).
6
+ # removed_in_flight is keyed by step name, and a step may be named like a
7
+ # payload key (removed_step :payload), which the Redactor would blank:
8
+ # the tools send it as [{ step:, runs: }] rows instead.
9
+ module WorkflowInsightsPayload
10
+ module_function
11
+
12
+ def row(row) = row.merge(removed_in_flight: row.fetch(:removed_in_flight, {}).map { |step, runs| { step: step, runs: runs } })
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module MCP
5
+ # What the workflow tools (issue #542) send back. A run's arguments, its
6
+ # steps' outputs, its signals' payloads and its error text (message,
7
+ # backtrace, cause messages, an event's error_message) can carry
8
+ # application data, and the Redactor only blanks known payload keys
9
+ # holding a leaf value, so these tools gate them explicitly: replaced by
10
+ # Redactor::REDACTED unless BaseTool.payloads_allowed? says otherwise.
11
+ # Written out through MCP::Serialization.
12
+ module WorkflowPayloads
13
+ REDACTED = Redactor::REDACTED
14
+ STEP_SECRETS = %i[output error_message error_backtrace].freeze
15
+
16
+ module_function
17
+
18
+ def row(row, allowed:)
19
+ serialize(allowed ? row : row.merge(redact(row, :error_message)))
20
+ end
21
+
22
+ def detail(detail, allowed:)
23
+ return serialize(detail.except(:timeline)) if allowed
24
+
25
+ gated = detail.except(:timeline).merge(arguments: REDACTED, **redact(detail, :error_message))
26
+ gated[:steps] = Array(detail[:steps]).map { |step| gate_step(step) }
27
+ gated[:signals] = Array(detail[:signals]).map { |signal| signal.merge(payload: REDACTED) }
28
+ gated[:events] = Array(detail[:events]).map { |event| gate_event(event) }
29
+ serialize(gated)
30
+ end
31
+
32
+ def group(group, allowed:)
33
+ serialize(allowed ? group : group.merge(redact(group, :error_message)))
34
+ end
35
+
36
+ def gate_step(step)
37
+ gated = step.merge(STEP_SECRETS.to_h { |key| [key, step[key].nil? ? nil : REDACTED] })
38
+ gated[:error_causes] = Array(step[:error_causes]).map { |cause| cause.slice("class", :class) }
39
+ gated
40
+ end
41
+
42
+ def gate_event(event)
43
+ details = event[:details] || {}
44
+ event.merge(details: details.reject { |key, _| key.to_s == "error_message" })
45
+ end
46
+
47
+ # { key => REDACTED } when the value is there, so a nil stays nil.
48
+ def redact(hash, key) = hash[key].nil? ? {} : { key => REDACTED }
49
+
50
+ def serialize(value) = Serialization.serialize(value)
51
+ end
52
+ end
53
+ end