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