ductwork 1.3.0 → 1.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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/audit-common/accepted-tradeoffs.md +0 -5
  3. data/CHANGELOG.md +19 -0
  4. data/app/assets/javascripts/ductwork/application.js +50 -41
  5. data/app/assets/stylesheets/ductwork/application.css +859 -146
  6. data/app/controllers/ductwork/application_controller.rb +39 -8
  7. data/app/controllers/ductwork/dashboards_controller.rb +44 -11
  8. data/app/controllers/ductwork/pipelines_controller.rb +47 -7
  9. data/app/controllers/ductwork/step_errors_controller.rb +9 -5
  10. data/app/helpers/ductwork/application_helper.rb +107 -18
  11. data/app/views/ductwork/dashboards/show.html.erb +75 -126
  12. data/app/views/ductwork/pipelines/_graph.html.erb +37 -0
  13. data/app/views/ductwork/pipelines/_step.html.erb +76 -0
  14. data/app/views/ductwork/pipelines/index.html.erb +11 -101
  15. data/app/views/ductwork/pipelines/show.html.erb +62 -285
  16. data/app/views/ductwork/shared/_error.html.erb +7 -0
  17. data/app/views/ductwork/shared/_filter_menu.html.erb +13 -0
  18. data/app/views/ductwork/shared/_pager.html.erb +16 -0
  19. data/app/views/ductwork/shared/_runs_table.html.erb +30 -0
  20. data/app/views/ductwork/shared/_status_tabs.html.erb +11 -0
  21. data/app/views/ductwork/step_errors/index.html.erb +52 -96
  22. data/app/views/layouts/ductwork/application.html.erb +19 -23
  23. data/lib/ductwork/cli.rb +47 -8
  24. data/lib/ductwork/engine.rb +0 -2
  25. data/lib/ductwork/models/pipeline.rb +52 -31
  26. data/lib/ductwork/pipeline_graph/layout.rb +161 -0
  27. data/lib/ductwork/pipeline_graph.rb +231 -0
  28. data/lib/ductwork/version.rb +1 -1
  29. data/lib/ductwork.rb +20 -19
  30. metadata +11 -5
  31. data/app/assets/images/ductwork/octagon-x.svg +0 -1
  32. data/app/assets/stylesheets/ductwork/vendor/flexboxgrid.min.css +0 -1
  33. data/app/assets/stylesheets/ductwork/vendor/pico.min.css +0 -4
@@ -1,101 +1,57 @@
1
- <div>
2
- <h3>Step Errors</h3>
1
+ <% content_for(:title, "Step errors") %>
3
2
 
4
- <div class="separate-content">
5
- <div class="filter-group">
6
- <span class="filter-group-item">Filters:</span>
7
-
8
- <details class="dropdown filter-group-item">
9
- <summary><%= params[:klass] || "Step Class" %></summary>
10
- <ul>
11
- <% @klasses.each do |klass| %>
12
- <li>
13
- <%= link_to(klass, step_errors_path(**params.permit(:status).merge(klass: klass))) %>
14
- </li>
15
- <% end %>
16
- </ul>
17
- </details>
18
-
19
- <%= link_to("Clear", step_errors_path) %>
20
- </div>
3
+ <div class="page-head">
4
+ <h1 class="page-title">Step errors</h1>
5
+ </div>
21
6
 
22
- <div role="group" style="width: auto;">
23
- <button <%= first_page? && "disabled" %> data-href="<%= previous_page_path %>">
24
- <%= link_to(previous_page_path, class: "stealthy-link") do %>
25
- &larr;
26
- <% end %>
27
- </button>
28
- <button data-href="<%= next_page_path %>">
29
- &rarr;
30
- </button>
31
- </div>
7
+ <div class="toolbar">
8
+ <div class="menus">
9
+ <%= render("ductwork/shared/filter_menu", label: "Class", param: :klass, options: @klasses) %>
32
10
  </div>
