durable_flow 0.1.0 → 0.2.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 +4 -4
- data/README.md +92 -17
- data/app/controllers/durable_flow/workflow_runs_controller.rb +136 -0
- data/app/views/durable_flow/workflow_runs/definition.html.erb +145 -0
- data/app/views/durable_flow/workflow_runs/index.html.erb +4 -0
- data/app/views/durable_flow/workflow_runs/show.html.erb +4 -1
- data/app/views/layouts/durable_flow/application.html.erb +88 -1
- data/config/routes.rb +5 -1
- data/lib/durable_flow/child_workflow_builder.rb +26 -0
- data/lib/durable_flow/definition_analyzer.rb +342 -0
- data/lib/durable_flow/definition_graph.rb +99 -0
- data/lib/durable_flow/errors.rb +18 -0
- data/lib/durable_flow/models/workflow_step.rb +26 -0
- data/lib/durable_flow/step_proxy.rb +52 -3
- data/lib/durable_flow/test_helper.rb +40 -0
- data/lib/durable_flow/version.rb +1 -1
- data/lib/durable_flow/workflow.rb +328 -25
- data/lib/durable_flow.rb +6 -0
- metadata +19 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 958d0ca64fa3941faa0ac018e61d07dddadf9529e50aeeb5ecf462244cac0586
|
|
4
|
+
data.tar.gz: 127aa82a9307c5fb3550cba8cbb466160f423d9c594a97a4fde0ad34885e66f5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7010c414b4529becbcd04338cd6655732f506a892991befd602141466aa43c7764602aa64c093b80263357690b123ff464f3cbc44477814b247166b8318423b8
|
|
7
|
+
data.tar.gz: 78eadf53c4c16297308f125399c120de4754353ef53334cfba84f383f25daa54e122e1212d816af4eaa7fbdb6d0135ab0de2dd6d8723723a8e1fc822908d4424
|
data/README.md
CHANGED
|
@@ -7,17 +7,17 @@ It lets you write long-running business workflows as normal Ruby methods. Side e
|
|
|
7
7
|
```ruby
|
|
8
8
|
class WelcomeWorkflow < DurableFlow::Workflow
|
|
9
9
|
def perform(user_id:, trial_id:)
|
|
10
|
-
user = step(:load_user) { User.find(user_id) }
|
|
10
|
+
user = step.run(:load_user) { User.find(user_id) }
|
|
11
11
|
|
|
12
|
-
step(:send_welcome) { UserMailer.welcome(user).deliver_now }
|
|
12
|
+
step.run(:send_welcome) { UserMailer.welcome(user).deliver_now }
|
|
13
13
|
|
|
14
14
|
step.sleep(:trial_delay, 1.day)
|
|
15
15
|
|
|
16
|
-
trial = step(:start_trial) { Billing.start_trial!(user, trial_id:) }
|
|
16
|
+
trial = step.run(:start_trial) { Billing.start_trial!(user, trial_id:) }
|
|
17
17
|
|
|
18
18
|
event = step.wait_for_event(:trial_confirmed, timeout: 7.days, match: { trial_id: trial.id })
|
|
19
19
|
|
|
20
|
-
step(:finalize) { user.update!(onboarded_at: Time.current, confirmed_at: event[:confirmed_at]) }
|
|
20
|
+
step.run(:finalize) { user.update!(onboarded_at: Time.current, confirmed_at: event[:confirmed_at]) }
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
23
|
```
|
|
@@ -30,12 +30,16 @@ DurableFlow is alpha software. The API and storage model will likely change as t
|
|
|
30
30
|
|
|
31
31
|
## UI
|
|
32
32
|
|
|
33
|
-
DurableFlow ships with a small mountable Rails engine for inspecting workflow runs, step timelines, waits, workflow logs, arguments, and errors.
|
|
33
|
+
DurableFlow ships with a small mountable Rails engine for inspecting workflow runs, definition DAGs, step timelines, waits, workflow logs, arguments, and errors.
|
|
34
34
|
|
|
35
35
|

|
|
36
36
|
|
|
37
37
|

|
|
38
38
|
|
|
39
|
+
Each run detail links to a definition DAG view that statically analyzes the workflow class and overlays runtime status from that run.
|
|
40
|
+
|
|
41
|
+

