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