durable_flow 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 43d876d7d9d09c0c0c883d510fb18634f6a6b7f81445287b1414353294181bd4
4
+ data.tar.gz: ed01f4612ff81e58afb7cbdbf2a2a8ab8aa236973d5062fca60726d59db25261
5
+ SHA512:
6
+ metadata.gz: 9d24efb9075c6755a7f4cc67408c1c79b42adc1656134d468af2e6a1c7c259707819c6f7771843e145daed37777ead0577e990a048d8940aff2477eadc28df06
7
+ data.tar.gz: 81ca1ec109939a621565d94bd858dde30091ab87e275263db52bdb6a37ad15b589cfa5606200aab9bbcac1671657bcd2c128f8e0102488597e7a4a388163f222
data/MIT-LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2026 DurableFlow contributors
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,552 @@
1
+ # DurableFlow
2
+
3
+ DurableFlow is an Inngest-style workflow runtime for Rails, built on `ActiveJob::Continuable`, Active Record, Solid Queue, and `Rails.event`.
4
+
5
+ It lets you write long-running business workflows as normal Ruby methods. Side effects live in named `step` blocks. Step return values are persisted, so after a crash, deploy, sleep, event wakeup, or retry, the workflow replays from the top and completed steps return their stored values without running again.
6
+
7
+ ```ruby
8
+ class WelcomeWorkflow < DurableFlow::Workflow
9
+ def perform(user_id:, trial_id:)
10
+ user = step(:load_user) { User.find(user_id) }
11
+
12
+ step(:send_welcome) { UserMailer.welcome(user).deliver_now }
13
+
14
+ step.sleep(:trial_delay, 1.day)
15
+
16
+ trial = step(:start_trial) { Billing.start_trial!(user, trial_id:) }
17
+
18
+ event = step.wait_for_event(:trial_confirmed, timeout: 7.days, match: { trial_id: trial.id })
19
+
20
+ step(:finalize) { user.update!(onboarded_at: Time.current, confirmed_at: event[:confirmed_at]) }
21
+ end
22
+ end
23
+ ```
24
+
25
+ The goal is durable, observable workflows without a separate workflow server, Redis dependency, or external control plane.
26
+
27
+ ## Alpha Notice
28
+
29
+ DurableFlow is alpha software. The API and storage model will likely change as the design is exercised in real applications, and this gem has not yet been used in a production setting.
30
+
31
+ ## UI
32
+
33
+ DurableFlow ships with a small mountable Rails engine for inspecting workflow runs, step timelines, waits, workflow logs, arguments, and errors.
34
+
35
+ ![DurableFlow workflow runs index](docs/screenshots/workflow-runs.png)
36
+
37
+ ![DurableFlow workflow run detail](docs/screenshots/workflow-run-detail.png)
38
+
39
+ ## Live Updates
40
+
41
+ DurableFlow emits committed lifecycle changes for workflow runs, steps, waits, events, and workflow logs. The default broadcaster is a no-op, so live UI is opt-in.
42
+
43
+ ```ruby
44
+ # config/initializers/durable_flow.rb
45
+ DurableFlow.live_broadcaster = ->(change) do
46
+ next unless change.run_id
47
+
48
+ ActionCable.server.broadcast(
49
+ "durable_flow:run:#{change.run_id}",
50
+ change.payload
51
+ )
52
+ end
53
+ ```
54
+
55
+ Each change includes:
56
+
57
+ ```ruby
58
+ change.type # "workflow_run.updated", "workflow_step.created", "workflow_log.created", ...
59
+ change.run_id # nil for standalone workflow_event.created changes
60
+ change.workflow_class
61
+ change.record_class
62
+ change.record_id
63
+ change.record # Active Record object for in-process renderers
64
+ change.snapshot # small serializable hash
65
+ change.payload # snapshot plus type/run metadata
66
+ ```
67
+
68
+ Workflow logs are emitted the same way:
69
+
70
+ ```ruby
71
+ step(:create_refund) do
72
+ log.info("Creating refund", refund_id: refund.id)
73
+ end
74
+
75
+ # Broadcasts:
76
+ change.type # "workflow_log.created"
77
+ change.payload.fetch(:level) # "info"
78
+ change.payload.fetch(:message) # "Creating refund"
79
+ change.payload.fetch(:workflow_step_id)
80
+ change.record.data_value # { refund_id: ... } for in-process renderers
81
+ ```
82
+
83
+ You can also register subscribers:
84
+
85
+ ```ruby
86
+ DurableFlow.on_change do |change|
87
+ Rails.logger.info("[DurableFlow] #{change.type} #{change.run_id}")
88
+ end
89
+ ```
90
+
91
+ For Turbo Streams, keep authorization in the host app and render whatever partial fits your UI:
92
+
93
+ ```ruby
94
+ DurableFlow.live_broadcaster = ->(change) do
95
+ next unless change.run_id
96
+
97
+ Turbo::StreamsChannel.broadcast_replace_to(
98
+ "durable_flow:run:#{change.run_id}",
99
+ target: "workflow_run",
100
+ partial: "workflow_runs/live_run",
101
+ locals: { run_id: change.run_id }
102
+ )
103
+ end
104
+ ```
105
+
106
+ Broadcasts run from `after_commit`, and broadcaster errors are reported but do not fail workflow execution.
107
+
108
+ ## Timeline Data
109
+
110
+ Use `WorkflowRun#timeline` when building a custom UI. It preloads and groups the run's steps, waits, matched events, and logs so applications do not need to recreate the join logic.
111
+
112
+ ```ruby
113
+ run = DurableFlow::WorkflowRun.find_by!(run_id: params[:run_id])
114
+ timeline = run.timeline
115
+
116
+ timeline.step_entries.each do |entry|
117
+ entry.step # DurableFlow::WorkflowStep
118
+ entry.logs # logs written inside this step
119
+ entry.waits # waits created by this step
120
+ entry.events # events that matched those waits
121
+ end
122
+
123
+ timeline.run_logs # logs written outside a step
124
+ timeline.items # flat chronological items: :step, :wait, :event, :log
125
+ ```
126
+
127
+ ## Demo App
128
+
129
+ The repo includes a small live Rails demo app at `examples/live_demo`.
130
+
131
+ ```sh
132
+ mise exec ruby@3.4 -- bundle exec ruby examples/live_demo/server.rb
133
+ ```
134
+
135
+ Open `http://127.0.0.1:4568/live`, start one or more review workflows, then approve or reject the latest waiting run. The page updates from `DurableFlow.live_broadcaster` using Server-Sent Events and links to the mounted engine UI at `/durable_flow`. Use **Reset demo** to clear the local demo database.
136
+
137
+ ## Status
138
+
139
+ This is a working prototype targeted at Rails 8.1+ continuation and structured event APIs.
140
+
141
+ Verified behavior:
142
+
143
+ - Rails 8.1.3 compatibility.
144
+ - Step return-value memoization across replays.
145
+ - Dynamic string step names.
146
+ - Cursor-based `ActiveJob::Continuable` loops.
147
+ - Durable sleep through `perform_later(wait_until:)`.
148
+ - Event waits through `Rails.event`.
149
+ - Parent workflows waiting for child workflow completion.
150
+ - Solid Queue `1.1.2` integration.
151
+ - Database-backed workflow execution leases to prevent concurrent execution of the same run.
152
+ - Opt-in live lifecycle broadcasts through `DurableFlow.live_broadcaster`.
153
+ - Explicit workflow logs through `log.info`, `log.warn`, `log.error`, and `log.debug`.
154
+
155
+ ## Install
156
+
157
+ Use the current GitHub `main` branch:
158
+
159
+ ```ruby
160
+ # Gemfile
161
+ gem "durable_flow", git: "https://github.com/skorfmann/durableflow.git", branch: "main"
162
+ ```
163
+
164
+ For a less moving target, pin a commit SHA with `ref:` instead of `branch:`.
165
+
166
+ For local development before publishing the gem:
167
+
168
+ ```ruby
169
+ # Gemfile
170
+ gem "durable_flow", path: "../durableflow"
171
+ ```
172
+
173
+ For a published gem:
174
+
175
+ ```ruby
176
+ # Gemfile
177
+ gem "durable_flow"
178
+ ```
179
+
180
+ Then install the tables:
181
+
182
+ ```sh
183
+ bundle install
184
+ bin/rails generate durable_flow:install
185
+ bin/rails db:migrate
186
+ ```
187
+
188
+ DurableFlow runs through Active Job. Solid Queue is the recommended adapter:
189
+
190
+ ```ruby
191
+ # config/application.rb or config/environments/production.rb
192
+ config.active_job.queue_adapter = :solid_queue
193
+ ```
194
+
195
+ Mount the optional workflow run viewer:
196
+
197
+ ```ruby
198
+ # config/routes.rb
199
+ mount DurableFlow::Engine => "/durable_flow"
200
+ ```
201
+
202
+ ## Configure
203
+
204
+ Workflow runs use a database-backed execution lease so concurrent workers do not execute the same run at the same time. The lease is refreshed as steps start and continuations checkpoint.
205
+
206
+ The default TTL is 10 minutes. Set it longer than your longest single step that does not checkpoint:
207
+
208
+ ```ruby
209
+ # config/initializers/durable_flow.rb
210
+ DurableFlow.execution_lock_ttl = 15.minutes
211
+ ```
212
+
213
+ What that means:
214
+
215
+ ```ruby
216
+ step(:sync_big_account) do
217
+ SomeApi.sync_everything(account) # If this can take 25 minutes, use a TTL over 25 minutes.
218
+ end
219
+ ```
220
+
221
+ If you process records in a cursor loop and call `advance!` or `checkpoint!`, the lease refreshes automatically as the loop progresses:
222
+
223
+ ```ruby
224
+ step :sync_accounts, start: 0 do |s|
225
+ Account.find_each(start: s.cursor) do |account|
226
+ SyncAccount.call(account)
227
+ s.advance!(from: account.id)
228
+ end
229
+ end
230
+ ```
231
+
232
+ ## Write a Workflow
233
+
234
+ Create an application base class if you want shared defaults:
235
+
236
+ ```ruby
237
+ # app/workflows/application_workflow.rb
238
+ class ApplicationWorkflow < DurableFlow::Workflow
239
+ queue_as :default
240
+ end
241
+ ```
242
+
243
+ Then write workflows as plain Ruby:
244
+
245
+ ```ruby
246
+ # app/workflows/trial_onboarding_workflow.rb
247
+ class TrialOnboardingWorkflow < ApplicationWorkflow
248
+ def perform(user_id:, trial_id:)
249
+ user = step(:load_user) { User.find(user_id) }
250
+
251
+ step(:send_welcome_email) do
252
+ UserMailer.with(user: user).welcome.deliver_now
253
+ true
254
+ end
255
+
256
+ step.sleep(:wait_for_trial_activity, 3.days)
257
+
258
+ event = step.wait_for_event(
259
+ :trial_activated,
260
+ timeout: 4.days,
261
+ match: { trial_id: trial_id },
262
+ )
263
+
264
+ step(:mark_onboarded) do
265
+ user.update!(onboarded_at: Time.current, onboarding_source: event[:source])
266
+ end
267
+ end
268
+ end
269
+ ```
270
+
271
+ Start it like any Active Job:
272
+
273
+ ```ruby
274
+ TrialOnboardingWorkflow.perform_later(user_id: user.id, trial_id: trial.id)
275
+ ```
276
+
277
+ Wake it with a Rails event:
278
+
279
+ ```ruby
280
+ Rails.event.notify(:trial_activated, trial_id: trial.id, source: "checkout")
281
+ ```
282
+
283
+ ## Step API
284
+
285
+ Memoized side-effect step:
286
+
287
+ ```ruby
288
+ order = step(:create_order) { Order.create!(cart:) }
289
+ ```
290
+
291
+ Durable sleep:
292
+
293
+ ```ruby
294
+ step.sleep(:retry_tomorrow, 1.day)
295
+ step.sleep(:wait_until_send_at, until: campaign.send_at)
296
+ ```
297
+
298
+ Wait for a Rails event:
299
+
300
+ ```ruby
301
+ event = step.wait_for_event(:payment_received, timeout: 2.days, match: { invoice_id: invoice.id })
302
+ ```
303
+
304
+ Use a different event name than the step name:
305
+
306
+ ```ruby
307
+ event = step.wait_for_event(:wait_for_charge, event: :stripe_charge_succeeded, match: { charge_id: charge.id })
308
+ ```
309
+
310
+ Wait for a child workflow:
311
+
312
+ ```ruby
313
+ child_run_id = step(:start_child) { SendInvoiceWorkflow.perform_later(invoice.id).job_id }
314
+ completion = step.wait_for_workflow(:child_finished, child_run_id, timeout: 1.hour)
315
+ ```
316
+
317
+ Write structured workflow logs:
318
+
319
+ ```ruby
320
+ step(:create_refund) do
321
+ log.info("Creating refund", refund_id: refund.id, amount_cents: refund.amount_cents)
322
+ Refunds.create!(refund)
323
+ end
324
+ ```
325
+
326
+ Logs are persisted with the current workflow run and, when called inside a step, the current workflow step. They also emit `workflow_log.created` live changes.
327
+
328
+ ## Iteration Patterns
329
+
330
+ Small bounded list: one memoized step per item.
331
+
332
+ ```ruby
333
+ item_ids.each do |item_id|
334
+ step("notify-#{item_id}") { Notifier.item_ready(item_id).deliver_now }
335
+ end
336
+ ```
337
+
338
+ Large cursor loop: one continuation step with cursor checkpoints.
339
+
340
+ ```ruby
341
+ step :process_orders, start: 0 do |s|
342
+ Order.pending.find_each(start: s.cursor) do |order|
343
+ ProcessOrder.call(order)
344
+ s.advance!(from: order.id)
345
+ end
346
+ end
347
+ ```
348
+
349
+ Parallel or high-cardinality work: fan out to child workflows.
350
+
351
+ ```ruby
352
+ child_run_ids = step(:start_children) do
353
+ account.users.find_each.map { |user| SyncUserWorkflow.perform_later(user.id).job_id }
354
+ end
355
+
356
+ child_run_ids.each do |run_id|
357
+ step.wait_for_workflow("child-#{run_id}", run_id, timeout: 30.minutes)
358
+ end
359
+ ```
360
+
361
+ ## Rules
362
+
363
+ - Put side effects inside `step` blocks.
364
+ - Keep code outside `step` blocks deterministic and cheap. It runs on every replay.
365
+ - Step names must be stable across replays. Use `"notify-#{record.id}"`, not `SecureRandom.uuid`.
366
+ - Step return values must be Active Job serializable.
367
+ - Steps should still be idempotent where practical. If a process crashes after a side effect runs but before the step result commits, that step can run again.
368
+ - Set `DurableFlow.execution_lock_ttl` longer than your longest non-checkpointing step.
369
+
370
+ ## Tables
371
+
372
+ The install generator creates:
373
+
374
+ - `durable_flow_workflow_runs`
375
+ - `durable_flow_workflow_steps`
376
+ - `durable_flow_workflow_events`
377
+ - `durable_flow_workflow_waits`
378
+ - `durable_flow_workflow_logs`
379
+
380
+ Step results, workflow arguments, and log data are serialized through Active Job. Event payloads are stored from `Rails.event` notifications and matched against pending waits.
381
+
382
+ ## Testing
383
+
384
+ Executable examples live in `examples/workflows.rb` and are covered by `test/durable_flow/examples_test.rb`.
385
+
386
+ DurableFlow also ships test helpers for application test suites:
387
+
388
+ ```ruby
389
+ # test/test_helper.rb
390
+ require "durable_flow/test_helper"
391
+
392
+ class ActiveSupport::TestCase
393
+ include DurableFlow::TestHelper
394
+
395
+ setup { clear_durable_flow! }
396
+ end
397
+ ```
398
+
399
+ Example workflow test:
400
+
401
+ ```ruby
402
+ class TrialOnboardingWorkflowTest < ActiveSupport::TestCase
403
+ test "waits for trial activation and finishes" do
404
+ freeze_time do
405
+ changes = capture_durable_flow_changes do
406
+ TrialOnboardingWorkflow.perform_later(user_id: users(:ada).id, trial_id: trials(:pending).id)
407
+ perform_durable_flow_jobs(at: Time.current)
408
+ end
409
+
410
+ run = durable_flow_run_for(TrialOnboardingWorkflow)
411
+
412
+ assert_step_succeeded run, :send_welcome_email
413
+ assert_workflow_sleeping run, step: :wait_for_trial_activity
414
+ assert_durable_flow_change changes, "workflow_run.created"
415
+
416
+ travel_to_next_workflow_wake run
417
+ perform_durable_flow_jobs(at: Time.current)
418
+
419
+ assert_workflow_waiting_for run, :trial_activated, match: { trial_id: trials(:pending).id }
420
+
421
+ resume_workflows_for :trial_activated, trial_id: trials(:pending).id, source: "checkout"
422
+
423
+ assert_workflow_completed run
424
+ assert_step_result run, :mark_onboarded, true
425
+ assert_workflow_log run, level: :info, message: "Trial activated"
426
+ end
427
+ end
428
+ end
429
+ ```
430
+
431
+ Useful helpers include `perform_durable_flow_jobs`, `resume_workflows_for`, `travel_to_next_workflow_wake`, `durable_flow_run_for`, `durable_flow_timeline_for`, `assert_workflow_completed`, `assert_workflow_sleeping`, `assert_workflow_waiting_for`, `assert_step_succeeded`, `assert_step_result`, `assert_step_attempts`, `assert_workflow_log`, `assert_step_log`, `capture_durable_flow_changes`, and `assert_durable_flow_change`.
432
+
433
+ Run the suite against the vendored Rails copy:
434
+
435
+ ```sh
436
+ mise exec ruby@3.4 -- bundle exec rake test
437
+ ```
438
+
439
+ Run it against released Rails 8.1:
440
+
441
+ ```sh
442
+ RAILS_VERSION=8.1.3 mise exec ruby@3.4 -- bundle exec rake test
443
+ ```
444
+
445
+ Current suite:
446
+
447
+ ```text
448
+ 25 runs, 212 assertions, 0 failures, 0 errors, 0 skips
449
+ ```
450
+
451
+ ## Publishing
452
+
453
+ Gem releases are published manually from GitHub Actions using pre-1.0 SemVer versions such as `0.1.0`, `0.1.1`, and `0.2.0`.
454
+
455
+ Open **Actions -> Release gem -> Run workflow**, select `main`, and enter the version to publish.
456
+
457
+ The workflow validates the `x.y.z` version input, writes that version into `lib/durable_flow/version.rb` inside the CI checkout, runs the test suite, builds the gem, and pushes it to RubyGems.
458
+
459
+ ## Copyable App Prompt
460
+
461
+ Paste this into Codex, Claude Code, or another coding agent inside a Rails app:
462
+
463
+ ```text
464
+ Add DurableFlow workflows to this Rails application.
465
+
466
+ Use the DurableFlow gem from:
467
+ - GitHub main: gem "durable_flow", git: "https://github.com/skorfmann/durableflow.git", branch: "main"
468
+ - local path: gem "durable_flow", path: "../durableflow"
469
+ - or published gem: gem "durable_flow"
470
+
471
+ Tasks:
472
+ 1. Add the gem to the Gemfile and run bundle install.
473
+ 2. Run bin/rails generate durable_flow:install and migrate the database.
474
+ 3. Ensure Active Job uses Solid Queue in the relevant environment.
475
+ 4. Mount DurableFlow::Engine at /durable_flow.
476
+ 5. Add config/initializers/durable_flow.rb with DurableFlow.execution_lock_ttl set longer than the app's longest non-checkpointing workflow step.
477
+ 6. Create app/workflows/application_workflow.rb inheriting from DurableFlow::Workflow.
478
+ 7. Convert this business process into a DurableFlow workflow:
479
+ [Describe the business process here.]
480
+ 8. Put all side effects inside named step blocks.
481
+ 9. Use step.sleep for durable delays.
482
+ 10. Use step.wait_for_event for external callbacks or user actions, and emit matching Rails.event.notify calls from the app code that receives those callbacks.
483
+ 11. Add log.info/log.warn/log.error calls for important workflow milestones, using structured fields such as model ids, tokens, and external request ids.
484
+ 12. Add tests using DurableFlow::TestHelper that prove:
485
+ - completed steps do not run twice on replay,
486
+ - sleep resumes correctly,
487
+ - event waits resume only for matching payloads,
488
+ - timeout behavior is explicit.
489
+
490
+ Important constraints:
491
+ - Step names must be stable across replays.
492
+ - Step return values must be Active Job serializable.
493
+ - Code outside step blocks must be deterministic and cheap.
494
+ - Workflow log data must be Active Job serializable.
495
+ - If a single step can run longer than DurableFlow.execution_lock_ttl without checkpointing, increase the TTL or split the work into checkpointed chunks.
496
+ ```
497
+
498
+ ## Copyable Human-in-the-Loop Prompt
499
+
500
+ DurableFlow does not ship a generic human task system. For most apps, the right implementation depends on your existing users, permissions, admin UI, notifications, and domain models.
501
+
502
+ Paste this into a coding agent inside your Rails app when a workflow needs a human decision:
503
+
504
+ ```text
505
+ Add a human-in-the-loop step to this DurableFlow workflow.
506
+
507
+ Use app-native Rails models and UI. Do not add a generic DurableFlow task framework.
508
+
509
+ Workflow:
510
+ [Name or paste the workflow here.]
511
+
512
+ Human decision needed:
513
+ [Describe the decision, for example "finance approves or rejects a refund".]
514
+
515
+ Build this using DurableFlow's existing event wait primitive:
516
+ 1. Add an app model for the pending decision if one does not already exist. Keep it domain-specific, for example RefundApproval, AccountReview, DeploymentApproval, or KycReview.
517
+ 2. In the workflow, create that record inside a named step.
518
+ 3. Pause the workflow with step.wait_for_event, matching on that decision record's id or public token.
519
+ 4. Add the app UI/controller action that lets an authorized human submit the decision.
520
+ 5. In that controller action, persist the decision and emit Rails.event.notify with the event name and matching id/token.
521
+ 6. Resume the workflow and branch on the event payload.
522
+ 7. Add tests for approve, reject, and timeout paths.
523
+
524
+ Example shape:
525
+
526
+ approval = step(:create_refund_approval) { RefundApproval.create!(refund: refund, status: "pending") }
527
+
528
+ decision = step.wait_for_event(
529
+ :refund_approval_decided,
530
+ timeout: 2.days,
531
+ match: { refund_approval_id: approval.id }
532
+ )
533
+
534
+ Controller completion should emit:
535
+
536
+ Rails.event.notify(
537
+ :refund_approval_decided,
538
+ refund_approval_id: approval.id,
539
+ decision: "approved",
540
+ decided_by_id: Current.user.id
541
+ )
542
+
543
+ Constraints:
544
+ - Use the app's existing authentication and authorization.
545
+ - Use a public token instead of a sequential database id for public links or external callbacks.
546
+ - Keep the decision record domain-specific and easy to query from the app's admin UI.
547
+ - Put workflow side effects inside named step blocks.
548
+ ```
549
+
550
+ ## What It Is Not
551
+
552
+ DurableFlow is not trying to replace Temporal or Inngest Cloud. It is an in-app Rails workflow runtime for teams that want durable, observable, multi-step business logic while staying inside Rails, Active Job, and their database.
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableFlow
4
+ class WorkflowRunsController < ActionController::Base
5
+ layout "durable_flow/application"
6
+
7
+ helper_method :durable_flow_duration,
8
+ :durable_flow_format_time,
9
+ :durable_flow_json,
10
+ :durable_flow_status_class
11
+
12
+ def index
13
+ @workflow_runs = WorkflowRun.order(created_at: :desc).limit(100)
14
+ @status_counts = @workflow_runs.group_by(&:status).transform_values(&:count)
15
+ @active_count = @workflow_runs.count { |run| !run.terminal? }
16
+ end
17
+
18
+ def show
19
+ @workflow_run = WorkflowRun.find_by!(run_id: params[:run_id])
20
+ @workflow_timeline = @workflow_run.timeline
21
+ end
22
+
23
+ private
24
+ def durable_flow_status_class(status)
25
+ case status.to_s
26
+ when "completed", "succeeded", "matched"
27
+ "success"
28
+ when "failed", "timed_out"
29
+ "danger"
30
+ when "waiting", "sleeping", "pending"
31
+ "waiting"
32
+ when "running", "ready", "retrying", "enqueued", "info"
33
+ "active"
34
+ when "warn"
35
+ "waiting"
36
+ when "error"
37
+ "danger"
38
+ else
39
+ "neutral"
40
+ end
41
+ end
42
+
43
+ def durable_flow_format_time(value)
44
+ return "—" unless value
45
+
46
+ value.in_time_zone.strftime("%b %-d, %H:%M:%S %Z")
47
+ end
48
+
49
+ def durable_flow_duration(started_at, finished_at = nil)
50
+ return "—" unless started_at
51
+
52
+ seconds = ((finished_at || Time.current) - started_at).to_i
53
+ return "#{seconds}s" if seconds < 60
54
+
55
+ minutes = seconds / 60
56
+ return "#{minutes}m" if minutes < 60
57
+
58
+ hours = minutes / 60
59
+ remaining_minutes = minutes % 60
60
+ remaining_minutes.zero? ? "#{hours}h" : "#{hours}h #{remaining_minutes}m"
61
+ end
62
+
63
+ def durable_flow_json(value)
64
+ return "" if value.blank?
65
+
66
+ JSON.pretty_generate(value)
67
+ rescue JSON::GeneratorError
68
+ value.inspect
69
+ end
70
+ end
71
+ end