@cronwatch/sdk 0.3.1 → 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 -2
- 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 +462 -45
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -260
- package/dist/index.d.ts +5 -260
- package/dist/index.js +462 -45
- 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 +20 -0
- 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 +20 -0
- 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 +18 -0
- 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 +18 -0
- 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-DUt2apl0.d.cts → types-Ddq1MUPL.d.cts} +29 -2
- package/dist/{types-DUt2apl0.d.ts → types-Ddq1MUPL.d.ts} +29 -2
- 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 +208 -2
package/dist/index.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { Cron } from 'croner';
|
|
2
|
-
import { randomBytes, 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;
|
|
@@ -455,6 +472,12 @@ ${out}`);
|
|
|
455
472
|
break;
|
|
456
473
|
}
|
|
457
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
|
+
}
|
|
458
481
|
title = `${name} recovered`;
|
|
459
482
|
const after = draft.details.after.map((c) => c.replace("_", " ")).join(", ");
|
|
460
483
|
lines.push(`A run ${run ? when(run.startedAt, now) : "just now"} succeeded${after ? ` after: ${after}` : ""}.`);
|
|
@@ -816,15 +839,17 @@ function messagePage(title, message, base) {
|
|
|
816
839
|
|
|
817
840
|
// src/routes/index.ts
|
|
818
841
|
var COOKIE = "cronwatch_token";
|
|
819
|
-
function cookieValue(token) {
|
|
820
|
-
|
|
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("");
|
|
821
845
|
}
|
|
822
846
|
var DEFAULT_RUNS = 20;
|
|
823
847
|
var MAX_RUNS = 500;
|
|
824
848
|
var CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none'";
|
|
825
849
|
var SECURITY_HEADERS = { "x-content-type-options": "nosniff", "referrer-policy": "same-origin", "x-robots-tag": "noindex" };
|
|
826
850
|
function developmentToken() {
|
|
827
|
-
|
|
851
|
+
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
|
852
|
+
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
828
853
|
}
|
|
829
854
|
function developmentSignInLine(origin, base, token) {
|
|
830
855
|
return `[cronwatch] CRONWATCH_TOKEN is not set, so this development server made a token for the dashboard. Sign in: ${origin}${base}/?token=${token}`;
|
|
@@ -845,6 +870,34 @@ function readCookie(request, name) {
|
|
|
845
870
|
}
|
|
846
871
|
return null;
|
|
847
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
|
+
}
|
|
848
901
|
function stripBase(pathname, base) {
|
|
849
902
|
let path = pathname.startsWith(base) ? pathname.slice(base.length) : pathname;
|
|
850
903
|
if (path === "") path = "/";
|
|
@@ -869,9 +922,9 @@ async function readBody(request) {
|
|
|
869
922
|
}
|
|
870
923
|
return {};
|
|
871
924
|
}
|
|
872
|
-
function crossSite(request,
|
|
925
|
+
function crossSite(request, publicOrigin) {
|
|
873
926
|
const origin = request.headers.get("origin");
|
|
874
|
-
if (origin !== null && origin !==
|
|
927
|
+
if (origin !== null && origin !== publicOrigin) return true;
|
|
875
928
|
const site = request.headers.get("sec-fetch-site");
|
|
876
929
|
return site !== null && site !== "same-origin" && site !== "none";
|
|
877
930
|
}
|
|
@@ -888,31 +941,37 @@ function runsLimit(value) {
|
|
|
888
941
|
}
|
|
889
942
|
function createRoutes(cw, options = {}) {
|
|
890
943
|
const optedOut = options.token === null;
|
|
891
|
-
const configured = optedOut ? null : options.token ||
|
|
944
|
+
const configured = optedOut ? null : options.token || readEnv("CRONWATCH_TOKEN") || null;
|
|
892
945
|
const base = (options.basePath ?? "/cronwatch").replace(/\/+$/, "");
|
|
893
|
-
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);
|
|
894
950
|
const generated = !configured && !optedOut && developing;
|
|
895
951
|
const token = generated ? developmentToken() : configured;
|
|
896
952
|
let announced = false;
|
|
953
|
+
let cookie = null;
|
|
954
|
+
const expectedCookie = (value) => cookie ??= cookieValue(value);
|
|
897
955
|
const serve = async (request, url, path, wantsHtml) => {
|
|
898
956
|
const method = request.method.toUpperCase();
|
|
957
|
+
const publicOrigin = originOf(request, url);
|
|
899
958
|
if (generated && !announced) {
|
|
900
959
|
announced = true;
|
|
901
|
-
console.info(developmentSignInLine(
|
|
960
|
+
console.info(developmentSignInLine(publicOrigin, base, token));
|
|
902
961
|
}
|
|
903
962
|
if (!token && !optedOut) {
|
|
904
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);
|
|
905
964
|
}
|
|
906
|
-
if (method !== "GET" && method !== "HEAD" && crossSite(request,
|
|
965
|
+
if (method !== "GET" && method !== "HEAD" && crossSite(request, publicOrigin)) {
|
|
907
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);
|
|
908
967
|
}
|
|
909
968
|
const bearer = request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") ?? null;
|
|
910
969
|
if (token) {
|
|
911
970
|
const query = wantsHtml && method === "GET" ? url.searchParams.get("token") : null;
|
|
912
|
-
const
|
|
971
|
+
const sent = readCookie(request, COOKIE);
|
|
913
972
|
const isCheck = path === "/api/check";
|
|
914
973
|
const cronSecretOk = isCheck && bearer !== null && cw.cronSecret !== null && constantTimeEqual(bearer, cw.cronSecret);
|
|
915
|
-
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));
|
|
916
975
|
if (!cronSecretOk && !tokenOk) {
|
|
917
976
|
if (generated) {
|
|
918
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);
|
|
@@ -921,15 +980,15 @@ function createRoutes(cw, options = {}) {
|
|
|
921
980
|
}
|
|
922
981
|
if (query !== null) {
|
|
923
982
|
url.searchParams.delete("token");
|
|
924
|
-
const secure =
|
|
983
|
+
const secure = publicOrigin.startsWith("https:") ? "; Secure" : "";
|
|
925
984
|
return redirect(url.pathname + (url.search || ""), {
|
|
926
|
-
"set-cookie": `${COOKIE}=${
|
|
985
|
+
"set-cookie": `${COOKIE}=${await expectedCookie(token)}; Path=${base || "/"}; HttpOnly; SameSite=Lax; Max-Age=${60 * 60 * 24 * 30}${secure}`
|
|
927
986
|
});
|
|
928
987
|
}
|
|
929
988
|
}
|
|
930
989
|
const redirectBack = () => {
|
|
931
990
|
const referer = request.headers.get("referer") ?? "";
|
|
932
|
-
return redirect(referer.startsWith(
|
|
991
|
+
return redirect(referer.startsWith(publicOrigin + "/") ? referer : `${base}/`);
|
|
933
992
|
};
|
|
934
993
|
const decoded = path.split("/").filter(Boolean).map(safeDecode);
|
|
935
994
|
if (decoded.some((part) => part === null)) {
|
|
@@ -1090,6 +1149,7 @@ function memory() {
|
|
|
1090
1149
|
}
|
|
1091
1150
|
},
|
|
1092
1151
|
async insertRun(run) {
|
|
1152
|
+
if (runs.has(run.id)) throw new Error(`run ${run.id} already exists`);
|
|
1093
1153
|
runs.set(run.id, clone(run));
|
|
1094
1154
|
order.set(run.id, ++seq);
|
|
1095
1155
|
},
|
|
@@ -1099,6 +1159,13 @@ function memory() {
|
|
|
1099
1159
|
const { status, finishedAt, durationMs, error, output, metrics } = clone(run);
|
|
1100
1160
|
runs.set(run.id, { ...existing, status, finishedAt, durationMs, error, output, metrics });
|
|
1101
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
|
+
},
|
|
1102
1169
|
async getRun(id) {
|
|
1103
1170
|
const r = runs.get(id);
|
|
1104
1171
|
return r ? clone(r) : null;
|
|
@@ -1194,6 +1261,7 @@ var CronWatch = class {
|
|
|
1194
1261
|
store;
|
|
1195
1262
|
alerts;
|
|
1196
1263
|
triage;
|
|
1264
|
+
sources;
|
|
1197
1265
|
/** The secret handler() requests must carry, or null when none is set. */
|
|
1198
1266
|
cronSecret;
|
|
1199
1267
|
retentionMs;
|
|
@@ -1209,6 +1277,8 @@ var CronWatch = class {
|
|
|
1209
1277
|
synced = /* @__PURE__ */ new Set();
|
|
1210
1278
|
/** The tail of each job's queue of state updates. See serial(). */
|
|
1211
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();
|
|
1212
1282
|
ready = null;
|
|
1213
1283
|
checking = null;
|
|
1214
1284
|
lastPruneAt = 0;
|
|
@@ -1221,7 +1291,8 @@ var CronWatch = class {
|
|
|
1221
1291
|
this.store = options.store ?? (this.usingDefaultStore = true, memory());
|
|
1222
1292
|
this.alerts = options.alerts ?? [consoleChannel()];
|
|
1223
1293
|
this.triage = options.triage;
|
|
1224
|
-
|
|
1294
|
+
this.sources = options.sources ?? [];
|
|
1295
|
+
const secret = options.cronSecret === void 0 ? readEnv("CRON_SECRET") : options.cronSecret;
|
|
1225
1296
|
this.cronSecret = secret ? secret : null;
|
|
1226
1297
|
this.secretOptOut = options.cronSecret === null;
|
|
1227
1298
|
this.retentionMs = parseDuration(options.retention ?? "30d", "retention");
|
|
@@ -1265,6 +1336,12 @@ var CronWatch = class {
|
|
|
1265
1336
|
definedJobs() {
|
|
1266
1337
|
return [...this.definitions.values()];
|
|
1267
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
|
+
}
|
|
1268
1345
|
handle(definition) {
|
|
1269
1346
|
const self = this;
|
|
1270
1347
|
return {
|
|
@@ -1303,6 +1380,12 @@ var CronWatch = class {
|
|
|
1303
1380
|
};
|
|
1304
1381
|
return json(body, outcome.run.status === "ok" ? 200 : 500);
|
|
1305
1382
|
};
|
|
1383
|
+
},
|
|
1384
|
+
start(options) {
|
|
1385
|
+
return self.startRun(definition, options ?? {});
|
|
1386
|
+
},
|
|
1387
|
+
resume(runId) {
|
|
1388
|
+
return self.resumeHandle(definition, runId);
|
|
1306
1389
|
}
|
|
1307
1390
|
};
|
|
1308
1391
|
}
|
|
@@ -1322,7 +1405,7 @@ var CronWatch = class {
|
|
|
1322
1405
|
if (!this.ready) {
|
|
1323
1406
|
this.ready = (async () => {
|
|
1324
1407
|
if (this.store.init) await this.store.init();
|
|
1325
|
-
if (this.usingDefaultStore &&
|
|
1408
|
+
if (this.usingDefaultStore && readEnv("NODE_ENV") === "production") {
|
|
1326
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.");
|
|
1327
1410
|
}
|
|
1328
1411
|
})().catch((error) => {
|
|
@@ -1439,6 +1522,21 @@ var CronWatch = class {
|
|
|
1439
1522
|
run.durationMs = Math.max(0, finishedAt - startedAt);
|
|
1440
1523
|
run.metrics = recorder.metrics();
|
|
1441
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) {
|
|
1442
1540
|
if (threw) {
|
|
1443
1541
|
run.status = "failed";
|
|
1444
1542
|
run.error = errorMessage(error);
|
|
@@ -1446,7 +1544,6 @@ var CronWatch = class {
|
|
|
1446
1544
|
run.status = "failed";
|
|
1447
1545
|
run.error = `HTTP ${result.status}${result.statusText ? ` ${result.statusText}` : ""}`;
|
|
1448
1546
|
} else {
|
|
1449
|
-
const expectText = recorder.expectText() ?? (typeof result === "string" ? result : null);
|
|
1450
1547
|
const unmet = checkExpectation(definition.expect, expectText);
|
|
1451
1548
|
if (unmet) {
|
|
1452
1549
|
run.status = "failed";
|
|
@@ -1457,44 +1554,336 @@ var CronWatch = class {
|
|
|
1457
1554
|
}
|
|
1458
1555
|
if (run.output !== null) run.output = stripNul(this.redact(run.output));
|
|
1459
1556
|
if (run.error !== null) run.error = stripNul(this.redact(run.error));
|
|
1460
|
-
|
|
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) {
|
|
1461
1566
|
if (!recorded) {
|
|
1567
|
+
await this.sync(definition);
|
|
1462
1568
|
try {
|
|
1463
|
-
await this.sync(definition);
|
|
1464
1569
|
await this.store.insertRun(run);
|
|
1465
|
-
|
|
1570
|
+
await this.finishRun(toStored(definition), run, finishedAt);
|
|
1571
|
+
return null;
|
|
1466
1572
|
} catch (e) {
|
|
1467
|
-
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}"`;
|
|
1468
1576
|
}
|
|
1469
|
-
|
|
1470
|
-
|
|
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;
|
|
1471
1629
|
try {
|
|
1472
|
-
await this.
|
|
1630
|
+
await this.ensureReady();
|
|
1631
|
+
stored = await this.store.getRun(id);
|
|
1473
1632
|
} catch (e) {
|
|
1474
1633
|
this.onError(e, `recording ${name}`);
|
|
1475
1634
|
}
|
|
1476
|
-
|
|
1477
|
-
await this.finishRun(toStored(definition), run, finishedAt, true);
|
|
1635
|
+
if (stored) return this.existingHandle(definition, stored);
|
|
1478
1636
|
}
|
|
1479
|
-
|
|
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);
|
|
1480
1663
|
}
|
|
1481
|
-
/**
|
|
1482
|
-
async
|
|
1483
|
-
|
|
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;
|
|
1484
1668
|
try {
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
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}"`);
|
|
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
|
+
};
|
|
1819
|
+
}
|
|
1820
|
+
/**
|
|
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.
|
|
1834
|
+
*/
|
|
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;
|
|
1488
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());
|
|
1489
1879
|
}
|
|
1490
1880
|
/**
|
|
1491
|
-
*
|
|
1492
|
-
* against the job's state and send what that produces. Never throws.
|
|
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.
|
|
1493
1883
|
*/
|
|
1494
|
-
async finishRun(definition, run, now
|
|
1884
|
+
async finishRun(definition, run, now) {
|
|
1495
1885
|
let drafts;
|
|
1496
1886
|
try {
|
|
1497
|
-
if (write) await this.store.updateRun(run);
|
|
1498
1887
|
let history = null;
|
|
1499
1888
|
({ result: drafts } = await this.updateState(run.job, async (previous) => {
|
|
1500
1889
|
history ??= await this.history(run);
|
|
@@ -1535,9 +1924,16 @@ var CronWatch = class {
|
|
|
1535
1924
|
}
|
|
1536
1925
|
async runCheck() {
|
|
1537
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
|
+
}
|
|
1538
1935
|
for (const definition of this.definitions.values()) await this.sync(definition);
|
|
1539
1936
|
const now = this.now();
|
|
1540
|
-
const alerts = [];
|
|
1541
1937
|
for (const run of await this.store.runningRuns()) {
|
|
1542
1938
|
try {
|
|
1543
1939
|
const declared = this.definitions.get(run.job);
|
|
@@ -1548,7 +1944,8 @@ var CronWatch = class {
|
|
|
1548
1944
|
run.finishedAt = now;
|
|
1549
1945
|
run.durationMs = now - run.startedAt;
|
|
1550
1946
|
run.error = `Still running after ${formatDuration(timeout)}; marked as timed out`;
|
|
1551
|
-
|
|
1947
|
+
if (!await this.writeRunIf(run, ["running"])) continue;
|
|
1948
|
+
alerts.push(...await this.finishRun(definition, run, now));
|
|
1552
1949
|
} catch (e) {
|
|
1553
1950
|
this.onError(e, `checking ${run.job}`);
|
|
1554
1951
|
}
|
|
@@ -1666,7 +2063,11 @@ var CronWatch = class {
|
|
|
1666
2063
|
routes(options) {
|
|
1667
2064
|
return createRoutes(this, options);
|
|
1668
2065
|
}
|
|
1669
|
-
/**
|
|
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
|
+
*/
|
|
1670
2071
|
start(every = "1m") {
|
|
1671
2072
|
if (this.timer) return;
|
|
1672
2073
|
const ms = Math.max(5e3, parseDuration(every, "check interval"));
|
|
@@ -1775,7 +2176,7 @@ var CronWatch = class {
|
|
|
1775
2176
|
const results = await Promise.all(
|
|
1776
2177
|
this.alerts.map(async (channel) => {
|
|
1777
2178
|
try {
|
|
1778
|
-
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);
|
|
1779
2180
|
return true;
|
|
1780
2181
|
} catch (e) {
|
|
1781
2182
|
this.onError(e, `alert channel ${channel.name}`);
|
|
@@ -1799,8 +2200,24 @@ var CronWatch = class {
|
|
|
1799
2200
|
}
|
|
1800
2201
|
}
|
|
1801
2202
|
};
|
|
1802
|
-
|
|
1803
|
-
|
|
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);
|
|
1804
2221
|
}
|
|
1805
2222
|
function clampLimit(limit, fallback, min) {
|
|
1806
2223
|
const n = typeof limit === "number" && Number.isFinite(limit) ? Math.trunc(limit) : fallback;
|