33
- <table class="striped">
34
- <thead>
35
- <tr>
36
- <th>Step ID</th>
37
- <th>Step Class</th>
38
- <th>Pipeline ID</th>
39
- <th>Error Message</th>
40
- <th>Error Backtrace</th>
41
- </tr>
42
- </thead>
43
-
44
- <tbody>
45
- <% @step_errors.each do |result| %>
46
- <tr class="not-clickable">
47
- <td>
48
- <code>
49
- <%= result.execution.job.step.id %>
50
- </code>
51
- </td>
52
- <td>
53
- <code>
54
- <%= result.execution.job.step.klass %>
55
- </code>
56
- </td>
57
- <td>
58
- <code>
59
- <%= link_to(result.execution.job.step.run.pipeline_id, pipeline_path(result.execution.job.step.run.pipeline_id)) %>
60
- </code>
61
- </td>
62
- <td>
63
- <code>
64
- <%= [result.error_klass, result.error_message].join(": ") %>
65
- </code>
66
- </td>
67
- <td>
68
- <button data-open-modal="modal-<%= result.id %>" class="tight-timestamp secondary">
69
- Open
70
- </button>
71
-
72
- <dialog id="modal-<%= result.id %>">
73
- <article>
74
- <header>
75
- <button aria-label="Close" rel="prev" data-close-modal="modal-<%= result.id %>"></button>
76
- <p>
77
- <strong>Error Backtrace</strong>
78
- </p>
79
- </header>
80
- <div>
81
- <h2>
82
- <%= result.error_klass %>
83
- </h2>
84
- <h5>
85
- <%= result.error_message %>
86
- </h5>
87
- <code>
88
- <%= (result.error_backtrace&.split("\n") || []).each do |line| %>
89
- <div><%= line %></div>
90
- <% end %>
91
- </code>
92
- </div>
93
- </article>
94
- </dialog>
11
+ </div>
95
12
 
96
- </td>
97
- </tr>
98
- <% end %>
99
- </tbody>
100
- </table>
13
+ <div class="panel">
14
+ <% if @step_errors.empty? %>
15
+ <p class="empty">No step errors match.</p>
16
+ <% else %>
17
+ <div class="row-grid row-grid-errors row-head">
18
+ <span>When</span>
19
+ <span>Error</span>
20
+ <span>Step</span>
21
+ <span>Pipeline</span>
22
+ <span></span>
23
+ </div>
24
+ <% @step_errors.each do |result| %>
25
+ <% step = result.execution.job.step %>
26
+ <details class="row">
27
+ <summary class="row-grid row-grid-errors">
28
+ <span class="wide-only"><%= timestamp(result.created_at) %></span>
29
+ <span class="cell-truncate">
30
+ <span class="error-title"><%= result.error_klass %></span>
31
+ <%= result.error_message %>
32
+ </span>
33
+ <span class="cell-truncate wide-only"><%= step.klass %></span>
34
+ <span class="wide-only"><%= short_id(step.run.pipeline_id) %></span>
35
+ </summary>
36
+ <div class="row-body">
37
+ <div class="detail-grid">
38
+ <div>
39
+ <span class="detail-label">Step</span>
40
+ <div><%= step.klass %> <code><%= step.id %></code></div>
41
+ </div>
42
+ <div>
43
+ <span class="detail-label">Pipeline</span>
44
+ <%= link_to(step.run.pipeline_klass, pipeline_path(step.run.pipeline_id)) %>
45
+ <code><%= step.run.pipeline_id %></code>
46
+ </div>
47
+ </div>
48
+ <div class="detail-block">
49
+ <%= render("ductwork/shared/error", result:) %>
50
+ </div>
51
+ </div>
52
+ </details>
53
+ <% end %>
54
+ <% end %>
101
55
  </div>
56
+
57
+ <%= render("ductwork/shared/pager", total: @error_count) %>
@@ -1,7 +1,8 @@
1
1
  <!DOCTYPE html>
2
- <html>
2
+ <html lang="en">
3
3
  <head>
4
- <title>Ductwork Dashboard</title>
4
+ <meta charset="utf-8">
5
+ <title><%= [content_for(:title), "Ductwork"].compact.join(" · ") %></title>
5
6
 
6
7
  <meta name="viewport" content="width=device-width,initial-scale=1">
7
8
  <meta name="apple-mobile-web-app-capable" content="yes">
@@ -11,8 +12,6 @@
11
12
  <%= csrf_meta_tags %>
12
13
  <%= csp_meta_tag %>
13
14
 
