@cronwatch/sdk 0.1.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +27 -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 +835 -293
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.d.cts +133 -45
  18. package/dist/index.d.ts +133 -45
  19. package/dist/index.js +836 -291
  20. package/dist/index.js.map +1 -1
  21. package/dist/postgres.cjs +152 -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 +152 -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 +184 -74
  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 +185 -75
  38. package/dist/sqlite.js.map +1 -1
  39. package/dist/{types-BQ1P8z55.d.cts → types-DUt2apl0.d.cts} +84 -7
  40. package/dist/{types-BQ1P8z55.d.ts → types-DUt2apl0.d.ts} +84 -7
  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 +97 -34
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Cron } from 'croner';
2
+ import { randomBytes, 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);
119
111
  }
120
- const prev = previousFire(parsed, now);
121
- if (prev === null || prev < registeredAt) return null;
122
- return { dueAt: prev, deadline: prev + graceMs2 };
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;
123
124
  }
124
- function runCovers(startedAt, dueAt) {
125
- return startedAt >= dueAt - 6e4;
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;
137
+ }
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;
141
+ }
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,51 @@ function muteOpens(previous, next) {
290
337
  function isSilenced(state, now) {
291
338
  return state.silencedUntil !== null && state.silencedUntil > now;
292
339
  }
340
+ function applySilence(previous, evaluation, now) {
341
+ if (!isSilenced(previous, now)) return evaluation;
342
+ return { state: muteOpens(previous, evaluation.state), alerts: [] };
343
+ }
344
+ function staleAlert(alert, state) {
345
+ if (alert.type === "recovered") return alert.details.after.some((condition) => state.open[condition] !== void 0);
346
+ return state.open[alert.type] !== alert.at;
347
+ }
348
+ function jobHealth(def, lastRun, state, now) {
349
+ const open = openConditions(state);
350
+ if (isSilenced(state, now)) return "silenced";
351
+ if (open.includes("stuck") || lastRun && isStuck(def, lastRun, now)) return "stuck";
352
+ if (open.includes("failed") || lastRun?.status === "failed" || lastRun?.status === "timeout") return "failing";
353
+ if (open.includes("missed")) return "late";
354
+ if (!lastRun) return "never_ran";
355
+ return "healthy";
356
+ }
357
+ function summarize(stored, recent, state, nextExpectedAt, now) {
358
+ return summary(stored, recent, state, nextExpectedAt, (lastRun) => jobHealth(stored.definition, lastRun, state, now));
359
+ }
360
+ function unevaluableSummary(stored, recent, state, now) {
361
+ return summary(stored, recent, state, null, () => isSilenced(state, now) ? "silenced" : "failing");
362
+ }
363
+ function summary(stored, recent, state, nextExpectedAt, health) {
364
+ const window = recent.slice(0, BASELINE_WINDOW);
365
+ const lastRun = window[0] ?? null;
366
+ const finished = window.filter((r) => r.status !== "running");
367
+ const okDurations = window.filter((r) => r.status === "ok" && r.durationMs !== null).map((r) => r.durationMs);
368
+ return {
369
+ name: stored.name,
370
+ definition: stored.definition,
371
+ health: health(lastRun),
372
+ open: openConditions(state),
373
+ lastRun,
374
+ nextExpectedAt,
375
+ consecutiveFailures: state.consecutiveFailures,
376
+ silencedUntil: state.silencedUntil,
377
+ stats: {
378
+ runs: finished.length,
379
+ okRate: finished.length === 0 ? 1 : finished.filter((r) => r.status === "ok").length / finished.length,
380
+ p50Ms: percentile(okDurations, 50),
381
+ p95Ms: percentile(okDurations, 95)
382
+ }
383
+ };
384
+ }
293
385
 
294
386
  // src/format.ts
295
387
  function when(at, now) {
@@ -305,14 +397,18 @@ function tail(text, n) {
305
397
  const lines = text.trimEnd().split("\n");
306
398
  return lines.slice(Math.max(0, lines.length - n)).join("\n");
307
399
  }
400
+ function errorLine(error) {
401
+ const text = firstLines(error, 4);
402
+ return /^[A-Za-z_$][\w$]*: /.test(text) ? text : `Error: ${text}`;
403
+ }
308
404
  function composeAlert(draft, def, now) {
309
405
  const name = def.name;
310
406
  const run = draft.run;
311
- const d = draft.details;
312
407
  let title;
313
408
  const lines = [];
314
409
  switch (draft.type) {
315
410
  case "missed": {
411
+ const d = draft.details;
316
412
  title = `${name} missed its scheduled run`;
317
413
  lines.push(`Due ${when(d.dueAt, now)}, and no run had started by ${when(d.deadline, now)} (grace ${formatDuration(d.graceMs)}).`);
318
414
  lines.push(`Schedule: ${def.schedule}${def.timezone ? ` (${def.timezone})` : ""}.`);
@@ -321,11 +417,11 @@ function composeAlert(draft, def, now) {
321
417
  }
322
418
  case "failed": {
323
419
  title = `${name} failed`;
324
- const n = d.consecutiveFailures;
420
+ const n = draft.details.consecutiveFailures;
325
421
  if (n > 1) lines.push(`${n} consecutive failures.`);
326
422
  if (run) {
327
423
  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)}`);
424
+ if (run.error) lines.push(errorLine(run.error));
329
425
  const out = tail(run.output, 8);
330
426
  if (out) lines.push(`Output (tail):
331
427
  ${out}`);
@@ -344,6 +440,7 @@ ${out}`);
344
440
  break;
345
441
  }
346
442
  case "slow": {
443
+ const d = draft.details;
347
444
  title = `${name} was slow`;
348
445
  lines.push(`Took ${formatDuration(d.durationMs)}; the limit is ${formatDuration(d.thresholdMs)} (${d.basis}).`);
349
446
  if (run) lines.push(`Started ${when(run.startedAt, now)}.`);
@@ -351,7 +448,7 @@ ${out}`);
351
448
  }
352
449
  case "over_budget": {
353
450
  title = `${name} went over budget`;
354
- for (const b of d.breaches) {
451
+ for (const b of draft.details.breaches) {
355
452
  lines.push(`${b.metric}: ${formatNumber(b.value)}, limit ${formatNumber(b.limit)} (${b.basis}).`);
356
453
  }
357
454
  if (run) lines.push(`Started ${when(run.startedAt, now)}.`);
@@ -359,22 +456,13 @@ ${out}`);
359
456
  }
360
457
  case "recovered": {
361
458
  title = `${name} recovered`;
362
- const after = d.after.map((c) => c.replace("_", " ")).join(", ");
459
+ const after = draft.details.after.map((c) => c.replace("_", " ")).join(", ");
363
460
  lines.push(`A run ${run ? when(run.startedAt, now) : "just now"} succeeded${after ? ` after: ${after}` : ""}.`);
364
461
  if (run?.durationMs !== null && run?.durationMs !== void 0) lines.push(`Ran ${formatDuration(run.durationMs)}.`);
365
462
  break;
366
463
  }
367
464
  }
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
- };
465
+ return { ...draft, job: name, definition: def, title, message: lines.join("\n"), at: now };
378
466
  }
379
467
 
380
468
  // src/http.ts
@@ -384,26 +472,85 @@ function json(body, status = 200, headers = {}) {
384
472
  headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...headers }
385
473
  });
386
474
  }
475
+ function constantTimeEqual(a, b) {
476
+ if (a.length !== b.length) return false;
477
+ let diff = 0;
478
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
479
+ return diff === 0;
480
+ }
387
481
 
388
482
  // src/output.ts
389
483
  var OUTPUT_CAP = 16 * 1024;
390
484
  function capOutput(text) {
391
- if (text.length <= OUTPUT_CAP) return text;
392
- return "[earlier output trimmed]\n" + text.slice(text.length - OUTPUT_CAP);
485
+ const clean = stripNul(text);
486
+ if (clean.length <= OUTPUT_CAP) return clean;
487
+ return "[earlier output trimmed]\n" + clean.slice(clean.length - OUTPUT_CAP);
488
+ }
489
+ function stripNul(text) {
490
+ return text.includes("\0") ? text.replace(/\u0000/g, "") : text;
393
491
  }
