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