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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 43d876d7d9d09c0c0c883d510fb18634f6a6b7f81445287b1414353294181bd4
4
- data.tar.gz: ed01f4612ff81e58afb7cbdbf2a2a8ab8aa236973d5062fca60726d59db25261
3
+ metadata.gz: 958d0ca64fa3941faa0ac018e61d07dddadf9529e50aeeb5ecf462244cac0586
4
+ data.tar.gz: 127aa82a9307c5fb3550cba8cbb466160f423d9c594a97a4fde0ad34885e66f5
5
5
  SHA512:
6
- metadata.gz: 9d24efb9075c6755a7f4cc67408c1c79b42adc1656134d468af2e6a1c7c259707819c6f7771843e145daed37777ead0577e990a048d8940aff2477eadc28df06
7
- data.tar.gz: 81ca1ec109939a621565d94bd858dde30091ab87e275263db52bdb6a37ad15b589cfa5606200aab9bbcac1671657bcd2c128f8e0102488597e7a4a388163f222
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
  ![DurableFlow workflow runs index](docs/screenshots/workflow-runs.png)
36
36
 
37
37
  ![DurableFlow workflow run detail](docs/screenshots/workflow-run-detail.png)
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
+ ![DurableFlow workflow definition DAG](docs/screenshots/workflow-definition-dag.png)
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
- Wait for a child workflow:
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
- 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)
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
- child_run_ids = step(:start_children) do
353
- account.users.find_each.map { |user| SyncUserWorkflow.perform_later(user.id).job_id }
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
- child_run_ids.each do |run_id|
357
- step.wait_for_workflow("child-#{run_id}", run_id, timeout: 30.minutes)
358
- end
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
- 25 runs, 212 assertions, 0 failures, 0 errors, 0 skips
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
- <span class="df-status <%= durable_flow_status_class(@workflow_run.status) %>"><%= @workflow_run.status %></span>
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-stats, .df-meta-grid, .df-detail-grid { grid-template-columns: 1fr; }
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
@@ -2,5 +2,9 @@
2
2
 
3
3
  DurableFlow::Engine.routes.draw do
4
4
  root to: "workflow_runs#index"
5
- resources :workflow_runs, only: [ :index, :show ], param: :run_id
5
+ resources :workflow_runs, only: [ :index, :show ], param: :run_id do
6
+ member do
7
+ get :definition
8
+ end
9
+ end
6
10
  end