cronwatch 0.14.1 → 0.14.3

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: d0a9fcfe8eb1b18e8afd7b80e2b41e62569bf606db798252d2305f9f353235ff
4
- data.tar.gz: 12626c9fc256348658a3843ac1704b97ef7cd4fde62f289e0c570c174dd29704
3
+ metadata.gz: d78436fcdc58bdb265650a95d09bfc6300133aa0d24434cfb575fe3f726bb846
4
+ data.tar.gz: 1c52f21a7b4a2c724c8cfa75932e38b56eade9198f53602718c4d5be78eebe24
5
5
  SHA512:
6
- metadata.gz: 4d9274543edac3d3400762e5863b08a2b9cf8a818b134996e28a292cfe802552e6ecffbc68b18a7a4a9258499e476e7e7982a546b4b204ebc5f146829e14cebc
7
- data.tar.gz: ab1c1ef624f8e94f56eb73c2be364ebf9b799e1b7bceef3a69e4398604e72c1eb63252f894aec82bdb4dfc61d9b9803048a02564dd60d807a273cfde62b0b364
6
+ metadata.gz: 554b274bd8e3058755b2459778d4addcc6de0d2ab39fef01d47c15837d3dee796837333df8c019eaba223b63505cfe6f7c9ef331d0d0fd8d8bcae0ed1c5d46c1
7
+ data.tar.gz: d199b491b1260aea57e40852d36827455c69f3aec205fec3b33b0df631b432dcabce27e75b5a3d605e72f1c3e63531af2620e71e50726e524467f63664e4cdd3
@@ -29,14 +29,14 @@ module Cronwatch
29
29
  def call(alert)
30
30
  url = @link&.call(alert)