14
- <%= stylesheet_link_tag "ductwork/vendor/pico.min.css" %>
15
- <%= stylesheet_link_tag "ductwork/vendor/flexboxgrid.min.css" %>
16
15
  <%= stylesheet_link_tag "ductwork/application.css" %>
17
16
 
18
17
  <%= javascript_include_tag "ductwork/application.js", type: "module" %>
@@ -22,27 +21,24 @@
22
21
  </head>
23
22
 
24
23
  <body>
25
- <header class="sticky-header">
26
- <nav class="container separate-content">
27
- <ul>
28
- <%= link_to(dashboard_path, class: "stealthy-link") do %>
29
- <li><%= image_tag("ductwork/logo.png", class: "logo") %></li>
30
- <li><h6 class="title">Ductwork</h6></li>
31
- <% end %>
32
- </ul>
33
-
34
- <div>
35
- <label>
36
- Version
37
- </label>
38
- <div style="color: var(--pico-color);">
39
- <%= Ductwork::VERSION %>
40
- </div>
41
- </div>
42
- </nav>
24
+ <header class="topbar">
25
+ <div class="shell topbar-inner">
26
+ <%= link_to(dashboard_path, class: "brand") do %>
27
+ <%= image_tag("ductwork/logo.png", alt: "") %>
28
+ <span>Ductwork</span>
29
+ <% end %>
30
+
31
+ <nav class="nav" aria-label="Dashboard">
32
+ <%= link_to("Overview", dashboard_path, aria: { current: controller_name == "dashboards" ? "page" : nil }) %>
33
+ <%= link_to("Pipelines", pipelines_path, aria: { current: controller_name == "pipelines" ? "page" : nil }) %>
34
+ <%= link_to("Errors", step_errors_path, aria: { current: controller_name == "step_errors" ? "page" : nil }) %>
35
+ </nav>
36
+
37
+ <span class="version">v<%= Ductwork::VERSION %></span>
38
+ </div>
43
39
  </header>
44
40
 
45
- <main class="container">
41
+ <main class="shell">
46
42
  <%= yield %>
47
43
  </main>
48
44
  </body>
data/lib/ductwork/cli.rb CHANGED
@@ -5,6 +5,7 @@ require "optparse"
5
5
  module Ductwork
6
6
  class CLI
7
7
  DEFAULT_COMMAND = "start"
8
+ BACKTRACE_FRAME_LIMIT = 5
8
9
 
9
10
  def self.start!(args)
10
11
  new(args).start!
@@ -31,8 +32,9 @@ module Ductwork
31
32
 
32
33
  def start!
33
34
  parse!
34
- auto_configure
35
- execute_command
35
+ configure
36
+ validate!
37
+ execute
36
38
  end
37
39
 
38
40
  private
@@ -44,9 +46,9 @@ module Ductwork
44
46
  top_level_parser.order(raw_args)
45
47
  @command = raw_args.shift || DEFAULT_COMMAND
46
48
 
47
- if command == "start"
49
+ if start_command?
48
50
  start_command_parser.parse!(raw_args)
49
- elsif command == "health"
51
+ elsif health_check_command?
50
52
  health_command_parser.parse!(raw_args)
51
53
  else
52
54
  warn "Unknown command: #{command}"
@@ -55,7 +57,15 @@ module Ductwork
55
57
  end
56
58
  end
57
59
 
58
- def auto_configure
60
+ def start_command?
61
+ command == "start"
62
+ end
63
+
64
+ def health_check_command?
65
+ command == "health"
66
+ end
67
+
68
+ def configure
59
69
  configuration_options[:role] = ENV.fetch("DUCTWORK_ROLE", nil)
60
70
  Ductwork.configuration = Configuration.new(**configuration_options)
61
71
  Ductwork.logger = if Ductwork.configuration.logger_source == "rails"
@@ -82,10 +92,39 @@ module Ductwork
82
92
  BANNER
83
93
  end
84
94
 
