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
@@ -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
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module MCP
5
+ # Base class for the opt-in write tools (issue #550). Deliberately not a
6
+ # BaseTool subclass: no write tool can inherit the read-only annotations,
7
+ # and no read tool can become a write tool by changing its superclass.
8
+ # Server registers these only when config.mcp_write_tools is true.
9
+ #
10
+ # Every call (#perform): the run loaded (unknown: ok: false), the tool's
11
+ # inputs and a reason (1-500 characters after trimming) checked,
12
+ # expected_status checked when given, the verb checked against the run's
13
+ # available_actions, then the same Web::DataSource action the dashboard
14
+ # uses, recorded as actor "mcp" (or "mcp:<client name>"). The answer is
15
+ # { ok:, reason:, workflow: } with the run as it is after the call,
16
+ # payloads always redacted; a refusal changes nothing and records nothing.
17
+ #
18
+ # The two checks read the run before acting, so they are advisory: the
19
+ # action's guarded transition under the run's row lock decides, and its
20
+ # code (:not_allowed, with the status the run is in now) is passed on.
21
+ class WriteTool < ::MCP::Tool
22
+ ANNOTATIONS = { read_only_hint: false, destructive_hint: true, idempotent_hint: false, open_world_hint: false }.freeze
23
+ REASON_LIMIT = 500
24
+ CLIENT_NAME_LIMIT = 100
25
+ ACTOR = "mcp"
26
+ # What an unexpected failure answers: its text can carry internals
27
+ # (hosts, SQL, values), and Web::DataSource has already logged it.
28
+ FAILED = "the action failed unexpectedly; the error is in the pgbus log"
29
+
30
+ annotations(**ANNOTATIONS)
31
+
32
+ class << self
33
+ # MCP::Tool.inherited resets @annotations_value on every subclass, so
34
+ # fall back to the nearest ancestor's (as BaseTool does).
35
+ def annotations_value
36
+ super || (superclass.respond_to?(:annotations_value) ? superclass.annotations_value : nil)
37
+ end
38
+
39
+ # For a verb whose repeat can never act again (cancel: a cancelled run
40
+ # stays cancelled). Not skip or resume at: a retry can act again once
41
+ # the run has parked or paused again.
42
+ def idempotent!
43
+ annotations(**ANNOTATIONS, idempotent_hint: true)
44
+ end
45
+
46
+ # The input_schema every write tool takes, plus the tool's own fields.
47
+ def workflow_input_schema(properties: {}, required: [])
48
+ {
49
+ properties: {
50
+ workflow_id: { type: "string", description: "The run's workflow_id (a UUID)." },
51
+ reason: { type: "string", minLength: 1, maxLength: REASON_LIMIT,
52
+ description: "Why, in 1-#{REASON_LIMIT} characters. Recorded on the run's audit trail." },
53
+ expected_status: {
54
+ type: "string", enum: Workflow::States::WORKFLOW,
55
+ description: "The status you read. If the run is in another one when the call starts: ok: false, nothing done."
56
+ },
57
+ **properties
58
+ },
59
+ required: %w[workflow_id reason] + required
60
+ }
61
+ end
62
+
63
+ # The tool description: what it does, the transition it makes, and the
64
+ # opt-in. Frozen: it is class state (the conformance audit checks).
65
+ def verb_description(verb, summary)
66
+ transition = Workflow::States.fetch(verb)
67
+ <<~DESC.freeze
68
+ #{summary} Allowed from: #{transition.from.join(", ")}. What happens: #{transition.effect}. A write tool: it exists only when the server was built with config.mcp_write_tools = true. reason is required and recorded on the run's audit trail with actor "mcp" (or "mcp:<client name>" when the transport knows the client); pass expected_status (the status you read with pgbus_workflow): a run that has moved on by the time the call starts answers ok: false and nothing is done (the check runs just before the action, whose transition is guarded again under the run's row lock). Answers { ok, reason, workflow } with the run as it is after the call; a refused call changes nothing and records nothing (a transition refused under the lock adds code and status, a disallowed verb available_actions); arguments, outputs, signal payloads and error text are redacted.
69
+ DESC
70
+ end
71
+
72
+ # Runs the checks, then yields (data_source, actor:, reason:) for the
73
+ # DataSource action, whose { ok:, ... } becomes the answer. +invalid+
74
+ # is the tool's own input refusal (a blank step, an oversized
75
+ # payload), answered like every other refusal: with the run as it is.
76
+ def perform(verb, workflow_id:, reason:, expected_status:, server_context:, invalid: nil)
77
+ data_source = BaseTool.data_source_from(server_context)
78
+ detail = data_source.workflow_detail(workflow_id)
79
+ return answer(ok: false, reason: "workflow #{workflow_id} not found", workflow: nil) unless detail
80
+
81
+ note = reason.to_s.strip
82
+ refusal = input_refusal(note, invalid) || guard_refusal(verb, detail, expected_status)
83
+ return answer(**refusal, workflow: present(detail)) if refusal
84
+
85
+ result = outcome(yield(data_source, actor: actor(server_context), reason: note))
86
+ answer(**result, workflow: present(data_source.workflow_detail(workflow_id)))
87
+ end
88
+
89
+ def answer(**value) = BaseTool.json_response(value)
90
+
91
+ private
92
+
93
+ def input_refusal(note, invalid)
94
+ unless note.length.between?(1, REASON_LIMIT)
95
+ return { ok: false, reason: "reason must be 1-#{REASON_LIMIT} characters after trimming whitespace" }
96
+ end
97
+
98
+ { ok: false, reason: invalid } if invalid
99
+ end
100
+
101
+ # The DataSource action's answer, ok and reason first; an unexpected
102
+ # failure's text replaced (FAILED).
103
+ def outcome(result)
104
+ reason = result[:code] == :failed ? FAILED : result[:reason]
105
+ { ok: result[:ok], reason: reason, **result.except(:ok, :reason) }
106
+ end
107
+
108
+ def guard_refusal(verb, detail, expected_status)
109
+ status = detail[:status].to_s
110
+ if expected_status && expected_status.to_s != status
111
+ return { ok: false, reason: "workflow #{detail[:workflow_id]} is #{status}, not #{expected_status}" }
112
+ end
113
+
114
+ allowed = Array(detail[:available_actions]).map(&:to_s)
115
+ return if allowed.include?(verb.to_s)
116
+
117
+ { ok: false, reason: "cannot #{verb.to_s.tr("_", " ")} a #{status} workflow", available_actions: allowed }
118
+ end
119
+
120
+ def present(detail) = detail && WorkflowPayloads.detail(detail, allowed: false)
121
+
122
+ # "mcp", or "mcp:<name>" when the transport knows the client (an
123
+ # MCP::ServerContext on mcp >= 1.x; a plain Hash context has none).
124
+ # The name is client-supplied: control characters dropped, length capped.
125
+ def actor(server_context)
126
+ info = server_context.client_info if server_context.respond_to?(:client_info)
127
+ name = info.is_a?(Hash) ? (info[:name] || info["name"]) : nil
128
+ name = name.to_s.gsub(/[^[:print:]]/, "").strip[0, CLIENT_NAME_LIMIT]
129
+ name.empty? ? ACTOR : "#{ACTOR}:#{name}"
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
data/lib/pgbus/mcp.rb CHANGED
@@ -14,6 +14,7 @@ module Pgbus
14
14
  REQUIRES = %w[
15
15
  pgbus/mcp/redactor
16
16
  pgbus/mcp/base_tool
17
+ pgbus/mcp/serialization
17
18
  pgbus/mcp/health_analyzer
18
19
  pgbus/mcp/tools/health_tool
19
20
  pgbus/mcp/tools/queues_tool
@@ -29,6 +30,20 @@ module Pgbus
29
30
  pgbus/mcp/tools/stats_tool
30
31
  pgbus/mcp/tools/recurring_tool
31
32
  pgbus/mcp/tools/deploy_tasks_tool
33
+ pgbus/mcp/workflow_payloads
34
+ pgbus/mcp/tools/workflows_tool
35
+ pgbus/mcp/tools/workflow_detail_tool
36
+ pgbus/mcp/tools/workflows_attention_tool
37
+ pgbus/mcp/workflow_insights_payload
38
+ pgbus/mcp/tools/workflow_definitions_tool
39
+ pgbus/mcp/tools/workflow_stats_tool
40
+ pgbus/mcp/write_tool
41
+ pgbus/mcp/tools/workflow_resume_tool
42
+ pgbus/mcp/tools/workflow_cancel_tool
43
+ pgbus/mcp/tools/workflow_skip_step_tool
44
+ pgbus/mcp/tools/workflow_resume_at_tool
45
+ pgbus/mcp/tools/workflow_run_now_tool
46
+ pgbus/mcp/tools/workflow_signal_tool
32
47
  pgbus/mcp/server
33
48
  pgbus/mcp/runner
34
49
  pgbus/mcp/rack_app
@@ -11,6 +11,10 @@ module Pgbus
11
11
  # gauge -> "name:value|g"
12
12
  # histogram -> "name:value|ms"
13
13
  #
14
+ # StatsD reads |ms values as milliseconds, so a histogram named
15
+ # *_seconds (the Prometheus convention, e.g.
16
+ # pgbus_workflow_step_duration_seconds) is sent as *_ms, times 1000.
17
+ #
14
18
  # Tags are appended as DogStatsD `|#k:v,k:v`. No gem dependency — a plain
15
19
  # UDPSocket. UDP send is fire-and-forget; any socket error is logged and
16
20
  # swallowed so a metrics hiccup never propagates into the producer thread.
@@ -32,6 +36,8 @@ module Pgbus
32
36
  end
33
37
 
34
38
  def histogram(name, value, tags = {})
39
+ return emit("#{name.delete_suffix("_seconds")}_ms", value * 1000.0, "ms", tags) if name.end_with?("_seconds")
40
+
35
41
  emit(name, value, "ms", tags)
36
42
  end
37
43
 
@@ -73,7 +73,8 @@ module Pgbus
73
73
  subscribe("pgbus.recurring.enqueue") { |event| on_recurring_enqueue(event) },
74
74
  subscribe("pgbus.worker.recycle") { |event| on_worker_recycle(event) },
75
75
  subscribe("pgbus.consumer.recycle") { |event| on_consumer_recycle(event) },
76
- subscribe("pgbus.client.pool") { |event| on_client_pool(event) }
76
+ subscribe("pgbus.client.pool") { |event| on_client_pool(event) },
77
+ *WorkflowMetrics::EVENTS.map { |name| subscribe(name) { |event| on_workflow(event) } }
77
78
  ].freeze