394
492
  function errorMessage(error) {
493
+ return capOutput(describeError(error));
494
+ }
495
+ function describeError(error) {
395
496
  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}`;
497
+ const frames = (error.stack ?? "").split("\n").filter((line) => /^\s+at /.test(line)).slice(0, 5);
498
+ return `${error.name}: ${error.message}${frames.length ? `
499
+ ${frames.join("\n")}` : ""}`;
399
500
  }
400
501
  if (typeof error === "string") return error;
401
502
  try {
402
- return JSON.stringify(error);
503
+ return JSON.stringify(error) ?? String(error);
403
504
  } catch {
404
505
  return String(error);
405
506
  }
406
507
  }
508
+ var REDACTED = "[redacted]";
509
+ var SECRET_PATTERNS = [
510
+ // A PEM private key, header to footer. Without a footer (the output was
511
+ // trimmed) it runs to the end of the base64 body. A "-" that starts five
512
+ // dashes ends the body, so the footer is never swallowed into it.
513
+ [
514
+ /-----BEGIN (?:[A-Z0-9]{1,20} ){0,3}PRIVATE KEY-----(?:[A-Za-z0-9+/=\s,:]|-(?!----)){0,16384}(?:-----END (?:[A-Z0-9]{1,20} ){0,3}PRIVATE KEY-----)?/g,
515
+ REDACTED
516
+ ],
517
+ // password=..., API_KEY: ..., "client_secret": "...", TOKEN='...', token=...,
518
+ // :password=>"..." (but not max_tokens: 800). A quoted value is blanked to
519
+ // its closing quote, spaces and all, and keeps its quotes.
520
+ [
521
+ /\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,
522
+ (_match, name, double, single) => {
523
+ const quote = double ?? single ?? "";
524
+ return `${name}${quote}${REDACTED}${quote}`;
525
+ }
526
+ ],
527
+ // Authorization: Basic <base64> and Authorization: Token <token>, also as a JSON or hash entry.
528
+ [/\b((?:proxy-)?authorization["']?\s{0,3}(?:=>|[=:])\s{0,3}["']?\s{0,3}(?:basic|token)\s{1,3})[A-Za-z0-9._~+/=:-]{1,4096}/gi, `$1${REDACTED}`],
529
+ // Credentials inside a URL: postgres://user:password@host. The password
530
+ // runs to the last "@" before a "/" or a space, so one that contains "@"
531
+ // is blanked whole.
532
+ [/(\b[a-z][a-z0-9+.-]{0,30}:\/\/[^\s/:@]{0,256}:)[^\s/]{1,256}@/gi, `$1${REDACTED}@`],
533
+ // Authorization: Bearer <token>
534
+ [/\b(Bearer\s{1,3})[A-Za-z0-9._~+/=-]{8,4096}/g, `$1${REDACTED}`],
535
+ // A bare JWT: three base64url segments, the first starting eyJ.
536
+ [/\beyJ[A-Za-z0-9_-]{4,4096}\.[A-Za-z0-9_-]{4,4096}\.[A-Za-z0-9_-]{0,4096}/g, REDACTED],
537
+ // Incoming webhook URLs carry their secret in the path.
538
+ [/(\bhooks\.slack\.com\/(?:services|workflows|triggers)\/)[A-Za-z0-9/_-]{1,255}/gi, `$1${REDACTED}`],
539
+ [/(\bdiscord(?:app)?\.com\/api\/(?:v\d{1,2}\/)?webhooks\/)[A-Za-z0-9/_-]{1,255}/gi, `$1${REDACTED}`],
540
+ // Well-known token shapes: AWS, GitHub, Slack, Stripe, Anthropic, OpenAI and Google style keys.
541
+ [/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, REDACTED],
542
+ [/\b(?:gh[pousr]_[A-Za-z0-9]{30,255}|github_pat_[A-Za-z0-9_]{20,255})\b/g, REDACTED],
543
+ [/\bxox[abposr]-[A-Za-z0-9-]{10,255}/g, REDACTED],
544
+ [/\b[rsp]k_(?:live|test)_[A-Za-z0-9]{10,255}\b/g, REDACTED],
545
+ [/\bwhsec_[A-Za-z0-9+/=]{16,255}/g, REDACTED],
546
+ [/\bsk-[A-Za-z0-9_-]{20,255}/g, REDACTED],
547
+ [/\bAIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-])/g, REDACTED]
548
+ ];
549
+ function redactSecrets(text) {
550
+ let out = text;
551
+ for (const [pattern, replacement] of SECRET_PATTERNS) out = out.replace(pattern, replacement);
552
+ return out;
553
+ }
407
554
 
408
555
  // src/job.ts
409
556
  function stringify(part) {
@@ -418,6 +565,9 @@ function stringify(part) {
418
565
  function createRecorder(run) {
419
566
  const lines = [];
420
567
  let size = 0;
568
+ const head = [];
569
+ let headSize = 0;
570
+ let dropped = false;
421
571
  const metrics = {};
422
572
  const controller = new AbortController();
423
573
  const context = {
@@ -427,10 +577,15 @@ function createRecorder(run) {
427
577
  signal: controller.signal,
428
578
  log(...parts) {
429
579
  const line = parts.map(stringify).join(" ");
580
+ if (headSize < OUTPUT_CAP) {
581
+ head.push(line);
582
+ headSize += line.length + 1;
583
+ }
430
584
  lines.push(line);
431
585
  size += line.length + 1;
432
586
  while (size > 64 * 1024 && lines.length > 1) {
433
587
  size -= lines.shift().length + 1;
588
+ dropped = true;
434
589
  }
435
590
  },
436
591
  metric(name, value) {
@@ -446,6 +601,12 @@ function createRecorder(run) {
446
601
  return {
447
602
  context,
448
603
  output: () => lines.length === 0 ? null : capOutput(lines.join("\n")),
604
+ expectText: () => {
605
+ if (lines.length === 0) return null;
606
+ const all = lines.join("\n");
607
+ if (!dropped && all.length <= 2 * OUTPUT_CAP) return all;
608
+ return head.join("\n").slice(0, OUTPUT_CAP) + "\n" + all.slice(-OUTPUT_CAP);
609
+ },
449
610
  metrics: () => ({ ...metrics }),
450
611
  abort: () => controller.abort()
451
612
  };
@@ -467,6 +628,7 @@ function checkExpectation(expect, output) {
467
628
  return text.includes(expect) ? null : `Output did not contain ${JSON.stringify(expect)}`;
468
629
  }
469
630
  if (expect instanceof RegExp) {
631
+ expect.lastIndex = 0;
470
632
  return expect.test(text) ? null : `Output did not match ${expect.toString()}`;
471
633
  }
472
634
  let ok = false;
@@ -511,6 +673,8 @@ button:hover{border-color:var(--muted)}
511
673
  .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
674
  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
675
  details summary{cursor:pointer;color:var(--muted);font-size:13px}details{margin-top:6px}
676
+ 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)}
677
+ 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
678
  .empty{padding:40px 16px;text-align:center;color:var(--muted)}
515
679
  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
680
  footer{margin-top:28px;color:var(--muted);font-size:12px}
@@ -614,13 +778,13 @@ function jobPage(job, runs, now, base) {
614
778
  <div class="actions">
615
779
  ${healthPill(job)}
616
780
  ${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>
781
+ <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
782
  </div>
619
783
  </header>
620
784
  <div class="grid">
621
785
  <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
786
  <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>
787
+ <div class="card stat"><div class="k">Success, last ${h(job.stats.runs)}</div><div class="v">${h(okRate)}</div></div>
624
788
  <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
789
  </div>
626
790
  <div class="card" style="margin-bottom:20px">
@@ -631,11 +795,11 @@ function jobPage(job, runs, now, base) {
631
795
  ${d.maxDuration ? `<dt>Max duration</dt><dd class="mono">${h(d.maxDuration)}</dd>` : ""}
632
796
  ${d.budget ? `<dt>Budget</dt><dd class="mono">${h(Object.entries(d.budget).map(([k, v]) => `${k} \u2264 ${v}`).join(", "))}</dd>` : ""}
633
797
  ${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>` : ""}
798
+ ${d.failuresBeforeAlert && d.failuresBeforeAlert > 1 ? `<dt>Alert after</dt><dd>${h(d.failuresBeforeAlert)} consecutive failures</dd>` : ""}
635
799
  ${d.description ? `<dt>Description</dt><dd>${h(d.description)}</dd>` : ""}
