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