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
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Web
5
+ # Shapes WorkflowInsightsQueries' rows into the hashes the insights pages
6
+ # and MCP tools read (issue #549). Pure: no I/O.
7
+ #
8
+ # Steps come in the class's declaration order (gravestones included, as
9
+ # kind "removed"), then any step name the rows hold that the class does
10
+ # not declare (declared: false). For a class that no longer loads, the
11
+ # names in the rows, ordered by their mean position in the runs started
12
+ # in the window. Live counts are keyed by the run's status (ready,
13
+ # performing, waiting, paused), so each links to the filtered list.
14
+ module WorkflowInsightsReport
15
+ LIVE = Workflow::States::ONGOING.map(&:to_sym).freeze
16
+ STOPPED = Workflow::States::WORKFLOW.map(&:to_sym).freeze
17
+ OUTCOMES = %i[completed failed reattempted skipped cancelled].freeze
18
+ # Percentiles over fewer samples than this get a "few samples" note.
19
+ FEW_SAMPLES = 20
20
+ # The real-time step that walks a window's buckets: 15 min, the finest
21
+ # granularity of a UTC offset change.
22
+ WALK = 900
23
+ DAY = 86_400
24
+
25
+ module_function
26
+
27
+ def summary(name, klass, ongoing_rows, ended_row, attention)
28
+ live = ongoing_rows.sum { |row| row[:runs] }
29
+ e2e = ended_row ? ended_row[:e2e_ms].slice(:p50, :p95, :samples) : { p50: nil, p95: nil, samples: 0 }
30
+ { workflow_class: name, loaded: !klass.nil?, ongoing: live,
31
+ paused: ongoing_rows.select { |row| row[:status] == "paused" }.sum { |row| row[:runs] },
32
+ attention: attention, finished: ended_row&.fetch(:finished) || 0, cancelled: ended_row&.fetch(:cancelled) || 0,
33
+ e2e_ms: e2e, removed_in_flight: removed_in_flight(klass, ongoing_rows) }
34
+ end
35
+
36
+ def detail(name, klass, window:, since:, now:, ongoing:, ended:, attention:, stats:, histogram:, throughput:, reached:,
37
+ stopped:, revisions:, zone: nil)
38
+ names = step_list(klass, stats, ongoing, reached, stopped)
39
+ summary(name, klass, ongoing, ended, attention).merge(
40
+ window: window.to_h.merge(since: since), bucket_seconds: window.bucket_seconds,
41
+ started: stopped.values.sum, e2e_ms: ended ? ended[:e2e_ms] : empty_percentiles(%i[p50 p95 p99]),
42
+ steps: names.map { |entry| step(entry, stats[entry[:name]], ongoing) },
43
+ duration_histogram: duration_histogram(histogram),
44
+ throughput: throughput_buckets(throughput, since: since, now: now, bucket: window.bucket_seconds, zone: zone),
45
+ funnel: names.map { |entry| funnel_step(entry[:name], reached, stopped) },
46
+ by_revision: by_revision(revisions, names.map { |entry| entry[:name] })
47
+ )
48
+ end
49
+
50
+ # [{ name:, kind:, removed:, declared: }] in pipeline order.
51
+ def step_list(klass, stats, ongoing, reached, stopped)
52
+ seen = (stats.keys + ongoing.map { |row| row[:step] } + reached.keys + stopped.keys.map(&:first)).compact.uniq
53
+ declared = klass ? klass.workflow_steps.entries.map { |entry| declared_step(entry) } : []
54
+ extra = (seen - declared.map { |entry| entry[:name] }).sort_by do |name|
55
+ [reached.dig(name, :position) || Float::INFINITY, stats.dig(name, :first_seen_at)&.to_f || Float::INFINITY, name]
56
+ end
57
+ declared + extra.map { |name| { name: name, kind: nil, removed: false, declared: false } }
58
+ end
59
+
60
+ def declared_step(entry)
61
+ kind = if entry.removed? then "removed"
62
+ elsif entry.kind == "fan_out" then "fan_out"
63
+ elsif entry.waits_for_signal? then "signal"
64
+ else "step"
65
+ end
66
+ { name: entry.name, kind: kind, removed: entry.removed?, declared: true }
67
+ end
68
+
69
+ def step(entry, stats, ongoing)
70
+ counts = LIVE.to_h { |status| [status, 0] }
71
+ ongoing.each { |row| counts[row[:status].to_sym] += row[:runs] if row[:step] == entry[:name] }
72
+ stats ||= { attempts: 0, run_ms: empty_percentiles(%i[p50 p95 p99]), pickup_ms: empty_percentiles(%i[p50 p95]) }
73
+ entry.merge(counts: counts, sample_size: stats[:attempts], outcomes: OUTCOMES.to_h { |key| [key, stats[key].to_i] },
74
+ rates: rates(stats), run_ms: stats[:run_ms], pickup_ms: stats[:pickup_ms])
75
+ end
76
+
77
+ # Shares of the attempts: failed (an error was recorded), reattempted,
78
+ # skipped. nil without attempts.
79
+ def rates(stats)
80
+ attempts = stats[:attempts].to_i
81
+ %i[failed reattempted skipped].to_h do |key|
82
+ [key, attempts.zero? ? nil : (stats[key].to_f / attempts).round(4)]
83
+ end
84
+ end
85
+
86
+ def empty_percentiles(keys) = keys.to_h { |key| [key, nil] }.merge(samples: 0)
87
+
88
+ # Gravestones some ongoing run still sits on: { "reminder" => 3 }.
89
+ def removed_in_flight(klass, ongoing_rows)
90
+ return {} unless klass
91
+
92
+ names = klass.workflow_steps.removed_names
93
+ ongoing_rows.select { |row| names.include?(row[:step]) }
94
+ .each_with_object(Hash.new(0)) { |row, counts| counts[row[:step]] += row[:runs] }.to_h
95
+ end
96
+
97
+ def duration_histogram(histogram)
98
+ bounds = WorkflowInsightsQueries::DURATION_BOUNDS_MS
99
+ (0..bounds.size).map do |bin|
100
+ { from_ms: bin.zero? ? 0 : bounds[bin - 1], to_ms: bounds[bin], count: histogram.fetch(bin, 0) }
101
+ end
102
+ end
103
+
104
+ # Every bucket of the window, oldest first, empty ones as zeros. +from+
105
+ # and +to+ are the real times its wall-clock start and the next
106
+ # bucket's fall on in +zone+ (a day bucket across a DST change is 23 or
107
+ # 25 hours long; a fall-back hour spans two real hours).
108
+ def throughput_buckets(counts, since:, now:, bucket:, zone: nil)
109
+ zone ||= utc_zone
110
+ bucket_starts(since: since, now: now, bucket: bucket, zone: zone).map do |start|
111
+ started, finished, cancelled = counts.fetch(start, [0, 0, 0])
112
+ { from: from_wall(start, zone), to: from_wall(start + bucket, zone), started: started, finished: finished,
113
+ cancelled: cancelled }
114
+ end
115
+ end
116
+
117
+ # The wall-clock epoch seconds (in +zone+) each bucket from +since+ to
118
+ # +now+ starts at: what WorkflowInsightsQueries.throughput keys by.
119
+ # Walked in real time (steps of at most WALK seconds, finer than any DST
120
+ # change), so a spring-forward night has no bucket for the hour it
121
+ # skips and a fall-back night's repeated hour is one bucket.
122
+ def bucket_starts(since:, now:, bucket:, zone: nil)
123
+ zone ||= utc_zone
124
+ moments = (since.to_i..now.to_i).step([bucket, WALK].min).map { |seconds| Time.at(seconds) } << now
125
+ moments.map { |moment| wall_epoch(moment, zone) / bucket * bucket }.uniq.sort
126
+ end
127
+
128
+ # +time+'s wall clock in +zone+, as epoch seconds.
129
+ def wall_epoch(time, zone)
130
+ local = time.in_time_zone(zone)
131
+ local.to_i + local.utc_offset
132
+ end
133
+
134
+ # The real (UTC) time a bucket's wall-clock start falls on in +zone+:
135
+ # the first of the two in a repeated hour, or, when a DST change skips
136
+ # the start itself (a midnight that jumps to 01:00), the first moment
137
+ # of the bucket that exists.
138
+ def from_wall(wall, zone)
139
+ (0...DAY).step(WALK) do |later|
140
+ return zone.tzinfo.local_to_utc(Time.at(wall + later).utc, true).utc
141
+ rescue TZInfo::PeriodNotFound
142
+ next
143
+ end
144
+ Time.at(wall).utc
145
+ end
146
+
147
+ def utc_zone = ActiveSupport::TimeZone["UTC"]
148
+
149
+ def funnel_step(name, reached, stopped)
150
+ { name: name, reached: reached.dig(name, :reached) || 0,
151
+ stopped: STOPPED.to_h { |status| [status, stopped.fetch([name, status.to_s], 0)] } }
152
+ end
153
+
154
+ # One entry per revision (newest first): its totals and its steps in
155
+ # pipeline order.
156
+ def by_revision(rows, order)
157
+ rows.group_by { |row| row[:revision] }.map do |revision, group|
158
+ total = group.find { |row| row[:step].nil? }
159
+ steps = group.select { |row| row[:step] }.sort_by { |row| order.index(row[:step]) || order.size }
160
+ { revision: revision, first_seen_at: total[:first_seen_at], last_seen_at: total[:last_seen_at],
161
+ **revision_numbers(total[:stats]),
162
+ steps: steps.map { |row| { name: row[:step], **revision_numbers(row[:stats]) } } }
163
+ end
164
+ end
165
+
166
+ def revision_numbers(stats)
167
+ { attempts: stats[:attempts], rates: rates(stats), run_ms: stats[:run_ms].slice(:p50, :p95, :samples) }
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Web
5
+ # Workflow arguments, step outputs and signal payloads as the Workflows
6
+ # dashboard and its MCP tools show them (issue #542). They are stored
7
+ # through ActiveJob::Arguments; this reads that stored form without
8
+ # deserializing it, so showing a run never loads its records (a GlobalID
9
+ # shows as its URI) and never runs a custom serializer.
10
+ module WorkflowPayload
11
+ BOOKKEEPING = %w[_aj_symbol_keys _aj_ruby2_keywords _aj_hash_with_indifferent_access].freeze
12
+
13
+ module_function
14
+
15
+ def display(value)
16
+ case value
17
+ when nil then {}
18
+ when Hash then display_hash(value)
19
+ when Array then value.map { |element| element.nil? ? nil : display(element) }
20
+ else value
21
+ end
22
+ end
23
+
24
+ def display_hash(hash)
25
+ return hash["_aj_globalid"] if hash.size == 1 && hash.key?("_aj_globalid")
26
+ if hash.key?("_aj_serialized")
27
+ # A serializer that stores one "value" shows it; one that stores
28
+ # several fields (a Range: begin, end, exclude_end) shows those.
29
+ return hash.key?("value") ? display(hash["value"]) : display_hash(hash.except("_aj_serialized"))
30
+ end
31
+
32
+ hash.except(*BOOKKEEPING).transform_values { |nested| nested.nil? ? nil : display(nested) }
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+ require "active_support/core_ext/object/blank"
6
+ require "active_support/core_ext/time"
7
+
8
+ module Pgbus
9
+ module Web
10
+ # The Workflows list's query string (issue #542), checked: a status from
11
+ # DataSource::WorkflowList::STATUSES, the text filters stripped (blank
12
+ # ones dropped, each at most MAX_LENGTH characters) and the created-at
13
+ # and ended-at (issue #549: finished_at or cancelled_at) ranges parsed in
14
+ # Time.zone (a bare date as the start or the end of that day). Anything
15
+ # else raises Invalid, which the controller answers with 400. #to_param
16
+ # is the same query back, for links that keep it.
17
+ class WorkflowQuery
18
+ class Invalid < StandardError; end
19
+
20
+ TEXT_FILTERS = %i[workflow_class step error_class subject_type subject_id workflow_id].freeze
21
+ DATE_FILTERS = %i[created_from created_to ended_from ended_to].freeze
22
+ MAX_LENGTH = 255
23
+ DATE_ONLY = /\A\d{4}-\d{2}-\d{2}\z/
24
+ # A date (what <input type="date"> sends) or an ISO 8601 date and time.
25
+ DATE_OR_TIME = /\A\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?\z/
26
+
27
+ attr_reader :status, :filters, :raw
28
+
29
+ def initialize(params)
30
+ @status = (params[:status].presence || "ongoing").to_s
31
+ raise Invalid, "unknown status #{@status.inspect}" unless DataSource::WorkflowList::STATUSES.include?(@status)
32
+
33
+ @raw = {}
34
+ @filters = {}
35
+ TEXT_FILTERS.each { |key| text(key, params[key]) }
36
+ DATE_FILTERS.each { |key| date(key, params[key]) }
37
+ end
38
+
39
+ def filtered? = @raw.any?
40
+
41
+ # The query as URL params (status and the filters as given), merged with +extra+.
42
+ def to_param(**extra) = { status: status, **@raw }.merge(extra).compact
43
+
44
+ private
45
+
46
+ def text(key, value)
47
+ value = scalar(key, value)
48
+ return if value.empty?
49
+ raise Invalid, "#{key} is longer than #{MAX_LENGTH} characters" if value.length > MAX_LENGTH
50
+
51
+ @raw[key] = value
52
+ @filters[key] = value
53
+ end
54
+
55
+ def date(key, value)
56
+ value = scalar(key, value)
57
+ return if value.empty?
58
+
59
+ time = parse_time(value) || raise(Invalid, "#{key} #{value.inspect} is not a date")
60
+ time = time.end_of_day if key.end_with?("_to") && value.match?(DATE_ONLY)
61
+ @raw[key] = value
62
+ @filters[key] = time
63
+ end
64
+
65
+ def scalar(key, value)
66
+ return "" if value.nil?
67
+ raise Invalid, "#{key} must be a single value" unless value.is_a?(String)
68
+
69
+ value.strip
70
+ end
71
+
72
+ # Date.valid_date? because Time.zone.parse rolls 2026-02-30 over to March.
73
+ def parse_time(value)
74
+ return nil unless value.match?(DATE_OR_TIME) && Date.valid_date?(*value[0, 10].split("-").map(&:to_i))
75
+
76
+ Time.zone ? Time.zone.parse(value) : Time.parse(value)
77
+ rescue ArgumentError
78
+ nil
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Web
5
+ # The Workflows dashboard's words for one run (issue #542): the badge
6
+ # (its status, or "scheduled" for a ready run whose step waits for a
7
+ # time still ahead) and what it is doing and what happens next, as a
8
+ # pgbus.workflows.next.<key>_html key and its arguments. Arguments named
9
+ # :step, :signal and :batch are names, :ago and :late past moments and
10
+ # :time a future one; the helper renders each. Pure.
11
+ module WorkflowState
12
+ BADGE_TONES = { "ready" => :gray, "scheduled" => :gray, "performing" => :blue, "waiting" => :yellow,
13
+ "paused" => :yellow, "finished" => :green, "cancelled" => :red }.freeze
14
+
15
+ Result = Data.define(:badge, :badge_tone, :next_key, :next_args)
16
+
17
+ module_function
18
+
19
+ # row – a DataSource#workflows row. threshold – how long a due step may
20
+ # wait for a worker before it counts as overdue (workflow_stall_threshold).
21
+ def present(row, threshold:, now: Time.current)
22
+ badge, key, args = derive(row, threshold, now)
23
+ Result.new(badge: badge, badge_tone: BADGE_TONES.fetch(badge, :gray), next_key: key, next_args: args)
24
+ end
25
+
26
+ def derive(row, threshold, now)
27
+ step = row[:current_step]
28
+ case row[:status]
29
+ when "performing" then performing(row, step)
30
+ when "waiting" then waiting(row, step)
31
+ when "paused" then paused(row, step)
32
+ when "ready" then ready(row, step, threshold, now)
33
+ when "finished" then ["finished", "finished", { ago: row[:finished_at] || row[:updated_at] }]
34
+ when "cancelled" then ["cancelled", "cancelled", { ago: row[:cancelled_at] || row[:updated_at] }]
35
+ else [row[:status].to_s, "unknown", { status: row[:status].to_s }]
36
+ end
37
+ end
38
+
39
+ def performing(row, step)
40
+ attempt = row[:current_attempt].to_i
41
+ return ["performing", "performing", { step: step }] if attempt <= 1
42
+
43
+ ["performing", "performing_attempt", { step: step, attempt: attempt }]
44
+ end
45
+
46
+ def waiting(row, step)
47
+ if row[:batch_id]
48
+ progress = row[:batch_progress] || {}
49
+ return ["waiting", "waiting_batch", { batch: batch_name(row), done: progress[:done].to_i, total: progress[:total].to_i }]
50
+ end
51
+ return ["waiting", "waiting_step", { step: step }] unless row[:waiting_for]
52
+
53
+ args = { signal: row[:waiting_for], ago: row[:waiting_since] }
54
+ return ["waiting", "waiting_signal", args] unless row[:timeout_at]
55
+
56
+ signal_deadline(row, args.merge(time: row[:timeout_at]))
57
+ end
58
+
59
+ # An on_timeout: :notify deadline sends a late notice and the wait goes
60
+ # on (issue #565); late_since is the first deadline the wait missed.
61
+ def signal_deadline(row, args)
62
+ # A deploy that changed on_timeout since the last notice: the next
63
+ # deadline does something else (nil: the class is not loaded here).
64
+ late = row[:late_since] && [nil, "notify"].include?(row[:on_timeout]&.to_s)
65
+ return ["waiting", "waiting_signal_late", args.merge(late: row[:late_since])] if late
66
+
67
+ ["waiting", row[:on_timeout].to_s == "notify" ? "waiting_signal_notify" : "waiting_signal_timeout", args]
68
+ end
69
+
70
+ def paused(row, step)
71
+ return ["paused", "paused_error", { step: step, error: row[:error_class] }] if row[:error_class]
72
+
73
+ ["paused", "paused_manual", { step: step }]
74
+ end
75
+
76
+ def ready(row, step, threshold, now)
77
+ due = JobState.time(row[:scheduled_at])
78
+ return ["ready", "ready", { step: step }] unless due
79
+ return ["scheduled", "scheduled", { step: step, time: due }] if due > now
80
+ return ["ready", "overdue", { step: step, ago: due }] if threshold && due < now - threshold
81
+
82
+ ["ready", "due", { step: step, ago: due }]
83
+ end
84
+
85
+ # The fan-out batch as its description names it: "Class#step".
86
+ def batch_name(row) = "#{row[:workflow_class]}##{row[:current_step]}"
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Web
5
+ # Where a workflow run's time went (issue #542), from its step rows and
6
+ # audit trail. Each attempt splits into
7
+ #
8
+ # waited created_at → scheduled_at the step's wait: (or a reattempt's)
9
+ # queued scheduled_at → started_at due, not yet claimed by a worker
10
+ # ran started_at → finished_at the body (up to now while in progress)
11
+ # waiting waiting_since → … parked on a signal or its fan-out batch
12
+ #
13
+ # and a paused run's pauses (paused → resumed, from the events) are their
14
+ # own segments, so a long gap explains itself; each wait_timed_out event
15
+ # is a marker. The show page's step table prints durations; the
16
+ # range-bar chart (charts.js renderTimeline) draws chart. Pure: rows in,
17
+ # hashes out.
18
+ module WorkflowTimeline
19
+ OPEN = %w[scheduled waiting in_progress].freeze
20
+ PAUSE_ENDS = %w[resumed cancelled finished].freeze
21
+ PAUSE_EVENTS = (%w[paused] + PAUSE_ENDS).freeze
22
+ WAIT_TIMED_OUT = "wait_timed_out"
23
+ RAN_SERIES = { "completed" => "ran_ok", "skipped" => "ran_ok", "failed" => "ran_failed",
24
+ "in_progress" => "ran_open", "waiting" => "ran_open" }.freeze
25
+
26
+ module_function
27
+
28
+ # [{ kind:, from:, to: }] in time order. +step+ is a step hash (the
29
+ # data source's) with Time values.
30
+ def segments(step, now:)
31
+ created, scheduled, started, since = step.values_at(:created_at, :scheduled_at, :started_at, :waiting_since)
32
+ ending = step[:finished_at] || (OPEN.include?(step[:status].to_s) ? now : nil)
33
+ pieces = if since && scheduled && since < scheduled
34
+ # A signal step woken by its signal: the row was scheduled
35
+ # again at the wake and claimed a second time.
36
+ [[:waited, created, since], [:waiting, since, scheduled], [:queued, scheduled, started],
37
+ [:ran, started, ending]]
38
+ elsif since
39
+ [[:waited, created, scheduled], [:queued, scheduled, started], [:ran, started, since],
40
+ [:waiting, since, ending]]
41
+ else
42
+ [[:waited, created, scheduled], [:queued, scheduled, started || ending], [:ran, started, ending]]
43
+ end
44
+ clip(pieces, step[:finished_at])
45
+ end
46
+
47
+ # { waited:, queued:, ran:, waiting: } in seconds; nil for a kind the
48
+ # attempt never reached.
49
+ def durations(step, now:)
50
+ totals = segments(step, now: now).group_by { |segment| segment[:kind] }
51
+ %i[waited queued ran waiting].to_h do |kind|
52
+ pieces = totals[kind]
53
+ [kind, pieces&.sum { |segment| (segment[:to] - segment[:from]).to_f }]
54
+ end
55
+ end
56
+
57
+ # [{ from:, to: }]: each "paused" event up to the event that ended it,
58
+ # oldest first; a pause still open runs to now. +events+ in any order.
59
+ def paused_periods(events, status:, now:)
60
+ periods = []
61
+ opened = nil
62
+ events.sort_by { |event| event[:created_at] }.each do |event|
63
+ if event[:event] == "paused"
64
+ opened ||= event[:created_at]
65
+ elsif opened && PAUSE_ENDS.include?(event[:event])
66
+ periods << { from: opened, to: event[:created_at] }
67
+ opened = nil
68
+ end
69
+ end
70
+ periods << { from: opened, to: now } if opened && status.to_s == "paused"
71
+ periods
72
+ end
73
+
74
+ # The chart payload: one row per attempt (oldest first), the paused
75
+ # periods and the wait_timed_out marks (wait_timeouts). Times are epoch milliseconds; each segment carries its
76
+ # series (its colour: ran is coloured by how the attempt ended) and its
77
+ # duration in words for the tooltip.
78
+ def chart(steps, events, status:, now:)
79
+ rows = steps.sort_by { |step| [step[:created_at], step[:attempt].to_i] }.map do |step|
80
+ { label: "#{step[:step_name]} ##{step[:attempt]}", status: step[:status], outcome: step[:outcome],
81
+ error_class: step[:error_class],
82
+ segments: segments(step, now: now).map { |segment| chart_segment(segment, series_for(segment[:kind], step)) } }
83
+ end
84
+ paused = paused_periods(events, status: status, now: now).map do |period|
85
+ chart_segment(period.merge(kind: :paused), "paused")
86
+ end
87
+ { rows: rows, paused: paused, wait_timeouts: wait_timeouts(events) }
88
+ end
89
+
90
+ # [{ at:, step:, action: }] per wait_timed_out event, oldest first, at
91
+ # the deadline it reports (when it went out, without one): the chart
92
+ # draws each as a line across the rows, the page lists them. An
93
+ # on_timeout: :notify wait has one per late notice (issue #565).
94
+ def wait_timeouts(events)
95
+ timed_out = events.select { |event| event[:event] == WAIT_TIMED_OUT }.sort_by { |event| event[:created_at] }
96
+ timed_out.map do |event|
97
+ details = event[:details].to_h
98
+ { at: millis(deadline(details["timeout_at"]) || event[:created_at]),
99
+ step: "#{event[:step_name]} ##{event[:attempt]}", action: details["action"] }
100
+ end
101
+ end
102
+
103
+ def series_for(kind, step)
104
+ return kind.to_s unless kind == :ran
105
+
106
+ RAN_SERIES.fetch(step[:status].to_s, "ran_cancelled")
107
+ end
108
+
109
+ def chart_segment(segment, series)
110
+ { kind: segment[:kind].to_s, series: series, from: millis(segment[:from]), to: millis(segment[:to]),
111
+ duration: TimeFormat.duration(segment[:to] - segment[:from]) }
112
+ end
113
+
114
+ def millis(time) = (time.to_f * 1000).round
115
+
116
+ def deadline(iso) = iso && Time.iso8601(iso)
117
+
118
+ # Drops a piece without both ends or of no length, and ends every piece
119
+ # at the attempt's finished_at (a step cancelled before its scheduled_at).
120
+ def clip(pieces, finished)
121
+ pieces.filter_map do |kind, from, to|
122
+ next unless from && to
123
+
124
+ to = [to, finished].min if finished
125
+ { kind: kind, from: from, to: to } if to > from
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "active_support/core_ext/time"
5
+
6
+ module Pgbus
7
+ module Web
8
+ # The window the workflow insights read (issue #549): 1 h, 24 h (the
9
+ # default), 7 d or 30 d, never longer than config.workflow_retention
10
+ # (finished and cancelled runs older than that are gone, so a longer
11
+ # window would undercount). Each length has its chart bucket, at most 30
12
+ # per window: 5 min, 1 h, 6 h, 1 d; a capped window takes the bucket of
13
+ # the shortest choice that covers it. Anything else raises Invalid (400
14
+ # on the page, an error on the MCP tool).
15
+ class WorkflowWindow
16
+ class Invalid < StandardError; end
17
+
18
+ # name => [seconds, bucket seconds]
19
+ CHOICES = Ractor.make_shareable({ "1h" => [3_600, 300], "24h" => [86_400, 3_600], "7d" => [604_800, 21_600],
20
+ "30d" => [2_592_000, 86_400] })
21
+ DEFAULT = "24h"
22
+
23
+ attr_reader :name, :seconds, :bucket_seconds, :now
24
+
25
+ # +now+ is fixed here, so every read and every link of one page share
26
+ # one window end.
27
+ def self.parse(value, retention: Pgbus.configuration.workflow_retention, now: Time.current)
28
+ name = value.nil? || value == "" ? DEFAULT : value
29
+ raise Invalid, "unknown window #{value.inspect} (one of #{CHOICES.keys.join(", ")})" unless name.is_a?(String) && CHOICES.key?(name)
30
+
31
+ new(name, retention: retention, now: now)
32
+ end
33
+
34
+ def initialize(name, retention:, now: Time.current)
35
+ @name = name
36
+ @now = now
37
+ requested = CHOICES.fetch(name).first
38
+ @seconds = retention ? [requested, retention.to_i].min : requested
39
+ @capped = @seconds < requested
40
+ @bucket_seconds = CHOICES.values.find { |length, _| length >= @seconds }.last
41
+ freeze
42
+ end
43
+
44
+ def capped? = @capped
45
+
46
+ def since(reference = now) = reference - seconds
47
+
48
+ def to_h = { name: name, seconds: seconds, bucket_seconds: bucket_seconds, capped: capped? }
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+
5
+ module Pgbus
6
+ class Workflow
7
+ # The on_complete callback of a fan_out step's batch (issue #541):
8
+ # perform(step_id) resumes the run once every member finished (FanOut).
9
+ # Sent on the step's StepJob queue (config.workflow_queue unless the
10
+ # class or step says otherwise). A callback is never a member of the
11
+ # batch it reports on, and the StepJob it leads to is sent outside any
12
+ # Pgbus.batch block, so neither joins a batch.
13
+ class BatchFinishedJob < ::ActiveJob::Base
14
+ queue_as { Pgbus.configuration.workflow_queue }
15
+
16
+ def perform(step_id)
17
+ FanOut.finished(step_id)
18
+ end
19
+ end
20
+ end
21
+ end