@cronwatch/sdk 0.3.0 → 0.4.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 +8 -7
- package/dist/anthropic.d.cts +1 -1
- package/dist/anthropic.d.ts +1 -1
- package/dist/bugsnag.cjs +108 -0
- package/dist/bugsnag.cjs.map +1 -0
- package/dist/bugsnag.d.cts +26 -0
- package/dist/bugsnag.d.ts +26 -0
- package/dist/bugsnag.js +106 -0
- package/dist/bugsnag.js.map +1 -0
- package/dist/client-B7bqnkI5.d.cts +450 -0
- package/dist/client-pmqH3apS.d.ts +450 -0
- package/dist/d1.cjs +214 -0
- package/dist/d1.cjs.map +1 -0
- package/dist/d1.d.cts +42 -0
- package/dist/d1.d.ts +42 -0
- package/dist/d1.js +212 -0
- package/dist/d1.js.map +1 -0
- package/dist/datadog.cjs +95 -0
- package/dist/datadog.cjs.map +1 -0
- package/dist/datadog.d.cts +24 -0
- package/dist/datadog.d.ts +24 -0
- package/dist/datadog.js +93 -0
- package/dist/datadog.js.map +1 -0
- package/dist/discord.cjs +2 -0
- package/dist/discord.cjs.map +1 -1
- package/dist/discord.d.cts +1 -1
- package/dist/discord.d.ts +1 -1
- package/dist/discord.js +2 -0
- package/dist/discord.js.map +1 -1
- package/dist/email-BJ-z_epD.d.cts +21 -0
- package/dist/email-_H0faCh3.d.ts +21 -0
- package/dist/honeybadger.cjs +101 -0
- package/dist/honeybadger.cjs.map +1 -0
- package/dist/honeybadger.d.cts +23 -0
- package/dist/honeybadger.d.ts +23 -0
- package/dist/honeybadger.js +99 -0
- package/dist/honeybadger.js.map +1 -0
- package/dist/index.cjs +692 -124
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -228
- package/dist/index.d.ts +5 -228
- package/dist/index.js +692 -124
- package/dist/index.js.map +1 -1
- package/dist/mailgun.cjs +115 -0
- package/dist/mailgun.cjs.map +1 -0
- package/dist/mailgun.d.cts +21 -0
- package/dist/mailgun.d.ts +21 -0
- package/dist/mailgun.js +113 -0
- package/dist/mailgun.js.map +1 -0
- package/dist/newrelic.cjs +88 -0
- package/dist/newrelic.cjs.map +1 -0
- package/dist/newrelic.d.cts +25 -0
- package/dist/newrelic.d.ts +25 -0
- package/dist/newrelic.js +86 -0
- package/dist/newrelic.js.map +1 -0
- package/dist/node.cjs +191 -0
- package/dist/node.cjs.map +1 -0
- package/dist/node.d.cts +97 -0
- package/dist/node.d.ts +97 -0
- package/dist/node.js +186 -0
- package/dist/node.js.map +1 -0
- package/dist/pg-cron.cjs +286 -0
- package/dist/pg-cron.cjs.map +1 -0
- package/dist/pg-cron.d.cts +93 -0
- package/dist/pg-cron.d.ts +93 -0
- package/dist/pg-cron.js +280 -0
- package/dist/pg-cron.js.map +1 -0
- package/dist/postgres.cjs +33 -1
- 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 +33 -1
- package/dist/postgres.js.map +1 -1
- package/dist/postmark.cjs +106 -0
- package/dist/postmark.cjs.map +1 -0
- package/dist/postmark.d.cts +18 -0
- package/dist/postmark.d.ts +18 -0
- package/dist/postmark.js +104 -0
- package/dist/postmark.js.map +1 -0
- package/dist/resend.cjs +114 -0
- package/dist/resend.cjs.map +1 -0
- package/dist/resend.d.cts +16 -0
- package/dist/resend.d.ts +16 -0
- package/dist/resend.js +112 -0
- package/dist/resend.js.map +1 -0
- package/dist/rollbar.cjs +108 -0
- package/dist/rollbar.cjs.map +1 -0
- package/dist/rollbar.d.cts +20 -0
- package/dist/rollbar.d.ts +20 -0
- package/dist/rollbar.js +106 -0
- package/dist/rollbar.js.map +1 -0
- package/dist/sendgrid.cjs +114 -0
- package/dist/sendgrid.cjs.map +1 -0
- package/dist/sendgrid.d.cts +19 -0
- package/dist/sendgrid.d.ts +19 -0
- package/dist/sendgrid.js +112 -0
- package/dist/sendgrid.js.map +1 -0
- package/dist/sentry.cjs +130 -0
- package/dist/sentry.cjs.map +1 -0
- package/dist/sentry.d.cts +28 -0
- package/dist/sentry.d.ts +28 -0
- package/dist/sentry.js +127 -0
- package/dist/sentry.js.map +1 -0
- package/dist/ses.cjs +170 -0
- package/dist/ses.cjs.map +1 -0
- package/dist/ses.d.cts +25 -0
- package/dist/ses.d.ts +25 -0
- package/dist/ses.js +168 -0
- package/dist/ses.js.map +1 -0
- package/dist/slack.cjs +2 -0
- 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 +2 -0
- package/dist/slack.js.map +1 -1
- package/dist/sqlite.cjs +62 -5
- 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 +62 -5
- package/dist/sqlite.js.map +1 -1
- package/dist/twilio.cjs +168 -0
- package/dist/twilio.cjs.map +1 -0
- package/dist/twilio.d.cts +52 -0
- package/dist/twilio.d.ts +52 -0
- package/dist/twilio.js +162 -0
- package/dist/twilio.js.map +1 -0
- package/dist/{types-C1PyRjI5.d.cts → types-Ddq1MUPL.d.cts} +50 -4
- package/dist/{types-C1PyRjI5.d.ts → types-Ddq1MUPL.d.ts} +50 -4
- package/dist/webhook.cjs +11 -5
- package/dist/webhook.cjs.map +1 -1
- package/dist/webhook.d.cts +6 -3
- package/dist/webhook.d.ts +6 -3
- package/dist/webhook.js +11 -6
- package/dist/webhook.js.map +1 -1
- package/package.json +211 -5
package/dist/index.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { Cron } from 'croner';
|
|
2
|
-
import { createHash } from 'crypto';
|
|
3
2
|
|
|
4
3
|
// src/duration.ts
|
|
5
4
|
var UNIT_MS = {
|
|
@@ -53,6 +52,16 @@ function formatRelative(at, now) {
|
|
|
53
52
|
if (abs < 5e3) return text;
|
|
54
53
|
return diff < 0 ? `${text} ago` : `in ${text}`;
|
|
55
54
|
}
|
|
55
|
+
|
|
56
|
+
// src/env.ts
|
|
57
|
+
function readEnv(name) {
|
|
58
|
+
return typeof process === "undefined" || !process.env ? void 0 : process.env[name];
|
|
59
|
+
}
|
|
60
|
+
function isDevelopment() {
|
|
61
|
+
if (typeof process === "undefined" || !process.env) return false;
|
|
62
|
+
const mode = process.env.NODE_ENV;
|
|
63
|
+
return mode === "development" || mode === "test";
|
|
64
|
+
}
|
|
56
65
|
var cache = /* @__PURE__ */ new Map();
|
|
57
66
|
var crons = /* @__PURE__ */ new WeakMap();
|
|
58
67
|
var EARLY_SLACK_MS = 6e4;
|
|
@@ -305,7 +314,15 @@ function onRunFinish(def, run, state, history, now) {
|
|
|
305
314
|
function onCheck(def, stored, lastRun, state, now) {
|
|
306
315
|
const next = cloneState(state);
|
|
307
316
|
const alerts = [];
|
|
308
|
-
if (!def.schedule)
|
|
317
|
+
if (!def.schedule) {
|
|
318
|
+
const since = next.open.missed;
|
|
319
|
+
if (since !== void 0) {
|
|
320
|
+
delete next.open.missed;
|
|
321
|
+
next.pendingRecovery = (next.pendingRecovery ?? []).filter((c) => c !== "missed");
|
|
322
|
+
alerts.push({ type: "recovered", run: lastRun, details: { after: ["missed"], reason: "unscheduled", since } });
|
|
323
|
+
}
|
|
324
|
+
return { state: next, alerts, nextExpectedAt: null, dueAt: null };
|
|
325
|
+
}
|
|
309
326
|
const parsed = parseSchedule(def.schedule, def.timezone);
|
|
310
327
|
const grace = graceMs(def);
|
|
311
328
|
const lastRunAt = lastRun?.startedAt ?? null;
|
|
@@ -337,6 +354,14 @@ function muteOpens(previous, next) {
|
|
|
337
354
|
function isSilenced(state, now) {
|
|
338
355
|
return state.silencedUntil !== null && state.silencedUntil > now;
|
|
339
356
|
}
|
|
357
|
+
function applySilence(previous, evaluation, now) {
|
|
358
|
+
if (!isSilenced(previous, now)) return evaluation;
|
|
359
|
+
return { state: muteOpens(previous, evaluation.state), alerts: [] };
|
|
360
|
+
}
|
|
361
|
+
function staleAlert(alert, state) {
|
|
362
|
+
if (alert.type === "recovered") return alert.details.after.some((condition) => state.open[condition] !== void 0);
|
|
363
|
+
return state.open[alert.type] !== alert.at;
|
|
364
|
+
}
|
|
340
365
|
function jobHealth(def, lastRun, state, now) {
|
|
341
366
|
const open = openConditions(state);
|
|
342
367
|
if (isSilenced(state, now)) return "silenced";
|
|
@@ -347,6 +372,12 @@ function jobHealth(def, lastRun, state, now) {
|
|
|
347
372
|
return "healthy";
|
|
348
373
|
}
|
|
349
374
|
function summarize(stored, recent, state, nextExpectedAt, now) {
|
|
375
|
+
return summary(stored, recent, state, nextExpectedAt, (lastRun) => jobHealth(stored.definition, lastRun, state, now));
|
|
376
|
+
}
|
|
377
|
+
function unevaluableSummary(stored, recent, state, now) {
|
|
378
|
+
return summary(stored, recent, state, null, () => isSilenced(state, now) ? "silenced" : "failing");
|
|
379
|
+
}
|
|
380
|
+
function summary(stored, recent, state, nextExpectedAt, health) {
|
|
350
381
|
const window = recent.slice(0, BASELINE_WINDOW);
|
|
351
382
|
const lastRun = window[0] ?? null;
|
|
352
383
|
const finished = window.filter((r) => r.status !== "running");
|
|
@@ -354,7 +385,7 @@ function summarize(stored, recent, state, nextExpectedAt, now) {
|
|
|
354
385
|
return {
|
|
355
386
|
name: stored.name,
|
|
356
387
|
definition: stored.definition,
|
|
357
|
-
health:
|
|
388
|
+
health: health(lastRun),
|
|
358
389
|
open: openConditions(state),
|
|
359
390
|
lastRun,
|
|
360
391
|
nextExpectedAt,
|
|
@@ -441,6 +472,12 @@ ${out}`);
|
|
|
441
472
|
break;
|
|
442
473
|
}
|
|
443
474
|
case "recovered": {
|
|
475
|
+
if (draft.details.reason === "unscheduled") {
|
|
476
|
+
title = `${name} is no longer scheduled`;
|
|
477
|
+
const since = draft.details.since;
|
|
478
|
+
lines.push(`${since === void 0 ? "" : `Missed since ${when(since, now)}. `}It has no schedule now, so nothing is due; the missed alert is closed.`);
|
|
479
|
+
break;
|
|
480
|
+
}
|
|
444
481
|
title = `${name} recovered`;
|
|
445
482
|
const after = draft.details.after.map((c) => c.replace("_", " ")).join(", ");
|
|
446
483
|
lines.push(`A run ${run ? when(run.startedAt, now) : "just now"} succeeded${after ? ` after: ${after}` : ""}.`);
|
|
@@ -468,8 +505,12 @@ function constantTimeEqual(a, b) {
|
|
|
468
505
|
// src/output.ts
|
|
469
506
|
var OUTPUT_CAP = 16 * 1024;
|
|
470
507
|
function capOutput(text) {
|
|
471
|
-
|
|
472
|
-
|
|
508
|
+
const clean = stripNul(text);
|
|
509
|
+
if (clean.length <= OUTPUT_CAP) return clean;
|
|
510
|
+
return "[earlier output trimmed]\n" + clean.slice(clean.length - OUTPUT_CAP);
|
|
511
|
+
}
|
|
512
|
+
function stripNul(text) {
|
|
513
|
+
return text.includes("\0") ? text.replace(/\u0000/g, "") : text;
|
|
473
514
|
}
|
|
474
515
|
function errorMessage(error) {
|
|
475
516
|
return capOutput(describeError(error));
|
|
@@ -489,25 +530,44 @@ ${frames.join("\n")}` : ""}`;
|
|
|
489
530
|
}
|
|
490
531
|
var REDACTED = "[redacted]";
|
|
491
532
|
var SECRET_PATTERNS = [
|
|
492
|
-
//
|
|
493
|
-
//
|
|
533
|
+
// A PEM private key, header to footer. Without a footer (the output was
|
|
534
|
+
// trimmed) it runs to the end of the base64 body. A "-" that starts five
|
|
535
|
+
// dashes ends the body, so the footer is never swallowed into it.
|
|
494
536
|
[
|
|
495
|
-
|
|
537
|
+
/-----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,
|
|
538
|
+
REDACTED
|
|
539
|
+
],
|
|
540
|
+
// password=..., API_KEY: ..., "client_secret": "...", TOKEN='...', token=...,
|
|
541
|
+
// :password=>"..." (but not max_tokens: 800). A quoted value is blanked to
|
|
542
|
+
// its closing quote, spaces and all, and keeps its quotes.
|
|
543
|
+
[
|
|
544
|
+
/\b([A-Za-z0-9_-]{0,40}(?:secret|token|passw(?:or)?d|pwd|api[_-]?key|access[_-]?key|private[_-]?key|credential)[A-Za-z0-9_-]{0,40}(?<![Tt][Oo][Kk][Ee][Nn][Ss])"?\s{0,3}(?:=>|[=:])\s{0,3})(?:(")[^"\n]{1,4096}"|(')[^'\n]{1,4096}'|["']?[^\s"',;&]{1,4096})/gi,
|
|
496
545
|
(_match, name, double, single) => {
|
|
497
546
|
const quote = double ?? single ?? "";
|
|
498
547
|
return `${name}${quote}${REDACTED}${quote}`;
|
|
499
548
|
}
|
|
500
549
|
],
|
|
501
|
-
//
|
|
502
|
-
[
|
|
550
|
+
// Authorization: Basic <base64> and Authorization: Token <token>, also as a JSON or hash entry.
|
|
551
|
+
[/\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}`],
|
|
552
|
+
// Credentials inside a URL: postgres://user:password@host. The password
|
|
553
|
+
// runs to the last "@" before a "/" or a space, so one that contains "@"
|
|
554
|
+
// is blanked whole.
|
|
555
|
+
[/(\b[a-z][a-z0-9+.-]{0,30}:\/\/[^\s/:@]{0,256}:)[^\s/]{1,256}@/gi, `$1${REDACTED}@`],
|
|
503
556
|
// Authorization: Bearer <token>
|
|
504
557
|
[/\b(Bearer\s{1,3})[A-Za-z0-9._~+/=-]{8,4096}/g, `$1${REDACTED}`],
|
|
505
|
-
//
|
|
558
|
+
// A bare JWT: three base64url segments, the first starting eyJ.
|
|
559
|
+
[/\beyJ[A-Za-z0-9_-]{4,4096}\.[A-Za-z0-9_-]{4,4096}\.[A-Za-z0-9_-]{0,4096}/g, REDACTED],
|
|
560
|
+
// Incoming webhook URLs carry their secret in the path.
|
|
561
|
+
[/(\bhooks\.slack\.com\/(?:services|workflows|triggers)\/)[A-Za-z0-9/_-]{1,255}/gi, `$1${REDACTED}`],
|
|
562
|
+
[/(\bdiscord(?:app)?\.com\/api\/(?:v\d{1,2}\/)?webhooks\/)[A-Za-z0-9/_-]{1,255}/gi, `$1${REDACTED}`],
|
|
563
|
+
// Well-known token shapes: AWS, GitHub, Slack, Stripe, Anthropic, OpenAI and Google style keys.
|
|
506
564
|
[/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, REDACTED],
|
|
507
565
|
[/\b(?:gh[pousr]_[A-Za-z0-9]{30,255}|github_pat_[A-Za-z0-9_]{20,255})\b/g, REDACTED],
|
|
508
566
|
[/\bxox[abposr]-[A-Za-z0-9-]{10,255}/g, REDACTED],
|
|
509
567
|
[/\b[rsp]k_(?:live|test)_[A-Za-z0-9]{10,255}\b/g, REDACTED],
|
|
510
|
-
[/\
|
|
568
|
+
[/\bwhsec_[A-Za-z0-9+/=]{16,255}/g, REDACTED],
|
|
569
|
+
[/\bsk-[A-Za-z0-9_-]{20,255}/g, REDACTED],
|
|
570
|
+
[/\bAIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-])/g, REDACTED]
|
|
511
571
|
];
|
|
512
572
|
function redactSecrets(text) {
|
|
513
573
|
let out = text;
|
|
@@ -779,14 +839,21 @@ function messagePage(title, message, base) {
|
|
|
779
839
|
|
|
780
840
|
// src/routes/index.ts
|
|
781
841
|
var COOKIE = "cronwatch_token";
|
|
782
|
-
function cookieValue(token) {
|
|
783
|
-
|
|
842
|
+
async function cookieValue(token) {
|
|
843
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`cronwatch-cookie:${token}`));
|
|
844
|
+
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
784
845
|
}
|
|
785
|
-
var LOOPBACK = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
786
846
|
var DEFAULT_RUNS = 20;
|
|
787
847
|
var MAX_RUNS = 500;
|
|
788
848
|
var CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none'";
|
|
789
849
|
var SECURITY_HEADERS = { "x-content-type-options": "nosniff", "referrer-policy": "same-origin", "x-robots-tag": "noindex" };
|
|
850
|
+
function developmentToken() {
|
|
851
|
+
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
|
852
|
+
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
853
|
+
}
|
|
854
|
+
function developmentSignInLine(origin, base, token) {
|
|
855
|
+
return `[cronwatch] CRONWATCH_TOKEN is not set, so this development server made a token for the dashboard. Sign in: ${origin}${base}/?token=${token}`;
|
|
856
|
+
}
|
|
790
857
|
function safeDecode(value) {
|
|
791
858
|
try {
|
|
792
859
|
return decodeURIComponent(value);
|
|
@@ -803,6 +870,34 @@ function readCookie(request, name) {
|
|
|
803
870
|
}
|
|
804
871
|
return null;
|
|
805
872
|
}
|
|
873
|
+
function firstValue(value) {
|
|
874
|
+
const first = value?.split(",")[0]?.trim();
|
|
875
|
+
return first ? first : null;
|
|
876
|
+
}
|
|
877
|
+
function configuredOrigin(value) {
|
|
878
|
+
if (value === void 0 || value === "") return null;
|
|
879
|
+
let url;
|
|
880
|
+
try {
|
|
881
|
+
url = new URL(value);
|
|
882
|
+
} catch {
|
|
883
|
+
throw new Error(`routes: origin must be an absolute URL such as "https://app.example.com", got ${JSON.stringify(value)}`);
|
|
884
|
+
}
|
|
885
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`routes: origin must be http or https, got ${JSON.stringify(value)}`);
|
|
886
|
+
return url.origin;
|
|
887
|
+
}
|
|
888
|
+
function forwardedOrigin(request, url) {
|
|
889
|
+
const proto = firstValue(request.headers.get("x-forwarded-proto"))?.toLowerCase() ?? null;
|
|
890
|
+
const host = firstValue(request.headers.get("x-forwarded-host"));
|
|
891
|
+
if (proto === null && host === null) return url.origin;
|
|
892
|
+
if (proto !== null && proto !== "http" && proto !== "https") return url.origin;
|
|
893
|
+
try {
|
|
894
|
+
const built = new URL(`${proto ?? url.protocol.slice(0, -1)}://${host ?? url.host}`);
|
|
895
|
+
if (built.pathname !== "/" || built.username || built.password || built.search || built.hash) return url.origin;
|
|
896
|
+
return built.origin;
|
|
897
|
+
} catch {
|
|
898
|
+
return url.origin;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
806
901
|
function stripBase(pathname, base) {
|
|
807
902
|
let path = pathname.startsWith(base) ? pathname.slice(base.length) : pathname;
|
|
808
903
|
if (path === "") path = "/";
|
|
@@ -827,9 +922,9 @@ async function readBody(request) {
|
|
|
827
922
|
}
|
|
828
923
|
return {};
|
|
829
924
|
}
|
|
830
|
-
function crossSite(request,
|
|
925
|
+
function crossSite(request, publicOrigin) {
|
|
831
926
|
const origin = request.headers.get("origin");
|
|
832
|
-
if (origin !== null && origin !==
|
|
927
|
+
if (origin !== null && origin !== publicOrigin) return true;
|
|
833
928
|
const site = request.headers.get("sec-fetch-site");
|
|
834
929
|
return site !== null && site !== "same-origin" && site !== "none";
|
|
835
930
|
}
|
|
@@ -846,38 +941,54 @@ function runsLimit(value) {
|
|
|
846
941
|
}
|
|
847
942
|
function createRoutes(cw, options = {}) {
|
|
848
943
|
const optedOut = options.token === null;
|
|
849
|
-
const
|
|
944
|
+
const configured = optedOut ? null : options.token || readEnv("CRONWATCH_TOKEN") || null;
|
|
850
945
|
const base = (options.basePath ?? "/cronwatch").replace(/\/+$/, "");
|
|
851
|
-
const developing =
|
|
946
|
+
const developing = isDevelopment();
|
|
947
|
+
const fixedOrigin = configuredOrigin(options.origin);
|
|
948
|
+
const trustProxy = options.trustProxy === true;
|
|
949
|
+
const originOf = (request, url) => fixedOrigin ?? (trustProxy ? forwardedOrigin(request, url) : url.origin);
|
|
950
|
+
const generated = !configured && !optedOut && developing;
|
|
951
|
+
const token = generated ? developmentToken() : configured;
|
|
952
|
+
let announced = false;
|
|
953
|
+
let cookie = null;
|
|
954
|
+
const expectedCookie = (value) => cookie ??= cookieValue(value);
|
|
852
955
|
const serve = async (request, url, path, wantsHtml) => {
|
|
853
956
|
const method = request.method.toUpperCase();
|
|
854
|
-
|
|
855
|
-
|
|
957
|
+
const publicOrigin = originOf(request, url);
|
|
958
|
+
if (generated && !announced) {
|
|
959
|
+
announced = true;
|
|
960
|
+
console.info(developmentSignInLine(publicOrigin, base, token));
|
|
961
|
+
}
|
|
962
|
+
if (!token && !optedOut) {
|
|
963
|
+
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);
|
|
856
964
|
}
|
|
857
|
-
if (method !== "GET" && method !== "HEAD" && crossSite(request,
|
|
965
|
+
if (method !== "GET" && method !== "HEAD" && crossSite(request, publicOrigin)) {
|
|
858
966
|
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);
|
|
859
967
|
}
|
|
860
968
|
const bearer = request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") ?? null;
|
|
861
969
|
if (token) {
|
|
862
970
|
const query = wantsHtml && method === "GET" ? url.searchParams.get("token") : null;
|
|
863
|
-
const
|
|
971
|
+
const sent = readCookie(request, COOKIE);
|
|
864
972
|
const isCheck = path === "/api/check";
|
|
865
973
|
const cronSecretOk = isCheck && bearer !== null && cw.cronSecret !== null && constantTimeEqual(bearer, cw.cronSecret);
|
|
866
|
-
const tokenOk = bearer !== null ? constantTimeEqual(bearer, token) : query !== null ? constantTimeEqual(query, token) :
|
|
974
|
+
const tokenOk = bearer !== null ? constantTimeEqual(bearer, token) : query !== null ? constantTimeEqual(query, token) : sent !== null && constantTimeEqual(sent, await expectedCookie(token));
|
|
867
975
|
if (!cronSecretOk && !tokenOk) {
|
|
976
|
+
if (generated) {
|
|
977
|
+
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);
|
|
978
|
+
}
|
|
868
979
|
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);
|
|
869
980
|
}
|
|
870
981
|
if (query !== null) {
|
|
871
982
|
url.searchParams.delete("token");
|
|
872
|
-
const secure =
|
|
983
|
+
const secure = publicOrigin.startsWith("https:") ? "; Secure" : "";
|
|
873
984
|
return redirect(url.pathname + (url.search || ""), {
|
|
874
|
-
"set-cookie": `${COOKIE}=${
|
|
985
|
+
"set-cookie": `${COOKIE}=${await expectedCookie(token)}; Path=${base || "/"}; HttpOnly; SameSite=Lax; Max-Age=${60 * 60 * 24 * 30}${secure}`
|
|
875
986
|
});
|
|
876
987
|
}
|
|
877
988
|
}
|
|
878
989
|
const redirectBack = () => {
|
|
879
990
|
const referer = request.headers.get("referer") ?? "";
|
|
880
|
-
return redirect(referer.startsWith(
|
|
991
|
+
return redirect(referer.startsWith(publicOrigin + "/") ? referer : `${base}/`);
|
|
881
992
|
};
|
|
882
993
|
const decoded = path.split("/").filter(Boolean).map(safeDecode);
|
|
883
994
|
if (decoded.some((part) => part === null)) {
|
|
@@ -1038,6 +1149,7 @@ function memory() {
|
|
|
1038
1149
|
}
|
|
1039
1150
|
},
|
|
1040
1151
|
async insertRun(run) {
|
|
1152
|
+
if (runs.has(run.id)) throw new Error(`run ${run.id} already exists`);
|
|
1041
1153
|
runs.set(run.id, clone(run));
|
|
1042
1154
|
order.set(run.id, ++seq);
|
|
1043
1155
|
},
|
|
@@ -1047,6 +1159,13 @@ function memory() {
|
|
|
1047
1159
|
const { status, finishedAt, durationMs, error, output, metrics } = clone(run);
|
|
1048
1160
|
runs.set(run.id, { ...existing, status, finishedAt, durationMs, error, output, metrics });
|
|
1049
1161
|
},
|
|
1162
|
+
async updateRunIf(run, fromStatuses) {
|
|
1163
|
+
const existing = runs.get(run.id);
|
|
1164
|
+
if (!existing || !fromStatuses.includes(existing.status)) return false;
|
|
1165
|
+
const { status, finishedAt, durationMs, error, output, metrics } = clone(run);
|
|
1166
|
+
runs.set(run.id, { ...existing, status, finishedAt, durationMs, error, output, metrics });
|
|
1167
|
+
return true;
|
|
1168
|
+
},
|
|
1050
1169
|
async getRun(id) {
|
|
1051
1170
|
const r = runs.get(id);
|
|
1052
1171
|
return r ? clone(r) : null;
|
|
@@ -1068,6 +1187,11 @@ function memory() {
|
|
|
1068
1187
|
async setState(state) {
|
|
1069
1188
|
states.set(state.job, clone(state));
|
|
1070
1189
|
},
|
|
1190
|
+
async compareAndSetState(state, expectedVersion) {
|
|
1191
|
+
if ((states.get(state.job)?.version ?? 0) !== expectedVersion) return false;
|
|
1192
|
+
states.set(state.job, clone(state));
|
|
1193
|
+
return true;
|
|
1194
|
+
},
|
|
1071
1195
|
async prune(before) {
|
|
1072
1196
|
const newest = /* @__PURE__ */ new Map();
|
|
1073
1197
|
for (const run of runs.values()) newest.set(run.job, Math.max(newest.get(run.job) ?? -Infinity, run.startedAt));
|
|
@@ -1090,6 +1214,8 @@ var TRIAGE_TIMEOUT_MS = 25e3;
|
|
|
1090
1214
|
var CHANNEL_TIMEOUT_MS = 15e3;
|
|
1091
1215
|
var PRUNE_INTERVAL_MS = 60 * 6e4;
|
|
1092
1216
|
var MAX_UNDELIVERED = 20;
|
|
1217
|
+
var RETRY_BUDGET_MS = 2e4;
|
|
1218
|
+
var STATE_ATTEMPTS = 10;
|
|
1093
1219
|
var HISTORY_PAGE = BASELINE_WINDOW + 5;
|
|
1094
1220
|
var HISTORY_MAX = 200;
|
|
1095
1221
|
function validateDefinition(def) {
|
|
@@ -1135,6 +1261,7 @@ var CronWatch = class {
|
|
|
1135
1261
|
store;
|
|
1136
1262
|
alerts;
|
|
1137
1263
|
triage;
|
|
1264
|
+
sources;
|
|
1138
1265
|
/** The secret handler() requests must carry, or null when none is set. */
|
|
1139
1266
|
cronSecret;
|
|
1140
1267
|
retentionMs;
|
|
@@ -1150,6 +1277,8 @@ var CronWatch = class {
|
|
|
1150
1277
|
synced = /* @__PURE__ */ new Set();
|
|
1151
1278
|
/** The tail of each job's queue of state updates. See serial(). */
|
|
1152
1279
|
queues = /* @__PURE__ */ new Map();
|
|
1280
|
+
/** start() calls with an id still in flight, so two at once in this process record one run. */
|
|
1281
|
+
starting = /* @__PURE__ */ new Map();
|
|
1153
1282
|
ready = null;
|
|
1154
1283
|
checking = null;
|
|
1155
1284
|
lastPruneAt = 0;
|
|
@@ -1157,17 +1286,29 @@ var CronWatch = class {
|
|
|
1157
1286
|
firstTick = null;
|
|
1158
1287
|
usingDefaultStore = false;
|
|
1159
1288
|
warnedNoSecret = false;
|
|
1289
|
+
warnedDeferredStart = false;
|
|
1160
1290
|
constructor(options = {}) {
|
|
1161
1291
|
this.store = options.store ?? (this.usingDefaultStore = true, memory());
|
|
1162
1292
|
this.alerts = options.alerts ?? [consoleChannel()];
|
|
1163
1293
|
this.triage = options.triage;
|
|
1164
|
-
|
|
1294
|
+
this.sources = options.sources ?? [];
|
|
1295
|
+
const secret = options.cronSecret === void 0 ? readEnv("CRON_SECRET") : options.cronSecret;
|
|
1165
1296
|
this.cronSecret = secret ? secret : null;
|
|
1166
1297
|
this.secretOptOut = options.cronSecret === null;
|
|
1167
1298
|
this.retentionMs = parseDuration(options.retention ?? "30d", "retention");
|
|
1168
1299
|
this.defaults = options.defaults ?? {};
|
|
1169
1300
|
this.now = options.now ?? (() => Date.now());
|
|
1170
|
-
|
|
1301
|
+
const redact = options.redact;
|
|
1302
|
+
this.redact = redact === false ? (text) => text : typeof redact === "function" ? (text) => {
|
|
1303
|
+
try {
|
|
1304
|
+
const out = redact(text);
|
|
1305
|
+
if (typeof out !== "string") throw new TypeError(`redact must return a string, not ${out === null ? "null" : typeof out}`);
|
|
1306
|
+
return out;
|
|
1307
|
+
} catch (e) {
|
|
1308
|
+
this.report(e, "redact");
|
|
1309
|
+
return redactSecrets(text);
|
|
1310
|
+
}
|
|
1311
|
+
} : redactSecrets;
|
|
1171
1312
|
if (options.deliver !== void 0 && options.deliver !== "now" && options.deliver !== "check") {
|
|
1172
1313
|
throw new Error(`deliver must be "now" or "check", not ${JSON.stringify(options.deliver)}`);
|
|
1173
1314
|
}
|
|
@@ -1195,6 +1336,12 @@ var CronWatch = class {
|
|
|
1195
1336
|
definedJobs() {
|
|
1196
1337
|
return [...this.definitions.values()];
|
|
1197
1338
|
}
|
|
1339
|
+
/** A handle on a run started elsewhere, as job(name).resume(runId). The job must be declared in this process. */
|
|
1340
|
+
resumeRun(name, runId) {
|
|
1341
|
+
const definition = this.definitions.get(name);
|
|
1342
|
+
if (!definition) return Promise.reject(new Error(`resumeRun: job "${name}" is not declared; call job() first`));
|
|
1343
|
+
return this.resumeHandle(definition, runId);
|
|
1344
|
+
}
|
|
1198
1345
|
handle(definition) {
|
|
1199
1346
|
const self = this;
|
|
1200
1347
|
return {
|
|
@@ -1233,6 +1380,12 @@ var CronWatch = class {
|
|
|
1233
1380
|
};
|
|
1234
1381
|
return json(body, outcome.run.status === "ok" ? 200 : 500);
|
|
1235
1382
|
};
|
|
1383
|
+
},
|
|
1384
|
+
start(options) {
|
|
1385
|
+
return self.startRun(definition, options ?? {});
|
|
1386
|
+
},
|
|
1387
|
+
resume(runId) {
|
|
1388
|
+
return self.resumeHandle(definition, runId);
|
|
1236
1389
|
}
|
|
1237
1390
|
};
|
|
1238
1391
|
}
|
|
@@ -1241,11 +1394,18 @@ var CronWatch = class {
|
|
|
1241
1394
|
this.warnedNoSecret = true;
|
|
1242
1395
|
this.onError(new Error("handler() refused a request because no CRON_SECRET is set; pass secret: null to allow unauthenticated requests"), "handler");
|
|
1243
1396
|
}
|
|
1397
|
+
/** onError, for places that must carry on even when onError itself throws. */
|
|
1398
|
+
report(error, where) {
|
|
1399
|
+
try {
|
|
1400
|
+
this.onError(error, where);
|
|
1401
|
+
} catch {
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1244
1404
|
async ensureReady() {
|
|
1245
1405
|
if (!this.ready) {
|
|
1246
1406
|
this.ready = (async () => {
|
|
1247
1407
|
if (this.store.init) await this.store.init();
|
|
1248
|
-
if (this.usingDefaultStore &&
|
|
1408
|
+
if (this.usingDefaultStore && readEnv("NODE_ENV") === "production") {
|
|
1249
1409
|
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.");
|
|
1250
1410
|
}
|
|
1251
1411
|
})().catch((error) => {
|
|
@@ -1264,7 +1424,8 @@ var CronWatch = class {
|
|
|
1264
1424
|
/**
|
|
1265
1425
|
* Runs `fn` after every earlier state update for the same job has settled,
|
|
1266
1426
|
* so two runs (or a run and a check) in this process never read and write
|
|
1267
|
-
* the job's state over each other. Other processes are
|
|
1427
|
+
* the job's state over each other. Other processes are coordinated by
|
|
1428
|
+
* updateState() instead.
|
|
1268
1429
|
*/
|
|
1269
1430
|
serial(job, fn) {
|
|
1270
1431
|
const result = (this.queues.get(job) ?? Promise.resolve()).then(fn);
|
|
@@ -1280,6 +1441,38 @@ var CronWatch = class {
|
|
|
1280
1441
|
async readState(job) {
|
|
1281
1442
|
return normalizeState(await this.store.getState(job), job);
|
|
1282
1443
|
}
|
|
1444
|
+
/**
|
|
1445
|
+
* Every read-modify-write of a job's state goes through here. In turn with
|
|
1446
|
+
* this process's other updates to the job (serial()), it reads the state,
|
|
1447
|
+
* asks `change` for the next one, and writes it with the version one
|
|
1448
|
+
* higher, only if the stored version is still the one read. When another
|
|
1449
|
+
* process wrote in between, the write is refused and it starts again from
|
|
1450
|
+
* a fresh read, up to STATE_ATTEMPTS times. So `change` may run more than
|
|
1451
|
+
* once and must only compute: whatever it returns from the attempt that
|
|
1452
|
+
* was written is the result. Nothing is written when the state is
|
|
1453
|
+
* unchanged. Returns the state as stored.
|
|
1454
|
+
*/
|
|
1455
|
+
updateState(job, change) {
|
|
1456
|
+
return this.serial(job, async () => {
|
|
1457
|
+
for (let attempt = 1; ; attempt++) {
|
|
1458
|
+
const current = await this.readState(job);
|
|
1459
|
+
const { state, result } = await change(current);
|
|
1460
|
+
if (sameState(state, current)) return { state: current, result };
|
|
1461
|
+
const version = current.version ?? 0;
|
|
1462
|
+
const next = { ...state, version: version + 1 };
|
|
1463
|
+
if (await this.writeState(next, version)) return { state: next, result };
|
|
1464
|
+
if (attempt >= STATE_ATTEMPTS) {
|
|
1465
|
+
throw new Error(`the state of ${job} changed under ${STATE_ATTEMPTS} attempts in a row to update it; gave up`);
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
/** A conditional write, or for a store without compareAndSetState, a plain one that always succeeds. */
|
|
1471
|
+
async writeState(state, expectedVersion) {
|
|
1472
|
+
if (typeof this.store.compareAndSetState === "function") return this.store.compareAndSetState(state, expectedVersion);
|
|
1473
|
+
await this.store.setState(state);
|
|
1474
|
+
return true;
|
|
1475
|
+
}
|
|
1283
1476
|
/**
|
|
1284
1477
|
* Runs a function as a recorded run. The function always runs, whatever
|
|
1285
1478
|
* the store is doing: store errors go to onError, and the result is the
|
|
@@ -1308,11 +1501,8 @@ var CronWatch = class {
|
|
|
1308
1501
|
} catch (e) {
|
|
1309
1502
|
this.onError(e, `recording ${name}`);
|
|
1310
1503
|
}
|
|
1311
|
-
const started = recorded ? this.
|
|
1312
|
-
|
|
1313
|
-
const after = onRunStart(before);
|
|
1314
|
-
if (!sameState(before, after)) await this.store.setState(after);
|
|
1315
|
-
}).catch((e) => this.onError(e, `starting ${name}`)) : Promise.resolve();
|
|
1504
|
+
const started = recorded ? this.updateState(name, (before) => ({ state: onRunStart(before), result: void 0 })).then(() => {
|
|
1505
|
+
}, (e) => this.onError(e, `starting ${name}`)) : Promise.resolve();
|
|
1316
1506
|
const recorder = createRecorder(run);
|
|
1317
1507
|
const timer = setTimeout(() => recorder.abort(), timeoutMs(definition));
|
|
1318
1508
|
if (typeof timer === "object" && "unref" in timer) timer.unref();
|
|
@@ -1332,6 +1522,21 @@ var CronWatch = class {
|
|
|
1332
1522
|
run.durationMs = Math.max(0, finishedAt - startedAt);
|
|
1333
1523
|
run.metrics = recorder.metrics();
|
|
1334
1524
|
run.output = recorder.output() ?? (typeof result === "string" ? capOutput(result) : null);
|
|
1525
|
+
this.conclude(definition, run, result, error, threw, recorder.expectText() ?? (typeof result === "string" ? result : null));
|
|
1526
|
+
await started;
|
|
1527
|
+
try {
|
|
1528
|
+
const ignored = await this.recordFinish(definition, run, recorded, finishedAt);
|
|
1529
|
+
if (ignored) this.report(new Error(`run ${run.id} of ${name} ${ignored}; ignored`), `finishing ${name}`);
|
|
1530
|
+
} catch (e) {
|
|
1531
|
+
this.onError(e, `recording ${name}`);
|
|
1532
|
+
}
|
|
1533
|
+
return { run, result, error, threw };
|
|
1534
|
+
}
|
|
1535
|
+
/**
|
|
1536
|
+
* Sets a finished run's status and error from how it ended, then redacts
|
|
1537
|
+
* its output and error. Shared by execute() and RunHandle.finish().
|
|
1538
|
+
*/
|
|
1539
|
+
conclude(definition, run, result, error, threw, expectText) {
|
|
1335
1540
|
if (threw) {
|
|
1336
1541
|
run.status = "failed";
|
|
1337
1542
|
run.error = errorMessage(error);
|
|
@@ -1339,7 +1544,6 @@ var CronWatch = class {
|
|
|
1339
1544
|
run.status = "failed";
|
|
1340
1545
|
run.error = `HTTP ${result.status}${result.statusText ? ` ${result.statusText}` : ""}`;
|
|
1341
1546
|
} else {
|
|
1342
|
-
const expectText = recorder.expectText() ?? (typeof result === "string" ? result : null);
|
|
1343
1547
|
const unmet = checkExpectation(definition.expect, expectText);
|
|
1344
1548
|
if (unmet) {
|
|
1345
1549
|
run.status = "failed";
|
|
@@ -1348,51 +1552,344 @@ var CronWatch = class {
|
|
|
1348
1552
|
run.status = "ok";
|
|
1349
1553
|
}
|
|
1350
1554
|
}
|
|
1351
|
-
if (run.output !== null) run.output = this.redact(run.output);
|
|
1352
|
-
if (run.error !== null) run.error = this.redact(run.error);
|
|
1353
|
-
|
|
1555
|
+
if (run.output !== null) run.output = stripNul(this.redact(run.output));
|
|
1556
|
+
if (run.error !== null) run.error = stripNul(this.redact(run.error));
|
|
1557
|
+
}
|
|
1558
|
+
/**
|
|
1559
|
+
* Writes a finished run and evaluates it. `recorded` says whether its
|
|
1560
|
+
* start was written; if not, it is inserted now. Returns why nothing was
|
|
1561
|
+
* recorded (another process finished the run first, say), or null. Throws
|
|
1562
|
+
* when the store does, so a handle can be finished again. Shared by
|
|
1563
|
+
* execute() and RunHandle.finish().
|
|
1564
|
+
*/
|
|
1565
|
+
async recordFinish(definition, run, recorded, finishedAt) {
|
|
1354
1566
|
if (!recorded) {
|
|
1567
|
+
await this.sync(definition);
|
|
1355
1568
|
try {
|
|
1356
|
-
await this.sync(definition);
|
|
1357
1569
|
await this.store.insertRun(run);
|
|
1358
|
-
|
|
1570
|
+
await this.finishRun(toStored(definition), run, finishedAt);
|
|
1571
|
+
return null;
|
|
1359
1572
|
} catch (e) {
|
|
1360
|
-
this.
|
|
1573
|
+
const stored = await this.store.getRun(run.id).catch(() => null);
|
|
1574
|
+
if (!stored) throw e;
|
|
1575
|
+
if (stored.job !== run.job) return `belongs to job "${stored.job}"`;
|
|
1361
1576
|
}
|
|
1362
|
-
|
|
1363
|
-
|
|
1577
|
+
}
|
|
1578
|
+
const claim = await this.claimFinish(run);
|
|
1579
|
+
if ("ignored" in claim) return claim.ignored;
|
|
1580
|
+
if (!claim.lateAfterTimeout || run.status === "ok") await this.finishRun(toStored(definition), run, finishedAt);
|
|
1581
|
+
return null;
|
|
1582
|
+
}
|
|
1583
|
+
/** A conditional write (Store.updateRunIf), or for a store without one, a read then a plain write. */
|
|
1584
|
+
async writeRunIf(run, fromStatuses) {
|
|
1585
|
+
if (typeof this.store.updateRunIf === "function") return this.store.updateRunIf(run, fromStatuses);
|
|
1586
|
+
const stored = await this.store.getRun(run.id);
|
|
1587
|
+
if (!stored || !fromStatuses.includes(stored.status)) return false;
|
|
1588
|
+
await this.store.updateRun(run);
|
|
1589
|
+
return true;
|
|
1590
|
+
}
|
|
1591
|
+
/**
|
|
1592
|
+
* Writes a finished run over its stored row, only while that row is still
|
|
1593
|
+
* running, or else still marked timeout by a check. Only the process whose
|
|
1594
|
+
* write lands goes on to evaluate the run; for the others it returns why
|
|
1595
|
+
* nothing was written. `lateAfterTimeout` means a check already counted
|
|
1596
|
+
* the run as a stuck failure: a late failure must not count twice, while
|
|
1597
|
+
* a late success still closes stuck and recovers. Throws when the store does.
|
|
1598
|
+
*/
|
|
1599
|
+
async claimFinish(run) {
|
|
1600
|
+
if (await this.writeRunIf(run, ["running"])) return { lateAfterTimeout: false };
|
|
1601
|
+
if (await this.writeRunIf(run, ["timeout"])) return { lateAfterTimeout: true };
|
|
1602
|
+
const stored = await this.store.getRun(run.id);
|
|
1603
|
+
return { ignored: stored ? `was already finished as ${stored.status}` : "was not found" };
|
|
1604
|
+
}
|
|
1605
|
+
/** job.start(): records a running run and returns a handle to finish it. See JobHandle.start. */
|
|
1606
|
+
async startRun(definition, options) {
|
|
1607
|
+
const id = options.id;
|
|
1608
|
+
if (id === void 0) return this.recordStart(definition, options.trigger);
|
|
1609
|
+
checkRunId(definition.name, id, "start");
|
|
1610
|
+
const key = `${definition.name}
|
|
1611
|
+
${id}`;
|
|
1612
|
+
const inFlight = this.starting.get(key);
|
|
1613
|
+
if (inFlight) return inFlight;
|
|
1614
|
+
const started = this.recordStart(definition, options.trigger, id).finally(() => {
|
|
1615
|
+
if (this.starting.get(key) === started) this.starting.delete(key);
|
|
1616
|
+
});
|
|
1617
|
+
this.starting.set(key, started);
|
|
1618
|
+
return started;
|
|
1619
|
+
}
|
|
1620
|
+
/**
|
|
1621
|
+
* The start of execute() without the function: the run is inserted and
|
|
1622
|
+
* missed and stuck close (onRunStart). A store that fails is reported and
|
|
1623
|
+
* the handle inserts the finished run instead, as execute() does.
|
|
1624
|
+
*/
|
|
1625
|
+
async recordStart(definition, trigger = "start", id) {
|
|
1626
|
+
const name = definition.name;
|
|
1627
|
+
if (id !== void 0) {
|
|
1628
|
+
let stored = null;
|
|
1364
1629
|
try {
|
|
1365
|
-
await this.
|
|
1630
|
+
await this.ensureReady();
|
|
1631
|
+
stored = await this.store.getRun(id);
|
|
1366
1632
|
} catch (e) {
|
|
1367
1633
|
this.onError(e, `recording ${name}`);
|
|
1368
1634
|
}
|
|
1369
|
-
|
|
1370
|
-
await this.finishRun(toStored(definition), run, finishedAt, true);
|
|
1635
|
+
if (stored) return this.existingHandle(definition, stored);
|
|
1371
1636
|
}
|
|
1372
|
-
|
|
1637
|
+
const run = {
|
|
1638
|
+
id: id ?? crypto.randomUUID(),
|
|
1639
|
+
job: name,
|
|
1640
|
+
status: "running",
|
|
1641
|
+
startedAt: this.now(),
|
|
1642
|
+
finishedAt: null,
|
|
1643
|
+
durationMs: null,
|
|
1644
|
+
error: null,
|
|
1645
|
+
output: null,
|
|
1646
|
+
metrics: {},
|
|
1647
|
+
trigger
|
|
1648
|
+
};
|
|
1649
|
+
let recorded = false;
|
|
1650
|
+
try {
|
|
1651
|
+
await this.sync(definition);
|
|
1652
|
+
await this.store.insertRun({ ...run });
|
|
1653
|
+
recorded = true;
|
|
1654
|
+
} catch (e) {
|
|
1655
|
+
const stored = id === void 0 ? null : await this.store.getRun(id).catch(() => null);
|
|
1656
|
+
if (stored) return this.existingHandle(definition, stored);
|
|
1657
|
+
this.onError(e, `recording ${name}`);
|
|
1658
|
+
}
|
|
1659
|
+
if (recorded) {
|
|
1660
|
+
await this.updateState(name, (before) => ({ state: onRunStart(before), result: void 0 })).catch((e) => this.onError(e, `starting ${name}`));
|
|
1661
|
+
}
|
|
1662
|
+
return this.runHandle(definition, run.id, run, recorded, null);
|
|
1373
1663
|
}
|
|
1374
|
-
/**
|
|
1375
|
-
async
|
|
1376
|
-
|
|
1664
|
+
/** job.resume() and cw.resumeRun(). A store that cannot be read is reported, and finish() reads it again. */
|
|
1665
|
+
async resumeHandle(definition, runId) {
|
|
1666
|
+
checkRunId(definition.name, runId, "resume");
|
|
1667
|
+
let stored;
|
|
1377
1668
|
try {
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1669
|
+
await this.ensureReady();
|
|
1670
|
+
stored = await this.store.getRun(runId);
|
|
1671
|
+
} catch (e) {
|
|
1672
|
+
this.onError(e, `resuming ${definition.name}`);
|
|
1673
|
+
return this.runHandle(definition, runId, null, true, null);
|
|
1674
|
+
}
|
|
1675
|
+
if (!stored) return this.runHandle(definition, runId, null, true, "was not found");
|
|
1676
|
+
return this.existingHandle(definition, stored);
|
|
1677
|
+
}
|
|
1678
|
+
/** A handle on a stored run. One still running, or marked timeout by a check, can be finished. */
|
|
1679
|
+
existingHandle(definition, stored) {
|
|
1680
|
+
if (stored.job !== definition.name) {
|
|
1681
|
+
throw new Error(`run "${stored.id}" belongs to job "${stored.job}", not "${definition.name}"`);
|
|
1381
1682
|
}
|
|
1683
|
+
const finished = stored.status === "ok" || stored.status === "failed";
|
|
1684
|
+
return this.runHandle(definition, stored.id, stored, true, finished ? `already finished as ${stored.status}` : null);
|
|
1685
|
+
}
|
|
1686
|
+
/**
|
|
1687
|
+
* The handle itself. `base` is the run as last known here, `recorded`
|
|
1688
|
+
* whether its start is in the store, and `inactive` why finish() has
|
|
1689
|
+
* nothing to do, or null. Lines and metrics wait in the handle until
|
|
1690
|
+
* flush() or finish() merges them onto a fresh read of the stored run.
|
|
1691
|
+
*/
|
|
1692
|
+
runHandle(definition, id, base, recorded, inactive) {
|
|
1693
|
+
const self = this;
|
|
1694
|
+
const name = definition.name;
|
|
1695
|
+
const fresh = () => createRecorder({ id, job: name, startedAt: base?.startedAt ?? 0 });
|
|
1696
|
+
let recorder = fresh();
|
|
1697
|
+
let finished = inactive !== null;
|
|
1698
|
+
let finishCalled = false;
|
|
1699
|
+
let head = null;
|
|
1700
|
+
let queue = Promise.resolve();
|
|
1701
|
+
const inTurn = (fn) => {
|
|
1702
|
+
const result = queue.then(fn);
|
|
1703
|
+
queue = result.catch(() => {
|
|
1704
|
+
});
|
|
1705
|
+
return result;
|
|
1706
|
+
};
|
|
1707
|
+
const ignored = (why) => self.report(new Error(`run ${id} of ${name} ${why}; ignored`), `finishing ${name}`);
|
|
1708
|
+
const finish = (outcome) => {
|
|
1709
|
+
if (finishCalled) {
|
|
1710
|
+
ignored("was already finished by this handle");
|
|
1711
|
+
return Promise.resolve(null);
|
|
1712
|
+
}
|
|
1713
|
+
finishCalled = true;
|
|
1714
|
+
const wasInactive = finished;
|
|
1715
|
+
finished = true;
|
|
1716
|
+
const retryable = (error) => {
|
|
1717
|
+
finishCalled = false;
|
|
1718
|
+
finished = false;
|
|
1719
|
+
self.report(error, `finishing ${name}`);
|
|
1720
|
+
return null;
|
|
1721
|
+
};
|
|
1722
|
+
return inTurn(async () => {
|
|
1723
|
+
if (wasInactive) {
|
|
1724
|
+
ignored(inactive);
|
|
1725
|
+
return null;
|
|
1726
|
+
}
|
|
1727
|
+
let from = base;
|
|
1728
|
+
if (recorded) {
|
|
1729
|
+
try {
|
|
1730
|
+
from = await self.store.getRun(id) ?? base;
|
|
1731
|
+
} catch (e) {
|
|
1732
|
+
return retryable(e);
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
if (!from) {
|
|
1736
|
+
ignored("was not found");
|
|
1737
|
+
return null;
|
|
1738
|
+
}
|
|
1739
|
+
if (from.job !== name) {
|
|
1740
|
+
ignored(`belongs to job "${from.job}"`);
|
|
1741
|
+
return null;
|
|
1742
|
+
}
|
|
1743
|
+
if (from.status === "ok" || from.status === "failed") {
|
|
1744
|
+
ignored(`was already finished as ${from.status}`);
|
|
1745
|
+
return null;
|
|
1746
|
+
}
|
|
1747
|
+
const failed = typeof outcome === "object" && outcome !== null && "error" in outcome;
|
|
1748
|
+
const result = typeof outcome === "string" ? outcome : failed ? void 0 : outcome?.result;
|
|
1749
|
+
const error = failed ? outcome.error : void 0;
|
|
1750
|
+
const finishedAt = self.now();
|
|
1751
|
+
const added = recorder.output() ?? (typeof result === "string" ? capOutput(result) : null);
|
|
1752
|
+
const run = {
|
|
1753
|
+
...from,
|
|
1754
|
+
status: "running",
|
|
1755
|
+
finishedAt,
|
|
1756
|
+
durationMs: Math.max(0, finishedAt - from.startedAt),
|
|
1757
|
+
error: null,
|
|
1758
|
+
output: joinOutput(from.output, added),
|
|
1759
|
+
metrics: { ...from.metrics, ...recorder.metrics() }
|
|
1760
|
+
};
|
|
1761
|
+
const expectText = joinLines(head, joinLines(from.output, recorder.expectText() ?? (typeof result === "string" ? result : null)));
|
|
1762
|
+
self.conclude(definition, run, result, error, failed, expectText);
|
|
1763
|
+
let why;
|
|
1764
|
+
try {
|
|
1765
|
+
why = await self.recordFinish(definition, run, recorded, finishedAt);
|
|
1766
|
+
} catch (e) {
|
|
1767
|
+
return retryable(e);
|
|
1768
|
+
}
|
|
1769
|
+
if (why) {
|
|
1770
|
+
ignored(why);
|
|
1771
|
+
return null;
|
|
1772
|
+
}
|
|
1773
|
+
return run;
|
|
1774
|
+
});
|
|
1775
|
+
};
|
|
1776
|
+
return {
|
|
1777
|
+
id,
|
|
1778
|
+
job: name,
|
|
1779
|
+
startedAt: base?.startedAt ?? null,
|
|
1780
|
+
get active() {
|
|
1781
|
+
return !finished;
|
|
1782
|
+
},
|
|
1783
|
+
log: (...parts) => recorder.context.log(...parts),
|
|
1784
|
+
metric: (metric, value) => recorder.context.metric(metric, value),
|
|
1785
|
+
metrics: (values) => recorder.context.metrics(values),
|
|
1786
|
+
flush: () => inTurn(async () => {
|
|
1787
|
+
if (finished || !recorded) return;
|
|
1788
|
+
const lines = recorder.output();
|
|
1789
|
+
const metrics = recorder.metrics();
|
|
1790
|
+
if (lines === null && Object.keys(metrics).length === 0) return;
|
|
1791
|
+
const taken = recorder;
|
|
1792
|
+
recorder = fresh();
|
|
1793
|
+
const putBack = () => {
|
|
1794
|
+
const later = recorder;
|
|
1795
|
+
recorder = fresh();
|
|
1796
|
+
for (const text of [taken.expectText(), later.expectText()]) if (text !== null) recorder.context.log(text);
|
|
1797
|
+
recorder.context.metrics({ ...taken.metrics(), ...later.metrics() });
|
|
1798
|
+
};
|
|
1799
|
+
try {
|
|
1800
|
+
const stored = await self.store.getRun(id);
|
|
1801
|
+
if (!stored || stored.status !== "running") return putBack();
|
|
1802
|
+
if (stored.job !== name) {
|
|
1803
|
+
putBack();
|
|
1804
|
+
self.report(new Error(`run ${id} of ${name} belongs to job "${stored.job}"; ignored`), `flushing ${name}`);
|
|
1805
|
+
return;
|
|
1806
|
+
}
|
|
1807
|
+
const output = lines === null ? stored.output : joinOutput(stored.output, stripNul(self.redact(lines)));
|
|
1808
|
+
if (!await self.writeRunIf({ ...stored, output, metrics: { ...stored.metrics, ...metrics } }, ["running"])) return putBack();
|
|
1809
|
+
const text = taken.expectText();
|
|
1810
|
+
if (text !== null && (head === null || head.length < OUTPUT_CAP)) head = (joinLines(head, text) ?? "").slice(0, OUTPUT_CAP);
|
|
1811
|
+
} catch (e) {
|
|
1812
|
+
putBack();
|
|
1813
|
+
self.report(e, `flushing ${name}`);
|
|
1814
|
+
}
|
|
1815
|
+
}),
|
|
1816
|
+
finish,
|
|
1817
|
+
fail: (error) => finish({ error })
|
|
1818
|
+
};
|
|
1382
1819
|
}
|
|
1383
1820
|
/**
|
|
1384
|
-
* Record a
|
|
1385
|
-
*
|
|
1821
|
+
* Record a run that happened outside this process, for a Source. Its job
|
|
1822
|
+
* must be declared with job() first. Runs are keyed by id: a new one is
|
|
1823
|
+
* inserted, a stored one still running (or marked timeout by a check) is
|
|
1824
|
+
* finished when this one is not running, and anything else is left alone,
|
|
1825
|
+
* so recording the same run twice changes nothing. Finishing is
|
|
1826
|
+
* conditional (see Store.updateRunIf): when two processes record the same
|
|
1827
|
+
* finish, only the one whose write lands evaluates it, and the other
|
|
1828
|
+
* reports it as already finished. A stored run of another job is left
|
|
1829
|
+
* alone and reported. A finished run is judged as if it had been wrapped
|
|
1830
|
+
* here (expect, failures, duration, budgets) and its output and error are
|
|
1831
|
+
* redacted the same way; one finishing after a check marked it timeout is
|
|
1832
|
+
* judged only when it succeeded, as RunHandle.finish() does. Returns the
|
|
1833
|
+
* alerts it sent.
|
|
1386
1834
|
*/
|
|
1387
|
-
async
|
|
1835
|
+
async recordRun(input, options = {}) {
|
|
1836
|
+
const declared = this.definitions.get(input.job);
|
|
1837
|
+
if (!declared) throw new Error(`recordRun: job "${input.job}" is not declared; call job() first`);
|
|
1838
|
+
await this.sync(declared);
|
|
1839
|
+
const run = { ...input, metrics: { ...input.metrics } };
|
|
1840
|
+
if (run.status === "ok") {
|
|
1841
|
+
const unmet = checkExpectation(declared.expect, run.output);
|
|
1842
|
+
if (unmet) {
|
|
1843
|
+
run.status = "failed";
|
|
1844
|
+
run.error = unmet;
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
if (run.output !== null) run.output = stripNul(this.redact(capOutput(run.output)));
|
|
1848
|
+
if (run.error !== null) run.error = stripNul(this.redact(capOutput(run.error)));
|
|
1849
|
+
const evaluate = options.evaluate !== false;
|
|
1850
|
+
const definition = toStored(declared);
|
|
1851
|
+
const stored = await this.store.getRun(run.id);
|
|
1852
|
+
if (stored) return this.recordOver(definition, stored, run, evaluate);
|
|
1853
|
+
try {
|
|
1854
|
+
await this.store.insertRun(run);
|
|
1855
|
+
} catch (e) {
|
|
1856
|
+
const again = await this.store.getRun(run.id).catch(() => null);
|
|
1857
|
+
if (again) return this.recordOver(definition, again, run, evaluate);
|
|
1858
|
+
throw e;
|
|
1859
|
+
}
|
|
1860
|
+
if (!evaluate) return [];
|
|
1861
|
+
await this.updateState(run.job, (before) => ({ state: onRunStart(before), result: void 0 }));
|
|
1862
|
+
if (run.status === "running") return [];
|
|
1863
|
+
return this.finishRun(definition, run, this.now());
|
|
1864
|
+
}
|
|
1865
|
+
/** recordRun() for a run already stored. */
|
|
1866
|
+
async recordOver(definition, stored, run, evaluate) {
|
|
1867
|
+
if (stored.job !== run.job) {
|
|
1868
|
+
this.report(new Error(`run ${run.id} of ${run.job} belongs to job "${stored.job}"; ignored`), `recording ${run.job}`);
|
|
1869
|
+
return [];
|
|
1870
|
+
}
|
|
1871
|
+
if (stored.status !== "running" && stored.status !== "timeout" || run.status === "running") return [];
|
|
1872
|
+
const claim = await this.claimFinish(run);
|
|
1873
|
+
if ("ignored" in claim) {
|
|
1874
|
+
this.report(new Error(`run ${run.id} of ${run.job} ${claim.ignored}; ignored`), `recording ${run.job}`);
|
|
1875
|
+
return [];
|
|
1876
|
+
}
|
|
1877
|
+
if (!evaluate || claim.lateAfterTimeout && run.status !== "ok") return [];
|
|
1878
|
+
return this.finishRun(definition, run, this.now());
|
|
1879
|
+
}
|
|
1880
|
+
/**
|
|
1881
|
+
* Evaluate a finished run (ok, failed, or timed out by a check), already
|
|
1882
|
+
* written, against the job's state and send what that produces. Never throws.
|
|
1883
|
+
*/
|
|
1884
|
+
async finishRun(definition, run, now) {
|
|
1388
1885
|
let drafts;
|
|
1389
1886
|
try {
|
|
1390
|
-
|
|
1391
|
-
drafts = await this.
|
|
1392
|
-
|
|
1393
|
-
const
|
|
1394
|
-
return
|
|
1395
|
-
});
|
|
1887
|
+
let history = null;
|
|
1888
|
+
({ result: drafts } = await this.updateState(run.job, async (previous) => {
|
|
1889
|
+
history ??= await this.history(run);
|
|
1890
|
+
const settled = applySilence(previous, onRunFinish(definition, run, previous, history, now), now);
|
|
1891
|
+
return { state: settled.state, result: settled.alerts };
|
|
1892
|
+
}));
|
|
1396
1893
|
} catch (e) {
|
|
1397
1894
|
this.onError(e, `evaluating ${run.job}`);
|
|
1398
1895
|
return [];
|
|
@@ -1427,30 +1924,51 @@ var CronWatch = class {
|
|
|
1427
1924
|
}
|
|
1428
1925
|
async runCheck() {
|
|
1429
1926
|
await this.ensureReady();
|
|
1927
|
+
const alerts = [];
|
|
1928
|
+
for (const source of this.sources) {
|
|
1929
|
+
try {
|
|
1930
|
+
alerts.push(...await source.sync(this) ?? []);
|
|
1931
|
+
} catch (e) {
|
|
1932
|
+
this.onError(e, `source ${source.name}`);
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1430
1935
|
for (const definition of this.definitions.values()) await this.sync(definition);
|
|
1431
1936
|
const now = this.now();
|
|
1432
|
-
const alerts = [];
|
|
1433
1937
|
for (const run of await this.store.runningRuns()) {
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1938
|
+
try {
|
|
1939
|
+
const declared = this.definitions.get(run.job);
|
|
1940
|
+
const definition = declared ? toStored(declared) : (await this.store.getJob(run.job))?.definition;
|
|
1941
|
+
if (!definition || !isStuck(definition, run, now)) continue;
|
|
1942
|
+
const timeout = timeoutMs(definition);
|
|
1943
|
+
run.status = "timeout";
|
|
1944
|
+
run.finishedAt = now;
|
|
1945
|
+
run.durationMs = now - run.startedAt;
|
|
1946
|
+
run.error = `Still running after ${formatDuration(timeout)}; marked as timed out`;
|
|
1947
|
+
if (!await this.writeRunIf(run, ["running"])) continue;
|
|
1948
|
+
alerts.push(...await this.finishRun(definition, run, now));
|
|
1949
|
+
} catch (e) {
|
|
1950
|
+
this.onError(e, `checking ${run.job}`);
|
|
1951
|
+
}
|
|
1442
1952
|
}
|
|
1443
1953
|
const jobs = [];
|
|
1954
|
+
const retries = { spentMs: 0 };
|
|
1444
1955
|
for (const stored of await this.store.listJobs()) {
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
const
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1956
|
+
try {
|
|
1957
|
+
const recent = await this.store.listRuns(stored.name, BASELINE_WINDOW);
|
|
1958
|
+
let nextExpectedAt = null;
|
|
1959
|
+
const { state, result: drafts } = await this.updateState(stored.name, (previous) => {
|
|
1960
|
+
const evaluation = onCheck(stored.definition, stored, recent[0] ?? null, previous, now);
|
|
1961
|
+
nextExpectedAt = evaluation.nextExpectedAt;
|
|
1962
|
+
const settled = applySilence(previous, evaluation, now);
|
|
1963
|
+
return { state: settled.state, result: settled.alerts };
|
|
1964
|
+
});
|
|
1965
|
+
alerts.push(...await this.retryUndelivered(stored.name, state, now, retries));
|
|
1966
|
+
alerts.push(...await this.dispatch(drafts, stored.definition, now));
|
|
1967
|
+
jobs.push(summarize(stored, recent, state, nextExpectedAt, now));
|
|
1968
|
+
} catch (e) {
|
|
1969
|
+
this.onError(e, `checking ${stored.name}`);
|
|
1970
|
+
jobs.push(await this.unevaluable(stored, now));
|
|
1971
|
+
}
|
|
1454
1972
|
}
|
|
1455
1973
|
let pruned = 0;
|
|
1456
1974
|
if (now - this.lastPruneAt > PRUNE_INTERVAL_MS) {
|
|
@@ -1463,12 +1981,24 @@ var CronWatch = class {
|
|
|
1463
1981
|
}
|
|
1464
1982
|
return { checkedAt: now, jobs, alerts, pruned };
|
|
1465
1983
|
}
|
|
1466
|
-
/** A job's summary and its newest runs, without alerting. */
|
|
1984
|
+
/** A job's summary and its newest runs, without alerting. A job that cannot be evaluated is reported and shown as failing. */
|
|
1467
1985
|
async snapshot(stored, now, runs) {
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1986
|
+
let recent = [];
|
|
1987
|
+
try {
|
|
1988
|
+
recent = await this.store.listRuns(stored.name, Math.max(runs, BASELINE_WINDOW));
|
|
1989
|
+
const state = await this.readState(stored.name);
|
|
1990
|
+
const { nextExpectedAt } = onCheck(stored.definition, stored, recent[0] ?? null, state, now);
|
|
1991
|
+
return { job: summarize(stored, recent, state, nextExpectedAt, now), runs: recent.slice(0, runs) };
|
|
1992
|
+
} catch (e) {
|
|
1993
|
+
this.onError(e, `reading ${stored.name}`);
|
|
1994
|
+
return { job: await this.unevaluable(stored, now), runs: recent.slice(0, runs) };
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
/** The summary of a job whose evaluation failed, from whatever can still be read. */
|
|
1998
|
+
async unevaluable(stored, now) {
|
|
1999
|
+
const recent = await this.store.listRuns(stored.name, BASELINE_WINDOW).catch(() => []);
|
|
2000
|
+
const state = await this.readState(stored.name).catch(() => emptyState(stored.name));
|
|
2001
|
+
return unevaluableSummary(stored, recent, state, now);
|
|
1472
2002
|
}
|
|
1473
2003
|
/** Every job the store knows about, with its health. Does not send alerts. */
|
|
1474
2004
|
async jobs() {
|
|
@@ -1515,12 +2045,12 @@ var CronWatch = class {
|
|
|
1515
2045
|
/** Read, change and write one job's state, in turn with every other update to it. */
|
|
1516
2046
|
async patchState(name, change) {
|
|
1517
2047
|
await this.ensureReady();
|
|
1518
|
-
|
|
1519
|
-
const
|
|
1520
|
-
change(
|
|
1521
|
-
|
|
1522
|
-
return state;
|
|
2048
|
+
const { state } = await this.updateState(name, (current) => {
|
|
2049
|
+
const next = normalizeState(current, name);
|
|
2050
|
+
change(next);
|
|
2051
|
+
return { state: next, result: void 0 };
|
|
1523
2052
|
});
|
|
2053
|
+
return state;
|
|
1524
2054
|
}
|
|
1525
2055
|
/** Remove a job and its runs from the store. A job still declared in code comes back on its next run. */
|
|
1526
2056
|
async forget(name) {
|
|
@@ -1533,10 +2063,18 @@ var CronWatch = class {
|
|
|
1533
2063
|
routes(options) {
|
|
1534
2064
|
return createRoutes(this, options);
|
|
1535
2065
|
}
|
|
1536
|
-
/**
|
|
2066
|
+
/**
|
|
2067
|
+
* Check on an interval, for long-running servers. Default every minute.
|
|
2068
|
+
* Not for serverless functions or Cloudflare Workers, where nothing runs
|
|
2069
|
+
* between requests: call check() from a cron there instead.
|
|
2070
|
+
*/
|
|
1537
2071
|
start(every = "1m") {
|
|
1538
2072
|
if (this.timer) return;
|
|
1539
2073
|
const ms = Math.max(5e3, parseDuration(every, "check interval"));
|
|
2074
|
+
if (this.deferDelivery && !this.warnedDeferredStart) {
|
|
2075
|
+
this.warnedDeferredStart = true;
|
|
2076
|
+
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.');
|
|
2077
|
+
}
|
|
1540
2078
|
const tick = () => this.check().catch((e) => this.onError(e, "check"));
|
|
1541
2079
|
this.timer = setInterval(tick, ms);
|
|
1542
2080
|
if (typeof this.timer === "object" && "unref" in this.timer) this.timer.unref();
|
|
@@ -1556,20 +2094,10 @@ var CronWatch = class {
|
|
|
1556
2094
|
this.stop();
|
|
1557
2095
|
if (this.store.close) await this.store.close();
|
|
1558
2096
|
}
|
|
1559
|
-
/** Save an evaluation's state, honouring silence, and return what should be sent. Call inside serial(). */
|
|
1560
|
-
async settle(previous, evaluation, now) {
|
|
1561
|
-
let { state, alerts } = evaluation;
|
|
1562
|
-
if (isSilenced(previous, now)) {
|
|
1563
|
-
state = muteOpens(previous, state);
|
|
1564
|
-
alerts = [];
|
|
1565
|
-
}
|
|
1566
|
-
if (!sameState(state, previous)) await this.store.setState(state);
|
|
1567
|
-
return { state, alerts };
|
|
1568
|
-
}
|
|
1569
2097
|
/**
|
|
1570
2098
|
* Compose, triage and send each draft. The state was saved before this
|
|
1571
|
-
* (
|
|
1572
|
-
* delivery fields are written back, onto a fresh read of the state.
|
|
2099
|
+
* (updateState), so a slow channel holds up nothing else; afterwards only
|
|
2100
|
+
* the delivery fields are written back, onto a fresh read of the state.
|
|
1573
2101
|
*/
|
|
1574
2102
|
async dispatch(drafts, definition, now) {
|
|
1575
2103
|
if (drafts.length === 0) return [];
|
|
@@ -1581,41 +2109,63 @@ var CronWatch = class {
|
|
|
1581
2109
|
if (this.deferDelivery) {
|
|
1582
2110
|
failed.push(alert);
|
|
1583
2111
|
} else {
|
|
1584
|
-
if (this.triage && alert.type !== "recovered") await this.addTriage(alert);
|
|
2112
|
+
if (this.triage && alert.type !== "recovered") await this.addTriage(alert, TRIAGE_TIMEOUT_MS);
|
|
1585
2113
|
(await this.deliver(alert) ? delivered : failed).push(alert);
|
|
1586
2114
|
}
|
|
1587
2115
|
composed.push(alert);
|
|
1588
2116
|
}
|
|
1589
|
-
await this.recordDelivery(definition.name, delivered, failed, now);
|
|
2117
|
+
await this.recordDelivery(definition.name, delivered, failed, [], now);
|
|
1590
2118
|
return composed;
|
|
1591
2119
|
}
|
|
1592
|
-
/**
|
|
1593
|
-
|
|
2120
|
+
/**
|
|
2121
|
+
* Send the alerts that no channel accepted last time, once each, oldest
|
|
2122
|
+
* first. `state` is the job's state as this check left it: an alert that
|
|
2123
|
+
* no longer describes it (staleAlert) is dropped instead. Retries across a
|
|
2124
|
+
* check share RETRY_BUDGET_MS of wall-clock time; once it is spent the rest
|
|
2125
|
+
* stay queued for the next check.
|
|
2126
|
+
*/
|
|
2127
|
+
async retryUndelivered(name, state, now, budget) {
|
|
1594
2128
|
const pending = state.undelivered ?? [];
|
|
1595
2129
|
if (pending.length === 0 || isSilenced(state, now) || this.deferDelivery) return [];
|
|
1596
2130
|
const delivered = [];
|
|
1597
2131
|
const failed = [];
|
|
2132
|
+
const dropped = pending.filter((alert) => staleAlert(alert, state));
|
|
1598
2133
|
for (const alert of pending) {
|
|
1599
|
-
if (
|
|
2134
|
+
if (dropped.includes(alert)) continue;
|
|
2135
|
+
const left = RETRY_BUDGET_MS - budget.spentMs;
|
|
2136
|
+
if (left <= 0) break;
|
|
2137
|
+
const started = Date.now();
|
|
2138
|
+
if (this.triage && alert.type !== "recovered" && alert.triage === void 0) await this.addTriage(alert, Math.min(TRIAGE_TIMEOUT_MS, left));
|
|
1600
2139
|
(await this.deliver(alert) ? delivered : failed).push(alert);
|
|
2140
|
+
budget.spentMs += Math.max(0, Date.now() - started);
|
|
1601
2141
|
}
|
|
1602
|
-
await this.recordDelivery(name, delivered, failed, now);
|
|
2142
|
+
await this.recordDelivery(name, delivered, failed, dropped, now);
|
|
1603
2143
|
return delivered;
|
|
1604
2144
|
}
|
|
1605
|
-
/**
|
|
1606
|
-
|
|
2145
|
+
/**
|
|
2146
|
+
* Mark delivered alerts done, drop stale ones, and keep failed ones for the
|
|
2147
|
+
* next check. A failed alert replaces its stored copy, so a triage made on
|
|
2148
|
+
* this attempt is kept. lastAlertAt moves only on a delivery.
|
|
2149
|
+
*/
|
|
2150
|
+
async recordDelivery(name, delivered, failed, dropped, now) {
|
|
1607
2151
|
try {
|
|
1608
|
-
await this.
|
|
1609
|
-
const previous = await this.readState(name);
|
|
2152
|
+
const { result: trimmed } = await this.updateState(name, (previous) => {
|
|
1610
2153
|
const state = normalizeState(previous, name);
|
|
1611
|
-
const done = new Set(delivered.map(alertKey));
|
|
1612
|
-
const
|
|
2154
|
+
const done = new Set([...delivered, ...dropped].map(alertKey));
|
|
2155
|
+
const retried = new Map(failed.map((a) => [alertKey(a), a]));
|
|
2156
|
+
const kept = state.undelivered.filter((a) => !done.has(alertKey(a))).map((a) => retried.get(alertKey(a)) ?? a);
|
|
1613
2157
|
const known = new Set(kept.map(alertKey));
|
|
1614
2158
|
kept.push(...failed.filter((a) => !known.has(alertKey(a))));
|
|
1615
2159
|
state.undelivered = kept.slice(-MAX_UNDELIVERED);
|
|
1616
2160
|
if (delivered.length > 0) state.lastAlertAt = now;
|
|
1617
|
-
|
|
2161
|
+
return { state, result: Math.max(0, kept.length - MAX_UNDELIVERED) };
|
|
1618
2162
|
});
|
|
2163
|
+
if (trimmed > 0) {
|
|
2164
|
+
this.onError(
|
|
2165
|
+
new Error(`${trimmed} undelivered alert${trimmed === 1 ? "" : "s"} for ${name} dropped: only the newest ${MAX_UNDELIVERED} are kept for retry`),
|
|
2166
|
+
`alert queue for ${name}`
|
|
2167
|
+
);
|
|
2168
|
+
}
|
|
1619
2169
|
} catch (e) {
|
|
1620
2170
|
this.onError(e, `recording alert delivery for ${name}`);
|
|
1621
2171
|
}
|
|
@@ -1626,7 +2176,7 @@ var CronWatch = class {
|
|
|
1626
2176
|
const results = await Promise.all(
|
|
1627
2177
|
this.alerts.map(async (channel) => {
|
|
1628
2178
|
try {
|
|
1629
|
-
await withTimeout(channel.send(alert), CHANNEL_TIMEOUT_MS);
|
|
2179
|
+
await withTimeout(channel.send(alert, { onError: (e) => this.report(e, `alert channel ${channel.name}`) }), CHANNEL_TIMEOUT_MS);
|
|
1630
2180
|
return true;
|
|
1631
2181
|
} catch (e) {
|
|
1632
2182
|
this.onError(e, `alert channel ${channel.name}`);
|
|
@@ -1636,20 +2186,38 @@ var CronWatch = class {
|
|
|
1636
2186
|
);
|
|
1637
2187
|
return results.includes(true);
|
|
1638
2188
|
}
|
|
1639
|
-
|
|
2189
|
+
/** Sets alert.triage to the diagnosis, or to null when there is none, so it is tried once per alert. */
|
|
2190
|
+
async addTriage(alert, timeout) {
|
|
1640
2191
|
const controller = new AbortController();
|
|
1641
2192
|
try {
|
|
1642
2193
|
const recentRuns = await this.store.listRuns(alert.job, 5);
|
|
1643
|
-
const diagnosis = await withTimeout(this.triage({ alert, recentRuns, signal: controller.signal }),
|
|
1644
|
-
|
|
2194
|
+
const diagnosis = await withTimeout(this.triage({ alert, recentRuns, signal: controller.signal }), timeout);
|
|
2195
|
+
alert.triage = typeof diagnosis === "string" && diagnosis !== "" ? diagnosis : null;
|
|
1645
2196
|
} catch (e) {
|
|
1646
2197
|
controller.abort();
|
|
2198
|
+
alert.triage = null;
|
|
1647
2199
|
this.onError(e, `triage for ${alert.job}`);
|
|
1648
2200
|
}
|
|
1649
2201
|
}
|
|
1650
2202
|
};
|
|
1651
|
-
|
|
1652
|
-
|
|
2203
|
+
var RESERVED_RUN_ID_PREFIX = "pgcron:";
|
|
2204
|
+
function checkRunId(job, id, method) {
|
|
2205
|
+
if (typeof id !== "string" || id.length === 0 || id.length > 200) {
|
|
2206
|
+
throw new Error(`job "${job}": ${method}() needs a run id of 1 to 200 characters (got ${typeof id === "string" ? `${id.length} characters` : typeof id})`);
|
|
2207
|
+
}
|
|
2208
|
+
if (id.startsWith(RESERVED_RUN_ID_PREFIX)) {
|
|
2209
|
+
throw new Error(`job "${job}": ${method}() cannot take a run id starting with "${RESERVED_RUN_ID_PREFIX}", which the pg_cron source uses for its runs`);
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
function joinLines(before, after) {
|
|
2213
|
+
if (before === null || before === "") return after;
|
|
2214
|
+
if (after === null) return before;
|
|
2215
|
+
return `${before}
|
|
2216
|
+
${after}`;
|
|
2217
|
+
}
|
|
2218
|
+
function joinOutput(before, after) {
|
|
2219
|
+
const joined = joinLines(before, after);
|
|
2220
|
+
return joined === null ? null : capOutput(joined);
|
|
1653
2221
|
}
|
|
1654
2222
|
function clampLimit(limit, fallback, min) {
|
|
1655
2223
|
const n = typeof limit === "number" && Number.isFinite(limit) ? Math.trunc(limit) : fallback;
|