@cronwatch/sdk 0.9.0 → 0.10.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 (67) hide show
  1. package/dist/anthropic.d.cts +1 -1
  2. package/dist/anthropic.d.ts +1 -1
  3. package/dist/bugsnag.d.cts +1 -1
  4. package/dist/bugsnag.d.ts +1 -1
  5. package/dist/{client-CQm-8-mR.d.ts → client-BcIjVBiZ.d.ts} +68 -13
  6. package/dist/{client-D35cfnNV.d.cts → client-DUoSea7X.d.cts} +68 -13
  7. package/dist/d1.cjs.map +1 -1
  8. package/dist/d1.d.cts +1 -1
  9. package/dist/d1.d.ts +1 -1
  10. package/dist/d1.js.map +1 -1
  11. package/dist/datadog.d.cts +1 -1
  12. package/dist/datadog.d.ts +1 -1
  13. package/dist/discord.cjs +17 -2
  14. package/dist/discord.cjs.map +1 -1
  15. package/dist/discord.d.cts +11 -2
  16. package/dist/discord.d.ts +11 -2
  17. package/dist/discord.js +16 -3
  18. package/dist/discord.js.map +1 -1
  19. package/dist/{email-D1d5niMy.d.cts → email-JZs3vxwq.d.cts} +1 -1
  20. package/dist/{email-BxCp9RQV.d.ts → email-VrGsVz43.d.ts} +1 -1
  21. package/dist/honeybadger.d.cts +1 -1
  22. package/dist/honeybadger.d.ts +1 -1
  23. package/dist/index.cjs +207 -88
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +4 -4
  26. package/dist/index.d.ts +4 -4
  27. package/dist/index.js +207 -88
  28. package/dist/index.js.map +1 -1
  29. package/dist/mailgun.d.cts +2 -2
  30. package/dist/mailgun.d.ts +2 -2
  31. package/dist/newrelic.d.cts +1 -1
  32. package/dist/newrelic.d.ts +1 -1
  33. package/dist/pg-cron.cjs +2 -1
  34. package/dist/pg-cron.cjs.map +1 -1
  35. package/dist/pg-cron.d.cts +2 -2
  36. package/dist/pg-cron.d.ts +2 -2
  37. package/dist/pg-cron.js +2 -1
  38. package/dist/pg-cron.js.map +1 -1
  39. package/dist/postgres.cjs.map +1 -1
  40. package/dist/postgres.d.cts +1 -1
  41. package/dist/postgres.d.ts +1 -1
  42. package/dist/postgres.js.map +1 -1
  43. package/dist/postmark.d.cts +2 -2
  44. package/dist/postmark.d.ts +2 -2
  45. package/dist/resend.d.cts +2 -2
  46. package/dist/resend.d.ts +2 -2
  47. package/dist/rollbar.d.cts +1 -1
  48. package/dist/rollbar.d.ts +1 -1
  49. package/dist/sendgrid.d.cts +2 -2
  50. package/dist/sendgrid.d.ts +2 -2
  51. package/dist/sentry.d.cts +1 -1
  52. package/dist/sentry.d.ts +1 -1
  53. package/dist/ses.d.cts +2 -2
  54. package/dist/ses.d.ts +2 -2
  55. package/dist/slack.d.cts +1 -1
  56. package/dist/slack.d.ts +1 -1
  57. package/dist/sqlite.cjs.map +1 -1
  58. package/dist/sqlite.d.cts +1 -1
  59. package/dist/sqlite.d.ts +1 -1
  60. package/dist/sqlite.js.map +1 -1
  61. package/dist/twilio.d.cts +1 -1
  62. package/dist/twilio.d.ts +1 -1
  63. package/dist/{types-Cz47Boks.d.cts → types-DyWCxlSi.d.cts} +14 -1
  64. package/dist/{types-Cz47Boks.d.ts → types-DyWCxlSi.d.ts} +14 -1
  65. package/dist/webhook.d.cts +1 -1
  66. package/dist/webhook.d.ts +1 -1
  67. package/package.json +1 -1
package/dist/index.d.cts CHANGED
@@ -1,7 +1,7 @@
1
- import { C as CronWatchOptions, a as CronWatch } from './client-D35cfnNV.cjs';
2
- export { F as FetchHandler, H as HandlerFn, b as HandlerOptions, J as JobContext, c as JobFn, d as JobHandle, R as RecordRunOptions, e as Routes, f as RoutesOptions, g as RunHandle, h as RunOutcome, S as Source, i as SourceHost, j as StartOptions, k as consoleChannel, l as createRoutes, m as custom } from './client-D35cfnNV.cjs';
3
- import { S as Store, D as Duration, b as AlertDraft, c as StoredJobDefinition, A as Alert } from './types-Cz47Boks.cjs';
4
- export { a as AlertChannel, d as AlertDetails, e as AlertType, B as BudgetBreach, C as ChannelContext, f as CheckResult, g as Condition, E as ExpectRule, J as JobDefinition, h as JobHealth, i as JobOptions, j as JobState, k as JobSummary, R as Run, l as RunStatus, m as StoredJob, T as TriageContext, n as TriageFn } from './types-Cz47Boks.cjs';
1
+ import { C as CronWatchOptions, a as CronWatch } from './client-DUoSea7X.cjs';
2
+ export { F as FetchHandler, H as HandlerFn, b as HandlerOptions, J as JobContext, c as JobFn, d as JobHandle, R as RecordRunOptions, e as Routes, f as RoutesOptions, g as RunHandle, h as RunOutcome, S as Source, i as SourceHost, j as StartOptions, k as consoleChannel, l as createRoutes, m as custom } from './client-DUoSea7X.cjs';
3
+ import { S as Store, D as Duration, b as AlertDraft, c as StoredJobDefinition, A as Alert } from './types-DyWCxlSi.cjs';
4
+ export { a as AlertChannel, d as AlertDetails, e as AlertType, B as BudgetBreach, C as ChannelContext, f as CheckResult, g as Condition, E as ExpectRule, J as JobDefinition, h as JobHealth, i as JobOptions, j as JobState, k as JobSummary, R as Run, l as RunStatus, m as SendingAlert, n as StoredJob, T as TriageContext, o as TriageFn } from './types-DyWCxlSi.cjs';
5
5
 
