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