@cronwatch/sdk 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/anthropic.d.cts +1 -1
- package/dist/anthropic.d.ts +1 -1
- package/dist/bugsnag.d.cts +1 -1
- package/dist/bugsnag.d.ts +1 -1
- package/dist/{client-D35cfnNV.d.cts → client-CRMfhylq.d.cts} +129 -37
- package/dist/{client-CQm-8-mR.d.ts → client-D2Xk2600.d.ts} +129 -37
- package/dist/d1.cjs +29 -11
- package/dist/d1.cjs.map +1 -1
- package/dist/d1.d.cts +1 -1
- package/dist/d1.d.ts +1 -1
- package/dist/d1.js +29 -11
- package/dist/d1.js.map +1 -1
- package/dist/datadog.d.cts +1 -1
- package/dist/datadog.d.ts +1 -1
- package/dist/discord.cjs +25 -4
- package/dist/discord.cjs.map +1 -1
- package/dist/discord.d.cts +20 -4
- package/dist/discord.d.ts +20 -4
- package/dist/discord.js +22 -3
- package/dist/discord.js.map +1 -1
- package/dist/{email-BxCp9RQV.d.ts → email-BWuUiCkw.d.ts} +1 -1
- package/dist/{email-D1d5niMy.d.cts → email-Dcqe3k8n.d.cts} +1 -1
- package/dist/honeybadger.d.cts +1 -1
- package/dist/honeybadger.d.ts +1 -1
- package/dist/index.cjs +380 -123
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +15 -9
- package/dist/index.d.ts +15 -9
- package/dist/index.js +379 -124
- package/dist/index.js.map +1 -1
- package/dist/mailgun.d.cts +2 -2
- package/dist/mailgun.d.ts +2 -2
- package/dist/newrelic.d.cts +1 -1
- package/dist/newrelic.d.ts +1 -1
- package/dist/pg-cron.cjs +19 -8
- package/dist/pg-cron.cjs.map +1 -1
- package/dist/pg-cron.d.cts +15 -12
- package/dist/pg-cron.d.ts +15 -12
- package/dist/pg-cron.js +16 -5
- package/dist/pg-cron.js.map +1 -1
- package/dist/postgres.cjs +28 -10
- package/dist/postgres.cjs.map +1 -1
- package/dist/postgres.d.cts +1 -1
- package/dist/postgres.d.ts +1 -1
- package/dist/postgres.js +28 -10
- package/dist/postgres.js.map +1 -1
- package/dist/postmark.d.cts +2 -2
- package/dist/postmark.d.ts +2 -2
- package/dist/resend.d.cts +2 -2
- package/dist/resend.d.ts +2 -2
- package/dist/rollbar.d.cts +1 -1
- package/dist/rollbar.d.ts +1 -1
- package/dist/sendgrid.d.cts +2 -2
- package/dist/sendgrid.d.ts +2 -2
- package/dist/sentry.cjs +4 -1
- package/dist/sentry.cjs.map +1 -1
- package/dist/sentry.d.cts +5 -2
- package/dist/sentry.d.ts +5 -2
- package/dist/sentry.js +4 -1
- package/dist/sentry.js.map +1 -1
- package/dist/ses.d.cts +2 -2
- package/dist/ses.d.ts +2 -2
- package/dist/slack.d.cts +1 -1
- package/dist/slack.d.ts +1 -1
- package/dist/sqlite.cjs +29 -11
- package/dist/sqlite.cjs.map +1 -1
- package/dist/sqlite.d.cts +1 -1
- package/dist/sqlite.d.ts +1 -1
- package/dist/sqlite.js +29 -11
- package/dist/sqlite.js.map +1 -1
- package/dist/twilio.cjs +10 -4
- package/dist/twilio.cjs.map +1 -1
- package/dist/twilio.d.cts +12 -7
- package/dist/twilio.d.ts +12 -7
- package/dist/twilio.js +7 -1
- package/dist/twilio.js.map +1 -1
- package/dist/{types-Cz47Boks.d.cts → types-DLnlUie8.d.cts} +18 -2
- package/dist/{types-Cz47Boks.d.ts → types-DLnlUie8.d.ts} +18 -2
- package/dist/webhook.cjs +10 -5
- package/dist/webhook.cjs.map +1 -1
- package/dist/webhook.d.cts +18 -7
- package/dist/webhook.d.ts +18 -7
- package/dist/webhook.js +10 -6
- package/dist/webhook.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -80,13 +80,46 @@ function beyondDates(at) {
|
|
|
80
80
|
function readEnv(name) {
|
|
81
81
|
return typeof process === "undefined" || !process.env ? void 0 : process.env[name];
|
|
82
82
|
}
|
|
83
|
+
function readSecretEnv(name) {
|
|
84
|
+
const value = readEnv(name);
|
|
85
|
+
return value === void 0 || value.trim() === "" ? void 0 : value;
|
|
86
|
+
}
|
|
87
|
+
function secretOption(value, what) {
|
|
88
|
+
if (value === void 0 || value === null) return value;
|
|
89
|
+
if (typeof value !== "string") {
|
|
90
|
+
throw new TypeError(`${what} must be a string, or null to opt out, not ${Array.isArray(value) ? "an array" : typeof value}`);
|
|
91
|
+
}
|
|
92
|
+
return value.trim() === "" ? void 0 : value;
|
|
93
|
+
}
|
|
94
|
+
function environment() {
|
|
95
|
+
if (typeof process === "undefined" || !process.env) return "";
|
|
96
|
+
for (const value of [process.env.CRONWATCH_ENV, process.env.APP_ENV, process.env.NODE_ENV]) {
|
|
97
|
+
const name = typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
98
|
+
if (name === "") continue;
|
|
99
|
+
if (name === "prod") return "production";
|
|
100
|
+
if (name === "dev" || name === "local" || name === "test" || name === "testing") return "development";
|
|
101
|
+
return name;
|
|
102
|
+
}
|
|
103
|
+
return "";
|
|
104
|
+
}
|
|
83
105
|
function isDevelopment() {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
106
|
+
return environment() === "development";
|
|
107
|
+
}
|
|
108
|
+
function isProduction() {
|
|
109
|
+
return environment() === "production";
|
|
87
110
|
}
|
|
88
111
|
var cache = /* @__PURE__ */ new Map();
|
|
89
112
|
var crons = /* @__PURE__ */ new WeakMap();
|
|
113
|
+
var never = /* @__PURE__ */ new WeakSet();
|
|
114
|
+
var PROBE_FROM_MS = Date.UTC(2e3, 0, 1);
|
|
115
|
+
function neverFires(cron) {
|
|
116
|
+
try {
|
|
117
|
+
return cron.nextRuns(1, new Date(PROBE_FROM_MS)).length === 0;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
return error instanceof RangeError && /call stack/i.test(error.message);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
var ONE_TIME_DATE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/;
|
|
90
123
|
var EARLY_SLACK_MS = 6e4;
|
|
91
124
|
function parseSchedule(schedule, timezone) {
|
|
92
125
|
const key = `${timezone ?? ""}|${schedule}`;
|
|
@@ -100,6 +133,10 @@ function parseSchedule(schedule, timezone) {
|
|
|
100
133
|
if (everyMs < 1e3) throw new Error(`schedule "${schedule}" is shorter than one second`);
|
|
101
134
|
parsed = { kind: "interval", source: text, everyMs };
|
|
102
135
|
} else {
|
|
136
|
+
if (text.slice(1).includes(":")) {
|
|
137
|
+
const reason = ONE_TIME_DATE.test(text) ? "CronPattern: a one-time date is not supported" : "Invalid ISO8601 passed to timezone parser.";
|
|
138
|
+
throw new Error(`schedule "${schedule}" is not a cron expression or "every <duration>": ${reason}`);
|
|
139
|
+
}
|
|
103
140
|
let cron;
|
|
104
141
|
try {
|
|
105
142
|
cron = new croner.Cron(text, timezone ? { timezone } : {});
|
|
@@ -108,6 +145,7 @@ function parseSchedule(schedule, timezone) {
|
|
|
108
145
|
}
|
|
109
146
|
parsed = { kind: "cron", source: text, ...timezone ? { timezone } : {} };
|
|
110
147
|
crons.set(parsed, cron);
|
|
148
|
+
if (neverFires(cron)) never.add(parsed);
|
|
111
149
|
}
|
|
112
150
|
cache.set(key, parsed);
|
|
113
151
|
return parsed;
|
|
@@ -134,6 +172,7 @@ function countFrom(from) {
|
|
|
134
172
|
function fireAfter(parsed, from) {
|
|
135
173
|
const cron = crons.get(parsed);
|
|
136
174
|
if (!cron) throw new Error(`schedule "${parsed.source}" was not made by parseSchedule`);
|
|
175
|
+
if (never.has(parsed)) return null;
|
|
137
176
|
const start = countFrom(from);
|
|
138
177
|
if (start === null) return null;
|
|
139
178
|
let probe = start;
|
|
@@ -149,6 +188,7 @@ function fireAfter(parsed, from) {
|
|
|
149
188
|
function firesBetween(parsed, from, to, limit) {
|
|
150
189
|
const cron = crons.get(parsed);
|
|
151
190
|
if (!cron) throw new Error(`schedule "${parsed.source}" was not made by parseSchedule`);
|
|
191
|
+
if (never.has(parsed)) return [];
|
|
152
192
|
const out = [];
|
|
153
193
|
const start = countFrom(from);
|
|
154
194
|
if (start === null) return out;
|
|
@@ -268,6 +308,9 @@ function runDuration(startedAt, finishedAt) {
|
|
|
268
308
|
const ms = finishedAt - startedAt;
|
|
269
309
|
return ms > 0 ? Math.min(ms, MAX_DURATION_MS) : 0;
|
|
270
310
|
}
|
|
311
|
+
function silenceEnd(now, ms) {
|
|
312
|
+
return Math.min(now + Math.floor(Math.min(ms, MAX_DURATION_MS)), MAX_DURATION_MS);
|
|
313
|
+
}
|
|
271
314
|
function stateVersion(state) {
|
|
272
315
|
const version = state?.version;
|
|
273
316
|
return typeof version === "number" && Number.isSafeInteger(version) && version >= 0 ? version + 0 : 0;
|
|
@@ -280,17 +323,74 @@ function failureCount(state) {
|
|
|
280
323
|
function emptyState(job) {
|
|
281
324
|
return { job, open: {}, consecutiveFailures: 0, silencedUntil: null, lastAlertAt: null, pendingRecovery: [], undelivered: [] };
|
|
282
325
|
}
|
|
326
|
+
function isObject(value) {
|
|
327
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
328
|
+
}
|
|
283
329
|
function normalizeState(state, job) {
|
|
284
|
-
if (!state) return emptyState(job);
|
|
330
|
+
if (!isObject(state)) return emptyState(job);
|
|
331
|
+
const { sending, ...rest } = state;
|
|
332
|
+
const open = isObject(state.open) ? Object.fromEntries(Object.entries(state.open).filter(([, at]) => typeof at === "number")) : {};
|
|
333
|
+
const number = (value) => typeof value === "number" ? value : null;
|
|
334
|
+
const list = (value) => Array.isArray(value) ? value : [];
|
|
285
335
|
return {
|
|
286
336
|
...emptyState(job),
|
|
287
|
-
...
|
|
288
|
-
open
|
|
337
|
+
...rest,
|
|
338
|
+
open,
|
|
289
339
|
consecutiveFailures: failureCount(state),
|
|
290
|
-
|
|
291
|
-
|
|
340
|
+
silencedUntil: number(state.silencedUntil),
|
|
341
|
+
lastAlertAt: number(state.lastAlertAt),
|
|
342
|
+
pendingRecovery: list(state.pendingRecovery).filter((c) => typeof c === "string"),
|
|
343
|
+
undelivered: list(state.undelivered).filter((alert) => isObject(alert)),
|
|
344
|
+
...Array.isArray(sending) && sending.length > 0 ? { sending: [...sending] } : {}
|
|
292
345
|
};
|
|
293
346
|
}
|
|
347
|
+
var MAX_UNDELIVERED = 20;
|
|
348
|
+
var SEND_LEASE_MS = 5 * 6e4;
|
|
349
|
+
function alertKey(alert) {
|
|
350
|
+
return `${alert.type}|${alert.at}|${alert.run?.id ?? ""}`;
|
|
351
|
+
}
|
|
352
|
+
function newest(list, max) {
|
|
353
|
+
return { kept: list.slice(-max), dropped: Math.max(0, list.length - max) };
|
|
354
|
+
}
|
|
355
|
+
function queueUndelivered(state, alerts) {
|
|
356
|
+
const next = cloneState(state);
|
|
357
|
+
const byKey = new Map(alerts.map((alert) => [alertKey(alert), alert]));
|
|
358
|
+
const queue = next.undelivered.map((alert) => byKey.get(alertKey(alert)) ?? alert);
|
|
359
|
+
const known = new Set(queue.map(alertKey));
|
|
360
|
+
queue.push(...alerts.filter((alert) => !known.has(alertKey(alert))));
|
|
361
|
+
const { kept, dropped } = newest(queue, MAX_UNDELIVERED);
|
|
362
|
+
next.undelivered = kept;
|
|
363
|
+
return { state: next, dropped };
|
|
364
|
+
}
|
|
365
|
+
function holdAlerts(state, alerts, until, deferred) {
|
|
366
|
+
if (alerts.length === 0) return { state, dropped: 0 };
|
|
367
|
+
if (deferred) return queueUndelivered(state, alerts);
|
|
368
|
+
const next = cloneState(state);
|
|
369
|
+
const { kept, dropped } = newest([...next.sending ?? [], ...alerts.map((alert) => ({ until, alert }))], MAX_UNDELIVERED);
|
|
370
|
+
next.sending = kept;
|
|
371
|
+
return { state: next, dropped };
|
|
372
|
+
}
|
|
373
|
+
function releaseSending(state, now) {
|
|
374
|
+
const sending = state.sending ?? [];
|
|
375
|
+
const lapsed = sending.filter((entry) => !(typeof entry?.until === "number" && entry.until > now));
|
|
376
|
+
if (lapsed.length === 0) return { state, dropped: 0 };
|
|
377
|
+
const held = sending.filter((entry) => !lapsed.includes(entry));
|
|
378
|
+
return queueUndelivered(
|
|
379
|
+
{ ...state, sending: held },
|
|
380
|
+
lapsed.filter((entry) => typeof entry?.alert === "object" && entry.alert !== null).map((entry) => entry.alert)
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
function recordSent(state, delivered, failed, stale, now) {
|
|
384
|
+
const next = cloneState(state);
|
|
385
|
+
const done = new Set([...delivered, ...stale].map(alertKey));
|
|
386
|
+
next.undelivered = next.undelivered.filter((alert) => !done.has(alertKey(alert)));
|
|
387
|
+
const sent = new Set([...delivered, ...failed, ...stale].map(alertKey));
|
|
388
|
+
const held = (next.sending ?? []).filter((entry) => !(typeof entry?.alert === "object" && entry.alert !== null && sent.has(alertKey(entry.alert))));
|
|
389
|
+
if (held.length > 0) next.sending = held;
|
|
390
|
+
else delete next.sending;
|
|
391
|
+
if (delivered.length > 0) next.lastAlertAt = now;
|
|
392
|
+
return queueUndelivered(next, failed);
|
|
393
|
+
}
|
|
294
394
|
function cloneState(state) {
|
|
295
395
|
return normalizeState(state, state.job);
|
|
296
396
|
}
|
|
@@ -444,8 +544,12 @@ function applySilence(previous, evaluation, now) {
|
|
|
444
544
|
return { state: muteOpens(previous, evaluation.state), alerts: [] };
|
|
445
545
|
}
|
|
446
546
|
function staleAlert(alert, state) {
|
|
447
|
-
if (alert.type === "recovered")
|
|
448
|
-
|
|
547
|
+
if (alert.type === "recovered") {
|
|
548
|
+
const after2 = isObject(alert.details) ? alert.details.after : void 0;
|
|
549
|
+
if (!Array.isArray(after2)) return true;
|
|
550
|
+
return after2.some((condition) => typeof condition !== "string" || state.open[condition] !== void 0);
|
|
551
|
+
}
|
|
552
|
+
return typeof alert.at !== "number" || state.open[alert.type] !== alert.at;
|
|
449
553
|
}
|
|
450
554
|
function jobHealth(def, lastRun, state, now) {
|
|
451
555
|
const open = openConditions(state);
|
|
@@ -594,7 +698,16 @@ var OUTPUT_CAP = 16 * 1024;
|
|
|
594
698
|
function capOutput(text) {
|
|
595
699
|
const clean = stripNul(text);
|
|
596
700
|
if (clean.length <= OUTPUT_CAP) return clean;
|
|
597
|
-
return
|
|
701
|
+
return TRIMMED + clean.slice(clean.length - OUTPUT_CAP);
|
|
702
|
+
}
|
|
703
|
+
var TRIMMED = "[earlier output trimmed]\n";
|
|
704
|
+
var REDACT_EDGE = 3 * (OUTPUT_CAP + 1024);
|
|
705
|
+
function redactAndCap(text, redact) {
|
|
706
|
+
const clean = stripNul(text);
|
|
707
|
+
const from = clean.length - (OUTPUT_CAP + REDACT_EDGE);
|
|
708
|
+
if (from <= 0) return capOutput(redact(clean));
|
|
709
|
+
const redacted = stripNul(redact(clean.slice(from)));
|
|
710
|
+
return TRIMMED + redacted.slice(Math.max(redacted.length - OUTPUT_CAP, REDACT_EDGE));
|
|
598
711
|
}
|
|
599
712
|
function stripNul(text) {
|
|
600
713
|
return text.includes("\0") ? text.replace(/\u0000/g, "") : text;
|
|
@@ -602,9 +715,6 @@ function stripNul(text) {
|
|
|
602
715
|
function stripJsonNul(json2) {
|
|
603
716
|
return json2.includes("\\u0000") ? json2.replace(/\\(u0000|[\s\S])/g, (escape, next) => next === "u0000" ? "" : escape) : json2;
|
|
604
717
|
}
|
|
605
|
-
function errorMessage(error) {
|
|
606
|
-
return capOutput(describeError(error));
|
|
607
|
-
}
|
|
608
718
|
function describeError(error) {
|
|
609
719
|
if (error instanceof Error) {
|
|
610
720
|
const frames = (error.stack ?? "").split("\n").filter((line) => /^\s+at /.test(line)).slice(0, 5);
|
|
@@ -713,7 +823,7 @@ function createRecorder(run) {
|
|
|
713
823
|
};
|
|
714
824
|
return {
|
|
715
825
|
context,
|
|
716
|
-
output: () => lines.length === 0 ? null :
|
|
826
|
+
output: () => lines.length === 0 ? null : lines.join("\n"),
|
|
717
827
|
expectText: () => {
|
|
718
828
|
if (lines.length === 0) return null;
|
|
719
829
|
const all = lines.join("\n");
|
|
@@ -752,6 +862,15 @@ function checkExpectation(expect, output) {
|
|
|
752
862
|
}
|
|
753
863
|
return ok ? null : "Output did not pass the expect() check";
|
|
754
864
|
}
|
|
865
|
+
function readStoredJob(stored) {
|
|
866
|
+
const definition = stored.definition;
|
|
867
|
+
if (typeof definition !== "object" || definition === null || Array.isArray(definition)) {
|
|
868
|
+
return { job: { ...stored, definition: { name: stored.name } }, readable: false };
|
|
869
|
+
}
|
|
870
|
+
const read = { ...definition };
|
|
871
|
+
if ("tags" in read && !(Array.isArray(read.tags) && read.tags.every((tag) => typeof tag === "string"))) delete read.tags;
|
|
872
|
+
return { job: { ...stored, definition: read }, readable: true };
|
|
873
|
+
}
|
|
755
874
|
|
|
756
875
|
// src/routes/escape.ts
|
|
757
876
|
function escapeHtml(value) {
|
|
@@ -1454,7 +1573,7 @@ function jobPage(job, runs, now, base, complete = true) {
|
|
|
1454
1573
|
run.error ? `<details class="out error" open><summary>error</summary><pre>${h(run.error)}</pre></details>` : "",
|
|
1455
1574
|
run.output ? `<details class="out"${run.status === "ok" ? "" : " open"}><summary>output</summary><pre>${h(run.output)}</pre></details>` : ""
|
|
1456
1575
|
].join("");
|
|
1457
|
-
const metrics = Object.entries(run.metrics).map(([k, v]) => `<span><span class="k">${h(k)}</span> ${h(Number.isInteger(v) ? v : v.toFixed(4))}</span>`).join("");
|
|
1576
|
+
const metrics = Object.entries(run.metrics ?? {}).filter(([, v]) => typeof v === "number" && Number.isFinite(v)).map(([k, v]) => `<span><span class="k">${h(k)}</span> ${h(Number.isInteger(v) ? v : v.toFixed(4))}</span>`).join("");
|
|
1458
1577
|
return `<tr${detail ? ` class="has-detail"` : ""}>
|
|
1459
1578
|
<td class="nowrap">${runState(run)}</td>
|
|
1460
1579
|
<td class="nowrap">${h(when2(run.startedAt, now))} <span class="muted">UTC</span><span class="sub">${stamp(run.startedAt, now)}</span></td>
|
|
@@ -1523,12 +1642,18 @@ function jobPage(job, runs, now, base, complete = true) {
|
|
|
1523
1642
|
return layout(`${job.name}: CronWatch`, body, base, { refresh: 60 });
|
|
1524
1643
|
}
|
|
1525
1644
|
function messagePage(title, message, base, signIn = false) {
|
|
1526
|
-
const form = signIn ? `<form class="signin" method="
|
|
1645
|
+
const form = signIn ? `<form class="signin" method="post" action="${h(base)}/signin"><label for="token">Token</label><input id="token" name="token" type="password" autocomplete="current-password" autocapitalize="off" spellcheck="false" required><button class="primary" type="submit">Sign in</button></form>` : "";
|
|
1527
1646
|
return layout(title, `<header class="top">${brand(base)}</header><main class="message"><h1>${h(title)}</h1><p>${h(message)}</p>${form}</main>`, base);
|
|
1528
1647
|
}
|
|
1529
1648
|
|
|
1649
|
+
// src/version.ts
|
|
1650
|
+
var VERSION = "0.11.0";
|
|
1651
|
+
var API_VERSION = 1;
|
|
1652
|
+
|
|
1530
1653
|
// src/routes/index.ts
|
|
1531
1654
|
var COOKIE = "cronwatch_token";
|
|
1655
|
+
var LIBRARY = "@cronwatch/sdk";
|
|
1656
|
+
var LANGUAGE = "typescript";
|
|
1532
1657
|
async function cookieValue(token) {
|
|
1533
1658
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`cronwatch-cookie:${token}`));
|
|
1534
1659
|
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
@@ -1612,6 +1737,20 @@ function forwardedOrigin(request, url) {
|
|
|
1612
1737
|
return url.origin;
|
|
1613
1738
|
}
|
|
1614
1739
|
}
|
|
1740
|
+
function bearerToken(header) {
|
|
1741
|
+
const match = header === null ? null : /^Bearer\s+([\s\S]*)$/i.exec(header);
|
|
1742
|
+
return match ? match[1] : null;
|
|
1743
|
+
}
|
|
1744
|
+
function signInReturn(referer, publicOrigin, base) {
|
|
1745
|
+
if (referer === null || !referer.startsWith(publicOrigin + "/")) return `${base}/`;
|
|
1746
|
+
let url;
|
|
1747
|
+
try {
|
|
1748
|
+
url = new URL(referer);
|
|
1749
|
+
} catch {
|
|
1750
|
+
return `${base}/`;
|
|
1751
|
+
}
|
|
1752
|
+
return url.searchParams.has("token") ? `${base}/` : referer;
|
|
1753
|
+
}
|
|
1615
1754
|
function stripBase(pathname, base) {
|
|
1616
1755
|
let path = pathname.startsWith(base) ? pathname.slice(base.length) : pathname;
|
|
1617
1756
|
if (path === "") path = "/";
|
|
@@ -1654,8 +1793,12 @@ function runsLimit(value) {
|
|
|
1654
1793
|
return Number.isFinite(n) ? Math.min(MAX_RUNS, Math.max(1, n)) : DEFAULT_RUNS;
|
|
1655
1794
|
}
|
|
1656
1795
|
function createRoutes(cw, options = {}) {
|
|
1796
|
+
return cw.routes(options);
|
|
1797
|
+
}
|
|
1798
|
+
function buildRoutes(cw, options = {}) {
|
|
1657
1799
|
const optedOut = options.token === null;
|
|
1658
|
-
const
|
|
1800
|
+
const given = secretOption(options.token, "routes: token");
|
|
1801
|
+
const configured = optedOut ? null : given || readSecretEnv("CRONWATCH_TOKEN") || null;
|
|
1659
1802
|
const base = (options.basePath ?? "/cronwatch").replace(/\/+$/, "");
|
|
1660
1803
|
const developing = isDevelopment();
|
|
1661
1804
|
const fixedOrigin = configuredOrigin(options.origin);
|
|
@@ -1687,7 +1830,17 @@ function createRoutes(cw, options = {}) {
|
|
|
1687
1830
|
if (method !== "GET" && method !== "HEAD" && crossSite(request, publicOrigin)) {
|
|
1688
1831
|
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);
|
|
1689
1832
|
}
|
|
1690
|
-
const bearer = request.headers.get("authorization")
|
|
1833
|
+
const bearer = bearerToken(request.headers.get("authorization"));
|
|
1834
|
+
const signInCookie = async () => {
|
|
1835
|
+
const secure = publicOrigin.startsWith("https:") ? "; Secure" : "";
|
|
1836
|
+
return `${COOKIE}=${await expectedCookie(token)}; Path=${base || "/"}; HttpOnly; SameSite=Lax; Max-Age=${60 * 60 * 24 * 30}${secure}`;
|
|
1837
|
+
};
|
|
1838
|
+
const signInPage = () => generated ? 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, or enter the token from it below, and this browser stays signed in.", base, true), 401) : html(messagePage("Sign in", "Enter your CRONWATCH_TOKEN and this browser stays signed in.", base, true), 401);
|
|
1839
|
+
if (token && method === "POST" && path === "/signin") {
|
|
1840
|
+
const sent = (await readBody(request)).token;
|
|
1841
|
+
if (sent === void 0 || !constantTimeEqual(sent, token)) return signInPage();
|
|
1842
|
+
return redirect(signInReturn(request.headers.get("referer"), publicOrigin, base), { "set-cookie": await signInCookie() });
|
|
1843
|
+
}
|
|
1691
1844
|
if (token) {
|
|
1692
1845
|
const query = wantsHtml && method === "GET" ? url.searchParams.get("token") : null;
|
|
1693
1846
|
const sent = readCookie(request, COOKIE);
|
|
@@ -1695,17 +1848,12 @@ function createRoutes(cw, options = {}) {
|
|
|
1695
1848
|
const cronSecretOk = isCheck && bearer !== null && cw.cronSecret !== null && constantTimeEqual(bearer, cw.cronSecret);
|
|
1696
1849
|
const tokenOk = bearer !== null ? constantTimeEqual(bearer, token) : query !== null ? constantTimeEqual(query, token) : sent !== null && constantTimeEqual(sent, await expectedCookie(token));
|
|
1697
1850
|
if (!cronSecretOk && !tokenOk) {
|
|
1698
|
-
if (
|
|
1699
|
-
|
|
1700
|
-
}
|
|
1701
|
-
return wantsHtml ? html(messagePage("Sign in", `Open this page with ?token=<your CRONWATCH_TOKEN> once and it will stay signed in.`, base, true), 401) : api({ ok: false, error: "Unauthorized" }, 401);
|
|
1851
|
+
if (wantsHtml) return signInPage();
|
|
1852
|
+
return generated ? api({ ok: false, error: "Unauthorized: CRONWATCH_TOKEN is not set, so this development server made a token; it is in the server log" }, 401) : api({ ok: false, error: "Unauthorized" }, 401);
|
|
1702
1853
|
}
|
|
1703
1854
|
if (query !== null) {
|
|
1704
1855
|
url.searchParams.delete("token");
|
|
1705
|
-
|
|
1706
|
-
return redirect(url.pathname + (url.search || ""), {
|
|
1707
|
-
"set-cookie": `${COOKIE}=${await expectedCookie(token)}; Path=${base || "/"}; HttpOnly; SameSite=Lax; Max-Age=${60 * 60 * 24 * 30}${secure}`
|
|
1708
|
-
});
|
|
1856
|
+
return redirect(url.pathname + (url.search || ""), { "set-cookie": await signInCookie() });
|
|
1709
1857
|
}
|
|
1710
1858
|
}
|
|
1711
1859
|
const redirectBack = () => {
|
|
@@ -1756,6 +1904,9 @@ function createRoutes(cw, options = {}) {
|
|
|
1756
1904
|
}
|
|
1757
1905
|
if (parts[0] === "api") {
|
|
1758
1906
|
const rest = parts.slice(1);
|
|
1907
|
+
if (method === "GET" && rest.length === 0) {
|
|
1908
|
+
return api({ ok: true, library: LIBRARY, language: LANGUAGE, version: VERSION, api: API_VERSION });
|
|
1909
|
+
}
|
|
1759
1910
|
if (method === "GET" && rest[0] === "jobs" && rest.length === 1) {
|
|
1760
1911
|
return api({ ok: true, jobs: await cw.jobs() });
|
|
1761
1912
|
}
|
|
@@ -1783,9 +1934,13 @@ function createRoutes(cw, options = {}) {
|
|
|
1783
1934
|
} catch (e) {
|
|
1784
1935
|
return api({ ok: false, error: e.message }, 400);
|
|
1785
1936
|
}
|
|
1786
|
-
|
|
1937
|
+
await cw.silence(name, duration);
|
|
1938
|
+
return api({ ok: true, job: await cw.jobSummary(name) });
|
|
1939
|
+
}
|
|
1940
|
+
if (rest[2] === "unsilence") {
|
|
1941
|
+
await cw.unsilence(name);
|
|
1942
|
+
return api({ ok: true, job: await cw.jobSummary(name) });
|
|
1787
1943
|
}
|
|
1788
|
-
if (rest[2] === "unsilence") return api({ ok: true, state: await cw.unsilence(name) });
|
|
1789
1944
|
}
|
|
1790
1945
|
if (rest[0] === "check" && rest.length === 1) {
|
|
1791
1946
|
if (method === "GET" && bearer === null) {
|
|
@@ -1933,11 +2088,11 @@ function memory() {
|
|
|
1933
2088
|
return true;
|
|
1934
2089
|
},
|
|
1935
2090
|
async prune(before) {
|
|
1936
|
-
const
|
|
1937
|
-
for (const run of runs.values())
|
|
2091
|
+
const newest2 = /* @__PURE__ */ new Map();
|
|
2092
|
+
for (const run of runs.values()) newest2.set(run.job, Math.max(newest2.get(run.job) ?? -Infinity, run.startedAt));
|
|
1938
2093
|
let n = 0;
|
|
1939
2094
|
for (const [id, run] of runs) {
|
|
1940
|
-
if (run.status !== "running" && run.startedAt < before && run.startedAt <
|
|
2095
|
+
if (run.status !== "running" && run.startedAt < before && run.startedAt < newest2.get(run.job)) {
|
|
1941
2096
|
runs.delete(id);
|
|
1942
2097
|
order.delete(id);
|
|
1943
2098
|
n++;
|
|
@@ -1953,7 +2108,6 @@ var NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/;
|
|
|
1953
2108
|
var TRIAGE_TIMEOUT_MS = 25e3;
|
|
1954
2109
|
var CHANNEL_TIMEOUT_MS = 15e3;
|
|
1955
2110
|
var PRUNE_INTERVAL_MS = 60 * 6e4;
|
|
1956
|
-
var MAX_UNDELIVERED = 20;
|
|
1957
2111
|
var RETRY_BUDGET_MS = 2e4;
|
|
1958
2112
|
var STATE_ATTEMPTS = 10;
|
|
1959
2113
|
var HISTORY_PAGE = BASELINE_WINDOW + 5;
|
|
@@ -1994,10 +2148,7 @@ function validateDefinition(def) {
|
|
|
1994
2148
|
function sameState(a, b) {
|
|
1995
2149
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
1996
2150
|
}
|
|
1997
|
-
|
|
1998
|
-
return `${alert.type}|${alert.at}|${alert.run?.id ?? ""}`;
|
|
1999
|
-
}
|
|
2000
|
-
var CronWatch = class {
|
|
2151
|
+
var Cronwatch = class {
|
|
2001
2152
|
store;
|
|
2002
2153
|
alerts;
|
|
2003
2154
|
triage;
|
|
@@ -2010,7 +2161,7 @@ var CronWatch = class {
|
|
|
2010
2161
|
/** cronSecret was passed as null: handlers may run without a secret. */
|
|
2011
2162
|
secretOptOut;
|
|
2012
2163
|
redact;
|
|
2013
|
-
/** "check": queue alerts for another process's check instead of sending them. See
|
|
2164
|
+
/** "check": queue alerts for another process's check instead of sending them. See CronwatchOptions.deliver. */
|
|
2014
2165
|
deferDelivery;
|
|
2015
2166
|
defaults;
|
|
2016
2167
|
definitions = /* @__PURE__ */ new Map();
|
|
@@ -2029,12 +2180,15 @@ var CronWatch = class {
|
|
|
2029
2180
|
usingDefaultStore = false;
|
|
2030
2181
|
warnedNoSecret = false;
|
|
2031
2182
|
warnedDeferredStart = false;
|
|
2183
|
+
/** Stored jobs read with a definition that was not a JSON object (see readJob). */
|
|
2184
|
+
unreadable = /* @__PURE__ */ new WeakSet();
|
|
2032
2185
|
constructor(options = {}) {
|
|
2033
2186
|
this.store = options.store ?? (this.usingDefaultStore = true, memory());
|
|
2034
2187
|
this.alerts = options.alerts ?? [consoleChannel()];
|
|
2035
2188
|
this.triage = options.triage;
|
|
2036
2189
|
this.sources = options.sources ?? [];
|
|
2037
|
-
const
|
|
2190
|
+
const given = secretOption(options.cronSecret, "cronSecret");
|
|
2191
|
+
const secret = options.cronSecret === void 0 ? readSecretEnv("CRON_SECRET") : given;
|
|
2038
2192
|
this.cronSecret = secret ? secret : null;
|
|
2039
2193
|
this.secretOptOut = options.cronSecret === null;
|
|
2040
2194
|
this.retentionMs = parseDuration(options.retention ?? "30d", "retention");
|
|
@@ -2095,7 +2249,7 @@ var CronWatch = class {
|
|
|
2095
2249
|
return outcome.result;
|
|
2096
2250
|
},
|
|
2097
2251
|
handler(fn, options) {
|
|
2098
|
-
const own = options?.secret;
|
|
2252
|
+
const own = secretOption(options?.secret, "handler: secret");
|
|
2099
2253
|
const secret = own === null ? null : own ? own : self.cronSecret;
|
|
2100
2254
|
const optedOut = own === null || !own && self.secretOptOut;
|
|
2101
2255
|
return async (request) => {
|
|
@@ -2147,7 +2301,7 @@ var CronWatch = class {
|
|
|
2147
2301
|
if (!this.ready) {
|
|
2148
2302
|
this.ready = (async () => {
|
|
2149
2303
|
if (this.store.init) await this.store.init();
|
|
2150
|
-
if (this.usingDefaultStore &&
|
|
2304
|
+
if (this.usingDefaultStore && isProduction()) {
|
|
2151
2305
|
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.");
|
|
2152
2306
|
}
|
|
2153
2307
|
})().catch((error) => {
|
|
@@ -2163,19 +2317,58 @@ var CronWatch = class {
|
|
|
2163
2317
|
* that replaced it, never its own over it, and one forgotten since writes
|
|
2164
2318
|
* its own. The writes of one name take turns, in the order they were asked
|
|
2165
2319
|
* for, so one still under way cannot land after a later one; and a name
|
|
2166
|
-
* declared again while its write was under way is still to be written.
|
|
2320
|
+
* declared again while its write was under way is still to be written. A
|
|
2321
|
+
* name is marked as written only while that same declaration stands, so a
|
|
2322
|
+
* forget that lands during the write (deleting the row after it) leaves
|
|
2323
|
+
* the name to be written again, as does one forgotten before it.
|
|
2324
|
+
*
|
|
2325
|
+
* With `confirm`, as a run starts, a name already written is read back:
|
|
2326
|
+
* another process may have forgotten the job since, and a job still
|
|
2327
|
+
* declared here comes back on its next run.
|
|
2167
2328
|
*/
|
|
2168
|
-
async sync(definition) {
|
|
2329
|
+
async sync(definition, confirm = false) {
|
|
2169
2330
|
await this.ensureReady();
|
|
2170
2331
|
const name = definition.name;
|
|
2171
|
-
if (this.synced.has(name))
|
|
2332
|
+
if (this.synced.has(name)) {
|
|
2333
|
+
if (!confirm || await this.store.getJob(name)) return;
|
|
2334
|
+
this.synced.delete(name);
|
|
2335
|
+
}
|
|
2172
2336
|
await this.serial(name, async () => {
|
|
2173
2337
|
if (this.synced.has(name)) return;
|
|
2174
2338
|
const standing = this.definitions.get(name) ?? definition;
|
|
2175
2339
|
await this.store.upsertJob(toStored(standing), this.now());
|
|
2176
|
-
if (
|
|
2340
|
+
if (this.definitions.get(name) === standing) this.synced.add(name);
|
|
2177
2341
|
}, this.syncing);
|
|
2178
2342
|
}
|
|
2343
|
+
/**
|
|
2344
|
+
* Every stored job, once each declaration has been written. A job declared
|
|
2345
|
+
* here that the store no longer has was forgotten by another process after
|
|
2346
|
+
* this one wrote it: it is written again, as its next run would, so it is
|
|
2347
|
+
* checked and shown while any process still declares it.
|
|
2348
|
+
*/
|
|
2349
|
+
async storedJobs() {
|
|
2350
|
+
for (const definition of this.definitions.values()) await this.sync(definition);
|
|
2351
|
+
const jobs = await this.store.listJobs();
|
|
2352
|
+
const listed = new Set(jobs.map((job) => job.name));
|
|
2353
|
+
const missing = [...this.definitions.values()].filter((definition) => !listed.has(definition.name));
|
|
2354
|
+
if (missing.length === 0) return jobs.map((job) => this.readJob(job));
|
|
2355
|
+
for (const definition of missing) {
|
|
2356
|
+
if (this.definitions.get(definition.name) !== definition) continue;
|
|
2357
|
+
this.synced.delete(definition.name);
|
|
2358
|
+
await this.sync(definition);
|
|
2359
|
+
}
|
|
2360
|
+
return (await this.store.listJobs()).map((job) => this.readJob(job));
|
|
2361
|
+
}
|
|
2362
|
+
/** A stored job read leniently (readStoredJob); one whose definition is unreadable is remembered, so that evaluating it throws. */
|
|
2363
|
+
readJob(stored) {
|
|
2364
|
+
const { job, readable } = readStoredJob(stored);
|
|
2365
|
+
if (!readable) this.unreadable.add(job);
|
|
2366
|
+
return job;
|
|
2367
|
+
}
|
|
2368
|
+
/** Throws for a job whose stored definition was not a JSON object: reported, and shown as failing, while the others carry on. */
|
|
2369
|
+
evaluable(stored) {
|
|
2370
|
+
if (this.unreadable.has(stored)) throw new Error(`job "${stored.name}": its stored definition is not a JSON object`);
|
|
2371
|
+
}
|
|
2179
2372
|
/**
|
|
2180
2373
|
* Runs `fn` after every earlier state update for the same job has settled,
|
|
2181
2374
|
* so two runs (or a run and a check) in this process never read and write
|
|
@@ -2251,7 +2444,7 @@ var CronWatch = class {
|
|
|
2251
2444
|
};
|
|
2252
2445
|
let recorded = false;
|
|
2253
2446
|
try {
|
|
2254
|
-
await this.sync(definition);
|
|
2447
|
+
await this.sync(definition, true);
|
|
2255
2448
|
await this.store.insertRun({ ...run });
|
|
2256
2449
|
recorded = true;
|
|
2257
2450
|
} catch (e) {
|
|
@@ -2276,7 +2469,7 @@ var CronWatch = class {
|
|
|
2276
2469
|
run.finishedAt = finishedAt;
|
|
2277
2470
|
run.durationMs = runDuration(startedAt, finishedAt);
|
|
2278
2471
|
run.metrics = recorder.metrics();
|
|
2279
|
-
run.output = recorder.output() ?? (typeof result === "string" ?
|
|
2472
|
+
run.output = recorder.output() ?? (typeof result === "string" ? result : null);
|
|
2280
2473
|
this.conclude(definition, run, result, error, threw, recorder.expectText() ?? (typeof result === "string" ? result : null));
|
|
2281
2474
|
await started;
|
|
2282
2475
|
try {
|
|
@@ -2289,12 +2482,13 @@ var CronWatch = class {
|
|
|
2289
2482
|
}
|
|
2290
2483
|
/**
|
|
2291
2484
|
* Sets a finished run's status and error from how it ended, then redacts
|
|
2292
|
-
* its output and error. Shared by execute()
|
|
2485
|
+
* its output and error and caps them, in that order. Shared by execute()
|
|
2486
|
+
* and RunHandle.finish().
|
|
2293
2487
|
*/
|
|
2294
2488
|
conclude(definition, run, result, error, threw, expectText) {
|
|
2295
2489
|
if (threw) {
|
|
2296
2490
|
run.status = "failed";
|
|
2297
|
-
run.error =
|
|
2491
|
+
run.error = describeError(error);
|
|
2298
2492
|
} else if (result instanceof Response && result.status >= 400) {
|
|
2299
2493
|
run.status = "failed";
|
|
2300
2494
|
run.error = `HTTP ${result.status}${result.statusText ? ` ${result.statusText}` : ""}`;
|
|
@@ -2307,8 +2501,8 @@ var CronWatch = class {
|
|
|
2307
2501
|
run.status = "ok";
|
|
2308
2502
|
}
|
|
2309
2503
|
}
|
|
2310
|
-
if (run.output !== null) run.output =
|
|
2311
|
-
if (run.error !== null) run.error =
|
|
2504
|
+
if (run.output !== null) run.output = redactAndCap(run.output, this.redact);
|
|
2505
|
+
if (run.error !== null) run.error = redactAndCap(run.error, this.redact);
|
|
2312
2506
|
}
|
|
2313
2507
|
/**
|
|
2314
2508
|
* Writes a finished run and evaluates it. `recorded` says whether its
|
|
@@ -2403,7 +2597,7 @@ ${id}`;
|
|
|
2403
2597
|
};
|
|
2404
2598
|
let recorded = false;
|
|
2405
2599
|
try {
|
|
2406
|
-
await this.sync(definition);
|
|
2600
|
+
await this.sync(definition, true);
|
|
2407
2601
|
await this.store.insertRun({ ...run });
|
|
2408
2602
|
recorded = true;
|
|
2409
2603
|
} catch (e) {
|
|
@@ -2503,14 +2697,15 @@ ${id}`;
|
|
|
2503
2697
|
const result = typeof outcome === "string" ? outcome : failed ? void 0 : outcome?.result;
|
|
2504
2698
|
const error = failed ? outcome.error : void 0;
|
|
2505
2699
|
const finishedAt = self.now();
|
|
2506
|
-
const added = recorder.output() ?? (typeof result === "string" ?
|
|
2700
|
+
const added = recorder.output() ?? (typeof result === "string" ? result : null);
|
|
2507
2701
|
const run = {
|
|
2508
2702
|
...from,
|
|
2509
2703
|
status: "running",
|
|
2510
2704
|
finishedAt,
|
|
2511
2705
|
durationMs: runDuration(from.startedAt, finishedAt),
|
|
2512
2706
|
error: null,
|
|
2513
|
-
|
|
2707
|
+
// Capped by conclude(), after it is redacted.
|
|
2708
|
+
output: joinLines(from.output, added),
|
|
2514
2709
|
metrics: { ...from.metrics, ...recorder.metrics() }
|
|
2515
2710
|
};
|
|
2516
2711
|
const expectText = joinLines(head, joinLines(from.output, recorder.expectText() ?? (typeof result === "string" ? result : null)));
|
|
@@ -2559,7 +2754,7 @@ ${id}`;
|
|
|
2559
2754
|
self.report(new Error(`run ${id} of ${name} belongs to job "${stored.job}"; ignored`), `flushing ${name}`);
|
|
2560
2755
|
return;
|
|
2561
2756
|
}
|
|
2562
|
-
const output = lines === null ? stored.output : joinOutput(stored.output,
|
|
2757
|
+
const output = lines === null ? stored.output : joinOutput(stored.output, redactAndCap(lines, self.redact));
|
|
2563
2758
|
if (!await self.writeRunIf({ ...stored, output, metrics: { ...stored.metrics, ...metrics } }, ["running"])) return putBack();
|
|
2564
2759
|
const text = taken.expectText();
|
|
2565
2760
|
if (text !== null && (head === null || head.length < OUTPUT_CAP)) head = (joinLines(head, text) ?? "").slice(0, OUTPUT_CAP);
|
|
@@ -2584,13 +2779,23 @@ ${id}`;
|
|
|
2584
2779
|
* alone and reported. A finished run is judged as if it had been wrapped
|
|
2585
2780
|
* here (expect, failures, duration, budgets) and its output and error are
|
|
2586
2781
|
* redacted the same way; one finishing after a check marked it timeout is
|
|
2587
|
-
* judged only when it succeeded, as RunHandle.finish() does.
|
|
2588
|
-
*
|
|
2782
|
+
* judged only when it succeeded, as RunHandle.finish() does. A metric that
|
|
2783
|
+
* is not a finite number throws before anything is written, as
|
|
2784
|
+
* job.metric() does. Returns the alerts it sent.
|
|
2589
2785
|
*/
|
|
2590
2786
|
async recordRun(input, options = {}) {
|
|
2591
2787
|
const declared = this.definitions.get(input.job);
|
|
2592
2788
|
if (!declared) throw new Error(`recordRun: job "${input.job}" is not declared; call job() first`);
|
|
2789
|
+
if (typeof input.id !== "string" || input.id.length === 0 || input.id.length > MAX_RUN_ID) {
|
|
2790
|
+
const got = typeof input.id === "string" ? `${input.id.length} characters` : typeof input.id;
|
|
2791
|
+
throw new Error(`recordRun: run ids must be 1 to ${MAX_RUN_ID} characters (got ${got}; job "${input.job}")`);
|
|
2792
|
+
}
|
|
2593
2793
|
if (input.id.includes("\0")) throw new Error(`recordRun: run ids cannot contain a NUL character (job "${input.job}")`);
|
|
2794
|
+
for (const [metric, value] of Object.entries(input.metrics ?? {})) {
|
|
2795
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
2796
|
+
throw new Error(`recordRun: metric "${metric}" must be a finite number (job "${input.job}", run "${input.id}")`);
|
|
2797
|
+
}
|
|
2798
|
+
}
|
|
2594
2799
|
await this.sync(declared);
|
|
2595
2800
|
const run = { ...input, metrics: { ...input.metrics } };
|
|
2596
2801
|
if (run.status === "ok") {
|
|
@@ -2600,8 +2805,8 @@ ${id}`;
|
|
|
2600
2805
|
run.error = unmet;
|
|
2601
2806
|
}
|
|
2602
2807
|
}
|
|
2603
|
-
if (run.output !== null) run.output =
|
|
2604
|
-
if (run.error !== null) run.error =
|
|
2808
|
+
if (run.output !== null) run.output = redactAndCap(run.output, this.redact);
|
|
2809
|
+
if (run.error !== null) run.error = redactAndCap(run.error, this.redact);
|
|
2605
2810
|
const evaluate = options.evaluate !== false;
|
|
2606
2811
|
const definition = toStored(declared);
|
|
2607
2812
|
const stored = await this.store.getRun(run.id);
|
|
@@ -2635,22 +2840,42 @@ ${id}`;
|
|
|
2635
2840
|
}
|
|
2636
2841
|
/**
|
|
2637
2842
|
* Evaluate a finished run (ok, failed, or timed out by a check), already
|
|
2638
|
-
* written, against the job's state and send what that produces.
|
|
2843
|
+
* written, against the job's state and send what that produces. The
|
|
2844
|
+
* alerts are written with that state (see outbox()). Never throws.
|
|
2639
2845
|
*/
|
|
2640
2846
|
async finishRun(definition, run, now) {
|
|
2641
|
-
let
|
|
2847
|
+
let held;
|
|
2642
2848
|
try {
|
|
2643
2849
|
let history = null;
|
|
2644
|
-
({ result:
|
|
2850
|
+
({ result: held } = await this.updateState(run.job, async (previous) => {
|
|
2645
2851
|
history ??= await this.history(run);
|
|
2646
|
-
|
|
2647
|
-
return { state: settled.state, result: settled.alerts };
|
|
2852
|
+
return this.outbox(applySilence(previous, onRunFinish(definition, run, previous, history, now), now), definition, now);
|
|
2648
2853
|
}));
|
|
2649
2854
|
} catch (e) {
|
|
2650
2855
|
this.onError(e, `evaluating ${run.job}`);
|
|
2651
2856
|
return [];
|
|
2652
2857
|
}
|
|
2653
|
-
|
|
2858
|
+
this.reportDropped(run.job, held.dropped);
|
|
2859
|
+
return this.dispatch(run.job, held.alerts, now);
|
|
2860
|
+
}
|
|
2861
|
+
/**
|
|
2862
|
+
* An evaluation as it is written: its drafts composed into alerts and held
|
|
2863
|
+
* in the same state (holdAlerts), so the write that opens a condition also
|
|
2864
|
+
* keeps its alerts, and a process that stops before sending them does not
|
|
2865
|
+
* lose them. Called inside updateState(), so it only computes.
|
|
2866
|
+
*/
|
|
2867
|
+
outbox(settled, definition, now) {
|
|
2868
|
+
const alerts = settled.alerts.map((draft) => composeAlert(draft, definition, now));
|
|
2869
|
+
const { state, dropped } = holdAlerts(settled.state, alerts, this.now() + SEND_LEASE_MS, this.deferDelivery);
|
|
2870
|
+
return { state, result: { alerts, dropped } };
|
|
2871
|
+
}
|
|
2872
|
+
/** Reports alerts let go because a job's queue was full. */
|
|
2873
|
+
reportDropped(name, dropped) {
|
|
2874
|
+
if (dropped <= 0) return;
|
|
2875
|
+
this.report(
|
|
2876
|
+
new Error(`${dropped} undelivered alert${dropped === 1 ? "" : "s"} for ${name} dropped: only the newest ${MAX_UNDELIVERED} are kept for retry`),
|
|
2877
|
+
`alert queue for ${name}`
|
|
2878
|
+
);
|
|
2654
2879
|
}
|
|
2655
2880
|
/**
|
|
2656
2881
|
* The runs before `run`, newest first, with up to BASELINE_WINDOW
|
|
@@ -2690,11 +2915,16 @@ ${id}`;
|
|
|
2690
2915
|
}
|
|
2691
2916
|
for (const definition of this.definitions.values()) await this.sync(definition);
|
|
2692
2917
|
const now = this.now();
|
|
2693
|
-
for (const
|
|
2918
|
+
for (const listed of await this.store.runningRuns()) {
|
|
2694
2919
|
try {
|
|
2695
|
-
const declared = this.definitions.get(
|
|
2696
|
-
const
|
|
2697
|
-
|
|
2920
|
+
const declared = this.definitions.get(listed.job);
|
|
2921
|
+
const found = declared ? null : await this.store.getJob(listed.job);
|
|
2922
|
+
const stored = found ? this.readJob(found) : null;
|
|
2923
|
+
if (stored) this.evaluable(stored);
|
|
2924
|
+
const definition = declared ? toStored(declared) : stored?.definition;
|
|
2925
|
+
if (!definition || !isStuck(definition, listed, now)) continue;
|
|
2926
|
+
const run = await this.store.getRun(listed.id);
|
|
2927
|
+
if (!run || run.status !== "running" || run.job !== listed.job) continue;
|
|
2698
2928
|
const timeout = timeoutMs(definition);
|
|
2699
2929
|
run.status = "timeout";
|
|
2700
2930
|
run.finishedAt = now;
|
|
@@ -2703,23 +2933,27 @@ ${id}`;
|
|
|
2703
2933
|
if (!await this.writeRunIf(run, ["running"])) continue;
|
|
2704
2934
|
alerts.push(...await this.finishRun(definition, run, now));
|
|
2705
2935
|
} catch (e) {
|
|
2706
|
-
this.onError(e, `checking ${
|
|
2936
|
+
this.onError(e, `checking ${listed.job}`);
|
|
2707
2937
|
}
|
|
2708
2938
|
}
|
|
2709
2939
|
const jobs = [];
|
|
2710
2940
|
const retries = { spentMs: 0 };
|
|
2711
|
-
for (const stored of await this.
|
|
2941
|
+
for (const stored of await this.storedJobs()) {
|
|
2712
2942
|
try {
|
|
2943
|
+
this.evaluable(stored);
|
|
2713
2944
|
const recent = await this.store.listRuns(stored.name, BASELINE_WINDOW);
|
|
2714
2945
|
let nextExpectedAt = null;
|
|
2715
|
-
const { state, result:
|
|
2946
|
+
const { state, result: held } = await this.updateState(stored.name, (previous) => {
|
|
2716
2947
|
const evaluation = onCheck(stored.definition, stored, recent[0] ?? null, previous, now);
|
|
2717
2948
|
nextExpectedAt = evaluation.nextExpectedAt;
|
|
2718
2949
|
const settled = applySilence(previous, evaluation, now);
|
|
2719
|
-
|
|
2950
|
+
const released = releaseSending(settled.state, this.now());
|
|
2951
|
+
const out = this.outbox({ state: released.state, alerts: settled.alerts }, stored.definition, now);
|
|
2952
|
+
return { state: out.state, result: { alerts: out.result.alerts, dropped: released.dropped + out.result.dropped } };
|
|
2720
2953
|
});
|
|
2954
|
+
this.reportDropped(stored.name, held.dropped);
|
|
2721
2955
|
alerts.push(...await this.retryUndelivered(stored.name, state, now, retries));
|
|
2722
|
-
alerts.push(...await this.dispatch(
|
|
2956
|
+
alerts.push(...await this.dispatch(stored.name, held.alerts, now));
|
|
2723
2957
|
jobs.push(summarize(stored, recent, state, nextExpectedAt, now));
|
|
2724
2958
|
} catch (e) {
|
|
2725
2959
|
this.onError(e, `checking ${stored.name}`);
|
|
@@ -2742,6 +2976,7 @@ ${id}`;
|
|
|
2742
2976
|
let recent = [];
|
|
2743
2977
|
try {
|
|
2744
2978
|
recent = await this.store.listRuns(stored.name, Math.max(runs, BASELINE_WINDOW));
|
|
2979
|
+
this.evaluable(stored);
|
|
2745
2980
|
const state = await this.readState(stored.name);
|
|
2746
2981
|
const { nextExpectedAt } = onCheck(stored.definition, stored, recent[0] ?? null, state, now);
|
|
2747
2982
|
return { job: summarize(stored, recent, state, nextExpectedAt, now), runs: recent.slice(0, runs) };
|
|
@@ -2763,19 +2998,20 @@ ${id}`;
|
|
|
2763
2998
|
/** Every job's summary with its newest `limit` runs, read together. What the dashboard shows. */
|
|
2764
2999
|
async jobsWithRuns(limit = 20) {
|
|
2765
3000
|
await this.ensureReady();
|
|
2766
|
-
|
|
3001
|
+
const jobs = await this.storedJobs();
|
|
2767
3002
|
const now = this.now();
|
|
2768
3003
|
const out = [];
|
|
2769
|
-
for (const stored of
|
|
3004
|
+
for (const stored of jobs) out.push(await this.snapshot(stored, now, clampLimit(limit, 20, 0)));
|
|
2770
3005
|
return out;
|
|
2771
3006
|
}
|
|
3007
|
+
/** A job's summary, or null for one the store does not have. One declared here and forgotten elsewhere is written again, as storedJobs() does. */
|
|
2772
3008
|
async jobSummary(name) {
|
|
2773
3009
|
await this.ensureReady();
|
|
2774
3010
|
const definition = this.definitions.get(name);
|
|
2775
|
-
if (definition) await this.sync(definition);
|
|
3011
|
+
if (definition) await this.sync(definition, true);
|
|
2776
3012
|
const stored = await this.store.getJob(name);
|
|
2777
3013
|
if (!stored) return null;
|
|
2778
|
-
return (await this.snapshot(stored, this.now(), 0)).job;
|
|
3014
|
+
return (await this.snapshot(this.readJob(stored), this.now(), 0)).job;
|
|
2779
3015
|
}
|
|
2780
3016
|
/** A job's runs, newest first. `limit` is a whole number from 1 to 500. */
|
|
2781
3017
|
async runs(name, limit = 50) {
|
|
@@ -2786,11 +3022,14 @@ ${id}`;
|
|
|
2786
3022
|
await this.ensureReady();
|
|
2787
3023
|
return this.store.getRun(id);
|
|
2788
3024
|
}
|
|
2789
|
-
/**
|
|
3025
|
+
/**
|
|
3026
|
+
* Stop alerts for a job for a while. State keeps updating underneath. The
|
|
3027
|
+
* end is a whole millisecond, held at 2^53 - 1 (see silenceEnd).
|
|
3028
|
+
*/
|
|
2790
3029
|
async silence(name, duration) {
|
|
2791
3030
|
const ms = parseDuration(duration, "silence duration");
|
|
2792
3031
|
return this.patchState(name, (state) => {
|
|
2793
|
-
state.silencedUntil = this.now()
|
|
3032
|
+
state.silencedUntil = silenceEnd(this.now(), ms);
|
|
2794
3033
|
});
|
|
2795
3034
|
}
|
|
2796
3035
|
async unsilence(name) {
|
|
@@ -2808,28 +3047,38 @@ ${id}`;
|
|
|
2808
3047
|
});
|
|
2809
3048
|
return state;
|
|
2810
3049
|
}
|
|
2811
|
-
/**
|
|
3050
|
+
/**
|
|
3051
|
+
* Remove a job and its runs from the store. A job still declared in code
|
|
3052
|
+
* comes back: here on its next run, and in any other process that declares
|
|
3053
|
+
* it on its next run there, or at that process's next check or dashboard read.
|
|
3054
|
+
*/
|
|
2812
3055
|
async forget(name) {
|
|
2813
3056
|
await this.ensureReady();
|
|
2814
3057
|
this.definitions.delete(name);
|
|
2815
3058
|
this.synced.delete(name);
|
|
2816
3059
|
await this.store.deleteJob(name);
|
|
2817
3060
|
}
|
|
2818
|
-
/**
|
|
3061
|
+
/**
|
|
3062
|
+
* The dashboard and JSON API as fetch-style handlers: the one way to mount
|
|
3063
|
+
* them. A Next.js app exports them from app/cronwatch/[[...path]]/route.ts:
|
|
3064
|
+
*
|
|
3065
|
+
* export const { GET, POST, DELETE } = cw.routes();
|
|
3066
|
+
*/
|
|
2819
3067
|
routes(options) {
|
|
2820
|
-
return
|
|
3068
|
+
return buildRoutes(this, options);
|
|
2821
3069
|
}
|
|
2822
3070
|
/**
|
|
2823
3071
|
* Check on an interval, for long-running servers. Default every minute.
|
|
2824
3072
|
* Not for serverless functions or Cloudflare Workers, where nothing runs
|
|
2825
|
-
* between requests: call check() from a cron there instead.
|
|
3073
|
+
* between requests: call check() from a cron there instead. (A job's
|
|
3074
|
+
* start() opens a run; this starts the checks.)
|
|
2826
3075
|
*/
|
|
2827
|
-
|
|
3076
|
+
startChecking(every = "1m") {
|
|
2828
3077
|
if (this.timer) return;
|
|
2829
3078
|
const ms = Math.min(TIMER_MAX_MS, Math.max(5e3, parseDuration(every, "check interval")));
|
|
2830
3079
|
if (this.deferDelivery && !this.warnedDeferredStart) {
|
|
2831
3080
|
this.warnedDeferredStart = true;
|
|
2832
|
-
console.warn('[cronwatch]
|
|
3081
|
+
console.warn('[cronwatch] startChecking() was called with deliver: "check", so these checks send no alerts. Another process must run checks with deliver: "now" (the default) to send them.');
|
|
2833
3082
|
}
|
|
2834
3083
|
const tick = () => this.check().catch((e) => this.onError(e, "check"));
|
|
2835
3084
|
this.timer = setInterval(tick, ms);
|
|
@@ -2840,38 +3089,52 @@ ${id}`;
|
|
|
2840
3089
|
}, 1e3);
|
|
2841
3090
|
if (typeof this.firstTick === "object" && "unref" in this.firstTick) this.firstTick.unref();
|
|
2842
3091
|
}
|
|
3092
|
+
/**
|
|
3093
|
+
* startChecking(), under its former name.
|
|
3094
|
+
* @deprecated Renamed `startChecking`, since a job's `start()` opens a run.
|
|
3095
|
+
* This name still works through 1.x and goes in 2.0.
|
|
3096
|
+
*/
|
|
3097
|
+
start(every = "1m") {
|
|
3098
|
+
this.startChecking(every);
|
|
3099
|
+
}
|
|
3100
|
+
/** Stop the checks startChecking() began. */
|
|
2843
3101
|
stop() {
|
|
2844
3102
|
if (this.timer) clearInterval(this.timer);
|
|
2845
3103
|
if (this.firstTick) clearTimeout(this.firstTick);
|
|
2846
3104
|
this.timer = null;
|
|
2847
3105
|
this.firstTick = null;
|
|
2848
3106
|
}
|
|
3107
|
+
/**
|
|
3108
|
+
* Stop the interval, wait for a check already under way (bounded by its
|
|
3109
|
+
* own channel, triage and retry timeouts; what it throws was reported to
|
|
3110
|
+
* whoever started it), then close the store, so that check neither writes
|
|
3111
|
+
* after the store is closed nor loses the alerts it would queue.
|
|
3112
|
+
*/
|
|
2849
3113
|
async close() {
|
|
2850
3114
|
this.stop();
|
|
3115
|
+
if (this.checking) await this.checking.catch(() => {
|
|
3116
|
+
});
|
|
2851
3117
|
if (this.store.close) await this.store.close();
|
|
2852
3118
|
}
|
|
2853
3119
|
/**
|
|
2854
|
-
*
|
|
2855
|
-
*
|
|
2856
|
-
* the delivery fields are written back, onto
|
|
3120
|
+
* Triage and send each alert the outbox holds (see outbox()). The state,
|
|
3121
|
+
* with the alerts in it, was saved before this, so a slow channel holds up
|
|
3122
|
+
* nothing else; afterwards only the delivery fields are written back, onto
|
|
3123
|
+
* a fresh read of the state, and the alerts leave `sending`. Triage is made
|
|
3124
|
+
* here, never stored with the held alert: the write that opens a condition
|
|
3125
|
+
* cannot wait for it, and a retry triages an alert that has none. With
|
|
3126
|
+
* deliver: "check" the alerts were queued for a check elsewhere instead.
|
|
2857
3127
|
*/
|
|
2858
|
-
async dispatch(
|
|
2859
|
-
if (
|
|
2860
|
-
const composed = [];
|
|
3128
|
+
async dispatch(name, alerts, now) {
|
|
3129
|
+
if (alerts.length === 0 || this.deferDelivery) return alerts;
|
|
2861
3130
|
const delivered = [];
|
|
2862
3131
|
const failed = [];
|
|
2863
|
-
for (const
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
failed.push(alert);
|
|
2867
|
-
} else {
|
|
2868
|
-
if (this.triage && alert.type !== "recovered") await this.addTriage(alert, TRIAGE_TIMEOUT_MS);
|
|
2869
|
-
(await this.deliver(alert) ? delivered : failed).push(alert);
|
|
2870
|
-
}
|
|
2871
|
-
composed.push(alert);
|
|
3132
|
+
for (const alert of alerts) {
|
|
3133
|
+
if (this.triage && alert.type !== "recovered") await this.addTriage(alert, TRIAGE_TIMEOUT_MS);
|
|
3134
|
+
(await this.deliver(alert) ? delivered : failed).push(alert);
|
|
2872
3135
|
}
|
|
2873
|
-
await this.recordDelivery(
|
|
2874
|
-
return
|
|
3136
|
+
await this.recordDelivery(name, delivered, failed, [], now);
|
|
3137
|
+
return alerts;
|
|
2875
3138
|
}
|
|
2876
3139
|
/**
|
|
2877
3140
|
* Send the alerts that no channel accepted last time, once each, oldest
|
|
@@ -2900,28 +3163,18 @@ ${id}`;
|
|
|
2900
3163
|
}
|
|
2901
3164
|
/**
|
|
2902
3165
|
* Mark delivered alerts done, drop stale ones, and keep failed ones for the
|
|
2903
|
-
* next check
|
|
2904
|
-
*
|
|
3166
|
+
* next check, taking them all out of `sending` (recordSent). A failed alert
|
|
3167
|
+
* replaces its stored copy, so a triage made on this attempt is kept.
|
|
3168
|
+
* lastAlertAt moves only on a delivery. When this write fails, alerts
|
|
3169
|
+
* still in `sending` are retried once their lease runs out.
|
|
2905
3170
|
*/
|
|
2906
3171
|
async recordDelivery(name, delivered, failed, dropped, now) {
|
|
2907
3172
|
try {
|
|
2908
3173
|
const { result: trimmed } = await this.updateState(name, (previous) => {
|
|
2909
|
-
const state = normalizeState(previous, name);
|
|
2910
|
-
|
|
2911
|
-
const retried = new Map(failed.map((a) => [alertKey(a), a]));
|
|
2912
|
-
const kept = state.undelivered.filter((a) => !done.has(alertKey(a))).map((a) => retried.get(alertKey(a)) ?? a);
|
|
2913
|
-
const known = new Set(kept.map(alertKey));
|
|
2914
|
-
kept.push(...failed.filter((a) => !known.has(alertKey(a))));
|
|
2915
|
-
state.undelivered = kept.slice(-MAX_UNDELIVERED);
|
|
2916
|
-
if (delivered.length > 0) state.lastAlertAt = now;
|
|
2917
|
-
return { state, result: Math.max(0, kept.length - MAX_UNDELIVERED) };
|
|
3174
|
+
const { state, dropped: letGo } = recordSent(normalizeState(previous, name), delivered, failed, dropped, now);
|
|
3175
|
+
return { state, result: letGo };
|
|
2918
3176
|
});
|
|
2919
|
-
|
|
2920
|
-
this.onError(
|
|
2921
|
-
new Error(`${trimmed} undelivered alert${trimmed === 1 ? "" : "s"} for ${name} dropped: only the newest ${MAX_UNDELIVERED} are kept for retry`),
|
|
2922
|
-
`alert queue for ${name}`
|
|
2923
|
-
);
|
|
2924
|
-
}
|
|
3177
|
+
this.reportDropped(name, trimmed);
|
|
2925
3178
|
} catch (e) {
|
|
2926
3179
|
this.onError(e, `recording alert delivery for ${name}`);
|
|
2927
3180
|
}
|
|
@@ -2957,9 +3210,10 @@ ${id}`;
|
|
|
2957
3210
|
}
|
|
2958
3211
|
};
|
|
2959
3212
|
var RESERVED_RUN_ID_PREFIX = "pgcron:";
|
|
3213
|
+
var MAX_RUN_ID = 200;
|
|
2960
3214
|
function checkRunId(job, id, method) {
|
|
2961
|
-
if (typeof id !== "string" || id.length === 0 || id.length >
|
|
2962
|
-
throw new Error(`job "${job}": ${method}() needs a run id of 1 to
|
|
3215
|
+
if (typeof id !== "string" || id.length === 0 || id.length > MAX_RUN_ID) {
|
|
3216
|
+
throw new Error(`job "${job}": ${method}() needs a run id of 1 to ${MAX_RUN_ID} characters (got ${typeof id === "string" ? `${id.length} characters` : typeof id})`);
|
|
2963
3217
|
}
|
|
2964
3218
|
if (id.includes("\0")) throw new Error(`job "${job}": ${method}() cannot take a run id containing a NUL character`);
|
|
2965
3219
|
if (id.startsWith(RESERVED_RUN_ID_PREFIX)) {
|
|
@@ -3020,13 +3274,16 @@ function custom(name, send) {
|
|
|
3020
3274
|
await send(alert);
|
|
3021
3275
|
} };
|
|
3022
3276
|
}
|
|
3277
|
+
var CronWatch = Cronwatch;
|
|
3023
3278
|
|
|
3024
3279
|
// src/index.ts
|
|
3025
3280
|
function cronwatch(options = {}) {
|
|
3026
|
-
return new
|
|
3281
|
+
return new Cronwatch(options);
|
|
3027
3282
|
}
|
|
3028
3283
|
|
|
3029
3284
|
exports.CronWatch = CronWatch;
|
|
3285
|
+
exports.Cronwatch = Cronwatch;
|
|
3286
|
+
exports.VERSION = VERSION;
|
|
3030
3287
|
exports.composeAlert = composeAlert;
|
|
3031
3288
|
exports.consoleChannel = consoleChannel;
|
|
3032
3289
|
exports.createRoutes = createRoutes;
|