78
79
  end
79
80
 
@@ -251,6 +252,18 @@ module Pgbus
251
252
  backend.gauge("#{METRIC_PREFIX}pool_available", payload[:available], tags)
252
253
  end
253
254
 
255
+ # ── Workflows (issue #540) ────────────────────────────────────────
256
+
257
+ def on_workflow(event)
258
+ WorkflowMetrics.samples(event.name, event.payload).each do |kind, name, value, tags|
259
+ case kind
260
+ when :histogram then backend.histogram(name, value, tags)
261
+ when :counter then backend.increment(name, value, tags)
262
+ when :gauge then backend.gauge(name, value, tags)
263
+ end
264
+ end
265
+ end
266
+
254
267
  def hostname
255
268
  Socket.gethostname
256
269
  end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Metrics
5
+ # The pgbus.workflow.* event → metric mapping (issue #540), shared by
6
+ # Metrics::Subscriber (Prometheus, StatsD) and the AppSignal subscriber so
7
+ # both backends report the same names and labels:
8
+ #
9
+ # pgbus_workflow_step_duration_seconds{workflow_class, step, outcome} histogram
10
+ # pgbus_workflow_steps_total{workflow_class, step, outcome} counter
11
+ # pgbus_workflows_finished_total{workflow_class} counter
12
+ # pgbus_workflows_paused_total{workflow_class, error_class} counter (error_class absent for a manual pause)
13
+ # pgbus_workflows_cancelled_total{workflow_class} counter
14
+ # pgbus_workflow_stalls_total{workflow_class, step, detection} counter (detection: redelivery | sweep)
15
+ # pgbus_workflows{workflow_class, status} gauge, from each sweep
16
+ # pgbus_workflows_stuck{workflow_class, reason} gauge, from each sweep
17
+ #
18
+ # Label values come from code (class, step and exception names), so
19
+ # their cardinality is bounded; workflow ids never become labels.
20
+ module WorkflowMetrics
21
+ EVENTS = %w[
22
+ pgbus.workflow.step pgbus.workflow_finished pgbus.workflow_paused pgbus.workflow_cancelled
23
+ pgbus.workflow.stall_detected pgbus.workflow.gauge
24
+ ].freeze
25
+
26
+ module_function
27
+
28
+ # [[kind, name, value, tags], ...] for one event; kind is :histogram,
29
+ # :counter or :gauge.
30
+ def samples(event_name, payload)
31
+ klass = payload[:workflow_class]
32
+ case event_name
33
+ when "pgbus.workflow.step" then step_samples(klass, payload)
34
+ when "pgbus.workflow_finished" then [counter("pgbus_workflows_finished_total", workflow_class: klass)]
35
+ when "pgbus.workflow_paused"
36
+ [counter("pgbus_workflows_paused_total", workflow_class: klass, error_class: payload[:error_class])]
37
+ when "pgbus.workflow_cancelled" then [counter("pgbus_workflows_cancelled_total", workflow_class: klass)]
38
+ when "pgbus.workflow.stall_detected"
39
+ [counter("pgbus_workflow_stalls_total", workflow_class: klass, step: payload[:step_name], detection: payload[:detection])]
40
+ when "pgbus.workflow.gauge" then gauge_samples(payload)
41
+ else []
42
+ end
43
+ end
44
+
45
+ def step_samples(klass, payload)
46
+ tags = { workflow_class: klass, step: payload[:step_name], outcome: payload[:outcome] }.compact
47
+ samples = [[:counter, "pgbus_workflow_steps_total", 1, tags]]
48
+ samples.unshift([:histogram, "pgbus_workflow_step_duration_seconds", payload[:duration], tags]) if payload[:duration]
49
+ samples
50
+ end
51
+
52
+ def gauge_samples(payload)
53
+ statuses = Array(payload[:statuses]).map do |row|
54
+ [:gauge, "pgbus_workflows", row[:count], { workflow_class: row[:workflow_class], status: row[:status] }]
55
+ end
56
+ stuck = Array(payload[:stuck]).map do |row|
57
+ [:gauge, "pgbus_workflows_stuck", row[:count], { workflow_class: row[:workflow_class], reason: row[:reason] }]
58
+ end
59
+ statuses + stuck
60
+ end
61
+
62
+ def counter(name, **tags) = [:counter, name, 1, tags.compact]
63
+ end
64
+ end
65
+ end
@@ -19,6 +19,7 @@ module Pgbus
19
19
  JOB_LOCK_CLEANUP_INTERVAL = 300 # Run job lock cleanup every 5 minutes
