@cronwatch/sdk 0.13.1 → 0.14.1

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 (125) hide show
  1. package/dist/anthropic.cjs +3 -3
  2. package/dist/anthropic.cjs.map +1 -1
  3. package/dist/anthropic.d.cts +1 -1
  4. package/dist/anthropic.d.ts +1 -1
  5. package/dist/anthropic.js +3 -3
  6. package/dist/anthropic.js.map +1 -1
  7. package/dist/bugsnag.cjs +47 -9
  8. package/dist/bugsnag.cjs.map +1 -1
  9. package/dist/bugsnag.d.cts +1 -1
  10. package/dist/bugsnag.d.ts +1 -1
  11. package/dist/bugsnag.js +47 -9
  12. package/dist/bugsnag.js.map +1 -1
  13. package/dist/{client-DAJH7YiG.d.ts → client-C8j7B2Bo.d.ts} +31 -19
  14. package/dist/{client-z1LdR_m2.d.cts → client-CHjrNHH-.d.cts} +31 -19
  15. package/dist/d1.cjs.map +1 -1
  16. package/dist/d1.d.cts +2 -2
  17. package/dist/d1.d.ts +2 -2
  18. package/dist/d1.js.map +1 -1
  19. package/dist/datadog.cjs +47 -9
  20. package/dist/datadog.cjs.map +1 -1
  21. package/dist/datadog.d.cts +1 -1
  22. package/dist/datadog.d.ts +1 -1
  23. package/dist/datadog.js +47 -9
  24. package/dist/datadog.js.map +1 -1
  25. package/dist/discord.cjs +55 -10
  26. package/dist/discord.cjs.map +1 -1
  27. package/dist/discord.d.cts +1 -1
  28. package/dist/discord.d.ts +1 -1
  29. package/dist/discord.js +55 -10
  30. package/dist/discord.js.map +1 -1
  31. package/dist/{email-CCUzlLe9.d.cts → email-D7SfsU0F.d.ts} +2 -2
  32. package/dist/{email-CFtDqlvZ.d.ts → email-zSazJClH.d.cts} +2 -2
  33. package/dist/honeybadger.cjs +47 -9
  34. package/dist/honeybadger.cjs.map +1 -1
  35. package/dist/honeybadger.d.cts +1 -1
  36. package/dist/honeybadger.d.ts +1 -1
  37. package/dist/honeybadger.js +47 -9
  38. package/dist/honeybadger.js.map +1 -1
  39. package/dist/index.cjs +281 -100
  40. package/dist/index.cjs.map +1 -1
  41. package/dist/index.d.cts +11 -7
  42. package/dist/index.d.ts +11 -7
  43. package/dist/index.js +281 -100
  44. package/dist/index.js.map +1 -1
  45. package/dist/mailgun.cjs +47 -9
  46. package/dist/mailgun.cjs.map +1 -1
  47. package/dist/mailgun.d.cts +2 -2
  48. package/dist/mailgun.d.ts +2 -2
  49. package/dist/mailgun.js +47 -9
  50. package/dist/mailgun.js.map +1 -1
  51. package/dist/newrelic.cjs +47 -9
  52. package/dist/newrelic.cjs.map +1 -1
  53. package/dist/newrelic.d.cts +1 -1
  54. package/dist/newrelic.d.ts +1 -1
  55. package/dist/newrelic.js +47 -9
  56. package/dist/newrelic.js.map +1 -1
  57. package/dist/pg-cron.cjs.map +1 -1
  58. package/dist/pg-cron.d.cts +2 -2
  59. package/dist/pg-cron.d.ts +2 -2
  60. package/dist/pg-cron.js.map +1 -1
  61. package/dist/postgres.cjs.map +1 -1
  62. package/dist/postgres.d.cts +2 -2
  63. package/dist/postgres.d.ts +2 -2
  64. package/dist/postgres.js.map +1 -1
  65. package/dist/postmark.cjs +47 -9
  66. package/dist/postmark.cjs.map +1 -1
  67. package/dist/postmark.d.cts +2 -2
  68. package/dist/postmark.d.ts +2 -2
  69. package/dist/postmark.js +47 -9
  70. package/dist/postmark.js.map +1 -1
  71. package/dist/resend.cjs +47 -9
  72. package/dist/resend.cjs.map +1 -1
  73. package/dist/resend.d.cts +2 -2
  74. package/dist/resend.d.ts +2 -2
  75. package/dist/resend.js +47 -9
  76. package/dist/resend.js.map +1 -1
  77. package/dist/rollbar.cjs +47 -9
  78. package/dist/rollbar.cjs.map +1 -1
  79. package/dist/rollbar.d.cts +1 -1
  80. package/dist/rollbar.d.ts +1 -1
  81. package/dist/rollbar.js +47 -9
  82. package/dist/rollbar.js.map +1 -1
  83. package/dist/sendgrid.cjs +47 -9
  84. package/dist/sendgrid.cjs.map +1 -1
  85. package/dist/sendgrid.d.cts +2 -2
  86. package/dist/sendgrid.d.ts +2 -2
  87. package/dist/sendgrid.js +47 -9
  88. package/dist/sendgrid.js.map +1 -1
  89. package/dist/sentry.cjs +47 -9
  90. package/dist/sentry.cjs.map +1 -1
  91. package/dist/sentry.d.cts +1 -1
  92. package/dist/sentry.d.ts +1 -1
  93. package/dist/sentry.js +47 -9
  94. package/dist/sentry.js.map +1 -1
  95. package/dist/ses.cjs +47 -9
  96. package/dist/ses.cjs.map +1 -1
  97. package/dist/ses.d.cts +2 -2
  98. package/dist/ses.d.ts +2 -2
  99. package/dist/ses.js +47 -9
  100. package/dist/ses.js.map +1 -1
  101. package/dist/slack.cjs +59 -11
  102. package/dist/slack.cjs.map +1 -1
  103. package/dist/slack.d.cts +6 -2
  104. package/dist/slack.d.ts +6 -2
  105. package/dist/slack.js +59 -11
  106. package/dist/slack.js.map +1 -1
  107. package/dist/sqlite.cjs.map +1 -1
  108. package/dist/sqlite.d.cts +2 -2
  109. package/dist/sqlite.d.ts +2 -2
  110. package/dist/sqlite.js.map +1 -1
  111. package/dist/twilio.cjs +47 -9
  112. package/dist/twilio.cjs.map +1 -1
  113. package/dist/twilio.d.cts +1 -1
  114. package/dist/twilio.d.ts +1 -1
  115. package/dist/twilio.js +47 -9
  116. package/dist/twilio.js.map +1 -1
  117. package/dist/{types-D6t0Z846.d.cts → types-B8IFVK5k.d.cts} +23 -7
  118. package/dist/{types-D6t0Z846.d.ts → types-B8IFVK5k.d.ts} +23 -7
  119. package/dist/webhook.cjs +39 -9
  120. package/dist/webhook.cjs.map +1 -1
  121. package/dist/webhook.d.cts +24 -6
  122. package/dist/webhook.d.ts +24 -6
  123. package/dist/webhook.js +39 -9
  124. package/dist/webhook.js.map +1 -1
  125. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -174,6 +174,13 @@ function countFrom(from) {
174
174
  return from >= FIRST_DATE_MS ? from : FIRST_DATE_MS - 1;
175
175
  }
