@cronwatch/sdk 0.1.0 → 0.3.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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -12
  3. package/dist/anthropic.cjs +19 -5
  4. package/dist/anthropic.cjs.map +1 -1
  5. package/dist/anthropic.d.cts +1 -1
  6. package/dist/anthropic.d.ts +1 -1
  7. package/dist/anthropic.js +19 -5
  8. package/dist/anthropic.js.map +1 -1
  9. package/dist/discord.cjs +14 -2
  10. package/dist/discord.cjs.map +1 -1
  11. package/dist/discord.d.cts +6 -2
  12. package/dist/discord.d.ts +6 -2
  13. package/dist/discord.js +13 -3
  14. package/dist/discord.js.map +1 -1
  15. package/dist/index.cjs +676 -285
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.d.cts +100 -44
  18. package/dist/index.d.ts +100 -44
  19. package/dist/index.js +677 -283
  20. package/dist/index.js.map +1 -1
  21. package/dist/postgres.cjs +140 -73
  22. package/dist/postgres.cjs.map +1 -1
  23. package/dist/postgres.d.cts +2 -2
  24. package/dist/postgres.d.ts +2 -2
  25. package/dist/postgres.js +140 -73
  26. package/dist/postgres.js.map +1 -1
  27. package/dist/slack.cjs +12 -6
  28. package/dist/slack.cjs.map +1 -1
  29. package/dist/slack.d.cts +1 -1
  30. package/dist/slack.d.ts +1 -1
  31. package/dist/slack.js +12 -6
  32. package/dist/slack.js.map +1 -1
  33. package/dist/sqlite.cjs +142 -71
  34. package/dist/sqlite.cjs.map +1 -1
  35. package/dist/sqlite.d.cts +3 -1
  36. package/dist/sqlite.d.ts +3 -1
  37. package/dist/sqlite.js +143 -72
  38. package/dist/sqlite.js.map +1 -1
  39. package/dist/{types-BQ1P8z55.d.cts → types-C1PyRjI5.d.cts} +63 -5
  40. package/dist/{types-BQ1P8z55.d.ts → types-C1PyRjI5.d.ts} +63 -5
  41. package/dist/webhook.cjs +10 -2
  42. package/dist/webhook.cjs.map +1 -1
  43. package/dist/webhook.d.cts +1 -1
  44. package/dist/webhook.d.ts +1 -1
  45. package/dist/webhook.js +10 -2
  46. package/dist/webhook.js.map +1 -1
  47. package/package.json +95 -32
package/dist/index.cjs CHANGED
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  var croner = require('croner');
4
+ var crypto$1 = require('crypto');
4
5
 
5
6
  // src/duration.ts
6
7
  var UNIT_MS = {
@@ -55,6 +56,8 @@ function formatRelative(at, now) {
55
56
  return diff < 0 ? `${text} ago` : `in ${text}`;
56
57
  }
57
58
  var cache = /* @__PURE__ */ new Map();
59
+ var crons = /* @__PURE__ */ new WeakMap();
60
+ var EARLY_SLACK_MS = 6e4;
58
61
  function parseSchedule(schedule, timezone) {
59
62
  const key = `${timezone ?? ""}|${schedule}`;
60
63
  const hit = cache.get(key);
@@ -73,58 +76,94 @@ function parseSchedule(schedule, timezone) {
73
76
  } catch (error) {
74
77
  throw new Error(`schedule "${schedule}" is not a cron expression or "every <duration>": ${error.message}`);
75
78
  }
76
- parsed = { kind: "cron", source: text, cron };
79
+ parsed = { kind: "cron", source: text, ...timezone ? { timezone } : {} };
80
+ crons.set(parsed, cron);
77
81
  }
78
82
  cache.set(key, parsed);
79
83
  return parsed;
80
84
  }
85
+ function fireAfter(parsed, from) {
86
+ const cron = crons.get(parsed);
87
+ if (!cron) throw new Error(`schedule "${parsed.source}" was not made by parseSchedule`);
88
+ let probe = from;
89
+ for (let attempt = 0; attempt < 4; attempt++) {
90
+ const runs = cron.nextRuns(8, new Date(probe));
91
+ if (runs.length === 0) return null;
92
+ const found = runs.find((d) => d.getTime() > from);
93
+ if (found) return found.getTime();
94
+ probe += 36e5;
95
+ }
96
+ return null;
97
+ }
81
98
  function nextFire(parsed, from, lastRunAt) {
82
99
  if (parsed.kind === "interval") {
83
100
  const base = lastRunAt ?? from;
84
101
  return base + parsed.everyMs;
85
102
  }
86
- const next = parsed.cron.nextRun(new Date(from));
87
- return next ? next.getTime() : null;
88
- }
89
- function previousFire(parsed, now) {
90
- if (parsed.kind !== "cron") throw new Error("previousFire is for cron schedules");
91
- const cron = parsed.cron;
92
- const fireAtOrBefore = (from) => {
93
- const next = cron.nextRun(new Date(from - 1));
94
- if (!next) return null;
95
- const t = next.getTime();
96
- return t <= now ? t : null;
97
- };
98
- const YEAR = 366 * 864e5;
99
- let step = 6e4;
100
- let lo = null;
101
- while (step <= YEAR) {
102
- if (fireAtOrBefore(now - step) !== null) {
103
- lo = now - step;
104
- break;
105
- }
106
- step *= 2;
107
- }
108
- if (lo === null) return null;
109
- let hi = now;
110
- while (hi - lo > 1) {
111
- const mid = Math.floor((lo + hi) / 2);
112
- if (fireAtOrBefore(mid) !== null) lo = mid;
113
- else hi = mid;
114
- }
115
- return fireAtOrBefore(lo);
103
+ return fireAfter(parsed, from);
116
104
  }
