cronwatch 0.10.0 → 0.11.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +18 -9
  3. data/lib/cronwatch/alerts/datadog.rb +1 -0
  4. data/lib/cronwatch/alerts/discord.rb +22 -3
  5. data/lib/cronwatch/alerts/email.rb +2 -0
  6. data/lib/cronwatch/alerts/honeybadger.rb +1 -0
  7. data/lib/cronwatch/alerts/postmark.rb +1 -0
  8. data/lib/cronwatch/alerts/provider.rb +2 -0
  9. data/lib/cronwatch/alerts/resend.rb +1 -0
  10. data/lib/cronwatch/alerts/rollbar.rb +1 -0
  11. data/lib/cronwatch/alerts/sentry.rb +1 -0
  12. data/lib/cronwatch/alerts/sigv4.rb +2 -0
  13. data/lib/cronwatch/alerts/slack.rb +1 -0
  14. data/lib/cronwatch/alerts/twilio.rb +1 -0
  15. data/lib/cronwatch/alerts/webhook.rb +18 -5
  16. data/lib/cronwatch/client.rb +66 -17
  17. data/lib/cronwatch/cron_pattern.rb +17 -8
  18. data/lib/cronwatch/deprecation.rb +18 -0
  19. data/lib/cronwatch/duration.rb +2 -0
  20. data/lib/cronwatch/environment.rb +93 -17
  21. data/lib/cronwatch/evaluate.rb +41 -10
  22. data/lib/cronwatch/flight.rb +2 -0
  23. data/lib/cronwatch/format.rb +1 -0
  24. data/lib/cronwatch/http.rb +21 -5
  25. data/lib/cronwatch/job.rb +2 -0
  26. data/lib/cronwatch/js.rb +74 -1
  27. data/lib/cronwatch/monitored.rb +6 -1
  28. data/lib/cronwatch/output.rb +3 -0
  29. data/lib/cronwatch/pg_cron.rb +22 -3
  30. data/lib/cronwatch/rails/active_job.rb +2 -2
  31. data/lib/cronwatch/schedule.rb +25 -1
  32. data/lib/cronwatch/scheduler.rb +6 -1
  33. data/lib/cronwatch/serialize.rb +1 -0
  34. data/lib/cronwatch/stats.rb +1 -0
  35. data/lib/cronwatch/stores/active_record.rb +36 -18
  36. data/lib/cronwatch/ticker.rb +2 -0
  37. data/lib/cronwatch/triage/anthropic.rb +2 -0
  38. data/lib/cronwatch/types.rb +148 -31
  39. data/lib/cronwatch/version.rb +1 -1
  40. data/lib/cronwatch/walker.rb +2 -0
  41. data/lib/cronwatch/web/app.rb +108 -33
  42. data/lib/cronwatch/web/html.rb +6 -3
  43. data/lib/cronwatch/web/origin.rb +2 -0
  44. data/lib/cronwatch/web/pwa.rb +2 -0
  45. data/lib/cronwatch/web/timeline.rb +2 -0
  46. data/lib/cronwatch/web.rb +1 -2
  47. data/lib/cronwatch/zone.rb +2 -0
  48. data/lib/cronwatch.rb +2 -0
  49. data/lib/generators/cronwatch/install/install_generator.rb +1 -1
  50. metadata +2 -1
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cronwatch
4
+ # @api private
4
5
  module Serialize
5
6
  module_function
6
7
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cronwatch
4
+ # @api private
4
5
  module Stats
5
6
  module_function
6
7
 
@@ -247,9 +247,10 @@ module Cronwatch
247
247
  read(:running_runs, []).map { |row| row_to_run(row) }
248
248
  end
249
249
 
250
+ # A state whose text does not parse, or is not an object, reads as none.
250
251
  def get_state(job)
251
252
  row = read(:get_state, [job]).first
252
- row && JobState.from_h(json(row["state"]))
253
+ row && JobState.from_h(lenient_json(row["state"]))
253
254
  end
254
255
 
255
256
  def set_state(state)
