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,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module RigidWorkflow
|
|
3
|
+
# Manages workflow run CRUD operations and bulk actions.
|
|
4
|
+
# Provides views for listing, filtering, and controlling workflow runs.
|
|
5
|
+
class RunsController < ApplicationController
|
|
6
|
+
helper WorkflowsHelper
|
|
7
|
+
|
|
8
|
+
def index
|
|
9
|
+
@runs = filter_runs.page(page_params)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def pending_runs
|
|
13
|
+
@runs = filter_runs(%i[pending]).page(page_params)
|
|
14
|
+
render :index
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def active_runs
|
|
18
|
+
@runs = filter_runs(%i[running]).page(page_params)
|
|
19
|
+
render :index
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def completed_runs
|
|
23
|
+
@runs = filter_runs(%i[completed]).page(page_params)
|
|
24
|
+
render :index
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def failed_runs
|
|
28
|
+
@runs = filter_runs(%i[failed]).page(page_params)
|
|
29
|
+
render :index
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def show
|
|
33
|
+
@run =
|
|
34
|
+
RigidWorkflow::Run.includes(steps: :attempts, signals: []).find(
|
|
35
|
+
params[:id]
|
|
36
|
+
)
|
|
37
|
+
respond_to do |format|
|
|
38
|
+
format.html
|
|
39
|
+
format.json do
|
|
40
|
+
render json: {
|
|
41
|
+
workflow: @run,
|
|
42
|
+
steps:
|
|
43
|
+
@run
|
|
44
|
+
.steps
|
|
45
|
+
.includes(:attempts)
|
|
46
|
+
.order(:id)
|
|
47
|
+
.map { |s|
|
|
48
|
+
s.attributes.merge(
|
|
49
|
+
attempts:
|
|
50
|
+
s.attempts.order(:attempt_number).map(&:attributes)
|
|
51
|
+
)
|
|
52
|
+
},
|
|
53
|
+
signals: @run.signals.order(:id)
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
ALLOWED_BULK_ACTIONS = %i[retry cancel].freeze
|
|
60
|
+
|
|
61
|
+
def bulk_action
|
|
62
|
+
permitted = bulk_action_params
|
|
63
|
+
return render_error("Invalid action") unless permitted[:action]
|
|
64
|
+
|
|
65
|
+
runs = RigidWorkflow::Run.where(id: permitted[:ids])
|
|
66
|
+
success_count = 0
|
|
67
|
+
errors = []
|
|
68
|
+
|
|
69
|
+
runs.each do |run|
|
|
70
|
+
begin
|
|
71
|
+
case permitted[:action]
|
|
72
|
+
when :retry
|
|
73
|
+
run.retry!
|
|
74
|
+
when :cancel
|
|
75
|
+
run.cancel!
|
|
76
|
+
end
|
|
77
|
+
success_count += 1
|
|
78
|
+
rescue => error
|
|
79
|
+
errors << "#{run.id}: #{error.message}"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
action_label =
|
|
84
|
+
permitted[:action] == :retry ? "retry" : permitted[:action].to_s
|
|
85
|
+
notice =
|
|
86
|
+
"Applied #{action_label} to #{success_count} #{"run".pluralize(success_count)}."
|
|
87
|
+
notice +=
|
|
88
|
+
" #{errors.join(', ')} #{"run".pluralize(errors.size)} failed." if errors.any?
|
|
89
|
+
|
|
90
|
+
# Redirect to show page if single run, otherwise to referrer or list
|
|
91
|
+
if permitted[:ids].size == 1
|
|
92
|
+
redirect_to runs_show_path(permitted[:ids].first), notice: notice
|
|
93
|
+
else
|
|
94
|
+
redirect_to request.referrer || admin_rigid_workflow_runs_path,
|
|
95
|
+
notice: notice
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def bulk_action_params
|
|
102
|
+
permitted = params.permit(:authenticity_token, :bulk_action, ids: [])
|
|
103
|
+
{
|
|
104
|
+
ids: Array(permitted[:ids]).select(&:present?),
|
|
105
|
+
action:
|
|
106
|
+
(
|
|
107
|
+
if ALLOWED_BULK_ACTIONS.include?(permitted[:bulk_action]&.to_sym)
|
|
108
|
+
permitted[:bulk_action].to_sym
|
|
109
|
+
else
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def render_error(message)
|
|
117
|
+
redirect_to request.referrer || admin_rigid_workflow_runs_path,
|
|
118
|
+
alert: message
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def page_params
|
|
122
|
+
page = params[:page].to_i
|
|
123
|
+
page.positive? ? page : 1
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def filter_runs(statuses = nil, workflow_class: nil)
|
|
127
|
+
RigidWorkflow::Run.filter_runs(
|
|
128
|
+
statuses,
|
|
129
|
+
workflow_class: workflow_class || params[:workflow]
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RigidWorkflow
|
|
4
|
+
# Provides workflow statistics and failed workflow reporting.
|
|
5
|
+
# Shows workflow performance metrics and error details.
|
|
6
|
+
class WorkflowsController < ApplicationController
|
|
7
|
+
def index
|
|
8
|
+
@stats = self.class.stats_for_ever
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module RigidWorkflow
|
|
3
|
+
# Serializes workflow runs to JSON for API responses.
|
|
4
|
+
module RunsHelper
|
|
5
|
+
def run_to_json(run)
|
|
6
|
+
{
|
|
7
|
+
workflow: {
|
|
8
|
+
id: run.id,
|
|
9
|
+
workflow_class: run.workflow_class,
|
|
10
|
+
status: run.status,
|
|
11
|
+
version: run.version,
|
|
12
|
+
params: run.params,
|
|
13
|
+
memory: run.memory,
|
|
14
|
+
created_at: run.created_at,
|
|
15
|
+
started_at: run.started_at,
|
|
16
|
+
finished_at: run.finished_at
|
|
17
|
+
},
|
|
18
|
+
steps:
|
|
19
|
+
run.steps.order(created_at: :asc).map { |step| serialize_step(step) },
|
|
20
|
+
signals:
|
|
21
|
+
run
|
|
22
|
+
.signals
|
|
23
|
+
.order(created_at: :asc)
|
|
24
|
+
.map { |signal| serialize_signal(signal) }
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def serialize_step(step)
|
|
31
|
+
attempt = step.current_attempt
|
|
32
|
+
{
|
|
33
|
+
id: step.id,
|
|
34
|
+
step_name: step.step_name,
|
|
35
|
+
activity_class: step.activity_class,
|
|
36
|
+
status: step.status,
|
|
37
|
+
attempt: step.attempt_count,
|
|
38
|
+
max_attempts: step.max_attempts,
|
|
39
|
+
input: step.input,
|
|
40
|
+
output: step.output,
|
|
41
|
+
error_details: step.error_details,
|
|
42
|
+
created_at: step.created_at,
|
|
43
|
+
started_at: attempt&.started_at || step.created_at,
|
|
44
|
+
finished_at: attempt&.finished_at,
|
|
45
|
+
scheduled_at: step.scheduled_at,
|
|
46
|
+
failed_at: attempt&.failed_at
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def serialize_signal(signal)
|
|
51
|
+
{
|
|
52
|
+
id: signal.id,
|
|
53
|
+
name: signal.name,
|
|
54
|
+
payload: signal.payload,
|
|
55
|
+
received_at: signal.received_at,
|
|
56
|
+
canceled_at: signal.canceled_at,
|
|
57
|
+
expires_at: signal.expires_at,
|
|
58
|
+
created_at: signal.created_at,
|
|
59
|
+
updated_at: signal.updated_at
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RigidWorkflow
|
|
4
|
+
# Formats workflow attributes for display in views.
|
|
5
|
+
# Handles identifiers, datetimes, durations, JSON, and status badges.
|
|
6
|
+
module WorkflowsHelper
|
|
7
|
+
IDENTIFIER_KEYS = %w[id workflow_run_id run_id].freeze
|
|
8
|
+
DATETIME_KEYS = %w[created_at updated_at finished_at started_at].freeze
|
|
9
|
+
CLASSNAME_KEYS = %w[
|
|
10
|
+
job_class
|
|
11
|
+
class_name
|
|
12
|
+
activity_class
|
|
13
|
+
workflow_class
|
|
14
|
+
].freeze
|
|
15
|
+
DURATION_KEYS = %w[duration].freeze
|
|
16
|
+
JSON_KEYS = %w[memory params].freeze
|
|
17
|
+
STATUS_KEYS = %w[status].freeze
|
|
18
|
+
|
|
19
|
+
def format_attribute(key, value)
|
|
20
|
+
return format_identifier(value) if IDENTIFIER_KEYS.include?(key)
|
|
21
|
+
return format_datetime(value) if DATETIME_KEYS.include?(key)
|
|
22
|
+
return format_classname(value) if CLASSNAME_KEYS.include?(key)
|
|
23
|
+
return format_duration(value) if DURATION_KEYS.include?(key)
|
|
24
|
+
return format_json(value) if JSON_KEYS.include?(key)
|
|
25
|
+
return format_status(value) if STATUS_KEYS.include?(key)
|
|
26
|
+
format_default(value)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def format_identifier(value)
|
|
30
|
+
return content_tag(:span, "—", class: "text-gray-400") if value.blank?
|
|
31
|
+
content_tag(
|
|
32
|
+
:code,
|
|
33
|
+
value,
|
|
34
|
+
class: "text-xs font-mono text-gray-500 select-all"
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def format_datetime(value)
|
|
39
|
+
return content_tag(:span, "—", class: "text-gray-400") if value.blank?
|
|
40
|
+
content_tag(
|
|
41
|
+
:span,
|
|
42
|
+
local_time(value, format: "%Y-%m-%d %H:%M:%S"),
|
|
43
|
+
class: "text-sm"
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def format_classname(value)
|
|
48
|
+
return content_tag(:span, "—", class: "text-gray-400") if value.blank?
|
|
49
|
+
content_tag(
|
|
50
|
+
:code,
|
|
51
|
+
value,
|
|
52
|
+
class: "text-sm font-mono font-medium text-gray-900 select-all"
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def format_duration(value)
|
|
57
|
+
return content_tag(:span, "—", class: "text-gray-400") if value.blank?
|
|
58
|
+
mins = value > 60 ? "#{(value / 60.0).floor}m" : ""
|
|
59
|
+
secs = (value < 60 ? "%.2gs" : "%.0fs") % (value % 60)
|
|
60
|
+
content_tag(:span, "#{mins}#{secs}", class: "text-sm")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def format_json(value)
|
|
64
|
+
return content_tag(:span, "—", class: "text-gray-400") if value.blank?
|
|
65
|
+
content_tag(
|
|
66
|
+
:pre,
|
|
67
|
+
JSON.pretty_generate(value),
|
|
68
|
+
class: "text-xs max-h-30 overflow-y-auto"
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def format_status(value)
|
|
73
|
+
badge_class =
|
|
74
|
+
case value.to_s.downcase
|
|
75
|
+
when "completed"
|
|
76
|
+
"bg-green-100 text-green-800"
|
|
77
|
+
when "failed", "error", "cancelled"
|
|
78
|
+
"bg-red-100 text-red-800"
|
|
79
|
+
when "pending"
|
|
80
|
+
"bg-yellow-100 text-yellow-800"
|
|
81
|
+
else
|
|
82
|
+
"bg-gray-100 text-gray-800"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
content_tag(
|
|
86
|
+
:span,
|
|
87
|
+
value.to_s.humanize,
|
|
88
|
+
class:
|
|
89
|
+
"inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium #{badge_class}"
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def format_default(value)
|
|
94
|
+
value.to_s.presence || content_tag(:span, "—", class: "text-gray-400")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def run_actions_for(status)
|
|
98
|
+
case status.to_s
|
|
99
|
+
when "pending", "compensating"
|
|
100
|
+
"cancel"
|
|
101
|
+
when "running"
|
|
102
|
+
"retry, cancel"
|
|
103
|
+
when "failed"
|
|
104
|
+
"retry"
|
|
105
|
+
else
|
|
106
|
+
""
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus";
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = [
|
|
5
|
+
"row",
|
|
6
|
+
"actionBar",
|
|
7
|
+
"selectAll",
|
|
8
|
+
"actionForm",
|
|
9
|
+
"action",
|
|
10
|
+
"actionButton",
|
|
11
|
+
"count",
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
toggleAll() {
|
|
15
|
+
const isChecked = this.selectAllTarget.checked;
|
|
16
|
+
this.rowTargets.forEach((cb) => (cb.checked = isChecked));
|
|
17
|
+
this.updateActionBar();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
toggleRow() {
|
|
21
|
+
const allChecked = this.rowTargets.every((cb) => cb.checked);
|
|
22
|
+
const someChecked = this.rowTargets.some((cb) => cb.checked);
|
|
23
|
+
|
|
24
|
+
this.selectAllTarget.checked = allChecked;
|
|
25
|
+
this.selectAllTarget.indeterminate = someChecked && !allChecked;
|
|
26
|
+
this.updateActionBar();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
deselectAll() {
|
|
30
|
+
this.rowTargets.forEach((cb) => (cb.checked = false));
|
|
31
|
+
this.selectAllTarget.checked = false;
|
|
32
|
+
this.selectAllTarget.indeterminate = false;
|
|
33
|
+
this.updateActionBar();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
updateActionBar() {
|
|
37
|
+
const selectedRows = this.rowTargets.filter((cb) => cb.checked);
|
|
38
|
+
const hasSelection = selectedRows.length > 0;
|
|
39
|
+
|
|
40
|
+
if (hasSelection) {
|
|
41
|
+
this.actionBarTarget.classList.remove("hidden");
|
|
42
|
+
} else {
|
|
43
|
+
this.actionBarTarget.classList.add("hidden");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
this.actionButtonTargets.forEach((btn) => {
|
|
47
|
+
const type = btn.dataset.type;
|
|
48
|
+
let count = 0;
|
|
49
|
+
selectedRows.forEach((cb) => {
|
|
50
|
+
const actions = cb
|
|
51
|
+
.closest("tr")
|
|
52
|
+
.dataset.actions.split(",")
|
|
53
|
+
.map((a) => a.trim());
|
|
54
|
+
if (actions.includes(type)) count++;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
btn.querySelector('[data-selection-target="count"]').textContent = count;
|
|
58
|
+
btn.disabled = count === 0;
|
|
59
|
+
btn.classList.toggle("opacity-50", count === 0);
|
|
60
|
+
btn.classList.toggle("cursor-not-allowed", count === 0);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
submitBulk(event) {
|
|
65
|
+
if (event.currentTarget.disabled) return;
|
|
66
|
+
this.actionTarget.value = event.currentTarget.dataset.type;
|
|
67
|
+
|
|
68
|
+
// Collect IDs from checked checkboxes
|
|
69
|
+
const ids = new Set();
|
|
70
|
+
this.rowTargets.forEach(cb => {
|
|
71
|
+
if (cb.checked) ids.add(cb.value);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Include IDs from existing hidden fields (for single-item views like show page)
|
|
75
|
+
this.actionFormTarget.querySelectorAll('input[name="ids[]"]').forEach(el => {
|
|
76
|
+
ids.add(el.value);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Rebuild ids[] inputs
|
|
80
|
+
this.actionFormTarget.querySelectorAll('input[name="ids[]"]').forEach(el => el.remove());
|
|
81
|
+
ids.forEach(id => {
|
|
82
|
+
const input = document.createElement('input');
|
|
83
|
+
input.type = 'hidden';
|
|
84
|
+
input.name = 'ids[]';
|
|
85
|
+
input.value = id;
|
|
86
|
+
this.actionFormTarget.appendChild(input);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
this.actionFormTarget.submit();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus";
|
|
2
|
+
|
|
3
|
+
// vis-timeline standalone build loads into window.vis
|
|
4
|
+
const { DataSet, Timeline } = window.vis;
|
|
5
|
+
|
|
6
|
+
function formatDuration(duration) {
|
|
7
|
+
if (duration > 3600 * 1000) {
|
|
8
|
+
return `${(duration / (3600 * 1000)).toFixed(0)}h`;
|
|
9
|
+
} else if (duration > 60 * 1000) {
|
|
10
|
+
return `${(duration / (60 * 1000)).toFixed(0)}m`;
|
|
11
|
+
} else if (duration >= 1000) {
|
|
12
|
+
return `${(duration / 1000).toFixed(0)}s`;
|
|
13
|
+
} else {
|
|
14
|
+
return `${duration}ms`;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default class extends Controller {
|
|
19
|
+
static targets = ["chart", "listItemTemplate"];
|
|
20
|
+
static values = { runId: String };
|
|
21
|
+
|
|
22
|
+
async connect() {
|
|
23
|
+
try {
|
|
24
|
+
const response = await fetch(`/admin/rigid_workflow/runs/${this.runIdValue}.json`);
|
|
25
|
+
const data = await response.json();
|
|
26
|
+
this.workflowData = data;
|
|
27
|
+
this.render(data);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
console.error("Failed to load workflow run:", error);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
backgroundForStatus(status) {
|
|
34
|
+
switch (status) {
|
|
35
|
+
case "completed":
|
|
36
|
+
return "!bg-green-100 !border-green-300";
|
|
37
|
+
case "pending":
|
|
38
|
+
return "!bg-yellow-100 !border-yellow-300";
|
|
39
|
+
case "failed":
|
|
40
|
+
return "!bg-red-100 !border-red-300";
|
|
41
|
+
default:
|
|
42
|
+
return "!bg-gray-100 !border-gray-300";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
render(data) {
|
|
47
|
+
this.workflowData = data;
|
|
48
|
+
this.steps = data.steps;
|
|
49
|
+
this.signals = data.signals;
|
|
50
|
+
|
|
51
|
+
const items = new DataSet();
|
|
52
|
+
const startDate = new Date(data.workflow.started_at);
|
|
53
|
+
const runEndDate = data.workflow.finished_at
|
|
54
|
+
? new Date(data.workflow.finished_at)
|
|
55
|
+
: new Date(data.workflow.updated_at);
|
|
56
|
+
|
|
57
|
+
const duration = runEndDate - startDate;
|
|
58
|
+
const duration_suffix = formatDuration(duration);
|
|
59
|
+
|
|
60
|
+
// Workflow overall
|
|
61
|
+
items.add({
|
|
62
|
+
id: data.workflow.id,
|
|
63
|
+
content: `<div class="flex items-center justify-between gap-2"><span>${data.workflow.workflow_class} <i>${duration_suffix}</i></span></div>`,
|
|
64
|
+
start: startDate,
|
|
65
|
+
end: runEndDate,
|
|
66
|
+
type: "range",
|
|
67
|
+
selectable: false,
|
|
68
|
+
className: `timeline-status-${data.workflow.status} ${this.backgroundForStatus(data.workflow.status)} !rounded !shadow-sm !font-medium !text-sm !text-gray-900`,
|
|
69
|
+
order: data.workflow.id,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Steps - render individual attempts
|
|
73
|
+
data.steps.forEach((step) => {
|
|
74
|
+
if (step.attempts && step.attempts.length > 0) {
|
|
75
|
+
step.attempts.forEach((attempt) => {
|
|
76
|
+
const startDate = new Date(attempt.started_at || step.started_at || step.created_at);
|
|
77
|
+
const endDate = new Date(
|
|
78
|
+
attempt.finished_at || attempt.failed_at || step.finished_at || +runEndDate,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const attempt_suffix = step.attempts.length > 1 ? ` (#${attempt.attempt_number})` : "";
|
|
82
|
+
const duration = endDate - startDate;
|
|
83
|
+
const duration_suffix = formatDuration(duration);
|
|
84
|
+
|
|
85
|
+
items.add({
|
|
86
|
+
id: attempt.id,
|
|
87
|
+
content: `<div class="flex items-center justify-between gap-2"><span>${step.step_name}${attempt_suffix}</span><span class="text-xs text-gray-500 ml-2"> <i>${duration_suffix}</i></span></div>`,
|
|
88
|
+
start: startDate,
|
|
89
|
+
end: endDate,
|
|
90
|
+
type: "range",
|
|
91
|
+
className: `timeline-status-${attempt.status} cursor-pointer ${this.backgroundForStatus(attempt.status)} !rounded !shadow-sm !font-medium !text-sm !text-gray-900`,
|
|
92
|
+
order: step.id + attempt.attempt_number * 0.001,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
} else {
|
|
96
|
+
// Fallback: render step if no attempts
|
|
97
|
+
const startDate = new Date(step.started_at || step.created_at);
|
|
98
|
+
const endDate = new Date(step.finished_at || +runEndDate);
|
|
99
|
+
|
|
100
|
+
const duration = endDate - startDate;
|
|
101
|
+
const duration_suffix = formatDuration(duration);
|
|
102
|
+
|
|
103
|
+
items.add({
|
|
104
|
+
id: step.id,
|
|
105
|
+
content: `<div class="flex items-center justify-between gap-2"><span>${step.step_name} <i>${duration_suffix}</i></span></div>`,
|
|
106
|
+
start: startDate,
|
|
107
|
+
end: endDate,
|
|
108
|
+
type: "range",
|
|
109
|
+
className: `timeline-status-${step.status} cursor-pointer !bg-blue-100 !border-blue-300 !rounded !shadow-sm !font-medium !text-sm !text-gray-900`,
|
|
110
|
+
order: step.id,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Signals
|
|
116
|
+
data.signals.forEach((signal, index) => {
|
|
117
|
+
const startDate = new Date(signal.created_at);
|
|
118
|
+
const endDate = new Date(
|
|
119
|
+
signal.received_at || signal.expires_at || signal.canceled_at || +runEndDate,
|
|
120
|
+
);
|
|
121
|
+
const isTimer = !!signal.expires_at;
|
|
122
|
+
|
|
123
|
+
// Determine status for signal/timer
|
|
124
|
+
let signalStatus = "pending";
|
|
125
|
+
if (signal.canceled_at) {
|
|
126
|
+
signalStatus = "canceled";
|
|
127
|
+
} else if (signal.received_at) {
|
|
128
|
+
signalStatus = "completed";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
items.add({
|
|
132
|
+
id: signal.id,
|
|
133
|
+
content: `<div class="flex items-center justify-between gap-2"><span>${signal.name}</span></div>`,
|
|
134
|
+
start: startDate,
|
|
135
|
+
end: endDate,
|
|
136
|
+
type: "range",
|
|
137
|
+
className: `timeline-status-${signalStatus} cursor-pointer ${signal.canceled_at ? "!bg-gray-100 !border-gray-300" : isTimer ? "!bg-yellow-100 !border-yellow-300" : "!bg-purple-100 !border-purple-300"} !rounded !shadow-sm !font-medium !text-sm !text-gray-900`,
|
|
138
|
+
order: signal.id,
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const options = {
|
|
143
|
+
orientation: "top",
|
|
144
|
+
stack: true,
|
|
145
|
+
showCurrentTime: false,
|
|
146
|
+
min: startDate - 0.1 * duration,
|
|
147
|
+
max: runEndDate - 0.2 * -duration,
|
|
148
|
+
zoomMax: duration * 1.3,
|
|
149
|
+
zoomMin: 500,
|
|
150
|
+
zoomKey: "shiftKey",
|
|
151
|
+
horizontalScroll: true,
|
|
152
|
+
verticalScroll: true,
|
|
153
|
+
minHeight: 250,
|
|
154
|
+
order: (a, b) => a.order - b.order,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
this.chartTarget.innerHTML = "";
|
|
158
|
+
const timeline = new Timeline(this.chartTarget, items, options);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RigidWorkflow
|
|
4
|
+
# Executes a workflow activity.
|
|
5
|
+
# Runs on the :activities queue.
|
|
6
|
+
class ActivityJob < ::ActiveJob::Base
|
|
7
|
+
queue_as :activities
|
|
8
|
+
limits_concurrency group: "rigid_workflow_step", key: ->(step_id, *) { step_id }
|
|
9
|
+
|
|
10
|
+
def perform(step_id)
|
|
11
|
+
Step.execute_activity(step_id)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RigidWorkflow
|
|
4
|
+
# Processes timed signals that have expired.
|
|
5
|
+
# Runs on the :timers queue.
|
|
6
|
+
class TimerJob < ::ActiveJob::Base
|
|
7
|
+
queue_as :timers
|
|
8
|
+
|
|
9
|
+
def perform(signal_id)
|
|
10
|
+
Signal.process_signal(signal_id)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RigidWorkflow
|
|
4
|
+
# Executes workflow advancement by scheduling the next iteration.
|
|
5
|
+
# Runs on the :workflows queue with per-run concurrency limits.
|
|
6
|
+
class WorkflowJob < ::ActiveJob::Base
|
|
7
|
+
queue_as :workflows
|
|
8
|
+
limits_concurrency group: "rigid_workflow", key: ->(run_id, *) { run_id }
|
|
9
|
+
|
|
10
|
+
def perform(run_id)
|
|
11
|
+
workflow_run = RigidWorkflow::Run.find_by(id: run_id)
|
|
12
|
+
return if workflow_run.nil? || workflow_run.finished?
|
|
13
|
+
|
|
14
|
+
RigidWorkflow::Orchestrator.advance!(workflow_run)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|