6
6
  /**
7
7
  * Keeps everything in process memory. The default when no store is given,
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { C as CronWatchOptions, a as CronWatch } from './client-CQm-8-mR.js';
2
- export { F as FetchHandler, H as HandlerFn, b as HandlerOptions, J as JobContext, c as JobFn, d as JobHandle, R as RecordRunOptions, e as Routes, f as RoutesOptions, g as RunHandle, h as RunOutcome, S as Source, i as SourceHost, j as StartOptions, k as consoleChannel, l as createRoutes, m as custom } from './client-CQm-8-mR.js';
3
- import { S as Store, D as Duration, b as AlertDraft, c as StoredJobDefinition, A as Alert } from './types-Cz47Boks.js';
4
- export { a as AlertChannel, d as AlertDetails, e as AlertType, B as BudgetBreach, C as ChannelContext, f as CheckResult, g as Condition, E as ExpectRule, J as JobDefinition, h as JobHealth, i as JobOptions, j as JobState, k as JobSummary, R as Run, l as RunStatus, m as StoredJob, T as TriageContext, n as TriageFn } from './types-Cz47Boks.js';
1
+ import { C as CronWatchOptions, a as CronWatch } from './client-BcIjVBiZ.js';
2
+ export { F as FetchHandler, H as HandlerFn, b as HandlerOptions, J as JobContext, c as JobFn, d as JobHandle, R as RecordRunOptions, e as Routes, f as RoutesOptions, g as RunHandle, h as RunOutcome, S as Source, i as SourceHost, j as StartOptions, k as consoleChannel, l as createRoutes, m as custom } from './client-BcIjVBiZ.js';
3
+ import { S as Store, D as Duration, b as AlertDraft, c as StoredJobDefinition, A as Alert } from './types-DyWCxlSi.js';
4
+ export { a as AlertChannel, d as AlertDetails, e as AlertType, B as BudgetBreach, C as ChannelContext, f as CheckResult, g as Condition, E as ExpectRule, J as JobDefinition, h as JobHealth, i as JobOptions, j as JobState, k as JobSummary, R as Run, l as RunStatus, m as SendingAlert, n as StoredJob, T as TriageContext, o as TriageFn } from './types-DyWCxlSi.js';
5
5
 
6
6
  /**
7
7
  * Keeps everything in process memory. The default when no store is given,
package/dist/index.js CHANGED
@@ -266,6 +266,9 @@ function runDuration(startedAt, finishedAt) {
266
266
  const ms = finishedAt - startedAt;
267
267
  return ms > 0 ? Math.min(ms, MAX_DURATION_MS) : 0;
268
268
  }
269
+ function silenceEnd(now, ms) {
270
+ return Math.min(now + Math.floor(Math.min(ms, MAX_DURATION_MS)), MAX_DURATION_MS);
271
+ }
269
272
  function stateVersion(state) {
270
273
  const version = state?.version;
271
274
  return typeof version === "number" && Number.isSafeInteger(version) && version >= 0 ? version + 0 : 0;
@@ -280,15 +283,64 @@ function emptyState(job) {
280
283
  }
281
284
  function normalizeState(state, job) {
282
285
  if (!state) return emptyState(job);
286
+ const { sending, ...rest } = state;
283
287
  return {
284
288
  ...emptyState(job),
285
- ...state,
289
+ ...rest,
286
290
  open: { ...state.open },
287
291
  consecutiveFailures: failureCount(state),
288
292
  pendingRecovery: [...state.pendingRecovery ?? []],
289
- undelivered: [...state.undelivered ?? []]
293
+ undelivered: [...state.undelivered ?? []],
294
+ ...Array.isArray(sending) && sending.length > 0 ? { sending: [...sending] } : {}
290
295
  };
291
296
  }
297
+ var MAX_UNDELIVERED = 20;
298
+ var SEND_LEASE_MS = 5 * 6e4;
299
+ function alertKey(alert) {
300
+ return `${alert.type}|${alert.at}|${alert.run?.id ?? ""}`;
301
+ }
302
+ function newest(list, max) {
303
+ return { kept: list.slice(-max), dropped: Math.max(0, list.length - max) };
304
+ }
305
+ function queueUndelivered(state, alerts) {
306
+ const next = cloneState(state);
307
+ const byKey = new Map(alerts.map((alert) => [alertKey(alert), alert]));
308
+ const queue = next.undelivered.map((alert) => byKey.get(alertKey(alert)) ?? alert);
309
+ const known = new Set(queue.map(alertKey));
310
+ queue.push(...alerts.filter((alert) => !known.has(alertKey(alert))));
311
+ const { kept, dropped } = newest(queue, MAX_UNDELIVERED);
312
+ next.undelivered = kept;
313
+ return { state: next, dropped };
314
+ }
315
+ function holdAlerts(state, alerts, until, deferred) {
316
+ if (alerts.length === 0) return { state, dropped: 0 };
317
+ if (deferred) return queueUndelivered(state, alerts);
318
+ const next = cloneState(state);
319
+ const { kept, dropped } = newest([...next.sending ?? [], ...alerts.map((alert) => ({ until, alert }))], MAX_UNDELIVERED);
320
+ next.sending = kept;
321
+ return { state: next, dropped };
322
+ }
323
+ function releaseSending(state, now) {
324
+ const sending = state.sending ?? [];
325
+ const lapsed = sending.filter((entry) => !(typeof entry?.until === "number" && entry.until > now));
326
+ if (lapsed.length === 0) return { state, dropped: 0 };
327
+ const held = sending.filter((entry) => !lapsed.includes(entry));
328
+ return queueUndelivered(
329
+ { ...state, sending: held },
330
+ lapsed.filter((entry) => typeof entry?.alert === "object" && entry.alert !== null).map((entry) => entry.alert)
331
+ );
332
+ }
333
+ function recordSent(state, delivered, failed, stale, now) {
334
+ const next = cloneState(state);
335
+ const done = new Set([...delivered, ...stale].map(alertKey));
336
+ next.undelivered = next.undelivered.filter((alert) => !done.has(alertKey(alert)));
337
+ const sent = new Set([...delivered, ...failed, ...stale].map(alertKey));
338
+ const held = (next.sending ?? []).filter((entry) => !(typeof entry?.alert === "object" && entry.alert !== null && sent.has(alertKey(entry.alert))));
339
+ if (held.length > 0) next.sending = held;
340
+ else delete next.sending;
341
+ if (delivered.length > 0) next.lastAlertAt = now;
342
+ return queueUndelivered(next, failed);
343
+ }
292
344
  function cloneState(state) {
293
345
  return normalizeState(state, state.job);
294
346
  }
@@ -592,7 +644,16 @@ var OUTPUT_CAP = 16 * 1024;
592
644
  function capOutput(text) {
593
645
  const clean = stripNul(text);
594
646
  if (clean.length <= OUTPUT_CAP) return clean;
595
- return "[earlier output trimmed]\n" + clean.slice(clean.length - OUTPUT_CAP);
647
+ return TRIMMED + clean.slice(clean.length - OUTPUT_CAP);
648
+ }
649
+ var TRIMMED = "[earlier output trimmed]\n";
650
+ var REDACT_EDGE = 3 * (OUTPUT_CAP + 1024);
651
+ function redactAndCap(text, redact) {
652
+ const clean = stripNul(text);
653
+ const from = clean.length - (OUTPUT_CAP + REDACT_EDGE);
654
+ if (from <= 0) return capOutput(redact(clean));
655
+ const redacted = stripNul(redact(clean.slice(from)));
656
+ return TRIMMED + redacted.slice(Math.max(redacted.length - OUTPUT_CAP, REDACT_EDGE));
596
657
  }
597
658
  function stripNul(text) {
598
659
  return text.includes("\0") ? text.replace(/\u0000/g, "") : text;
@@ -600,9 +661,6 @@ function stripNul(text) {
600
661
  function stripJsonNul(json2) {
601
662
  return json2.includes("\\u0000") ? json2.replace(/\\(u0000|[\s\S])/g, (escape, next) => next === "u0000" ? "" : escape) : json2;
602
663
  }
603
- function errorMessage(error) {
604
- return capOutput(describeError(error));
605
- }
606
664
  function describeError(error) {
607
665
  if (error instanceof Error) {
608
666
  const frames = (error.stack ?? "").split("\n").filter((line) => /^\s+at /.test(line)).slice(0, 5);
@@ -711,7 +769,7 @@ function createRecorder(run) {
711
769
  };
712
770
  return {
713
771
  context,
714
- output: () => lines.length === 0 ? null : capOutput(lines.join("\n")),
772
+ output: () => lines.length === 0 ? null : lines.join("\n"),
715
773
  expectText: () => {
716
774
  if (lines.length === 0) return null;
717
775
  const all = lines.join("\n");
@@ -1452,7 +1510,7 @@ function jobPage(job, runs, now, base, complete = true) {
1452
1510
  run.error ? `<details class="out error" open><summary>error</summary><pre>${h(run.error)}</pre></details>` : "",
1453
1511
  run.output ? `<details class="out"${run.status === "ok" ? "" : " open"}><summary>output</summary><pre>${h(run.output)}</pre></details>` : ""
1454
1512
  ].join("");
1455
- const metrics = Object.entries(run.metrics).map(([k, v]) => `<span><span class="k">${h(k)}</span> ${h(Number.isInteger(v) ? v : v.toFixed(4))}</span>`).join("");
1513
+ const metrics = Object.entries(run.metrics ?? {}).filter(([, v]) => typeof v === "number" && Number.isFinite(v)).map(([k, v]) => `<span><span class="k">${h(k)}</span> ${h(Number.isInteger(v) ? v : v.toFixed(4))}</span>`).join("");
1456
1514
  return `<tr${detail ? ` class="has-detail"` : ""}>
1457
1515
  <td class="nowrap">${runState(run)}</td>
1458
1516
  <td class="nowrap">${h(when2(run.startedAt, now))} <span class="muted">UTC</span><span class="sub">${stamp(run.startedAt, now)}</span></td>
@@ -1931,11 +1989,11 @@ function memory() {
1931
1989
  return true;
1932
1990
  },
1933
1991
  async prune(before) {
1934
- const newest = /* @__PURE__ */ new Map();
1935
- for (const run of runs.values()) newest.set(run.job, Math.max(newest.get(run.job) ?? -Infinity, run.startedAt));
1992
+ const newest2 = /* @__PURE__ */ new Map();
1993
+ for (const run of runs.values()) newest2.set(run.job, Math.max(newest2.get(run.job) ?? -Infinity, run.startedAt));
1936
1994
  let n = 0;
