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.
@@ -0,0 +1,464 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>DurableFlow</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: light;
10
+ --bg: #f6f7f9;
11
+ --panel: #ffffff;
12
+ --panel-soft: #f9fafb;
13
+ --ink: #111318;
14
+ --muted: #68707d;
15
+ --subtle: #8b93a1;
16
+ --line: #e2e5ea;
17
+ --line-strong: #cfd5dd;
18
+ --nav: #121417;
19
+ --nav-muted: #9aa3af;
20
+ --green: #0f8a5f;
21
+ --green-soft: #e7f6ef;
22
+ --blue: #2c6fbb;
23
+ --blue-soft: #e8f1fb;
24
+ --amber: #aa6a00;
25
+ --amber-soft: #fff4dd;
26
+ --red: #c13b3b;
27
+ --red-soft: #fdecec;
28
+ --purple: #7755aa;
29
+ --purple-soft: #f0ebf8;
30
+ --shadow: 0 1px 2px rgba(18, 20, 23, 0.06), 0 10px 26px rgba(18, 20, 23, 0.05);
31
+ }
32
+
33
+ * { box-sizing: border-box; }
34
+
35
+ html, body { min-height: 100%; }
36
+
37
+ body {
38
+ margin: 0;
39
+ background: var(--bg);
40
+ color: var(--ink);
41
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
42
+ font-size: 14px;
43
+ letter-spacing: 0;
44
+ }
45
+
46
+ a { color: inherit; text-decoration: none; }
47
+
48
+ .df-shell {
49
+ min-height: 100vh;
50
+ display: grid;
51
+ grid-template-columns: 248px minmax(0, 1fr);
52
+ }
53
+
54
+ .df-sidebar {
55
+ background: var(--nav);
56
+ color: #f5f7fa;
57
+ padding: 22px 18px;
58
+ border-right: 1px solid #24282f;
59
+ }
60
+
61
+ .df-brand {
62
+ display: flex;
63
+ align-items: center;
64
+ gap: 10px;
65
+ margin-bottom: 28px;
66
+ }
67
+
68
+ .df-mark {
69
+ width: 30px;
70
+ height: 30px;
71
+ border: 1px solid #3a404a;
72
+ border-radius: 8px;
73
+ display: grid;
74
+ place-items: center;
75
+ color: #9df2bd;
76
+ background: #181c20;
77
+ font-weight: 750;
78
+ }
79
+
80
+ .df-brand-name { font-size: 15px; font-weight: 700; }
81
+ .df-brand-subtitle { color: var(--nav-muted); font-size: 12px; margin-top: 2px; }
82
+
83
+ .df-nav-label {
84
+ color: #707986;
85
+ font-size: 11px;
86
+ font-weight: 700;
87
+ text-transform: uppercase;
88
+ margin: 22px 10px 8px;
89
+ }
90
+
91
+ .df-nav-item {
92
+ display: flex;
93
+ align-items: center;
94
+ gap: 10px;
95
+ min-height: 38px;
96
+ padding: 0 10px;
97
+ border-radius: 8px;
98
+ color: #e8edf3;
99
+ font-weight: 600;
100
+ }
101
+
102
+ .df-nav-item.active { background: #20252b; }
103
+ .df-nav-dot { width: 7px; height: 7px; border-radius: 50%; background: #6ee7a8; }
104
+
105
+ .df-sidebar-note {
106
+ color: var(--nav-muted);
107
+ font-size: 12px;
108
+ line-height: 1.55;
109
+ margin: 24px 10px 0;
110
+ }
111
+
112
+ .df-main { min-width: 0; }
113
+
114
+ .df-page {
115
+ max-width: 1280px;
116
+ margin: 0 auto;
117
+ padding: 28px 32px 42px;
118
+ }
119
+
120
+ .df-header {
121
+ display: flex;
122
+ align-items: flex-start;
123
+ justify-content: space-between;
124
+ gap: 22px;
125
+ margin-bottom: 22px;
126
+ }
127
+
128
+ .df-kicker {
129
+ color: var(--muted);
130
+ font-size: 12px;
131
+ font-weight: 700;
132
+ text-transform: uppercase;
133
+ margin-bottom: 6px;
134
+ }
135
+
136
+ h1, h2, h3, p { margin-top: 0; }
137
+
138
+ h1 {
139
+ margin-bottom: 6px;
140
+ font-size: 28px;
141
+ line-height: 1.16;
142
+ letter-spacing: 0;
143
+ }
144
+
145
+ h2 { font-size: 16px; margin-bottom: 14px; }
146
+ h3 { font-size: 13px; margin-bottom: 10px; }
147
+
148
+ .df-muted { color: var(--muted); }
149
+ .df-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
150
+
151
+ .df-button {
152
+ display: inline-flex;
153
+ align-items: center;
154
+ gap: 8px;
155
+ min-height: 36px;
156
+ padding: 0 12px;
157
+ border: 1px solid var(--line-strong);
158
+ border-radius: 8px;
159
+ background: var(--panel);
160
+ color: var(--ink);
161
+ font-weight: 650;
162
+ }
163
+
164
+ .df-stats {
165
+ display: grid;
166
+ grid-template-columns: repeat(4, minmax(0, 1fr));
167
+ gap: 12px;
168
+ margin-bottom: 18px;
169
+ }
170
+
171
+ .df-stat {
172
+ background: var(--panel);
173
+ border: 1px solid var(--line);
174
+ border-radius: 8px;
175
+ padding: 14px 15px;
176
+ box-shadow: var(--shadow);
177
+ }
178
+
179
+ .df-stat-label { color: var(--muted); font-size: 12px; font-weight: 650; }
180
+ .df-stat-value { margin-top: 7px; font-size: 26px; line-height: 1; font-weight: 760; }
181
+
182
+ .df-panel {
183
+ background: var(--panel);
184
+ border: 1px solid var(--line);
185
+ border-radius: 8px;
186
+ box-shadow: var(--shadow);
187
+ }
188
+
189
+ .df-panel-header {
190
+ display: flex;
191
+ align-items: center;
192
+ justify-content: space-between;
193
+ gap: 16px;
194
+ min-height: 58px;
195
+ padding: 14px 16px;
196
+ border-bottom: 1px solid var(--line);
197
+ }
198
+
199
+ .df-panel-title { font-weight: 750; }
200
+ .df-panel-subtitle { color: var(--muted); font-size: 12px; margin-top: 2px; }
201
+
202
+ .df-filter {
203
+ min-width: 260px;
204
+ height: 34px;
205
+ border: 1px solid var(--line);
206
+ border-radius: 8px;
207
+ background: var(--panel-soft);
208
+ color: var(--subtle);
209
+ display: flex;
210
+ align-items: center;
211
+ padding: 0 11px;
212
+ font-size: 13px;
213
+ }
214
+
215
+ table {
216
+ width: 100%;
217
+ border-collapse: collapse;
218
+ table-layout: fixed;
219
+ }
220
+
221
+ th, td {
222
+ padding: 13px 16px;
223
+ border-bottom: 1px solid var(--line);
224
+ text-align: left;
225
+ vertical-align: middle;
226
+ }
227
+
228
+ th {
229
+ color: var(--muted);
230
+ font-size: 11px;
231
+ font-weight: 760;
232
+ text-transform: uppercase;
233
+ }
234
+
235
+ tbody tr:hover { background: #fbfcfd; }
236
+ tbody tr:last-child td { border-bottom: 0; }
237
+
238
+ .df-run-link { font-weight: 700; color: #1d4f91; }
239
+ .df-run-id { margin-top: 4px; color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
240
+
241
+ .df-status {
242
+ display: inline-flex;
243
+ align-items: center;
244
+ gap: 7px;
245
+ min-height: 24px;
246
+ padding: 0 9px;
247
+ border-radius: 999px;
248
+ font-size: 12px;
249
+ font-weight: 750;
250
+ }
251
+
252
+ .df-status::before {
253
+ content: "";
254
+ width: 7px;
255
+ height: 7px;
256
+ border-radius: 50%;
257
+ background: currentColor;
258
+ }
259
+
260
+ .df-status.success { color: var(--green); background: var(--green-soft); }
261
+ .df-status.active { color: var(--blue); background: var(--blue-soft); }
262
+ .df-status.waiting { color: var(--amber); background: var(--amber-soft); }
263
+ .df-status.danger { color: var(--red); background: var(--red-soft); }
264
+ .df-status.neutral { color: var(--purple); background: var(--purple-soft); }
265
+
266
+ .df-detail-grid {
267
+ display: grid;
268
+ grid-template-columns: minmax(0, 1.45fr) minmax(320px, 0.65fr);
269
+ gap: 16px;
270
+ align-items: start;
271
+ }
272
+
273
+ .df-meta-grid {
274
+ display: grid;
275
+ grid-template-columns: repeat(4, minmax(0, 1fr));
276
+ gap: 10px;
277
+ margin-bottom: 16px;
278
+ }
279
+
280
+ .df-meta {
281
+ background: var(--panel);
282
+ border: 1px solid var(--line);
283
+ border-radius: 8px;
284
+ padding: 12px 13px;
285
+ }
286
+
287
+ .df-meta-label { color: var(--muted); font-size: 11px; font-weight: 720; text-transform: uppercase; }
288
+ .df-meta-value { margin-top: 6px; font-weight: 680; overflow-wrap: anywhere; }
289
+
290
+ .df-timeline { padding: 6px 0; }
291
+
292
+ .df-step {
293
+ display: grid;
294
+ grid-template-columns: 46px minmax(0, 1fr);
295
+ gap: 0;
296
+ padding: 0 16px;
297
+ }
298
+
299
+ .df-step-rail {
300
+ position: relative;
301
+ display: flex;
302
+ justify-content: center;
303
+ }
304
+
305
+ .df-step-rail::after {
306
+ content: "";
307
+ position: absolute;
308
+ top: 32px;
309
+ bottom: -8px;
310
+ width: 1px;
311
+ background: var(--line);
312
+ }
313
+
314
+ .df-step:last-child .df-step-rail::after { display: none; }
315
+
316
+ .df-step-dot {
317
+ width: 18px;
318
+ height: 18px;
319
+ border-radius: 50%;
320
+ margin-top: 18px;
321
+ border: 3px solid var(--panel);
322
+ box-shadow: 0 0 0 1px var(--line-strong);
323
+ background: var(--subtle);
324
+ z-index: 1;
325
+ }
326
+
327
+ .df-step-dot.success { background: var(--green); }
328
+ .df-step-dot.active { background: var(--blue); }
329
+ .df-step-dot.waiting { background: var(--amber); }
330
+ .df-step-dot.danger { background: var(--red); }
331
+ .df-step-dot.neutral { background: var(--purple); }
332
+
333
+ .df-step-body {
334
+ min-width: 0;
335
+ padding: 13px 0 16px;
336
+ border-bottom: 1px solid var(--line);
337
+ }
338
+
339
+ .df-step:last-child .df-step-body { border-bottom: 0; }
340
+
341
+ .df-step-top {
342
+ display: flex;
343
+ align-items: center;
344
+ justify-content: space-between;
345
+ gap: 12px;
346
+ }
347
+
348
+ .df-step-name { font-size: 14px; font-weight: 750; overflow-wrap: anywhere; }
349
+ .df-step-meta { color: var(--muted); font-size: 12px; margin-top: 5px; }
350
+
351
+ .df-code {
352
+ margin: 12px 0 0;
353
+ padding: 12px;
354
+ border: 1px solid var(--line);
355
+ border-radius: 8px;
356
+ background: #fbfcfd;
357
+ color: #2b3139;
358
+ overflow-x: auto;
359
+ white-space: pre-wrap;
360
+ font-size: 12px;
361
+ line-height: 1.55;
362
+ }
363
+
364
+ .df-step-logs {
365
+ margin-top: 12px;
366
+ border: 1px solid var(--line);
367
+ border-radius: 8px;
368
+ background: #fbfcfd;
369
+ overflow: hidden;
370
+ }
371
+
372
+ .df-step-logs-label {
373
+ padding: 9px 12px;
374
+ border-bottom: 1px solid var(--line);
375
+ color: var(--muted);
376
+ font-size: 11px;
377
+ font-weight: 760;
378
+ text-transform: uppercase;
379
+ }
380
+
381
+ .df-step-log {
382
+ padding: 11px 12px;
383
+ border-bottom: 1px solid var(--line);
384
+ }
385
+
386
+ .df-step-log:last-child { border-bottom: 0; }
387
+
388
+ .df-step-log-top {
389
+ display: flex;
390
+ align-items: center;
391
+ gap: 9px;
392
+ flex-wrap: wrap;
393
+ }
394
+
395
+ .df-step-log-message { font-weight: 700; }
396
+
397
+ .df-side-section {
398
+ padding: 15px 16px;
399
+ border-bottom: 1px solid var(--line);
400
+ }
401
+
402
+ .df-side-section:last-child { border-bottom: 0; }
403
+
404
+ .df-kv {
405
+ display: grid;
406
+ grid-template-columns: 112px minmax(0, 1fr);
407
+ gap: 10px;
408
+ padding: 8px 0;
409
+ border-bottom: 1px solid var(--line);
410
+ }
411
+
412
+ .df-kv:last-child { border-bottom: 0; }
413
+ .df-kv-key { color: var(--muted); font-size: 12px; }
414
+ .df-kv-value { min-width: 0; overflow-wrap: anywhere; }
415
+
416
+ .df-empty {
417
+ padding: 26px 16px;
418
+ color: var(--muted);
419
+ text-align: center;
420
+ }
421
+
422
+ @media (max-width: 900px) {
423
+ .df-shell { grid-template-columns: 1fr; }
424
+ .df-sidebar { display: none; }
425
+ .df-page { padding: 22px 16px 32px; }
426
+ .df-header { flex-direction: column; }
427
+ .df-stats, .df-meta-grid, .df-detail-grid { grid-template-columns: 1fr; }
428
+ .df-filter { min-width: 100%; }
429
+ th, td { padding: 11px 12px; }
430
+ .df-hide-sm { display: none; }
431
+ }
432
+ </style>
433
+ </head>
434
+ <body>
435
+ <div class="df-shell">
436
+ <aside class="df-sidebar">
437
+ <div class="df-brand">
438
+ <div class="df-mark">D</div>
439
+ <div>
440
+ <div class="df-brand-name">DurableFlow</div>
441
+ <div class="df-brand-subtitle">Workflow runs</div>
442
+ </div>
443
+ </div>
444
+
445
+ <div class="df-nav-label">Observe</div>
446
+ <%= link_to workflow_runs_path, class: "df-nav-item active" do %>
447
+ <span class="df-nav-dot"></span>
448
+ Runs
449
+ <% end %>
450
+
451
+ <div class="df-nav-label">Runtime</div>
452
+ <div class="df-sidebar-note">
453
+ Steps, sleeps, waits, and events are persisted in your Rails database and resumed through Active Job.
454
+ </div>
455
+ </aside>
456
+
457
+ <main class="df-main">
458
+ <div class="df-page">
459
+ <%= yield %>
460
+ </div>
461
+ </main>
462
+ </div>
463
+ </body>
464
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ DurableFlow::Engine.routes.draw do
4
+ root to: "workflow_runs#index"
5
+ resources :workflow_runs, only: [ :index, :show ], param: :run_id
6
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableFlow
4
+ class Dispatcher
5
+ class << self
6
+ def dispatch(event)
7
+ WorkflowWait.pending.where(event_name: event.name).find_each do |wait|
8
+ next unless wait.matches_event?(event)
9
+
10
+ wait.update!(status: "matched", workflow_event: event)
11
+ wait.workflow_run.update!(status: "ready")
12
+ enqueue(wait.workflow_run)
13
+ end
14
+ end
15
+
16
+ def enqueue(workflow_run)
17
+ return if workflow_run.terminal?
18
+ return if workflow_run.serialized_job.blank?
19
+
20
+ job = ActiveJob::Base.deserialize(workflow_run.serialized_job)
21
+ job.scheduled_at = nil
22
+ job.enqueue
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableFlow
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace DurableFlow
6
+ end
7
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableFlow
4
+ class Error < StandardError; end
5
+
6
+ class MissingStepResultError < Error; end
7
+
8
+ class WaitTimeoutError < Error
9
+ attr_reader :event_name, :step_name
10
+
11
+ def initialize(event_name:, step_name:)
12
+ @event_name = event_name
13
+ @step_name = step_name
14
+ super("Timed out waiting for event #{event_name.inspect} in step #{step_name.inspect}")
15
+ end
16
+ end
17
+
18
+ class Pause < Exception
19
+ attr_reader :reason, :status
20
+
21
+ def initialize(reason:, status:)
22
+ @reason = reason
23
+ @status = status
24
+ super("Paused workflow (#{reason})")
25
+ end
26
+ end
27
+
28
+ class Interrupt < ActiveJob::Continuation::Interrupt
29
+ attr_reader :reason, :resume_options, :status
30
+
31
+ def initialize(reason:, resume_options:, status:)
32
+ @reason = reason
33
+ @resume_options = resume_options
34
+ @status = status
35
+ super("Interrupted workflow (#{reason})")
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableFlow
4
+ class EventSubscriber
5
+ def emit(event)
6
+ return unless DurableFlow.database_ready?
7
+ return if Fiber[:durable_flow_recording_event]
8
+
9
+ Fiber[:durable_flow_recording_event] = true
10
+ workflow_event = WorkflowEvent.create!(
11
+ name: event.fetch(:name).to_s,
12
+ payload: Serializer.dump(event[:payload] || {}),
13
+ tags: Serializer.dump(event[:tags] || {}),
14
+ context: Serializer.dump(event[:context] || {}),
15
+ source_location: Serializer.dump(event[:source_location] || {}),
16
+ occurred_at: occurred_at(event[:timestamp]),
17
+ )
18
+
19
+ Dispatcher.dispatch(workflow_event)
20
+ ensure
21
+ Fiber[:durable_flow_recording_event] = false
22
+ end
23
+
24
+ private
25
+ def occurred_at(timestamp)
26
+ return Time.current unless timestamp
27
+
28
+ Time.at(timestamp.to_r / 1_000_000_000).utc
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableFlow
4
+ module Live
5
+ Change = Struct.new(
6
+ :type,
7
+ :run_id,
8
+ :workflow_class,
9
+ :record_class,
10
+ :record_id,
11
+ :record,
12
+ :snapshot,
13
+ :occurred_at,
14
+ keyword_init: true,
15
+ ) do
16
+ def self.from_record(record, action:)
17
+ workflow_run = workflow_run_for(record)
18
+
19
+ new(
20
+ type: "#{record.model_name.element}.#{action}",
21
+ run_id: workflow_run&.run_id,
22
+ workflow_class: workflow_run&.workflow_class,
23
+ record_class: record.class.name,
24
+ record_id: record.id,
25
+ record: record,
26
+ snapshot: record.live_snapshot,
27
+ occurred_at: Time.current,
28
+ )
29
+ end
30
+
31
+ def payload
32
+ snapshot.merge(
33
+ type: type,
34
+ run_id: run_id,
35
+ workflow_class: workflow_class,
36
+ record_class: record_class,
37
+ record_id: record_id,
38
+ occurred_at: occurred_at,
39
+ ).compact
40
+ end
41
+
42
+ def self.workflow_run_for(record)
43
+ return record if record.is_a?(WorkflowRun)
44
+ return record.workflow_run if record.respond_to?(:workflow_run)
45
+ end
46
+ private_class_method :workflow_run_for
47
+ end
48
+
49
+ module Broadcastable
50
+ extend ActiveSupport::Concern
51
+
52
+ included do
53
+ after_commit :broadcast_live_change, on: [ :create, :update ]
54
+ end
55
+
56
+ def live_snapshot
57
+ attributes.symbolize_keys
58
+ end
59
+
60
+ private
61
+ def broadcast_live_change
62
+ action = previously_new_record? ? "created" : "updated"
63
+ DurableFlow.broadcast_change(Change.from_record(self, action: action))
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableFlow
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableFlow
4
+ class WorkflowEvent < ApplicationRecord
5
+ include Live::Broadcastable
6
+
7
+ self.table_name = "durable_flow_workflow_events"
8
+
9
+ has_many :workflow_waits, class_name: "DurableFlow::WorkflowWait"
10
+
11
+ scope :named, ->(name) { where(name: name.to_s) }
12
+
13
+ def payload_value
14
+ Serializer.load(payload)
15
+ end
16
+
17
+ def matches_payload?(expected)
18
+ self.class.subset?(expected || {}, payload_value)
19
+ end
20
+
21
+ def self.subset?(expected, actual)
22
+ return true if expected.blank?
23
+ return expected == actual unless expected.is_a?(Hash)
24
+ return false unless actual.respond_to?(:[])
25
+
26
+ expected.all? do |key, expected_value|
27
+ actual_value = if actual.respond_to?(:key?) && actual.key?(key)
28
+ actual[key]
29
+ elsif actual.respond_to?(:key?) && actual.key?(key.to_s)
30
+ actual[key.to_s]
31
+ elsif actual.respond_to?(:key?) && actual.key?(key.to_sym)
32
+ actual[key.to_sym]
33
+ else
34
+ actual[key]
35
+ end
36
+
37
+ subset?(expected_value, actual_value)
38
+ end
39
+ end
40
+
41
+ def live_snapshot
42
+ {
43
+ id: id,
44
+ name: name,
45
+ payload: payload,
46
+ tags: tags,
47
+ context: context,
48
+ source_location: source_location,
49
+ occurred_at: occurred_at,
50
+ created_at: created_at,
51
+ updated_at: updated_at,
52
+ }
53
+ end
54
+ end
55
+ end