@@ -347,7 +348,8 @@ module Cronwatch
347
348
  # The version inside a state's JSON, as Evaluate.state_version reads
348
349
  # it: a whole number from 0 to 2**53 - 1, else 0 (none, or a foreign
349
350
  # row's 1.5 or "x", which must neither fail the statement nor refuse
350
- # every write for good). Each CASE tests the JSON type before any cast.
351
+ # every write for good; on SQLite, also text that is not JSON). Each
352
+ # CASE tests the JSON type before any cast.
351
353
  version = lambda do |column|
352
354
  if pg
353
355
  v = "(#{column}->>'version')::numeric"
@@ -355,7 +357,8 @@ module Cronwatch
355
357
  "WHEN #{v} % 1 = 0 AND #{v} BETWEEN 0 AND 9007199254740991 THEN #{v}::bigint ELSE 0 END"
356
358
  end
357
359
  v = "json_extract(#{column}, '$.version')"
358
- "CASE WHEN json_type(#{column}, '$.version') NOT IN ('integer', 'real') THEN 0 " \
360
+ # Text that is not JSON at all (SQLite holds any) counts as 0 too, before json_type could fail on it.
361
+ "CASE WHEN NOT json_valid(#{column}) THEN 0 WHEN json_type(#{column}, '$.version') NOT IN ('integer', 'real') THEN 0 " \
359
362
  "WHEN #{v} = CAST(#{v} AS INTEGER) AND #{v} BETWEEN 0 AND 9007199254740991 THEN CAST(#{v} AS INTEGER) ELSE 0 END"
360
363
  end