85
- def execute_command
86
- if command == "start"
95
+ def validate!
96
+ if start_command?
97
+ Ductwork.validate!
98
+ end
99
+ rescue StandardError => e
100
+ report_invalid_definitions(e)
101
+ exit 1
102
+ end
103
+
104
+ def report_invalid_definitions(error)
105
+ warn "ductwork failed to validate your step and pipeline definitions"
106
+ warn ""
107
+ warn " #{error.class}: #{error.message}"
108
+
109
+ definition_frames(error).each do |frame|
110
+ warn " #{frame}"
111
+ end
112
+ end
113
+
114
+ def definition_frames(error)
115
+ frames = error.backtrace.to_a
116
+
117
+ return frames if Ductwork.logger.debug?
118
+
119
+ application_frames = frames.grep(/\A#{Regexp.escape(Rails.root.to_s)}/)
120
+
121
+ (application_frames.presence || frames).first(BACKTRACE_FRAME_LIMIT)
122
+ end
123
+
124
+ def execute
125
+ if start_command?
87
126
  launch_processes
88
- elsif command == "health"
127
+ elsif health_check_command?
89
128
  check_health
90
129
  end
91
130
  end
@@ -11,8 +11,6 @@ module Ductwork
11
11
  initializer "ductwork.assets.precompile" do |app|
12
12
  if app.config.respond_to?(:assets)
13
13
  app.config.assets.precompile += %w[
14
- ductwork/vendor/pico.min.css
15
- ductwork/vendor/flexboxgrid.min.css
16
14
  ductwork/application.css
17
15
  ductwork/application.js
18
16
  ]
@@ -30,6 +30,9 @@ module Ductwork
30
30
  class DefinitionError < StandardError; end
31
31
  class ReviveError < StandardError; end
32
32
 
33
+ DEFAULT_STEP_ORDER_PRECISION = 6
34
+ private_constant :DEFAULT_STEP_ORDER_PRECISION
35
+
33
36
  class << self
34
37
  attr_reader :pipeline_definition
35
38
 
@@ -151,16 +154,7 @@ module Ductwork
151
154
 
152
155
  new_branch.save!
153
156
 
154
- branch.steps.where(status:).find_each do |step|
155
- new_step = step.dup
156
- new_step.source_step = step
157
- new_step.branch = new_branch
158
- new_step.run = new_run
159
- new_step.started_at = now
160
- new_step.completed_at = now
161
-
162
- new_step.save!
163
- end
157
+ duplicate_succeeded_steps(branch.steps.where(status:), new_branch, new_run, now)
164
158
  end
165
159
  end
166
160
 
@@ -180,36 +174,60 @@ module Ductwork
180
174
 
181
175
  def revive_branch_steps(branch, new_branch, new_run, now)
182
176
  duplicate_prior_steps_as_completed(branch, new_branch, new_run, now)
177
+ revive_latest_step(branch.latest_step, new_branch, new_run, now)
178
+ end
183
179
 
184
- if branch.job_retries_exhausted?
185
- re_enqueue_failed_step(branch, new_branch, new_run, now)
180
+ def revive_latest_step(latest_step, new_branch, new_run, now)
181
+ if latest_step.failed?
182
+ re_enqueue_failed_step(latest_step, new_branch, new_run, now)
186
183
  else
187
- re_advance_latest_step(branch, new_branch, new_run, now)
184
+ re_advance_latest_step(latest_step, new_branch, new_run, now)
188
185
  end
189
186
  end
190
187
 
191
188
  def duplicate_prior_steps_as_completed(branch, new_branch, new_run, now)
192
- latest_step = branch.steps.order(started_at: :desc).first
189
+ latest_step = branch.latest_step
193
190
 
194
191
  return if latest_step.blank?
195
192
 
196
193
  status = %i[advancing waiting completed]
197
194
  id = latest_step.id
198
195
 
199
- branch.steps.where(status:).where.not(id:).find_each do |step|
200
- new_step = step.dup
201
- new_step.source_step = step
202
- new_step.branch = new_branch
203
- new_step.run = new_run
204
- new_step.started_at = now
205
- new_step.completed_at = now
196
+ steps = branch.steps.where(status:).where.not(id:)
197
+
198
+ duplicate_succeeded_steps(steps, new_branch, new_run, now, steps_after: 1)
199
+ end
200
+
201
+ def duplicate_succeeded_steps(steps, new_branch, new_run, now, steps_after: 0)
202
+ ordered_steps = steps.includes(:job).order(:started_at, :id).to_a
203
+ tick = step_order_tick
204
+
205
+ ordered_steps.reverse_each.with_index(steps_after) do |step, ticks_back|
206
+ started_at = now - (ticks_back * tick)
206
207
 
207
- new_step.save!
208
+ duplicate_succeeded_step(step, new_branch, new_run, now, started_at)
208
209
  end
209
210
  end
210
211
 
211
- def re_enqueue_failed_step(branch, new_branch, new_run, now)
212
- failed_step = branch.steps.find_by(status: :failed)
212
+ def step_order_tick
213
+ precision = Ductwork::Step.columns_hash["started_at"].precision
214
+
215
+ Rational(1, 10**(precision || DEFAULT_STEP_ORDER_PRECISION))
216
+ end
217
+
218
+ def duplicate_succeeded_step(step, new_branch, new_run, now, started_at)
219
+ new_step = step.dup
220
+ new_step.source_step = step
221
+ new_step.branch = new_branch
222
+ new_step.run = new_run
223
+ new_step.started_at = started_at
224
+ new_step.completed_at = now
225
+
226
+ new_step.save!
227
+ duplicate_job(step.job, new_step, now) if step.job.present?
228
+ end
229
+
230
+ def re_enqueue_failed_step(failed_step, new_branch, new_run, now)
213
231
  args = JSON.parse(failed_step.job.input_args).fetch("args")
214
232
  step = new_branch.steps.create!(
215
233
  run: new_run,
@@ -223,8 +241,7 @@ module Ductwork
223
241
  Ductwork::Job.enqueue(step, *args)
224
242
  end
225
243
 
226
- def re_advance_latest_step(branch, new_branch, new_run, now)
227
- latest_step = branch.steps.order(started_at: :desc).first
244
+ def re_advance_latest_step(latest_step, new_branch, new_run, now)
228
245
  step = new_branch.steps.create!(
229
246
  run: new_run,
230
247
  source_step: latest_step,
@@ -234,11 +251,15 @@ module Ductwork
234
251
  status: :advancing,
235
252
  started_at: now
236
253
  )
237
- job = latest_step.job.dup
238
- job.step = step
239
- job.started_at = now
240
- job.completed_at = now
241
- job.save!
254
+ duplicate_job(latest_step.job, step, now)
255
+ end
256
+
257
+ def duplicate_job(job, new_step, now)
258
+ new_job = job.dup
259
+ new_job.step = new_step
260
+ new_job.started_at = now
261
+ new_job.completed_at = now
262
+ new_job.save!
242
263
  end
243
264
 
244
265
  def conditionally_duplicate_context(last_run, new_run, now, duplicate_context)
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ductwork
4
+ class PipelineGraph
5
+ # Places a pipeline's nodes in columns by longest path from the start, so
6
+ # branches of different lengths still line up where they merge. A link
7
+ # that spans several columns is routed through a placeholder in each one
8
+ # so it runs around the cards in between rather than behind them.
9
+ class Layout
10
+ CARD_WIDTH = 208
11
+ COLUMN_GAP = 48
12
+ ROW_GAP = 24
13
+ PADDING = 8
14
+ SWEEPS = 8
15
+
16
+ attr_reader :width, :height
17
+
18
+ def initialize(nodes:, links:, card_height:)
19
+ @nodes = nodes
20
+ @links = links
21
+ @card_height = card_height
22
+ @pitch = card_height + ROW_GAP
23
+
24
+ assign_columns
25
+ subdivide_links
26
+ order_columns
27
+ assign_rows
28
+ measure
29
+ end
30
+
31
+ def position(node)
32
+ [x(node), y(node)]
33
+ end
34
+
35
+ def route(from, to)
36
+ chain = chains.fetch([from, to])
37
+ start = "M#{right(chain.first).join(" ")}"
38
+ curves = chain.each_cons(2).map { |a, b| curve(a, b) }
39
+
40
+ [start, *curves].join(" ")
41
+ end
42
+
43
+ private
44
+
45
+ attr_reader :nodes, :links, :card_height, :pitch, :column, :columns, :chains, :rows,
46
+ :predecessors, :successors
47
+
48
+ def assign_columns
49
+ @column = nodes.to_h { |node| [node, 0] }
50
+ outgoing = links.group_by(&:first)
51
+
52
+ nodes.each do |node|
53
+ outgoing.fetch(node, []).map(&:last).each do |to|
54
+ column[to] = [column[to], column[node] + 1].max
55
+ end
56
+ end
57
+ end
58
+
59
+ def subdivide_links
60
+ @predecessors = Hash.new { |hash, key| hash[key] = [] }
61
+ @successors = Hash.new { |hash, key| hash[key] = [] }
62
+ @chains = links.to_h { |from, to| [[from, to], chain_for(from, to)] }
63
+
64
+ chains.each_value do |chain|
65
+ chain.each_cons(2) do |a, b|
66
+ successors[a] << b
67
+ predecessors[b] << a
68
+ end
69
+ end
70
+ end
71
+
72
+ def chain_for(from, to)
73
+ placeholders = ((column[from] + 1)...column[to]).map do |index|
74
+ [from, to, index].tap { |placeholder| column[placeholder] = index }
75
+ end
76
+
77
+ [from, *placeholders, to]
78
+ end
79
+
80
+ def order_columns
81
+ @columns = column.keys.group_by { |vertex| column[vertex] }.sort.map(&:last)
82
+
83
+ (1...columns.length).each do |index|
84
+ ranks = columns[index - 1].each_with_index.to_h
85
+ columns[index] = columns[index].sort_by.with_index do |vertex, position|
86
+ [mean(predecessors[vertex].filter_map { |predecessor| ranks[predecessor] }) || position, position]
87
+ end
88
+ end
89
+ end
90
+
91
+ def assign_rows
92
+ @rows = {}
93
+ columns.each do |vertices|
94
+ vertices.each_with_index { |vertex, index| rows[vertex] = (index - ((vertices.length - 1) / 2.0)) * pitch }
95
+ end
96
+
97
+ SWEEPS.times do
98
+ sweep(columns, predecessors)
99
+ sweep(columns.reverse, successors)
100
+ end
101
+ sweep(columns, predecessors)
102
+ end
103
+
104
+ def sweep(ordered_columns, neighbors)
105
+ ordered_columns.each do |vertices|
106
+ targets = vertices.map { |vertex| mean(neighbors[vertex].map { |other| rows[other] }) || rows[vertex] }
107
+
108
+ vertices.zip(separate(targets)) { |vertex, row| rows[vertex] = row }
109
+ end
110
+ end
111
+
112
+ # Pushes rows apart to at least `pitch`, keeping their order and their
113
+ # overall center where the targets asked for it.
114
+ def separate(targets)
115
+ placed = targets.each_with_object([]) do |target, result|
116
+ result << (result.empty? ? target : [target, result.last + pitch].max)
117
+ end
118
+ shift = mean(targets) - mean(placed)
119
+
120
+ placed.map { |row| row + shift }
121
+ end
122
+
123
+ def measure
124
+ top = rows.values.min || 0
125
+ rows.transform_values! { |row| (row - top).round + PADDING }
126
+
127
+ @width = (PADDING * 2) + (columns.length * CARD_WIDTH) + ([columns.length - 1, 0].max * COLUMN_GAP)
128
+ @height = (rows.values.max || 0) + card_height + PADDING
129
+ end
130
+
131
+ def x(vertex)
132
+ PADDING + (column[vertex] * (CARD_WIDTH + COLUMN_GAP))
133
+ end
134
+
135
+ def y(vertex)
136
+ rows[vertex]
137
+ end
138
+
139
+ def left(vertex)
140
+ [x(vertex), y(vertex) + (card_height / 2)]
141
+ end
142
+
143
+ def right(vertex)
144
+ [x(vertex) + CARD_WIDTH, y(vertex) + (card_height / 2)]
145
+ end
146
+
147
+ def curve(from, to)
148
+ (start_x, start_y) = right(from)
149
+ (end_x, end_y) = left(to)
150
+ middle = (start_x + end_x) / 2
151
+ path = "C#{middle} #{start_y}, #{middle} #{end_y}, #{end_x} #{end_y}"
152
+
153
+ nodes.include?(to) ? path : "#{path} L#{right(to).join(" ")}"
154
+ end
155
+
156
+ def mean(values)
157
+ values.sum.to_f / values.length if values.any?
158
+ end
159
+ end
160
+ end
161
+ end