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,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Workflow
5
+ # Which signal a `wait_for:` step takes:
6
+ #
7
+ # step :capture, wait_for: :payment_confirmed do ... end
8
+ # step :ship, wait_for: Pgbus::Workflow::SignalMatcher.new(:shipped) { |payload| payload[:region] == "eu" }
9
+ # step :ship, wait_for: EuShipment.new # any object answering matches?(signal)
10
+ #
11
+ # A matcher runs under the workflow row lock (when the step reaches its
12
+ # gate, and when signal! looks for a waiting step), so it must be fast
13
+ # and free of side effects. It gets a Pgbus::WorkflowSignal whose
14
+ # +payload+ has indifferent access.
15
+ class SignalMatcher
16
+ attr_reader :signal_name, :condition
17
+
18
+ # A name becomes a matcher; a matcher, or anything answering matches?,
19
+ # is kept as it is.
20
+ def self.coerce(value)
21
+ return value if value.respond_to?(:matches?)
22
+ return new(value) if (value.is_a?(Symbol) || value.is_a?(String)) && !value.to_s.strip.empty?
23
+
24
+ raise ArgumentError, "wait_for: takes a signal name, a Pgbus::Workflow::SignalMatcher or an object " \
25
+ "answering matches?(signal), got #{value.inspect}"
26
+ end
27
+
28
+ # What the step row's wait_for column shows: the signal name, else the
29
+ # matcher's class.
30
+ def self.label(matcher)
31
+ return matcher.signal_name.to_s if matcher.respond_to?(:signal_name) && matcher.signal_name
32
+
33
+ matcher.class.name.to_s
34
+ end
35
+
36
+ def initialize(name, &condition)
37
+ raise ArgumentError, "a SignalMatcher needs a signal name, got #{name.inspect}" if name.to_s.strip.empty?
38
+
39
+ @signal_name = -name.to_s.strip
40
+ @condition = condition
41
+ freeze
42
+ end
43
+
44
+ def matches?(signal)
45
+ return false unless signal.name.to_s.strip == signal_name
46
+
47
+ condition.nil? || (condition.call(signal.payload) ? true : false)
48
+ end
49
+
50
+ def to_s = signal_name
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Workflow
5
+ # Signals (issue #541): what a wait_for: step waits for.
6
+ #
7
+ # deliver (signal!) stores the signal under the run's row lock and, when
8
+ # the run's current step is waiting for a signal it matches, claims it
9
+ # for that step and wakes it. Otherwise it stays pending (buffered).
10
+ #
11
+ # gate runs in the StepJob after the claim and the guards, under the same
12
+ # lock: a signal this step already holds (woken by signal!, or claimed by
13
+ # an earlier attempt that reattempted or paused on a failure), else the
14
+ # oldest pending one its matcher takes, else the step parks as waiting.
15
+ #
16
+ # A claimed signal is consumed when its step ends, except when that
17
+ # attempt reattempts or fails into a pause: the next attempt runs with it.
18
+ module Signals
19
+ module_function
20
+
21
+ # Returns [record, signal]. A redelivery with a stored idempotency key
22
+ # returns the stored signal (duplicate_delivery? true) and does nothing
23
+ # else. A blank key is no key: "" would otherwise dedup every signal
24
+ # sent with it against the first.
25
+ def deliver(workflow_id, name, payload:, idempotency_key:, actor:, reason: nil)
26
+ name = signal_name!(name)
27
+ serialized = Payload.dump(payload || {}, what: "payload of signal #{name}")
28
+ key = idempotency_key.to_s.presence
29
+ Runner.locked(workflow_id) do |record, notes|
30
+ raise ActiveRecord::RecordNotFound, "workflow #{workflow_id} is gone" unless record
31
+
32
+ States.check!(:signal, record.status)
33
+ signal = WorkflowSignal.insert_signal!(workflow_id: workflow_id, name: name, payload: serialized, idempotency_key: key)
34
+ signal ||= duplicate(record, key)
35
+ received!(record, signal, actor: actor, reason: reason, notes: notes)
36
+ [record, signal]
37
+ end
38
+ end
39
+
40
+ # The gate of a wait_for: step. True when the body runs (with a signal,
41
+ # or timed out), false when the step parked. Sets received_signal and
42
+ # timed_out? on +workflow+.
43
+ def gate(record, step, definition, workflow, now: Time.current)
44
+ if timed_out?(step, now)
45
+ workflow.send(:enter_wait, signal: nil, timed_out: true)
46
+ return true
47
+ end
48
+
49
+ signal = WorkflowSignal.held_by_step(record.workflow_id, step.step_name) || match(record, definition.wait_for)
50
+ return park(record, step, definition, now) unless signal
51
+
52
+ claim!(record, step, signal, actor: Runner::SYSTEM_ACTOR) if signal.status == "pending"
53
+ step.update_columns(signal_id: signal.id) unless step.signal_id == signal.id
54
+ workflow.send(:enter_wait, signal: signal, timed_out: false)
55
+ true
56
+ end
57
+
58
+ # The step's attempt ended (StepOutcome.apply): consume the signal it
59
+ # holds, unless the next attempt runs with it.
60
+ def settle(step, status, column, now:)
61
+ return unless step.respond_to?(:signal_id) && step.signal_id
62
+ return if column == "reattempted" || (status == "failed" && column == "paused")
63
+
64
+ WorkflowSignal.consume!(step.signal_id, now: now)
65
+ end
66
+
67
+ def signal_name!(name)
68
+ text = name.to_s.strip
69
+ raise ArgumentError, "signal! needs a signal name, got #{name.inspect}" if text.empty?
70
+
71
+ -text
72
+ end
73
+
74
+ def duplicate(record, key)
75
+ WorkflowSignal.find_by!(workflow_id: record.workflow_id, idempotency_key: key).tap do |signal|
76
+ signal.duplicate_delivery = true
77
+ end
78
+ end
79
+
80
+ def received!(record, signal, actor:, notes:, reason: nil)
81
+ duplicate = signal.duplicate_delivery?
82
+ Runner.event!(record, "signal_received", actor: actor, reason: reason,
83
+ details: { name: signal.name, idempotency_key: signal.idempotency_key,
84
+ signal_id: signal.id, duplicate: duplicate }.compact)
85
+ woke = !duplicate && wake_waiting(record, signal, actor: actor)
86
+ notes.instrument("pgbus.workflow.signal",
87
+ Runner.payload_for(record, name: signal.name, duplicate: duplicate, woke: woke))
88
+ end
89
+
90
+ def wake_waiting(record, signal, actor:) # rubocop:disable Naming/PredicateMethod -- true when it woke the step
91
+ step = WorkflowStep.latest_for(record.workflow_id)
92
+ return false unless step&.status == "waiting" && step.batch_id.nil?
93
+
94
+ definition = record.workflow_klass&.workflow_steps&.fetch(step.step_name)
95
+ return false unless definition&.waits_for_signal? && definition.wait_for.matches?(signal)
96
+
97
+ claim!(record, step, signal, actor: actor)
98
+ Waiting.wake!(record, step, now: Time.current, signal_id: signal.id)
99
+ true
100
+ end
101
+
102
+ def claim!(record, step, signal, actor:)
103
+ now = Time.current
104
+ WorkflowSignal.claim!(signal.id, step_id: step.id, now: now)
105
+ signal.assign_attributes(status: "claimed", claimed_by_step_id: step.id, claimed_at: now)
106
+ Runner.event!(record, "signal_claimed", actor: actor, step: step, details: { name: signal.name, signal_id: signal.id })
107
+ end
108
+
109
+ # The oldest pending signal +matcher+ takes. A SignalMatcher narrows the
110
+ # query to its name; any other matcher sees every pending signal.
111
+ def match(record, matcher)
112
+ name = matcher.respond_to?(:signal_name) ? matcher.signal_name : nil
113
+ WorkflowSignal.pending_for(record.workflow_id, name: name).find_each(batch_size: 100) do |signal|
114
+ return signal if matcher.matches?(signal)
115
+ end
116
+ nil
117
+ end
118
+
119
+ def park(record, step, definition, now) # rubocop:disable Naming/PredicateMethod -- the gate's false: the body does not run
120
+ timeout_at = definition.wait_timeout && (now + definition.wait_timeout)
121
+ Waiting.park!(record, step, now: now, wait_for: definition.wait_for_name, timeout_at: timeout_at)
122
+ false
123
+ end
124
+
125
+ # This attempt waited, holds no signal, and its wait_timeout passed: the
126
+ # sweep woke it with on_timeout: :run.
127
+ def timed_out?(step, now)
128
+ !step.waiting_since.nil? && step.signal_id.nil? && !step.timeout_at.nil? && step.timeout_at <= now + 1
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Workflow
5
+ # The one transition table (issue #540). Every verb, the workflow states
6
+ # it is legal from, the state it moves the workflow to, and what it does
7
+ # to the step rows. The runner's guarded UPDATEs take their WHERE status
8
+ # IN (...) from here, available_actions (dashboard buttons, MCP tools)
9
+ # reads it, and the docs page lists it. Nothing else decides whether a
10
+ # transition is legal.
11
+ #
12
+ # Outside verbs are the ones a person or a program calls on a run
13
+ # (console, dashboard, MCP); runner verbs are the StepJob's and the
14
+ # sweep's own moves, never offered as actions.
15
+ module States
16
+ WORKFLOW = %w[ready performing waiting paused finished cancelled].freeze
17
+ ONGOING = %w[ready performing waiting paused].freeze
18
+ TERMINAL = %w[finished cancelled].freeze
19
+ STEP = %w[scheduled waiting in_progress completed failed cancelled skipped].freeze
20
+ OPEN_STEP = %w[scheduled waiting in_progress].freeze
21
+
22
+ # Why an ongoing run needs a person (the sweep's gauge, and the
23
+ # dashboard's needs-attention view later).
24
+ STUCK_REASONS = %w[paused_error stalled paused_manual overdue waiting_overdue].freeze
25
+
26
+ Transition = Data.define(:verb, :from, :to, :outside, :error, :effect)
27
+
28
+ TRANSITIONS = Ractor.make_shareable(
29
+ [
30
+ # Outside verbs
31
+ Transition.new(verb: :pause, from: %w[ready performing waiting], to: "paused", outside: true,
32
+ error: :InvalidTransition,
33
+ effect: "a running step finishes and records its outcome; nothing new is claimed"),
34
+ Transition.new(verb: :resume, from: %w[paused], to: "ready", outside: true, error: :NotPaused,
35
+ effect: "a failed step runs again as a new attempt; a completed or skipped one is " \
36
+ "followed by the next step; a scheduled one is sent again; a waiting one waits again"),
37
+ Transition.new(verb: :resume_at, from: %w[paused], to: "ready", outside: true, error: :NotPaused,
38
+ effect: "the named step runs as a new attempt"),
39
+ Transition.new(verb: :cancel, from: ONGOING, to: "cancelled", outside: true, error: :InvalidTransition,
40
+ effect: "scheduled and in_progress steps are cancelled; a running step's outcome is ignored"),
41
+ Transition.new(verb: :run_now, from: %w[ready], to: "ready", outside: true, error: :InvalidTransition,
42
+ effect: "the scheduled step's wait is cut short and it is sent now"),
43
+ Transition.new(verb: :reschedule, from: %w[ready], to: "ready", outside: true, error: :InvalidTransition,
44
+ effect: "the scheduled step moves to the given time"),
45
+ Transition.new(verb: :rerun, from: TERMINAL, to: "ready", outside: true, error: :InvalidTransition,
46
+ effect: "a new run starts with the same subject and arguments"),
47
+ Transition.new(verb: :skip, from: %w[waiting], to: "ready", outside: true, error: :InvalidTransition,
48
+ effect: "the waiting step is skipped (a signal it holds is consumed) and the next step is " \
49
+ "scheduled, or the run finishes after the last"),
50
+ # signal leaves the state alone unless it wakes a waiting step (wake).
51
+ Transition.new(verb: :signal, from: ONGOING, to: nil, outside: true, error: :NotOngoing,
52
+ effect: "the signal is stored; a waiting step it matches is scheduled again, otherwise it " \
53
+ "stays pending until a step claims it"),
54
+ # Runner verbs
55
+ Transition.new(verb: :claim, from: %w[ready], to: "performing", outside: false, error: :InvalidTransition,
56
+ effect: "the scheduled step moves to in_progress"),
57
+ Transition.new(verb: :advance, from: %w[performing], to: "ready", outside: false, error: :InvalidTransition,
58
+ effect: "the step is completed or skipped and the next step is scheduled"),
59
+ Transition.new(verb: :finish, from: %w[performing paused], to: "finished", outside: false,
60
+ error: :InvalidTransition, effect: "the last step (or one that called finished!) completed"),
61
+ Transition.new(verb: :reattempt, from: %w[performing], to: "ready", outside: false, error: :InvalidTransition,
62
+ effect: "the step failed and its next attempt is scheduled"),
63
+ Transition.new(verb: :step_pause, from: %w[performing], to: "paused", outside: false,
64
+ error: :InvalidTransition,
65
+ effect: "the step completed after pause!, or failed (an exception or a stall)"),
66
+ Transition.new(verb: :step_cancel, from: %w[performing paused], to: "cancelled", outside: false,
67
+ error: :InvalidTransition, effect: "the step called cancel!, or cancel_if or a policy cancelled it"),
68
+ Transition.new(verb: :unpause, from: %w[paused], to: "performing", outside: false, error: :NotPaused,
69
+ effect: "resume! while the step that was running at the outside pause! is still running: " \
70
+ "the run goes on performing it"),
71
+ Transition.new(verb: :hold, from: %w[paused], to: "paused", outside: false, error: :InvalidTransition,
72
+ effect: "an outcome that arrived after an outside pause!: recorded on the step; a " \
73
+ "completed, skipped or reattempted step has its next row scheduled; the workflow " \
74
+ "stays paused"),
75
+ Transition.new(verb: :park, from: %w[performing], to: "waiting", outside: false, error: :InvalidTransition,
76
+ effect: "the step waits for a matching signal or for its fan-out batch; nothing is sent " \
77
+ "until it is woken"),
78
+ Transition.new(verb: :wake, from: %w[waiting], to: "ready", outside: false, error: :InvalidTransition,
79
+ effect: "a matching signal arrived, or the wait timed out with on_timeout: :run: the " \
80
+ "waiting step is scheduled again"),
81
+ Transition.new(verb: :resolve, from: %w[waiting], to: "performing", outside: false,
82
+ error: :InvalidTransition,
83
+ effect: "the fan-out batch finished, the wait timed out, or an operator skipped the step: " \
84
+ "its outcome is applied as for a finished attempt"),
85
+ Transition.new(verb: :rewait, from: %w[paused], to: "waiting", outside: false, error: :NotPaused,
86
+ effect: "resume! on a run paused while its step was waiting: it waits again")
87
+ ].to_h { |transition| [transition.verb, transition] }
88
+ )
89
+
90
+ module_function
91
+
92
+ def fetch(verb)
93
+ TRANSITIONS.fetch(verb.to_sym) { raise ArgumentError, "unknown workflow verb #{verb.inspect}" }
94
+ end
95
+
96
+ def from(verb) = fetch(verb).from
97
+
98
+ def to(verb) = fetch(verb).to
99
+
100
+ def legal?(verb, status) = from(verb).include?(status.to_s)
101
+
102
+ # Returns the target state, or raises the verb's documented error.
103
+ def check!(verb, status)
104
+ transition = fetch(verb)
105
+ return transition.to if transition.from.include?(status.to_s)
106
+
107
+ raise Workflow.const_get(transition.error),
108
+ "cannot #{verb.to_s.tr("_", " ")} a #{status} workflow (allowed from: #{transition.from.join(", ")})"
109
+ end
110
+
111
+ # The outside verbs legal in +status+: the dashboard's buttons and the
112
+ # MCP write tools.
113
+ def available_actions(status)
114
+ state = status.to_s
115
+ TRANSITIONS.each_value.select { |transition| transition.outside && transition.from.include?(state) }.map(&:verb)
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Workflow
5
+ # The workflow step running in this fiber, while its body runs:
6
+ #
7
+ # Pgbus::Workflow::StepContext.workflow_id # => "6f1c…" (nil outside a step)
8
+ # Rails.logger.tagged("wf=#{Pgbus::Workflow::StepContext.workflow_id}") { ... }
9
+ #
10
+ # Not named Current: every constant under Pgbus::Workflow is visible from
11
+ # a workflow subclass's step bodies, and a bare `Current` there must stay
12
+ # the app's ActiveSupport::CurrentAttributes.
13
+ #
14
+ # Fiber-local (Thread.current[] is per fiber), saved and restored around
15
+ # each step so a nested or concurrent step never sees another's context.
16
+ # Pgbus's own log lines inside a step carry workflow=… step=… attempt=…
17
+ # through Pgbus::LogFormatter's context.
18
+ module StepContext
19
+ KEY = :pgbus_workflow_step_context
20
+
21
+ # The running step, as the fiber-local value.
22
+ Frame = Data.define(:workflow_id, :workflow_class, :step_name, :attempt)
23
+
24
+ # Which attempt a workflow instance is running its body for. Set by the
25
+ # StepJob on the fresh instance it runs the body on, never by app code.
26
+ Attempt = Data.define(:step_id, :step_name, :attempt)
27
+
28
+ module_function
29
+
30
+ def with(workflow_id:, workflow_class:, step_name:, attempt:, &)
31
+ previous = Thread.current[KEY]
32
+ Thread.current[KEY] = Frame.new(workflow_id: workflow_id, workflow_class: workflow_class,
33
+ step_name: step_name, attempt: attempt)
34
+ LogFormatter.with_context(workflow: workflow_id, step: step_name, attempt: attempt, &)
35
+ ensure
36
+ Thread.current[KEY] = previous
37
+ end
38
+
39
+ def context = Thread.current[KEY]
40
+
41
+ def workflow_id = context&.workflow_id
42
+
43
+ def workflow_class = context&.workflow_class
44
+
45
+ def step_name = context&.step_name
46
+
47
+ def attempt = context&.attempt
48
+
49
+ # "workflow=… step=… attempt=…" for a log line, or "" outside a step.
50
+ def log_tag
51
+ current = context
52
+ return "" unless current
53
+
54
+ "workflow=#{current.workflow_id} step=#{current.step_name} attempt=#{current.attempt}"
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Workflow
5
+ # One declared step: its name, its body (a block or the name of an
6
+ # instance method) and its options. Built and validated when the class
7
+ # body runs, so a bad option fails at load, not on the first run.
8
+ class StepDefinition
9
+ OPTIONS = %i[wait skip_if job_options timeout on_exception wait_for wait_timeout on_timeout].freeze
10
+ # What a wait_for: step does when its wait_timeout: passes first.
11
+ ON_TIMEOUT = %i[pause! cancel! skip! run notify].freeze
12
+ # The values written without a bang (they do not end the wait as a verb would).
13
+ PLAIN_ON_TIMEOUT = %w[run notify].freeze
14
+ # The StepJob options a class (set_step_job_options) or a step
15
+ # (job_options:) may set. Anything else raises at class load.
16
+ JOB_OPTIONS = %i[queue priority wait wait_until].freeze
17
+
18
+ attr_reader :name, :body, :wait, :skip_if, :job_options, :timeout, :exception_policies,
19
+ :wait_for, :wait_for_name, :wait_timeout, :on_timeout
20
+
21
+ def self.validate_job_options!(options)
22
+ raise ArgumentError, "job_options: must be a Hash, got #{options.inspect}" unless options.is_a?(Hash)
23
+
24
+ options.to_h do |key, value|
25
+ key = key.to_sym
26
+ unless JOB_OPTIONS.include?(key)
27
+ raise ArgumentError,
28
+ "unknown job_options: #{key.inspect} — accepted: #{JOB_OPTIONS.map(&:inspect).join(", ")}"
29
+ end
30
+
31
+ [key, job_option_value(key, value)]
32
+ end.freeze
33
+ end
34
+
35
+ def self.job_option_value(key, value)
36
+ case key
37
+ when :queue
38
+ raise ArgumentError, "job_options: queue must be a queue name, got #{value.inspect}" if value.to_s.strip.empty?
39
+
40
+ -value.to_s
41
+ when :priority
42
+ return value if value.is_a?(Integer)
43
+
44
+ raise ArgumentError, "job_options: priority must be an Integer, got #{value.inspect}"
45
+ when :wait then seconds(value, "job_options: wait", positive: false)
46
+ when :wait_until
47
+ # Time, DateTime and TimeWithZone; not a String, which ActiveSupport
48
+ # also gives #to_time (nil for an unparseable one).
49
+ return value.to_time if value.respond_to?(:to_time) && !value.is_a?(String) && value.to_time
50
+
51
+ raise ArgumentError, "job_options: wait_until must be a Time, got #{value.inspect}"
52
+ else value
53
+ end
54
+ end
55
+
56
+ # Seconds as an Integer when whole, else a Float. Durations are
57
+ # accepted; nil stays nil.
58
+ def self.seconds(value, option, positive:)
59
+ return nil if value.nil?
60
+
61
+ seconds = duration?(value) ? value.to_f : value
62
+ unless seconds.is_a?(Numeric) && seconds.real? && seconds.finite? && (positive ? seconds.positive? : !seconds.negative?)
63
+ requirement = positive ? "a positive" : "a non-negative"
64
+ raise ArgumentError, "#{option}: must be #{requirement} number of seconds or a Duration, got #{value.inspect}"
65
+ end
66
+
67
+ seconds == seconds.to_i ? seconds.to_i : seconds.to_f
68
+ end
69
+
70
+ def self.duration?(value)
71
+ defined?(ActiveSupport::Duration) && value.is_a?(ActiveSupport::Duration)
72
+ end
73
+
74
+ def initialize(name:, body:, wait: nil, skip_if: nil, job_options: {}, timeout: nil, on_exception: nil,
75
+ wait_for: nil, wait_timeout: nil, on_timeout: nil)
76
+ @name = -name.to_s
77
+ @body = body
78
+ @wait = self.class.seconds(wait, "wait", positive: false) || 0
79
+ @skip_if = validate_skip_if(skip_if)
80
+ @job_options = self.class.validate_job_options!(job_options.nil? ? {} : job_options)
81
+ @timeout = self.class.seconds(timeout, "timeout", positive: true)
82
+ @exception_policies = ExceptionPolicy.coerce_all(on_exception)
83
+ assign_wait(wait_for, wait_timeout, on_timeout)
84
+ freeze
85
+ end
86
+
87
+ def kind = "step"
88
+
89
+ # True for a wait_for: step: it parks until a matching signal arrives.
90
+ def waits_for_signal? = !wait_for.nil?
91
+
92
+ def removed? = false
93
+
94
+ # Runs the body on +workflow+; the return value is ignored.
95
+ def invoke(workflow)
96
+ body.is_a?(Symbol) ? workflow.send(body) : workflow.instance_exec(&body)
97
+ nil
98
+ end
99
+
100
+ def skip?(workflow)
101
+ return false if skip_if.nil?
102
+
103
+ result = skip_if.is_a?(Symbol) ? workflow.send(skip_if) : workflow.instance_exec(&skip_if)
104
+ result ? true : false
105
+ end
106
+
107
+ private
108
+
109
+ def assign_wait(wait_for, wait_timeout, on_timeout)
110
+ @wait_for = wait_for.nil? ? nil : SignalMatcher.coerce(wait_for)
111
+ @wait_for_name = @wait_for && SignalMatcher.label(@wait_for)
112
+ raise ArgumentError, "on_timeout: needs wait_for: and wait_timeout:" if !on_timeout.nil? && (@wait_for.nil? || wait_timeout.nil?)
113
+ raise ArgumentError, "wait_timeout: needs wait_for: (it bounds the wait for a signal)" if wait_timeout && @wait_for.nil?
114
+
115
+ @wait_timeout = self.class.seconds(wait_timeout, "wait_timeout", positive: true)
116
+ @on_timeout = @wait_timeout && normalize_on_timeout(on_timeout.nil? ? :pause! : on_timeout)
117
+ end
118
+
119
+ def normalize_on_timeout(value)
120
+ verb = PLAIN_ON_TIMEOUT.include?(value.to_s) ? value.to_sym : :"#{value.to_s.delete_suffix("!")}!"
121
+ return verb if ON_TIMEOUT.include?(verb)
122
+
123
+ raise ArgumentError, "on_timeout: must be one of #{ON_TIMEOUT.map(&:inspect).join(", ")}, got #{value.inspect}"
124
+ end
125
+
126
+ def validate_skip_if(value)
127
+ return value if value.nil? || value.is_a?(Symbol) || value.is_a?(Proc)
128
+
129
+ raise ArgumentError, "skip_if: must be a lambda, a Proc or a method name, got #{value.inspect}"
130
+ end
131
+ end
132
+ end
133
+ end