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,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,9 @@
1
+ import "@hotwired/turbo-rails";
2
+ import LocalTime from "local-time";
3
+ import "rigid_workflow/controllers";
4
+
5
+ LocalTime.start();
6
+
7
+ document.addEventListener("turbo:morph", () => {
8
+ LocalTime.run();
9
+ });
@@ -0,0 +1,7 @@
1
+ import { Application } from "@hotwired/stimulus";
2
+
3
+ const application = Application.start();
4
+ application.debug = false;
5
+ window.Stimulus = application;
6
+
7
+ export { application };
@@ -0,0 +1,4 @@
1
+ import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading";
2
+ import { application } from "rigid_workflow/controllers/application";
3
+
4
+ eagerLoadControllersFrom("rigid_workflow/controllers", application);
@@ -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