active_durable 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +131 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +630 -0
  5. data/Rakefile +12 -0
  6. data/app/controllers/active_durable/application_controller.rb +25 -0
  7. data/app/controllers/active_durable/executions_controller.rb +59 -0
  8. data/app/helpers/active_durable/dashboard_helper.rb +163 -0
  9. data/app/views/active_durable/executions/index.html.erb +90 -0
  10. data/app/views/active_durable/executions/show.html.erb +197 -0
  11. data/app/views/layouts/active_durable/application.html.erb +422 -0
  12. data/config/routes.rb +13 -0
  13. data/lib/active_durable/configuration.rb +59 -0
  14. data/lib/active_durable/engine.rb +24 -0
  15. data/lib/active_durable/errors.rb +61 -0
  16. data/lib/active_durable/execution.rb +43 -0
  17. data/lib/active_durable/flow.rb +277 -0
  18. data/lib/active_durable/flow_parallel.rb +200 -0
  19. data/lib/active_durable/lease.rb +50 -0
  20. data/lib/active_durable/notebook.rb +100 -0
  21. data/lib/active_durable/open_telemetry.rb +94 -0
  22. data/lib/active_durable/operations.rb +111 -0
  23. data/lib/active_durable/parallel.rb +54 -0
  24. data/lib/active_durable/record.rb +13 -0
  25. data/lib/active_durable/registry.rb +89 -0
  26. data/lib/active_durable/retry_policy.rb +42 -0
  27. data/lib/active_durable/run_job.rb +12 -0
  28. data/lib/active_durable/runner.rb +157 -0
  29. data/lib/active_durable/serializer.rb +40 -0
  30. data/lib/active_durable/signal_record.rb +20 -0
  31. data/lib/active_durable/step.rb +34 -0
  32. data/lib/active_durable/sweep_job.rb +12 -0
  33. data/lib/active_durable/sweeper.rb +22 -0
  34. data/lib/active_durable/testing.rb +118 -0
  35. data/lib/active_durable/version.rb +5 -0
  36. data/lib/active_durable.rb +145 -0
  37. data/lib/generators/active_durable/install/install_generator.rb +27 -0
  38. data/lib/generators/active_durable/install/templates/create_active_durable_tables.rb.tt +60 -0
  39. data/lib/tasks/active_durable.rake +24 -0
  40. data/sig/active_durable.rbs +4 -0
  41. metadata +134 -0
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveDurable
4
+ # Lists executions, shows their notebook and runs the operator actions.
5
+ class ExecutionsController < ApplicationController
6
+ PER_PAGE = 50
7
+
8
+ rescue_from ActiveDurable::Error do |error|
9
+ redirect_to execution_path(params[:id]), alert: error.message
10
+ end
11
+
12
+ def index
13
+ @page = [params[:page].to_i, 1].max
14
+ rows = filtered_executions.offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
15
+ @next_page = rows.size > PER_PAGE
16
+ @executions = rows.first(PER_PAGE)
17
+ @steps_by_execution = Step.where(execution_id: @executions.map(&:id)).order(:id).group_by(&:execution_id)
18
+ @counts = Execution.group(:status).count
19
+ @recipes = Execution.distinct.order(:recipe).pluck(:recipe)
20
+ end
21
+
22
+ def show
23
+ @execution = Execution.find(params[:id])
24
+ steps = @execution.steps.to_a
25
+ @steps = steps
26
+ @forward_steps = steps.reject(&:undo?).sort_by { |step| step.position.to_i }
27
+ @undo_steps = steps.select(&:undo?)
28
+ @signals = @execution.signals.to_a
29
+ @reruns = Execution.where(forked_from: @execution.id).order(:created_at).to_a
30
+ end
31
+
32
+ def retry_now
33
+ ActiveDurable.retry(params[:id])
34
+ redirect_to execution_path(params[:id]), notice: "Retrying. Failed steps got a fresh set of attempts."
35
+ end
36
+
37
+ def compensate
38
+ ActiveDurable.compensate(params[:id], reason: "Compensated from the dashboard")
39
+ redirect_to execution_path(params[:id]), notice: "Compensation started."
40
+ end
41
+
42
+ def rerun
43
+ execution = ActiveDurable.rerun(params[:id], from: params.require(:from))
44
+ redirect_to execution_path(execution), notice: "New execution started from :#{params[:from]}."
45
+ end
46
+
47
+ private
48
+
49
+ def filtered_executions
50
+ scope = Execution.order(updated_at: :desc)
51
+ @status = params[:status].presence_in(Execution::STATUSES)
52
+ scope = scope.where(status: @status) if @status
53
+ @recipe = params[:recipe].presence
54
+ scope = scope.where(recipe: @recipe) if @recipe
55
+ @query = params[:q].to_s.strip
56
+ @query.present? ? scope.where("id LIKE ?", "%#{Execution.sanitize_sql_like(@query)}%") : scope
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveDurable
4
+ # View helpers for the dashboard.
5
+ module DashboardHelper
6
+ # One block of the track: a step (or a parallel group) and the state it is drawn in.
7
+ TrackItem = Struct.new(:name, :kind, :state, :step, :branches, keyword_init: true)
8
+
9
+ STATUS_TONES = {
10
+ "completed" => "good", "compensated" => "undo", "blocked" => "bad", "superseded" => "muted",
11
+ "running" => "busy", "pending" => "busy", "sleeping" => "wait", "waiting" => "wait",
12
+ "retrying" => "wait", "failed" => "bad", "undone" => "undo"
13
+ }.freeze
14
+
15
+ STATUS_WORDS = {
16
+ "pending" => "Queued", "running" => "Running", "sleeping" => "Sleeping", "waiting" => "Waiting",
17
+ "completed" => "Completed", "compensated" => "Undone", "blocked" => "Needs a person",
18
+ "superseded" => "Superseded"
19
+ }.freeze
20
+
21
+ def status_pill(status)
22
+ tag.span(status, class: "pill pill-#{STATUS_TONES.fetch(status.to_s, "muted")}")
23
+ end
24
+
25
+ def status_counts
26
+ @status_counts ||= Execution.group(:status).count
27
+ end
28
+
29
+ def recipe_names
30
+ @recipe_names ||= Execution.distinct.order(:recipe).pluck(:recipe)
31
+ end
32
+
33
+ def json_preview(value, limit: 160)
34
+ return tag.span("—", class: "muted") if value.nil?
35
+
36
+ text = value.to_json
37
+ text = "#{text[0, limit]}…" if text.length > limit
38
+ tag.code(text, class: "json")
39
+ end
40
+
41
+ def pretty_json(value)
42
+ return tag.p("Nothing yet.", class: "muted") if value.nil?
43
+
44
+ tag.pre(JSON.pretty_generate(value), class: "json-block")
45
+ end
46
+
47
+ # Absolute time, rewritten by the page script as "3 min ago" / "in 2 h".
48
+ def when_text(time)
49
+ return tag.span("—", class: "muted") if time.nil?
50
+
51
+ absolute = time.utc.strftime("%Y-%m-%d %H:%M:%S UTC")
52
+ tag.time(absolute, datetime: time.utc.iso8601, title: absolute, data: { relative: true })
53
+ end
54
+
55
+ # Lets long step names wrap after an underscore instead of in the middle of a word.
56
+ def breakable(name)
57
+ safe_join(name.to_s.split(/(?<=_)/), tag.wbr)
58
+ end
59
+
60
+ def ticket_for(execution, step)
61
+ "#{execution.id}:#{step.name}"
62
+ end
63
+
64
+ def compensating_now?(execution)
65
+ execution.compensating && execution.status != "compensated"
66
+ end
67
+
68
+ # The first step that did not complete: rerunning from earlier would repeat work that succeeded.
69
+ def rerun_default(steps)
70
+ (steps.find { |step| !step.completed? } || steps.last).name
71
+ end
72
+
73
+ def can_retry?(execution)
74
+ execution.status == "blocked"
75
+ end
76
+
77
+ def can_compensate?(execution, steps)
78
+ %w[blocked pending running sleeping waiting].include?(execution.status) && !execution.compensating &&
79
+ steps.none? { |step| step.kind == "pivot" && step.completed? }
80
+ end
81
+
82
+ def can_rerun?(execution)
83
+ execution.status == "completed" || (execution.status == "blocked" && !execution.compensating)
84
+ end
85
+
86
+ # Turns a notebook into the blocks of the track, in recipe order. Parallel branches are grouped under
87
+ # their parallel step, undone steps are marked, and an active execution gets a "next" ghost block.
88
+ def track_items(execution, steps)
89
+ undone = steps.select { |step| step.undo? && step.completed? }.to_set { |step| step.name.delete_suffix(":undo") }
90
+ branches, main = steps.reject(&:undo?).partition { |step| step.position.nil? }
91
+
92
+ items = main.sort_by(&:position).map do |step|
93
+ build_item(step, undone, branches.select { |branch| branch.name.start_with?("#{step.name}/") })
94
+ end
95
+ items.concat(unfinished_parallel_groups(main, branches, undone))
96
+ items << TrackItem.new(name: "next", kind: "ghost", state: "next", step: nil, branches: []) if moving?(execution)
97
+ items
98
+ end
99
+
100
+ # Undo entries in the order they ran (last step first).
101
+ def undo_lane(steps)
102
+ steps.select(&:undo?).sort_by { |step| [step.updated_at, step.id] }
103
+ end
104
+
105
+ def item_title(item)
106
+ attempts = item.step&.attempts.to_i
107
+ [item.name, item.kind, state_word(item.state), ("#{attempts} attempts" if attempts > 1)].compact.join(", ")
108
+ end
109
+
110
+ def state_word(state)
111
+ {
112
+ "completed" => "done", "failed" => "failed", "retrying" => "retrying", "waiting" => "waiting for a signal",
113
+ "sleeping" => "sleeping", "undone" => "undone", "next" => "up next", "running" => "running"
114
+ }.fetch(state.to_s, state.to_s)
115
+ end
116
+
117
+ # When the block wakes up (retry, sleep or wait timeout), for the countdowns.
118
+ def item_wake_at(item)
119
+ step = item.step
120
+ return nil unless step
121
+ return Time.iso8601(step.result["wake_at"]) if step.kind == "sleep" && step.result.is_a?(Hash)
122
+
123
+ step.wake_at
124
+ rescue ArgumentError
125
+ nil
126
+ end
127
+
128
+ private
129
+
130
+ # Branches of a flow.parallel whose own entry is not written yet (it is written when every branch ends).
131
+ def unfinished_parallel_groups(main, branches, undone)
132
+ recorded = main.to_set(&:name)
133
+ branches.group_by { |branch| branch.name.split("/", 2).first }.filter_map do |group, kids|
134
+ next if recorded.include?(group)
135
+
136
+ TrackItem.new(name: group, kind: "parallel", state: "running", step: nil,
137
+ branches: kids.map { |kid| build_item(kid, undone, []) })
138
+ end
139
+ end
140
+
141
+ def moving?(execution)
142
+ %w[pending running].include?(execution.status) && !execution.compensating
143
+ end
144
+
145
+ def build_item(step, undone, kids)
146
+ TrackItem.new(name: step.name.split("/", 2).last, kind: step.kind, state: step_state(step, undone), step: step,
147
+ branches: kids.map { |kid| build_item(kid, undone, []) })
148
+ end
149
+
150
+ def step_state(step, undone)
151
+ return "undone" if undone.include?(step.name)
152
+ return "sleeping" if step.kind == "sleep" && sleeping?(step)
153
+
154
+ step.status
155
+ end
156
+
157
+ def sleeping?(step)
158
+ step.result.is_a?(Hash) && Time.iso8601(step.result.fetch("wake_at", "")) > Time.current
159
+ rescue ArgumentError
160
+ false
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,90 @@
1
+ <% content_for :title, "Executions" %>
2
+ <% blocked = @counts["blocked"].to_i %>
3
+ <% moving = ActiveDurable::Execution::ACTIVE.sum { |status| @counts[status].to_i } %>
4
+
5
+ <header class="stage-head">
6
+ <div>
7
+ <h1><%= @status ? ActiveDurable::DashboardHelper::STATUS_WORDS[@status] : "Executions" %><%= " in #{@recipe}" if @recipe %></h1>
8
+ <p class="lede" data-live-summary>
9
+ <% if blocked.positive? %>
10
+ <strong class="error"><%= pluralize(blocked, "saga") %> <%= blocked == 1 ? "needs" : "need" %> a person.</strong>
11
+ <% end %>
12
+ <%= moving.zero? ? "Nothing is in progress." : "#{pluralize(moving, "saga")} in progress." %>
13
+ <%= pluralize(@counts.values.sum, "execution") %> in total.
14
+ </p>
15
+ </div>
16
+ <div class="tools">
17
+ <%= form_with url: executions_path, method: :get, class: "inline", local: true do %>
18
+ <%= hidden_field_tag :status, @status if @status %>
19
+ <%= select_tag :recipe, options_for_select([["All recipes", ""]] + @recipes, @recipe), id: "recipe-filter", "aria-label": "Recipe" %>
20
+ <%= search_field_tag :q, @query, placeholder: "Execution id contains…", id: "id-filter", "aria-label": "Execution id" %>
21
+ <button type="submit">Filter</button>
22
+ <% end %>
23
+ <button type="button" class="live" data-live-toggle aria-pressed="false">
24
+ <span class="led" aria-hidden="true"></span><span data-live-label>Paused</span>
25
+ </button>
26
+ </div>
27
+ </header>
28
+
29
+ <section class="sheet" data-live-region>
30
+ <% if @executions.empty? %>
31
+ <div class="empty">
32
+ <div class="mini" aria-hidden="true"><i class="b st-next"></i><i class="b st-next"></i><i class="b st-next"></i></div>
33
+ <p><strong>No executions here yet.</strong></p>
34
+ <p class="muted">Start one with <code>Durable.start(:recipe_name)</code> and it appears here.</p>
35
+ </div>
36
+ <% else %>
37
+ <table>
38
+ <thead>
39
+ <tr><th>Execution</th><th>Steps</th><th>Status</th><th>Wakes</th><th>Problem</th><th>Updated</th></tr>
40
+ </thead>
41
+ <tbody>
42
+ <% @executions.each do |execution| %>
43
+ <% items = track_items(execution, @steps_by_execution.fetch(execution.id, [])) %>
44
+ <tr data-id="<%= execution.id %>" data-status="<%= execution.status %>">
45
+ <td>
46
+ <%= link_to execution.id, execution_path(execution), class: "exec-link" %>
47
+ <span class="recipe"><%= execution.recipe %>, version <%= execution.recipe_version %></span>
48
+ </td>
49
+ <td>
50
+ <span class="mini" role="img" aria-label="<%= items.map { |item| item_title(item) }.join("; ") %>">
51
+ <% items.first(14).each_with_index do |item, index| %>
52
+ <% if item.branches.any? %>
53
+ <span class="stack" title="<%= item_title(item) %>">
54
+ <% item.branches.first(4).each do |branch| %>
55
+ <i class="b st-<%= branch.state %>" style="--i: <%= index %>"></i>
56
+ <% end %>
57
+ </span>
58
+ <% else %>
59
+ <i class="b st-<%= item.state %> k-<%= item.kind %>" style="--i: <%= index %>" title="<%= item_title(item) %>"></i>
60
+ <% end %>
61
+ <% end %>
62
+ <% if items.size > 14 %><span class="more">+<%= items.size - 14 %></span><% end %>
63
+ </span>
64
+ </td>
65
+ <td>
66
+ <span class="status tone-<%= ActiveDurable::DashboardHelper::STATUS_TONES.fetch(execution.status, "muted") %>">
67
+ <i class="dot" aria-hidden="true"></i><%= execution.status %>
68
+ </span>
69
+ <% if compensating_now?(execution) %><%= status_pill("compensating") %><% end %>
70
+ </td>
71
+ <td class="when"><%= execution.wake_at ? when_text(execution.wake_at) : tag.span("—", class: "muted") %></td>
72
+ <td class="problem"><%= execution.error&.dig("message").to_s.truncate(110) %></td>
73
+ <td class="when"><%= when_text(execution.updated_at) %></td>
74
+ </tr>
75
+ <% end %>
76
+ </tbody>
77
+ </table>
78
+ <% end %>
79
+ </section>
80
+
81
+ <% if @page > 1 || @next_page %>
82
+ <nav class="pager" aria-label="Pages">
83
+ <% if @page > 1 %>
84
+ <%= link_to "Newer", executions_path(status: @status, recipe: @recipe, q: @query.presence, page: @page - 1) %>
85
+ <% end %>
86
+ <% if @next_page %>
87
+ <%= link_to "Older", executions_path(status: @status, recipe: @recipe, q: @query.presence, page: @page + 1) %>
88
+ <% end %>
89
+ </nav>
90
+ <% end %>
@@ -0,0 +1,197 @@
1
+ <% content_for :title, @execution.id %>
2
+ <% tone = ActiveDurable::DashboardHelper::STATUS_TONES.fetch(@execution.status, "muted") %>
3
+ <% items = track_items(@execution, @steps) %>
4
+ <% undos = undo_lane(@steps) %>
5
+ <% backwards = @execution.compensating %>
6
+
7
+ <p class="crumbs">Executions / <%= @execution.recipe %></p>
8
+
9
+ <header class="hero tone-<%= tone %>">
10
+ <div class="beacon" aria-hidden="true"></div>
11
+ <div class="hero-main">
12
+ <h1><%= @execution.id %></h1>
13
+ <p class="sub">
14
+ Recipe <strong><%= @execution.recipe %></strong>, version <%= @execution.recipe_version %>.
15
+ Started <%= when_text(@execution.created_at) %>, last change <%= when_text(@execution.updated_at) %>.
16
+ <% if @execution.wake_at %>Wakes <%= when_text(@execution.wake_at) %>.<% end %>
17
+ <% if @execution.forked_from %>
18
+ Rerun of <%= link_to @execution.forked_from, execution_path(@execution.forked_from), class: "mono" %>.
19
+ <% end %>
20
+ </p>
21
+ </div>
22
+ <span class="badge"><%= ActiveDurable::DashboardHelper::STATUS_WORDS.fetch(@execution.status, @execution.status) %><%= ", undoing" if compensating_now?(@execution) %></span>
23
+ </header>
24
+
25
+ <% if @execution.error %>
26
+ <section class="problem-card" role="alert">
27
+ <strong><%= @execution.error["message"] %></strong>
28
+ <span><%= @execution.error["class"] %><%= " in :#{@execution.error["step"]}" if @execution.error["step"] %></span>
29
+ </section>
30
+ <% end %>
31
+
32
+ <% if can_retry?(@execution) || can_compensate?(@execution, @forward_steps) || (can_rerun?(@execution) && @forward_steps.any?) %>
33
+ <section class="actions" aria-label="Actions">
34
+ <% if can_retry?(@execution) %>
35
+ <%= button_to retry_execution_path(@execution), class: "primary", form_class: "inline" do %>Retry<% end %>
36
+ <% end %>
37
+ <% if can_compensate?(@execution, @forward_steps) %>
38
+ <%= button_to compensate_execution_path(@execution), class: "danger", form_class: "inline",
39
+ form: { data: { confirm: "Undo every completed step of #{@execution.id}?" } } do %>
40
+ Undo everything
41
+ <% end %>
42
+ <% end %>
43
+ <% if can_rerun?(@execution) && @forward_steps.any? %>
44
+ <%= form_with url: rerun_execution_path(@execution), method: :post, class: "inline", local: true do %>
45
+ <label for="rerun-from">Run again from</label>
46
+ <%= select_tag :from, options_for_select(@forward_steps.map(&:name), rerun_default(@forward_steps)), id: "rerun-from" %>
47
+ <button type="submit">Rerun</button>
48
+ <% end %>
49
+ <p class="note">A rerun is a new execution. Steps before the one you choose are reused; that step and the ones
50
+ after it run again with new tickets, so they have effects again.</p>
51
+ <% end %>
52
+ </section>
53
+ <% end %>
54
+
55
+ <section class="board" aria-labelledby="track-title">
56
+ <div class="board-head">
57
+ <h2 id="track-title">The saga, step by step</h2>
58
+ <span class="muted"><%= pluralize(@forward_steps.count { |step| step.position }, "step") %> reached<%= ", undoing backwards" if compensating_now?(@execution) %></span>
59
+ </div>
60
+ <% if items.empty? %>
61
+ <p class="muted">No step has run yet. A worker picks this saga up from its queue.</p>
62
+ <% else %>
63
+ <div class="track-scroll">
64
+ <div class="track">
65
+ <% items.each_with_index do |item, index| %>
66
+ <% if index.positive? %>
67
+ <% previous = items[index - 1] %>
68
+ <% if previous.kind == "pivot" && previous.state != "failed" %>
69
+ <span class="gate" style="--i: <%= index %>"><span>point of no return</span></span>
70
+ <% end %>
71
+ <% wire = if item.state == "undone" || (backwards && previous.state == "undone") then "back"
72
+ elsif %w[completed sleeping waiting].include?(item.state) then "lit"
73
+ elsif %w[next running retrying].include?(item.state) then "lit to-active"
74
+ else "" end %>
75
+ <span class="wire <%= wire %>" style="--i: <%= index %>" aria-hidden="true"></span>
76
+ <% end %>
77
+ <% wake_at = item_wake_at(item) %>
78
+ <article class="block st-<%= item.state %> k-<%= item.kind %>" style="--i: <%= index %>" aria-label="<%= item_title(item) %>">
79
+ <div class="top">
80
+ <span class="kind <%= "k-pivot-word" if item.kind == "pivot" %>"><%= item.kind == "ghost" ? "up next" : item.kind %></span>
81
+ <% if item.step&.position %><span class="no"><%= item.step.position %></span><% end %>
82
+ </div>
83
+ <h3><%= item.kind == "ghost" ? "…" : breakable(item.name) %></h3>
84
+ <p class="state"><%= state_word(item.state).capitalize %></p>
85
+ <% if item.step && item.step.attempts.to_i > 1 %><p class="meta"><%= item.step.attempts %> attempts</p><% end %>
86
+ <% if wake_at && %w[retrying sleeping waiting].include?(item.state) %>
87
+ <p class="meta"><%= item.state == "waiting" ? "Gives up" : "Wakes" %> <%= when_text(wake_at) %></p>
88
+ <% end %>
89
+ <% if item.step&.result && item.state == "completed" && item.kind != "sleep" && item.branches.empty? %>
90
+ <p class="peek" title="<%= item.step.result.to_json %>"><%= item.step.result.to_json %></p>
91
+ <% end %>
92
+ <% if item.branches.any? %>
93
+ <div class="branches">
94
+ <% item.branches.each do |branch| %>
95
+ <span class="branch"><i class="st-<%= branch.state %>" aria-hidden="true"></i><%= branch.name %>
96
+ <span class="muted"><%= state_word(branch.state) %></span></span>
97
+ <% end %>
98
+ </div>
99
+ <% end %>
100
+ <% if item.state == "waiting" %>
101
+ <span class="radar" aria-hidden="true"></span>
102
+ <% elsif item.state == "retrying" %>
103
+ <span class="spin" aria-hidden="true"></span>
104
+ <% elsif item.state == "sleeping" %>
105
+ <span class="ring" aria-hidden="true" data-ring-from="<%= item.step.created_at.utc.iso8601 %>"
106
+ data-ring-until="<%= wake_at&.utc&.iso8601 %>"></span>
107
+ <% end %>
108
+ </article>
109
+ <% end %>
110
+ </div>
111
+ </div>
112
+ <% end %>
113
+ <% if undos.any? %>
114
+ <div class="undo-lane" aria-label="Undos, in the order they ran">
115
+ <span class="label">Undone, last step first</span>
116
+ <% undos.each_with_index do |undo, index| %>
117
+ <% if index.positive? %><span class="arrow" aria-hidden="true">←</span><% end %>
118
+ <span class="undo-chip <%= "failed" unless undo.completed? %>" style="--i: <%= index %>">
119
+ <%= undo.name.delete_suffix(":undo") %><% unless undo.completed? %>, <%= undo.status %> after <%= undo.attempts %><% end %>
120
+ </span>
121
+ <% end %>
122
+ </div>
123
+ <% end %>
124
+ </section>
125
+
126
+ <div class="details">
127
+ <section class="panel" aria-labelledby="notebook-title">
128
+ <h2 id="notebook-title">Notebook</h2>
129
+ <% if @forward_steps.empty? %>
130
+ <p class="muted">Empty: no step has been written down yet.</p>
131
+ <% else %>
132
+ <div class="sheet">
133
+ <table>
134
+ <thead>
135
+ <tr><th>#</th><th>Step and ticket</th><th>Status</th><th>Attempts</th><th>Result</th><th>Error</th></tr>
136
+ </thead>
137
+ <tbody>
138
+ <% @forward_steps.each do |step| %>
139
+ <tr>
140
+ <td><%= step.position %></td>
141
+ <td>
142
+ <strong><%= step.name %></strong><span class="k <%= "k-pivot-word" if step.kind == "pivot" %>"><%= step.kind %></span><br>
143
+ <code class="muted ticket"><%= ticket_for(@execution, step) %></code>
144
+ </td>
145
+ <td><%= status_pill(step.status) %></td>
146
+ <td><%= step.attempts %></td>
147
+ <td class="result"><%= json_preview(step.result) %></td>
148
+ <td class="error"><%= step.error&.dig("message").to_s.truncate(120) %></td>
149
+ </tr>
150
+ <% end %>
151
+ <% @undo_steps.each do |step| %>
152
+ <tr>
153
+ <td></td>
154
+ <td><strong><%= step.name %></strong><span class="k">undo</span></td>
155
+ <td><%= status_pill(step.completed? ? "undone" : step.status) %></td>
156
+ <td><%= step.attempts %></td>
157
+ <td></td>
158
+ <td class="error"><%= step.error&.dig("message").to_s.truncate(120) %></td>
159
+ </tr>
160
+ <% end %>
161
+ </tbody>
162
+ </table>
163
+ </div>
164
+ <% end %>
165
+ </section>
166
+
167
+ <div class="side">
168
+ <section class="panel">
169
+ <h2>Input</h2>
170
+ <%= pretty_json(@execution.input) %>
171
+ </section>
172
+ <section class="panel">
173
+ <h2>Output</h2>
174
+ <%= pretty_json(@execution.output) %>
175
+ </section>
176
+ <% if @signals.any? %>
177
+ <section class="panel">
178
+ <h2>Signals</h2>
179
+ <dl class="facts">
180
+ <% @signals.each do |signal| %>
181
+ <dt><strong><%= signal.name %></strong></dt>
182
+ <dd><%= json_preview(signal.payload) %><br>
183
+ <span class="muted">received <%= when_text(signal.created_at) %><%= signal.consumed_at ? ", used" : ", not used yet" %></span></dd>
184
+ <% end %>
185
+ </dl>
186
+ </section>
187
+ <% end %>
188
+ <% if @reruns.any? %>
189
+ <section class="panel">
190
+ <h2>Reruns</h2>
191
+ <% @reruns.each do |rerun| %>
192
+ <p><%= link_to rerun.id, execution_path(rerun), class: "mono" %> <%= status_pill(rerun.status) %></p>
193
+ <% end %>
194
+ </section>
195
+ <% end %>
196
+ </div>
197
+ </div>