636
800
  ${d.tags?.length ? `<dt>Tags</dt><dd>${d.tags.map((t) => `<span class="pill mutedpill">${h(t)}</span>`).join(" ")}</dd>` : ""}
637
801
  ${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>` : ""}
802
+ ${job.consecutiveFailures > 0 ? `<dt>Consecutive failures</dt><dd>${h(job.consecutiveFailures)}</dd>` : ""}
639
803
  </dl>
640
804
  </div>
641
805
  <div class="card">
@@ -652,18 +816,32 @@ function messagePage(title, message, base) {
652
816
 
653
817
  // src/routes/index.ts
654
818
  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;
819
+ function cookieValue(token) {
820
+ return createHash("sha256").update(`cronwatch-cookie:${token}`).digest("hex");
821
+ }
822
+ var DEFAULT_RUNS = 20;
823
+ var MAX_RUNS = 500;
824
+ var CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none'";
825
+ var SECURITY_HEADERS = { "x-content-type-options": "nosniff", "referrer-policy": "same-origin", "x-robots-tag": "noindex" };
826
+ function developmentToken() {
827
+ return randomBytes(32).toString("base64url");
828
+ }
829
+ function developmentSignInLine(origin, base, token) {
830
+ return `[cronwatch] CRONWATCH_TOKEN is not set, so this development server made a token for the dashboard. Sign in: ${origin}${base}/?token=${token}`;
831
+ }
832
+ function safeDecode(value) {
833
+ try {
834
+ return decodeURIComponent(value);
835
+ } catch {
836
+ return null;
837
+ }
660
838
  }
661
839
  function readCookie(request, name) {
662
840
  const header = request.headers.get("cookie");
663
841
  if (!header) return null;
664
842
  for (const part of header.split(";")) {
665
843
  const [k, ...rest] = part.trim().split("=");
666
- if (k === name) return decodeURIComponent(rest.join("="));
844
+ if (k === name) return safeDecode(rest.join("="));
667
845
  }
668
846
  return null;
669
847
  }
@@ -691,56 +869,81 @@ async function readBody(request) {
691
869
  }
692
870
  return {};
693
871
  }
872
+ function crossSite(request, url) {
873
+ const origin = request.headers.get("origin");
874
+ if (origin !== null && origin !== url.origin) return true;
875
+ const site = request.headers.get("sec-fetch-site");
876
+ return site !== null && site !== "same-origin" && site !== "none";
877
+ }
878
+ function silenceDuration(value) {
879
+ if (value === void 0 || value === null) return "1h";
880
+ const text = value.trim();
881
+ const duration = /^\d+(\.\d+)?$/.test(text) ? Number(text) : text;
882
+ parseDuration(duration, "silence duration");
883
+ return duration;
884
+ }
885
+ function runsLimit(value) {
886
+ const n = value === null || value.trim() === "" ? NaN : Math.trunc(Number(value));
887
+ return Number.isFinite(n) ? Math.min(MAX_RUNS, Math.max(1, n)) : DEFAULT_RUNS;
888
+ }
694
889
  function createRoutes(cw, options = {}) {
695
- const token = options.token === void 0 ? process.env.CRONWATCH_TOKEN ?? null : options.token;
890
+ const optedOut = options.token === null;
891
+ const configured = optedOut ? null : options.token || process.env.CRONWATCH_TOKEN || null;
696
892
  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");
893
+ const developing = process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test";
894
+ const generated = !configured && !optedOut && developing;
895
+ const token = generated ? developmentToken() : configured;
896
+ let announced = false;
897
+ const serve = async (request, url, path, wantsHtml) => {
702
898
  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);
899
+ if (generated && !announced) {
900
+ announced = true;
901
+ console.info(developmentSignInLine(url.origin, base, token));
705
902
  }
903
+ if (!token && !optedOut) {
904
+ return wantsHtml ? html(messagePage("CronWatch routes are locked", "Set CRONWATCH_TOKEN (or pass token to cw.routes()), or pass token: null to serve them open behind your own auth.", base), 503) : api({ ok: false, error: "CRONWATCH_TOKEN is not set" }, 503);
905
+ }
906
+ if (method !== "GET" && method !== "HEAD" && crossSite(request, url)) {
907
+ 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);
908
+ }
909
+ const bearer = request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") ?? null;
706
910
  if (token) {
707
- const bearer = request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") ?? null;
708
- const query = url.searchParams.get("token");
911
+ const query = wantsHtml && method === "GET" ? url.searchParams.get("token") : null;
709
912
  const cookie = readCookie(request, COOKIE);
710
- const presented = bearer ?? query ?? cookie;
711
913
  const isCheck = path === "/api/check";
712
914
  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);
915
+ const tokenOk = bearer !== null ? constantTimeEqual(bearer, token) : query !== null ? constantTimeEqual(query, token) : cookie !== null && constantTimeEqual(cookie, cookieValue(token));
916
+ if (!cronSecretOk && !tokenOk) {
917
+ if (generated) {
918
+ return wantsHtml ? html(messagePage("Sign in", "CRONWATCH_TOKEN is not set, so this development server made a token. The sign-in link is in the server log: open it once and this browser stays signed in.", base), 401) : api({ ok: false, error: "Unauthorized: CRONWATCH_TOKEN is not set, so this development server made a token; it is in the server log" }, 401);
919
+ }
920
+ 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
921
  }
716
- if (query !== null && wantsHtml && method === "GET") {
922
+ if (query !== null) {
717
923
  url.searchParams.delete("token");
718
924
  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
- }
925
+ return redirect(url.pathname + (url.search || ""), {
926
+ "set-cookie": `${COOKIE}=${cookieValue(token)}; Path=${base || "/"}; HttpOnly; SameSite=Lax; Max-Age=${60 * 60 * 24 * 30}${secure}`
725
927
  });
726
928
  }
727
929
  }
728
930
  const redirectBack = () => {
729
931
  const referer = request.headers.get("referer") ?? "";
730
- const location = referer.startsWith(url.origin + "/") ? referer : `${base}/`;
731
- return new Response(null, { status: 303, headers: { location } });
932
+ return redirect(referer.startsWith(url.origin + "/") ? referer : `${base}/`);
732
933
  };
733
- const jobName = (segment) => segment ? decodeURIComponent(segment) : "";
734
- const parts = path.split("/").filter(Boolean);
934
+ const decoded = path.split("/").filter(Boolean).map(safeDecode);
935
+ if (decoded.some((part) => part === null)) {
936
+ return wantsHtml ? html(messagePage("Bad request", "The path is not valid.", base), 400) : api({ ok: false, error: "Bad path" }, 400);
937
+ }
938
+ const parts = decoded;
735
939
  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));
940
+ const entries = await cw.jobsWithRuns(20);
941
+ const runsByJob = new Map(entries.map((entry) => [entry.job.name, entry.runs]));
942
+ return html(dashboardPage(entries.map((entry) => entry.job), runsByJob, cw.now(), base));
740
943
  }
741
944
  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);
945
+ const job = await cw.jobSummary(parts[1]);
946
+ if (!job) return html(messagePage("No such job", `${parts[1]} is not in the store.`, base), 404);
744
947
  return html(jobPage(job, await cw.runs(job.name, 50), cw.now(), base));
745
948
  }
746
949
  if (method === "POST" && path === "/check") {
@@ -748,68 +951,107 @@ function createRoutes(cw, options = {}) {
748
951
  return redirectBack();
749
952
  }
750
953
  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") {
954
+ const name = parts[1];
955
+ if (parts[2] === "forget") {
756
956
  await cw.forget(name);
757
- return new Response(null, { status: 303, headers: { location: `${base}/` } });
758
- } else return html(messagePage("Not found", path, base), 404);
957
+ return redirect(`${base}/`);
958
+ }
959
+ if (parts[2] !== "silence" && parts[2] !== "unsilence") return html(messagePage("Not found", path, base), 404);
960
+ if (!await cw.jobSummary(name)) return html(messagePage("No such job", `${name} is not in the store.`, base), 404);
961
+ if (parts[2] === "silence") {
962
+ let duration;
963
+ try {
964
+ duration = silenceDuration((await readBody(request)).for);
965
+ } catch (e) {
966
+ return html(messagePage("Not silenced", e.message, base), 400);
967
+ }
968
+ await cw.silence(name, duration);
969
+ } else await cw.unsilence(name);
759
970
  return redirectBack();
760
971
  }
761
972
  if (parts[0] === "api") {
762
973
  const rest = parts.slice(1);
763
974
  if (method === "GET" && rest[0] === "jobs" && rest.length === 1) {
764
- return json({ ok: true, jobs: await cw.jobs() });
975
+ return api({ ok: true, jobs: await cw.jobs() });
765
976
  }
766
977
  if (rest[0] === "jobs" && rest.length === 2) {
767
- const name = jobName(rest[1]);
978
+ const name = rest[1];
768
979
  if (method === "GET") {
769
980
  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) });
981
+ if (!job) return api({ ok: false, error: "No such job" }, 404);
982
+ return api({ ok: true, job, runs: await cw.runs(name, runsLimit(url.searchParams.get("runs"))) });
773
983
  }
774
984
  if (method === "DELETE") {
985
+ if (!await cw.jobSummary(name)) return api({ ok: false, error: "No such job" }, 404);
775
986
  await cw.forget(name);
776
- return json({ ok: true });
987
+ return api({ ok: true });
777
988
  }
778
989
  }
779
990
  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) });
991
+ const name = rest[1];
992
+ if (!await cw.jobSummary(name)) return api({ ok: false, error: "No such job" }, 404);
993
+ if (rest[2] === "silence") {
994
+ const body = await readBody(request);
995
+ let duration;
996
+ try {
997
+ duration = silenceDuration(body.for ?? url.searchParams.get("for"));
998
+ } catch (e) {
999
+ return api({ ok: false, error: e.message }, 400);
1000
+ }
1001
+ return api({ ok: true, state: await cw.silence(name, duration) });
1002
+ }
1003
+ if (rest[2] === "unsilence") return api({ ok: true, state: await cw.unsilence(name) });
785
1004
  }
786
- if ((method === "GET" || method === "POST") && rest[0] === "check" && rest.length === 1) {
787
- const result = await cw.check();
788
- return json({ ok: true, ...result });
1005
+ if (rest[0] === "check" && rest.length === 1) {
1006
+ if (method === "GET" && bearer === null) {
1007
+ return api({ ok: false, error: "Use POST, or GET with an Authorization bearer" }, 405, { allow: "POST" });
1008
+ }
1009
+ if (method === "GET" || method === "POST") {
1010
+ const result = await cw.check();
1011
+ return api({ ok: true, ...result });
1012
+ }
789
1013
  }
790
1014
  if (method === "GET" && rest[0] === "runs" && rest.length === 2) {
791
1015
  const run = await cw.getRun(rest[1]);
792
- return run ? json({ ok: true, run }) : json({ ok: false, error: "No such run" }, 404);
1016
+ return run ? api({ ok: true, run }) : api({ ok: false, error: "No such run" }, 404);
793
1017
  }
794
- return json({ ok: false, error: "Not found" }, 404);
1018
+ return api({ ok: false, error: "Not found" }, 404);
795
1019
  }
796
1020
  return html(messagePage("Not found", path, base), 404);
797
1021
  };
1022
+ const handler = async (request) => {
1023
+ let wantsHtml = true;
1024
+ try {
1025
+ const url = new URL(request.url);
1026
+ const path = stripBase(url.pathname, base);
1027
+ wantsHtml = !path.startsWith("/api");
1028
+ return await serve(request, url, path, wantsHtml);
1029
+ } catch (e) {
1030
+ try {
1031
+ cw.onError(e, "routes");
1032
+ } catch {
1033
+ }
1034
+ return wantsHtml ? html(messagePage("Something went wrong", "The request failed and the error was reported.", base), 500) : api({ ok: false, error: "Internal error" }, 500);
1035
+ }
1036
+ };
798
1037
  return { handler, GET: handler, POST: handler, DELETE: handler };
799
1038
  }
800
- function safeDuration(value, fallback) {
801
- if (!value) return fallback;
802
- try {
803
- parseDuration(value);
804
- return value;
805
- } catch {
806
- return fallback;
807
- }
1039
+ function api(body, status = 200, headers = {}) {
1040
+ return json(body, status, { ...SECURITY_HEADERS, ...headers });
1041
+ }
1042
+ function redirect(location, headers = {}) {
1043
+ return new Response(null, { status: 303, headers: { location, "cache-control": "no-store", ...SECURITY_HEADERS, ...headers } });
808
1044
  }
809
1045
  function html(body, status = 200) {
810
1046
  return new Response(body, {
811
1047
  status,
812
- headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "x-robots-tag": "noindex" }
1048
+ headers: {
1049
+ "content-type": "text/html; charset=utf-8",
1050
+ "cache-control": "no-store",
1051
+ "content-security-policy": CSP,
1052
+ "x-frame-options": "DENY",
1053
+ ...SECURITY_HEADERS
1054
+ }
813
1055
  });
814
1056
  }
815
1057
 
@@ -821,6 +1063,7 @@ function memory() {
821
1063
  const states = /* @__PURE__ */ new Map();
822
1064
  let seq = 0;
823
1065
  const clone = (v) => JSON.parse(JSON.stringify(v));
1066
+ const byName = (a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
824
1067
  return {
825
1068
  async upsertJob(definition, now) {
826
1069
  const existing = jobs.get(definition.name);
@@ -836,7 +1079,7 @@ function memory() {
836
1079
  return j ? clone(j) : null;
837
1080
  },
838
1081
  async listJobs() {
839
- return [...jobs.values()].map(clone).sort((a, b) => a.name.localeCompare(b.name));
1082
+ return [...jobs.values()].map(clone).sort(byName);
840
1083
  },
841
1084
  async deleteJob(name) {
842
1085
  jobs.delete(name);
@@ -851,7 +1094,10 @@ function memory() {
851
1094
  order.set(run.id, ++seq);
852
1095
  },
853
1096
  async updateRun(run) {
854
- runs.set(run.id, clone(run));
1097
+ const existing = runs.get(run.id);
1098
+ if (!existing) return;
1099
+ const { status, finishedAt, durationMs, error, output, metrics } = clone(run);
1100
+ runs.set(run.id, { ...existing, status, finishedAt, durationMs, error, output, metrics });
855
1101
  },
856
1102
  async getRun(id) {
857
1103
  const r = runs.get(id);
@@ -865,7 +1111,7 @@ function memory() {
865
1111
  return list[0] ?? null;
866
1112
  },
867
1113
  async runningRuns() {
868
- return [...runs.values()].filter((r) => r.status === "running").map(clone);
1114
+ 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
1115
  },
870
1116
  async getState(job) {
871
1117
  const s = states.get(job);
@@ -874,10 +1120,17 @@ function memory() {
874
1120
  async setState(state) {
875
1121
  states.set(state.job, clone(state));
876
1122
  },
1123
+ async compareAndSetState(state, expectedVersion) {
1124
+ if ((states.get(state.job)?.version ?? 0) !== expectedVersion) return false;
1125
+ states.set(state.job, clone(state));
1126
+ return true;
1127
+ },
877
1128
  async prune(before) {
1129
+ const newest = /* @__PURE__ */ new Map();
1130
+ for (const run of runs.values()) newest.set(run.job, Math.max(newest.get(run.job) ?? -Infinity, run.startedAt));
878
1131
  let n = 0;
879
1132
  for (const [id, run] of runs) {
880
- if (run.status !== "running" && run.startedAt < before) {
1133
+ if (run.status !== "running" && run.startedAt < before && run.startedAt < newest.get(run.job)) {
881
1134
  runs.delete(id);
882
1135
  order.delete(id);
883
1136
  n++;
@@ -891,37 +1144,104 @@ function memory() {
891
1144
  // src/client.ts
892
1145
  var NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/;
893
1146
  var TRIAGE_TIMEOUT_MS = 25e3;
1147
+ var CHANNEL_TIMEOUT_MS = 15e3;
894
1148
  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;
1149
+ var MAX_UNDELIVERED = 20;
1150
+ var RETRY_BUDGET_MS = 2e4;
1151
+ var STATE_ATTEMPTS = 10;
1152
+ var HISTORY_PAGE = BASELINE_WINDOW + 5;
1153
+ var HISTORY_MAX = 200;
1154
+ function validateDefinition(def) {
1155
+ const name = def.name;
1156
+ if (def.schedule !== void 0) {
1157
+ if (typeof def.schedule !== "string" || def.schedule.trim() === "") throw new Error(`job "${name}": schedule must be a non-empty string`);
1158
+ parseSchedule(def.schedule, def.timezone);
1159
+ }
1160
+ if (def.timezone !== void 0) {
1161
+ try {
1162
+ new Intl.DateTimeFormat("en-US", { timeZone: def.timezone });
1163
+ } catch {
1164
+ throw new Error(`job "${name}": timezone "${def.timezone}" is not an IANA timezone`);
1165
+ }
1166
+ }
1167
+ if (def.grace !== void 0) parseDuration(def.grace, "grace");
1168
+ if (def.timeout !== void 0 && parseDuration(def.timeout, "timeout") <= 0) throw new Error(`job "${name}": timeout must be longer than zero`);
1169
+ if (def.maxDuration !== void 0 && parseDuration(def.maxDuration, "maxDuration") <= 0) {
1170
+ throw new Error(`job "${name}": maxDuration must be longer than zero`);
1171
+ }
1172
+ if (def.failuresBeforeAlert !== void 0 && !(Number.isInteger(def.failuresBeforeAlert) && def.failuresBeforeAlert >= 1)) {
1173
+ throw new Error(`job "${name}": failuresBeforeAlert must be a whole number, 1 or more (got ${String(def.failuresBeforeAlert)})`);
1174
+ }
1175
+ if (def.budget !== void 0) {
1176
+ if (typeof def.budget !== "object" || def.budget === null) throw new Error(`job "${name}": budget must be an object of { metric: ceiling }`);
1177
+ for (const [metric, ceiling] of Object.entries(def.budget)) {
1178
+ if (typeof ceiling !== "number" || !Number.isFinite(ceiling) || ceiling < 0) {
1179
+ throw new Error(`job "${name}": budget.${metric} must be a finite number, 0 or more (got ${String(ceiling)})`);
1180
+ }
1181
+ }
1182
+ }
1183
+ if (def.expect !== void 0 && typeof def.expect !== "string" && !(def.expect instanceof RegExp) && typeof def.expect !== "function") {
1184
+ throw new Error(`job "${name}": expect must be a string, a RegExp or a function`);
1185
+ }
1186
+ }
1187
+ function sameState(a, b) {
1188
+ return JSON.stringify(a) === JSON.stringify(b);
1189
+ }
1190
+ function alertKey(alert) {
1191
+ return `${alert.type}|${alert.at}|${alert.run?.id ?? ""}`;
900
1192
  }
901
1193
  var CronWatch = class {
902
1194
  store;
903
1195
  alerts;
904
1196
  triage;
1197
+ /** The secret handler() requests must carry, or null when none is set. */
905
1198
  cronSecret;
906
1199
  retentionMs;
907
1200
  now;
908
1201
  onError;
1202
+ /** cronSecret was passed as null: handlers may run without a secret. */
1203
+ secretOptOut;
1204
+ redact;
1205
+ /** "check": queue alerts for another process's check instead of sending them. See CronWatchOptions.deliver. */
1206
+ deferDelivery;
909
1207
  defaults;
910
1208
  definitions = /* @__PURE__ */ new Map();
911
1209
  synced = /* @__PURE__ */ new Set();
1210
+ /** The tail of each job's queue of state updates. See serial(). */
1211
+ queues = /* @__PURE__ */ new Map();
912
1212
  ready = null;
913
1213
  checking = null;
914
1214
  lastPruneAt = 0;
915
1215
  timer = null;
1216
+ firstTick = null;
916
1217
  usingDefaultStore = false;
1218
+ warnedNoSecret = false;
1219
+ warnedDeferredStart = false;
917
1220
  constructor(options = {}) {
918
1221
  this.store = options.store ?? (this.usingDefaultStore = true, memory());
919
1222
  this.alerts = options.alerts ?? [consoleChannel()];
920
1223
  this.triage = options.triage;
921
- this.cronSecret = options.cronSecret === void 0 ? process.env.CRON_SECRET ?? null : options.cronSecret;
1224
+ const secret = options.cronSecret === void 0 ? process.env.CRON_SECRET : options.cronSecret;
1225
+ this.cronSecret = secret ? secret : null;
1226
+ this.secretOptOut = options.cronSecret === null;
922
1227
  this.retentionMs = parseDuration(options.retention ?? "30d", "retention");
923
1228
  this.defaults = options.defaults ?? {};
924
1229
  this.now = options.now ?? (() => Date.now());
1230
+ const redact = options.redact;
1231
+ this.redact = redact === false ? (text) => text : typeof redact === "function" ? (text) => {
1232
+ try {
1233
+ const out = redact(text);
1234
+ if (typeof out !== "string") throw new TypeError(`redact must return a string, not ${out === null ? "null" : typeof out}`);
1235
+ return out;
1236
+ } catch (e) {
1237
+ this.report(e, "redact");
1238
+ return redactSecrets(text);
1239
+ }
1240
+ } : redactSecrets;
1241
+ if (options.deliver !== void 0 && options.deliver !== "now" && options.deliver !== "check") {
1242
+ throw new Error(`deliver must be "now" or "check", not ${JSON.stringify(options.deliver)}`);
1243
+ }
1244
+ this.deferDelivery = options.deliver === "check";
925
1245
  this.onError = options.onError ?? ((error, where) => console.error(`[cronwatch] ${where}:`, error));
926
1246
  }
927
1247
  /** Declare a job. Call it once, at module level, and keep the handle. */
@@ -930,10 +1250,7 @@ var CronWatch = class {
930
1250
  throw new Error(`job name "${name}" must be 1 to 120 characters of letters, digits, ".", "_", ":" or "-"`);
931
1251
  }
932
1252
  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");
1253
+ validateDefinition(definition);
937
1254
  this.definitions.set(name, definition);
938
1255
  this.synced.delete(name);
939
1256
  return this.handle(definition);
@@ -959,11 +1276,17 @@ var CronWatch = class {
959
1276
  return outcome.result;
960
1277
  },
961
1278
  handler(fn, options) {
962
- const secret = options?.secret === void 0 ? self.cronSecret : options.secret;
1279
+ const own = options?.secret;
1280
+ const secret = own === null ? null : own ? own : self.cronSecret;
1281
+ const optedOut = own === null || !own && self.secretOptOut;
963
1282
  return async (request) => {
1283
+ if (!secret && !optedOut && !isDevelopment()) {
1284
+ self.warnNoSecret();
1285
+ 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);
1286
+ }
964
1287
  if (secret) {
965
1288
  const header = request.headers.get("authorization") ?? "";
966
- if (!constantTimeEqual2(header, `Bearer ${secret}`)) {
1289
+ if (!constantTimeEqual(header, `Bearer ${secret}`)) {
967
1290
  return json({ ok: false, error: "Unauthorized" }, 401);
968
1291
  }
969
1292
  }
@@ -975,13 +1298,26 @@ var CronWatch = class {
975
1298
  run: outcome.run.id,
976
1299
  status: outcome.run.status,
977
1300
  durationMs: outcome.run.durationMs,
978
- ...outcome.run.error ? { error: outcome.run.error.split("\n")[0] } : {}
1301
+ // Error text only goes to a caller who proved they hold the secret.
1302
+ ...secret && outcome.run.error ? { error: outcome.run.error.split("\n")[0] } : {}
979
1303
  };
980
1304
  return json(body, outcome.run.status === "ok" ? 200 : 500);
981
1305
  };
982
1306
  }
983
1307
  };
984
1308
  }
1309
+ warnNoSecret() {
1310
+ if (this.warnedNoSecret) return;
1311
+ this.warnedNoSecret = true;
1312
+ this.onError(new Error("handler() refused a request because no CRON_SECRET is set; pass secret: null to allow unauthenticated requests"), "handler");
1313
+ }
1314
+ /** onError, for places that must carry on even when onError itself throws. */
1315
+ report(error, where) {
1316
+ try {
1317
+ this.onError(error, where);
1318
+ } catch {
1319
+ }
1320
+ }
985
1321
  async ensureReady() {
986
1322
  if (!this.ready) {
987
1323
  this.ready = (async () => {
@@ -989,7 +1325,10 @@ var CronWatch = class {
989
1325
  if (this.usingDefaultStore && process.env.NODE_ENV === "production") {
990
1326
  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
1327
  }
992
- })();
1328
+ })().catch((error) => {
1329
+ this.ready = null;
1330
+ throw error;
1331
+ });
993
1332
  }
994
1333
  await this.ready;
995
1334
  }
@@ -999,13 +1338,69 @@ var CronWatch = class {
999
1338
  await this.store.upsertJob(toStored(definition), this.now());
1000
1339
  this.synced.add(definition.name);
1001
1340
  }
1002
- /** Runs a function as a recorded run. Never throws for the job's own error; see `threw`. */
1341
+ /**
1342
+ * Runs `fn` after every earlier state update for the same job has settled,
1343
+ * so two runs (or a run and a check) in this process never read and write
1344
+ * the job's state over each other. Other processes are coordinated by
1345
+ * updateState() instead.
1346
+ */
1347
+ serial(job, fn) {
1348
+ const result = (this.queues.get(job) ?? Promise.resolve()).then(fn);
1349
+ const tail2 = result.then(() => {
1350
+ }, () => {
1351
+ });
1352
+ this.queues.set(job, tail2);
1353
+ void tail2.then(() => {
1354
+ if (this.queues.get(job) === tail2) this.queues.delete(job);
1355
+ });
1356
+ return result;
1357
+ }
1358
+ async readState(job) {
1359
+ return normalizeState(await this.store.getState(job), job);
1360
+ }
1361
+ /**
1362
+ * Every read-modify-write of a job's state goes through here. In turn with
1363
+ * this process's other updates to the job (serial()), it reads the state,
1364
+ * asks `change` for the next one, and writes it with the version one
1365
+ * higher, only if the stored version is still the one read. When another
1366
+ * process wrote in between, the write is refused and it starts again from
1367
+ * a fresh read, up to STATE_ATTEMPTS times. So `change` may run more than
1368
+ * once and must only compute: whatever it returns from the attempt that
1369
+ * was written is the result. Nothing is written when the state is
1370
+ * unchanged. Returns the state as stored.
1371
+ */
1372
+ updateState(job, change) {
1373
+ return this.serial(job, async () => {
1374
+ for (let attempt = 1; ; attempt++) {
1375
+ const current = await this.readState(job);
1376
+ const { state, result } = await change(current);
1377
+ if (sameState(state, current)) return { state: current, result };
1378
+ const version = current.version ?? 0;
1379
+ const next = { ...state, version: version + 1 };
1380
+ if (await this.writeState(next, version)) return { state: next, result };
1381
+ if (attempt >= STATE_ATTEMPTS) {
1382
+ throw new Error(`the state of ${job} changed under ${STATE_ATTEMPTS} attempts in a row to update it; gave up`);
1383
+ }
1384
+ }
1385
+ });
1386
+ }
1387
+ /** A conditional write, or for a store without compareAndSetState, a plain one that always succeeds. */
1388
+ async writeState(state, expectedVersion) {
1389
+ if (typeof this.store.compareAndSetState === "function") return this.store.compareAndSetState(state, expectedVersion);
1390
+ await this.store.setState(state);
1391
+ return true;
1392
+ }
1393
+ /**
1394
+ * Runs a function as a recorded run. The function always runs, whatever
1395
+ * the store is doing: store errors go to onError, and the result is the
1396
+ * function's own outcome. Never throws for the job's own error; see `threw`.
1397
+ */
1003
1398
  async execute(definition, fn, trigger) {
1004
- await this.sync(definition);
1399
+ const name = definition.name;
1005
1400
  const startedAt = this.now();
1006
1401
  const run = {
1007
1402
  id: crypto.randomUUID(),
1008
- job: definition.name,
1403
+ job: name,
1009
1404
  status: "running",
1010
1405
  startedAt,
1011
1406
  finishedAt: null,
@@ -1015,12 +1410,16 @@ var CronWatch = class {
1015
1410
  metrics: {},
1016
1411
  trigger
1017
1412
  };
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);
1413
+ let recorded = false;
1414
+ try {
1415
+ await this.sync(definition);
1416
+ await this.store.insertRun({ ...run });
1417
+ recorded = true;
1418
+ } catch (e) {
1419
+ this.onError(e, `recording ${name}`);
1023
1420
  }
1421
+ const started = recorded ? this.updateState(name, (before) => ({ state: onRunStart(before), result: void 0 })).then(() => {
1422
+ }, (e) => this.onError(e, `starting ${name}`)) : Promise.resolve();
1024
1423
  const recorder = createRecorder(run);
1025
1424
  const timer = setTimeout(() => recorder.abort(), timeoutMs(definition));
1026
1425
  if (typeof timer === "object" && "unref" in timer) timer.unref();
@@ -1039,7 +1438,7 @@ var CronWatch = class {
1039
1438
  run.finishedAt = finishedAt;
1040
1439
  run.durationMs = Math.max(0, finishedAt - startedAt);
1041
1440
  run.metrics = recorder.metrics();
1042
- run.output = recorder.output() ?? (typeof result === "string" ? result : null);
1441
+ run.output = recorder.output() ?? (typeof result === "string" ? capOutput(result) : null);
1043
1442
  if (threw) {
1044
1443
  run.status = "failed";
1045
1444
  run.error = errorMessage(error);
@@ -1047,7 +1446,8 @@ var CronWatch = class {
1047
1446
  run.status = "failed";
1048
1447
  run.error = `HTTP ${result.status}${result.statusText ? ` ${result.statusText}` : ""}`;
1049
1448
  } else {
1050
- const unmet = checkExpectation(definition.expect, run.output);
1449
+ const expectText = recorder.expectText() ?? (typeof result === "string" ? result : null);
1450
+ const unmet = checkExpectation(definition.expect, expectText);
1051
1451
  if (unmet) {
1052
1452
  run.status = "failed";
1053
1453
  run.error = unmet;
@@ -1055,21 +1455,75 @@ var CronWatch = class {
1055
1455
  run.status = "ok";
1056
1456
  }
1057
1457
  }
1058
- await this.store.updateRun(run);
1458
+ if (run.output !== null) run.output = stripNul(this.redact(run.output));
1459
+ if (run.error !== null) run.error = stripNul(this.redact(run.error));
1460
+ await started;
1461
+ if (!recorded) {
1462
+ try {
1463
+ await this.sync(definition);
1464
+ await this.store.insertRun(run);
1465
+ recorded = true;
1466
+ } catch (e) {
1467
+ this.onError(e, `recording ${name}`);
1468
+ }
1469
+ if (recorded) await this.finishRun(toStored(definition), run, finishedAt, false);
1470
+ } else if (await this.markedTimedOut(run)) {
1471
+ try {
1472
+ await this.store.updateRun(run);
1473
+ } catch (e) {
1474
+ this.onError(e, `recording ${name}`);
1475
+ }
1476
+ } else {
1477
+ await this.finishRun(toStored(definition), run, finishedAt, true);
1478
+ }
1479
+ return { run, result, error, threw };
1480
+ }
1481
+ /** Whether a check already marked this run as timed out, for a failure that finished late. */
1482
+ async markedTimedOut(run) {
1483
+ if (run.status === "ok") return false;
1059
1484
  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);
1485
+ return (await this.store.getRun(run.id))?.status === "timeout";
1486
+ } catch {
1487
+ return false;
1488
+ }
1489
+ }
1490
+ /**
1491
+ * Record a finished run (ok, failed, or timed out by a check), evaluate it
1492
+ * against the job's state and send what that produces. Never throws.
1493
+ */
1494
+ async finishRun(definition, run, now, write) {
1495
+ let drafts;
1496
+ try {
1497
+ if (write) await this.store.updateRun(run);
1498
+ let history = null;
1499
+ ({ result: drafts } = await this.updateState(run.job, async (previous) => {
1500
+ history ??= await this.history(run);
1501
+ const settled = applySilence(previous, onRunFinish(definition, run, previous, history, now), now);
1502
+ return { state: settled.state, result: settled.alerts };
1503
+ }));
1064
1504
  } catch (e) {
1065
- this.onError(e, `evaluating ${definition.name}`);
1505
+ this.onError(e, `evaluating ${run.job}`);
1506
+ return [];
1066
1507
  }
1067
- return { run, result, error, threw };
1508
+ return this.dispatch(drafts, definition, now);
1068
1509
  }
1069
1510
  /**
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.
1511
+ * The runs before `run`, newest first, with up to BASELINE_WINDOW
1512
+ * successful ones when the store has them. One small read normally; a
1513
+ * larger one only when failures crowd the successes out of it.
1514
+ */
1515
+ async history(run) {
1516
+ let runs = await this.store.listRuns(run.job, HISTORY_PAGE);
1517
+ if (runs.length === HISTORY_PAGE && !hasFullBaseline(runs.filter((r) => r.id !== run.id))) {
1518
+ runs = await this.store.listRuns(run.job, HISTORY_MAX);
1519
+ }
1520
+ return runs.filter((r) => r.id !== run.id);
1521
+ }
1522
+ /**
1523
+ * Look for missed and stuck runs across every job, send alerts, retry
1524
+ * alerts no channel accepted, and prune old runs. Call it from an interval
1525
+ * (start()), a cron hitting the mounted routes, or by hand. Concurrent
1526
+ * calls share one check.
1073
1527
  */
1074
1528
  check() {
1075
1529
  if (!this.checking) {
@@ -1085,28 +1539,39 @@ var CronWatch = class {
1085
1539
  const now = this.now();
1086
1540
  const alerts = [];
1087
1541
  for (const run of await this.store.runningRuns()) {
1088
- const stored = await this.store.getJob(run.job);
1089
- const declared = this.definitions.get(run.job);
1090
- const definition = declared ? toStored(declared) : stored?.definition;
1091
- if (!definition || !isStuck(definition, run, now)) continue;
1092
- run.status = "timeout";
1093
- run.finishedAt = now;
1094
- 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));
1542
+ try {
1543
+ const declared = this.definitions.get(run.job);
1544
+ const definition = declared ? toStored(declared) : (await this.store.getJob(run.job))?.definition;
1545
+ if (!definition || !isStuck(definition, run, now)) continue;
1546
+ const timeout = timeoutMs(definition);
1547
+ run.status = "timeout";
1548
+ run.finishedAt = now;
1549
+ run.durationMs = now - run.startedAt;
1550
+ run.error = `Still running after ${formatDuration(timeout)}; marked as timed out`;
1551
+ alerts.push(...await this.finishRun(definition, run, now, true));
1552
+ } catch (e) {
1553
+ this.onError(e, `checking ${run.job}`);
1554
+ }
1101
1555
  }
1102
1556
  const jobs = [];
1557
+ const retries = { spentMs: 0 };
1103
1558
  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));
1559
+ try {
1560
+ const recent = await this.store.listRuns(stored.name, BASELINE_WINDOW);
1561
+ let nextExpectedAt = null;
1562
+ const { state, result: drafts } = await this.updateState(stored.name, (previous) => {
1563
+ const evaluation = onCheck(stored.definition, stored, recent[0] ?? null, previous, now);
1564
+ nextExpectedAt = evaluation.nextExpectedAt;
1565
+ const settled = applySilence(previous, evaluation, now);
1566
+ return { state: settled.state, result: settled.alerts };
1567
+ });
1568
+ alerts.push(...await this.retryUndelivered(stored.name, state, now, retries));
1569
+ alerts.push(...await this.dispatch(drafts, stored.definition, now));
1570
+ jobs.push(summarize(stored, recent, state, nextExpectedAt, now));
1571
+ } catch (e) {
1572
+ this.onError(e, `checking ${stored.name}`);
1573
+ jobs.push(await this.unevaluable(stored, now));
1574
+ }
1110
1575
  }
1111
1576
  let pruned = 0;
1112
1577
  if (now - this.lastPruneAt > PRUNE_INTERVAL_MS) {
@@ -1119,18 +1584,36 @@ var CronWatch = class {
1119
1584
  }
1120
1585
  return { checkedAt: now, jobs, alerts, pruned };
1121
1586
  }
1587
+ /** A job's summary and its newest runs, without alerting. A job that cannot be evaluated is reported and shown as failing. */
1588
+ async snapshot(stored, now, runs) {
1589
+ let recent = [];
1590
+ try {
1591
+ recent = await this.store.listRuns(stored.name, Math.max(runs, BASELINE_WINDOW));
1592
+ const state = await this.readState(stored.name);
1593
+ const { nextExpectedAt } = onCheck(stored.definition, stored, recent[0] ?? null, state, now);
1594
+ return { job: summarize(stored, recent, state, nextExpectedAt, now), runs: recent.slice(0, runs) };
1595
+ } catch (e) {
1596
+ this.onError(e, `reading ${stored.name}`);
1597
+ return { job: await this.unevaluable(stored, now), runs: recent.slice(0, runs) };
1598
+ }
1599
+ }
1600
+ /** The summary of a job whose evaluation failed, from whatever can still be read. */
1601
+ async unevaluable(stored, now) {
1602
+ const recent = await this.store.listRuns(stored.name, BASELINE_WINDOW).catch(() => []);
1603
+ const state = await this.readState(stored.name).catch(() => emptyState(stored.name));
1604
+ return unevaluableSummary(stored, recent, state, now);
1605
+ }
1122
1606
  /** Every job the store knows about, with its health. Does not send alerts. */
1123
1607
  async jobs() {
1608
+ return (await this.jobsWithRuns(0)).map((entry) => entry.job);
1609
+ }
1610
+ /** Every job's summary with its newest `limit` runs, read together. What the dashboard shows. */
1611
+ async jobsWithRuns(limit = 20) {
1124
1612
  await this.ensureReady();
1125
1613
  for (const definition of this.definitions.values()) await this.sync(definition);
1126
1614
  const now = this.now();
1127
1615
  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
- }
1616
+ for (const stored of await this.store.listJobs()) out.push(await this.snapshot(stored, now, clampLimit(limit, 20, 0)));
1134
1617
  return out;
1135
1618
  }
1136
1619
  async jobSummary(name) {
@@ -1139,15 +1622,12 @@ var CronWatch = class {
1139
1622
  if (definition) await this.sync(definition);
1140
1623
  const stored = await this.store.getJob(name);
1141
1624
  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);
1625
+ return (await this.snapshot(stored, this.now(), 0)).job;
1147
1626
  }
1627
+ /** A job's runs, newest first. `limit` is a whole number from 1 to 500. */
1148
1628
  async runs(name, limit = 50) {
1149
1629
  await this.ensureReady();
1150
- return this.store.listRuns(name, Math.min(500, Math.max(1, limit)));
1630
+ return this.store.listRuns(name, clampLimit(limit, 50, 1));
1151
1631
  }
1152
1632
  async getRun(id) {
1153
1633
  await this.ensureReady();
@@ -1155,17 +1635,24 @@ var CronWatch = class {
1155
1635
  }
1156
1636
  /** Stop alerts for a job for a while. State keeps updating underneath. */
1157
1637
  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;
1638
+ const ms = parseDuration(duration, "silence duration");
1639
+ return this.patchState(name, (state) => {
1640
+ state.silencedUntil = this.now() + ms;
1641
+ });
1163
1642
  }
1164
1643
  async unsilence(name) {
1644
+ return this.patchState(name, (state) => {
1645
+ state.silencedUntil = null;
1646
+ });
1647
+ }
1648
+ /** Read, change and write one job's state, in turn with every other update to it. */
1649
+ async patchState(name, change) {
1165
1650
  await this.ensureReady();
1166
- const state = await this.store.getState(name) ?? emptyState(name);
1167
- state.silencedUntil = null;
1168
- await this.store.setState(state);
1651
+ const { state } = await this.updateState(name, (current) => {
1652
+ const next = normalizeState(current, name);
1653
+ change(next);
1654
+ return { state: next, result: void 0 };
1655
+ });
1169
1656
  return state;
1170
1657
  }
1171
1658
  /** Remove a job and its runs from the store. A job still declared in code comes back on its next run. */
@@ -1183,88 +1670,146 @@ var CronWatch = class {
1183
1670
  start(every = "1m") {
1184
1671
  if (this.timer) return;
1185
1672
  const ms = Math.max(5e3, parseDuration(every, "check interval"));
1673
+ if (this.deferDelivery && !this.warnedDeferredStart) {
1674
+ this.warnedDeferredStart = true;
1675
+ console.warn('[cronwatch] start() was called with deliver: "check", so these checks send no alerts. Another process must run checks with deliver: "now" (the default) to send them.');
1676
+ }
1186
1677
  const tick = () => this.check().catch((e) => this.onError(e, "check"));
1187
1678
  this.timer = setInterval(tick, ms);
1188
1679
  if (typeof this.timer === "object" && "unref" in this.timer) this.timer.unref();
1189
- setTimeout(tick, 1e3).unref?.();
1680
+ this.firstTick = setTimeout(() => {
1681
+ this.firstTick = null;
1682
+ void tick();
1683
+ }, 1e3);
1684
+ if (typeof this.firstTick === "object" && "unref" in this.firstTick) this.firstTick.unref();
1190
1685
  }
1191
1686
  stop() {
1192
1687
  if (this.timer) clearInterval(this.timer);
1688
+ if (this.firstTick) clearTimeout(this.firstTick);
1193
1689
  this.timer = null;
1690
+ this.firstTick = null;
1194
1691
  }
1195
1692
  async close() {
1196
1693
  this.stop();
1197
1694
  if (this.store.close) await this.store.close();
1198
1695
  }
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)
1696
+ /**
1697
+ * Compose, triage and send each draft. The state was saved before this
1698
+ * (updateState), so a slow channel holds up nothing else; afterwards only
1699
+ * the delivery fields are written back, onto a fresh read of the state.
1700
+ */
1701
+ async dispatch(drafts, definition, now) {
1702
+ if (drafts.length === 0) return [];
1703
+ const composed = [];
1704
+ const delivered = [];
1705
+ const failed = [];
1706
+ for (const draft of drafts) {
1707
+ const alert = composeAlert(draft, definition, now);
1708
+ if (this.deferDelivery) {
1709
+ failed.push(alert);
1710
+ } else {
1711
+ if (this.triage && alert.type !== "recovered") await this.addTriage(alert, TRIAGE_TIMEOUT_MS);
1712
+ (await this.deliver(alert) ? delivered : failed).push(alert);
1225
1713
  }
1226
- };
1714
+ composed.push(alert);
1715
+ }
1716
+ await this.recordDelivery(definition.name, delivered, failed, [], now);
1717
+ return composed;
1227
1718
  }
1228
- /** Save an evaluation's state, honouring silence, and send its alerts. */
1229
- async settle(previous, evaluation, definition, now) {
1230
- if (isSilenced(previous, now)) {
1231
- evaluation.state = muteOpens(previous, evaluation.state);
1232
- evaluation.alerts = [];
1719
+ /**
1720
+ * Send the alerts that no channel accepted last time, once each, oldest
1721
+ * first. `state` is the job's state as this check left it: an alert that
1722
+ * no longer describes it (staleAlert) is dropped instead. Retries across a
1723
+ * check share RETRY_BUDGET_MS of wall-clock time; once it is spent the rest
1724
+ * stay queued for the next check.
1725
+ */
1726
+ async retryUndelivered(name, state, now, budget) {
1727
+ const pending = state.undelivered ?? [];
1728
+ if (pending.length === 0 || isSilenced(state, now) || this.deferDelivery) return [];
1729
+ const delivered = [];
1730
+ const failed = [];
1731
+ const dropped = pending.filter((alert) => staleAlert(alert, state));
1732
+ for (const alert of pending) {
1733
+ if (dropped.includes(alert)) continue;
1734
+ const left = RETRY_BUDGET_MS - budget.spentMs;
1735
+ if (left <= 0) break;
1736
+ const started = Date.now();
1737
+ if (this.triage && alert.type !== "recovered" && alert.triage === void 0) await this.addTriage(alert, Math.min(TRIAGE_TIMEOUT_MS, left));
1738
+ (await this.deliver(alert) ? delivered : failed).push(alert);
1739
+ budget.spentMs += Math.max(0, Date.now() - started);
1233
1740
  }
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);
1741
+ await this.recordDelivery(name, delivered, failed, dropped, now);
1742
+ return delivered;
1236
1743
  }
1237
- async dispatch(drafts, definition, state, now) {
1238
- if (drafts.length === 0) return [];
1239
- const sent = [];
1240
- for (const draft of drafts) {
1241
- 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
- }
1744
+ /**
1745
+ * Mark delivered alerts done, drop stale ones, and keep failed ones for the
1746
+ * next check. A failed alert replaces its stored copy, so a triage made on
1747
+ * this attempt is kept. lastAlertAt moves only on a delivery.
1748
+ */
1749
+ async recordDelivery(name, delivered, failed, dropped, now) {
1750
+ try {
1751
+ const { result: trimmed } = await this.updateState(name, (previous) => {
1752
+ const state = normalizeState(previous, name);
1753
+ const done = new Set([...delivered, ...dropped].map(alertKey));
1754
+ const retried = new Map(failed.map((a) => [alertKey(a), a]));
1755
+ const kept = state.undelivered.filter((a) => !done.has(alertKey(a))).map((a) => retried.get(alertKey(a)) ?? a);
1756
+ const known = new Set(kept.map(alertKey));
1757
+ kept.push(...failed.filter((a) => !known.has(alertKey(a))));
1758
+ state.undelivered = kept.slice(-MAX_UNDELIVERED);
1759
+ if (delivered.length > 0) state.lastAlertAt = now;
1760
+ return { state, result: Math.max(0, kept.length - MAX_UNDELIVERED) };
1761
+ });
1762
+ if (trimmed > 0) {
1763
+ this.onError(
1764
+ new Error(`${trimmed} undelivered alert${trimmed === 1 ? "" : "s"} for ${name} dropped: only the newest ${MAX_UNDELIVERED} are kept for retry`),
1765
+ `alert queue for ${name}`
1766
+ );
1250
1767
  }
1251
- for (const channel of this.alerts) {
1768
+ } catch (e) {
1769
+ this.onError(e, `recording alert delivery for ${name}`);
1770
+ }
1771
+ }
1772
+ /** Send to every channel at once. True when at least one accepted it, or there are none. */
1773
+ async deliver(alert) {
1774
+ if (this.alerts.length === 0) return true;
1775
+ const results = await Promise.all(
1776
+ this.alerts.map(async (channel) => {
1252
1777
  try {
1253
- await channel.send(alert);
1778
+ await withTimeout(channel.send(alert), CHANNEL_TIMEOUT_MS);
1779
+ return true;
1254
1780
  } catch (e) {
1255
1781
  this.onError(e, `alert channel ${channel.name}`);
1782
+ return false;
1256
1783
  }
1257
- }
1258
- sent.push(alert);
1784
+ })
1785
+ );
1786
+ return results.includes(true);
1787
+ }
1788
+ /** Sets alert.triage to the diagnosis, or to null when there is none, so it is tried once per alert. */
1789
+ async addTriage(alert, timeout) {
1790
+ const controller = new AbortController();
1791
+ try {
1792
+ const recentRuns = await this.store.listRuns(alert.job, 5);
1793
+ const diagnosis = await withTimeout(this.triage({ alert, recentRuns, signal: controller.signal }), timeout);
1794
+ alert.triage = typeof diagnosis === "string" && diagnosis !== "" ? diagnosis : null;
1795
+ } catch (e) {
1796
+ controller.abort();
1797
+ alert.triage = null;
1798
+ this.onError(e, `triage for ${alert.job}`);
1259
1799
  }
1260
- state.lastAlertAt = now;
1261
- await this.store.setState(state);
1262
- return sent;
1263
1800
  }
1264
1801
  };
1802
+ function isDevelopment() {
1803
+ return process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test";
1804
+ }
1805
+ function clampLimit(limit, fallback, min) {
1806
+ const n = typeof limit === "number" && Number.isFinite(limit) ? Math.trunc(limit) : fallback;
1807
+ return Math.min(500, Math.max(min, n));
1808
+ }
1265
1809
  function withTimeout(promise, ms) {
1266
1810
  return new Promise((resolve, reject) => {
1267
1811
  const t = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);
1812
+ if (typeof t === "object" && "unref" in t) t.unref();
1268
1813
  promise.then((v) => {
1269
1814
  clearTimeout(t);
1270
1815
  resolve(v);
@@ -1297,6 +1842,6 @@ function cronwatch(options = {}) {
1297
1842
  return new CronWatch(options);
1298
1843
  }
1299
1844
 
1300
- export { CronWatch, composeAlert, consoleChannel, createRoutes, cronwatch, custom, formatDuration, formatRelative, json, memory, nextFire, parseDuration, parseSchedule, previousFire };
1845
+ export { CronWatch, composeAlert, consoleChannel, createRoutes, cronwatch, custom, formatDuration, memory, nextFire, parseDuration, parseSchedule };
1301
1846
  //# sourceMappingURL=index.js.map
1302
1847
  //# sourceMappingURL=index.js.map