20
20
  STATS_CLEANUP_INTERVAL = 3600 # Run stats cleanup every hour
21
21
  ORPHAN_STREAM_SWEEP_INTERVAL = 3600 # Run orphan stream sweep every hour
22
+ WORKFLOW_CLEANUP_INTERVAL = 3600 # Delete finished/cancelled workflows past workflow_retention every hour
22
23
  TABLE_MAINTENANCE_INTERVAL = Pgbus::TableMaintenance::MAINTENANCE_INTERVAL
23
24
 
24
25
  # Page size for archive compaction. Each cycle deletes up to this
@@ -57,6 +58,7 @@ module Pgbus
57
58
  @last_stream_archive_compaction_at
58
59
  @last_outbox_cleanup_at @last_job_lock_cleanup_at @last_stats_cleanup_at
59
60
  @last_orphan_stream_sweep_at @last_table_maintenance_at
61
+ @last_workflow_sweep_at @last_workflow_cleanup_at
60
62
  ].freeze
61
63
 
62
64
  # Outcome of a single maintenance task in a cycle. Lets run_maintenance
@@ -247,6 +249,8 @@ module Pgbus
247
249
  sweep_interval = config.streams_orphan_sweep_interval
248
250
  results << run_if_due(now, :@last_orphan_stream_sweep_at, sweep_interval) { sweep_orphan_streams } if sweep_interval