1937
1995
  for (const [id, run] of runs) {
1938
- if (run.status !== "running" && run.startedAt < before && run.startedAt < newest.get(run.job)) {
1996
+ if (run.status !== "running" && run.startedAt < before && run.startedAt < newest2.get(run.job)) {
1939
1997
  runs.delete(id);
1940
1998
  order.delete(id);
1941
1999
  n++;
@@ -1951,7 +2009,6 @@ var NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/;
1951
2009
  var TRIAGE_TIMEOUT_MS = 25e3;
1952
2010
  var CHANNEL_TIMEOUT_MS = 15e3;
1953
2011
  var PRUNE_INTERVAL_MS = 60 * 6e4;
1954
- var MAX_UNDELIVERED = 20;
1955
2012
  var RETRY_BUDGET_MS = 2e4;
1956
2013
  var STATE_ATTEMPTS = 10;
1957
2014
  var HISTORY_PAGE = BASELINE_WINDOW + 5;
@@ -1992,9 +2049,6 @@ function validateDefinition(def) {
1992
2049
  function sameState(a, b) {
1993
2050
  return JSON.stringify(a) === JSON.stringify(b);
1994
2051
  }
1995
- function alertKey(alert) {
1996
- return `${alert.type}|${alert.at}|${alert.run?.id ?? ""}`;
1997
- }
1998
2052
  var CronWatch = class {
1999
2053
  store;
2000
2054
  alerts;
@@ -2161,19 +2215,48 @@ var CronWatch = class {
2161
2215
  * that replaced it, never its own over it, and one forgotten since writes
2162
2216
  * its own. The writes of one name take turns, in the order they were asked
2163
2217
  * for, so one still under way cannot land after a later one; and a name
2164
- * declared again while its write was under way is still to be written.
2218
+ * declared again while its write was under way is still to be written. A
2219
+ * name is marked as written only while that same declaration stands, so a
2220
+ * forget that lands during the write (deleting the row after it) leaves
2221
+ * the name to be written again, as does one forgotten before it.
2222
+ *
2223
+ * With `confirm`, as a run starts, a name already written is read back:
2224
+ * another process may have forgotten the job since, and a job still
2225
+ * declared here comes back on its next run.
2165
2226
  */
2166
- async sync(definition) {
2227
+ async sync(definition, confirm = false) {
2167
2228
  await this.ensureReady();
2168
2229
  const name = definition.name;
2169
- if (this.synced.has(name)) return;
2230
+ if (this.synced.has(name)) {
2231
+ if (!confirm || await this.store.getJob(name)) return;
2232
+ this.synced.delete(name);
2233
+ }
2170
2234
  await this.serial(name, async () => {
2171
2235
  if (this.synced.has(name)) return;
2172
2236
  const standing = this.definitions.get(name) ?? definition;
2173
2237
  await this.store.upsertJob(toStored(standing), this.now());
2174
- if ((this.definitions.get(name) ?? standing) === standing) this.synced.add(name);
2238
+ if (this.definitions.get(name) === standing) this.synced.add(name);
2175
2239
  }, this.syncing);
2176
2240
  }
2241
+ /**
2242
+ * Every stored job, once each declaration has been written. A job declared
2243
+ * here that the store no longer has was forgotten by another process after
2244
+ * this one wrote it: it is written again, as its next run would, so it is
2245
+ * checked and shown while any process still declares it.
2246
+ */
2247
+ async storedJobs() {
2248
+ for (const definition of this.definitions.values()) await this.sync(definition);
2249
+ const jobs = await this.store.listJobs();
2250
+ const listed = new Set(jobs.map((job) => job.name));
2251
+ const missing = [...this.definitions.values()].filter((definition) => !listed.has(definition.name));
2252
+ if (missing.length === 0) return jobs;
2253
+ for (const definition of missing) {
2254
+ if (this.definitions.get(definition.name) !== definition) continue;
2255
+ this.synced.delete(definition.name);
2256
+ await this.sync(definition);
2257
+ }
2258
+ return this.store.listJobs();
2259
+ }
2177
2260
  /**
2178
2261
  * Runs `fn` after every earlier state update for the same job has settled,
2179
2262
  * so two runs (or a run and a check) in this process never read and write
@@ -2249,7 +2332,7 @@ var CronWatch = class {
2249
2332
  };
2250
2333
  let recorded = false;
2251
2334
  try {
2252
- await this.sync(definition);
2335
+ await this.sync(definition, true);
2253
2336
  await this.store.insertRun({ ...run });
2254
2337
  recorded = true;
2255
2338
  } catch (e) {
@@ -2274,7 +2357,7 @@ var CronWatch = class {
2274
2357
  run.finishedAt = finishedAt;
2275
2358
  run.durationMs = runDuration(startedAt, finishedAt);
2276
2359
  run.metrics = recorder.metrics();
2277
- run.output = recorder.output() ?? (typeof result === "string" ? capOutput(result) : null);
2360
+ run.output = recorder.output() ?? (typeof result === "string" ? result : null);
2278
2361
  this.conclude(definition, run, result, error, threw, recorder.expectText() ?? (typeof result === "string" ? result : null));
2279
2362
  await started;
2280
2363
  try {
@@ -2287,12 +2370,13 @@ var CronWatch = class {
2287
2370
  }
2288
2371
  /**
2289
2372
  * Sets a finished run's status and error from how it ended, then redacts
2290
- * its output and error. Shared by execute() and RunHandle.finish().
2373
+ * its output and error and caps them, in that order. Shared by execute()
2374
+ * and RunHandle.finish().
2291
2375
  */
2292
2376
  conclude(definition, run, result, error, threw, expectText) {
2293
2377
  if (threw) {
2294
2378
  run.status = "failed";
2295
- run.error = errorMessage(error);
2379
+ run.error = describeError(error);
2296
2380
  } else if (result instanceof Response && result.status >= 400) {
2297
2381
  run.status = "failed";
2298
2382
  run.error = `HTTP ${result.status}${result.statusText ? ` ${result.statusText}` : ""}`;
@@ -2305,8 +2389,8 @@ var CronWatch = class {
2305
2389
  run.status = "ok";
2306
2390
  }
2307
2391
  }
2308
- if (run.output !== null) run.output = stripNul(this.redact(run.output));
2309
- if (run.error !== null) run.error = stripNul(this.redact(run.error));
2392
+ if (run.output !== null) run.output = redactAndCap(run.output, this.redact);
2393
+ if (run.error !== null) run.error = redactAndCap(run.error, this.redact);
2310
2394
  }
2311
2395
  /**
2312
2396
  * Writes a finished run and evaluates it. `recorded` says whether its
@@ -2401,7 +2485,7 @@ ${id}`;
2401
2485
  };
2402
2486
  let recorded = false;
2403
2487
  try {
2404
- await this.sync(definition);
2488
+ await this.sync(definition, true);
2405
2489
  await this.store.insertRun({ ...run });
2406
2490
  recorded = true;
2407
2491
  } catch (e) {
@@ -2501,14 +2585,15 @@ ${id}`;
2501
2585
  const result = typeof outcome === "string" ? outcome : failed ? void 0 : outcome?.result;
2502
2586
  const error = failed ? outcome.error : void 0;
2503
2587
  const finishedAt = self.now();
2504
- const added = recorder.output() ?? (typeof result === "string" ? capOutput(result) : null);
2588
+ const added = recorder.output() ?? (typeof result === "string" ? result : null);
2505
2589
  const run = {
2506
2590
  ...from,
2507
2591
  status: "running",
2508
2592
  finishedAt,
2509
2593
  durationMs: runDuration(from.startedAt, finishedAt),
2510
2594
  error: null,
2511
- output: joinOutput(from.output, added),
2595
+ // Capped by conclude(), after it is redacted.
2596
+ output: joinLines(from.output, added),
2512
2597
  metrics: { ...from.metrics, ...recorder.metrics() }
2513
2598
  };
2514
2599
  const expectText = joinLines(head, joinLines(from.output, recorder.expectText() ?? (typeof result === "string" ? result : null)));
@@ -2557,7 +2642,7 @@ ${id}`;
2557
2642
  self.report(new Error(`run ${id} of ${name} belongs to job "${stored.job}"; ignored`), `flushing ${name}`);
2558
2643
  return;
2559
2644
  }
2560
- const output = lines === null ? stored.output : joinOutput(stored.output, stripNul(self.redact(lines)));
2645
+ const output = lines === null ? stored.output : joinOutput(stored.output, redactAndCap(lines, self.redact));
2561
2646
  if (!await self.writeRunIf({ ...stored, output, metrics: { ...stored.metrics, ...metrics } }, ["running"])) return putBack();
2562
2647
  const text = taken.expectText();
2563
2648
  if (text !== null && (head === null || head.length < OUTPUT_CAP)) head = (joinLines(head, text) ?? "").slice(0, OUTPUT_CAP);
@@ -2582,13 +2667,19 @@ ${id}`;
2582
2667
  * alone and reported. A finished run is judged as if it had been wrapped
2583
2668
  * here (expect, failures, duration, budgets) and its output and error are
2584
2669
  * redacted the same way; one finishing after a check marked it timeout is
2585
- * judged only when it succeeded, as RunHandle.finish() does. Returns the
2586
- * alerts it sent.
2670
+ * judged only when it succeeded, as RunHandle.finish() does. A metric that
2671
+ * is not a finite number throws before anything is written, as
2672
+ * job.metric() does. Returns the alerts it sent.
2587
2673
  */
2588
2674
  async recordRun(input, options = {}) {
2589
2675
  const declared = this.definitions.get(input.job);
2590
2676
  if (!declared) throw new Error(`recordRun: job "${input.job}" is not declared; call job() first`);
2591
2677
  if (input.id.includes("\0")) throw new Error(`recordRun: run ids cannot contain a NUL character (job "${input.job}")`);
2678
+ for (const [metric, value] of Object.entries(input.metrics ?? {})) {
2679
+ if (typeof value !== "number" || !Number.isFinite(value)) {
2680
+ throw new Error(`recordRun: metric "${metric}" must be a finite number (job "${input.job}", run "${input.id}")`);
2681
+ }
2682
+ }
2592
2683
  await this.sync(declared);
2593
2684
  const run = { ...input, metrics: { ...input.metrics } };
2594
2685
  if (run.status === "ok") {
@@ -2598,8 +2689,8 @@ ${id}`;
2598
2689
  run.error = unmet;
2599
2690
  }
2600
2691
  }
2601
- if (run.output !== null) run.output = stripNul(this.redact(capOutput(run.output)));
2602
- if (run.error !== null) run.error = stripNul(this.redact(capOutput(run.error)));
2692
+ if (run.output !== null) run.output = redactAndCap(run.output, this.redact);
2693
+ if (run.error !== null) run.error = redactAndCap(run.error, this.redact);
2603
2694
  const evaluate = options.evaluate !== false;
2604
2695
  const definition = toStored(declared);
2605
2696
  const stored = await this.store.getRun(run.id);
@@ -2633,22 +2724,42 @@ ${id}`;
2633
2724
  }
2634
2725
  /**
2635
2726
  * Evaluate a finished run (ok, failed, or timed out by a check), already
2636
- * written, against the job's state and send what that produces. Never throws.
2727
+ * written, against the job's state and send what that produces. The
2728
+ * alerts are written with that state (see outbox()). Never throws.
2637
2729
  */
2638
2730
  async finishRun(definition, run, now) {
2639
- let drafts;
2731
+ let held;
2640
2732
  try {
2641
2733
  let history = null;
2642
- ({ result: drafts } = await this.updateState(run.job, async (previous) => {
2734
+ ({ result: held } = await this.updateState(run.job, async (previous) => {
2643
2735
  history ??= await this.history(run);
2644
- const settled = applySilence(previous, onRunFinish(definition, run, previous, history, now), now);
2645
- return { state: settled.state, result: settled.alerts };
2736
+ return this.outbox(applySilence(previous, onRunFinish(definition, run, previous, history, now), now), definition, now);
2646
2737
  }));
2647
2738
  } catch (e) {
2648
2739
  this.onError(e, `evaluating ${run.job}`);
2649
2740
  return [];
2650
2741
  }
2651
- return this.dispatch(drafts, definition, now);
2742
+ this.reportDropped(run.job, held.dropped);
2743
+ return this.dispatch(run.job, held.alerts, now);
2744
+ }
2745
+ /**
2746
+ * An evaluation as it is written: its drafts composed into alerts and held
2747
+ * in the same state (holdAlerts), so the write that opens a condition also
2748
+ * keeps its alerts, and a process that stops before sending them does not
2749
+ * lose them. Called inside updateState(), so it only computes.
2750
+ */
2751
+ outbox(settled, definition, now) {
2752
+ const alerts = settled.alerts.map((draft) => composeAlert(draft, definition, now));
2753
+ const { state, dropped } = holdAlerts(settled.state, alerts, this.now() + SEND_LEASE_MS, this.deferDelivery);
2754
+ return { state, result: { alerts, dropped } };
2755
+ }
2756
+ /** Reports alerts let go because a job's queue was full. */
2757
+ reportDropped(name, dropped) {
2758
+ if (dropped <= 0) return;
2759
+ this.report(
2760
+ new Error(`${dropped} undelivered alert${dropped === 1 ? "" : "s"} for ${name} dropped: only the newest ${MAX_UNDELIVERED} are kept for retry`),
2761
+ `alert queue for ${name}`
2762
+ );
2652
2763
  }
2653
2764
  /**
2654
2765
  * The runs before `run`, newest first, with up to BASELINE_WINDOW
@@ -2688,11 +2799,13 @@ ${id}`;
2688
2799
  }
2689
2800
  for (const definition of this.definitions.values()) await this.sync(definition);
2690
2801
  const now = this.now();
2691
- for (const run of await this.store.runningRuns()) {
2802
+ for (const listed of await this.store.runningRuns()) {
2692
2803
  try {
2693
- const declared = this.definitions.get(run.job);
2694
- const definition = declared ? toStored(declared) : (await this.store.getJob(run.job))?.definition;
2695
- if (!definition || !isStuck(definition, run, now)) continue;
2804
+ const declared = this.definitions.get(listed.job);
2805
+ const definition = declared ? toStored(declared) : (await this.store.getJob(listed.job))?.definition;
2806
+ if (!definition || !isStuck(definition, listed, now)) continue;
2807
+ const run = await this.store.getRun(listed.id);
2808
+ if (!run || run.status !== "running" || run.job !== listed.job) continue;
2696
2809
  const timeout = timeoutMs(definition);
2697
2810
  run.status = "timeout";
2698
2811
  run.finishedAt = now;
@@ -2701,23 +2814,26 @@ ${id}`;
2701
2814
  if (!await this.writeRunIf(run, ["running"])) continue;
2702
2815
  alerts.push(...await this.finishRun(definition, run, now));
2703
2816
  } catch (e) {
2704
- this.onError(e, `checking ${run.job}`);
2817
+ this.onError(e, `checking ${listed.job}`);
2705
2818
  }
2706
2819
  }
2707
2820
  const jobs = [];
2708
2821
  const retries = { spentMs: 0 };
2709
- for (const stored of await this.store.listJobs()) {
2822
+ for (const stored of await this.storedJobs()) {
2710
2823
  try {
2711
2824
  const recent = await this.store.listRuns(stored.name, BASELINE_WINDOW);
2712
2825
  let nextExpectedAt = null;
2713
- const { state, result: drafts } = await this.updateState(stored.name, (previous) => {
2826
+ const { state, result: held } = await this.updateState(stored.name, (previous) => {
2714
2827
  const evaluation = onCheck(stored.definition, stored, recent[0] ?? null, previous, now);
2715
2828
  nextExpectedAt = evaluation.nextExpectedAt;
2716
2829
  const settled = applySilence(previous, evaluation, now);
2717
- return { state: settled.state, result: settled.alerts };
2830
+ const released = releaseSending(settled.state, this.now());
2831
+ const out = this.outbox({ state: released.state, alerts: settled.alerts }, stored.definition, now);
2832
+ return { state: out.state, result: { alerts: out.result.alerts, dropped: released.dropped + out.result.dropped } };
2718
2833
  });
2834
+ this.reportDropped(stored.name, held.dropped);
2719
2835
  alerts.push(...await this.retryUndelivered(stored.name, state, now, retries));
2720
- alerts.push(...await this.dispatch(drafts, stored.definition, now));
2836
+ alerts.push(...await this.dispatch(stored.name, held.alerts, now));
2721
2837
  jobs.push(summarize(stored, recent, state, nextExpectedAt, now));
2722
2838
  } catch (e) {
2723
2839
  this.onError(e, `checking ${stored.name}`);
@@ -2761,16 +2877,17 @@ ${id}`;
2761
2877
  /** Every job's summary with its newest `limit` runs, read together. What the dashboard shows. */
2762
2878
  async jobsWithRuns(limit = 20) {
2763
2879
  await this.ensureReady();
2764
- for (const definition of this.definitions.values()) await this.sync(definition);
2880
+ const jobs = await this.storedJobs();
2765
2881
  const now = this.now();
2766
2882
  const out = [];
2767
- for (const stored of await this.store.listJobs()) out.push(await this.snapshot(stored, now, clampLimit(limit, 20, 0)));
2883
+ for (const stored of jobs) out.push(await this.snapshot(stored, now, clampLimit(limit, 20, 0)));
2768
2884
  return out;
2769
2885
  }
2886
+ /** A job's summary, or null for one the store does not have. One declared here and forgotten elsewhere is written again, as storedJobs() does. */
2770
2887
  async jobSummary(name) {
2771
2888
  await this.ensureReady();
2772
2889
  const definition = this.definitions.get(name);
2773
- if (definition) await this.sync(definition);
2890
+ if (definition) await this.sync(definition, true);
2774
2891
  const stored = await this.store.getJob(name);
2775
2892
  if (!stored) return null;
2776
2893
  return (await this.snapshot(stored, this.now(), 0)).job;
@@ -2784,11 +2901,14 @@ ${id}`;
2784
2901
  await this.ensureReady();
2785
2902
  return this.store.getRun(id);
2786
2903
  }
2787
- /** Stop alerts for a job for a while. State keeps updating underneath. */
2904
+ /**
2905
+ * Stop alerts for a job for a while. State keeps updating underneath. The
2906
+ * end is a whole millisecond, held at 2^53 - 1 (see silenceEnd).
2907
+ */
2788
2908
  async silence(name, duration) {
2789
2909
  const ms = parseDuration(duration, "silence duration");
2790
2910
  return this.patchState(name, (state) => {
2791
- state.silencedUntil = this.now() + ms;
2911
+ state.silencedUntil = silenceEnd(this.now(), ms);
2792
2912
  });
2793
2913
  }
2794
2914
  async unsilence(name) {
@@ -2806,7 +2926,11 @@ ${id}`;
2806
2926
  });
2807
2927
  return state;
2808
2928
  }
2809
- /** Remove a job and its runs from the store. A job still declared in code comes back on its next run. */
2929
+ /**
2930
+ * Remove a job and its runs from the store. A job still declared in code
2931
+ * comes back: here on its next run, and in any other process that declares
2932
+ * it on its next run there, or at that process's next check or dashboard read.
2933
+ */
2810
2934
  async forget(name) {
2811
2935
  await this.ensureReady();
2812
2936
  this.definitions.delete(name);
@@ -2844,32 +2968,37 @@ ${id}`;
2844
2968
  this.timer = null;
2845
2969
  this.firstTick = null;
2846
2970
  }
2971
+ /**
2972
+ * Stop the interval, wait for a check already under way (bounded by its
2973
+ * own channel, triage and retry timeouts; what it throws was reported to
2974
+ * whoever started it), then close the store, so that check neither writes
2975
+ * after the store is closed nor loses the alerts it would queue.
2976
+ */
2847
2977
  async close() {
2848
2978
  this.stop();
2979
+ if (this.checking) await this.checking.catch(() => {
2980
+ });
2849
2981
  if (this.store.close) await this.store.close();
2850
2982
  }
2851
2983
  /**
2852
- * Compose, triage and send each draft. The state was saved before this
2853
- * (updateState), so a slow channel holds up nothing else; afterwards only
2854
- * the delivery fields are written back, onto a fresh read of the state.
2984
+ * Triage and send each alert the outbox holds (see outbox()). The state,
2985
+ * with the alerts in it, was saved before this, so a slow channel holds up
2986
+ * nothing else; afterwards only the delivery fields are written back, onto
2987
+ * a fresh read of the state, and the alerts leave `sending`. Triage is made
2988
+ * here, never stored with the held alert: the write that opens a condition
2989
+ * cannot wait for it, and a retry triages an alert that has none. With
2990
+ * deliver: "check" the alerts were queued for a check elsewhere instead.
2855
2991
  */
2856
- async dispatch(drafts, definition, now) {
2857
- if (drafts.length === 0) return [];
2858
- const composed = [];
2992
+ async dispatch(name, alerts, now) {
2993
+ if (alerts.length === 0 || this.deferDelivery) return alerts;
2859
2994
  const delivered = [];
2860
2995
  const failed = [];
2861
- for (const draft of drafts) {
2862
- const alert = composeAlert(draft, definition, now);
2863
- if (this.deferDelivery) {
2864
- failed.push(alert);
2865
- } else {
2866
- if (this.triage && alert.type !== "recovered") await this.addTriage(alert, TRIAGE_TIMEOUT_MS);
2867
- (await this.deliver(alert) ? delivered : failed).push(alert);
2868
- }
2869
- composed.push(alert);
2996
+ for (const alert of alerts) {
2997
+ if (this.triage && alert.type !== "recovered") await this.addTriage(alert, TRIAGE_TIMEOUT_MS);
2998
+ (await this.deliver(alert) ? delivered : failed).push(alert);
2870
2999
  }
2871
- await this.recordDelivery(definition.name, delivered, failed, [], now);
2872
- return composed;
3000
+ await this.recordDelivery(name, delivered, failed, [], now);
3001
+ return alerts;
2873
3002
  }
2874
3003
  /**
2875
3004
  * Send the alerts that no channel accepted last time, once each, oldest
@@ -2898,28 +3027,18 @@ ${id}`;
2898
3027
  }
2899
3028
  /**
2900
3029
  * Mark delivered alerts done, drop stale ones, and keep failed ones for the
2901
- * next check. A failed alert replaces its stored copy, so a triage made on
2902
- * this attempt is kept. lastAlertAt moves only on a delivery.
3030
+ * next check, taking them all out of `sending` (recordSent). A failed alert
3031
+ * replaces its stored copy, so a triage made on this attempt is kept.
3032
+ * lastAlertAt moves only on a delivery. When this write fails, alerts
3033
+ * still in `sending` are retried once their lease runs out.
2903
3034
  */
2904
3035
  async recordDelivery(name, delivered, failed, dropped, now) {
2905
3036
  try {
2906
3037
  const { result: trimmed } = await this.updateState(name, (previous) => {
2907
- const state = normalizeState(previous, name);
2908
- const done = new Set([...delivered, ...dropped].map(alertKey));
2909
- const retried = new Map(failed.map((a) => [alertKey(a), a]));
2910
- const kept = state.undelivered.filter((a) => !done.has(alertKey(a))).map((a) => retried.get(alertKey(a)) ?? a);
2911
- const known = new Set(kept.map(alertKey));
2912
- kept.push(...failed.filter((a) => !known.has(alertKey(a))));
2913
- state.undelivered = kept.slice(-MAX_UNDELIVERED);
2914
- if (delivered.length > 0) state.lastAlertAt = now;
2915
- return { state, result: Math.max(0, kept.length - MAX_UNDELIVERED) };
3038
+ const { state, dropped: letGo } = recordSent(normalizeState(previous, name), delivered, failed, dropped, now);
3039
+ return { state, result: letGo };
2916
3040
  });
2917
- if (trimmed > 0) {
2918
- this.onError(
2919
- new Error(`${trimmed} undelivered alert${trimmed === 1 ? "" : "s"} for ${name} dropped: only the newest ${MAX_UNDELIVERED} are kept for retry`),
2920
- `alert queue for ${name}`
2921
- );
2922
- }
3041
+ this.reportDropped(name, trimmed);
2923
3042
  } catch (e) {
2924
3043
  this.onError(e, `recording alert delivery for ${name}`);
2925
3044
  }