31
31
  title = "#{EMOJI[alert.type]} *#{Slack.escape(alert.title)}*#{url && !url.empty? ? " (<#{Slack.link_target(url)}|open>)" : ""}"
32
- body = JS.head16(Slack.code_block_safe(Slack.escape(alert.message)), 2900)
32
+ body = Slack.cut_escaped(Slack.code_block_safe(Slack.escape(alert.message)), 2900)
33
33
  blocks = [
34
34
  { "type" => "section", "text" => { "type" => "mrkdwn", "text" => title } },
35
35
  { "type" => "section", "text" => { "type" => "mrkdwn", "text" => "```#{body}```" } },
36
36
  ]
37
37
  if alert.triage && !alert.triage.empty?
38
38
  # Its own block, so a long diagnosis cannot push a block past Slack's 3000 character limit.
39
- blocks << { "type" => "section", "text" => { "type" => "mrkdwn", "text" => JS.head16("_Triage:_ #{Slack.escape(alert.triage)}", 3000) } }
39
+ blocks << { "type" => "section", "text" => { "type" => "mrkdwn", "text" => Slack.cut_escaped("_Triage:_ #{Slack.escape(alert.triage)}", 3000) } }
40
40
  end
41
41
  payload = {
42
42
  # The notification fallback is parsed as mrkdwn too, so it is escaped like the blocks.
@@ -52,6 +52,14 @@ module Cronwatch
52
52
  text.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
53
53
  end
54
54
 
55
+ # Escaped text cut to at most `max` UTF-16 units, splitting neither an
56
+ # entity escape wrote nor a surrogate pair.
57
+ def self.cut_escaped(text, max)
58
+ kept = JS.head16(text, max)
59
+ amp = kept.rindex("&")
60
+ amp && !kept.index(";", amp) ? kept[0, amp] : kept
61
+ end
62
+
55
63
  # A link's URL as Slack's <url|text> can hold it: `<`, `>`, and `|`
56
64
  # percent-encoded, which ends neither the link nor its text.
57
65
  def self.link_target(url)
@@ -45,9 +45,13 @@ module Cronwatch
45
45
  # The longest run id, in UTF-16 code units (JavaScript's string length):
46
46
  # what start, resume, and record_run take, and every store holds.
47
47
  MAX_RUN_ID = 200
48
+ # The statuses record_run takes.
49
+ RUN_STATUSES = %w[running ok failed timeout].freeze
50
+ # The error a check gives an import whose finish never landed (Evaluate.stranded?).
51
+ STRANDED_ERROR = "Imported, but its finish was never written; marked as timed out"
48
52
  private_constant :CHANNEL_TIMEOUT_MS, :DEFAULT_OPTIONS, :FORK_LOCK, :HISTORY_MAX, :HISTORY_PAGE, :MAX_RUN_ID,
49
- :PRUNE_INTERVAL_MS, :RESERVED_RUN_ID_PREFIX, :RETRY_BUDGET_MS, :SILENCE_OPTIONS, :STATE_ATTEMPTS,
50
- :TRIAGE_TIMEOUT_MS
53
+ :PRUNE_INTERVAL_MS, :RESERVED_RUN_ID_PREFIX, :RETRY_BUDGET_MS, :RUN_STATUSES, :SILENCE_OPTIONS,
54
+ :STATE_ATTEMPTS, :STRANDED_ERROR, :TRIAGE_TIMEOUT_MS
51
55
 
52
56
  # What execute returns: the recorded run, and the block's own outcome.
53
57
  #
@@ -362,7 +366,8 @@ module Cronwatch
362
366
  # redacted the same way; one finishing after a check marked it timeout is
363
367
  # judged only when it succeeded, as RunHandle#finish does. A metric that
364
368
  # is not a finite number raises ArgumentError before anything is written,
365
- # as JobContext#metric does. `evaluate: false` stores it without judging
369
+ # as JobContext#metric does, and so does a status, start, finish,
370
+ # duration, error, output, or trigger evaluation cannot read. `evaluate: false` stores it without judging
366
371
  # it, for history imported on first sight. Returns the alerts it sent.
367
372
  #
368
373
  # `run` is a Cronwatch::Run, or a hash of its fields (camelCase or snake_case keys).
@@ -379,6 +384,7 @@ module Cronwatch
379
384
  if input.id.include?("\0")
380
385
  raise ArgumentError, "record_run: run ids cannot contain a NUL character (job \"#{input.job}\")"
381
386
  end
387
+ check_record_fields(input)
382
388
  # Refused as JobContext#metric refuses them: a store keeps NaN and Infinity as null.
383
389
  unless input.metrics.nil? || input.metrics.is_a?(Hash)
384
390
  raise ArgumentError, "record_run: metrics must be a Hash of numbers (job \"#{input.job}\", run \"#{input.id}\")"
@@ -410,8 +416,18 @@ module Cronwatch
410
416
  stored = @store.get_run(run.id)
411
417
  return record_over(definition, stored, run, evaluate) if stored
412
418
 
419
+ # An evaluated import stays retryable until its durable evaluation marker
420
+ # is written. A finished one's placeholder has finished_at set to when it
421
+ # was written, so a check can tell one whose finish never landed
422
+ # (Evaluate.stranded?); the finish writes the run's own. One still
423
+ # running keeps finished_at nil, so it times out as any running run does.
424
+ inserted = run.dup
425
+ if evaluate
426
+ inserted.finished_at = now unless inserted.status == :running
427
+ inserted.status = :running
428
+ end
413
429
  begin
414
- @store.insert_run(run)
430
+ @store.insert_run(inserted)
415
431
  rescue StandardError
416
432
  # Another process recorded it first.
417
433
  again = begin
@@ -425,10 +441,7 @@ module Cronwatch
425
441
  end
426
442
  return [] unless evaluate
427
443
 
428
- update_state(run.job) { |before| [Evaluate.hold_run(Evaluate.on_run_start(definition, run, before, now), run.id), nil] }
429
- return [] if run.status == :running
430
-
431
- finish_run(definition, run, now, pending_only: true)
444
+ record_over(definition, inserted, run, true)
432
445
  end
433
446
 
434
447
  # Hands an error to on_error, as a source reports what went wrong. See #report.
@@ -911,13 +924,42 @@ module Cronwatch
911
924
  end
912
925
  end
913
926
 
927
+ # The fields evaluation reads, checked before record_run writes anything.
928
+ def check_record_fields(input)
929
+ where = "job \"#{input.job}\", run \"#{input.id}\""
930
+ status = input.status
931
+ unless (status.is_a?(Symbol) || status.is_a?(String)) && RUN_STATUSES.include?(status.to_s)
932
+ raise ArgumentError, "record_run: status must be \"running\", \"ok\", \"failed\", or \"timeout\" (#{where})"
933
+ end
934
+ raise ArgumentError, "record_run: started_at must be a finite number (#{where})" unless JS.finite?(input.started_at)
935
+ raise ArgumentError, "record_run: finished_at must be a finite number or nil (#{where})" unless input.finished_at.nil? || JS.finite?(input.finished_at)
936
+ raise ArgumentError, "record_run: duration_ms must be a finite number or nil (#{where})" unless input.duration_ms.nil? || JS.finite?(input.duration_ms)
937
+
938
+ { "error" => input.error, "output" => input.output }.each do |field, value|
939
+ raise ArgumentError, "record_run: #{field} must be a String or nil (#{where})" unless value.nil? || value.is_a?(String)
940
+ end
941
+ raise ArgumentError, "record_run: trigger must be a String (#{where})" unless input.trigger.is_a?(String)
942
+ end
943
+
914
944
  # record_run for a run already stored.
915
945
  def record_over(definition, stored, run, evaluate)
916
946
  if stored.job != run.job
917
947
  report(RuntimeError.new("run #{run.id} of #{run.job} belongs to job \"#{stored.job}\"; ignored"), "recording #{run.job}")
918
948
  return []
919
949
  end
920
- return [] if !%i[running timeout].include?(stored.status) || run.status == :running
950
+ return [] unless %i[running timeout].include?(stored.status)
951
+
952
+ if evaluate && stored.status == :running
953
+ update_state(run.job) do |before|
954
+ # Recheck each CAS attempt so a concurrent finish cannot resurrect its marker.
955
+ current = @store.get_run(run.id)
956
+ if current.nil? || current.job != run.job || current.status != :running || (before.pending_runs || []).include?(run.id)
957
+ next [before, nil]
958
+ end
959
+ [Evaluate.hold_run(Evaluate.on_run_start(definition, run, before, now), run.id), nil]
960
+ end
961
+ end
962
+ return [] if run.status == :running
921
963
 
922
964
  tracked = (read_state(run.job).pending_runs || []).include?(run.id)
923
965
  late, ignored = claim_finish(run)
@@ -1337,18 +1379,25 @@ module Cronwatch
1337
1379
  next if definition.nil?
1338
1380
 
1339
1381
  evaluable(listed.job, definition)
1340
- next unless Evaluate.stuck?(definition, listed, at)
1382
+ stranded = Evaluate.stranded?(listed, at)
1383
+ next unless stranded || Evaluate.stuck?(definition, listed, at)
1341
1384
 
1342
1385
  # Read again just before the write: lines and metrics flushed since the
1343
1386
  # list was read (while earlier stuck runs were sent, say) are kept.
1344
1387
  run = @store.get_run(listed.id)
1345
1388
  next if run.nil? || run.status != :running || run.job != listed.job
1346
1389
 
1347
- timeout = Evaluate.timeout_ms(definition)
1348
1390
  run.status = :timeout
1349
- run.finished_at = at
1350
- run.duration_ms = Evaluate.run_duration(run.started_at, at)
1351
- run.error = "Still running after #{Duration.format(timeout)}; marked as timed out"
1391
+ if stranded
1392
+ # Its own status was never written; a late finish of it is judged
1393
+ # as one after a timeout, so a success still recovers.
1394
+ run.duration_ms = Evaluate.run_duration(run.started_at, run.finished_at) if run.duration_ms.nil?
1395
+ run.error = run.error.nil? ? STRANDED_ERROR : "#{STRANDED_ERROR}\n#{run.error}"
1396
+ else
1397
+ run.finished_at = at
1398
+ run.duration_ms = Evaluate.run_duration(run.started_at, at)
1399
+ run.error = "Still running after #{Duration.format(Evaluate.timeout_ms(definition))}; marked as timed out"
1400
+ end
1352
1401
  # Only over a row still running: a finish that landed meanwhile wins.
1353
1402
  next unless write_run_if(run, [:running])
1354
1403
 
@@ -688,7 +688,16 @@ module Cronwatch
688
688
 
689
689
  # Whether a running run has gone on longer than the job's timeout.
690
690
  def stuck?(definition, run, now)
691
- run.status == :running && now - run.started_at > timeout_ms(definition)
691
+ run.status == :running && run.finished_at.nil? && now - run.started_at > timeout_ms(definition)
692
+ end
693
+
694
+ # Whether a finished import was left as its placeholder: record_run writes
695
+ # one as running, with finished_at set to when it was written, and the
696
+ # finish that follows never landed (the importer stopped, or a store write
697
+ # failed and it was not retried). A check closes one SEND_LEASE_MS after
698
+ # it was written, as it closes a stuck run.
699
+ def stranded?(run, now)
700
+ run.status == :running && !run.finished_at.nil? && now - run.finished_at > SEND_LEASE_MS
692
701
  end
693
702
 
694
703
  # While a job is silenced nothing new is recorded as an incident: conditions
@@ -55,25 +55,41 @@ module Cronwatch
55
55
  # name lookup: it can be interrupted from Ruby 3.3. On Ruby 3.2 a lookup
56
56
  # that hangs is cut off only by the client's channel deadline.
57
57
  http.open_timeout = HTTP.remaining(deadline)
58
- http.start do |conn|
58
+ # Net::HTTP's read and write timeouts restart for each socket operation.
59
+ # Bound the entire exchange too, including a status line or headers
60
+ # arriving one byte at a time. The connection is private to this call.
61
+ answer = nil
62
+ Timeout.timeout(HTTP.remaining(deadline), TimeoutError, "The operation was aborted due to timeout") do
63
+ http.start
59
64
  remaining = HTTP.remaining(deadline)
60
- conn.read_timeout = remaining
61
- conn.write_timeout = remaining
62
- conn.request(request) do |response|
65
+ http.read_timeout = remaining
66
+ http.write_timeout = remaining
67
+ http.request(request) do |response|
63
68
  status = response.code.to_i
69
+ answer = Response.new(status: status, body: "".b)
64
70
  # Returning from inside the block leaves the rest unread; the connection closes after.
65
- return Response.new(status: status, body: "") if status >= 200 && status < 300
71
+ return answer if status >= 200 && status < 300
66
72
 
67
- return Response.new(status: status, body: read_body(conn, response, deadline))
73
+ read_body(http, response, deadline, answer.body)
74
+ return answer
68
75
  end
69
76
  end
70
- rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout
77
+ rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, TimeoutError
78
+ return answer if answer
79
+
71
80
  raise TimeoutError
81
+ ensure
82
+ # Timeout has cancelled its timer before this cleanup runs. A timeout
83
+ # during close must not replace an answer or interrupt socket cleanup.
84
+ http.finish if http&.started?
72
85
  end
73
86
 
74
87
  # A connection that gives up after ten seconds, as the SDK's requests do.
88
+ # It goes direct: nil as the proxy address keeps Net::HTTP from taking
89
+ # one from http_proxy or https_proxy, which fetch never reads, so an
90
+ # alert (and the credentials in it) reaches only the channel's own host.
75
91
  def connection(uri)
76
- http = Net::HTTP.new(uri.host, uri.port)
92
+ http = Net::HTTP.new(uri.host, uri.port, nil)
77
93
  http.use_ssl = uri.scheme == "https"
78
94
  http.open_timeout = @timeout
79
95
  http.read_timeout = @timeout
@@ -85,8 +101,7 @@ module Cronwatch
85
101
 
86
102
  # The body, read in chunks until it ends or MAX_BODY bytes have come,
87
103
  # whichever is first; what had come when the deadline passes first.
88
- def read_body(conn, response, deadline)
89
- body = "".b
104
+ def read_body(conn, response, deadline, body)
90
105
  conn.read_timeout = HTTP.remaining(deadline)
91
106
  response.read_body do |chunk|
92
107
  raise TimeoutError if HTTP.monotonic >= deadline
data/lib/cronwatch/job.rb CHANGED
@@ -111,8 +111,14 @@ module Cronwatch
111
111
  # dropped from @lines: what expect_text needs once the output runs long.
112
112
  @head = []
113
113
  @head_size = 0
114
- @whole = ""
115
- @tail = ""
114
+ # Every line logged while they come to no more than twice the cap
115
+ # (nil after), joined only when read, so a log is not a copy of them all.
116
+ @whole = []
117
+ @whole_size = 0
118
+ # The raw lines behind expect_text's tail, each with its UTF-16 length,
119
+ # joined only when it is read.
120
+ @raw = []
121
+ @raw_size = 0
116
122
  @logged = false
117
123
  @metrics = {}
118
124
  @lock = Mutex.new
@@ -126,9 +132,15 @@ module Cronwatch
126
132
  separator = @logged ? "\n" : ""
127
133
  @logged = true
128
134
  if @whole
129
- @whole = JS.length16(@whole) + separator.length + length <= 2 * Output::CAP ? @whole + separator + line : nil
135
+ @whole_size += separator.length + length
136
+ @whole = @whole_size <= 2 * Output::CAP ? @whole << line.dup : nil
130
137
  end
131
- @tail = JS.tail16(@tail + separator + JS.tail16(line, Output::CAP), Output::CAP).dup
138
+ raw = length > Output::CAP ? JS.tail16(line, Output::CAP).dup : line.dup
139
+ raw_length = length > Output::CAP ? JS.length16(raw) : length
140
+ @raw << [raw, raw_length]
141
+ @raw_size += raw_length + 1
142
+ # Drops the oldest only while what is left still holds the cap.
143
+ @raw_size -= @raw.shift[1] + 1 while @raw.length > 1 && @raw_size - (@raw[0][1] + 1) > Output::CAP
132
144
  if @head_size < Output::CAP
133
145
  @head << JS.head16(line, Output::CAP - @head_size).dup
134
146
  @head_size += length + 1
@@ -165,9 +177,9 @@ module Cronwatch
165
177
  @lock.synchronize do
166
178
  next nil if @lines.empty?
167
179
 
168
- next @whole if @whole
180
+ next @whole.join("\n") if @whole
169
181
 
170
- "#{JS.head16(@head.join("\n"), Output::CAP)}\n#{@tail}"
182
+ "#{JS.head16(@head.join("\n"), Output::CAP)}\n#{JS.tail16(@raw.map(&:first).join("\n"), Output::CAP)}"
171
183
  end
172
184
  end
173
185
 
@@ -109,6 +109,7 @@ module Cronwatch
109
109
  @pending = {}
110
110
  # Runs read before they started, by runid, with when they were first seen.
111
111
  @held = {}
112
+ @retry = {}
112
113
  # Each job's name and definition as last declared, by jobid.
113
114
  @known = {}
114
115
  # The last definition declared for each name, so an unchanged job is not declared again.
@@ -493,10 +494,12 @@ module Cronwatch
493
494
  def record(host, names, row, evaluate, alerts, now)
494
495
  runid = Integer(row["runid"])
495
496
  jobid = Integer(row["jobid"])
497
+ evaluate = @retry.fetch(runid, evaluate)
496
498
  name = @pending[runid] || names[jobid]
497
499
  if name.nil? || (@declared_now && !names.value?(name) && !@declared_now.include?(name))
498
500
  @pending.delete(runid)
499
501
  @held.delete(runid)
502
+ @retry.delete(runid)
500
503
  @retired.delete(name) if name
501
504
  return
502
505
  end
@@ -516,9 +519,11 @@ module Cronwatch
516
519
  begin
517
520
  alerts.concat(host.record_run(run, evaluate: evaluate))
518
521
  rescue StandardError => e
522
+ @retry[runid] = evaluate
519
523
  host.on_error(e, "source pg_cron: run #{runid}")
520
524
  return
521
525
  end
526
+ @retry.delete(runid)
522
527
  if run.status == :running
523
528
  @pending[runid] = name
524
529
  else
@@ -561,7 +566,7 @@ module Cronwatch
561
566
  id = run_id_of(run.id)
562
567
  @pending[id] = run.job if id && watched.include?(run.job)
563
568
  end
564
- open = (@pending.keys + @held.keys).to_set
569
+ open = (@pending.keys + @held.keys + @retry.keys).to_set
565
570
  complete = false
566
571
  MAX_PAGES.times do
567
572
  jobids = names.keys
@@ -585,6 +590,7 @@ module Cronwatch
585
590
  open.each do |runid|
586
591
  @pending.delete(runid)
587
592
  @held.delete(runid)
593
+ @retry.delete(runid)
588
594
  end
589
595
  end
590
596
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Cronwatch
4
4
  # Kept in step with @cronwatch/sdk.
5
- VERSION = "0.14.1"
5
+ VERSION = "0.14.3"
6
6
  end
@@ -204,6 +204,14 @@ module Cronwatch
204
204
  end
205
205
 
206
206
  def call(env)
207
+ status, headers, body = answer(env)
208
+ # A HEAD reads what a GET would, and is answered without the body.
209
+ env["REQUEST_METHOD"].to_s.upcase == "HEAD" ? [status, headers, []] : [status, headers, body]
210
+ end
211
+
212
+ private
213
+
214
+ def answer(env)
207
215
  wants_html = true
208
216
  base = @base_path || env["SCRIPT_NAME"].to_s.sub(%r{/+\z}, "")
209
217
  begin
@@ -228,8 +236,6 @@ module Cronwatch
228
236
  end
229
237
  end
230
238
 
231
- private
232
-
233
239
  def serve(request, path, wants_html, base)
234
240
  cw = client
235
241
  method = request.verb
@@ -308,14 +314,15 @@ module Cronwatch
308
314
  return wants_html ? html(HTML.message_page("Bad request", "The path is not valid.", base), 400) : api({ ok: false, error: "Bad path" }, 400)
309
315
  end
310
316
 
311
- # HTML
312
- if method == "GET" && path == "/"
317
+ # HTML. A HEAD reads what a GET would; call sends it without the body.
318
+ reads = %w[GET HEAD].include?(method)
319
+ if reads && path == "/"
313
320
  entries = cw.jobs_with_runs(BOARD_PAGE_RUNS)
314
321
  now = cw.now
315
322
  runs_by_job = entries.to_h { |entry| [entry.job.name, entry.runs] }
316
323
  return html(HTML.dashboard_page(entries.map(&:job), runs_by_job, now, base, nil, board_lanes(cw, entries, now)))
317
324
  end
318
- if method == "GET" && parts[0] == "jobs" && parts.length == 2
325
+ if reads && parts[0] == "jobs" && parts.length == 2
319
326
  job = cw.job_summary(parts[1])
320
327
  return html(HTML.message_page("No such job", "#{parts[1]} is not in the store.", base), 404) unless job
321
328
 
@@ -363,14 +370,15 @@ module Cronwatch
363
370
 
364
371
  def serve_api(request, method, rest, bearer)
365
372
  cw = client
366
- if method == "GET" && rest.empty?
373
+ reads = %w[GET HEAD].include?(method)
374
+ if reads && rest.empty?
367
375
  return api({ ok: true, library: LIBRARY, language: LANGUAGE, version: VERSION, api: API_VERSION })
368
376
  end
369
- return api({ ok: true, jobs: cw.jobs }) if method == "GET" && rest[0] == "jobs" && rest.length == 1
377
+ return api({ ok: true, jobs: cw.jobs }) if reads && rest[0] == "jobs" && rest.length == 1
370
378
 
371
379
  if rest[0] == "jobs" && rest.length == 2
372
380
  name = rest[1]
373
- if method == "GET"
381
+ if reads
374
382
  job = cw.job_summary(name)
375
383
  return api({ ok: false, error: "No such job" }, 404) unless job
376
384
 
@@ -413,7 +421,7 @@ module Cronwatch
413
421
  end
414
422
  return api({ "ok" => true }.merge(cw.check.to_h)) if %w[GET POST].include?(method)
415
423
  end
416
- if method == "GET" && rest[0] == "runs" && rest.length == 2
424
+ if reads && rest[0] == "runs" && rest.length == 2
417
425
  run = cw.get_run(rest[1])
418
426
  return run ? api({ ok: true, run: run }) : api({ ok: false, error: "No such run" }, 404)
419
427
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cronwatch
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.14.1
4
+ version: 0.14.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jon C. Phillips