117
- function expectation(parsed, now, lastRunAt, registeredAt, graceMs2) {
105
+ function expectation(parsed, lastRunAt, registeredAt, graceMs2) {
106
+ let dueAt;
118
107
  if (parsed.kind === "interval") {
119
- const dueAt = (lastRunAt ?? registeredAt) + parsed.everyMs;
120
- return { dueAt, deadline: dueAt + graceMs2 };
108
+ dueAt = (lastRunAt ?? registeredAt) + parsed.everyMs;
109
+ } else if (lastRunAt === null) {
110
+ dueAt = fireAfter(parsed, registeredAt - 1);
111
+ } else {
112
+ dueAt = dueAfterRun(parsed, lastRunAt);
113
+ }
114
+ return dueAt === null ? null : { dueAt, deadline: dueAt + graceMs2 };
115
+ }
116
+ function dueAfterRun(parsed, startedAt) {
117
+ const next = fireAfter(parsed, startedAt);
118
+ if (next === null) return null;
119
+ const following = fireAfter(parsed, next);
120
+ const covers = runCovers(startedAt, next, following) || inSpringForwardGap(parsed, startedAt, next);
121
+ return covers ? following : next;
122
+ }
123
+ function runCovers(startedAt, dueAt, followingAt = null) {
124
+ const slack = followingAt === null ? EARLY_SLACK_MS : Math.min(EARLY_SLACK_MS, Math.floor((followingAt - dueAt) / 2));
125
+ return startedAt >= dueAt - slack;
126
+ }
127
+ function inSpringForwardGap(parsed, startedAt, fireAt) {
128
+ const LOOKBACK = 3 * 36e5;
129
+ const after = utcOffset(fireAt, parsed.timezone);
130
+ const before = utcOffset(fireAt - LOOKBACK, parsed.timezone);
131
+ const gap = after - before;
132
+ if (gap <= 0) return false;
133
+ let lo = fireAt - LOOKBACK;
134
+ let hi = fireAt;
135
+ while (hi - lo > 6e4) {
136
+ const mid = lo + Math.floor((hi - lo) / 2);
137
+ if (utcOffset(mid, parsed.timezone) === after) hi = mid;
138
+ else lo = mid;
121
139
  }
122
- const prev = previousFire(parsed, now);
123
- if (prev === null || prev < registeredAt) return null;
124
- return { dueAt: prev, deadline: prev + graceMs2 };
140
+ const jumpAt = Math.floor(hi / 6e4) * 6e4;
141
+ if (fireAt - jumpAt >= gap || startedAt < jumpAt - EARLY_SLACK_MS || startedAt >= fireAt) return false;
142
+ return fireAfter(parsed, jumpAt - 1) === fireAt;
125
143
  }
126
- function runCovers(startedAt, dueAt) {
127
- return startedAt >= dueAt - 6e4;
144
+ var formatters = /* @__PURE__ */ new Map();
145
+ function utcOffset(at, timezone) {
146
+ const key = timezone ?? "";
147
+ let format = formatters.get(key);
148
+ if (!format) {
149
+ format = new Intl.DateTimeFormat("en-US", {
150
+ ...timezone ? { timeZone: timezone } : {},
151
+ hourCycle: "h23",
152
+ year: "numeric",
153
+ month: "numeric",
154
+ day: "numeric",
155
+ hour: "numeric",
156
+ minute: "numeric",
157
+ second: "numeric"
158
+ });
159
+ formatters.set(key, format);
160
+ }
161
+ const parts = {};
162
+ for (const part of format.formatToParts(new Date(at))) {
163
+ if (part.type !== "literal") parts[part.type] = Number(part.value);
164
+ }
165
+ const wall = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
166
+ return wall - Math.floor(at / 1e3) * 1e3;
128
167
  }
129
168
 
130
169
  // src/stats.ts
@@ -148,10 +187,20 @@ var SLOW_FLOOR_MS = 1e4;
148
187
  var BASELINE_MIN_RUNS = 5;
149
188
  var BASELINE_WINDOW = 20;
150
189
  function emptyState(job) {
151
- return { job, open: {}, consecutiveFailures: 0, silencedUntil: null, lastAlertAt: null };
190
+ return { job, open: {}, consecutiveFailures: 0, silencedUntil: null, lastAlertAt: null, pendingRecovery: [], undelivered: [] };
191
+ }
192
+ function normalizeState(state, job) {
193
+ if (!state) return emptyState(job);
194
+ return {
195
+ ...emptyState(job),
196
+ ...state,
197
+ open: { ...state.open },
198
+ pendingRecovery: [...state.pendingRecovery ?? []],
199
+ undelivered: [...state.undelivered ?? []]
200
+ };
152
201
  }
153
202
  function cloneState(state) {
154
- return { ...state, open: { ...state.open } };
203
+ return normalizeState(state, state.job);
155
204
  }
156
205
  function openCondition(state, condition, now) {
157
206
  if (state.open[condition] !== void 0) return false;
@@ -161,6 +210,8 @@ function openCondition(state, condition, now) {
161
210
  function closeCondition(state, condition) {
162
211
  if (state.open[condition] === void 0) return false;
163
212
  delete state.open[condition];
213
+ const pending = state.pendingRecovery ??= [];
214
+ if (!pending.includes(condition)) pending.push(condition);
164
215
  return true;
165
216
  }
166
217
  function openConditions(state) {
@@ -198,25 +249,27 @@ function budgetBreaches(def, run, history) {
198
249
  }
199
250
  return breaches;
200
251
  }
252
+ function hasFullBaseline(history) {
253
+ return history.filter((r) => r.status === "ok").length >= BASELINE_WINDOW;
254
+ }
201
255
  function formatNumber(n) {
202
256
  if (Number.isInteger(n)) return n.toLocaleString("en-US");
203
257
  return n.toLocaleString("en-US", { maximumFractionDigits: 4 });
204
258
  }
205
259
  function onRunStart(state) {
206
260
  const next = cloneState(state);
207
- const openBefore = openConditions(next);
208
261
  closeCondition(next, "missed");
209
262
  closeCondition(next, "stuck");
210
- return { state: next, openBefore };
263
+ return next;
211
264
  }
212
- function onRunFinish(def, run, state, history, openBefore, now) {
265
+ function onRunFinish(def, run, state, history, now) {
213
266
  const next = cloneState(state);
214
267
  const alerts = [];
215
268
  if (run.status === "ok") {
216
269
  next.consecutiveFailures = 0;
217
- closeCondition(next, "failed");
218
270
  closeCondition(next, "missed");
219
271
  closeCondition(next, "stuck");
272
+ closeCondition(next, "failed");
220
273
  const slow = slowThreshold(def, history);
221
274
  if (slow && run.durationMs !== null && run.durationMs > slow.thresholdMs) {
222
275
  if (openCondition(next, "slow", now)) {
@@ -233,9 +286,10 @@ function onRunFinish(def, run, state, history, openBefore, now) {
233
286
  } else {
234
287
  closeCondition(next, "over_budget");
235
288
  }
236
- const wasOpen = /* @__PURE__ */ new Set([...openBefore, ...openConditions(state)]);
237
- if (wasOpen.size > 0 && openConditions(next).length === 0) {
238
- alerts.push({ type: "recovered", run, details: { after: [...wasOpen] } });
289
+ const pending = next.pendingRecovery ?? [];
290
+ if (pending.length > 0 && openConditions(next).length === 0) {
291
+ alerts.push({ type: "recovered", run, details: { after: [...pending] } });
292
+ next.pendingRecovery = [];
239
293
  }
240
294
  return { state: next, alerts };
241
295
  }
@@ -256,26 +310,19 @@ function onCheck(def, stored, lastRun, state, now) {
256
310
  if (!def.schedule) return { state: next, alerts, nextExpectedAt: null, dueAt: null };
257
311
  const parsed = parseSchedule(def.schedule, def.timezone);
258
312
  const grace = graceMs(def);
259
- const exp = expectation(parsed, now, lastRun?.startedAt ?? null, stored.createdAt, grace);
260
- let nextExpectedAt = null;
261
- if (parsed.kind === "interval") {
262
- nextExpectedAt = (lastRun?.startedAt ?? stored.createdAt) + parsed.everyMs;
263
- } else {
264
- const upcoming = parsed.cron.nextRun(new Date(now));
265
- nextExpectedAt = upcoming ? upcoming.getTime() : null;
266
- }
313
+ const lastRunAt = lastRun?.startedAt ?? null;
314
+ const exp = expectation(parsed, lastRunAt, stored.createdAt, grace);
315
+ const nextExpectedAt = parsed.kind === "interval" ? nextFire(parsed, stored.createdAt, lastRunAt) : nextFire(parsed, now, null);
267
316
  if (!exp) return { state: next, alerts, nextExpectedAt, dueAt: null };
268
- const covered = parsed.kind === "cron" && lastRun !== null && runCovers(lastRun.startedAt, exp.dueAt);
269
- if (covered) {
270
- closeCondition(next, "missed");
271
- } else if (now > exp.deadline) {
317
+ if (parsed.kind === "interval" && lastRun?.status === "running") {
318
+ return { state: next, alerts, nextExpectedAt, dueAt: exp.dueAt };
319
+ }
320
+ if (now > exp.deadline) {
272
321
  if (openCondition(next, "missed", now)) {
273
- alerts.push({
274
- type: "missed",
275
- run: lastRun,
276
- details: { dueAt: exp.dueAt, deadline: exp.deadline, graceMs: grace, lastRunAt: lastRun?.startedAt ?? null }
277
- });
322
+ alerts.push({ type: "missed", run: lastRun, details: { dueAt: exp.dueAt, deadline: exp.deadline, graceMs: grace, lastRunAt } });
278
323
  }
324
+ } else {
325
+ closeCondition(next, "missed");
279
326
  }
280
327
  return { state: next, alerts, nextExpectedAt, dueAt: exp.dueAt };
281
328
  }
@@ -292,6 +339,37 @@ function muteOpens(previous, next) {
292
339
  function isSilenced(state, now) {
293
340
  return state.silencedUntil !== null && state.silencedUntil > now;
294
341
  }
342
+ function jobHealth(def, lastRun, state, now) {
343
+ const open = openConditions(state);
344
+ if (isSilenced(state, now)) return "silenced";
345
+ if (open.includes("stuck") || lastRun && isStuck(def, lastRun, now)) return "stuck";
346
+ if (open.includes("failed") || lastRun?.status === "failed" || lastRun?.status === "timeout") return "failing";
347
+ if (open.includes("missed")) return "late";
348
+ if (!lastRun) return "never_ran";
349
+ return "healthy";
350
+ }
351
+ function summarize(stored, recent, state, nextExpectedAt, now) {
352
+ const window = recent.slice(0, BASELINE_WINDOW);
353
+ const lastRun = window[0] ?? null;
354
+ const finished = window.filter((r) => r.status !== "running");
355
+ const okDurations = window.filter((r) => r.status === "ok" && r.durationMs !== null).map((r) => r.durationMs);
356
+ return {
357
+ name: stored.name,
358
+ definition: stored.definition,
359
+ health: jobHealth(stored.definition, lastRun, state, now),
360
+ open: openConditions(state),
361
+ lastRun,
362
+ nextExpectedAt,
363
+ consecutiveFailures: state.consecutiveFailures,
364
+ silencedUntil: state.silencedUntil,
365
+ stats: {
366
+ runs: finished.length,
367
+ okRate: finished.length === 0 ? 1 : finished.filter((r) => r.status === "ok").length / finished.length,
368
+ p50Ms: percentile(okDurations, 50),
369
+ p95Ms: percentile(okDurations, 95)
370
+ }
371
+ };
372
+ }
295
373
 
296
374
  // src/format.ts
297
375
  function when(at, now) {
@@ -307,14 +385,18 @@ function tail(text, n) {
307
385
  const lines = text.trimEnd().split("\n");
308
386
  return lines.slice(Math.max(0, lines.length - n)).join("\n");
309
387
  }
388
+ function errorLine(error) {
389
+ const text = firstLines(error, 4);
390
+ return /^[A-Za-z_$][\w$]*: /.test(text) ? text : `Error: ${text}`;
391
+ }
310
392
  function composeAlert(draft, def, now) {
311
393
  const name = def.name;
312
394
  const run = draft.run;
313
- const d = draft.details;
314
395
  let title;
315
396
  const lines = [];
316
397
  switch (draft.type) {
317
398
  case "missed": {
399
+ const d = draft.details;
318
400
  title = `${name} missed its scheduled run`;
319
401
  lines.push(`Due ${when(d.dueAt, now)}, and no run had started by ${when(d.deadline, now)} (grace ${formatDuration(d.graceMs)}).`);
320
402
  lines.push(`Schedule: ${def.schedule}${def.timezone ? ` (${def.timezone})` : ""}.`);
@@ -323,11 +405,11 @@ function composeAlert(draft, def, now) {
323
405
  }
324
406
  case "failed": {
325
407
  title = `${name} failed`;
326
- const n = d.consecutiveFailures;
408
+ const n = draft.details.consecutiveFailures;
327
409
  if (n > 1) lines.push(`${n} consecutive failures.`);
328
410
  if (run) {
329
411
  lines.push(`Started ${when(run.startedAt, now)}${run.durationMs !== null ? `, ran ${formatDuration(run.durationMs)}` : ""}.`);
330
- if (run.error) lines.push(`Error: ${firstLines(run.error, 4)}`);
412
+ if (run.error) lines.push(errorLine(run.error));
331
413
  const out = tail(run.output, 8);
332
414
  if (out) lines.push(`Output (tail):
333
415
  ${out}`);
@@ -346,6 +428,7 @@ ${out}`);
346
428
  break;
347
429
  }
348
430
  case "slow": {
431
+ const d = draft.details;
349
432
  title = `${name} was slow`;
350
433
  lines.push(`Took ${formatDuration(d.durationMs)}; the limit is ${formatDuration(d.thresholdMs)} (${d.basis}).`);
351
434
  if (run) lines.push(`Started ${when(run.startedAt, now)}.`);
@@ -353,7 +436,7 @@ ${out}`);
353
436
  }
354
437
  case "over_budget": {
355
438
  title = `${name} went over budget`;
356
- for (const b of d.breaches) {
439
+ for (const b of draft.details.breaches) {
357
440
  lines.push(`${b.metric}: ${formatNumber(b.value)}, limit ${formatNumber(b.limit)} (${b.basis}).`);
358
441
  }
359
442
  if (run) lines.push(`Started ${when(run.startedAt, now)}.`);
@@ -361,22 +444,13 @@ ${out}`);
361
444
  }
362
445
  case "recovered": {
363
446
  title = `${name} recovered`;
364
- const after = d.after.map((c) => c.replace("_", " ")).join(", ");
447
+ const after = draft.details.after.map((c) => c.replace("_", " ")).join(", ");
365
448
  lines.push(`A run ${run ? when(run.startedAt, now) : "just now"} succeeded${after ? ` after: ${after}` : ""}.`);
366
449
  if (run?.durationMs !== null && run?.durationMs !== void 0) lines.push(`Ran ${formatDuration(run.durationMs)}.`);
367
450
  break;
368
451
  }
369
452
  }
370
- return {
371
- type: draft.type,
372
- job: name,
373
- definition: def,
374
- run,
375
- title,
376
- message: lines.join("\n"),
377
- details: d,
378
- at: now
379
- };
453
+ return { ...draft, job: name, definition: def, title, message: lines.join("\n"), at: now };
380
454
  }
381
455
 
382
456
  // src/http.ts
@@ -386,6 +460,12 @@ function json(body, status = 200, headers = {}) {
386
460
  headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...headers }
387
461
  });
388
462
  }
463
+ function constantTimeEqual(a, b) {
464
+ if (a.length !== b.length) return false;
465
+ let diff = 0;
466
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
467
+ return diff === 0;
468
+ }
389
469
 
390
470
  // src/output.ts
391
471
  var OUTPUT_CAP = 16 * 1024;
@@ -394,18 +474,48 @@ function capOutput(text) {
394
474
  return "[earlier output trimmed]\n" + text.slice(text.length - OUTPUT_CAP);
395
475
  }
396
476
  function errorMessage(error) {
477
+ return capOutput(describeError(error));
478
+ }
479
+ function describeError(error) {
397
480
  if (error instanceof Error) {
398
- const stack = error.stack ? `
399
- ${error.stack.split("\n").slice(1, 6).join("\n")}` : "";
400
- return `${error.name}: ${error.message}${stack}`;
481
+ const frames = (error.stack ?? "").split("\n").filter((line) => /^\s+at /.test(line)).slice(0, 5);
482
+ return `${error.name}: ${error.message}${frames.length ? `
483
+ ${frames.join("\n")}` : ""}`;
401
484
  }
402
485
  if (typeof error === "string") return error;
403
486
  try {
404
- return JSON.stringify(error);
487
+ return JSON.stringify(error) ?? String(error);
405
488
  } catch {
406
489
  return String(error);
407
490
  }
408
491
  }
492
+ var REDACTED = "[redacted]";
493
+ var SECRET_PATTERNS = [
494
+ // password=..., API_KEY: ..., "client_secret": "...", TOKEN='...', token=... (but not max_tokens: 800).
495
+ // A quoted value is blanked to its closing quote, spaces and all, and keeps its quotes.
496
+ [
497
+ /\b([A-Za-z0-9_-]{0,40}(?:secret|token|passw(?:or)?d|pwd|api[_-]?key|access[_-]?key|private[_-]?key|credential)[A-Za-z0-9_-]{0,40}(?<![Tt][Oo][Kk][Ee][Nn][Ss])"?\s{0,3}[=:]\s{0,3})(?:(")[^"\n]{1,4096}"|(')[^'\n]{1,4096}'|["']?[^\s"',;&]{1,4096})/gi,
498
+ (_match, name, double, single) => {
499
+ const quote = double ?? single ?? "";
500
+ return `${name}${quote}${REDACTED}${quote}`;
501
+ }
502
+ ],
503
+ // Credentials inside a URL: postgres://user:password@host
504
+ [/(\b[a-z][a-z0-9+.-]{0,30}:\/\/[^\s/:@]{0,256}:)[^\s/@]{1,256}@/gi, `$1${REDACTED}@`],
505
+ // Authorization: Bearer <token>
506
+ [/\b(Bearer\s{1,3})[A-Za-z0-9._~+/=-]{8,4096}/g, `$1${REDACTED}`],
507
+ // Well-known token shapes: AWS, GitHub, Slack, Stripe, Anthropic and OpenAI style keys.
508
+ [/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, REDACTED],
509
+ [/\b(?:gh[pousr]_[A-Za-z0-9]{30,255}|github_pat_[A-Za-z0-9_]{20,255})\b/g, REDACTED],
510
+ [/\bxox[abposr]-[A-Za-z0-9-]{10,255}/g, REDACTED],
511
+ [/\b[rsp]k_(?:live|test)_[A-Za-z0-9]{10,255}\b/g, REDACTED],
512
+ [/\bsk-[A-Za-z0-9_-]{20,255}/g, REDACTED]
513
+ ];
514
+ function redactSecrets(text) {
515
+ let out = text;
516
+ for (const [pattern, replacement] of SECRET_PATTERNS) out = out.replace(pattern, replacement);
517
+ return out;
518
+ }
409
519
 
410
520
  // src/job.ts
411
521
  function stringify(part) {
@@ -420,6 +530,9 @@ function stringify(part) {
420
530
  function createRecorder(run) {
421
531
  const lines = [];
422
532
  let size = 0;
533
+ const head = [];
534
+ let headSize = 0;
535
+ let dropped = false;
423
536
  const metrics = {};
424
537
  const controller = new AbortController();
425
538
  const context = {
@@ -429,10 +542,15 @@ function createRecorder(run) {
429
542
  signal: controller.signal,
430
543
  log(...parts) {
431
544
  const line = parts.map(stringify).join(" ");
545
+ if (headSize < OUTPUT_CAP) {
546
+ head.push(line);
547
+ headSize += line.length + 1;
548
+ }
432
549
  lines.push(line);
433
550
  size += line.length + 1;
434
551
  while (size > 64 * 1024 && lines.length > 1) {
435
552
  size -= lines.shift().length + 1;
553
+ dropped = true;
436
554
  }
437
555
  },
438
556
  metric(name, value) {
@@ -448,6 +566,12 @@ function createRecorder(run) {
448
566
  return {
449
567
  context,
450
568
  output: () => lines.length === 0 ? null : capOutput(lines.join("\n")),
569
+ expectText: () => {
570
+ if (lines.length === 0) return null;
571
+ const all = lines.join("\n");
572
+ if (!dropped && all.length <= 2 * OUTPUT_CAP) return all;
573
+ return head.join("\n").slice(0, OUTPUT_CAP) + "\n" + all.slice(-OUTPUT_CAP);
574
+ },
451
575
  metrics: () => ({ ...metrics }),
452
576
  abort: () => controller.abort()
453
577
  };
@@ -469,6 +593,7 @@ function checkExpectation(expect, output) {
469
593
  return text.includes(expect) ? null : `Output did not contain ${JSON.stringify(expect)}`;
470
594
  }
471
595
  if (expect instanceof RegExp) {
596
+ expect.lastIndex = 0;
472
597
  return expect.test(text) ? null : `Output did not match ${expect.toString()}`;
473
598
  }
474
599
  let ok = false;
@@ -513,6 +638,8 @@ button:hover{border-color:var(--muted)}
513
638
  .stat{padding:12px 14px}.stat .k{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}.stat .v{font-size:20px;font-weight:600;margin-top:2px}
514
639
  pre{margin:0;padding:10px 12px;background:var(--pill);border-radius:8px;font:12.5px/1.45 var(--mono);white-space:pre-wrap;word-break:break-word;max-height:320px;overflow:auto}
515
640
  details summary{cursor:pointer;color:var(--muted);font-size:13px}details{margin-top:6px}
641
+ details.confirm{margin:0}details.confirm summary{list-style:none;display:inline-block;font-size:13px;padding:5px 10px;border:1px solid var(--line);border-radius:7px;background:var(--card);color:var(--fg)}
642
+ details.confirm summary::-webkit-details-marker{display:none}details.confirm[open] summary{border-color:var(--muted)}details.confirm form{margin-left:8px;font-size:13px}
516
643
  .empty{padding:40px 16px;text-align:center;color:var(--muted)}
517
644
  dl{display:grid;grid-template-columns:max-content 1fr;gap:6px 16px;margin:0;padding:14px 16px;font-size:14px}dt{color:var(--muted)}dd{margin:0}
518
645
  footer{margin-top:28px;color:var(--muted);font-size:12px}
@@ -616,13 +743,13 @@ function jobPage(job, runs, now, base) {
616
743
  <div class="actions">
617
744
  ${healthPill(job)}
618
745
  ${silenced ? `<form class="inline" method="post" action="${h(base)}/jobs/${encodeURIComponent(job.name)}/unsilence"><button type="submit">Unsilence (until ${h(formatRelative(job.silencedUntil, now))})</button></form>` : `<form class="inline" method="post" action="${h(base)}/jobs/${encodeURIComponent(job.name)}/silence"><select name="for"><option value="1h">1 hour</option><option value="4h">4 hours</option><option value="1d">1 day</option><option value="7d">1 week</option></select> <button type="submit">Silence</button></form>`}
619
- <form class="inline" method="post" action="${h(base)}/jobs/${encodeURIComponent(job.name)}/forget" onsubmit="return confirm('Remove this job and its runs from the store?')"><button type="submit">Forget</button></form>
746
+ <details class="confirm"><summary>Forget</summary><form class="inline" method="post" action="${h(base)}/jobs/${encodeURIComponent(job.name)}/forget"><span class="muted">Remove this job and its runs from the store?</span> <button type="submit">Forget</button></form></details>
620
747
  </div>
621
748
  </header>
622
749
  <div class="grid">
623
750
  <div class="card stat"><div class="k">Last run</div><div class="v">${job.lastRun ? h(formatRelative(job.lastRun.startedAt, now)) : "never"}</div></div>
624
751
  <div class="card stat"><div class="k">Next due</div><div class="v">${job.nextExpectedAt ? h(formatRelative(job.nextExpectedAt, now)) : "no schedule"}</div></div>
625
- <div class="card stat"><div class="k">Success, last ${job.stats.runs}</div><div class="v">${okRate}</div></div>
752
+ <div class="card stat"><div class="k">Success, last ${h(job.stats.runs)}</div><div class="v">${h(okRate)}</div></div>
626
753
  <div class="card stat"><div class="k">p50 / p95</div><div class="v">${job.stats.p50Ms !== null ? h(formatDuration(job.stats.p50Ms)) : "?"} <span class="muted">/</span> ${job.stats.p95Ms !== null ? h(formatDuration(job.stats.p95Ms)) : "?"}</div></div>
627
754
  </div>
628
755
  <div class="card" style="margin-bottom:20px">
@@ -633,11 +760,11 @@ function jobPage(job, runs, now, base) {
633
760
  ${d.maxDuration ? `<dt>Max duration</dt><dd class="mono">${h(d.maxDuration)}</dd>` : ""}
634
761
  ${d.budget ? `<dt>Budget</dt><dd class="mono">${h(Object.entries(d.budget).map(([k, v]) => `${k} \u2264 ${v}`).join(", "))}</dd>` : ""}
635
762
  ${d.expect ? `<dt>Expect</dt><dd class="mono">${h(d.expect)}</dd>` : ""}
636
- ${d.failuresBeforeAlert && d.failuresBeforeAlert > 1 ? `<dt>Alert after</dt><dd>${d.failuresBeforeAlert} consecutive failures</dd>` : ""}
763
+ ${d.failuresBeforeAlert && d.failuresBeforeAlert > 1 ? `<dt>Alert after</dt><dd>${h(d.failuresBeforeAlert)} consecutive failures</dd>` : ""}
637
764
  ${d.description ? `<dt>Description</dt><dd>${h(d.description)}</dd>` : ""}
638
765
  ${d.tags?.length ? `<dt>Tags</dt><dd>${d.tags.map((t) => `<span class="pill mutedpill">${h(t)}</span>`).join(" ")}</dd>` : ""}
639
766
  ${job.open.length ? `<dt>Open</dt><dd>${job.open.map((c) => `<span class="pill warn">${h(c.replace("_", " "))}</span>`).join(" ")}</dd>` : ""}
640
- ${job.consecutiveFailures > 0 ? `<dt>Consecutive failures</dt><dd>${job.consecutiveFailures}</dd>` : ""}
767
+ ${job.consecutiveFailures > 0 ? `<dt>Consecutive failures</dt><dd>${h(job.consecutiveFailures)}</dd>` : ""}
641
768
  </dl>
642
769
  </div>
643
770
  <div class="card">
@@ -654,18 +781,27 @@ function messagePage(title, message, base) {
654
781
 
655
782
  // src/routes/index.ts
656
783
  var COOKIE = "cronwatch_token";
657
- function constantTimeEqual(a, b) {
658
- if (a.length !== b.length) return false;
659
- let diff = 0;
660
- for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
661
- return diff === 0;
784
+ function cookieValue(token) {
785
+ return crypto$1.createHash("sha256").update(`cronwatch-cookie:${token}`).digest("hex");
786
+ }
787
+ var LOOPBACK = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
788
+ var DEFAULT_RUNS = 20;
789
+ var MAX_RUNS = 500;
790
+ var CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none'";
791
+ var SECURITY_HEADERS = { "x-content-type-options": "nosniff", "referrer-policy": "same-origin", "x-robots-tag": "noindex" };
792
+ function safeDecode(value) {
793
+ try {
794
+ return decodeURIComponent(value);
795
+ } catch {
796
+ return null;
797
+ }
662
798
  }
663
799
  function readCookie(request, name) {
664
800
  const header = request.headers.get("cookie");
665
801
  if (!header) return null;
666
802
  for (const part of header.split(";")) {
667
803
  const [k, ...rest] = part.trim().split("=");
668
- if (k === name) return decodeURIComponent(rest.join("="));
804
+ if (k === name) return safeDecode(rest.join("="));
669
805
  }
670
806
  return null;
671
807
  }
@@ -693,56 +829,71 @@ async function readBody(request) {
693
829
  }
694
830
  return {};
695
831
  }
832
+ function crossSite(request, url) {
833
+ const origin = request.headers.get("origin");
834
+ if (origin !== null && origin !== url.origin) return true;
835
+ const site = request.headers.get("sec-fetch-site");
836
+ return site !== null && site !== "same-origin" && site !== "none";
837
+ }
838
+ function silenceDuration(value) {
839
+ if (value === void 0 || value === null) return "1h";
840
+ const text = value.trim();
841
+ const duration = /^\d+(\.\d+)?$/.test(text) ? Number(text) : text;
842
+ parseDuration(duration, "silence duration");
843
+ return duration;
844
+ }
845
+ function runsLimit(value) {
846
+ const n = value === null || value.trim() === "" ? NaN : Math.trunc(Number(value));
847
+ return Number.isFinite(n) ? Math.min(MAX_RUNS, Math.max(1, n)) : DEFAULT_RUNS;
848
+ }
696
849
  function createRoutes(cw, options = {}) {
697
- const token = options.token === void 0 ? process.env.CRONWATCH_TOKEN ?? null : options.token;
850
+ const optedOut = options.token === null;
851
+ const token = optedOut ? null : options.token || process.env.CRONWATCH_TOKEN || null;
698
852
  const base = (options.basePath ?? "/cronwatch").replace(/\/+$/, "");
699
- const production = process.env.NODE_ENV === "production";
700
- const handler = async (request) => {
701
- const url = new URL(request.url);
702
- const path = stripBase(url.pathname, base);
703
- const wantsHtml = !path.startsWith("/api");
853
+ const developing = process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test";
854
+ const serve = async (request, url, path, wantsHtml) => {
704
855
  const method = request.method.toUpperCase();
705
- if (!token && production) {
706
- return wantsHtml ? html(messagePage("CronWatch routes are locked", "Set CRONWATCH_TOKEN (or pass token to cw.routes()) to use them in production.", base), 503) : json({ ok: false, error: "CRONWATCH_TOKEN is not set" }, 503);
856
+ if (!token && !optedOut && !(developing && LOOPBACK.has(url.hostname))) {
857
+ return wantsHtml ? html(messagePage("CronWatch routes are locked", "Set CRONWATCH_TOKEN (or pass token to cw.routes()). Without one the routes only answer localhost in development.", base), 503) : api({ ok: false, error: "CRONWATCH_TOKEN is not set" }, 503);
858
+ }
859
+ if (method !== "GET" && method !== "HEAD" && crossSite(request, url)) {
860
+ return wantsHtml ? html(messagePage("Cross-site request refused", "Changes can only be made from the dashboard itself.", base), 403) : api({ ok: false, error: "Cross-site request refused" }, 403);
707
861
  }
862
+ const bearer = request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") ?? null;
708
863
  if (token) {
709
- const bearer = request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") ?? null;
710
- const query = url.searchParams.get("token");
864
+ const query = wantsHtml && method === "GET" ? url.searchParams.get("token") : null;
711
865
  const cookie = readCookie(request, COOKIE);
712
- const presented = bearer ?? query ?? cookie;
713
866
  const isCheck = path === "/api/check";
714
867
  const cronSecretOk = isCheck && bearer !== null && cw.cronSecret !== null && constantTimeEqual(bearer, cw.cronSecret);
715
- if (!cronSecretOk && (presented === null || !constantTimeEqual(presented, token))) {
716
- return wantsHtml ? html(messagePage("Sign in", `Open this page with ?token=<your CRONWATCH_TOKEN> once and it will stay signed in.`, base), 401) : json({ ok: false, error: "Unauthorized" }, 401);
868
+ const tokenOk = bearer !== null ? constantTimeEqual(bearer, token) : query !== null ? constantTimeEqual(query, token) : cookie !== null && constantTimeEqual(cookie, cookieValue(token));
869
+ if (!cronSecretOk && !tokenOk) {
870
+ return wantsHtml ? html(messagePage("Sign in", `Open this page with ?token=<your CRONWATCH_TOKEN> once and it will stay signed in.`, base), 401) : api({ ok: false, error: "Unauthorized" }, 401);
717
871
  }
718
- if (query !== null && wantsHtml && method === "GET") {
872
+ if (query !== null) {
719
873
  url.searchParams.delete("token");
720
874
  const secure = url.protocol === "https:" ? "; Secure" : "";
721
- return new Response(null, {
722
- status: 303,
723
- headers: {
724
- location: url.pathname + (url.search || ""),
725
- "set-cookie": `${COOKIE}=${encodeURIComponent(token)}; Path=${base || "/"}; HttpOnly; SameSite=Lax; Max-Age=${60 * 60 * 24 * 30}${secure}`
726
- }
875
+ return redirect(url.pathname + (url.search || ""), {
876
+ "set-cookie": `${COOKIE}=${cookieValue(token)}; Path=${base || "/"}; HttpOnly; SameSite=Lax; Max-Age=${60 * 60 * 24 * 30}${secure}`
727
877
  });
728
878
  }
729
879
  }
730
880
  const redirectBack = () => {
731
881
  const referer = request.headers.get("referer") ?? "";
732
- const location = referer.startsWith(url.origin + "/") ? referer : `${base}/`;
733
- return new Response(null, { status: 303, headers: { location } });
882
+ return redirect(referer.startsWith(url.origin + "/") ? referer : `${base}/`);
734
883
  };
735
- const jobName = (segment) => segment ? decodeURIComponent(segment) : "";
736
- const parts = path.split("/").filter(Boolean);
884
+ const decoded = path.split("/").filter(Boolean).map(safeDecode);
885
+ if (decoded.some((part) => part === null)) {
886
+ return wantsHtml ? html(messagePage("Bad request", "The path is not valid.", base), 400) : api({ ok: false, error: "Bad path" }, 400);
887
+ }
888
+ const parts = decoded;
737
889
  if (method === "GET" && path === "/") {
738
- const jobs = await cw.jobs();
739
- const runsByJob = /* @__PURE__ */ new Map();
740
- for (const job of jobs) runsByJob.set(job.name, await cw.runs(job.name, 20));
741
- return html(dashboardPage(jobs, runsByJob, cw.now(), base));
890
+ const entries = await cw.jobsWithRuns(20);
891
+ const runsByJob = new Map(entries.map((entry) => [entry.job.name, entry.runs]));
892
+ return html(dashboardPage(entries.map((entry) => entry.job), runsByJob, cw.now(), base));
742
893
  }
743
894
  if (method === "GET" && parts[0] === "jobs" && parts.length === 2) {
744
- const job = await cw.jobSummary(jobName(parts[1]));
745
- if (!job) return html(messagePage("No such job", `${jobName(parts[1])} is not in the store.`, base), 404);
895
+ const job = await cw.jobSummary(parts[1]);
896
+ if (!job) return html(messagePage("No such job", `${parts[1]} is not in the store.`, base), 404);
746
897
  return html(jobPage(job, await cw.runs(job.name, 50), cw.now(), base));
747
898
  }
748
899
  if (method === "POST" && path === "/check") {
@@ -750,68 +901,107 @@ function createRoutes(cw, options = {}) {
750
901
  return redirectBack();
751
902
  }
752
903
  if (method === "POST" && parts[0] === "jobs" && parts.length === 3) {
753
- const name = jobName(parts[1]);
754
- const body = await readBody(request);
755
- if (parts[2] === "silence") await cw.silence(name, safeDuration(body.for, "1h"));
756
- else if (parts[2] === "unsilence") await cw.unsilence(name);
757
- else if (parts[2] === "forget") {
904
+ const name = parts[1];
905
+ if (parts[2] === "forget") {
758
906
  await cw.forget(name);
759
- return new Response(null, { status: 303, headers: { location: `${base}/` } });
760
- } else return html(messagePage("Not found", path, base), 404);
907
+ return redirect(`${base}/`);
908
+ }
909
+ if (parts[2] !== "silence" && parts[2] !== "unsilence") return html(messagePage("Not found", path, base), 404);
910
+ if (!await cw.jobSummary(name)) return html(messagePage("No such job", `${name} is not in the store.`, base), 404);
911
+ if (parts[2] === "silence") {
912
+ let duration;
913
+ try {
914
+ duration = silenceDuration((await readBody(request)).for);
915
+ } catch (e) {
916
+ return html(messagePage("Not silenced", e.message, base), 400);
917
+ }
918
+ await cw.silence(name, duration);
919
+ } else await cw.unsilence(name);
761
920
  return redirectBack();
762
921
  }
763
922
  if (parts[0] === "api") {
764
923
  const rest = parts.slice(1);
765
924
  if (method === "GET" && rest[0] === "jobs" && rest.length === 1) {
766
- return json({ ok: true, jobs: await cw.jobs() });
925
+ return api({ ok: true, jobs: await cw.jobs() });
767
926
  }
768
927
  if (rest[0] === "jobs" && rest.length === 2) {
769
- const name = jobName(rest[1]);
928
+ const name = rest[1];
770
929
  if (method === "GET") {
771
930
  const job = await cw.jobSummary(name);
772
- if (!job) return json({ ok: false, error: "No such job" }, 404);
773
- const limit = Number(url.searchParams.get("runs") ?? 20);
774
- return json({ ok: true, job, runs: await cw.runs(name, Number.isFinite(limit) ? limit : 20) });
931
+ if (!job) return api({ ok: false, error: "No such job" }, 404);
932
+ return api({ ok: true, job, runs: await cw.runs(name, runsLimit(url.searchParams.get("runs"))) });
775
933
  }
776
934
  if (method === "DELETE") {
935
+ if (!await cw.jobSummary(name)) return api({ ok: false, error: "No such job" }, 404);
777
936
  await cw.forget(name);
778
- return json({ ok: true });
937
+ return api({ ok: true });
779
938
  }
780
939
  }
781
940
  if (method === "POST" && rest[0] === "jobs" && rest.length === 3) {
782
- const name = jobName(rest[1]);
783
- if (!await cw.jobSummary(name)) return json({ ok: false, error: "No such job" }, 404);
784
- const body = await readBody(request);
785
- if (rest[2] === "silence") return json({ ok: true, state: await cw.silence(name, safeDuration(body.for ?? url.searchParams.get("for"), "1h")) });
786
- if (rest[2] === "unsilence") return json({ ok: true, state: await cw.unsilence(name) });
941
+ const name = rest[1];
942
+ if (!await cw.jobSummary(name)) return api({ ok: false, error: "No such job" }, 404);
943
+ if (rest[2] === "silence") {
944
+ const body = await readBody(request);
945
+ let duration;
946
+ try {
947
+ duration = silenceDuration(body.for ?? url.searchParams.get("for"));
948
+ } catch (e) {
949
+ return api({ ok: false, error: e.message }, 400);
950
+ }
951
+ return api({ ok: true, state: await cw.silence(name, duration) });
952
+ }
953
+ if (rest[2] === "unsilence") return api({ ok: true, state: await cw.unsilence(name) });
787
954
  }
788
- if ((method === "GET" || method === "POST") && rest[0] === "check" && rest.length === 1) {
789
- const result = await cw.check();
790
- return json({ ok: true, ...result });
955
+ if (rest[0] === "check" && rest.length === 1) {
956
+ if (method === "GET" && bearer === null) {
957
+ return api({ ok: false, error: "Use POST, or GET with an Authorization bearer" }, 405, { allow: "POST" });
958
+ }
959
+ if (method === "GET" || method === "POST") {
960
+ const result = await cw.check();
961
+ return api({ ok: true, ...result });
962
+ }
791
963
  }
792
964
  if (method === "GET" && rest[0] === "runs" && rest.length === 2) {
793
965
  const run = await cw.getRun(rest[1]);
794
- return run ? json({ ok: true, run }) : json({ ok: false, error: "No such run" }, 404);
966
+ return run ? api({ ok: true, run }) : api({ ok: false, error: "No such run" }, 404);
795
967
  }
796
- return json({ ok: false, error: "Not found" }, 404);
968
+ return api({ ok: false, error: "Not found" }, 404);
797
969
  }
798
970
  return html(messagePage("Not found", path, base), 404);
799
971
  };
972
+ const handler = async (request) => {
973
+ let wantsHtml = true;
974
+ try {
975
+ const url = new URL(request.url);
976
+ const path = stripBase(url.pathname, base);
977
+ wantsHtml = !path.startsWith("/api");
978
+ return await serve(request, url, path, wantsHtml);
979
+ } catch (e) {
980
+ try {
981
+ cw.onError(e, "routes");
982
+ } catch {
983
+ }
984
+ return wantsHtml ? html(messagePage("Something went wrong", "The request failed and the error was reported.", base), 500) : api({ ok: false, error: "Internal error" }, 500);
985
+ }
986
+ };
800
987
  return { handler, GET: handler, POST: handler, DELETE: handler };
801
988
  }
802
- function safeDuration(value, fallback) {
803
- if (!value) return fallback;
804
- try {
805
- parseDuration(value);
806
- return value;
807
- } catch {
808
- return fallback;
809
- }
989
+ function api(body, status = 200, headers = {}) {
990
+ return json(body, status, { ...SECURITY_HEADERS, ...headers });
991
+ }
992
+ function redirect(location, headers = {}) {
993
+ return new Response(null, { status: 303, headers: { location, "cache-control": "no-store", ...SECURITY_HEADERS, ...headers } });
810
994
  }
811
995
  function html(body, status = 200) {
812
996
  return new Response(body, {
813
997
  status,
814
- headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "x-robots-tag": "noindex" }
998
+ headers: {
999
+ "content-type": "text/html; charset=utf-8",
1000
+ "cache-control": "no-store",
1001
+ "content-security-policy": CSP,
1002
+ "x-frame-options": "DENY",
1003
+ ...SECURITY_HEADERS
1004
+ }
815
1005
  });
816
1006
  }
817
1007
 
@@ -823,6 +1013,7 @@ function memory() {
823
1013
  const states = /* @__PURE__ */ new Map();
824
1014
  let seq = 0;
825
1015
  const clone = (v) => JSON.parse(JSON.stringify(v));
1016
+ const byName = (a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
826
1017
  return {
827
1018
  async upsertJob(definition, now) {
828
1019
  const existing = jobs.get(definition.name);
@@ -838,7 +1029,7 @@ function memory() {
838
1029
  return j ? clone(j) : null;
839
1030
  },
840
1031
  async listJobs() {
841
- return [...jobs.values()].map(clone).sort((a, b) => a.name.localeCompare(b.name));
1032
+ return [...jobs.values()].map(clone).sort(byName);
842
1033
  },
843
1034
  async deleteJob(name) {
844
1035
  jobs.delete(name);
@@ -853,7 +1044,10 @@ function memory() {
853
1044
  order.set(run.id, ++seq);
854
1045
  },
855
1046
  async updateRun(run) {
856
- runs.set(run.id, clone(run));
1047
+ const existing = runs.get(run.id);
1048
+ if (!existing) return;
1049
+ const { status, finishedAt, durationMs, error, output, metrics } = clone(run);
1050
+ runs.set(run.id, { ...existing, status, finishedAt, durationMs, error, output, metrics });
857
1051
  },
858
1052
  async getRun(id) {
859
1053
  const r = runs.get(id);
@@ -867,7 +1061,7 @@ function memory() {
867
1061
  return list[0] ?? null;
868
1062
  },
869
1063
  async runningRuns() {
870
- return [...runs.values()].filter((r) => r.status === "running").map(clone);
1064
+ return [...runs.values()].filter((r) => r.status === "running").sort((a, b) => a.startedAt - b.startedAt || order.get(a.id) - order.get(b.id)).map(clone);
871
1065
  },
872
1066
  async getState(job) {
873
1067
  const s = states.get(job);
@@ -877,9 +1071,11 @@ function memory() {
877
1071
  states.set(state.job, clone(state));
878
1072
  },
879
1073
  async prune(before) {
1074
+ const newest = /* @__PURE__ */ new Map();
1075
+ for (const run of runs.values()) newest.set(run.job, Math.max(newest.get(run.job) ?? -Infinity, run.startedAt));
880
1076
  let n = 0;
881
1077
  for (const [id, run] of runs) {
882
- if (run.status !== "running" && run.startedAt < before) {
1078
+ if (run.status !== "running" && run.startedAt < before && run.startedAt < newest.get(run.job)) {
883
1079
  runs.delete(id);
884
1080
  order.delete(id);
885
1081
  n++;
@@ -893,37 +1089,91 @@ function memory() {
893
1089
  // src/client.ts
894
1090
  var NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/;
895
1091
  var TRIAGE_TIMEOUT_MS = 25e3;
1092
+ var CHANNEL_TIMEOUT_MS = 15e3;
896
1093
  var PRUNE_INTERVAL_MS = 60 * 6e4;
897
- function constantTimeEqual2(a, b) {
898
- if (a.length !== b.length) return false;
899
- let diff = 0;
900
- for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
901
- return diff === 0;
1094
+ var MAX_UNDELIVERED = 20;
1095
+ var HISTORY_PAGE = BASELINE_WINDOW + 5;
1096
+ var HISTORY_MAX = 200;
1097
+ function validateDefinition(def) {
1098
+ const name = def.name;
1099
+ if (def.schedule !== void 0) {
1100
+ if (typeof def.schedule !== "string" || def.schedule.trim() === "") throw new Error(`job "${name}": schedule must be a non-empty string`);
1101
+ parseSchedule(def.schedule, def.timezone);
1102
+ }
1103
+ if (def.timezone !== void 0) {
1104
+ try {
1105
+ new Intl.DateTimeFormat("en-US", { timeZone: def.timezone });
1106
+ } catch {
1107
+ throw new Error(`job "${name}": timezone "${def.timezone}" is not an IANA timezone`);
1108
+ }
1109
+ }
1110
+ if (def.grace !== void 0) parseDuration(def.grace, "grace");
1111
+ if (def.timeout !== void 0 && parseDuration(def.timeout, "timeout") <= 0) throw new Error(`job "${name}": timeout must be longer than zero`);
1112
+ if (def.maxDuration !== void 0 && parseDuration(def.maxDuration, "maxDuration") <= 0) {
1113
+ throw new Error(`job "${name}": maxDuration must be longer than zero`);
1114
+ }
1115
+ if (def.failuresBeforeAlert !== void 0 && !(Number.isInteger(def.failuresBeforeAlert) && def.failuresBeforeAlert >= 1)) {
1116
+ throw new Error(`job "${name}": failuresBeforeAlert must be a whole number, 1 or more (got ${String(def.failuresBeforeAlert)})`);
1117
+ }
1118
+ if (def.budget !== void 0) {
1119
+ if (typeof def.budget !== "object" || def.budget === null) throw new Error(`job "${name}": budget must be an object of { metric: ceiling }`);
1120
+ for (const [metric, ceiling] of Object.entries(def.budget)) {
1121
+ if (typeof ceiling !== "number" || !Number.isFinite(ceiling) || ceiling < 0) {
1122
+ throw new Error(`job "${name}": budget.${metric} must be a finite number, 0 or more (got ${String(ceiling)})`);
1123
+ }
1124
+ }
1125
+ }
1126
+ if (def.expect !== void 0 && typeof def.expect !== "string" && !(def.expect instanceof RegExp) && typeof def.expect !== "function") {
1127
+ throw new Error(`job "${name}": expect must be a string, a RegExp or a function`);
1128
+ }
1129
+ }
1130
+ function sameState(a, b) {
1131
+ return JSON.stringify(a) === JSON.stringify(b);
1132
+ }
1133
+ function alertKey(alert) {
1134
+ return `${alert.type}|${alert.at}|${alert.run?.id ?? ""}`;
902
1135
  }
903
1136
  var CronWatch = class {
904
1137
  store;
905
1138
  alerts;
906
1139
  triage;
1140
+ /** The secret handler() requests must carry, or null when none is set. */
907
1141
  cronSecret;
908
1142
  retentionMs;
909
1143
  now;
910
1144
  onError;
1145
+ /** cronSecret was passed as null: handlers may run without a secret. */
1146
+ secretOptOut;
1147
+ redact;
1148
+ /** "check": queue alerts for another process's check instead of sending them. See CronWatchOptions.deliver. */
1149
+ deferDelivery;
911
1150
  defaults;
912
1151
  definitions = /* @__PURE__ */ new Map();
913
1152
  synced = /* @__PURE__ */ new Set();
1153
+ /** The tail of each job's queue of state updates. See serial(). */
1154
+ queues = /* @__PURE__ */ new Map();
914
1155
  ready = null;
915
1156
  checking = null;
916
1157
  lastPruneAt = 0;
917
1158
  timer = null;
1159
+ firstTick = null;
918
1160
  usingDefaultStore = false;
1161
+ warnedNoSecret = false;
919
1162
  constructor(options = {}) {
920
1163
  this.store = options.store ?? (this.usingDefaultStore = true, memory());
921
1164
  this.alerts = options.alerts ?? [consoleChannel()];
922
1165
  this.triage = options.triage;
923
- this.cronSecret = options.cronSecret === void 0 ? process.env.CRON_SECRET ?? null : options.cronSecret;
1166
+ const secret = options.cronSecret === void 0 ? process.env.CRON_SECRET : options.cronSecret;
1167
+ this.cronSecret = secret ? secret : null;
1168
+ this.secretOptOut = options.cronSecret === null;
924
1169
  this.retentionMs = parseDuration(options.retention ?? "30d", "retention");
925
1170
  this.defaults = options.defaults ?? {};
926
1171
  this.now = options.now ?? (() => Date.now());
1172
+ this.redact = options.redact === false ? (text) => text : options.redact ?? redactSecrets;
1173
+ if (options.deliver !== void 0 && options.deliver !== "now" && options.deliver !== "check") {
1174
+ throw new Error(`deliver must be "now" or "check", not ${JSON.stringify(options.deliver)}`);
1175
+ }
1176
+ this.deferDelivery = options.deliver === "check";
927
1177
  this.onError = options.onError ?? ((error, where) => console.error(`[cronwatch] ${where}:`, error));
928
1178
  }
929
1179
  /** Declare a job. Call it once, at module level, and keep the handle. */
@@ -932,10 +1182,7 @@ var CronWatch = class {
932
1182
  throw new Error(`job name "${name}" must be 1 to 120 characters of letters, digits, ".", "_", ":" or "-"`);
933
1183
  }
934
1184
  const definition = { ...this.defaults, ...options, name };
935
- if (definition.schedule) parseSchedule(definition.schedule, definition.timezone);
936
- if (definition.grace !== void 0) parseDuration(definition.grace, "grace");
937
- if (definition.timeout !== void 0) parseDuration(definition.timeout, "timeout");
938
- if (definition.maxDuration !== void 0) parseDuration(definition.maxDuration, "maxDuration");
1185
+ validateDefinition(definition);
939
1186
  this.definitions.set(name, definition);
940
1187
  this.synced.delete(name);
941
1188
  return this.handle(definition);
@@ -961,11 +1208,17 @@ var CronWatch = class {
961
1208
  return outcome.result;
962
1209
  },
963
1210
  handler(fn, options) {
964
- const secret = options?.secret === void 0 ? self.cronSecret : options.secret;
1211
+ const own = options?.secret;
1212
+ const secret = own === null ? null : own ? own : self.cronSecret;
1213
+ const optedOut = own === null || !own && self.secretOptOut;
965
1214
  return async (request) => {
1215
+ if (!secret && !optedOut && !isDevelopment()) {
1216
+ self.warnNoSecret();
1217
+ return json({ ok: false, error: "CRON_SECRET is not set, so this job will not run for an unauthenticated request. Set it, or pass secret: null to handler() to allow anyone." }, 503);
1218
+ }
966
1219
  if (secret) {
967
1220
  const header = request.headers.get("authorization") ?? "";
968
- if (!constantTimeEqual2(header, `Bearer ${secret}`)) {
1221
+ if (!constantTimeEqual(header, `Bearer ${secret}`)) {
969
1222
  return json({ ok: false, error: "Unauthorized" }, 401);
970
1223
  }
971
1224
  }
@@ -977,13 +1230,19 @@ var CronWatch = class {
977
1230
  run: outcome.run.id,
978
1231
  status: outcome.run.status,
979
1232
  durationMs: outcome.run.durationMs,
980
- ...outcome.run.error ? { error: outcome.run.error.split("\n")[0] } : {}
1233
+ // Error text only goes to a caller who proved they hold the secret.
1234
+ ...secret && outcome.run.error ? { error: outcome.run.error.split("\n")[0] } : {}
981
1235
  };
982
1236
  return json(body, outcome.run.status === "ok" ? 200 : 500);
983
1237
  };
984
1238
  }
985
1239
  };
986
1240
  }
1241
+ warnNoSecret() {
1242
+ if (this.warnedNoSecret) return;
1243
+ this.warnedNoSecret = true;
1244
+ this.onError(new Error("handler() refused a request because no CRON_SECRET is set; pass secret: null to allow unauthenticated requests"), "handler");
1245
+ }
987
1246
  async ensureReady() {
988
1247
  if (!this.ready) {
989
1248
  this.ready = (async () => {
@@ -991,7 +1250,10 @@ var CronWatch = class {
991
1250
  if (this.usingDefaultStore && process.env.NODE_ENV === "production") {
992
1251
  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.");
993
1252
  }
994
- })();
1253
+ })().catch((error) => {
1254
+ this.ready = null;
1255
+ throw error;
1256
+ });
995
1257
  }
996
1258
  await this.ready;
997
1259
  }
@@ -1001,13 +1263,36 @@ var CronWatch = class {
1001
1263
  await this.store.upsertJob(toStored(definition), this.now());
1002
1264
  this.synced.add(definition.name);
1003
1265
  }
1004
- /** Runs a function as a recorded run. Never throws for the job's own error; see `threw`. */
1266
+ /**
1267
+ * Runs `fn` after every earlier state update for the same job has settled,
1268
+ * so two runs (or a run and a check) in this process never read and write
1269
+ * the job's state over each other. Other processes are not coordinated.
1270
+ */
1271
+ serial(job, fn) {
1272
+ const result = (this.queues.get(job) ?? Promise.resolve()).then(fn);
1273
+ const tail2 = result.then(() => {
1274
+ }, () => {
1275
+ });
1276
+ this.queues.set(job, tail2);
1277
+ void tail2.then(() => {
1278
+ if (this.queues.get(job) === tail2) this.queues.delete(job);
1279
+ });
1280
+ return result;
1281
+ }
1282
+ async readState(job) {
1283
+ return normalizeState(await this.store.getState(job), job);
1284
+ }
1285
+ /**
1286
+ * Runs a function as a recorded run. The function always runs, whatever
1287
+ * the store is doing: store errors go to onError, and the result is the
1288
+ * function's own outcome. Never throws for the job's own error; see `threw`.
1289
+ */
1005
1290
  async execute(definition, fn, trigger) {
1006
- await this.sync(definition);
1291
+ const name = definition.name;
1007
1292
  const startedAt = this.now();
1008
1293
  const run = {
1009
1294
  id: crypto.randomUUID(),
1010
- job: definition.name,
1295
+ job: name,
1011
1296
  status: "running",
1012
1297
  startedAt,
1013
1298
  finishedAt: null,
@@ -1017,12 +1302,19 @@ var CronWatch = class {
1017
1302
  metrics: {},
1018
1303
  trigger
1019
1304
  };
1020
- await this.store.insertRun(run);
1021
- const before = await this.store.getState(definition.name) ?? emptyState(definition.name);
1022
- const started = onRunStart(before);
1023
- if (openConditions(started.state).length !== openConditions(before).length) {
1024
- await this.store.setState(started.state);
1305
+ let recorded = false;
1306
+ try {
1307
+ await this.sync(definition);
1308
+ await this.store.insertRun({ ...run });
1309
+ recorded = true;
1310
+ } catch (e) {
1311
+ this.onError(e, `recording ${name}`);
1025
1312
  }
1313
+ const started = recorded ? this.serial(name, async () => {
1314
+ const before = await this.readState(name);
1315
+ const after = onRunStart(before);
1316
+ if (!sameState(before, after)) await this.store.setState(after);
1317
+ }).catch((e) => this.onError(e, `starting ${name}`)) : Promise.resolve();
1026
1318
  const recorder = createRecorder(run);
1027
1319
  const timer = setTimeout(() => recorder.abort(), timeoutMs(definition));
1028
1320
  if (typeof timer === "object" && "unref" in timer) timer.unref();
@@ -1041,7 +1333,7 @@ var CronWatch = class {
1041
1333
  run.finishedAt = finishedAt;
1042
1334
  run.durationMs = Math.max(0, finishedAt - startedAt);
1043
1335
  run.metrics = recorder.metrics();
1044
- run.output = recorder.output() ?? (typeof result === "string" ? result : null);
1336
+ run.output = recorder.output() ?? (typeof result === "string" ? capOutput(result) : null);
1045
1337
  if (threw) {
1046
1338
  run.status = "failed";
1047
1339
  run.error = errorMessage(error);
@@ -1049,7 +1341,8 @@ var CronWatch = class {
1049
1341
  run.status = "failed";
1050
1342
  run.error = `HTTP ${result.status}${result.statusText ? ` ${result.statusText}` : ""}`;
1051
1343
  } else {
1052
- const unmet = checkExpectation(definition.expect, run.output);
1344
+ const expectText = recorder.expectText() ?? (typeof result === "string" ? result : null);
1345
+ const unmet = checkExpectation(definition.expect, expectText);
1053
1346
  if (unmet) {
1054
1347
  run.status = "failed";
1055
1348
  run.error = unmet;
@@ -1057,21 +1350,74 @@ var CronWatch = class {
1057
1350
  run.status = "ok";
1058
1351
  }
1059
1352
  }
1060
- await this.store.updateRun(run);
1353
+ if (run.output !== null) run.output = this.redact(run.output);
1354
+ if (run.error !== null) run.error = this.redact(run.error);
1355
+ await started;
1356
+ if (!recorded) {
1357
+ try {
1358
+ await this.sync(definition);
1359
+ await this.store.insertRun(run);
1360
+ recorded = true;
1361
+ } catch (e) {
1362
+ this.onError(e, `recording ${name}`);
1363
+ }
1364
+ if (recorded) await this.finishRun(toStored(definition), run, finishedAt, false);
1365
+ } else if (await this.markedTimedOut(run)) {
1366
+ try {
1367
+ await this.store.updateRun(run);
1368
+ } catch (e) {
1369
+ this.onError(e, `recording ${name}`);
1370
+ }
1371
+ } else {
1372
+ await this.finishRun(toStored(definition), run, finishedAt, true);
1373
+ }
1374
+ return { run, result, error, threw };
1375
+ }
1376
+ /** Whether a check already marked this run as timed out, for a failure that finished late. */
1377
+ async markedTimedOut(run) {
1378
+ if (run.status === "ok") return false;
1061
1379
  try {
1062
- const history = (await this.store.listRuns(definition.name, BASELINE_WINDOW + 5)).filter((r) => r.id !== run.id);
1063
- const state = await this.store.getState(definition.name) ?? emptyState(definition.name);
1064
- const evaluation = onRunFinish(definition, run, state, history, started.openBefore, finishedAt);
1065
- await this.settle(state, evaluation, toStored(definition), finishedAt);
1380
+ return (await this.store.getRun(run.id))?.status === "timeout";
1381
+ } catch {
1382
+ return false;
1383
+ }
1384
+ }
1385
+ /**
1386
+ * Record a finished run (ok, failed, or timed out by a check), evaluate it
1387
+ * against the job's state and send what that produces. Never throws.
1388
+ */
1389
+ async finishRun(definition, run, now, write) {
1390
+ let drafts;
1391
+ try {
1392
+ if (write) await this.store.updateRun(run);
1393
+ drafts = await this.serial(run.job, async () => {
1394
+ const history = await this.history(run);
1395
+ const previous = await this.readState(run.job);
1396
+ return (await this.settle(previous, onRunFinish(definition, run, previous, history, now), now)).alerts;
1397
+ });
1066
1398
  } catch (e) {
1067
- this.onError(e, `evaluating ${definition.name}`);
1399
+ this.onError(e, `evaluating ${run.job}`);
1400
+ return [];
1068
1401
  }
1069
- return { run, result, error, threw };
1402
+ return this.dispatch(drafts, definition, now);
1070
1403
  }
1071
1404
  /**
1072
- * Look for missed and stuck runs across every job, send alerts, and prune
1073
- * old runs. Call it from an interval (start()), a cron hitting the mounted
1074
- * routes, or by hand. Concurrent calls share one check.
1405
+ * The runs before `run`, newest first, with up to BASELINE_WINDOW
1406
+ * successful ones when the store has them. One small read normally; a
1407
+ * larger one only when failures crowd the successes out of it.
1408
+ */
1409
+ async history(run) {
1410
+ let runs = await this.store.listRuns(run.job, HISTORY_PAGE);
1411
+ if (runs.length === HISTORY_PAGE && !hasFullBaseline(runs.filter((r) => r.id !== run.id))) {
1412
+ runs = await this.store.listRuns(run.job, HISTORY_MAX);
1413
+ }
1414
+ return runs.filter((r) => r.id !== run.id);
1415
+ }
1416
+ /**
1417
+ * Look for missed and stuck runs across every job, send alerts, retry
1418
+ * alerts no channel accepted, and prune old runs. Call it from an interval
1419
+ * (start()), a cron hitting the mounted routes, or by hand. Concurrent
1420
+ * calls share one check.
1075
1421
  */
1076
1422
  check() {
1077
1423
  if (!this.checking) {
@@ -1087,28 +1433,26 @@ var CronWatch = class {
1087
1433
  const now = this.now();
1088
1434
  const alerts = [];
1089
1435
  for (const run of await this.store.runningRuns()) {
1090
- const stored = await this.store.getJob(run.job);
1091
1436
  const declared = this.definitions.get(run.job);
1092
- const definition = declared ? toStored(declared) : stored?.definition;
1437
+ const definition = declared ? toStored(declared) : (await this.store.getJob(run.job))?.definition;
1093
1438
  if (!definition || !isStuck(definition, run, now)) continue;
1094
1439
  run.status = "timeout";
1095
1440
  run.finishedAt = now;
1096
1441
  run.durationMs = now - run.startedAt;
1097
- run.error = `Still running after ${Math.round(timeoutMs(definition) / 6e4)} minutes; marked as timed out`;
1098
- await this.store.updateRun(run);
1099
- const history = (await this.store.listRuns(run.job, BASELINE_WINDOW + 5)).filter((r) => r.id !== run.id);
1100
- const state = await this.store.getState(run.job) ?? emptyState(run.job);
1101
- const evaluation = onRunFinish(definition, run, state, history, [], now);
1102
- alerts.push(...await this.settle(state, evaluation, definition, now));
1442
+ run.error = `Still running after ${formatDuration(timeoutMs(definition))}; marked as timed out`;
1443
+ alerts.push(...await this.finishRun(definition, run, now, true));
1103
1444
  }
1104
1445
  const jobs = [];
1105
1446
  for (const stored of await this.store.listJobs()) {
1106
- const definition = stored.definition;
1107
- const lastRun = await this.store.lastRun(stored.name);
1108
- const state = await this.store.getState(stored.name) ?? emptyState(stored.name);
1109
- const evaluation = onCheck(definition, stored, lastRun, state, now);
1110
- alerts.push(...await this.settle(state, evaluation, definition, now));
1111
- jobs.push(await this.summarize(stored, lastRun, evaluation.state, evaluation.nextExpectedAt, now));
1447
+ const recent = await this.store.listRuns(stored.name, BASELINE_WINDOW);
1448
+ const { previous, evaluation, settled } = await this.serial(stored.name, async () => {
1449
+ const previous2 = await this.readState(stored.name);
1450
+ const evaluation2 = onCheck(stored.definition, stored, recent[0] ?? null, previous2, now);
1451
+ return { previous: previous2, evaluation: evaluation2, settled: await this.settle(previous2, evaluation2, now) };
1452
+ });
1453
+ alerts.push(...await this.retryUndelivered(stored.name, previous, now));
1454
+ alerts.push(...await this.dispatch(settled.alerts, stored.definition, now));
1455
+ jobs.push(summarize(stored, recent, settled.state, evaluation.nextExpectedAt, now));
1112
1456
  }
1113
1457
  let pruned = 0;
1114
1458
  if (now - this.lastPruneAt > PRUNE_INTERVAL_MS) {
@@ -1121,18 +1465,24 @@ var CronWatch = class {
1121
1465
  }
1122
1466
  return { checkedAt: now, jobs, alerts, pruned };
1123
1467
  }
1468
+ /** A job's summary and its newest runs, without alerting. */
1469
+ async snapshot(stored, now, runs) {
1470
+ const recent = await this.store.listRuns(stored.name, Math.max(runs, BASELINE_WINDOW));
1471
+ const state = await this.readState(stored.name);
1472
+ const { nextExpectedAt } = onCheck(stored.definition, stored, recent[0] ?? null, state, now);
1473
+ return { job: summarize(stored, recent, state, nextExpectedAt, now), runs: recent.slice(0, runs) };
1474
+ }
1124
1475
  /** Every job the store knows about, with its health. Does not send alerts. */
1125
1476
  async jobs() {
1477
+ return (await this.jobsWithRuns(0)).map((entry) => entry.job);
1478
+ }
1479
+ /** Every job's summary with its newest `limit` runs, read together. What the dashboard shows. */
1480
+ async jobsWithRuns(limit = 20) {
1126
1481
  await this.ensureReady();
1127
1482
  for (const definition of this.definitions.values()) await this.sync(definition);
1128
1483
  const now = this.now();
1129
1484
  const out = [];
1130
- for (const stored of await this.store.listJobs()) {
1131
- const lastRun = await this.store.lastRun(stored.name);
1132
- const state = await this.store.getState(stored.name) ?? emptyState(stored.name);
1133
- const { nextExpectedAt } = onCheck(stored.definition, stored, lastRun, state, now);
1134
- out.push(await this.summarize(stored, lastRun, state, nextExpectedAt, now));
1135
- }
1485
+ for (const stored of await this.store.listJobs()) out.push(await this.snapshot(stored, now, clampLimit(limit, 20, 0)));
1136
1486
  return out;
1137
1487
  }
1138
1488
  async jobSummary(name) {
@@ -1141,15 +1491,12 @@ var CronWatch = class {
1141
1491
  if (definition) await this.sync(definition);
1142
1492
  const stored = await this.store.getJob(name);
1143
1493
  if (!stored) return null;
1144
- const now = this.now();
1145
- const lastRun = await this.store.lastRun(name);
1146
- const state = await this.store.getState(name) ?? emptyState(name);
1147
- const { nextExpectedAt } = onCheck(stored.definition, stored, lastRun, state, now);
1148
- return this.summarize(stored, lastRun, state, nextExpectedAt, now);
1494
+ return (await this.snapshot(stored, this.now(), 0)).job;
1149
1495
  }
1496
+ /** A job's runs, newest first. `limit` is a whole number from 1 to 500. */
1150
1497
  async runs(name, limit = 50) {
1151
1498
  await this.ensureReady();
1152
- return this.store.listRuns(name, Math.min(500, Math.max(1, limit)));
1499
+ return this.store.listRuns(name, clampLimit(limit, 50, 1));
1153
1500
  }
1154
1501
  async getRun(id) {
1155
1502
  await this.ensureReady();
@@ -1157,18 +1504,25 @@ var CronWatch = class {
1157
1504
  }
1158
1505
  /** Stop alerts for a job for a while. State keeps updating underneath. */
1159
1506
  async silence(name, duration) {
1160
- await this.ensureReady();
1161
- const state = await this.store.getState(name) ?? emptyState(name);
1162
- state.silencedUntil = this.now() + parseDuration(duration, "silence duration");
1163
- await this.store.setState(state);
1164
- return state;
1507
+ const ms = parseDuration(duration, "silence duration");
1508
+ return this.patchState(name, (state) => {
1509
+ state.silencedUntil = this.now() + ms;
1510
+ });
1165
1511
  }
1166
1512
  async unsilence(name) {
1513
+ return this.patchState(name, (state) => {
1514
+ state.silencedUntil = null;
1515
+ });
1516
+ }
1517
+ /** Read, change and write one job's state, in turn with every other update to it. */
1518
+ async patchState(name, change) {
1167
1519
  await this.ensureReady();
1168
- const state = await this.store.getState(name) ?? emptyState(name);
1169
- state.silencedUntil = null;
1170
- await this.store.setState(state);
1171
- return state;
1520
+ return this.serial(name, async () => {
1521
+ const state = await this.readState(name);
1522
+ change(state);
1523
+ await this.store.setState(state);
1524
+ return state;
1525
+ });
1172
1526
  }
1173
1527
  /** Remove a job and its runs from the store. A job still declared in code comes back on its next run. */
1174
1528
  async forget(name) {
@@ -1188,85 +1542,125 @@ var CronWatch = class {
1188
1542
  const tick = () => this.check().catch((e) => this.onError(e, "check"));
1189
1543
  this.timer = setInterval(tick, ms);
1190
1544
  if (typeof this.timer === "object" && "unref" in this.timer) this.timer.unref();
1191
- setTimeout(tick, 1e3).unref?.();
1545
+ this.firstTick = setTimeout(() => {
1546
+ this.firstTick = null;
1547
+ void tick();
1548
+ }, 1e3);
1549
+ if (typeof this.firstTick === "object" && "unref" in this.firstTick) this.firstTick.unref();
1192
1550
  }
1193
1551
  stop() {
1194
1552
  if (this.timer) clearInterval(this.timer);
1553
+ if (this.firstTick) clearTimeout(this.firstTick);
1195
1554
  this.timer = null;
1555
+ this.firstTick = null;
1196
1556
  }
1197
1557
  async close() {
1198
1558
  this.stop();
1199
1559
  if (this.store.close) await this.store.close();
1200
1560
  }
1201
- async summarize(stored, lastRun, state, nextExpectedAt, now) {
1202
- const recent = await this.store.listRuns(stored.name, BASELINE_WINDOW);
1203
- const finished = recent.filter((r) => r.status !== "running");
1204
- const okDurations = recent.filter((r) => r.status === "ok" && r.durationMs !== null).map((r) => r.durationMs);
1205
- const open = openConditions(state);
1206
- let health;
1207
- if (state.silencedUntil !== null && state.silencedUntil > now) health = "silenced";
1208
- else if (open.includes("stuck") || lastRun && isStuck(stored.definition, lastRun, now)) health = "stuck";
1209
- else if (open.includes("failed") || lastRun?.status === "failed" || lastRun?.status === "timeout") health = "failing";
1210
- else if (open.includes("missed")) health = "late";
1211
- else if (!lastRun) health = "never_ran";
1212
- else health = "healthy";
1213
- return {
1214
- name: stored.name,
1215
- definition: stored.definition,
1216
- health,
1217
- open,
1218
- lastRun,
1219
- nextExpectedAt,
1220
- consecutiveFailures: state.consecutiveFailures,
1221
- silencedUntil: state.silencedUntil,
1222
- stats: {
1223
- runs: finished.length,
1224
- okRate: finished.length === 0 ? 1 : finished.filter((r) => r.status === "ok").length / finished.length,
1225
- p50Ms: percentile(okDurations, 50),
1226
- p95Ms: percentile(okDurations, 95)
1227
- }
1228
- };
1229
- }
1230
- /** Save an evaluation's state, honouring silence, and send its alerts. */
1231
- async settle(previous, evaluation, definition, now) {
1561
+ /** Save an evaluation's state, honouring silence, and return what should be sent. Call inside serial(). */
1562
+ async settle(previous, evaluation, now) {
1563
+ let { state, alerts } = evaluation;
1232
1564
  if (isSilenced(previous, now)) {
1233
- evaluation.state = muteOpens(previous, evaluation.state);
1234
- evaluation.alerts = [];
1565
+ state = muteOpens(previous, state);
1566
+ alerts = [];
1235
1567
  }
1236
- if (JSON.stringify(evaluation.state) !== JSON.stringify(previous)) await this.store.setState(evaluation.state);
1237
- return this.dispatch(evaluation.alerts, definition, evaluation.state, now);
1568
+ if (!sameState(state, previous)) await this.store.setState(state);
1569
+ return { state, alerts };
1238
1570
  }
1239
- async dispatch(drafts, definition, state, now) {
1571
+ /**
1572
+ * Compose, triage and send each draft. The state was saved before this
1573
+ * (settle), so a slow channel holds up nothing else; afterwards only the
1574
+ * delivery fields are written back, onto a fresh read of the state.
1575
+ */
1576
+ async dispatch(drafts, definition, now) {
1240
1577
  if (drafts.length === 0) return [];
1241
- const sent = [];
1578
+ const composed = [];
1579
+ const delivered = [];
1580
+ const failed = [];
1242
1581
  for (const draft of drafts) {
1243
1582
  const alert = composeAlert(draft, definition, now);
1244
- if (this.triage && alert.type !== "recovered") {
1245
- try {
1246
- const recentRuns = await this.store.listRuns(alert.job, 5);
1247
- const diagnosis = await withTimeout(this.triage({ alert, recentRuns }), TRIAGE_TIMEOUT_MS);
1248
- if (diagnosis) alert.triage = diagnosis;
1249
- } catch (e) {
1250
- this.onError(e, `triage for ${alert.job}`);
1251
- }
1583
+ if (this.deferDelivery) {
1584
+ failed.push(alert);
1585
+ } else {
1586
+ if (this.triage && alert.type !== "recovered") await this.addTriage(alert);
1587
+ (await this.deliver(alert) ? delivered : failed).push(alert);
1252
1588
  }
1253
- for (const channel of this.alerts) {
1589
+ composed.push(alert);
1590
+ }
1591
+ await this.recordDelivery(definition.name, delivered, failed, now);
1592
+ return composed;
1593
+ }
1594
+ /** Send the alerts that no channel accepted last time, once each. */
1595
+ async retryUndelivered(name, state, now) {
1596
+ const pending = state.undelivered ?? [];
1597
+ if (pending.length === 0 || isSilenced(state, now) || this.deferDelivery) return [];
1598
+ const delivered = [];
1599
+ const failed = [];
1600
+ for (const alert of pending) {
1601
+ if (this.triage && alert.type !== "recovered" && alert.triage === void 0) await this.addTriage(alert);
1602
+ (await this.deliver(alert) ? delivered : failed).push(alert);
1603
+ }
1604
+ await this.recordDelivery(name, delivered, failed, now);
1605
+ return delivered;
1606
+ }
1607
+ /** Mark delivered alerts done and keep failed ones for the next check. lastAlertAt moves only on a delivery. */
1608
+ async recordDelivery(name, delivered, failed, now) {
1609
+ try {
1610
+ await this.serial(name, async () => {
1611
+ const previous = await this.readState(name);
1612
+ const state = normalizeState(previous, name);
1613
+ const done = new Set(delivered.map(alertKey));
1614
+ const kept = state.undelivered.filter((a) => !done.has(alertKey(a)));
1615
+ const known = new Set(kept.map(alertKey));
1616
+ kept.push(...failed.filter((a) => !known.has(alertKey(a))));
1617
+ state.undelivered = kept.slice(-MAX_UNDELIVERED);
1618
+ if (delivered.length > 0) state.lastAlertAt = now;
1619
+ if (!sameState(state, previous)) await this.store.setState(state);
1620
+ });
1621
+ } catch (e) {
1622
+ this.onError(e, `recording alert delivery for ${name}`);
1623
+ }
1624
+ }
1625
+ /** Send to every channel at once. True when at least one accepted it, or there are none. */
1626
+ async deliver(alert) {
1627
+ if (this.alerts.length === 0) return true;
1628
+ const results = await Promise.all(
1629
+ this.alerts.map(async (channel) => {
1254
1630
  try {
1255
- await channel.send(alert);
1631
+ await withTimeout(channel.send(alert), CHANNEL_TIMEOUT_MS);
1632
+ return true;
1256
1633
  } catch (e) {
1257
1634
  this.onError(e, `alert channel ${channel.name}`);
1635
+ return false;
1258
1636
  }
1259
- }
1260
- sent.push(alert);
1637
+ })
1638
+ );
1639
+ return results.includes(true);
1640
+ }
1641
+ async addTriage(alert) {
1642
+ const controller = new AbortController();
1643
+ try {
1644
+ const recentRuns = await this.store.listRuns(alert.job, 5);
1645
+ const diagnosis = await withTimeout(this.triage({ alert, recentRuns, signal: controller.signal }), TRIAGE_TIMEOUT_MS);
1646
+ if (diagnosis) alert.triage = diagnosis;
1647
+ } catch (e) {
1648
+ controller.abort();
1649
+ this.onError(e, `triage for ${alert.job}`);
1261
1650
  }
1262
- state.lastAlertAt = now;
1263
- await this.store.setState(state);
1264
- return sent;
1265
1651
  }
1266
1652
  };
1653
+ function isDevelopment() {
1654
+ return process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test";
1655
+ }
1656
+ function clampLimit(limit, fallback, min) {
1657
+ const n = typeof limit === "number" && Number.isFinite(limit) ? Math.trunc(limit) : fallback;
1658
+ return Math.min(500, Math.max(min, n));
1659
+ }
1267
1660
  function withTimeout(promise, ms) {
1268
1661
  return new Promise((resolve, reject) => {
1269
1662
  const t = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);
1663
+ if (typeof t === "object" && "unref" in t) t.unref();
1270
1664
  promise.then((v) => {
1271
1665
  clearTimeout(t);
1272
1666
  resolve(v);
@@ -1306,12 +1700,9 @@ exports.createRoutes = createRoutes;
1306
1700
  exports.cronwatch = cronwatch;
1307
1701
  exports.custom = custom;
1308
1702
  exports.formatDuration = formatDuration;
1309
- exports.formatRelative = formatRelative;
1310
- exports.json = json;
1311
1703
  exports.memory = memory;
1312
1704
  exports.nextFire = nextFire;
1313
1705
  exports.parseDuration = parseDuration;
1314
1706
  exports.parseSchedule = parseSchedule;
1315
- exports.previousFire = previousFire;
1316
1707
  //# sourceMappingURL=index.cjs.map
1317
1708
  //# sourceMappingURL=index.cjs.map