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,113 @@
1
+ <div class="p-6 space-y-6">
2
+ <div class="flex items-center justify-between">
3
+ <div>
4
+ <%= link_to overview_path, class: "text-sm text-gray-500 hover:text-gray-700 mb-2 block" do %>
5
+ Rigid Workflow »
6
+ <% end %>
7
+ <h1 class="text-3xl font-bold text-gray-900"><%= action_name.humanize %></h1>
8
+ </div>
9
+ </div>
10
+
11
+ <div class="flex items-center justify-between my-4">
12
+ <div class="text-sm text-gray-500"><%= page_entries_info @runs %></div>
13
+
14
+ <% if @runs.total_count > @runs.size %>
15
+ <div class="text-sm">
16
+ <%= paginate @runs %>
17
+ </div>
18
+ <% end %>
19
+ </div>
20
+
21
+ <div class="bg-white rounded-xl border border-gray-200 overflow-hidden relative" data-controller="selection">
22
+ <%= form_with url: runs_bulk_action_path, method: :post, data: { selection_target: "actionForm" } do |f| %>
23
+ <!-- Floating Action Bar -->
24
+ <div data-selection-target="actionBar" class="hidden absolute mt-1 ml-10 flex items-center shadow-lg border border-gray-200 rounded-lg overflow-hidden bg-white z-10">
25
+ <%= hidden_field_tag :bulk_action, "", data: { selection_target: "action" } %>
26
+ <div class="flex items-center divide-x divide-gray-200">
27
+ <button type="button" data-action="click->selection#submitBulk" data-type="retry" data-selection-target="actionButton" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 flex items-center gap-2">
28
+ Retry <span data-selection-target="count" data-type="retry" class="ml-1 px-1.5 py-0.5 text-xs bg-gray-200 rounded-full">0</span>
29
+ </button>
30
+ <button type="button" data-action="click->selection#submitBulk" data-type="cancel" data-selection-target="actionButton" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 flex items-center gap-2">
31
+ Cancel <span data-selection-target="count" data-type="cancel" class="ml-1 px-1.5 py-0.5 text-xs bg-gray-200 rounded-full">0</span>
32
+ </button>
33
+ <button type="button" disabled class="hidden px-4 py-2 text-sm font-medium text-gray-400 bg-gray-100 flex items-center gap-2" data-selection-target="noActions">
34
+ No actions
35
+ </button>
36
+ <button type="button" data-action="click->selection#deselectAll" class="px-2 py-2 text-gray-400 hover:text-gray-600">
37
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
38
+ </button>
39
+ </div>
40
+ </div>
41
+
42
+ <div class="overflow-x-auto">
43
+ <table class="w-full">
44
+ <thead class="bg-gray-50 border-b border-gray-200 ">
45
+ <tr>
46
+ <th class="px-4 py-3 text-left"><input type="checkbox" data-selection-target="selectAll" data-action="change->selection#toggleAll" class="cursor-default rounded border-gray-300 text-blue-600 focus:ring-blue-500" <%= 'disabled' if @runs.empty? %>></th>
47
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Run ID</th>
48
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Workflow</th>
49
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
50
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Iters</th>
51
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Started</th>
52
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Duration</th>
53
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th>
54
+ </tr>
55
+ </thead>
56
+ <tbody class="bg-white divide-y divide-gray-200">
57
+ <% if @runs.any? %>
58
+ <% @runs.each do |run| %>
59
+ <tr class="cursor-pointer hover:bg-gray-50" data-actions="<%= run_actions_for(run.status) %>" data-id="<%= run.id %>" data-url="<%= runs_show_path(run) %>">
60
+ <td class="px-4 py-4 text-left"><input type="checkbox" name="ids[]" data-selection-target="row" data-action="change->selection#toggleRow" class="cursor-default row-checkbox rounded border-gray-300 text-blue-600 focus:ring-blue-500" value="<%= run.id %>"></td>
61
+ <td class="px-4 py-4 whitespace-nowrap text-xs font-mono text-gray-500 select-all"><%= run.id.to_s[0..12] %></td>
62
+ <td class="px-4 py-4 whitespace-nowrap text-sm font-medium text-gray-900 select-all"><%= run.workflow_class %></td>
63
+ <td class="px-4 py-4 whitespace-nowrap">
64
+ <span class="<%= {
65
+ 'pending' => 'bg-gray-100 text-gray-700',
66
+ 'running' => 'bg-blue-100 text-blue-700',
67
+ 'completed' => 'bg-green-100 text-green-700',
68
+ 'failed' => 'bg-red-100 text-red-700',
69
+ 'compensating' => 'bg-orange-100 text-orange-700'
70
+ }[run.status.to_s] || 'bg-gray-100 text-gray-700' %> px-2 py-1 rounded-full text-xs font-medium capitalize"><%= run.status %></span>
71
+ </td>
72
+ <td class="px-4 py-4 whitespace-nowrap text-sm text-gray-500"><%= run.iterations %></td>
73
+ <td class="px-4 py-4 whitespace-nowrap text-sm text-gray-500"><%= format_datetime(run.started_at) %></td>
74
+ <td class="px-4 py-4 whitespace-nowrap text-sm text-gray-500"><%= format_duration(run.duration) %></td>
75
+ <td class="px-4 py-4 whitespace-nowrap text-sm text-gray-500"><%= relative_time_in_words(run.created_at) %></td>
76
+ </tr>
77
+ <% end %>
78
+ <% else %>
79
+ <tr>
80
+ <td colspan="9" class="px-4 py-4 text-sm text-center text-gray-500">Nothing to see</td>
81
+ </tr>
82
+ <% end %>
83
+ </tbody>
84
+ </table>
85
+ </div>
86
+ <% end %>
87
+ </div>
88
+
89
+ <% if @runs.total_count >= 20 %>
90
+ <div class="flex items-center justify-between my-4">
91
+ <div class="text-sm text-gray-500"><%= page_entries_info @runs %></div>
92
+
93
+ <% if @runs.total_count > @runs.size %>
94
+ <div class="text-sm">
95
+ <%= paginate @runs %>
96
+ </div>
97
+ <% end %>
98
+ </div>
99
+ <% end %>
100
+ </div>
101
+
102
+ <script>
103
+ document.addEventListener("turbo:load", () => {
104
+ const rows = document.querySelectorAll("tr[data-url]");
105
+ rows.forEach(row => {
106
+ row.addEventListener("click", (e) => {
107
+ if (e.target.type !== 'checkbox') {
108
+ window.location.href = row.dataset.url;
109
+ }
110
+ });
111
+ });
112
+ });
113
+ </script>
@@ -0,0 +1,130 @@
1
+ <div class="p-6 space-y-6">
2
+ <div class="flex items-center justify-between">
3
+ <div>
4
+ <%= link_to overview_path, class: "text-sm text-gray-500 hover:text-gray-700 mb-2 block" do %>
5
+ Rigid Workflow »
6
+ <% end %>
7
+ <h1 class="text-3xl font-bold text-gray-900"><%= @run.workflow_class %> <%= content_tag(:code, @run.id.to_s[0..12], class:"font-mono select-all") %></h1>
8
+ </div>
9
+ <div class="flex items-center" data-controller="selection">
10
+ <%= form_with url: runs_bulk_action_path, method: :post, data: { selection_target: "actionForm" }, class: "inline" do |f| %>
11
+ <% available_actions = run_actions_for(@run.status).split(",").map(&:strip) %>
12
+ <%= hidden_field_tag :bulk_action, "", data: { selection_target: "action" } %>
13
+ <%= hidden_field_tag "ids[]", @run.id, data: { selection_target: "actionIds" } %>
14
+ <div class="flex items-center shadow-lg border border-gray-200 rounded-lg overflow-hidden">
15
+ <button type="button" data-action="click->selection#submitBulk" data-type="retry" data-selection-target="actionButton" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 border-r border-gray-200 <%= 'opacity-50 cursor-not-allowed' unless available_actions.include?("retry") %>" <%= "disabled" unless available_actions.include?("retry") %>>
16
+ Retry
17
+ </button>
18
+ <button type="button" data-action="click->selection#submitBulk" data-type="cancel" data-selection-target="actionButton" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 <%= 'opacity-50 cursor-not-allowed' unless available_actions.include?("cancel") %>" <%= "disabled" unless available_actions.include?("cancel") %>>
19
+ Cancel
20
+ </button>
21
+ </div>
22
+ <% end %>
23
+ </div>
24
+ </div>
25
+
26
+ <div class="bg-white p-4 border border-gray-300 rounded-lg">
27
+ <h2 class="text-lg font-semibold text-gray-900 mb-4">Run Details</h2>
28
+ <div class="grid grid-flow-col grid-rows-4 gap-4">
29
+ <% %w[created_at started_at finished_at updated_at duration id workflow_class version params status iterations].each do |key| %>
30
+ <%
31
+ value = if @run.respond_to?(key)
32
+ @run.send(key)
33
+ elsif @run.attributes.key?(key)
34
+ @run.attributes[key]
35
+ else
36
+ @run.try(key.to_sym)
37
+ end
38
+ %>
39
+ <% next if value.nil? %>
40
+ <div class="space-y-1">
41
+ <p class="text-xs font-medium text-gray-400 uppercase"><%= key.humanize %></p>
42
+ <p class="text-sm text-gray-800">
43
+ <%= format_attribute(key, value) %>
44
+ </p>
45
+ </div>
46
+ <% end %>
47
+ </div>
48
+ </div>
49
+
50
+ <div class="bg-white p-4 border border-gray-300 rounded-lg">
51
+ <h2 class="text-lg font-semibold text-gray-900 mb-4">Steps History</h2>
52
+ <% if @run.steps.any? %>
53
+ <% @run.steps.order(:id).each do |step| %>
54
+ <div class="mb-2 pb-2 border-b border-gray-100 last:border-0">
55
+ <div class="flex items-center justify-between">
56
+ <h3 class="font-medium text-gray-900">
57
+ <%= step.step_name %>
58
+ <span class="text-sm font-normal text-gray-500 ml-2"><%= step.activity_class %></span>
59
+ </h3>
60
+ <span class="<%= step.completed? ? 'bg-green-100 text-green-700' : step.failed? ? 'bg-red-100 text-red-700' : 'bg-blue-100 text-blue-700' %> px-2 py-1 rounded-full text-xs font-medium capitalize">
61
+ <%= step.status %>
62
+ </span>
63
+ </div>
64
+
65
+ <% if step.attempts.any? %>
66
+ <div class="mt-2 space-y-1">
67
+ <% step.attempts.order(:attempt_number).each do |attempt| %>
68
+ <div class="ml-4 px-3 py-1.5 bg-gray-50 rounded text-xs leading-tight">
69
+ <div>
70
+ <span class="font-medium">Attempt #<%= attempt.attempt_number %></span>
71
+ <span class="<%= attempt.completed? ? 'text-green-600' : attempt.failed? ? 'text-red-600' : 'text-blue-600' %> ml-2"><%= attempt.status %></span>
72
+ <% if attempt.started_at %>
73
+ <span class="text-gray-400 ml-2">Started: <%= format_datetime(attempt.started_at) %></span>
74
+ <% end %>
75
+ <% if attempt.finished_at %>
76
+ <span class="text-gray-400 ml-2">Finished: <%= format_datetime(attempt.finished_at) %></span>
77
+ <% end %>
78
+ </div>
79
+ <% if attempt.error_details.present? %>
80
+ <div class="text-red-500">
81
+ <strong>Error:</strong> <%= attempt.error_details["message"] %>
82
+ </div>
83
+ <% end %>
84
+ </div>
85
+ <% end %>
86
+ </div>
87
+ <% end %>
88
+ </div>
89
+ <% end %>
90
+ <% else %>
91
+ <p class="text-gray-500 text-sm">No steps recorded for this run.</p>
92
+ <% end %>
93
+ </div>
94
+
95
+ <div data-controller="workflow-viz"
96
+ data-workflow-viz-run-id-value="<%= @run.id %>"
97
+ class="w-full border border-gray-300 rounded-lg bg-white p-4">
98
+ <div data-workflow-viz-target="chart" class="w-full"></div>
99
+
100
+ <!-- Templates -->
101
+ <template data-workflow-viz-target="stepDetailTemplate">
102
+ <h3 class="text-md font-semibold text-gray-900 mb-2">Step: <span data-field="name"></span></h3>
103
+ <div class="grid grid-cols-2 gap-4">
104
+ <p class="text-sm"><span class="font-medium text-gray-500">Started:</span> <span data-field="started"></span></p>
105
+ <p class="text-sm"><span class="font-medium text-gray-500">Finished:</span> <span data-field="finished"></span></p>
106
+ <p class="text-sm"><span class="font-medium text-gray-500">Status:</span> <span data-field="status"></span></p>
107
+ <p class="text-sm text-red-500 col-span-2 hidden" data-field="error-container"><span class="font-medium">Error:</span> <span data-field="error"></span></p>
108
+ </div>
109
+ </template>
110
+
111
+ <template data-workflow-viz-target="signalDetailTemplate">
112
+ <h3 class="text-md font-semibold text-gray-900 mb-2"><span data-field="type"></span>: <span data-field="name"></span></h3>
113
+ <div class="grid grid-cols-2 gap-4">
114
+ <p class="text-sm"><span class="font-medium text-gray-500">Started:</span> <span data-field="started"></span></p>
115
+ <p class="text-sm"><span class="font-medium text-gray-500">Finished:</span> <span data-field="finished"></span></p>
116
+ <p class="text-sm text-red-500 col-span-2 hidden" data-field="error-container"><span class="font-medium">Error:</span> <span data-field="error"></span></p>
117
+ </div>
118
+ </template>
119
+
120
+ <template data-workflow-viz-target="listItemTemplate">
121
+ <div class="border-b border-gray-100 py-2 flex items-center justify-between">
122
+ <div>
123
+ <span class="text-sm font-medium text-gray-900" data-field="name"></span>
124
+ <span class="text-xs text-gray-500 ml-2" data-field="type"></span>
125
+ </div>
126
+ <div class="text-sm text-gray-600" data-field="status"></div>
127
+ </div>
128
+ </template>
129
+ </div>
130
+ </div>
@@ -0,0 +1,82 @@
1
+
2
+ <div class="p-6 space-y-6">
3
+ <div class="flex items-center justify-between">
4
+ <div>
5
+ <%= link_to overview_path, class: "text-sm text-gray-500 hover:text-gray-700 mb-2 block" do %>
6
+ Rigid Workflow »
7
+ <% end %>
8
+ <h1 class="text-3xl font-bold text-gray-900">Overview</h1>
9
+ </div>
10
+ </div>
11
+ <div class="bg-white rounded-xl border border-gray-200 overflow-hidden relative" data-controller="selection">
12
+ <!-- Floating Action Bar -->
13
+ <div data-selection-target="actionBar" class="hidden absolute mt-1 ml-10 flex items-center shadow-lg border border-gray-200 rounded-lg overflow-hidden bg-white z-10">
14
+ <%= form_with url: "#", method: :post, data: { selection_target: "actionForm" } do |f| %>
15
+ <div class="flex items-center divide-x divide-gray-200">
16
+ <button type="button" data-action="click->selection#deselectAll" class="px-2 py-2 text-gray-400 hover:text-gray-600">
17
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
18
+ </button>
19
+ </div>
20
+ <% end %>
21
+ </div>
22
+
23
+ <div class="overflow-x-auto">
24
+ <table class="w-full">
25
+ <thead class="bg-gray-50 border-b border-gray-200">
26
+ <tr>
27
+ <th class="px-4 py-3 text-left"><input type="checkbox" data-selection-target="selectAll" data-action="change->selection#toggleAll" class="cursor-default rounded border-gray-300 text-blue-600 focus:ring-blue-500"></th>
28
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Workflow</th>
29
+ <th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Completed</th>
30
+ <th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Active</th>
31
+ <th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Pending</th>
32
+ <th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Failed</th>
33
+ <th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Success %</th>
34
+ <th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Duration P50</th>
35
+ </tr>
36
+ </thead>
37
+ <tbody class="bg-white divide-y divide-gray-200">
38
+ <% total_completed = 0; total_active = 0; total_pending = 0; total_failed = 0 %>
39
+ <% @stats.each do |stat| %>
40
+ <% stat = stat.with_indifferent_access %>
41
+ <% total_completed += stat[:completed_runs].to_i %>
42
+ <% total_active += stat[:active_runs].to_i %>
43
+ <% total_pending += stat[:pending_runs].to_i %>
44
+ <% total_failed += stat[:failed_runs].to_i %>
45
+ <tr>
46
+ <td class="px-4 py-4 whitespace-nowrap"><input type="checkbox" data-selection-target="row" data-action="change->selection#toggleRow" class="cursor-default rounded border-gray-300 text-gray-600 focus:ring-gray-500" value="<%= stat[:workflow_class] %>"></td>
47
+ <td class="px-4 py-4 whitespace-nowrap text-sm font-medium text-gray-900"><%= stat[:workflow_class] %></td>
48
+ <td class="px-4 py-4 text-center whitespace-nowrap text-sm">
49
+ <%= link_to number_with_delimiter(stat[:completed_runs]), completed_runs_path(workflow: stat[:workflow_class]), class: "text-gray-600 hover:text-gray-800" %>
50
+ </td>
51
+ <td class="px-4 py-4 text-center whitespace-nowrap text-sm">
52
+ <%= link_to number_with_delimiter(stat[:active_runs]), active_runs_path(workflow: stat[:workflow_class]), class: "text-gray-600 hover:text-gray-800" %>
53
+ </td>
54
+ <td class="px-4 py-4 text-center whitespace-nowrap text-sm">
55
+ <%= link_to number_with_delimiter(stat[:pending_runs]), pending_runs_path(workflow: stat[:workflow_class]), class: "text-gray-600 hover:text-gray-800" %>
56
+ </td>
57
+ <td class="px-4 py-4 text-center whitespace-nowrap text-sm">
58
+ <%= link_to number_with_delimiter(stat[:failed_runs]), failed_runs_path(workflow: stat[:workflow_class]), class: "text-gray-600 hover:text-gray-800" %>
59
+ </td>
60
+ <td class="px-4 py-4 text-center whitespace-nowrap text-sm"><%= stat[:success_rate].to_f.round(1) %>%</td>
61
+ <td class="px-4 py-4 text-center whitespace-nowrap text-sm"><%= stat[:p50_duration] ? format_duration(ActiveSupport::Duration.parse(stat[:p50_duration])) : "-" %></td>
62
+ </tr>
63
+ <% end %>
64
+ </tbody>
65
+ <tfoot class="bg-gray-50 border-t border-gray-200">
66
+ <tr>
67
+ <td class="px-4 py-4"></td>
68
+ <td class="px-4 py-4"></td>
69
+ <td class="px-4 py-4 text-center text-sm font-bold text-gray-900"><%= number_with_delimiter(total_completed) %></td>
70
+ <td class="px-4 py-4 text-center text-sm font-bold text-gray-900"><%= number_with_delimiter(total_active) %></td>
71
+ <td class="px-4 py-4 text-center text-sm font-bold text-gray-900"><%= number_with_delimiter(total_pending) %></td>
72
+ <td class="px-4 py-4 text-center text-sm font-bold text-gray-900"><%= number_with_delimiter(total_failed) %></td>
73
+ <td class="px-4 py-4 text-center text-sm font-bold text-gray-900">
74
+ <% total_runs = total_completed + total_failed %>
75
+ <%= total_runs > 0 ? number_to_percentage(100.0 * total_completed / total_runs, precision: 1) : 0 %>
76
+ </td>
77
+ <td class="px-4 py-4"></td>
78
+ </tr>
79
+ </tfoot>
80
+ </table>
81
+ </div>
82
+ </div>
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+ pin "@hotwired/turbo-rails", to: "turbo.min.js"
3
+ pin "@hotwired/stimulus", to: "stimulus.min.js"
4
+ pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
5
+ pin "local-time", to: "local-time.es2017-esm.js"
6
+
7
+ pin "rigid_workflow/application", to: "rigid_workflow/application.js"
8
+ pin_all_from RigidWorkflow::Engine.root.join(
9
+ "app/javascript/rigid_workflow/controllers"
10
+ ),
11
+ under: "rigid_workflow/controllers"
data/config/routes.rb ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ return if RigidWorkflow::Engine.routes.named_routes.names.include?(:overview)
4
+
5
+ RigidWorkflow::Engine.routes.draw do
6
+ get "/" => "workflows#index", :as => :overview
7
+
8
+ scope :runs do
9
+ get "/" => "runs#index", :as => :runs
10
+ get "/pending" => "runs#pending_runs", :as => :pending_runs
11
+ get "/active" => "runs#active_runs", :as => :active_runs
12
+ get "/completed" => "runs#completed_runs", :as => :completed_runs
13
+ get "/failed" => "runs#failed_runs", :as => :failed_runs
14
+ get "/:id" => "runs#show", :as => :runs_show
15
+ post "/bulk_action" => "runs#bulk_action", :as => :runs_bulk_action
16
+ end
17
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RigidWorkflow
4
+ module Generators
5
+ # Generates a new activity class file.
6
+ class ActivityGenerator < ::Rails::Generators::NamedBase
7
+ source_root File.expand_path("templates", __dir__)
8
+
9
+ def create_activity_file
10
+ template "activity.rb.erb",
11
+ File.join("app/workflows/activities", "#{file_name}.rb")
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/migration"
5
+
6
+ module RigidWorkflow
7
+ module Generators
8
+ # Installs RigidWorkflow into a Rails application.
9
+ # Generates initializer, routes, and optional migrations.
10
+ class InstallGenerator < ::Rails::Generators::Base
11
+ include Rails::Generators::Migration
12
+
13
+ source_root File.expand_path("templates", __dir__)
14
+
15
+ class_option :with_migrations,
16
+ type: :boolean,
17
+ default: true,
18
+ desc: "Generate migrations"
19
+
20
+ # Standard Rails migration timestamp format
21
+ def self.next_migration_number(dirname)
22
+ next_num = Time.now.utc.strftime("%Y%m%d%H%M%S").to_i
23
+ current_num = current_migration_number(dirname)
24
+ next_num <= current_num ? (current_num + 1).to_s : next_num.to_s
25
+ end
26
+
27
+ def install
28
+ template "initializer.rb.erb", "config/initializers/rigid_workflow.rb"
29
+ route "mount RigidWorkflow::Engine => '/admin/rigid_workflow'"
30
+
31
+ if options[:with_migrations]
32
+ migration_template(
33
+ "migration.rb.erb",
34
+ "db/migrate/create_rigid_workflow_tables.rb"
35
+ )
36
+ end
37
+
38
+ display_success_message
39
+ end
40
+
41
+ private
42
+
43
+ # Accessible inside your migration.rb.erb as <%= migration_version %>
44
+ def migration_version
45
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
46
+ end
47
+
48
+ def display_success_message
49
+ say "\nRigidWorkflow installed successfully!", :green
50
+ say "\nNext steps:"
51
+ say " 1. Run: rails db:migrate"
52
+ say " 2. Define your workflow in app/workflows/"
53
+ say " 3. Start workflow: MyWorkflow(param: 1)"
54
+ say ""
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,6 @@
1
+ class <%= class_name %>Activity < RigidWorkflow::Activity
2
+ def perform(**args)
3
+ # TODO: Implement activity logic
4
+ { result: 'success' }
5
+ end
6
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ # RigidWorkflow Configuration
4
+ #
5
+ # This initializer configures RigidWorkflow's behavior. All options have
6
+ # sensible defaults - only configure what you need to override.
7
+ #
8
+ # SECURITY: The admin_controller is REQUIRED for authentication.
9
+ # The engine's admin UI has NO built-in authentication.
10
+ # Set this to a controller that already enforces authentication
11
+ # (e.g., Admin::BaseController with Devise, or your custom admin controller).
12
+ # The engine's ApplicationController will inherit from this class.
13
+ # Without this, all workflow data is publicly accessible when mounted.
14
+ RigidWorkflow.configure do |config|
15
+ # The controller to use for the admin UI. Inherit from your admin base controller
16
+ # to automatically apply authentication/authorization.
17
+ config.admin_controller = "MyAdminController"
18
+
19
+ # Maximum retry attempts for failed activities.
20
+ config.max_attempts = 3
21
+
22
+ # Base delay for exponential backoff retries (in seconds).
23
+ config.retry_delay = 15.seconds
24
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateRigidWorkflowTables < ActiveRecord::Migration[<%= migration_version.delete("[]") %>]
4
+ def change
5
+ create_table :rigid_workflow_runs, id: :string, if_not_exists: true do |t|
6
+ t.string :workflow_class, null: false
7
+ t.integer :version, default: 1, null: false
8
+ t.json :params, default: {}
9
+ t.json :memory, default: {}
10
+ t.string :status, default: "pending", null: false
11
+ t.integer :iterations, default: 0
12
+ t.datetime :started_at
13
+ t.datetime :finished_at
14
+ t.timestamps
15
+ end
16
+
17
+ add_index :rigid_workflow_runs, :workflow_class, if_not_exists: true
18
+ add_index :rigid_workflow_runs, :status, if_not_exists: true
19
+
20
+ create_table :rigid_workflow_steps, id: :string, if_not_exists: true do |t|
21
+ t.string :rigid_workflow_run_id, null: false
22
+ t.string :step_name, null: false
23
+ t.string :activity_class, null: false
24
+ t.json :input, default: {}
25
+ t.json :output, default: {}
26
+ t.json :error_details
27
+ t.string :status, default: "pending", null: false
28
+ t.integer :attempt, default: 1
29
+ t.integer :max_attempts, default: 3
30
+ t.datetime :started_at
31
+ t.datetime :finished_at
32
+ t.datetime :failed_at
33
+ t.datetime :scheduled_at
34
+ t.timestamps
35
+ end
36
+
37
+ add_index :rigid_workflow_steps, :rigid_workflow_run_id, if_not_exists: true
38
+ add_index :rigid_workflow_steps, :step_name, if_not_exists: true
39
+ add_index :rigid_workflow_steps, [:rigid_workflow_run_id, :step_name], unique: true, if_not_exists: true
40
+
41
+ create_table :rigid_workflow_signals, id: :string, if_not_exists: true do |t|
42
+ t.string :rigid_workflow_run_id, null: false
43
+ t.string :name, null: false
44
+ t.json :payload, default: {}
45
+ t.datetime :received_at
46
+ t.datetime :canceled_at
47
+ t.datetime :expires_at
48
+ t.timestamps
49
+ end
50
+
51
+ add_index :rigid_workflow_signals, :rigid_workflow_run_id, if_not_exists: true
52
+ add_index :rigid_workflow_signals, [:rigid_workflow_run_id, :name], unique: true, if_not_exists: true
53
+ end
54
+ end
@@ -0,0 +1,6 @@
1
+ class <%= class_name %>Workflow < RigidWorkflow::Workflow
2
+ def run
3
+ # TODO: Define steps
4
+ # step :my_step, MyActivity
5
+ end
6
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RigidWorkflow
4
+ module Generators
5
+ # Generates a new workflow class file.
6
+ class WorkflowGenerator < ::Rails::Generators::NamedBase
7
+ source_root File.expand_path("templates", __dir__)
8
+
9
+ def create_workflow_file
10
+ template "workflow.rb.erb",
11
+ File.join("app/workflows", "#{file_name}.rb")
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RigidWorkflow
4
+ # Base class for all activities.
5
+ # Activities represent a single, idempotent unit of work within a workflow.
6
+ class Activity
7
+ class_attribute :configs, default: { force_async: false }
8
+
9
+ # Forces all instances of this activity to run asynchronously.
10
+ # @param value [Boolean]
11
+ def self.force_async(value)
12
+ self.configs = self.configs.merge(force_async: value)
13
+ self
14
+ end
15
+
16
+ # @return [Boolean] Whether this activity is forced to run asynchronously
17
+ def self.force_async?
18
+ self.configs[:force_async] == true
19
+ end
20
+
21
+ # @api private
22
+ def initialize(step)
23
+ @workflow_run_id = step.rigid_workflow_run_id
24
+ @step_name = step.step_name
25
+ @step = step
26
+ end
27
+
28
+ # The main activity logic. Must be implemented by subclasses.
29
+ # @param args [Hash] Keyword arguments passed from the workflow step input
30
+ # @return [Object] The result of the activity, which will be persisted
31
+ def perform(**args)
32
+ return if Rails.env.test? && !RigidWorkflow.config.logging?
33
+ raise NotImplementedError, "Subclass must implement #perform"
34
+ end
35
+
36
+ # Logs a message associated with this activity execution.
37
+ # @param message [String]
38
+ def log(message, severity: Logger::INFO)
39
+ return if !RigidWorkflow.config.logging? && Rails.env.test?
40
+
41
+ puts message unless Rails.env.test?
42
+ Rails.logger.log(
43
+ severity,
44
+ "[#{@workflow_run_id.to_s[0..12]}][#{@step_name}] #{message}"
45
+ )
46
+ end
47
+
48
+ # Called during compensation to undo this activity's side effects.
49
+ # @step is available with full access to output/input via current_attempt.
50
+ # Override in subclasses. Default does nothing.
51
+ def compensate
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RigidWorkflow
4
+ # Rails engine for RigidWorkflow.
5
+ # Provides the plugin infrastructure for integrating with Rails applications.
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace RigidWorkflow
8
+
9
+ # Zeitwerk handles autoloading and reloading of the app/ but not the lib/.
10
+ initializer "rigid_workflow.zeitwerk" do
11
+ Rails.autoloaders.main.push_dir(Engine.root.join("lib/rigid_workflow"), namespace: RigidWorkflow)
12
+ end
13
+
14
+ initializer "rigid_workflow.assets" do |app|
15
+ app.config.assets.paths << root.join("app/assets/stylesheets")
16
+ app.config.assets.paths << root.join("app/javascript")
17
+ end
18
+
19
+ initializer "rigid_workflow.importmap", before: "importmap" do |app|
20
+ if app.config.respond_to?(:importmap)
21
+ app.config.importmap.paths << root.join("config/importmap.rb")
22
+
23
+ if Rails.env.development?
24
+ app.config.importmap.cache_sweepers << Engine.root.join(
25
+ "app/javascript"
26
+ )
27
+ end
28
+ end
29
+ end
30
+
31
+ initializer "rigid_workflow.assets.precompile" do |app|
32
+ app.config.assets.precompile += %w[rigid_workflow/application.css]
33
+ end
34
+ end
35
+ end