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