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