rigid_workflow 1.0.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 (44) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +648 -0
  3. data/README.md +427 -0
  4. data/app/assets/stylesheets/rigid_workflow/application.css +68 -0
  5. data/app/controllers/rigid_workflow/application_controller.rb +90 -0
  6. data/app/controllers/rigid_workflow/runs_controller.rb +133 -0
  7. data/app/controllers/rigid_workflow/workflows_controller.rb +11 -0
  8. data/app/helpers/rigid_workflow/runs_helper.rb +63 -0
  9. data/app/helpers/rigid_workflow/workflows_helper.rb +110 -0
  10. data/app/javascript/rigid_workflow/application.js +9 -0
  11. data/app/javascript/rigid_workflow/controllers/application.js +7 -0
  12. data/app/javascript/rigid_workflow/controllers/index.js +4 -0
  13. data/app/javascript/rigid_workflow/controllers/selection_controller.js +91 -0
  14. data/app/javascript/rigid_workflow/controllers/workflow_viz_controller.js +160 -0
  15. data/app/jobs/rigid_workflow/activity_job.rb +14 -0
  16. data/app/jobs/rigid_workflow/timer_job.rb +13 -0
  17. data/app/jobs/rigid_workflow/workflow_job.rb +17 -0
  18. data/app/models/rigid_workflow/run.rb +209 -0
  19. data/app/models/rigid_workflow/signal.rb +77 -0
  20. data/app/models/rigid_workflow/step.rb +182 -0
  21. data/app/models/rigid_workflow/step_attempt.rb +48 -0
  22. data/app/views/rigid_workflow/layouts/application.html.erb +77 -0
  23. data/app/views/rigid_workflow/runs/index.html.erb +113 -0
  24. data/app/views/rigid_workflow/runs/show.html.erb +130 -0
  25. data/app/views/rigid_workflow/workflows/index.html.erb +82 -0
  26. data/config/importmap.rb +11 -0
  27. data/config/routes.rb +17 -0
  28. data/lib/generators/rigid_workflow/activity_generator.rb +15 -0
  29. data/lib/generators/rigid_workflow/install_generator.rb +58 -0
  30. data/lib/generators/rigid_workflow/templates/activity.rb.erb +6 -0
  31. data/lib/generators/rigid_workflow/templates/initializer.rb.erb +24 -0
  32. data/lib/generators/rigid_workflow/templates/migration.rb.erb +54 -0
  33. data/lib/generators/rigid_workflow/templates/workflow.rb.erb +6 -0
  34. data/lib/generators/rigid_workflow/workflow_generator.rb +15 -0
  35. data/lib/rigid_workflow/activity.rb +54 -0
  36. data/lib/rigid_workflow/engine.rb +35 -0
  37. data/lib/rigid_workflow/id_generator.rb +21 -0
  38. data/lib/rigid_workflow/orchestrator.rb +83 -0
  39. data/lib/rigid_workflow/step_result.rb +50 -0
  40. data/lib/rigid_workflow/version.rb +5 -0
  41. data/lib/rigid_workflow/workflow.rb +59 -0
  42. data/lib/rigid_workflow/workflow_runner.rb +334 -0
  43. data/lib/rigid_workflow.rb +65 -0
  44. metadata +204 -0
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "action_view"
5
+
6
+ module RigidWorkflow
7
+ # Represents an execution of a workflow.
8
+ class Run < ::ActiveRecord::Base
9
+ self.table_name = "rigid_workflow_runs"
10
+
11
+ has_many :steps,
12
+ class_name: "RigidWorkflow::Step",
13
+ foreign_key: "rigid_workflow_run_id",
14
+ dependent: :destroy
15
+ has_many :signals,
16
+ class_name: "RigidWorkflow::Signal",
17
+ foreign_key: "rigid_workflow_run_id",
18
+ dependent: :destroy
19
+
20
+ def memory
21
+ super || {}
22
+ end
23
+
24
+ def memory=(value)
25
+ raise "Immutable once finished" if finished?
26
+ super(value)
27
+ end
28
+
29
+ def read_only?
30
+ completed?
31
+ end
32
+
33
+ before_create :generate_uuid
34
+
35
+ enum :status,
36
+ {
37
+ pending: "pending",
38
+ running: "running",
39
+ completed: "completed",
40
+ failed: "failed",
41
+ compensating: "compensating"
42
+ },
43
+ default: :pending
44
+
45
+ OPEN_STATES = %w[pending running compensating].freeze
46
+
47
+ def finished?
48
+ completed? || failed?
49
+ end
50
+
51
+ def duration
52
+ return nil if started_at.nil? || finished_at.nil?
53
+
54
+ finished_at - started_at
55
+ end
56
+
57
+ validates :workflow_class, presence: true
58
+ validates :version,
59
+ presence: true,
60
+ numericality: {
61
+ only_integer: true,
62
+ greater_than: 0
63
+ }
64
+
65
+ def start!
66
+ self.status = :running
67
+ self.started_at = Time.current
68
+ save!(context: :state_change)
69
+ end
70
+
71
+ def finish!
72
+ RigidWorkflow.instrument("workflow.complete", run_id: id) do
73
+ self.status = :completed
74
+ self.finished_at = Time.current
75
+ save!(context: :state_change)
76
+ cleanup_signals!
77
+ end
78
+ end
79
+
80
+ def fail!
81
+ RigidWorkflow.instrument("workflow.fail", run_id: id) do
82
+ self.status = :failed
83
+ save!(context: :state_change)
84
+ cleanup_signals!
85
+ end
86
+ end
87
+
88
+ def compensate!
89
+ # Set status to compensating BEFORE transaction so it persists on failure
90
+ update!(status: :compensating) unless compensating?
91
+
92
+ transaction do
93
+ # Find completed steps in reverse order (last completed = first to compensate)
94
+ steps
95
+ .where(status: :completed)
96
+ .order(id: :desc)
97
+ .find_each do |step|
98
+ activity_class = step.activity_class.constantize
99
+ activity = activity_class.new(step)
100
+ activity.compensate
101
+ step.update!(status: :compensated)
102
+ end
103
+ end
104
+
105
+ # If we get here, compensation succeeded
106
+ update!(status: :failed)
107
+ rescue => error
108
+ # If compensation fails mid-way, run stays :compensating (NOT failed)
109
+ raise error
110
+ end
111
+
112
+ def retry!
113
+ raise "Workflow cannot be retried when #{status}" unless failed? || running?
114
+
115
+ transaction do
116
+ self.status = :running
117
+ save!(context: :state_change)
118
+
119
+ failed_steps = steps.where(status: :failed)
120
+ failed_steps.each do |step|
121
+ step.update!(status: :pending)
122
+ end
123
+ failed_step_ids = failed_steps.pluck(:id)
124
+ failed_step_ids.each do |step_id|
125
+ RigidWorkflow.instrument("step.retry", run_id: id, step_id: step_id)
126
+ end
127
+
128
+ Orchestrator.schedule(self)
129
+ end
130
+ end
131
+
132
+ def cancel!
133
+ raise "Workflow is already finished" if finished?
134
+
135
+ transaction do
136
+ self.status = :failed
137
+ save!(context: :state_change)
138
+ # ActiveJob::Base.limits_concurrency ensures only one ActivityJob
139
+ # runs per step_id at a time, so race condition on attempt
140
+ # creation is not possible for a single step.
141
+ open_steps = steps.where(status: OPEN_STATES).includes(:attempts)
142
+ open_step_ids = open_steps.pluck(:id)
143
+ open_steps.each do |step|
144
+ step.attempts.last&.update!(
145
+ status: :failed,
146
+ error_details: {
147
+ "message" => "canceled"
148
+ }
149
+ )
150
+ step.update!(status: :failed)
151
+ end
152
+ open_step_ids.each do |step_id|
153
+ RigidWorkflow.instrument(
154
+ "step.canceled",
155
+ run_id: id,
156
+ step_id: step_id,
157
+ reason: "canceled"
158
+ )
159
+ end
160
+ cleanup_signals!
161
+ end
162
+ end
163
+
164
+ def self.bulk_apply(ids, action)
165
+ runs = where(id: ids)
166
+ results = { success: [], failed: [] }
167
+
168
+ runs.each do |run|
169
+ begin
170
+ run.send("#{action}!")
171
+ results[:success] << run.id
172
+ rescue StandardError => error
173
+ results[:failed] << { id: run.id, error: error.message }
174
+ end
175
+ end
176
+
177
+ results
178
+ end
179
+
180
+ def cleanup_signals!
181
+ now = Time.current
182
+ signals
183
+ .where(received_at: nil, canceled_at: nil)
184
+ .where("expires_at IS NULL OR expires_at > ?", now)
185
+ .update_all(canceled_at: now)
186
+ end
187
+
188
+ def emit_signal(name, payload = {})
189
+ signal = signals.where(name: name).first!
190
+ RigidWorkflow::Signal.process!(signal, payload)
191
+ end
192
+
193
+ def self.filter_runs(statuses = nil, workflow_class: nil)
194
+ relation = self.where(created_at: 1.month.ago..)
195
+ relation = relation.where(status: statuses) if statuses.present?
196
+ relation =
197
+ relation.where(
198
+ workflow_class: workflow_class
199
+ ) if workflow_class.present?
200
+ relation.order(created_at: :desc)
201
+ end
202
+
203
+ private
204
+
205
+ def generate_uuid
206
+ self.id ||= RigidWorkflow::IdGenerator.uuid_v7
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RigidWorkflow
4
+ # Represents a signal for inter-step communication.
5
+ # Supports waiting with optional timeout and payload exchange.
6
+ class Signal < ::ActiveRecord::Base
7
+ self.table_name = "rigid_workflow_signals"
8
+
9
+ belongs_to :workflow_run,
10
+ class_name: "RigidWorkflow::Run",
11
+ foreign_key: "rigid_workflow_run_id"
12
+
13
+ before_create :generate_uuid
14
+
15
+ scope :pending,
16
+ lambda {
17
+ where(received_at: nil, canceled_at: nil).where(
18
+ "expires_at IS NULL OR expires_at > ?",
19
+ Time.current
20
+ )
21
+ }
22
+ scope :received, -> { where.not(received_at: nil) }
23
+ scope :expired,
24
+ -> do
25
+ where(received_at: nil, canceled_at: nil).where(
26
+ "expires_at <= ?",
27
+ Time.current
28
+ )
29
+ end
30
+ scope :canceled, -> { where.not(canceled_at: nil) }
31
+
32
+ def received?
33
+ received_at.present?
34
+ end
35
+
36
+ def expired?
37
+ expires_at.present? && expires_at <= Time.current
38
+ end
39
+
40
+ def self.process!(signal, payload)
41
+ return if signal.received?
42
+
43
+ signal.update!(received_at: Time.current, payload: payload)
44
+ RigidWorkflow.instrument(
45
+ "signal.received",
46
+ run_id: signal.rigid_workflow_run_id,
47
+ signal_id: signal.id,
48
+ signal_name: signal.name
49
+ )
50
+ Orchestrator.schedule(signal.workflow_run)
51
+ end
52
+
53
+ # Class methods for processing signals.
54
+
55
+ class << self # Processes a signal and resumes the workflow if applicable.
56
+ # @param signal_id [Integer] The signal ID to process
57
+ # @return [Boolean] True if signal was processed successfully
58
+ def process_signal(signal_id)
59
+ signal = RigidWorkflow::Signal.find_by(id: signal_id)
60
+ return false if signal.nil? || signal.received?
61
+
62
+ run = signal.workflow_run
63
+ return false if !run.present? || run.finished?
64
+
65
+ signal.update!(received_at: Time.current)
66
+ RigidWorkflow::Orchestrator.schedule(run)
67
+ true
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def generate_uuid
74
+ self.id ||= RigidWorkflow::IdGenerator.uuid_v7
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RigidWorkflow
4
+ # Represents a single step within a workflow execution.
5
+ # Tracks activity class, status, input/output, and retry attempts.
6
+ class Step < ::ActiveRecord::Base
7
+ self.table_name = "rigid_workflow_steps"
8
+
9
+ belongs_to :workflow_run,
10
+ class_name: "RigidWorkflow::Run",
11
+ foreign_key: "rigid_workflow_run_id"
12
+
13
+ has_many :attempts,
14
+ class_name: "RigidWorkflow::StepAttempt",
15
+ foreign_key: "rigid_workflow_step_id",
16
+ dependent: :destroy
17
+
18
+ before_create :generate_uuid
19
+
20
+ enum :status,
21
+ {
22
+ pending: "pending",
23
+ enqueued: "enqueued",
24
+ running: "running",
25
+ waiting: "waiting",
26
+ completed: "completed",
27
+ failed: "failed",
28
+ compensating: "compensating",
29
+ compensated: "compensated"
30
+ },
31
+ default: :pending
32
+
33
+ validates :step_name, presence: true
34
+ validates :activity_class, presence: true
35
+
36
+ def completed?
37
+ status == "completed"
38
+ end
39
+
40
+ def attempt_count
41
+ attempts.count
42
+ end
43
+
44
+ def current_attempt
45
+ attempts.last
46
+ end
47
+
48
+ def output
49
+ current_attempt&.output || {}
50
+ end
51
+
52
+ def error_details
53
+ current_attempt&.error_details
54
+ end
55
+
56
+ def started_at
57
+ current_attempt&.started_at
58
+ end
59
+
60
+ def finished_at
61
+ current_attempt&.finished_at
62
+ end
63
+
64
+ def failed_at
65
+ current_attempt&.failed_at
66
+ end
67
+
68
+ def start!
69
+ pending_attempt = attempts.where(status: :pending).last
70
+ if pending_attempt
71
+ pending_attempt.update!(
72
+ status: :running,
73
+ started_at: Time.current
74
+ )
75
+ else
76
+ attempts.create!(
77
+ attempt_number: attempts.count + 1,
78
+ status: :running,
79
+ input: input,
80
+ started_at: Time.current
81
+ )
82
+ end
83
+ update!(status: :running)
84
+ end
85
+
86
+ def complete!(output = {})
87
+ RigidWorkflow.instrument(
88
+ "step.complete",
89
+ run_id: rigid_workflow_run_id,
90
+ step_id: id
91
+ ) do
92
+ safe_output =
93
+ if output.nil?
94
+ {}
95
+ elsif !output.is_a?(Hash)
96
+ { result: output }
97
+ else
98
+ output
99
+ end
100
+ current_attempt&.update!(
101
+ status: :completed,
102
+ finished_at: Time.current,
103
+ output: safe_output
104
+ )
105
+ update!(status: :completed)
106
+ end
107
+ end
108
+
109
+ def fail!(error_details = {})
110
+ RigidWorkflow.instrument(
111
+ "step.fail",
112
+ run_id: rigid_workflow_run_id,
113
+ step_id: id
114
+ ) do
115
+ current_attempt&.update!(
116
+ status: :failed,
117
+ failed_at: Time.current,
118
+ error_details: error_details
119
+ )
120
+ update!(status: :failed)
121
+ end
122
+ end
123
+
124
+ # Class methods for executing activities.
125
+ class << self
126
+ # Executes an activity for a given step.
127
+ # @param step_id [Integer] The step ID to execute
128
+ # @return [Object, false] The activity result or false if not runnable
129
+ def execute_activity(step_id)
130
+ step =
131
+ RigidWorkflow::Step.includes(:workflow_run, :attempts).find_by_id(
132
+ step_id
133
+ )
134
+ return false if step.nil? || step.completed?
135
+
136
+ workflow_run = step.workflow_run
137
+ return false unless workflow_run
138
+ return false unless runnable?(workflow_run)
139
+
140
+ step.start!
141
+
142
+ # brakeman:ignore UnsafeReflection
143
+ # This is intentional - activity_class is a validated string from the workflow definition
144
+ activity_class = step.activity_class.constantize
145
+ unless activity_class && activity_class < RigidWorkflow::Activity
146
+ raise ArgumentError, "#{step.activity_class} is not a valid Activity"
147
+ end
148
+
149
+ activity = activity_class.new(step)
150
+
151
+ input = step.input.deep_symbolize_keys
152
+ result = activity.perform(**input)
153
+
154
+ step.complete!(result)
155
+
156
+ RigidWorkflow::Orchestrator.schedule(workflow_run)
157
+ result
158
+ rescue StandardError => error
159
+ step.fail!(
160
+ error: error.class.to_s,
161
+ message: error.message,
162
+ backtrace: error.backtrace&.first(5)
163
+ )
164
+ # Schedule workflow advancement to handle retries or mark workflow as failed (Bug 3 fix)
165
+ if workflow_run && !workflow_run.finished?
166
+ RigidWorkflow::Orchestrator.schedule(workflow_run)
167
+ end
168
+ raise error
169
+ end
170
+
171
+ def runnable?(workflow_run)
172
+ workflow_run.present? && !workflow_run.finished?
173
+ end
174
+ end
175
+
176
+ private
177
+
178
+ def generate_uuid
179
+ self.id ||= RigidWorkflow::IdGenerator.uuid_v7
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RigidWorkflow
4
+ # Represents a single execution attempt of a step.
5
+ # Tracks attempt-specific status, input/output, and error details.
6
+ #
7
+ # Step ↔ StepAttempt Relationship:
8
+ #
9
+ # Step (1) ──── (has_many) ────> StepAttempt (N)
10
+ # - step_name - attempt_number
11
+ # - activity_class - status
12
+ # - input - input
13
+ # - status - output
14
+ # - max_attempts - error_details
15
+ # - scheduled_at - started_at
16
+ # - finished_at
17
+ # - failed_at
18
+ #
19
+ # Query current state: step.current_attempt
20
+ # Query history: step.attempts
21
+ class StepAttempt < ::ActiveRecord::Base
22
+ self.table_name = "rigid_workflow_step_attempts"
23
+
24
+ belongs_to :step,
25
+ class_name: "RigidWorkflow::Step",
26
+ foreign_key: "rigid_workflow_step_id"
27
+
28
+ before_create :generate_uuid
29
+
30
+ enum :status,
31
+ {
32
+ pending: "pending",
33
+ running: "running",
34
+ completed: "completed",
35
+ failed: "failed"
36
+ },
37
+ default: :running
38
+
39
+ validates :rigid_workflow_step_id, presence: true
40
+ validates :attempt_number, presence: true, numericality: { greater_than: 0 }
41
+
42
+ private
43
+
44
+ def generate_uuid
45
+ self.id ||= RigidWorkflow::IdGenerator.uuid_v7
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,77 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title><%= content_for(:title) || "RigidWorkflow Admin" %></title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <meta name="apple-mobile-web-app-capable" content="yes">
7
+ <meta name="application-name" content="RigidWorkflow Admin">
8
+ <meta name="mobile-web-app-capable" content="yes">
9
+ <%= csrf_meta_tags %>
10
+ <%= csp_meta_tag %>
11
+
12
+ <%= yield :head %>
13
+
14
+ <%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %>
15
+ <%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>
16
+
17
+ <link rel="icon" href="/icon.png" type="image/png">
18
+ <link rel="icon" href="/icon.svg" type="image/svg+xml">
19
+ <link rel="apple-touch-icon" href="/icon.png">
20
+
21
+ <%# Includes all stylesheet files %>
22
+ <%= stylesheet_link_tag "rigid_workflow/application", "data-turbo-track": "reload" %>
23
+
24
+ <%# Use blocking script for tailwind and vis-timeline to prevent reflow and repaint %>
25
+ <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4" defer></script>
26
+ <script src="https://cdn.jsdelivr.net/npm/vis-timeline@latest/standalone/umd/vis-timeline-graph2d.min.js" defer></script>
27
+
28
+ <%# Includes all javascript files %>
29
+ <%= javascript_importmap_tags "rigid_workflow/application" %>
30
+ </head>
31
+
32
+ <body class="bg-gray-50 text-gray-900 font-sans leading-normal tracking-normal transition-colors duration-500 min-h-screen">
33
+
34
+ <main class="min-h-screen">
35
+ <div id="flash-messages" class="fixed top-0 left-0 w-full h-full justify-center items-end z-50 pb-10 flex gap-2 pointer-events-none animate-fade-in-up">
36
+ <% if notice %>
37
+ <div class="p-4 bg-green-50 border border-green-300 text-green-700 rounded-2xl shadow-lg shadow-green-100/50">
38
+ <%= notice %>
39
+ </div>
40
+ <% end %>
41
+ <% if alert %>
42
+ <div class="p-4 bg-red-50 border border-red-300 text-red-700 rounded-2xl shadow-lg shadow-red-100/50">
43
+ <%= alert %>
44
+ </div>
45
+ <% end %>
46
+ </div>
47
+
48
+ <div class="flex min-h-screen">
49
+ <aside class="w-64 bg-gray-200/75 border-r border-gray-300">
50
+ <% @sidebar.each do |section| %>
51
+ <div class="m-4 mb-0">
52
+ <h2 class="text-xs font-semibold text-gray-400 uppercase tracking-wider"><%= section[:heading] %></h2>
53
+ </div>
54
+ <nav class="mt-2 px-3 mb-6 text-gray-700 ">
55
+ <% section[:items].each do |item| %>
56
+ <%= link_to item[:path], data: { turbo_frame: "_top" }, class: "nav-item flex justify-between items-center px-3 py-2 rounded-lg text-sm font-medium transition-colors #{'bg-gray-300 text-gray-900' if current_page?(item[:path])}" do %>
57
+ <span><%= item[:name] %></span>
58
+ <% if item[:count] %>
59
+ <span class="ml-2 bg-gray-100 text-gray-500 rounded-full px-2 py-0.5 text-xs font-mono">
60
+ <%= number_with_delimiter(item[:count]) %>
61
+ </span>
62
+ <% end %>
63
+ <% end %>
64
+ <% end %>
65
+ </nav>
66
+ <% end %>
67
+ </aside>
68
+
69
+ <div class="grow">
70
+ <%= yield %>
71
+ </div>
72
+ </div>
73
+ </main>
74
+
75
+ </body>
76
+ </html>
77
+