249
251
  results << run_if_due(now, :@last_table_maintenance_at, TABLE_MAINTENANCE_INTERVAL) { run_table_maintenance }
252
+ results << run_if_due(now, :@last_workflow_sweep_at, config.workflow_sweep_interval) { sweep_workflows }
253
+ results << run_if_due(now, :@last_workflow_cleanup_at, WORKFLOW_CLEANUP_INTERVAL) { cleanup_workflows }
250
254
  results
251
255
  end
252
256
 
@@ -388,6 +392,28 @@ module Pgbus
388
392
  e
389
393
  end
390
394
 
395
+ # Stalled and overdue workflow steps, timed-out waits, and the workflow gauges.
396
+ def sweep_workflows
397
+ summary = Workflow::Sweep.repair(config: config)
398
+ return unless summary && summary.values_at(:stalled, :resent, :timed_out, :fan_outs).compact.any?(&:positive?)
399
+
400
+ Pgbus.logger.info do
401
+ "[Pgbus] Workflow sweep: #{summary[:stalled]} stalled step(s) repaired, #{summary[:resent]} overdue step(s) re-sent, " \
402
+ "#{summary[:timed_out].to_i} wait(s) timed out, #{summary[:fan_outs].to_i} fan-out(s) resolved"
403
+ end
404
+ rescue StandardError => e
405
+ log_maintenance_failure("Workflow sweep", e)
406
+ e
407
+ end
408
+
409
+ def cleanup_workflows
410
+ deleted = Workflow::Sweep.cleanup(retention: config.workflow_retention)
411
+ Pgbus.logger.debug { "[Pgbus] Cleaned up #{deleted} finished workflows" } if deleted.positive?
412
+ rescue StandardError => e
413
+ log_maintenance_failure("Workflow cleanup", e)
414
+ e
415
+ end
416
+
391
417
  def cleanup_stats
392
418
  retention = config.stats_retention
393
419
  return unless retention&.positive?
data/lib/pgbus/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.19.3"
4
+ VERSION = "0.20.0"
5
5
  end