176
176
  function fireAfter(parsed, from) {
177
+ const found = croneFireAfter(parsed, from);
178
+ const start = countFrom(from);
179
+ if (start === null) return found;
180
+ const moved = gapFireAfter(parsed, start);
181
+ return moved !== null && (found === null || moved < found) ? moved : found;
182
+ }
183
+ function croneFireAfter(parsed, from) {
177
184
  const cron = crons.get(parsed);
178
185
  if (!cron) throw new Error(`schedule "${parsed.source}" was not made by parseSchedule`);
179
186
  if (never.has(parsed)) return null;
@@ -189,6 +196,43 @@ function fireAfter(parsed, from) {
189
196
  }
190
197
  return null;
191
198
  }
199
+ function springGap(timezone, at) {
200
+ const jump = springJump(timezone, at);
201
+ return jump && at < jump.jumpAt + jump.gapMs ? jump : null;
202
+ }
203
+ var JUMP_LOOKBACK_MS = 3 * 36e5;
204
+ function springJump(timezone, at) {
205
+ if (at - JUMP_LOOKBACK_MS < FIRST_DATE_MS || at >= LAST_DATE_MS) return null;
206
+ const after2 = utcOffset(at, timezone);
207
+ const before = utcOffset(at - JUMP_LOOKBACK_MS, timezone);
208
+ if (after2 <= before) return null;
209
+ let lo = at - JUMP_LOOKBACK_MS;
210
+ let hi = at;
211
+ while (hi - lo > 6e4) {
212
+ const mid = lo + Math.floor((hi - lo) / 2);
213
+ if (utcOffset(mid, timezone) === after2) hi = mid;
214
+ else lo = mid;
215
+ }
216
+ return { jumpAt: Math.floor(hi / 6e4) * 6e4, gapMs: after2 - before, beforeMs: before };
217
+ }
218
+ var wallCrons = /* @__PURE__ */ new WeakMap();
219
+ function wallCron(parsed) {
220
+ let wall = wallCrons.get(parsed);
221
+ if (!wall) {
222
+ wall = new croner.Cron(parsed.source, { timezone: "UTC" });
223
+ wallCrons.set(parsed, wall);
224
+ }
225
+ return wall;
226
+ }
227
+ function gapFireAfter(parsed, start) {
228
+ if (never.has(parsed)) return null;
229
+ const gap = springGap(parsed.timezone, start);
230
+ if (!gap) return null;
231
+ const wall = wallCron(parsed);
232
+ const [slot] = runsAfter(wall, 1, start + gap.beforeMs);
233
+ if (slot === void 0 || slot >= gap.jumpAt + gap.beforeMs + gap.gapMs) return null;
234
+ return slot - gap.beforeMs;
235
+ }
192
236
  function firesBetween(parsed, from, to, limit) {
193
237
  const cron = crons.get(parsed);
194
238
  if (!cron) throw new Error(`schedule "${parsed.source}" was not made by parseSchedule`);
@@ -199,15 +243,34 @@ function firesBetween(parsed, from, to, limit) {
199
243
  let probe = start;
200
244
  let last = start;
201
245
  for (let guard = 0; guard < 1e3; guard++) {
246
+ if (springGap(parsed.timezone, last)) {
247
+ const t = fireAfter(parsed, last);
248
+ if (t === null || t > to) return out;
249
+ out.push(t);
250
+ last = probe = t;
251
+ if (out.length > limit) return null;
252
+ continue;
253
+ }
202
254
  const batch = runsAfter(cron, Math.min(limit + 1 - out.length, 24), probe);
203
255
  if (batch.length === 0) return out;
256
+ let stepped = false;
204
257
  for (const t of batch) {
205
258
  if (t <= last) continue;
259
+ if (springGap(parsed.timezone, t)) {
260
+ const next = fireAfter(parsed, last);
261
+ if (next === null || next > to) return out;
262
+ out.push(next);
263
+ last = probe = next;
264
+ if (out.length > limit) return null;
265
+ stepped = true;
266
+ break;
267
+ }
206
268
  if (t > to) return out;
207
269
  out.push(t);
208
270
  last = t;
209
271
  if (out.length > limit) return null;
210
272
  }
273
+ if (stepped) continue;
211
274
  const end = batch[batch.length - 1];
212
275
  probe = end > probe ? end : probe + 36e5;
213
276
  }
@@ -220,10 +283,14 @@ function nextFire(parsed, from, lastRunAt) {
220
283
  }
221
284
  return fireAfter(parsed, from);
222
285
  }
223
- function expectation(parsed, lastRunAt, registeredAt, graceMs2) {
286
+ function intervalDue(everyMs, startedAt, finishedAt) {
287
+ const due = startedAt + everyMs;
288
+ return finishedAt !== null && finishedAt > due ? finishedAt + everyMs : due;
289
+ }
290
+ function expectation(parsed, lastRunAt, registeredAt, graceMs2, lastFinishedAt = null) {
224
291
  let dueAt;
225
292
  if (parsed.kind === "interval") {
226
- dueAt = (lastRunAt ?? registeredAt) + parsed.everyMs;
293
+ dueAt = lastRunAt === null ? registeredAt + parsed.everyMs : intervalDue(parsed.everyMs, lastRunAt, lastFinishedAt);
227
294
  } else if (lastRunAt === null) {
228
295
  dueAt = fireAfter(parsed, registeredAt - 1);
229
296
  } else {
@@ -232,33 +299,25 @@ function expectation(parsed, lastRunAt, registeredAt, graceMs2) {
232
299
  return dueAt === null ? null : { dueAt, deadline: dueAt + graceMs2 };
233
300
  }
234
301
  function dueAfterRun(parsed, startedAt) {
235
- const next = fireAfter(parsed, startedAt);
236
- if (next === null) return null;
237
- const following = fireAfter(parsed, next);
238
- const covers = runCovers(startedAt, next, following) || inSpringForwardGap(parsed, startedAt, next);
239
- return covers ? following : next;
302
+ let next = fireAfter(parsed, startedAt);
303
+ for (let guard = 0; next !== null && guard < 1e5; guard++) {
304
+ const following = fireAfter(parsed, next);
305
+ const covers = runCovers(startedAt, next, following) || inSpringForwardGap(parsed, startedAt, next);
306
+ if (!covers) return next;
307
+ next = following;
308
+ }
309
+ return next;
240
310
  }
241
311
  function runCovers(startedAt, dueAt, followingAt = null) {
242
312
  const slack = followingAt === null ? EARLY_SLACK_MS : Math.min(EARLY_SLACK_MS, Math.floor((followingAt - dueAt) / 2));
243
313
  return startedAt >= dueAt - slack;
244
314
  }
245
315
  function inSpringForwardGap(parsed, startedAt, fireAt) {
246
- const LOOKBACK = 3 * 36e5;
247
- if (fireAt - LOOKBACK < FIRST_DATE_MS) return false;
248
- const after2 = utcOffset(fireAt, parsed.timezone);
249
- const before = utcOffset(fireAt - LOOKBACK, parsed.timezone);
250
- const gap = after2 - before;
251
- if (gap <= 0) return false;
252
- let lo = fireAt - LOOKBACK;
253
- let hi = fireAt;
254
- while (hi - lo > 6e4) {
255
- const mid = lo + Math.floor((hi - lo) / 2);
256
- if (utcOffset(mid, parsed.timezone) === after2) hi = mid;
257
- else lo = mid;
258
- }
259
- const jumpAt = Math.floor(hi / 6e4) * 6e4;
260
- if (fireAt - jumpAt >= gap || startedAt < jumpAt - EARLY_SLACK_MS || startedAt >= fireAt) return false;
261
- return fireAfter(parsed, jumpAt - 1) === fireAt;
316
+ const gap = springGap(parsed.timezone, fireAt);
317
+ if (!gap || startedAt < gap.jumpAt - EARLY_SLACK_MS || startedAt >= fireAt) return false;
318
+ const wall = wallCron(parsed);
319
+ const fires = (at) => runsAfter(wall, 1, at - 1)[0] === at;
320
+ return fires(fireAt + gap.beforeMs) && !fires(fireAt + gap.beforeMs + gap.gapMs);
262
321
  }
263
322
  var formatters = /* @__PURE__ */ new Map();
264
323
  function utcOffset(at, timezone) {
@@ -355,15 +414,75 @@ var SEND_LEASE_MS = 5 * 6e4;
355
414
  function alertKey(alert) {
356
415
  return `${alert.type}|${alert.at}|${alert.run?.id ?? ""}`;
357
416
  }
417
+ var HELD_TEXT_CAP = 2048;
418
+ var HELD_TEXT_SLACK = 64;
419
+ function heldRun(run) {
420
+ const error = typeof run.error === "string" && run.error.length > HELD_TEXT_CAP + HELD_TEXT_SLACK ? keepHead(run.error) : run.error;
421
+ const output = typeof run.output === "string" && run.output.length > HELD_TEXT_CAP + HELD_TEXT_SLACK ? keepTail(run.output) : run.output;
422
+ return error === run.error && output === run.output ? run : { ...run, error, output };
423
+ }
424
+ var HELD_MESSAGE_CAP = 4096;
425
+ function keepHead(text, cap = HELD_TEXT_CAP) {
426
+ let end = cap;
427
+ if (isHighSurrogate(text.charCodeAt(end - 1))) end -= 1;
428
+ return `${text.slice(0, end)}
429
+ [${text.length - end} more characters trimmed]`;
430
+ }
431
+ function keepTail(text) {
432
+ let start = text.length - HELD_TEXT_CAP;
433
+ if (isLowSurrogate(text.charCodeAt(start)) && isHighSurrogate(text.charCodeAt(start - 1))) start += 1;
434
+ return `[${start} earlier characters trimmed]
435
+ ${text.slice(start)}`;
436
+ }
437
+ function isHighSurrogate(code) {
438
+ return code >= 55296 && code <= 56319;
439
+ }
440
+ function isLowSurrogate(code) {
441
+ return code >= 56320 && code <= 57343;
442
+ }
443
+ function heldAlert(alert) {
444
+ const run = alert.run;
445
+ const trimmedRun = isObject(run) ? heldRun(run) : run;
446
+ const message = alert.message;
447
+ const trimmedMessage = typeof message === "string" && message.length > HELD_MESSAGE_CAP + HELD_TEXT_SLACK ? keepHead(message, HELD_MESSAGE_CAP) : message;
448
+ if (trimmedRun === run && trimmedMessage === message) return alert;
449
+ return { ...alert, run: trimmedRun, message: trimmedMessage };
450
+ }
451
+ function channelKeys(names) {
452
+ const seen = /* @__PURE__ */ new Map();
453
+ const taken = /* @__PURE__ */ new Set();
454
+ return names.map((name) => {
455
+ let n = (seen.get(name) ?? 0) + 1;
456
+ seen.set(name, n);
457
+ let key = n === 1 ? name : `${name}#${n}`;
458
+ while (taken.has(key)) key = `${name}#${++n}`;
459
+ taken.add(key);
460
+ return key;
461
+ });
462
+ }
463
+ function owedChannels(alert, configured) {
464
+ const owed = alert.owed;
465
+ if (!Array.isArray(owed)) return [...configured];
466
+ return configured.filter((key) => owed.includes(key));
467
+ }
468
+ function afterSend(alert, tried, failed) {
469
+ const owed = tried.filter((key) => failed.includes(key));
470
+ return owed.length === 0 ? null : { ...alert, owed };
471
+ }
472
+ function withoutOwed(alert) {
473
+ if (!("owed" in alert)) return alert;
474
+ const { owed: _owed, ...rest } = alert;
475
+ return rest;
476
+ }
358
477
  function newest(list, max) {
359
478
  return { kept: list.slice(-max), dropped: Math.max(0, list.length - max) };
360
479
  }
361
480
  function queueUndelivered(state, alerts) {
362
481
  const next = cloneState(state);
363
- const byKey = new Map(alerts.map((alert) => [alertKey(alert), alert]));
482
+ const byKey = new Map(alerts.map((alert) => [alertKey(alert), heldAlert(alert)]));
364
483
  const queue = next.undelivered.map((alert) => byKey.get(alertKey(alert)) ?? alert);
365
484
  const known = new Set(queue.map(alertKey));
366
- queue.push(...alerts.filter((alert) => !known.has(alertKey(alert))));
485
+ queue.push(...[...byKey.values()].filter((alert) => !known.has(alertKey(alert))));
367
486
  const { kept, dropped } = newest(queue, MAX_UNDELIVERED);
368
487
  next.undelivered = kept;
369
488
  return { state: next, dropped };
@@ -372,7 +491,7 @@ function holdAlerts(state, alerts, until, deferred) {
372
491
  if (alerts.length === 0) return { state, dropped: 0 };
373
492
  if (deferred) return queueUndelivered(state, alerts);
374
493
  const next = cloneState(state);
375
- const { kept, dropped } = newest([...next.sending ?? [], ...alerts.map((alert) => ({ until, alert }))], MAX_UNDELIVERED);
494
+ const { kept, dropped } = newest([...next.sending ?? [], ...alerts.map((alert) => ({ until, alert: heldAlert(alert) }))], MAX_UNDELIVERED);
376
495
  next.sending = kept;
377
496
  return { state: next, dropped };
378
497
  }
@@ -386,7 +505,7 @@ function releaseSending(state, now) {
386
505
  lapsed.filter((entry) => typeof entry?.alert === "object" && entry.alert !== null).map((entry) => entry.alert)
387
506
  );
388
507
  }
389
- function recordSent(state, delivered, failed, stale, now) {
508
+ function recordSent(state, delivered, failed, stale, now, reached = delivered.length > 0) {
390
509
  const next = cloneState(state);
391
510
  const done = new Set([...delivered, ...stale].map(alertKey));
392
511
  next.undelivered = next.undelivered.filter((alert) => !done.has(alertKey(alert)));
@@ -394,7 +513,7 @@ function recordSent(state, delivered, failed, stale, now) {
394
513
  const held = (next.sending ?? []).filter((entry) => !(typeof entry?.alert === "object" && entry.alert !== null && sent.has(alertKey(entry.alert))));
395
514
  if (held.length > 0) next.sending = held;
396
515
  else delete next.sending;
397
- if (delivered.length > 0) next.lastAlertAt = now;
516
+ if (reached) next.lastAlertAt = now;
398
517
  return queueUndelivered(next, failed);
399
518
  }
400
519
  function claimUndelivered(state, now, until) {
@@ -502,9 +621,21 @@ function formatNumber(n) {
502
621
  if (Number.isInteger(n)) return n.toLocaleString("en-US");
503
622
  return n.toLocaleString("en-US", { maximumFractionDigits: 4 });
504
623
  }
505
- function onRunStart(state) {
624
+ function finishedAtOf(run) {
625
+ return run.status !== "running" && typeof run.finishedAt === "number" ? run.finishedAt : null;
626
+ }
627
+ function catchesUp(def, run, now) {
628
+ if (!def.schedule) return true;
629
+ try {
630
+ const exp = expectation(parseSchedule(def.schedule, def.timezone), run.startedAt, run.startedAt, graceMs(def), finishedAtOf(run));
631
+ return exp === null || now <= exp.deadline;
632
+ } catch {
633
+ return true;
634
+ }
635
+ }
636
+ function onRunStart(def, run, state, now) {
506
637
  const next = cloneState(state);
507
- closeCondition(next, "missed");
638
+ if (catchesUp(def, run, now)) closeCondition(next, "missed");
508
639
  closeCondition(next, "stuck");
509
640
  return next;
510
641
  }
@@ -513,7 +644,7 @@ function onRunFinish(def, run, state, history, now) {
513
644
  const alerts = [];
514
645
  if (run.status === "ok") {
515
646
  next.consecutiveFailures = 0;
516
- closeCondition(next, "missed");
647
+ if (catchesUp(def, run, now)) closeCondition(next, "missed");
517
648
  closeCondition(next, "stuck");
518
649
  closeCondition(next, "failed");
519
650
  const slow = slowThreshold(def, history);
@@ -550,7 +681,7 @@ function onRunFinish(def, run, state, history, now) {
550
681
  return { state: next, alerts };
551
682
  }
552
683
  next.consecutiveFailures = Math.min(next.consecutiveFailures + 1, MAX_DURATION_MS);
553
- closeCondition(next, "missed");
684
+ if (catchesUp(def, run, now)) closeCondition(next, "missed");
554
685
  const threshold = Math.max(1, def.failuresBeforeAlert ?? 1);
555
686
  const condition = run.status === "timeout" ? "stuck" : "failed";
556
687
  if (next.consecutiveFailures >= threshold) {
@@ -575,8 +706,8 @@ function onCheck(def, stored, lastRun, state, now) {
575
706
  const parsed = parseSchedule(def.schedule, def.timezone);
576
707
  const grace = graceMs(def);
577
708
  const lastRunAt = lastRun?.startedAt ?? null;
578
- const exp = expectation(parsed, lastRunAt, stored.createdAt, grace);
579
- const nextExpectedAt = parsed.kind === "interval" ? nextFire(parsed, stored.createdAt, lastRunAt) : nextFire(parsed, now, null);
709
+ const exp = expectation(parsed, lastRunAt, stored.createdAt, grace, lastRun ? finishedAtOf(lastRun) : null);
710
+ const nextExpectedAt = parsed.kind === "interval" ? exp.dueAt : nextFire(parsed, now, null);
580
711
  if (!exp) return { state: next, alerts, nextExpectedAt, dueAt: null };
581
712
  if (parsed.kind === "interval" && lastRun?.status === "running") {
582
713
  return { state: next, alerts, nextExpectedAt, dueAt: exp.dueAt };
@@ -862,11 +993,15 @@ function stringify(part) {
862
993
  }
863
994
  }
864
995
  function createRecorder(run) {
996
+ const window = OUTPUT_CAP + REDACT_EDGE + 2;
997
+ const copy = (text) => JSON.parse(JSON.stringify(text));
865
998
  const lines = [];
866
999
  let size = 0;
867
1000
  const head = [];
868
1001
  let headSize = 0;
869
- let dropped = false;
1002
+ let whole = "";
1003
+ let tail2 = "";
1004
+ let logged = false;
870
1005
  const metrics = {};
871
1006
  const controller = new AbortController();
872
1007
  const context = {
@@ -875,16 +1010,23 @@ function createRecorder(run) {
875
1010
  startedAt: run.startedAt,
876
1011
  signal: controller.signal,
877
1012
  log(...parts) {
878
- const line = parts.map(stringify).join(" ");
1013
+ let line = parts.map(stringify).join(" ");
1014
+ const separator = logged ? "\n" : "";
1015
+ logged = true;
1016
+ if (whole !== null) whole = whole.length + separator.length + line.length <= 2 * OUTPUT_CAP ? whole + separator + line : null;
1017
+ tail2 = copy((tail2 + separator + copy(line.slice(-OUTPUT_CAP))).slice(-OUTPUT_CAP));
879
1018
  if (headSize < OUTPUT_CAP) {
880
- head.push(line);
1019
+ head.push(copy(line.slice(0, OUTPUT_CAP - headSize)));
881
1020
  headSize += line.length + 1;
882
1021
  }
1022
+ line = stripNul(line);
1023
+ if (line.length >= window) {
1024
+ line = copy(line.slice(-68609));
1025
+ }
883
1026
  lines.push(line);
884
1027
  size += line.length + 1;
885
- while (size > 64 * 1024 && lines.length > 1) {
1028
+ while (size > window && lines.length > 1) {
886
1029
  size -= lines.shift().length + 1;
887
- dropped = true;
888
1030
  }
889
1031
  },
890
1032
  metric(name, value) {
@@ -901,10 +1043,8 @@ function createRecorder(run) {
901
1043
  context,
902
1044
  output: () => lines.length === 0 ? null : lines.join("\n"),
903
1045
  expectText: () => {
904
- if (lines.length === 0) return null;
905
- const all = lines.join("\n");
906
- if (!dropped && all.length <= 2 * OUTPUT_CAP) return all;
907
- return head.join("\n").slice(0, OUTPUT_CAP) + "\n" + all.slice(-OUTPUT_CAP);
1046
+ if (!logged) return null;
1047
+ return whole ?? head.join("\n").slice(0, OUTPUT_CAP) + "\n" + tail2;
908
1048
  },
909
1049
  metrics: () => ({ ...metrics }),
910
1050
  abort: () => controller.abort()
@@ -1078,7 +1218,7 @@ function staticAsset(path, base) {
1078
1218
  }
1079
1219
 
1080
1220
  // src/version.ts
1081
- var VERSION = "0.13.1";
1221
+ var VERSION = "0.14.1";
1082
1222
  var API_VERSION = 1;
1083
1223
 
1084
1224
  // src/routes/timeline.ts
@@ -1131,13 +1271,13 @@ function dueTimes(job, parsed, runs, from, to) {
1131
1271
  if (parsed.kind === "interval") {
1132
1272
  const every = parsed.everyMs;
1133
1273
  if ((to - from) / every > MAX_TICKS) return { times: [], dense: true };
1134
- const starts = runs.map((r) => r.startedAt).sort((a, b) => a - b);
1274
+ const ordered = [...runs].sort((a, b) => a.startedAt - b.startedAt);
1275
+ const dues = ordered.map((r) => intervalDue(every, r.startedAt, finishedAtOf(r)));
1135
1276
  const times2 = /* @__PURE__ */ new Set();
1136
- for (const start of starts) {
1137
- const t2 = start + every;
1277
+ for (const t2 of dues) {
1138
1278
  if (t2 >= from && t2 <= to) times2.add(t2);
1139
1279
  }
1140
- let t = starts.length ? starts[starts.length - 1] + every : job.nextExpectedAt;
1280
+ let t = dues.length ? dues[dues.length - 1] : job.nextExpectedAt;
1141
1281
  if (t !== null) {
1142
1282
  if (t < from) t += Math.ceil((from - t) / every) * every;
1143
1283
  for (; t <= to; t += every) times2.add(t);
@@ -1150,8 +1290,8 @@ function dueTimes(job, parsed, runs, from, to) {
1150
1290
  function missedAt(job, parsed, times, now) {
1151
1291
  if (!parsed || !job.open.includes("missed")) return null;
1152
1292
  const grace = safely(() => graceMs(job.definition), 0);
1153
- const last = job.lastRun?.startedAt ?? null;
1154
- if (last !== null) return safely(() => expectation(parsed, last, last, grace)?.dueAt ?? null, null);
1293
+ const lastRun = job.lastRun;
1294
+ if (lastRun) return safely(() => expectation(parsed, lastRun.startedAt, lastRun.startedAt, grace, finishedAtOf(lastRun))?.dueAt ?? null, null);
1155
1295
  const past = times.filter((t) => t + grace < now);
1156
1296
  return past.length ? past[past.length - 1] : null;
1157
1297
  }
@@ -1860,13 +2000,15 @@ function signInReturn(referer, publicOrigin, base) {
1860
2000
  return url.searchParams.has("token") ? `${base}/` : referer;
1861
2001
  }
1862
2002
  function stripBase(pathname, base) {
1863
- let path = pathname.startsWith(base) ? pathname.slice(base.length) : pathname;
2003
+ if (base !== "" && pathname !== base && !pathname.startsWith(`${base}/`)) return null;
2004
+ let path = pathname.slice(base.length);
1864
2005
  if (path === "") path = "/";
1865
2006
  if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
1866
2007
  return path;
1867
2008
  }
1868
2009
  var MAX_REQUEST_BODY = 1 << 20;
1869
2010
  var TOO_LARGE = /* @__PURE__ */ Symbol("too large");
2011
+ var BAD_BODY = /* @__PURE__ */ Symbol("malformed body");
1870
2012
  async function readLimited(request) {
1871
2013
  const length = Number(request.headers.get("content-length"));
1872
2014
  if (Number.isFinite(length) && length > MAX_REQUEST_BODY) return TOO_LARGE;
@@ -1913,14 +2055,14 @@ async function readBody(request) {
1913
2055
  try {
1914
2056
  if (json2) {
1915
2057
  const data = await copy.json();
1916
- return typeof data === "object" && data !== null ? Object.fromEntries(Object.entries(data).map(([k, v]) => [k, String(v)])) : {};
2058
+ return typeof data === "object" && data !== null && !Array.isArray(data) ? Object.fromEntries(Object.entries(data).map(([k, v]) => [k, String(v)])) : BAD_BODY;
1917
2059
  }
1918
2060
  const fields = await copy.formData();
1919
2061
  const out = {};
1920
2062
  for (const [k, v] of fields.entries()) out[k] = String(v);
1921
2063
  return out;
1922
2064
  } catch {
1923
- return {};
2065
+ return BAD_BODY;
1924
2066
  }
1925
2067
  }
1926
2068
  function crossSite(request, publicOrigin) {
@@ -1986,6 +2128,7 @@ function buildRoutes(cw, options = {}) {
1986
2128
  const signInPage = () => generated ? html(messagePage("Sign in", "CRONWATCH_TOKEN is not set, so this development server made a token. The sign-in link is in the server log: open it once, or enter the token from it below, and this browser stays signed in.", base, true), 401) : html(messagePage("Sign in", "Enter your CRONWATCH_TOKEN and this browser stays signed in.", base, true), 401);
1987
2129
  if (token && method === "POST" && path === "/signin") {
1988
2130
  const body = await readBody(request);
2131
+ if (body === BAD_BODY) return html(messagePage("Bad request", "Malformed request body", base), 400);
1989
2132
  const sent = body === TOO_LARGE ? void 0 : body.token;
1990
2133
  if (sent === void 0 || !constantTimeEqual(sent, token)) return signInPage();
1991
2134
  return redirect(signInReturn(request.headers.get("referer"), publicOrigin, base), { "set-cookie": await signInCookie() });
@@ -2043,6 +2186,7 @@ function buildRoutes(cw, options = {}) {
2043
2186
  if (parts[2] === "silence") {
2044
2187
  const body = await readBody(request);
2045
2188
  if (body === TOO_LARGE) return html(messagePage("Not silenced", "The request was too large.", base), 413);
2189
+ if (body === BAD_BODY) return html(messagePage("Not silenced", "Malformed request body", base), 400);
2046
2190
  let duration;
2047
2191
  try {
2048
2192
  duration = silenceDuration(body.for);
@@ -2080,6 +2224,7 @@ function buildRoutes(cw, options = {}) {
2080
2224
  if (rest[2] === "silence") {
2081
2225
  const body = await readBody(request);
2082
2226
  if (body === TOO_LARGE) return api({ ok: false, error: "Request body too large" }, 413);
2227
+ if (body === BAD_BODY) return api({ ok: false, error: "Malformed request body" }, 400);
2083
2228
  let duration;
2084
2229
  try {
2085
2230
  duration = silenceDuration(body.for ?? url.searchParams.get("for"));
@@ -2116,6 +2261,7 @@ function buildRoutes(cw, options = {}) {
2116
2261
  try {
2117
2262
  const url = new URL(request.url);
2118
2263
  const path = stripBase(url.pathname, base);
2264
+ if (path === null) return html(messagePage("Not found", url.pathname, base), 404);
2119
2265
  wantsHtml = !path.startsWith("/api");
2120
2266
  return await serve(request, url, path, wantsHtml);
2121
2267
  } catch (e) {
@@ -2470,7 +2616,7 @@ var Cronwatch = class {
2470
2616
  warnNoSecret() {
2471
2617
  if (this.warnedNoSecret) return;
2472
2618
  this.warnedNoSecret = true;
2473
- this.onError(new Error("handler() refused a request because no CRON_SECRET is set; pass secret: null to allow unauthenticated requests"), "handler");
2619
+ this.report(new Error("handler() refused a request because no CRON_SECRET is set; pass secret: null to allow unauthenticated requests"), "handler");
2474
2620
  }
2475
2621
  /** onError, for places that must carry on even when onError itself throws. */
2476
2622
  report(error, where) {
@@ -2483,6 +2629,13 @@ var Cronwatch = class {
2483
2629
  if (!this.ready) {
2484
2630
  this.ready = (async () => {
2485
2631
  if (this.store.init) await this.store.init();
2632
+ const missing = ["compareAndSetState", "updateRunIf"].filter((method) => typeof this.store[method] !== "function");
2633
+ if (missing.length > 0) {
2634
+ this.report(
2635
+ new Error(`the store has no ${missing.join(" or ")}, so two processes sharing it can overwrite each other's state or both evaluate the same run; it is safe only while one process at a time uses it`),
2636
+ "store"
2637
+ );
2638
+ }
2486
2639
  if (this.usingDefaultStore && isProduction()) {
2487
2640
  console.warn("[cronwatch] using the in-memory store: runs and state are lost on restart. Pass a store from @cronwatch/sdk/sqlite or @cronwatch/sdk/postgres.");
2488
2641
  }
@@ -2630,10 +2783,11 @@ var Cronwatch = class {
2630
2783
  await this.store.insertRun({ ...run });
2631
2784
  recorded = true;
2632
2785
  } catch (e) {
2633
- this.onError(e, `recording ${name}`);
2786
+ this.report(e, `recording ${name}`);
2634
2787
  }
2635
- const started = recorded ? this.updateState(name, (before) => ({ state: holdRun(onRunStart(before), run.id), result: void 0 })).then(() => {
2636
- }, (e) => this.onError(e, `starting ${name}`)) : Promise.resolve();
2788
+ const begun = { ...run };
2789
+ const started = recorded ? this.updateState(name, (before) => ({ state: holdRun(onRunStart(definition, begun, before, this.now()), run.id), result: void 0 })).then(() => {
2790
+ }, (e) => this.report(e, `starting ${name}`)) : Promise.resolve();
2637
2791
  const recorder = createRecorder(run);
2638
2792
  const clearTimer = after(timeoutMs(definition), () => recorder.abort());
2639
2793
  let result;
@@ -2658,7 +2812,7 @@ var Cronwatch = class {
2658
2812
  const ignored = await this.recordFinish(definition, run, recorded, finishedAt);
2659
2813
  if (ignored) this.report(new Error(`run ${run.id} of ${name} ${ignored}; ignored`), `finishing ${name}`);
2660
2814
  } catch (e) {
2661
- this.onError(e, `recording ${name}`);
2815
+ this.report(e, `recording ${name}`);
2662
2816
  }
2663
2817
  return { run, result, error, threw };
2664
2818
  }
@@ -2706,9 +2860,10 @@ var Cronwatch = class {
2706
2860
  if (stored.job !== run.job) return `belongs to job "${stored.job}"`;
2707
2861
  }
2708
2862
  }
2863
+ const tracked = (await this.readState(run.job)).pendingRuns?.includes(run.id) ?? false;
2709
2864
  const claim = await this.claimFinish(run);
2710
2865
  if ("ignored" in claim) return claim.ignored;
2711
- if (!claim.lateAfterTimeout || run.status === "ok") await this.finishRun(toStored(definition), run, finishedAt);
2866
+ if (!claim.lateAfterTimeout || run.status === "ok") await this.finishRun(toStored(definition), run, finishedAt, tracked && !claim.lateAfterTimeout);
2712
2867
  return null;
2713
2868
  }
2714
2869
  /** A conditional write (Store.updateRunIf), or for a store without one, a read then a plain write. */
@@ -2761,7 +2916,7 @@ ${id}`;
2761
2916
  await this.ensureReady();
2762
2917
  stored = await this.store.getRun(id);
2763
2918
  } catch (e) {
2764
- this.onError(e, `recording ${name}`);
2919
+ this.report(e, `recording ${name}`);
2765
2920
  }
2766
2921
  if (stored) return this.existingHandle(definition, stored);
2767
2922
  }
@@ -2785,10 +2940,10 @@ ${id}`;
2785
2940
  } catch (e) {
2786
2941
  const stored = id === void 0 ? null : await this.store.getRun(id).catch(() => null);
2787
2942
  if (stored) return this.existingHandle(definition, stored);
2788
- this.onError(e, `recording ${name}`);
2943
+ this.report(e, `recording ${name}`);
2789
2944
  }
2790
2945
  if (recorded) {
2791
- await this.updateState(name, (before) => ({ state: holdRun(onRunStart(before), run.id), result: void 0 })).catch((e) => this.onError(e, `starting ${name}`));
2946
+ await this.updateState(name, (before) => ({ state: holdRun(onRunStart(definition, run, before, this.now()), run.id), result: void 0 })).catch((e) => this.report(e, `starting ${name}`));
2792
2947
  }
2793
2948
  return this.runHandle(definition, run.id, run, recorded, null);
2794
2949
  }
@@ -2800,7 +2955,7 @@ ${id}`;
2800
2955
  await this.ensureReady();
2801
2956
  stored = await this.store.getRun(runId);
2802
2957
  } catch (e) {
2803
- this.onError(e, `resuming ${definition.name}`);
2958
+ this.report(e, `resuming ${definition.name}`);
2804
2959
  return this.runHandle(definition, runId, null, true, null);
2805
2960
  }
2806
2961
  if (!stored) return this.runHandle(definition, runId, null, true, "was not found");
@@ -3001,9 +3156,9 @@ ${id}`;
3001
3156
  throw e;
3002
3157
  }
3003
3158
  if (!evaluate) return [];
3004
- await this.updateState(run.job, (before) => ({ state: holdRun(onRunStart(before), run.id), result: void 0 }));
3159
+ await this.updateState(run.job, (before) => ({ state: holdRun(onRunStart(definition, run, before, this.now()), run.id), result: void 0 }));
3005
3160
  if (run.status === "running") return [];
3006
- return this.finishRun(definition, run, this.now());
3161
+ return this.finishRun(definition, run, this.now(), true);
3007
3162
  }
3008
3163
  /** recordRun() for a run already stored. */
3009
3164
  async recordOver(definition, stored, run, evaluate) {
@@ -3012,6 +3167,7 @@ ${id}`;
3012
3167
  return [];
3013
3168
  }
3014
3169
  if (stored.status !== "running" && stored.status !== "timeout" || run.status === "running") return [];
3170
+ const tracked = (await this.readState(run.job)).pendingRuns?.includes(run.id) ?? false;
3015
3171
  const claim = await this.claimFinish(run);
3016
3172
  if ("ignored" in claim) {
3017
3173
  this.report(new Error(`run ${run.id} of ${run.job} ${claim.ignored}; ignored`), `recording ${run.job}`);
@@ -3022,24 +3178,25 @@ ${id}`;
3022
3178
  return [];
3023
3179
  }
3024
3180
  if (claim.lateAfterTimeout && run.status !== "ok") return [];
3025
- return this.finishRun(definition, run, this.now());
3181
+ return this.finishRun(definition, run, this.now(), tracked && !claim.lateAfterTimeout);
3026
3182
  }
3027
3183
  /**
3028
3184
  * Evaluate a finished run (ok, failed, or timed out by a check), already
3029
3185
  * written, against the job's state and send what that produces. The
3030
3186
  * alerts are written with that state (see outbox()). Never throws.
3031
3187
  */
3032
- async finishRun(definition, run, now) {
3188
+ async finishRun(definition, run, now, pendingOnly = false) {
3033
3189
  let held;
3034
3190
  try {
3035
3191
  let history = null;
3036
3192
  ({ result: held } = await this.updateState(run.job, async (previous) => {
3193
+ if (pendingOnly && !(previous.pendingRuns ?? []).includes(run.id)) return { state: previous, result: { alerts: [], dropped: 0 } };
3037
3194
  history ??= await this.history(run);
3038
3195
  const before = releaseRun(previous, run.id);
3039
3196
  return this.outbox(applySilence(before, onRunFinish(definition, run, before, history, now), now), definition, now);
3040
3197
  }));
3041
3198
  } catch (e) {
3042
- this.onError(e, `evaluating ${run.job}`);
3199
+ this.report(e, `evaluating ${run.job}`);
3043
3200
  return [];
3044
3201
  }
3045
3202
  this.reportDropped(run.job, held.dropped);
@@ -3078,7 +3235,7 @@ ${id}`;
3078
3235
  }
3079
3236
  /**
3080
3237
  * Look for missed and stuck runs across every job, send alerts, retry
3081
- * alerts no channel accepted, and prune old runs. Call it from an interval
3238
+ * alerts to the channels that refused them, and prune old runs. Call it from an interval
3082
3239
  * (start()), a cron hitting the mounted routes, or by hand. Concurrent
3083
3240
  * calls share one check.
3084
3241
  */
@@ -3178,7 +3335,7 @@ ${id}`;
3178
3335
  continue;
3179
3336
  }
3180
3337
  if (run.status === "running" || typeof run.finishedAt !== "number" || run.finishedAt > now - SEND_LEASE_MS) continue;
3181
- alerts.push(...await this.finishRun(stored.definition, run, now));
3338
+ alerts.push(...await this.finishRun(stored.definition, run, now, true));
3182
3339
  } catch (e) {
3183
3340
  this.onError(e, `evaluating run ${id} of ${stored.name}`);
3184
3341
  }
@@ -3298,7 +3455,7 @@ ${id}`;
3298
3455
  this.warnedDeferredStart = true;
3299
3456
  console.warn('[cronwatch] startChecking() was called with deliver: "check", so these checks send no alerts. Another process must run checks with deliver: "now" (the default) to send them.');
3300
3457
  }
3301
- const tick = () => this.check().catch((e) => this.onError(e, "check"));
3458
+ const tick = () => this.check().catch((e) => this.report(e, "check"));
3302
3459
  this.timer = setInterval(tick, ms);
3303
3460
  if (typeof this.timer === "object" && "unref" in this.timer) this.timer.unref();
3304
3461
  this.firstTick = setTimeout(() => {
@@ -3347,21 +3504,28 @@ ${id}`;
3347
3504
  if (alerts.length === 0 || this.deferDelivery) return alerts;
3348
3505
  const delivered = [];
3349
3506
  const failed = [];
3507
+ let reached = false;
3350
3508
  for (const alert of alerts) {
3351
3509
  if (this.triage && alert.type !== "recovered") await this.addTriage(alert, TRIAGE_TIMEOUT_MS);
3352
- (await this.deliver(alert) ? delivered : failed).push(alert);
3510
+ const sent = await this.deliver(alert);
3511
+ reached ||= sent.reached;
3512
+ if (sent.left) failed.push(sent.left);
3513
+ else delivered.push(alert);
3353
3514
  }
3354
- await this.recordDelivery(name, delivered, failed, [], now);
3515
+ await this.recordDelivery(name, delivered, failed, [], now, reached);
3355
3516
  return alerts;
3356
3517
  }
3357
3518
  /**
3358
- * Send the alerts that no channel accepted last time, once each, oldest
3359
- * first. They are claimed first (claimUndelivered): moved into `sending`
3360
- * with a lease, in one conditional write, so when several processes check
3361
- * at once only the one whose write lands sends them. An alert that no
3362
- * longer describes the job (staleAlert) is dropped instead. Retries across
3363
- * a check share RETRY_BUDGET_MS of wall-clock time; claimed alerts it has
3364
- * no time left for go back to the queue for the next check.
3519
+ * Send the alerts some channel refused last time, once each, oldest
3520
+ * first, to the channels that still owe them (HeldAlert.owed) and no
3521
+ * others, from the copy held in state, its run trimmed (heldRun). They
3522
+ * are claimed first (claimUndelivered): moved into `sending` with a lease,
3523
+ * in one conditional write, so when several processes check at once only
3524
+ * the one whose write lands sends them. An alert that no longer describes
3525
+ * the job (staleAlert) is dropped instead. Retries across a check share
3526
+ * RETRY_BUDGET_MS of wall-clock time; claimed alerts it has no time left
3527
+ * for go back to the queue for the next check. Returns the alerts some
3528
+ * channel took on this attempt.
3365
3529
  */
3366
3530
  async retryUndelivered(name, state, now, budget) {
3367
3531
  if ((state.undelivered ?? []).length === 0 || this.deferDelivery) return [];
@@ -3372,12 +3536,13 @@ ${id}`;
3372
3536
  return { state: claim.state, result: claim.claimed };
3373
3537
  }));
3374
3538
  } catch (e) {
3375
- this.onError(e, `claiming undelivered alerts for ${name}`);
3539
+ this.report(e, `claiming undelivered alerts for ${name}`);
3376
3540
  return [];
3377
3541
  }
3378
3542
  if (claimed.length === 0) return [];
3379
3543
  const delivered = [];
3380
3544
  const failed = [];
3545
+ const sent = [];
3381
3546
  for (const alert of claimed) {
3382
3547
  const left = RETRY_BUDGET_MS - budget.spentMs;
3383
3548
  if (left <= 0) {
@@ -3386,45 +3551,61 @@ ${id}`;
3386
3551
  }
3387
3552
  const started = Date.now();
3388
3553
  if (this.triage && alert.type !== "recovered" && alert.triage === void 0) await this.addTriage(alert, Math.min(TRIAGE_TIMEOUT_MS, left));
3389
- (await this.deliver(alert) ? delivered : failed).push(alert);
3554
+ const outcome = await this.deliver(alert);
3555
+ if (outcome.reached) sent.push(withoutOwed(alert));
3556
+ if (outcome.left) failed.push(outcome.left);
3557
+ else delivered.push(alert);
3390
3558
  budget.spentMs += Math.max(0, Date.now() - started);
3391
3559
  }
3392
- await this.recordDelivery(name, delivered, failed, [], now);
3393
- return delivered;
3560
+ await this.recordDelivery(name, delivered, failed, [], now, sent.length > 0);
3561
+ return sent;
3394
3562
  }
3395
3563
  /**
3396
3564
  * Mark delivered alerts done, drop stale ones, and keep failed ones for the
3397
3565
  * next check, taking them all out of `sending` (recordSent). A failed alert
3398
- * replaces its stored copy, so a triage made on this attempt is kept.
3399
- * lastAlertAt moves only on a delivery. When this write fails, alerts
3400
- * still in `sending` are retried once their lease runs out.
3566
+ * replaces its stored copy, so a triage made on this attempt and the
3567
+ * channels that still owe it are kept. lastAlertAt moves when `reached`,
3568
+ * some channel accepted an alert on this attempt. When this write fails,
3569
+ * alerts still in `sending` are retried once their lease runs out.
3401
3570
  */
3402
- async recordDelivery(name, delivered, failed, dropped, now) {
3571
+ async recordDelivery(name, delivered, failed, dropped, now, reached) {
3403
3572
  try {
3404
3573
  const { result: trimmed } = await this.updateState(name, (previous) => {
3405
- const { state, dropped: letGo } = recordSent(normalizeState(previous, name), delivered, failed, dropped, now);
3574
+ const { state, dropped: letGo } = recordSent(normalizeState(previous, name), delivered, failed, dropped, now, reached);
3406
3575
  return { state, result: letGo };
3407
3576
  });
3408
3577
  this.reportDropped(name, trimmed);
3409
3578
  } catch (e) {
3410
- this.onError(e, `recording alert delivery for ${name}`);
3579
+ this.report(e, `recording alert delivery for ${name}`);
3411
3580
  }
3412
3581
  }
3413
- /** Send to every channel at once. True when at least one accepted it, or there are none. */
3582
+ /**
3583
+ * Send to every channel that still owes the alert (owedChannels: all of
3584
+ * them for an alert not sent before), at once, each failure reported under
3585
+ * its channel's name. `left` is the alert with `owed` cut to the channels
3586
+ * that refused it, or null when none did (or none is left to owe it);
3587
+ * `reached` is whether any channel accepted it. With no channels at all it
3588
+ * counts as delivered, as it always has.
3589
+ */
3414
3590
  async deliver(alert) {
3415
- if (this.alerts.length === 0) return true;
3416
- const results = await Promise.all(
3417
- this.alerts.map(async (channel) => {
3591
+ if (this.alerts.length === 0) return { left: null, reached: true };
3592
+ const keys = channelKeys(this.alerts.map((channel) => channel.name));
3593
+ const owed = owedChannels(alert, keys);
3594
+ if (owed.length === 0) return { left: null, reached: false };
3595
+ const plain = withoutOwed(alert);
3596
+ const failed = [];
3597
+ await Promise.all(
3598
+ this.alerts.map(async (channel, i) => {
3599
+ if (!owed.includes(keys[i])) return;
3418
3600
  try {
3419
- await withTimeout(channel.send(alert, { onError: (e) => this.report(e, `alert channel ${channel.name}`) }), CHANNEL_TIMEOUT_MS);
3420
- return true;
3601
+ await withTimeout(channel.send(plain, { onError: (e) => this.report(e, `alert channel ${channel.name}`) }), CHANNEL_TIMEOUT_MS);
3421
3602
  } catch (e) {
3422
- this.onError(e, `alert channel ${channel.name}`);
3423
- return false;
3603
+ failed.push(keys[i]);
3604
+ this.report(e, `alert channel ${channel.name}`);
3424
3605
  }
3425
3606
  })
3426
3607
  );
3427
- return results.includes(true);
3608
+ return { left: afterSend(alert, owed, failed), reached: failed.length < owed.length };
3428
3609
  }
3429
3610
  /** Sets alert.triage to the diagnosis, or to null when there is none, so it is tried once per alert. */
3430
3611
  async addTriage(alert, timeout) {
@@ -3436,7 +3617,7 @@ ${id}`;
3436
3617
  } catch (e) {
3437
3618
  controller.abort();
3438
3619
  alert.triage = null;
3439
- this.onError(e, `triage for ${alert.job}`);
3620
+ this.report(e, `triage for ${alert.job}`);
3440
3621
  }
3441
3622
  }
3442
3623
  };