@cronwatch/sdk 0.12.5 → 0.13.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 (119) hide show
  1. package/dist/anthropic.d.cts +1 -1
  2. package/dist/anthropic.d.ts +1 -1
  3. package/dist/bugsnag.cjs +7 -0
  4. package/dist/bugsnag.cjs.map +1 -1
  5. package/dist/bugsnag.d.cts +1 -1
  6. package/dist/bugsnag.d.ts +1 -1
  7. package/dist/bugsnag.js +7 -0
  8. package/dist/bugsnag.js.map +1 -1
  9. package/dist/{client-CFJUFEOg.d.cts → client-DAJH7YiG.d.ts} +28 -7
  10. package/dist/{client-k5UVIH38.d.ts → client-z1LdR_m2.d.cts} +28 -7
  11. package/dist/d1.d.cts +1 -1
  12. package/dist/d1.d.ts +1 -1
  13. package/dist/datadog.cjs +7 -0
  14. package/dist/datadog.cjs.map +1 -1
  15. package/dist/datadog.d.cts +1 -1
  16. package/dist/datadog.d.ts +1 -1
  17. package/dist/datadog.js +7 -0
  18. package/dist/datadog.js.map +1 -1
  19. package/dist/discord.cjs +25 -1
  20. package/dist/discord.cjs.map +1 -1
  21. package/dist/discord.d.cts +1 -1
  22. package/dist/discord.d.ts +1 -1
  23. package/dist/discord.js +25 -1
  24. package/dist/discord.js.map +1 -1
  25. package/dist/{email-U0hYRnPa.d.cts → email-CCUzlLe9.d.cts} +1 -1
  26. package/dist/{email-CPoxD8--.d.ts → email-CFtDqlvZ.d.ts} +1 -1
  27. package/dist/honeybadger.cjs +7 -0
  28. package/dist/honeybadger.cjs.map +1 -1
  29. package/dist/honeybadger.d.cts +1 -1
  30. package/dist/honeybadger.d.ts +1 -1
  31. package/dist/honeybadger.js +7 -0
  32. package/dist/honeybadger.js.map +1 -1
  33. package/dist/index.cjs +191 -47
  34. package/dist/index.cjs.map +1 -1
  35. package/dist/index.d.cts +5 -5
  36. package/dist/index.d.ts +5 -5
  37. package/dist/index.js +191 -47
  38. package/dist/index.js.map +1 -1
  39. package/dist/mailgun.cjs +7 -0
  40. package/dist/mailgun.cjs.map +1 -1
  41. package/dist/mailgun.d.cts +2 -2
  42. package/dist/mailgun.d.ts +2 -2
  43. package/dist/mailgun.js +7 -0
  44. package/dist/mailgun.js.map +1 -1
  45. package/dist/newrelic.cjs +7 -0
  46. package/dist/newrelic.cjs.map +1 -1
  47. package/dist/newrelic.d.cts +1 -1
  48. package/dist/newrelic.d.ts +1 -1
  49. package/dist/newrelic.js +7 -0
  50. package/dist/newrelic.js.map +1 -1
  51. package/dist/pg-cron.cjs.map +1 -1
  52. package/dist/pg-cron.d.cts +2 -2
  53. package/dist/pg-cron.d.ts +2 -2
  54. package/dist/pg-cron.js.map +1 -1
  55. package/dist/postgres.cjs +7 -2
  56. package/dist/postgres.cjs.map +1 -1
  57. package/dist/postgres.d.cts +1 -1
  58. package/dist/postgres.d.ts +1 -1
  59. package/dist/postgres.js +7 -2
  60. package/dist/postgres.js.map +1 -1
  61. package/dist/postmark.cjs +7 -0
  62. package/dist/postmark.cjs.map +1 -1
  63. package/dist/postmark.d.cts +2 -2
  64. package/dist/postmark.d.ts +2 -2
  65. package/dist/postmark.js +7 -0
  66. package/dist/postmark.js.map +1 -1
  67. package/dist/resend.cjs +7 -0
  68. package/dist/resend.cjs.map +1 -1
  69. package/dist/resend.d.cts +2 -2
  70. package/dist/resend.d.ts +2 -2
  71. package/dist/resend.js +7 -0
  72. package/dist/resend.js.map +1 -1
  73. package/dist/rollbar.cjs +7 -0
  74. package/dist/rollbar.cjs.map +1 -1
  75. package/dist/rollbar.d.cts +1 -1
  76. package/dist/rollbar.d.ts +1 -1
  77. package/dist/rollbar.js +7 -0
  78. package/dist/rollbar.js.map +1 -1
  79. package/dist/sendgrid.cjs +7 -0
  80. package/dist/sendgrid.cjs.map +1 -1
  81. package/dist/sendgrid.d.cts +2 -2
  82. package/dist/sendgrid.d.ts +2 -2
  83. package/dist/sendgrid.js +7 -0
  84. package/dist/sendgrid.js.map +1 -1
  85. package/dist/sentry.cjs +7 -0
  86. package/dist/sentry.cjs.map +1 -1
  87. package/dist/sentry.d.cts +1 -1
  88. package/dist/sentry.d.ts +1 -1
  89. package/dist/sentry.js +7 -0
  90. package/dist/sentry.js.map +1 -1
  91. package/dist/ses.cjs +7 -0
  92. package/dist/ses.cjs.map +1 -1
  93. package/dist/ses.d.cts +2 -2
  94. package/dist/ses.d.ts +2 -2
  95. package/dist/ses.js +7 -0
  96. package/dist/ses.js.map +1 -1
  97. package/dist/slack.cjs +32 -1
  98. package/dist/slack.cjs.map +1 -1
  99. package/dist/slack.d.cts +1 -1
  100. package/dist/slack.d.ts +1 -1
  101. package/dist/slack.js +32 -1
  102. package/dist/slack.js.map +1 -1
  103. package/dist/sqlite.d.cts +1 -1
  104. package/dist/sqlite.d.ts +1 -1
  105. package/dist/twilio.cjs +7 -0
  106. package/dist/twilio.cjs.map +1 -1
  107. package/dist/twilio.d.cts +1 -1
  108. package/dist/twilio.d.ts +1 -1
  109. package/dist/twilio.js +7 -0
  110. package/dist/twilio.js.map +1 -1
  111. package/dist/{types-V8SN54o9.d.cts → types-D6t0Z846.d.cts} +8 -0
  112. package/dist/{types-V8SN54o9.d.ts → types-D6t0Z846.d.ts} +8 -0
  113. package/dist/webhook.cjs +11 -1
  114. package/dist/webhook.cjs.map +1 -1
  115. package/dist/webhook.d.cts +3 -2
  116. package/dist/webhook.d.ts +3 -2
  117. package/dist/webhook.js +11 -1
  118. package/dist/webhook.js.map +1 -1
  119. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -81,27 +81,31 @@ function readEnv(name) {
81
81
  return typeof process === "undefined" || !process.env ? void 0 : process.env[name];
82
82
  }
