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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +131 -0
- data/LICENSE.txt +21 -0
- data/README.md +630 -0
- data/Rakefile +12 -0
- data/app/controllers/active_durable/application_controller.rb +25 -0
- data/app/controllers/active_durable/executions_controller.rb +59 -0
- data/app/helpers/active_durable/dashboard_helper.rb +163 -0
- data/app/views/active_durable/executions/index.html.erb +90 -0
- data/app/views/active_durable/executions/show.html.erb +197 -0
- data/app/views/layouts/active_durable/application.html.erb +422 -0
- data/config/routes.rb +13 -0
- data/lib/active_durable/configuration.rb +59 -0
- data/lib/active_durable/engine.rb +24 -0
- data/lib/active_durable/errors.rb +61 -0
- data/lib/active_durable/execution.rb +43 -0
- data/lib/active_durable/flow.rb +277 -0
- data/lib/active_durable/flow_parallel.rb +200 -0
- data/lib/active_durable/lease.rb +50 -0
- data/lib/active_durable/notebook.rb +100 -0
- data/lib/active_durable/open_telemetry.rb +94 -0
- data/lib/active_durable/operations.rb +111 -0
- data/lib/active_durable/parallel.rb +54 -0
- data/lib/active_durable/record.rb +13 -0
- data/lib/active_durable/registry.rb +89 -0
- data/lib/active_durable/retry_policy.rb +42 -0
- data/lib/active_durable/run_job.rb +12 -0
- data/lib/active_durable/runner.rb +157 -0
- data/lib/active_durable/serializer.rb +40 -0
- data/lib/active_durable/signal_record.rb +20 -0
- data/lib/active_durable/step.rb +34 -0
- data/lib/active_durable/sweep_job.rb +12 -0
- data/lib/active_durable/sweeper.rb +22 -0
- data/lib/active_durable/testing.rb +118 -0
- data/lib/active_durable/version.rb +5 -0
- data/lib/active_durable.rb +145 -0
- data/lib/generators/active_durable/install/install_generator.rb +27 -0
- data/lib/generators/active_durable/install/templates/create_active_durable_tables.rb.tt +60 -0
- data/lib/tasks/active_durable.rake +24 -0
- data/sig/active_durable.rbs +4 -0
- 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>
|