361
364
  sql = {
@@ -425,28 +428,43 @@ module Cronwatch
425
428
  end
426
429
  end
427
430
 
428
- def int(value)
429
- value.nil? ? nil : Integer(value)
431
+ # Rows are read leniently: a foreign, hand-edited or damaged row (SQLite
432
+ # keeps whatever type it is given, in any column) must affect only its
433
+ # own job, never every read. JSON text that does not parse (or nests
434
+ # past JSON.parse's limit) reads as nil, which the client takes as no
435
+ # state, or as an unreadable definition it reports.
436
+ def lenient_json(value)
437
+ json(value)
438
+ rescue ::JSON::ParserError
439
+ nil
430
440
  end
431
441
 
432
- def row_to_job(row)
433
- StoredJob.new(name: row["name"], definition: JobDefinition.from_h(definition_json(row["definition"])),
434
- created_at: int(row["created_at"]), updated_at: int(row["updated_at"]))
442
+ # A time that must be there: one that is not a finite number reads as 0.
443
+ def time(value)
444
+ JS.finite_number(value) || 0
435
445
  end
436
446
 
437
- # A definition that does not parse (nested past JSON.parse's limit, say)
438
- # reads as nil, which JobDefinition.from_h marks unreadable.
439
- def definition_json(value)
440
- json(value)
441
- rescue ::JSON::ParserError
442
- nil
447
+ # A time or duration that may be absent: one that is not a finite number reads as nil.
448
+ def maybe_time(value)
449
+ JS.finite_number(value)
450
+ end
451
+
452
+ def row_to_job(row)
453
+ StoredJob.new(name: row["name"], definition: JobDefinition.from_h(lenient_json(row["definition"]), row["name"]),
454
+ created_at: time(row["created_at"]), updated_at: time(row["updated_at"]))
443
455
  end
444
456
 
457
+ # A start that is not a finite number reads as 0, a finish or duration
458
+ # as nil; an error or output that is not text as nil; metrics that do
459
+ # not parse to an object as {}; a trigger that is not text as "run".
445
460
  def row_to_run(row)
446
- metrics = row["metrics"].nil? ? {} : Run.metrics_from(json(row["metrics"]))
447
- Run.new(id: row["id"], job: row["job"], status: row["status"].to_sym, started_at: int(row["started_at"]),
448
- finished_at: int(row["finished_at"]), duration_ms: int(row["duration_ms"]), error: row["error"],
449
- output: row["output"], metrics: metrics, trigger: row["trigger"])
461
+ error = row["error"]
462
+ output = row["output"]
463
+ trigger = row["trigger"]
464
+ Run.new(id: row["id"], job: row["job"], status: Run.status_from(row["status"]), started_at: time(row["started_at"]),
465
+ finished_at: maybe_time(row["finished_at"]), duration_ms: maybe_time(row["duration_ms"]),
466
+ error: error.is_a?(String) ? error : nil, output: output.is_a?(String) ? output : nil,
467
+ metrics: Run.metrics_from(lenient_json(row["metrics"])), trigger: trigger.is_a?(String) ? trigger : "run")
450
468
  end
451
469
  end
452
470
  end
@@ -6,6 +6,8 @@ module Cronwatch
6
6
  class Client
7
7
  # Calls the block after `first` seconds, then every `interval` seconds
8
8
  # counted from the start, in a background thread, until stopped.
9
+ #
10
+ # @api private
9
11
  class Ticker
10
12
  def initialize(interval, first, &tick)
11
13
  @lock = Mutex.new
@@ -37,6 +37,8 @@ module Cronwatch
37
37
 
38
38
  # JavaScript's /<\/?job_data/gi, spelled out: Ruby's /i folds more than ASCII.
39
39
  TAG = %r{</?[Jj][Oo][Bb]_[Dd][Aa][Tt][Aa]}
40
+ private_constant :DEFAULT_EFFORT, :DEFAULT_MAX_TOKENS, :DEFAULT_MODEL, :FALLBACK_BETA, :REQUEST_TIMEOUT_MS,
41
+ :SYSTEM, :TAG
40
42
 
41
43
  attr_reader :model, :effort, :max_tokens, :fallbacks, :context
42
44
 
@@ -7,6 +7,8 @@ module Cronwatch
7
7
  # Ruby names are snake_case symbols; everything that leaves the process
8
8
  # (store rows, JSON, webhook bodies) uses the SDK's camelCase names and
9
9
  # string values. Each type's to_h is that JSON shape, and from_h reads it.
10
+ #
11
+ # @api private
10
12
  module Naming
11
13
  module_function
12
14
 
@@ -51,6 +53,8 @@ module Cronwatch
51
53
  end
52
54
 
53
55
  # Shared by the structs below.
56
+ #
57
+ # @api private
54
58
  module Serializable
55
59
  def as_json(*)
56
60
  to_h
@@ -78,28 +82,41 @@ module Cronwatch
78
82
 
79
83
  FIELDS.each_key { |field| define_method(field) { @fields[field] } }
80
84
 
81
- # What from_h makes a stored definition that is not a JSON object from.
82
- UNREADABLE = {}.freeze
85
+ private_constant :BY_JSON, :FIELDS
83
86
 
84
87
  def initialize(fields = {})
85
88
  @fields = {}
86
89
  fields.each { |k, v| @fields[k.is_a?(Symbol) ? k : k.to_s] = v }
87
90
  @fields.freeze
88
- @unreadable = fields.equal?(UNREADABLE)
91
+ @unreadable = false
89
92
  freeze
90
93
  end
91
94
 
92
95
  # A stored definition that is not a JSON object (null, a string, a
93
- # number, written by hand or by something else) reads as an empty one
94
- # marked unreadable, so the one row does not stop every other job being
95
- # listed; a check or a dashboard read reports that job and shows it as
96
- # failing (see Client#evaluable).
97
- def self.from_h(hash)
96
+ # number, an array, or text that does not parse, written by hand or by
97
+ # something else) reads as `{ name }` (`name` being the job's, when
98
+ # given) marked unreadable, so the one row does not stop every other job
99
+ # being listed; a check or a dashboard read reports that job and shows it
100
+ # as failing (see Client#evaluable). `tags` is kept only when it is a
101
+ # list of strings. Every other field is kept as stored.
102
+ def self.from_h(hash, name = nil)
98
103
  return hash if hash.is_a?(JobDefinition)
99
- return new(UNREADABLE) unless hash.is_a?(Hash)
104
+ return unreadable(name) unless hash.is_a?(Hash)
105
+
106
+ fields = hash.each_with_object({}) { |(k, v), out| out[BY_JSON[k.to_s] || k.to_s] = v }
107
+ tags = fields[:tags]
108
+ fields.delete(:tags) if fields.key?(:tags) && !(tags.is_a?(Array) && tags.all?(String))
109
+ new(fields)
110
+ end
100
111
 
101
- new(hash.each_with_object({}) { |(k, v), out| out[BY_JSON[k.to_s] || k.to_s] = v })
112
+ # `{ name }`, marked unreadable. See from_h.
113
+ def self.unreadable(name)
114
+ definition = allocate
115
+ definition.instance_variable_set(:@fields, (name.nil? ? {} : { name: name }).freeze)
116
+ definition.instance_variable_set(:@unreadable, true)
117
+ definition.freeze
102
118
  end
119
+ private_class_method :unreadable
103
120
 
104
121
  def [](field)
105
122
  @fields[field]
@@ -161,7 +178,7 @@ module Cronwatch
161
178
  new(
162
179
  id: Naming.fetch(hash, "id"),
163
180
  job: Naming.fetch(hash, "job"),
164
- status: Naming.fetch(hash, "status")&.to_sym,
181
+ status: Run.status_from(Naming.fetch(hash, "status")),
165
182
  started_at: Naming.fetch(hash, "startedAt"),
166
183
  finished_at: Naming.fetch(hash, "finishedAt"),
167
184
  duration_ms: Naming.fetch(hash, "durationMs"),
@@ -174,12 +191,19 @@ module Cronwatch
174
191
 
175
192
  def to_h
176
193
  {
177
- "id" => id, "job" => job, "status" => status.to_s, "startedAt" => started_at, "finishedAt" => finished_at,
194
+ "id" => id, "job" => job, "status" => status.is_a?(Symbol) ? status.to_s : status,
195
+ "startedAt" => started_at, "finishedAt" => finished_at,
178
196
  "durationMs" => duration_ms, "error" => error, "output" => output,
179
197
  "metrics" => Run.metrics_from(metrics), "trigger" => trigger,
180
198
  }
181
199
  end
182
200
 
201
+ # A status as read: text as a Symbol, anything else (a foreign or
202
+ # damaged row's) kept as it came, which matches no known status.
203
+ def self.status_from(value)
204
+ value.is_a?(String) ? value.to_sym : value
205
+ end
206
+
183
207
  # Stored metrics as a Hash with string keys. Anything else (a string or
184
208
  # an array a foreign or corrupted row holds) reads as none.
185
209
  def self.metrics_from(value)
@@ -196,9 +220,10 @@ module Cronwatch
196
220
  def self.from_h(hash)
197
221
  return hash if hash.is_a?(StoredJob)
198
222
 
223
+ name = Naming.fetch(hash, "name")
199
224
  new(
200
- name: Naming.fetch(hash, "name"),
201
- definition: JobDefinition.from_h(Naming.fetch(hash, "definition") || {}),
225
+ name: name,
226
+ definition: JobDefinition.from_h(Naming.fetch(hash, "definition"), name),
202
227
  created_at: Naming.fetch(hash, "createdAt"),
203
228
  updated_at: Naming.fetch(hash, "updatedAt"),
204
229
  )
@@ -211,6 +236,8 @@ module Cronwatch
211
236
 
212
237
  # An alert before it has a title and message. See Format.compose_alert.
213
238
  # `details` is a hash with snake_case symbol keys; its JSON is camelCase.
239
+ #
240
+ # @api private
214
241
  AlertDraft = Struct.new(:type, :run, :details, keyword_init: true) do
215
242
  include Serializable
216
243
 
@@ -241,65 +268,145 @@ module Cronwatch
241
268
  @triage_tried = true
242
269
  end
243
270
 
271
+ # Read leniently, since a queued alert a foreign writer left malformed
272
+ # must affect only its own job: a field of the wrong type is kept as it
273
+ # came (a `type` or `run` that is not what it should be, `details` that
274
+ # are not an object, an `at` that is not a number), and the retry drops
275
+ # such an alert as stale (Evaluate.stale_alert?).
244
276
  def self.from_h(hash)
245
277
  return hash if hash.is_a?(Alert)
246
278
 
247
279
  run = Naming.fetch(hash, "run")
248
- details = Naming.from_json_value(Naming.fetch(hash, "details") || {})
249
- details[:after] = details[:after].map(&:to_sym) if details[:after].is_a?(Array)
250
- details[:reason] = details[:reason].to_sym if details[:reason].is_a?(String)
280
+ written = Naming.fetch(hash, "details") || {}
281
+ details = Naming.from_json_value(written)
282
+ if details.is_a?(Hash)
283
+ details[:after] = details[:after].map { |c| c.is_a?(String) ? c.to_sym : c } if details[:after].is_a?(Array)
284
+ details[:reason] = details[:reason].to_sym if details[:reason].is_a?(String)
285
+ end
286
+ type = Naming.fetch(hash, "type")
287
+ definition = Naming.fetch(hash, "definition")
251
288
  alert = new(
252
- type: Naming.fetch(hash, "type")&.to_sym,
253
- run: run && Run.from_h(run),
289
+ type: type.is_a?(String) ? type.to_sym : type,
290
+ run: run.is_a?(Hash) ? Run.from_h(run) : run,
254
291
  details: details,
255
292
  job: Naming.fetch(hash, "job"),
256
- definition: JobDefinition.from_h(Naming.fetch(hash, "definition") || {}),
293
+ definition: definition.nil? || definition.is_a?(Hash) ? JobDefinition.from_h(definition || {}) : definition,
257
294
  title: Naming.fetch(hash, "title"),
258
295
  message: Naming.fetch(hash, "message"),
259
296
  at: Naming.fetch(hash, "at"),
260
297
  )
261
298
  alert.triage_result = Naming.fetch(hash, "triage") if Naming.present?(hash, "triage")
299
+ alert.keep_as_written(hash, written, details)
262
300
  alert
263
301
  end
264
302
 
303
+ # What a stored alert held that its members do not say again: the
304
+ # fields this version does not know (a newer release may add one), its
305
+ # details as written, since snake_case and back would turn a key spelled
306
+ # `a_b` into `aB`, and which known fields it left out. to_h writes the
307
+ # first two back, the details only while they are unchanged, and leaves
308
+ # out a field that was absent while it still holds what its absence
309
+ # read as, so a partial alert (a foreign row's) is written as it came,
310
+ # in the order it came. An alert with every field the SDK writes has
311
+ # them written back in the SDK's order, then the fields it does not
312
+ # know, since a store on Postgres jsonb gives an object's keys back in
313
+ # an order of its own (as JobState.sending_entry does for an entry).
314
+ #
315
+ # @api private
316
+ def keep_as_written(hash, written, details)
317
+ extra = hash.each_with_object({}) { |(k, v), out| out[k.to_s] = v unless Alert::KNOWN.include?(k.to_s) }
318
+ @extra = extra.empty? ? nil : extra
319
+ @written_details = [Marshal.load(Marshal.dump(details)), Alert.string_keys(written)] if written.is_a?(Hash)
320
+ absent = (Alert::KNOWN - ["triage"]).reject { |key| Naming.present?(hash, key) }
321
+ @absent = absent.empty? ? nil : to_h.slice(*absent)
322
+ order = hash.keys.map(&:to_s)
323
+ @order = @absent && order.uniq == order ? order : nil
324
+ end
325
+
326
+ # @api private
327
+ def self.string_keys(value)
328
+ case value
329
+ when Hash then value.to_h { |k, v| [k.to_s, string_keys(v)] }
330
+ when Array then value.map { |v| string_keys(v) }
331
+ else value
332
+ end
333
+ end
334
+
265
335
  def to_h
266
336
  out = {
267
- "type" => type.to_s, "run" => run&.to_h, "details" => Naming.to_json_value(details), "job" => job,
268
- "definition" => definition.respond_to?(:to_h) ? definition.to_h : definition,
337
+ "type" => type.is_a?(Symbol) ? type.to_s : type, "run" => run.is_a?(Run) ? run.to_h : run,
338
+ "details" => details_json, "job" => job,
339
+ "definition" => definition.is_a?(JobDefinition) ? definition.to_h : definition,
269
340
  "title" => title, "message" => message, "at" => at,
270
341
  }
342
+ @absent&.each { |key, value| out.delete(key) if out[key] == value }
271
343
  out["triage"] = triage if triage_tried?
272
- out
344
+ @extra&.each { |key, value| out[key] = value unless out.key?(key) || Alert::KNOWN.include?(key) }
345
+ return out if @order.nil? || @order == out.keys
346
+
347
+ # The keys in the order they were read, as the object a store held
348
+ # keeps them; any added since go after.
349
+ written = @order.select { |key| out.key?(key) }
350
+ (written + (out.keys - written)).to_h { |key| [key, out[key]] }
351
+ end
352
+
353
+ private
354
+
355
+ def details_json
356
+ return @written_details[1] if @written_details && @written_details[0] == details
357
+
358
+ Naming.to_json_value(details)
273
359
  end
274
360
  end
275
361
 
362
+ # The fields this version reads from a stored alert; the rest are kept as they came.
363
+ # @api private
364
+ Alert::KNOWN = %w[type run details job definition title message at triage].freeze
365
+
276
366
  # `version` goes up by one on every write, so a store can refuse a write
277
367
  # made from a stale read (see Stores::Memory#compare_and_set_state). Absent
278
368
  # (nil) counts as 0.
279
369
  #
370
+ # from_h reads a state as stored, keeping each field as it came where it
371
+ # has the wrong type (Evaluate.normalize_state then reads it leniently),
372
+ # so a foreign, hand-edited or damaged row is written back unchanged by a
373
+ # read that changes nothing, and affects only its own job. A stored state
374
+ # that is not a JSON object reads as none (nil).
375
+ #
280
376
  # `sending` is the outbox (see Evaluate.hold_alerts): entries of
281
377
  # { "until" => epoch ms, "alert" => Alert }, read leniently, since an entry
282
378
  # a foreign writer left malformed must not make the whole state unreadable:
283
379
  # an entry that is not a Hash, or whose alert is not one, is kept as it
284
380
  # came. nil when there is none; it is never written empty.
381
+ #
382
+ # `extra` holds the fields this version does not know, written by a newer
383
+ # one, as the JSON they came as (string keys): every write carries them
384
+ # back unchanged, after the known fields, so a process sharing the store
385
+ # with a newer release never erases what that release keeps there. nil
386
+ # when there are none. An open condition this version does not know stays
387
+ # in `open` the same way, under its own name.
285
388
  JobState = Struct.new(:job, :open, :consecutive_failures, :silenced_until, :last_alert_at, :pending_recovery,
286
- :undelivered, :version, :sending, keyword_init: true) do
389
+ :undelivered, :version, :sending, :extra, keyword_init: true) do
287
390
  include Serializable
288
391
 
289
392
  def self.from_h(hash)
290
393
  return hash if hash.is_a?(JobState)
394
+ return nil unless hash.is_a?(Hash)
291
395
 
396
+ open = Naming.fetch(hash, "open") || {}
292
397
  pending = Naming.fetch(hash, "pendingRecovery")
293
398
  undelivered = Naming.fetch(hash, "undelivered")
294
399
  sending = Naming.fetch(hash, "sending")
400
+ extra = hash.each_with_object({}) { |(k, v), out| out[k.to_s] = v unless JobState::KNOWN.include?(k.to_s) }
295
401
  new(
402
+ extra: extra.empty? ? nil : extra,
296
403
  job: Naming.fetch(hash, "job"),
297
- open: (Naming.fetch(hash, "open") || {}).each_with_object({}) { |(k, v), out| out[k.to_sym] = v },
404
+ open: open.is_a?(Hash) ? open.to_h { |k, v| [k.to_sym, v] } : open,
298
405
  consecutive_failures: Naming.fetch(hash, "consecutiveFailures", 0),
299
406
  silenced_until: Naming.fetch(hash, "silencedUntil"),
300
407
  last_alert_at: Naming.fetch(hash, "lastAlertAt"),
301
- pending_recovery: pending&.map(&:to_sym),
302
- undelivered: undelivered&.map { |a| Alert.from_h(a) },
408
+ pending_recovery: pending.is_a?(Array) ? pending.map { |c| c.is_a?(String) ? c.to_sym : c } : pending,
409
+ undelivered: undelivered.is_a?(Array) ? undelivered.map { |a| a.is_a?(Hash) ? Alert.from_h(a) : a } : undelivered,
303
410
  version: Naming.fetch(hash, "version"),
304
411
  sending: sending.is_a?(Array) ? sending.map { |entry| JobState.sending_entry(entry) } : sending,
305
412
  )
@@ -331,21 +438,31 @@ module Cronwatch
331
438
  # only while it holds an entry.
332
439
  def to_h
333
440
  out = {
334
- "job" => job, "open" => (open || {}).transform_keys(&:to_s), "consecutiveFailures" => consecutive_failures,
335
- "silencedUntil" => silenced_until, "lastAlertAt" => last_alert_at,
441
+ "job" => job, "open" => open.is_a?(Hash) ? open.transform_keys(&:to_s) : (open || {}),
442
+ "consecutiveFailures" => consecutive_failures, "silencedUntil" => silenced_until, "lastAlertAt" => last_alert_at,
336
443
  }
337
- out["pendingRecovery"] = pending_recovery.map(&:to_s) unless pending_recovery.nil?
338
- out["undelivered"] = undelivered.map(&:to_h) unless undelivered.nil?
444
+ unless pending_recovery.nil?
445
+ out["pendingRecovery"] =
446
+ pending_recovery.is_a?(Array) ? pending_recovery.map { |c| c.is_a?(Symbol) ? c.to_s : c } : pending_recovery
447
+ end
448
+ unless undelivered.nil?
449
+ out["undelivered"] = undelivered.is_a?(Array) ? undelivered.map { |a| a.is_a?(Alert) ? a.to_h : a } : undelivered
450
+ end
339
451
  out["version"] = version unless version.nil?
340
452
  if sending.is_a?(Array) && !sending.empty?
341
453
  out["sending"] = sending.map do |entry|
342
454
  entry.is_a?(Hash) ? entry.transform_values { |v| v.is_a?(Alert) ? v.to_h : v } : entry
343
455
  end
344
456
  end
457
+ extra&.each { |key, value| out[key] = value unless out.key?(key) || JobState::KNOWN.include?(key) }
345
458
  out
346
459
  end
347
460
  end
348
461
 
462
+ # The fields this version reads from a stored state; the rest are kept in `extra`.
463
+ # @api private
464
+ JobState::KNOWN = %w[job open consecutiveFailures silencedUntil lastAlertAt pendingRecovery undelivered version sending].freeze
465
+
349
466
  JobStats = Struct.new(:runs, :ok_rate, :p50_ms, :p95_ms, keyword_init: true) do
350
467
  include Serializable
351
468
 
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Cronwatch
4
4
  # Kept in step with @cronwatch/sdk.
5
- VERSION = "0.10.0"
5
+ VERSION = "0.11.0"
6
6
  end
@@ -6,6 +6,8 @@ module Cronwatch
6
6
  # the next match, a field at a time, spilling into the next month or year
7
7
  # as croner does (including its habit of stepping into a day the month
8
8
  # does not have and letting the date roll over). Month is 0 based.
9
+ #
10
+ # @api private
9
11
  class Walker
10
12
  DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31].freeze
11
13
  # [field, the field above it, offset from a field value to its pattern index]