83
83
  function readSecretEnv(name) {
84
- const value = readEnv(name);
85
- return value === void 0 || value.trim() === "" ? void 0 : value;
84
+ const value = readEnv(name)?.trim();
85
+ return value === void 0 || value === "" ? void 0 : value;
86
86
  }
87
87
  function secretOption(value, what) {
88
88
  if (value === void 0 || value === null) return value;
89
89
  if (typeof value !== "string") {
90
90
  throw new TypeError(`${what} must be a string, or null to opt out, not ${Array.isArray(value) ? "an array" : typeof value}`);
91
91
  }
92
- return value.trim() === "" ? void 0 : value;
92
+ const trimmed = value.trim();
93
+ return trimmed === "" ? void 0 : trimmed;
93
94
  }
94
- function environment() {
95
- if (typeof process === "undefined" || !process.env) return "";
96
- for (const value of [process.env.CRONWATCH_ENV, process.env.APP_ENV, process.env.NODE_ENV]) {
95
+ function environmentOf(values) {
96
+ for (const value of values) {
97
97
  const name = typeof value === "string" ? value.trim().toLowerCase() : "";
98
98
  if (name === "") continue;
99
99
  if (name === "prod") return "production";
100
- if (name === "dev" || name === "local" || name === "test" || name === "testing") return "development";
100
+ if (name === "dev" || name === "local") return "development";
101
101
  return name;
102
102
  }
103
103
  return "";
104
104
  }
105
+ function environment() {
106
+ if (typeof process === "undefined" || !process.env) return "";
107
+ return environmentOf([process.env.CRONWATCH_ENV, process.env.APP_ENV, process.env.NODE_ENV]);
108
+ }
105
109
  function isDevelopment() {
106
110
  return environment() === "development";
107
111
  }
@@ -328,7 +332,7 @@ function isObject(value) {
328
332
  }
329
333
  function normalizeState(state, job) {
330
334
  if (!isObject(state)) return emptyState(job);
331
- const { sending, underFloor, ...rest } = state;
335
+ const { sending, underFloor, pendingRuns, ...rest } = state;
332
336
  const open = isObject(state.open) ? Object.fromEntries(Object.entries(state.open).filter(([, at]) => typeof at === "number")) : {};
333
337
  const number = (value) => typeof value === "number" ? value : null;
334
338
  const list = (value) => Array.isArray(value) ? value : [];
@@ -342,7 +346,8 @@ function normalizeState(state, job) {
342
346
  pendingRecovery: list(state.pendingRecovery).filter((c) => typeof c === "string"),
343
347
  undelivered: list(state.undelivered).filter((alert) => isObject(alert)),
344
348
  ...Array.isArray(sending) && sending.length > 0 ? { sending: [...sending] } : {},
345
- ...Array.isArray(underFloor) && underFloor.some((m) => typeof m === "string") ? { underFloor: underFloor.filter((m) => typeof m === "string") } : {}
349
+ ...Array.isArray(underFloor) && underFloor.some((m) => typeof m === "string") ? { underFloor: underFloor.filter((m) => typeof m === "string") } : {},
350
+ ...Array.isArray(pendingRuns) && pendingRuns.some((id) => typeof id === "string") ? { pendingRuns: pendingRuns.filter((id) => typeof id === "string") } : {}
346
351
  };
347
352
  }
348
353
  var MAX_UNDELIVERED = 20;
@@ -392,6 +397,34 @@ function recordSent(state, delivered, failed, stale, now) {
392
397
  if (delivered.length > 0) next.lastAlertAt = now;
393
398
  return queueUndelivered(next, failed);
394
399
  }
400
+ function claimUndelivered(state, now, until) {
401
+ const next = cloneState(state);
402
+ const queue = next.undelivered;
403
+ const stale = queue.filter((alert) => staleAlert(alert, next));
404
+ const silenced = isSilenced(next, now);
405
+ const room = Math.max(0, MAX_UNDELIVERED - (next.sending ?? []).length);
406
+ const claimed = queue.filter((alert) => !stale.includes(alert) && (!silenced || alert.type === "recovered")).slice(0, room);
407
+ if (stale.length === 0 && claimed.length === 0) return { state, claimed, stale };
408
+ next.undelivered = queue.filter((alert) => !stale.includes(alert) && !claimed.includes(alert));
409
+ if (claimed.length > 0) next.sending = [...next.sending ?? [], ...claimed.map((alert) => ({ until, alert }))];
410
+ return { state: next, claimed, stale };
411
+ }
412
+ function holdRun(state, runId) {
413
+ const next = cloneState(state);
414
+ const held = (next.pendingRuns ?? []).filter((id) => id !== runId);
415
+ held.push(runId);
416
+ next.pendingRuns = held.slice(-100);
417
+ return next;
418
+ }
419
+ function releaseRun(state, runId) {
420
+ const held = state.pendingRuns ?? [];
421
+ if (!held.includes(runId)) return state;
422
+ const next = cloneState(state);
423
+ const left = held.filter((id) => id !== runId);
424
+ if (left.length > 0) next.pendingRuns = left;
425
+ else delete next.pendingRuns;
426
+ return next;
427
+ }
395
428
  function cloneState(state) {
396
429
  return normalizeState(state, state.job);
397
430
  }
@@ -572,7 +605,7 @@ function isSilenced(state, now) {
572
605
  }
573
606
  function applySilence(previous, evaluation, now) {
574
607
  if (!isSilenced(previous, now)) return evaluation;
575
- return { state: muteOpens(previous, evaluation.state), alerts: [] };
608
+ return { state: muteOpens(previous, evaluation.state), alerts: evaluation.alerts.filter((alert) => alert.type === "recovered") };
576
609
  }
577
610
  function staleAlert(alert, state) {
578
611
  if (alert.type === "recovered") {
@@ -729,10 +762,10 @@ function json(body, status = 200, headers = {}) {
729
762
  headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...headers }
730
763
  });
731
764
  }
732
- function constantTimeEqual(a, b) {
733
- if (a.length !== b.length) return false;
734
- let diff = 0;
735
- for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
765
+ function constantTimeEqual(sent, secret) {
766
+ if (secret.length === 0) return sent.length === 0;
767
+ let diff = sent.length ^ secret.length;
768
+ for (let i = 0; i < sent.length; i++) diff |= sent.charCodeAt(i) ^ secret.charCodeAt(i % secret.length);
736
769
  return diff === 0;
737
770
  }
738
771
 
@@ -1045,7 +1078,7 @@ function staticAsset(path, base) {
1045
1078
  }
1046
1079
 
1047
1080
  // src/version.ts
1048
- var VERSION = "0.12.5";
1081
+ var VERSION = "0.13.1";
1049
1082
  var API_VERSION = 1;
1050
1083
 
1051
1084
  // src/routes/timeline.ts
@@ -1832,23 +1865,63 @@ function stripBase(pathname, base) {
1832
1865
  if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
1833
1866
  return path;
1834
1867
  }
1868
+ var MAX_REQUEST_BODY = 1 << 20;
1869
+ var TOO_LARGE = /* @__PURE__ */ Symbol("too large");
1870
+ async function readLimited(request) {
1871
+ const length = Number(request.headers.get("content-length"));
1872
+ if (Number.isFinite(length) && length > MAX_REQUEST_BODY) return TOO_LARGE;
1873
+ if (!request.body) return new Uint8Array(new ArrayBuffer(0));
1874
+ const reader = request.body.getReader();
1875
+ const chunks = [];
1876
+ let size = 0;
1877
+ try {
1878
+ for (; ; ) {
1879
+ const { done, value } = await reader.read();
1880
+ if (done) break;
1881
+ size += value.byteLength;
1882
+ if (size > MAX_REQUEST_BODY) {
1883
+ await reader.cancel().catch(() => {
1884
+ });
1885
+ return TOO_LARGE;
1886
+ }
1887
+ chunks.push(value);
1888
+ }
1889
+ } finally {
1890
+ reader.releaseLock();
1891
+ }
1892
+ const out = new Uint8Array(new ArrayBuffer(size));
1893
+ let at = 0;
1894
+ for (const chunk of chunks) {
1895
+ out.set(chunk, at);
1896
+ at += chunk.byteLength;
1897
+ }
1898
+ return out;
1899
+ }
1835
1900
  async function readBody(request) {
1836
1901
  const type = request.headers.get("content-type") ?? "";
1902
+ const json2 = type.includes("application/json");
1903
+ const form = type.includes("application/x-www-form-urlencoded") || type.includes("multipart/form-data");
1904
+ if (!json2 && !form) return {};
1905
+ let bytes;
1906
+ try {
1907
+ bytes = await readLimited(request);
1908
+ } catch {
1909
+ return {};
1910
+ }
1911
+ if (bytes === TOO_LARGE) return TOO_LARGE;
1912
+ const copy = new Request(request.url, { method: "POST", headers: { "content-type": type }, body: bytes });
1837
1913
  try {
1838
- if (type.includes("application/json")) {
1839
- const data = await request.json();
1914
+ if (json2) {
1915
+ const data = await copy.json();
1840
1916
  return typeof data === "object" && data !== null ? Object.fromEntries(Object.entries(data).map(([k, v]) => [k, String(v)])) : {};
1841
1917
  }
1842
- if (type.includes("application/x-www-form-urlencoded") || type.includes("multipart/form-data")) {
1843
- const form = await request.formData();
1844
- const out = {};
1845
- for (const [k, v] of form.entries()) out[k] = String(v);
1846
- return out;
1847
- }
1918
+ const fields = await copy.formData();
1919
+ const out = {};
1920
+ for (const [k, v] of fields.entries()) out[k] = String(v);
1921
+ return out;
1848
1922
  } catch {
1849
1923
  return {};
1850
1924
  }
1851
- return {};
1852
1925
  }
1853
1926
  function crossSite(request, publicOrigin) {
1854
1927
  const origin = request.headers.get("origin");
@@ -1912,7 +1985,8 @@ function buildRoutes(cw, options = {}) {
1912
1985
  };
1913
1986
  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);
1914
1987
  if (token && method === "POST" && path === "/signin") {
1915
- const sent = (await readBody(request)).token;
1988
+ const body = await readBody(request);
1989
+ const sent = body === TOO_LARGE ? void 0 : body.token;
1916
1990
  if (sent === void 0 || !constantTimeEqual(sent, token)) return signInPage();
1917
1991
  return redirect(signInReturn(request.headers.get("referer"), publicOrigin, base), { "set-cookie": await signInCookie() });
1918
1992
  }
@@ -1928,7 +2002,7 @@ function buildRoutes(cw, options = {}) {
1928
2002
  }
1929
2003
  if (query !== null) {
1930
2004
  url.searchParams.delete("token");
1931
- return redirect(url.pathname + (url.search || ""), { "set-cookie": await signInCookie() });
2005
+ return redirect(url.pathname.replace(/^\/+/, "/") + (url.search || ""), { "set-cookie": await signInCookie() });
1932
2006
  }
1933
2007
  }
1934
2008
  const redirectBack = () => {
@@ -1967,9 +2041,11 @@ function buildRoutes(cw, options = {}) {
1967
2041
  if (parts[2] !== "silence" && parts[2] !== "unsilence") return html(messagePage("Not found", path, base), 404);
1968
2042
  if (!await cw.jobSummary(name)) return html(messagePage("No such job", `${name} is not in the store.`, base), 404);
1969
2043
  if (parts[2] === "silence") {
2044
+ const body = await readBody(request);
2045
+ if (body === TOO_LARGE) return html(messagePage("Not silenced", "The request was too large.", base), 413);
1970
2046
  let duration;
1971
2047
  try {
1972
- duration = silenceDuration((await readBody(request)).for);
2048
+ duration = silenceDuration(body.for);
1973
2049
  } catch (e) {
1974
2050
  return html(messagePage("Not silenced", e.message, base), 400);
1975
2051
  }
@@ -2003,6 +2079,7 @@ function buildRoutes(cw, options = {}) {
2003
2079
  if (!await cw.jobSummary(name)) return api({ ok: false, error: "No such job" }, 404);
2004
2080
  if (rest[2] === "silence") {
2005
2081
  const body = await readBody(request);
2082
+ if (body === TOO_LARGE) return api({ ok: false, error: "Request body too large" }, 413);
2006
2083
  let duration;
2007
2084
  try {
2008
2085
  duration = silenceDuration(body.for ?? url.searchParams.get("for"));
@@ -2252,6 +2329,11 @@ var Cronwatch = class {
2252
2329
  deferDelivery;
2253
2330
  defaults;
2254
2331
  definitions = /* @__PURE__ */ new Map();
2332
+ /**
2333
+ * Declarations forgotten here (forget()), kept so that run() or resumeRun()
2334
+ * by name declares the job again as it was written, not bare.
2335
+ */
2336
+ forgotten = /* @__PURE__ */ new Map();
2255
2337
  synced = /* @__PURE__ */ new Set();
2256
2338
  /** The tail of each job's queue of state updates. See serial(). */
2257
2339
  queues = /* @__PURE__ */ new Map();
@@ -2306,13 +2388,26 @@ var Cronwatch = class {
2306
2388
  const definition = { ...this.defaults, ...options, name };
2307
2389
  validateDefinition(definition);
2308
2390
  this.definitions.set(name, definition);
2391
+ this.forgotten.delete(name);
2309
2392
  this.synced.delete(name);
2310
2393
  return this.handle(definition);
2311
2394
  }
2395
+ /** The declaration of `name`: the standing one, else one forgotten here, declared again as it was. */
2396
+ declared(name) {
2397
+ const standing = this.definitions.get(name);
2398
+ if (standing) return standing;
2399
+ const forgotten = this.forgotten.get(name);
2400
+ if (!forgotten) return void 0;
2401
+ this.forgotten.delete(name);
2402
+ this.definitions.set(name, forgotten);
2403
+ this.synced.delete(name);
2404
+ return forgotten;
2405
+ }
2312
2406
  run(name, optionsOrFn, maybeFn) {
2313
2407
  const fn = typeof optionsOrFn === "function" ? optionsOrFn : maybeFn;
2314
2408
  const options = typeof optionsOrFn === "function" ? void 0 : optionsOrFn;
2315
- const handle = options || !this.definitions.has(name) ? this.job(name, options) : this.handle(this.definitions.get(name));
2409
+ const declared = options ? void 0 : this.declared(name);
2410
+ const handle = declared ? this.handle(declared) : this.job(name, options);
2316
2411
  return handle.run(fn);
2317
2412
  }
2318
2413
  /** The definitions declared in this process. */
@@ -2321,7 +2416,7 @@ var Cronwatch = class {
2321
2416
  }
2322
2417
  /** A handle on a run started elsewhere, as job(name).resume(runId). The job must be declared in this process. */
2323
2418
  resumeRun(name, runId) {
2324
- const definition = this.definitions.get(name);
2419
+ const definition = this.declared(name);
2325
2420
  if (!definition) return Promise.reject(new Error(`resumeRun: job "${name}" is not declared; call job() first`));
2326
2421
  return this.resumeHandle(definition, runId);
2327
2422
  }
@@ -2537,7 +2632,7 @@ var Cronwatch = class {
2537
2632
  } catch (e) {
2538
2633
  this.onError(e, `recording ${name}`);
2539
2634
  }
2540
- const started = recorded ? this.updateState(name, (before) => ({ state: onRunStart(before), result: void 0 })).then(() => {
2635
+ const started = recorded ? this.updateState(name, (before) => ({ state: holdRun(onRunStart(before), run.id), result: void 0 })).then(() => {
2541
2636
  }, (e) => this.onError(e, `starting ${name}`)) : Promise.resolve();
2542
2637
  const recorder = createRecorder(run);
2543
2638
  const clearTimer = after(timeoutMs(definition), () => recorder.abort());
@@ -2693,7 +2788,7 @@ ${id}`;
2693
2788
  this.onError(e, `recording ${name}`);
2694
2789
  }
2695
2790
  if (recorded) {
2696
- await this.updateState(name, (before) => ({ state: onRunStart(before), result: void 0 })).catch((e) => this.onError(e, `starting ${name}`));
2791
+ await this.updateState(name, (before) => ({ state: holdRun(onRunStart(before), run.id), result: void 0 })).catch((e) => this.onError(e, `starting ${name}`));
2697
2792
  }
2698
2793
  return this.runHandle(definition, run.id, run, recorded, null);
2699
2794
  }
@@ -2906,7 +3001,7 @@ ${id}`;
2906
3001
  throw e;
2907
3002
  }
2908
3003
  if (!evaluate) return [];
2909
- await this.updateState(run.job, (before) => ({ state: onRunStart(before), result: void 0 }));
3004
+ await this.updateState(run.job, (before) => ({ state: holdRun(onRunStart(before), run.id), result: void 0 }));
2910
3005
  if (run.status === "running") return [];
2911
3006
  return this.finishRun(definition, run, this.now());
2912
3007
  }
@@ -2922,7 +3017,11 @@ ${id}`;
2922
3017
  this.report(new Error(`run ${run.id} of ${run.job} ${claim.ignored}; ignored`), `recording ${run.job}`);
2923
3018
  return [];
2924
3019
  }
2925
- if (!evaluate || claim.lateAfterTimeout && run.status !== "ok") return [];
3020
+ if (!evaluate) {
3021
+ await this.updateState(run.job, (current) => ({ state: releaseRun(current, run.id), result: void 0 })).catch((e) => this.onError(e, `recording ${run.job}`));
3022
+ return [];
3023
+ }
3024
+ if (claim.lateAfterTimeout && run.status !== "ok") return [];
2926
3025
  return this.finishRun(definition, run, this.now());
2927
3026
  }
2928
3027
  /**
@@ -2936,7 +3035,8 @@ ${id}`;
2936
3035
  let history = null;
2937
3036
  ({ result: held } = await this.updateState(run.job, async (previous) => {
2938
3037
  history ??= await this.history(run);
2939
- return this.outbox(applySilence(previous, onRunFinish(definition, run, previous, history, now), now), definition, now);
3038
+ const before = releaseRun(previous, run.id);
3039
+ return this.outbox(applySilence(before, onRunFinish(definition, run, before, history, now), now), definition, now);
2940
3040
  }));
2941
3041
  } catch (e) {
2942
3042
  this.onError(e, `evaluating ${run.job}`);
@@ -3041,6 +3141,7 @@ ${id}`;
3041
3141
  this.reportDropped(stored.name, held.dropped);
3042
3142
  alerts.push(...await this.retryUndelivered(stored.name, state, now, retries));
3043
3143
  alerts.push(...await this.dispatch(stored.name, held.alerts, now));
3144
+ alerts.push(...await this.evaluatePending(stored, recent, state, now));
3044
3145
  jobs.push(summarize(stored, recent, state, nextExpectedAt, now));
3045
3146
  } catch (e) {
3046
3147
  this.onError(e, `checking ${stored.name}`);
@@ -3058,6 +3159,32 @@ ${id}`;
3058
3159
  }
3059
3160
  return { checkedAt: now, jobs, alerts, pruned };
3060
3161
  }
3162
+ /**
3163
+ * Runs the job's state still holds as pending (holdRun) whose finish was
3164
+ * written but never evaluated: the process that finished them stopped, or
3165
+ * its state write failed, between the two writes. Each one finished more
3166
+ * than SEND_LEASE_MS ago is evaluated now, as its finish would have been;
3167
+ * one still running is left to the stuck check, and one the store no
3168
+ * longer has (given back, or pruned) is let go. The wait leaves a finish
3169
+ * under way in another process to write its own evaluation.
3170
+ */
3171
+ async evaluatePending(stored, recent, state, now) {
3172
+ const alerts = [];
3173
+ for (const id of state.pendingRuns ?? []) {
3174
+ try {
3175
+ const run = recent.find((r) => r.id === id) ?? await this.store.getRun(id);
3176
+ if (!run || run.job !== stored.name) {
3177
+ await this.updateState(stored.name, (current) => ({ state: releaseRun(current, id), result: void 0 }));
3178
+ continue;
3179
+ }
3180
+ 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));
3182
+ } catch (e) {
3183
+ this.onError(e, `evaluating run ${id} of ${stored.name}`);
3184
+ }
3185
+ }
3186
+ return alerts;
3187
+ }
3061
3188
  /** A job's summary and its newest runs, without alerting. A job that cannot be evaluated is reported and shown as failing. */
3062
3189
  async snapshot(stored, now, runs) {
3063
3190
  let recent = [];
@@ -3136,11 +3263,15 @@ ${id}`;
3136
3263
  }
3137
3264
  /**
3138
3265
  * Remove a job and its runs from the store. A job still declared in code
3139
- * comes back: here on its next run, and in any other process that declares
3140
- * it on its next run there, or at that process's next check or dashboard read.
3266
+ * comes back, with no history: here on its next run, declared as it was
3267
+ * written (cw.run() and cw.resumeRun() by name included), and in any other
3268
+ * process that declares it on its next run there, or at that process's
3269
+ * next check or dashboard read.
3141
3270
  */
3142
3271
  async forget(name) {
3143
3272
  await this.ensureReady();
3273
+ const definition = this.definitions.get(name);
3274
+ if (definition) this.forgotten.set(name, definition);
3144
3275
  this.definitions.delete(name);
3145
3276
  this.synced.delete(name);
3146
3277
  await this.store.deleteJob(name);
@@ -3225,27 +3356,40 @@ ${id}`;
3225
3356
  }
3226
3357
  /**
3227
3358
  * Send the alerts that no channel accepted last time, once each, oldest
3228
- * first. `state` is the job's state as this check left it: an alert that
3229
- * no longer describes it (staleAlert) is dropped instead. Retries across a
3230
- * check share RETRY_BUDGET_MS of wall-clock time; once it is spent the rest
3231
- * stay queued for the next check.
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.
3232
3365
  */
3233
3366
  async retryUndelivered(name, state, now, budget) {
3234
- const pending = state.undelivered ?? [];
3235
- if (pending.length === 0 || isSilenced(state, now) || this.deferDelivery) return [];
3367
+ if ((state.undelivered ?? []).length === 0 || this.deferDelivery) return [];
3368
+ let claimed;
3369
+ try {
3370
+ ({ result: claimed } = await this.updateState(name, (previous) => {
3371
+ const claim = claimUndelivered(previous, now, this.now() + SEND_LEASE_MS);
3372
+ return { state: claim.state, result: claim.claimed };
3373
+ }));
3374
+ } catch (e) {
3375
+ this.onError(e, `claiming undelivered alerts for ${name}`);
3376
+ return [];
3377
+ }
3378
+ if (claimed.length === 0) return [];
3236
3379
  const delivered = [];
3237
3380
  const failed = [];
3238
- const dropped = pending.filter((alert) => staleAlert(alert, state));
3239
- for (const alert of pending) {
3240
- if (dropped.includes(alert)) continue;
3381
+ for (const alert of claimed) {
3241
3382
  const left = RETRY_BUDGET_MS - budget.spentMs;
3242
- if (left <= 0) break;
3383
+ if (left <= 0) {
3384
+ failed.push(alert);
3385
+ continue;
3386
+ }
3243
3387
  const started = Date.now();
3244
3388
  if (this.triage && alert.type !== "recovered" && alert.triage === void 0) await this.addTriage(alert, Math.min(TRIAGE_TIMEOUT_MS, left));
3245
3389
  (await this.deliver(alert) ? delivered : failed).push(alert);
3246
3390
  budget.spentMs += Math.max(0, Date.now() - started);
3247
3391
  }
3248
- await this.recordDelivery(name, delivered, failed, dropped, now);
3392
+ await this.recordDelivery(name, delivered, failed, [], now);
3249
3393
  return delivered;
3250
3394
  }
3251
3395
  /**