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.
- checksums.yaml +7 -0
- data/LICENSE.txt +648 -0
- data/README.md +427 -0
- data/app/assets/stylesheets/rigid_workflow/application.css +68 -0
- data/app/controllers/rigid_workflow/application_controller.rb +90 -0
- data/app/controllers/rigid_workflow/runs_controller.rb +133 -0
- data/app/controllers/rigid_workflow/workflows_controller.rb +11 -0
- data/app/helpers/rigid_workflow/runs_helper.rb +63 -0
- data/app/helpers/rigid_workflow/workflows_helper.rb +110 -0
- data/app/javascript/rigid_workflow/application.js +9 -0
- data/app/javascript/rigid_workflow/controllers/application.js +7 -0
- data/app/javascript/rigid_workflow/controllers/index.js +4 -0
- data/app/javascript/rigid_workflow/controllers/selection_controller.js +91 -0
- data/app/javascript/rigid_workflow/controllers/workflow_viz_controller.js +160 -0
- data/app/jobs/rigid_workflow/activity_job.rb +14 -0
- data/app/jobs/rigid_workflow/timer_job.rb +13 -0
- data/app/jobs/rigid_workflow/workflow_job.rb +17 -0
- data/app/models/rigid_workflow/run.rb +209 -0
- data/app/models/rigid_workflow/signal.rb +77 -0
- data/app/models/rigid_workflow/step.rb +182 -0
- data/app/models/rigid_workflow/step_attempt.rb +48 -0
- data/app/views/rigid_workflow/layouts/application.html.erb +77 -0
- data/app/views/rigid_workflow/runs/index.html.erb +113 -0
- data/app/views/rigid_workflow/runs/show.html.erb +130 -0
- data/app/views/rigid_workflow/workflows/index.html.erb +82 -0
- data/config/importmap.rb +11 -0
- data/config/routes.rb +17 -0
- data/lib/generators/rigid_workflow/activity_generator.rb +15 -0
- data/lib/generators/rigid_workflow/install_generator.rb +58 -0
- data/lib/generators/rigid_workflow/templates/activity.rb.erb +6 -0
- data/lib/generators/rigid_workflow/templates/initializer.rb.erb +24 -0
- data/lib/generators/rigid_workflow/templates/migration.rb.erb +54 -0
- data/lib/generators/rigid_workflow/templates/workflow.rb.erb +6 -0
- data/lib/generators/rigid_workflow/workflow_generator.rb +15 -0
- data/lib/rigid_workflow/activity.rb +54 -0
- data/lib/rigid_workflow/engine.rb +35 -0
- data/lib/rigid_workflow/id_generator.rb +21 -0
- data/lib/rigid_workflow/orchestrator.rb +83 -0
- data/lib/rigid_workflow/step_result.rb +50 -0
- data/lib/rigid_workflow/version.rb +5 -0
- data/lib/rigid_workflow/workflow.rb +59 -0
- data/lib/rigid_workflow/workflow_runner.rb +334 -0
- data/lib/rigid_workflow.rb +65 -0
- 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
|
+
|