|
|
42
|
+
|
|
39
43
|
## Live Updates
|
|
40
44
|
|
|
41
45
|
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.
|
|
@@ -147,6 +151,8 @@ Verified behavior:
|
|
|
147
151
|
- Durable sleep through `perform_later(wait_until:)`.
|
|
148
152
|
- Event waits through `Rails.event`.
|
|
149
153
|
- Parent workflows waiting for child workflow completion.
|
|
154
|
+
- Parent workflows choosing whether child failures raise or return completion payloads.
|
|
155
|
+
- Active Job retry/discard handling reflected in workflow and step status.
|
|
150
156
|
- Solid Queue `1.1.2` integration.
|
|
151
157
|
- Database-backed workflow execution leases to prevent concurrent execution of the same run.
|
|
152
158
|
- Opt-in live lifecycle broadcasts through `DurableFlow.live_broadcaster`.
|
|
@@ -280,12 +286,14 @@ Wake it with a Rails event:
|
|
|
280
286
|
Rails.event.notify(:trial_activated, trial_id: trial.id, source: "checkout")
|
|
281
287
|
```
|
|
282
288
|
|
|
289
|
+
For a dense API reference optimized for coding agents, see [docs/llm-reference.md](docs/llm-reference.md).
|
|
290
|
+
|
|
283
291
|
## Step API
|
|
284
292
|
|
|
285
293
|
Memoized side-effect step:
|
|
286
294
|
|
|
287
295
|
```ruby
|
|
288
|
-
order = step(:create_order) { Order.create!(cart:) }
|
|
296
|
+
order = step.run(:create_order) { Order.create!(cart:) }
|
|
289
297
|
```
|
|
290
298
|
|
|
291
299
|
Durable sleep:
|
|
@@ -293,6 +301,7 @@ Durable sleep:
|
|
|
293
301
|
```ruby
|
|
294
302
|
step.sleep(:retry_tomorrow, 1.day)
|
|
295
303
|
step.sleep(:wait_until_send_at, until: campaign.send_at)
|
|
304
|
+
step.sleep_until(:wait_until_send_at, campaign.send_at)
|
|
296
305
|
```
|
|
297
306
|
|
|
298
307
|
Wait for a Rails event:
|
|
@@ -307,17 +316,72 @@ Use a different event name than the step name:
|
|
|
307
316
|
event = step.wait_for_event(:wait_for_charge, event: :stripe_charge_succeeded, match: { charge_id: charge.id })
|
|
308
317
|
```
|
|
309
318
|
|
|
310
|
-
|
|
319
|
+
Invoke a child workflow and wait for its result:
|
|
320
|
+
|
|
321
|
+
```ruby
|
|
322
|
+
completion = step.invoke(:send_invoice, SendInvoiceWorkflow, invoice.id, timeout: 1.hour)
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
If child startup needs custom code, return the enqueued job or run id from a block:
|
|
326
|
+
|
|
327
|
+
```ruby
|
|
328
|
+
completion = step.invoke(:send_invoice, timeout: 1.hour) do
|
|
329
|
+
SendInvoiceWorkflow.perform_later(invoice.id, source: "renewal")
|
|
330
|
+
end
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Fan out to child workflows and wait for all of them:
|
|
334
|
+
|
|
335
|
+
```ruby
|
|
336
|
+
completions = step.invoke_each(:send_invoice, invoices, timeout: 1.hour, concurrency: 25) do |invoice|
|
|
337
|
+
step.workflow(SendInvoiceWorkflow, invoice.id, key: invoice.id)
|
|
338
|
+
end
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
The block passed to `step.invoke_each` only declares child workflow requests. DurableFlow starts each child inside a named durable step, then waits for matching child completion events. This mirrors Inngest `step.invoke` and Restate workflow calls while keeping Ruby call sites explicit.
|
|
342
|
+
|
|
343
|
+
For workflows that read better as explicit starts, use the builder form:
|
|
344
|
+
|
|
345
|
+
```ruby
|
|
346
|
+
completions = step.child_workflows(:send_invoices, timeout: 1.hour) do |children|
|
|
347
|
+
invoices.each do |invoice|
|
|
348
|
+
children.workflow(SendInvoiceWorkflow, invoice.id, key: invoice.id)
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
If the item itself knows how to start its child workflow, it can provide a stable `workflow_key` and either `perform_later` or `workflow_class` with `workflow_args` / `workflow_kwargs`:
|
|
354
|
+
|
|
355
|
+
```ruby
|
|
356
|
+
class SendInvoiceRequest
|
|
357
|
+
def initialize(invoice)
|
|
358
|
+
@invoice = invoice
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def workflow_key = @invoice.id
|
|
362
|
+
def workflow_class = SendInvoiceWorkflow
|
|
363
|
+
def workflow_args = [ @invoice.id ]
|
|
364
|
+
end
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
`step.invoke_each` and `step.child_workflows` start every child in the batch before waiting for the first one. Pass `concurrency:` to process requests in fixed-size batches. Child failures raise `DurableFlow::ChildWorkflowFailedError` in the parent workflow.
|
|
368
|
+
|
|
369
|
+
If a parent should collect child failures and decide what to do, pass `on_failure: :return`:
|
|
311
370
|
|
|
312
371
|
```ruby
|
|
313
|
-
|
|
314
|
-
|
|
372
|
+
completions = step.invoke_each(:deliver_invoice, invoices, timeout: 1.hour, on_failure: :return) do |invoice|
|
|
373
|
+
step.workflow(DeliverInvoiceWorkflow, invoice.id, key: invoice.id)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
failed = completions.select { |completion| completion.fetch("status") == "failed" }
|
|
315
377
|
```
|
|
316
378
|
|
|
379
|
+
The default is `on_failure: :raise`, which fails the parent workflow when a child finishes failed. Returned failure completions include the child run id, status, workflow class, and error fields from the child completion event.
|
|
380
|
+
|
|
317
381
|
Write structured workflow logs:
|
|
318
382
|
|
|
319
383
|
```ruby
|
|
320
|
-
step(:create_refund) do
|
|
384
|
+
step.run(:create_refund) do
|
|
321
385
|
log.info("Creating refund", refund_id: refund.id, amount_cents: refund.amount_cents)
|
|
322
386
|
Refunds.create!(refund)
|
|
323
387
|
end
|
|
@@ -349,15 +413,26 @@ end
|
|
|
349
413
|
Parallel or high-cardinality work: fan out to child workflows.
|
|
350
414
|
|
|
351
415
|
```ruby
|
|
352
|
-
|
|
353
|
-
|
|
416
|
+
step.invoke_each(:sync_user, account.users.to_a, timeout: 30.minutes, concurrency: 25) do |user|
|
|
417
|
+
step.workflow(SyncUserWorkflow, user.id, key: user.id)
|
|
354
418
|
end
|
|
419
|
+
```
|
|
355
420
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
421
|
+
## Definition DAGs
|
|
422
|
+
|
|
423
|
+
DurableFlow can statically analyze a workflow's `perform` method and produce a conservative definition graph from durable primitives:
|
|
424
|
+
|
|
425
|
+
```ruby
|
|
426
|
+
graph = DurableFlow::DefinitionAnalyzer.call(IvwDailyDeliveryWorkflow)
|
|
427
|
+
graph.nodes # durable steps, sleeps, waits, calls, fan-out groups
|
|
428
|
+
graph.edges # possible execution order, including branch conditions
|
|
429
|
+
graph.warnings # dynamic Ruby that could not be represented precisely
|
|
359
430
|
```
|
|
360
431
|
|
|
432
|
+
The analyzer uses Prism and recognizes calls such as `step.run`, `step.sleep_until`, `step.wait_for_event`, `step.invoke`, and `step.invoke_each`. It does not execute workflow code. Ruby branches become conditional edges, fan-out calls become grouped nodes, and dynamic loops with hidden durable steps are reported as warnings.
|
|
433
|
+
|
|
434
|
+
Runtime timelines remain the source of truth for a specific run; definition DAGs are the static map.
|
|
435
|
+
|
|
361
436
|
## Rules
|
|
362
437
|
|
|
363
438
|
- Put side effects inside `step` blocks.
|
|
@@ -428,7 +503,7 @@ class TrialOnboardingWorkflowTest < ActiveSupport::TestCase
|
|
|
428
503
|
end
|
|
429
504
|
```
|
|
430
505
|
|
|
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`.
|
|
506
|
+
Useful helpers include `perform_durable_flow_jobs`, `perform_durable_flow_until_idle`, `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_workflow_waiting_for_workflow`, `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
507
|
|
|
433
508
|
Run the suite against the vendored Rails copy:
|
|
434
509
|
|
|
@@ -445,7 +520,7 @@ RAILS_VERSION=8.1.3 mise exec ruby@3.4 -- bundle exec rake test
|
|
|
445
520
|
Current suite:
|
|
446
521
|
|
|
447
522
|
```text
|
|
448
|
-
|
|
523
|
+
49 runs, 346 assertions, 0 failures, 0 errors, 0 skips
|
|
449
524
|
```
|
|
450
525
|
|
|
451
526
|
## Publishing
|
|
@@ -5,8 +5,13 @@ module DurableFlow
|
|
|
5
5
|
layout "durable_flow/application"
|
|
6
6
|
|
|
7
7
|
helper_method :durable_flow_duration,
|
|
8
|
+
:durable_flow_definition_class,
|
|
9
|
+
:durable_flow_definition_graph_layout,
|
|
10
|
+
:durable_flow_definition_node_status,
|
|
11
|
+
:durable_flow_definition_short_condition,
|
|
8
12
|
:durable_flow_format_time,
|
|
9
13
|
:durable_flow_json,
|
|
14
|
+
:durable_flow_node_label,
|
|
10
15
|
:durable_flow_status_class
|
|
11
16
|
|
|
12
17
|
def index
|
|
@@ -20,7 +25,138 @@ module DurableFlow
|
|
|
20
25
|
@workflow_timeline = @workflow_run.timeline
|
|
21
26
|
end
|
|
22
27
|
|
|
28
|
+
def definition
|
|
29
|
+
@workflow_run = WorkflowRun.find_by!(run_id: params[:run_id])
|
|
30
|
+
@workflow_class = @workflow_run.workflow_class.safe_constantize
|
|
31
|
+
@workflow_timeline = @workflow_run.timeline
|
|
32
|
+
@step_statuses_by_name = @workflow_run.workflow_steps.index_by(&:name).transform_values(&:status)
|
|
33
|
+
|
|
34
|
+
if @workflow_class.nil?
|
|
35
|
+
@definition_error = "Could not constantize #{@workflow_run.workflow_class.inspect}"
|
|
36
|
+
elsif !(@workflow_class <= DurableFlow::Workflow)
|
|
37
|
+
@definition_error = "#{@workflow_run.workflow_class} is not a DurableFlow::Workflow"
|
|
38
|
+
else
|
|
39
|
+
@definition_graph = DurableFlow::DefinitionAnalyzer.call(@workflow_class)
|
|
40
|
+
end
|
|
41
|
+
rescue StandardError => error
|
|
42
|
+
@definition_error = "#{error.class}: #{error.message}"
|
|
43
|
+
end
|
|
44
|
+
|
|
23
45
|
private
|
|
46
|
+
def durable_flow_definition_node_status(node, statuses_by_name)
|
|
47
|
+
direct_status = statuses_by_name[node.name]
|
|
48
|
+
return direct_status if direct_status
|
|
49
|
+
|
|
50
|
+
wait_status = statuses_by_name["#{node.name}_wait"]
|
|
51
|
+
return wait_status if wait_status
|
|
52
|
+
|
|
53
|
+
start_status = statuses_by_name["#{node.name}_start"]
|
|
54
|
+
return durable_flow_child_start_status(start_status) if start_status
|
|
55
|
+
|
|
56
|
+
fanout_statuses = statuses_by_name.filter_map do |step_name, status|
|
|
57
|
+
status if step_name.match?(/\A#{Regexp.escape(node.name)}_.+_(start|wait)\z/)
|
|
58
|
+
end
|
|
59
|
+
return "defined" if fanout_statuses.empty?
|
|
60
|
+
|
|
61
|
+
durable_flow_aggregate_status(fanout_statuses)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def durable_flow_child_start_status(status)
|
|
65
|
+
status == "succeeded" ? "running" : status
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def durable_flow_aggregate_status(statuses)
|
|
69
|
+
return "failed" if statuses.include?("failed")
|
|
70
|
+
return "waiting" if statuses.include?("waiting")
|
|
71
|
+
return "sleeping" if statuses.include?("sleeping")
|
|
72
|
+
return "running" if statuses.any? { |status| %w[pending running retrying ready enqueued].include?(status) }
|
|
73
|
+
return "succeeded" if statuses.all? { |status| status == "succeeded" }
|
|
74
|
+
|
|
75
|
+
"running"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def durable_flow_definition_class(node, statuses_by_name)
|
|
79
|
+
status = durable_flow_definition_node_status(node, statuses_by_name)
|
|
80
|
+
status == "defined" ? "neutral" : durable_flow_status_class(status)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def durable_flow_node_label(type)
|
|
84
|
+
case type.to_s
|
|
85
|
+
when "wait_event"
|
|
86
|
+
"wait event"
|
|
87
|
+
when "workflow_call"
|
|
88
|
+
"workflow call"
|
|
89
|
+
else
|
|
90
|
+
type.to_s.tr("_", " ")
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def durable_flow_definition_short_condition(condition)
|
|
95
|
+
condition = condition.to_s
|
|
96
|
+
return condition if condition.length <= 34
|
|
97
|
+
|
|
98
|
+
"#{condition.first(31)}…"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def durable_flow_definition_graph_layout(graph)
|
|
102
|
+
nodes = graph.nodes
|
|
103
|
+
node_width = 300
|
|
104
|
+
node_height = 76
|
|
105
|
+
column_gap = 58
|
|
106
|
+
row_gap = 124
|
|
107
|
+
padding_x = 56
|
|
108
|
+
padding_y = 52
|
|
109
|
+
ranks = durable_flow_definition_node_ranks(graph)
|
|
110
|
+
nodes_by_rank = nodes.group_by { |node| ranks.fetch(node.id, 0) }
|
|
111
|
+
max_columns = [ nodes_by_rank.values.map(&:size).max.to_i, 1 ].max
|
|
112
|
+
width = [ 880, (padding_x * 2) + (max_columns * node_width) + ((max_columns - 1) * column_gap) ].max
|
|
113
|
+
|
|
114
|
+
positions = {}
|
|
115
|
+
nodes_by_rank.keys.sort.each do |rank|
|
|
116
|
+
ranked_nodes = nodes_by_rank.fetch(rank).sort_by { |node| [ node.source_line || 0, node.id ] }
|
|
117
|
+
row_width = (ranked_nodes.size * node_width) + ([ ranked_nodes.size - 1, 0 ].max * column_gap)
|
|
118
|
+
start_x = (width - row_width) / 2
|
|
119
|
+
|
|
120
|
+
ranked_nodes.each_with_index do |node, index|
|
|
121
|
+
positions[node.id] = {
|
|
122
|
+
x: start_x + (index * (node_width + column_gap)),
|
|
123
|
+
y: padding_y + (rank * row_gap),
|
|
124
|
+
}
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
max_rank = ranks.values.max.to_i
|
|
129
|
+
{
|
|
130
|
+
width: width,
|
|
131
|
+
height: [ (padding_y * 2) + node_height + (max_rank * row_gap), 260 ].max,
|
|
132
|
+
node_width: node_width,
|
|
133
|
+
node_height: node_height,
|
|
134
|
+
positions: positions,
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def durable_flow_definition_node_ranks(graph)
|
|
139
|
+
ranks = graph.nodes.to_h { |node| [ node.id, 0 ] }
|
|
140
|
+
|
|
141
|
+
graph.nodes.size.times do
|
|
142
|
+
changed = false
|
|
143
|
+
|
|
144
|
+
graph.edges.each do |edge|
|
|
145
|
+
next unless ranks.key?(edge.from) && ranks.key?(edge.to)
|
|
146
|
+
|
|
147
|
+
next_rank = ranks.fetch(edge.from) + 1
|
|
148
|
+
if next_rank > ranks.fetch(edge.to)
|
|
149
|
+
ranks[edge.to] = next_rank
|
|
150
|
+
changed = true
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
break unless changed
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
ranks
|
|
158
|
+
end
|
|
159
|
+
|
|
24
160
|
def durable_flow_status_class(status)
|
|
25
161
|
case status.to_s
|
|
26
162
|
when "completed", "succeeded", "matched"
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
<div class="df-header">
|
|
2
|
+
<div>
|
|
3
|
+
<div class="df-kicker">
|
|
4
|
+
<%= link_to "Workflow Runs", workflow_runs_path %> /
|
|
5
|
+
<%= link_to "Run", workflow_run_path(@workflow_run.run_id) %> /
|
|
6
|
+
Definition DAG
|
|
7
|
+
</div>
|
|
8
|
+
<h1><%= @workflow_run.workflow_class %></h1>
|
|
9
|
+
<p class="df-muted df-mono"><%= @workflow_run.run_id %></p>
|
|
10
|
+
</div>
|
|
11
|
+
<div class="df-header-actions">
|
|
12
|
+
<%= link_to "Timeline", workflow_run_path(@workflow_run.run_id), class: "df-button" %>
|
|
13
|
+
<span class="df-status <%= durable_flow_status_class(@workflow_run.status) %>"><%= @workflow_run.status %></span>
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
<% if @definition_error.present? %>
|
|
18
|
+
<section class="df-panel">
|
|
19
|
+
<div class="df-empty">
|
|
20
|
+
<strong>Definition graph unavailable.</strong>
|
|
21
|
+
<div style="margin-top: 8px;"><%= @definition_error %></div>
|
|
22
|
+
</div>
|
|
23
|
+
</section>
|
|
24
|
+
<% elsif @definition_graph.nodes.empty? %>
|
|
25
|
+
<section class="df-panel">
|
|
26
|
+
<div class="df-empty">
|
|
27
|
+
No durable primitives were found in <%= @workflow_run.workflow_class %>#perform.
|
|
28
|
+
</div>
|
|
29
|
+
</section>
|
|
30
|
+
<% else %>
|
|
31
|
+
<% layout = durable_flow_definition_graph_layout(@definition_graph) %>
|
|
32
|
+
|
|
33
|
+
<div class="df-definition-grid">
|
|
34
|
+
<section class="df-panel">
|
|
35
|
+
<div class="df-panel-header">
|
|
36
|
+
<div>
|
|
37
|
+
<div class="df-panel-title">Definition Graph</div>
|
|
38
|
+
<div class="df-panel-subtitle">
|
|
39
|
+
<%= pluralize(@definition_graph.nodes.size, "node") %>,
|
|
40
|
+
<%= pluralize(@definition_graph.edges.size, "edge") %>,
|
|
41
|
+
runtime status overlaid from this run
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<div class="df-dag-wrap">
|
|
47
|
+
<svg
|
|
48
|
+
class="df-dag-svg"
|
|
49
|
+
viewBox="0 0 <%= layout.fetch(:width) %> <%= layout.fetch(:height) %>"
|
|
50
|
+
role="img"
|
|
51
|
+
aria-label="DurableFlow definition DAG for <%= @workflow_run.workflow_class %>"
|
|
52
|
+
>
|
|
53
|
+
<defs>
|
|
54
|
+
<marker id="df-dag-arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
|
|
55
|
+
<path d="M0,0 L0,6 L9,3 z" fill="#8b93a1" />
|
|
56
|
+
</marker>
|
|
57
|
+
</defs>
|
|
58
|
+
|
|
59
|
+
<% @definition_graph.edges.each do |edge| %>
|
|
60
|
+
<% from = layout.fetch(:positions)[edge.from] %>
|
|
61
|
+
<% to = layout.fetch(:positions)[edge.to] %>
|
|
62
|
+
<% next unless from && to %>
|
|
63
|
+
<% start_x = from.fetch(:x) + (layout.fetch(:node_width) / 2) %>
|
|
64
|
+
<% start_y = from.fetch(:y) + layout.fetch(:node_height) %>
|
|
65
|
+
<% end_x = to.fetch(:x) + (layout.fetch(:node_width) / 2) %>
|
|
66
|
+
<% end_y = to.fetch(:y) %>
|
|
67
|
+
<% mid_y = start_y + ((end_y - start_y) / 2.0) %>
|
|
68
|
+
|
|
69
|
+
<path
|
|
70
|
+
class="df-dag-edge"
|
|
71
|
+
d="M <%= start_x %> <%= start_y %> C <%= start_x %> <%= mid_y %>, <%= end_x %> <%= mid_y %>, <%= end_x %> <%= end_y %>"
|
|
72
|
+
marker-end="url(#df-dag-arrow)"
|
|
73
|
+
/>
|
|
74
|
+
|
|
75
|
+
<% if edge.condition.present? %>
|
|
76
|
+
<text class="df-dag-edge-label" x="<%= end_x + 16 %>" y="<%= mid_y - 5 %>">
|
|
77
|
+
<title><%= edge.condition %></title>
|
|
78
|
+
<%= durable_flow_definition_short_condition(edge.condition) %>
|
|
79
|
+
</text>
|
|
80
|
+
<% end %>
|
|
81
|
+
<% end %>
|
|
82
|
+
|
|
83
|
+
<% @definition_graph.nodes.each do |node| %>
|
|
84
|
+
<% position = layout.fetch(:positions).fetch(node.id) %>
|
|
85
|
+
<% status = durable_flow_definition_node_status(node, @step_statuses_by_name) %>
|
|
86
|
+
<% cls = durable_flow_definition_class(node, @step_statuses_by_name) %>
|
|
87
|
+
<% metadata = [ durable_flow_node_label(node.type), node.target_workflow_class ].compact.join(" / ") %>
|
|
88
|
+
|
|
89
|
+
<g class="df-dag-node" transform="translate(<%= position.fetch(:x) %> <%= position.fetch(:y) %>)">
|
|
90
|
+
<rect class="df-dag-card <%= cls %>" width="<%= layout.fetch(:node_width) %>" height="<%= layout.fetch(:node_height) %>" rx="8" />
|
|
91
|
+
<text class="df-dag-node-name" x="16" y="28"><%= node.name %></text>
|
|
92
|
+
<text class="df-dag-node-meta" x="16" y="52"><%= metadata %></text>
|
|
93
|
+
<text class="df-dag-status-label" x="<%= layout.fetch(:node_width) - 16 %>" y="28" text-anchor="end"><%= status %></text>
|
|
94
|
+
</g>
|
|
95
|
+
<% end %>
|
|
96
|
+
</svg>
|
|
97
|
+
</div>
|
|
98
|
+
</section>
|
|
99
|
+
|
|
100
|
+
<aside>
|
|
101
|
+
<section class="df-panel">
|
|
102
|
+
<div class="df-panel-header">
|
|
103
|
+
<div>
|
|
104
|
+
<div class="df-panel-title">Graph Summary</div>
|
|
105
|
+
<div class="df-panel-subtitle">Static analysis of the workflow definition</div>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<div class="df-side-section">
|
|
110
|
+
<div class="df-kv">
|
|
111
|
+
<div class="df-kv-key">Workflow</div>
|
|
112
|
+
<div class="df-kv-value"><%= @definition_graph.workflow_class %></div>
|
|
113
|
+
</div>
|
|
114
|
+
<div class="df-kv">
|
|
115
|
+
<div class="df-kv-key">Source</div>
|
|
116
|
+
<div class="df-kv-value"><%= @definition_graph.source_file %></div>
|
|
117
|
+
</div>
|
|
118
|
+
<div class="df-kv">
|
|
119
|
+
<div class="df-kv-key">Nodes</div>
|
|
120
|
+
<div class="df-kv-value"><%= @definition_graph.nodes.map(&:name).join(", ") %></div>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<% if @definition_graph.warnings.any? %>
|
|
125
|
+
<div class="df-side-section">
|
|
126
|
+
<h3>Warnings</h3>
|
|
127
|
+
<% @definition_graph.warnings.each do |warning| %>
|
|
128
|
+
<div class="df-warning"><%= warning %></div>
|
|
129
|
+
<% end %>
|
|
130
|
+
</div>
|
|
131
|
+
<% end %>
|
|
132
|
+
</section>
|
|
133
|
+
|
|
134
|
+
<section class="df-panel" style="margin-top: 16px;">
|
|
135
|
+
<div class="df-panel-header">
|
|
136
|
+
<div>
|
|
137
|
+
<div class="df-panel-title">Graph JSON</div>
|
|
138
|
+
<div class="df-panel-subtitle">DefinitionGraph#to_h</div>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
<pre class="df-code df-definition-json"><%= durable_flow_json(@definition_graph.to_h) %></pre>
|
|
142
|
+
</section>
|
|
143
|
+
</aside>
|
|
144
|
+
</div>
|
|
145
|
+
<% end %>
|
|
@@ -52,6 +52,10 @@
|
|
|
52
52
|
<td>
|
|
53
53
|
<%= link_to run.workflow_class, workflow_run_path(run.run_id), class: "df-run-link" %>
|
|
54
54
|
<div class="df-run-id df-mono"><%= run.run_id %></div>
|
|
55
|
+
<div class="df-run-actions">
|
|
56
|
+
<%= link_to "Timeline", workflow_run_path(run.run_id) %>
|
|
57
|
+
<%= link_to "Definition DAG", definition_workflow_run_path(run.run_id) %>
|
|
58
|
+
</div>
|
|
55
59
|
</td>
|
|
56
60
|
<td>
|
|
57
61
|
<span class="df-status <%= durable_flow_status_class(run.status) %>"><%= run.status %></span>
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
<h1><%= @workflow_run.workflow_class %></h1>
|
|
5
5
|
<p class="df-muted df-mono"><%= @workflow_run.run_id %></p>
|
|
6
6
|
</div>
|
|
7
|
-
<
|
|
7
|
+
<div class="df-header-actions">
|
|
8
|
+
<%= link_to "Definition DAG", definition_workflow_run_path(@workflow_run.run_id), class: "df-button" %>
|
|
9
|
+
<span class="df-status <%= durable_flow_status_class(@workflow_run.status) %>"><%= @workflow_run.status %></span>
|
|
10
|
+
</div>
|
|
8
11
|
</div>
|
|
9
12
|
|
|
10
13
|
<div class="df-meta-grid">
|
|
@@ -125,6 +125,14 @@
|
|
|
125
125
|
margin-bottom: 22px;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
.df-header-actions {
|
|
129
|
+
display: flex;
|
|
130
|
+
align-items: center;
|
|
131
|
+
justify-content: flex-end;
|
|
132
|
+
gap: 10px;
|
|
133
|
+
flex-wrap: wrap;
|
|
134
|
+
}
|
|
135
|
+
|
|
128
136
|
.df-kicker {
|
|
129
137
|
color: var(--muted);
|
|
130
138
|
font-size: 12px;
|
|
@@ -237,6 +245,7 @@
|
|
|
237
245
|
|
|
238
246
|
.df-run-link { font-weight: 700; color: #1d4f91; }
|
|
239
247
|
.df-run-id { margin-top: 4px; color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
|
|
248
|
+
.df-run-actions { display: flex; gap: 10px; margin-top: 7px; font-size: 12px; font-weight: 700; color: #1d4f91; }
|
|
240
249
|
|
|
241
250
|
.df-status {
|
|
242
251
|
display: inline-flex;
|
|
@@ -270,6 +279,13 @@
|
|
|
270
279
|
align-items: start;
|
|
271
280
|
}
|
|
272
281
|
|
|
282
|
+
.df-definition-grid {
|
|
283
|
+
display: grid;
|
|
284
|
+
grid-template-columns: minmax(0, 1fr) minmax(340px, 0.38fr);
|
|
285
|
+
gap: 16px;
|
|
286
|
+
align-items: start;
|
|
287
|
+
}
|
|
288
|
+
|
|
273
289
|
.df-meta-grid {
|
|
274
290
|
display: grid;
|
|
275
291
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
@@ -419,12 +435,83 @@
|
|
|
419
435
|
text-align: center;
|
|
420
436
|
}
|
|
421
437
|
|
|
438
|
+
.df-dag-wrap {
|
|
439
|
+
padding: 12px;
|
|
440
|
+
overflow: auto;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
.df-dag-svg {
|
|
444
|
+
display: block;
|
|
445
|
+
width: 100%;
|
|
446
|
+
min-width: 760px;
|
|
447
|
+
height: auto;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
.df-dag-edge {
|
|
451
|
+
fill: none;
|
|
452
|
+
stroke: var(--subtle);
|
|
453
|
+
stroke-width: 2;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
.df-dag-edge-label {
|
|
457
|
+
fill: var(--muted);
|
|
458
|
+
font-size: 12px;
|
|
459
|
+
font-weight: 700;
|
|
460
|
+
paint-order: stroke;
|
|
461
|
+
stroke: #fff;
|
|
462
|
+
stroke-width: 4px;
|
|
463
|
+
stroke-linejoin: round;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
.df-dag-card {
|
|
467
|
+
fill: #fbfcfd;
|
|
468
|
+
stroke: var(--line-strong);
|
|
469
|
+
stroke-width: 1.5;
|
|
470
|
+
filter: drop-shadow(0 6px 10px rgba(18, 20, 23, 0.08));
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
.df-dag-card.success { fill: var(--green-soft); stroke: var(--green); }
|
|
474
|
+
.df-dag-card.active { fill: var(--blue-soft); stroke: var(--blue); }
|
|
475
|
+
.df-dag-card.waiting { fill: var(--amber-soft); stroke: var(--amber); }
|
|
476
|
+
.df-dag-card.danger { fill: var(--red-soft); stroke: var(--red); }
|
|
477
|
+
.df-dag-card.neutral { fill: #fbfcfd; stroke: var(--line-strong); }
|
|
478
|
+
|
|
479
|
+
.df-dag-node-name {
|
|
480
|
+
fill: var(--ink);
|
|
481
|
+
font-size: 14px;
|
|
482
|
+
font-weight: 800;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
.df-dag-node-meta,
|
|
486
|
+
.df-dag-status-label {
|
|
487
|
+
fill: var(--muted);
|
|
488
|
+
font-size: 12px;
|
|
489
|
+
font-weight: 700;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
.df-warning {
|
|
493
|
+
color: var(--amber);
|
|
494
|
+
background: var(--amber-soft);
|
|
495
|
+
border-radius: 8px;
|
|
496
|
+
padding: 10px 12px;
|
|
497
|
+
margin-top: 8px;
|
|
498
|
+
line-height: 1.45;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
.df-definition-json {
|
|
502
|
+
margin: 0;
|
|
503
|
+
border: 0;
|
|
504
|
+
border-radius: 0;
|
|
505
|
+
max-height: 520px;
|
|
506
|
+
}
|
|
507
|
+
|
|
422
508
|
@media (max-width: 900px) {
|
|
423
509
|
.df-shell { grid-template-columns: 1fr; }
|
|
424
510
|
.df-sidebar { display: none; }
|
|
425
511
|
.df-page { padding: 22px 16px 32px; }
|
|
426
512
|
.df-header { flex-direction: column; }
|
|
427
|
-
.df-
|
|
513
|
+
.df-header-actions { justify-content: flex-start; }
|
|
514
|
+
.df-stats, .df-meta-grid, .df-detail-grid, .df-definition-grid { grid-template-columns: 1fr; }
|
|
428
515
|
.df-filter { min-width: 100%; }
|
|
429
516
|
th, td { padding: 11px 12px; }
|
|
430
517
|
.df-hide-sm { display: none; }
|
data/config/routes.rb
CHANGED