@cronwatch/sdk 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -5
- package/dist/anthropic.d.cts +1 -1
- package/dist/anthropic.d.ts +1 -1
- package/dist/discord.d.cts +1 -1
- package/dist/discord.d.ts +1 -1
- package/dist/index.cjs +234 -83
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +48 -16
- package/dist/index.d.ts +48 -16
- package/dist/index.js +235 -84
- package/dist/index.js.map +1 -1
- package/dist/postgres.cjs +13 -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 +13 -1
- package/dist/postgres.js.map +1 -1
- package/dist/slack.d.cts +1 -1
- package/dist/slack.d.ts +1 -1
- package/dist/sqlite.cjs +44 -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 +44 -5
- package/dist/sqlite.js.map +1 -1
- package/dist/{types-C1PyRjI5.d.cts → types-DUt2apl0.d.cts} +21 -2
- package/dist/{types-C1PyRjI5.d.ts → types-DUt2apl0.d.ts} +21 -2
- package/dist/webhook.d.cts +1 -1
- package/dist/webhook.d.ts +1 -1
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Cron } from 'croner';
|
|
2
|
-
import { createHash } from 'crypto';
|
|
2
|
+
import { randomBytes, createHash } from 'crypto';
|
|
3
3
|
|
|
4
4
|
// src/duration.ts
|
|
5
5
|
var UNIT_MS = {
|
|
@@ -337,6 +337,14 @@ function muteOpens(previous, next) {
|
|
|
337
337
|
function isSilenced(state, now) {
|
|
338
338
|
return state.silencedUntil !== null && state.silencedUntil > now;
|
|
339
339
|
}
|
|
340
|
+
function applySilence(previous, evaluation, now) {
|
|
341
|
+
if (!isSilenced(previous, now)) return evaluation;
|
|
342
|
+
return { state: muteOpens(previous, evaluation.state), alerts: [] };
|
|
343
|
+
}
|
|
344
|
+
function staleAlert(alert, state) {
|
|
345
|
+
if (alert.type === "recovered") return alert.details.after.some((condition) => state.open[condition] !== void 0);
|
|
346
|
+
return state.open[alert.type] !== alert.at;
|
|
347
|
+
}
|
|
340
348
|
function jobHealth(def, lastRun, state, now) {
|
|
341
349
|
const open = openConditions(state);
|
|
342
350
|
if (isSilenced(state, now)) return "silenced";
|
|
@@ -347,6 +355,12 @@ function jobHealth(def, lastRun, state, now) {
|
|
|
347
355
|
return "healthy";
|
|
348
356
|
}
|
|
349
357
|
function summarize(stored, recent, state, nextExpectedAt, now) {
|
|
358
|
+
return summary(stored, recent, state, nextExpectedAt, (lastRun) => jobHealth(stored.definition, lastRun, state, now));
|
|
359
|
+
}
|
|
360
|
+
function unevaluableSummary(stored, recent, state, now) {
|
|
361
|
+
return summary(stored, recent, state, null, () => isSilenced(state, now) ? "silenced" : "failing");
|
|
362
|
+
}
|
|
363
|
+
function summary(stored, recent, state, nextExpectedAt, health) {
|
|
350
364
|
const window = recent.slice(0, BASELINE_WINDOW);
|
|
351
365
|
const lastRun = window[0] ?? null;
|
|
352
366
|
const finished = window.filter((r) => r.status !== "running");
|
|
@@ -354,7 +368,7 @@ function summarize(stored, recent, state, nextExpectedAt, now) {
|
|
|
354
368
|
return {
|
|
355
369
|
name: stored.name,
|
|
356
370
|
definition: stored.definition,
|
|
357
|
-
health:
|
|
371
|
+
health: health(lastRun),
|
|
358
372
|
open: openConditions(state),
|
|
359
373
|
lastRun,
|
|
360
374
|
nextExpectedAt,
|
|
@@ -468,8 +482,12 @@ function constantTimeEqual(a, b) {
|
|
|
468
482
|
// src/output.ts
|
|
469
483
|
var OUTPUT_CAP = 16 * 1024;
|
|
470
484
|
function capOutput(text) {
|
|
471
|
-
|
|
472
|
-
|
|
485
|
+
const clean = stripNul(text);
|
|
486
|
+
if (clean.length <= OUTPUT_CAP) return clean;
|
|
487
|
+
return "[earlier output trimmed]\n" + clean.slice(clean.length - OUTPUT_CAP);
|
|
488
|
+
}
|
|
489
|
+
function stripNul(text) {
|
|
490
|
+
return text.includes("\0") ? text.replace(/\u0000/g, "") : text;
|
|
473
491
|
}
|
|
474
492
|
function errorMessage(error) {
|
|
475
493
|
return capOutput(describeError(error));
|
|
@@ -489,25 +507,44 @@ ${frames.join("\n")}` : ""}`;
|
|
|
489
507
|
}
|
|
490
508
|
var REDACTED = "[redacted]";
|
|
491
509
|
var SECRET_PATTERNS = [
|
|
492
|
-
//
|
|
493
|
-
//
|
|
510
|
+
// A PEM private key, header to footer. Without a footer (the output was
|
|
511
|
+
// trimmed) it runs to the end of the base64 body. A "-" that starts five
|
|
512
|
+
// dashes ends the body, so the footer is never swallowed into it.
|
|
513
|
+
[
|
|
514
|
+
/-----BEGIN (?:[A-Z0-9]{1,20} ){0,3}PRIVATE KEY-----(?:[A-Za-z0-9+/=\s,:]|-(?!----)){0,16384}(?:-----END (?:[A-Z0-9]{1,20} ){0,3}PRIVATE KEY-----)?/g,
|
|
515
|
+
REDACTED
|
|
516
|
+
],
|
|
517
|
+
// password=..., API_KEY: ..., "client_secret": "...", TOKEN='...', token=...,
|
|
518
|
+
// :password=>"..." (but not max_tokens: 800). A quoted value is blanked to
|
|
519
|
+
// its closing quote, spaces and all, and keeps its quotes.
|
|
494
520
|
[
|
|
495
|
-
/\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,
|
|
521
|
+
/\b([A-Za-z0-9_-]{0,40}(?:secret|token|passw(?:or)?d|pwd|api[_-]?key|access[_-]?key|private[_-]?key|credential)[A-Za-z0-9_-]{0,40}(?<![Tt][Oo][Kk][Ee][Nn][Ss])"?\s{0,3}(?:=>|[=:])\s{0,3})(?:(")[^"\n]{1,4096}"|(')[^'\n]{1,4096}'|["']?[^\s"',;&]{1,4096})/gi,
|
|
496
522
|
(_match, name, double, single) => {
|
|
497
523
|
const quote = double ?? single ?? "";
|
|
498
524
|
return `${name}${quote}${REDACTED}${quote}`;
|
|
499
525
|
}
|
|
500
526
|
],
|
|
501
|
-
//
|
|
502
|
-
[
|
|
527
|
+
// Authorization: Basic <base64> and Authorization: Token <token>, also as a JSON or hash entry.
|
|
528
|
+
[/\b((?:proxy-)?authorization["']?\s{0,3}(?:=>|[=:])\s{0,3}["']?\s{0,3}(?:basic|token)\s{1,3})[A-Za-z0-9._~+/=:-]{1,4096}/gi, `$1${REDACTED}`],
|
|
529
|
+
// Credentials inside a URL: postgres://user:password@host. The password
|
|
530
|
+
// runs to the last "@" before a "/" or a space, so one that contains "@"
|
|
531
|
+
// is blanked whole.
|
|
532
|
+
[/(\b[a-z][a-z0-9+.-]{0,30}:\/\/[^\s/:@]{0,256}:)[^\s/]{1,256}@/gi, `$1${REDACTED}@`],
|
|
503
533
|
// Authorization: Bearer <token>
|
|
504
534
|
[/\b(Bearer\s{1,3})[A-Za-z0-9._~+/=-]{8,4096}/g, `$1${REDACTED}`],
|
|
505
|
-
//
|
|
535
|
+
// A bare JWT: three base64url segments, the first starting eyJ.
|
|
536
|
+
[/\beyJ[A-Za-z0-9_-]{4,4096}\.[A-Za-z0-9_-]{4,4096}\.[A-Za-z0-9_-]{0,4096}/g, REDACTED],
|
|
537
|
+
// Incoming webhook URLs carry their secret in the path.
|
|
538
|
+
[/(\bhooks\.slack\.com\/(?:services|workflows|triggers)\/)[A-Za-z0-9/_-]{1,255}/gi, `$1${REDACTED}`],
|
|
539
|
+
[/(\bdiscord(?:app)?\.com\/api\/(?:v\d{1,2}\/)?webhooks\/)[A-Za-z0-9/_-]{1,255}/gi, `$1${REDACTED}`],
|
|
540
|
+
// Well-known token shapes: AWS, GitHub, Slack, Stripe, Anthropic, OpenAI and Google style keys.
|
|
506
541
|
[/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, REDACTED],
|
|
507
542
|
[/\b(?:gh[pousr]_[A-Za-z0-9]{30,255}|github_pat_[A-Za-z0-9_]{20,255})\b/g, REDACTED],
|
|
508
543
|
[/\bxox[abposr]-[A-Za-z0-9-]{10,255}/g, REDACTED],
|
|
509
544
|
[/\b[rsp]k_(?:live|test)_[A-Za-z0-9]{10,255}\b/g, REDACTED],
|
|
510
|
-
[/\
|
|
545
|
+
[/\bwhsec_[A-Za-z0-9+/=]{16,255}/g, REDACTED],
|
|
546
|
+
[/\bsk-[A-Za-z0-9_-]{20,255}/g, REDACTED],
|
|
547
|
+
[/\bAIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-])/g, REDACTED]
|
|
511
548
|
];
|
|
512
549
|
function redactSecrets(text) {
|
|
513
550
|
let out = text;
|
|
@@ -782,11 +819,16 @@ var COOKIE = "cronwatch_token";
|
|
|
782
819
|
function cookieValue(token) {
|
|
783
820
|
return createHash("sha256").update(`cronwatch-cookie:${token}`).digest("hex");
|
|
784
821
|
}
|
|
785
|
-
var LOOPBACK = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
786
822
|
var DEFAULT_RUNS = 20;
|
|
787
823
|
var MAX_RUNS = 500;
|
|
788
824
|
var CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none'";
|
|
789
825
|
var SECURITY_HEADERS = { "x-content-type-options": "nosniff", "referrer-policy": "same-origin", "x-robots-tag": "noindex" };
|
|
826
|
+
function developmentToken() {
|
|
827
|
+
return randomBytes(32).toString("base64url");
|
|
828
|
+
}
|
|
829
|
+
function developmentSignInLine(origin, base, token) {
|
|
830
|
+
return `[cronwatch] CRONWATCH_TOKEN is not set, so this development server made a token for the dashboard. Sign in: ${origin}${base}/?token=${token}`;
|
|
831
|
+
}
|
|
790
832
|
function safeDecode(value) {
|
|
791
833
|
try {
|
|
792
834
|
return decodeURIComponent(value);
|
|
@@ -846,13 +888,20 @@ function runsLimit(value) {
|
|
|
846
888
|
}
|
|
847
889
|
function createRoutes(cw, options = {}) {
|
|
848
890
|
const optedOut = options.token === null;
|
|
849
|
-
const
|
|
891
|
+
const configured = optedOut ? null : options.token || process.env.CRONWATCH_TOKEN || null;
|
|
850
892
|
const base = (options.basePath ?? "/cronwatch").replace(/\/+$/, "");
|
|
851
893
|
const developing = process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test";
|
|
894
|
+
const generated = !configured && !optedOut && developing;
|
|
895
|
+
const token = generated ? developmentToken() : configured;
|
|
896
|
+
let announced = false;
|
|
852
897
|
const serve = async (request, url, path, wantsHtml) => {
|
|
853
898
|
const method = request.method.toUpperCase();
|
|
854
|
-
if (
|
|
855
|
-
|
|
899
|
+
if (generated && !announced) {
|
|
900
|
+
announced = true;
|
|
901
|
+
console.info(developmentSignInLine(url.origin, base, token));
|
|
902
|
+
}
|
|
903
|
+
if (!token && !optedOut) {
|
|
904
|
+
return wantsHtml ? html(messagePage("CronWatch routes are locked", "Set CRONWATCH_TOKEN (or pass token to cw.routes()), or pass token: null to serve them open behind your own auth.", base), 503) : api({ ok: false, error: "CRONWATCH_TOKEN is not set" }, 503);
|
|
856
905
|
}
|
|
857
906
|
if (method !== "GET" && method !== "HEAD" && crossSite(request, url)) {
|
|
858
907
|
return wantsHtml ? html(messagePage("Cross-site request refused", "Changes can only be made from the dashboard itself.", base), 403) : api({ ok: false, error: "Cross-site request refused" }, 403);
|
|
@@ -865,6 +914,9 @@ function createRoutes(cw, options = {}) {
|
|
|
865
914
|
const cronSecretOk = isCheck && bearer !== null && cw.cronSecret !== null && constantTimeEqual(bearer, cw.cronSecret);
|
|
866
915
|
const tokenOk = bearer !== null ? constantTimeEqual(bearer, token) : query !== null ? constantTimeEqual(query, token) : cookie !== null && constantTimeEqual(cookie, cookieValue(token));
|
|
867
916
|
if (!cronSecretOk && !tokenOk) {
|
|
917
|
+
if (generated) {
|
|
918
|
+
return wantsHtml ? html(messagePage("Sign in", "CRONWATCH_TOKEN is not set, so this development server made a token. The sign-in link is in the server log: open it once and this browser stays signed in.", base), 401) : api({ ok: false, error: "Unauthorized: CRONWATCH_TOKEN is not set, so this development server made a token; it is in the server log" }, 401);
|
|
919
|
+
}
|
|
868
920
|
return wantsHtml ? html(messagePage("Sign in", `Open this page with ?token=<your CRONWATCH_TOKEN> once and it will stay signed in.`, base), 401) : api({ ok: false, error: "Unauthorized" }, 401);
|
|
869
921
|
}
|
|
870
922
|
if (query !== null) {
|
|
@@ -1068,6 +1120,11 @@ function memory() {
|
|
|
1068
1120
|
async setState(state) {
|
|
1069
1121
|
states.set(state.job, clone(state));
|
|
1070
1122
|
},
|
|
1123
|
+
async compareAndSetState(state, expectedVersion) {
|
|
1124
|
+
if ((states.get(state.job)?.version ?? 0) !== expectedVersion) return false;
|
|
1125
|
+
states.set(state.job, clone(state));
|
|
1126
|
+
return true;
|
|
1127
|
+
},
|
|
1071
1128
|
async prune(before) {
|
|
1072
1129
|
const newest = /* @__PURE__ */ new Map();
|
|
1073
1130
|
for (const run of runs.values()) newest.set(run.job, Math.max(newest.get(run.job) ?? -Infinity, run.startedAt));
|
|
@@ -1090,6 +1147,8 @@ var TRIAGE_TIMEOUT_MS = 25e3;
|
|
|
1090
1147
|
var CHANNEL_TIMEOUT_MS = 15e3;
|
|
1091
1148
|
var PRUNE_INTERVAL_MS = 60 * 6e4;
|
|
1092
1149
|
var MAX_UNDELIVERED = 20;
|
|
1150
|
+
var RETRY_BUDGET_MS = 2e4;
|
|
1151
|
+
var STATE_ATTEMPTS = 10;
|
|
1093
1152
|
var HISTORY_PAGE = BASELINE_WINDOW + 5;
|
|
1094
1153
|
var HISTORY_MAX = 200;
|
|
1095
1154
|
function validateDefinition(def) {
|
|
@@ -1157,6 +1216,7 @@ var CronWatch = class {
|
|
|
1157
1216
|
firstTick = null;
|
|
1158
1217
|
usingDefaultStore = false;
|
|
1159
1218
|
warnedNoSecret = false;
|
|
1219
|
+
warnedDeferredStart = false;
|
|
1160
1220
|
constructor(options = {}) {
|
|
1161
1221
|
this.store = options.store ?? (this.usingDefaultStore = true, memory());
|
|
1162
1222
|
this.alerts = options.alerts ?? [consoleChannel()];
|
|
@@ -1167,7 +1227,17 @@ var CronWatch = class {
|
|
|
1167
1227
|
this.retentionMs = parseDuration(options.retention ?? "30d", "retention");
|
|
1168
1228
|
this.defaults = options.defaults ?? {};
|
|
1169
1229
|
this.now = options.now ?? (() => Date.now());
|
|
1170
|
-
|
|
1230
|
+
const redact = options.redact;
|
|
1231
|
+
this.redact = redact === false ? (text) => text : typeof redact === "function" ? (text) => {
|
|
1232
|
+
try {
|
|
1233
|
+
const out = redact(text);
|
|
1234
|
+
if (typeof out !== "string") throw new TypeError(`redact must return a string, not ${out === null ? "null" : typeof out}`);
|
|
1235
|
+
return out;
|
|
1236
|
+
} catch (e) {
|
|
1237
|
+
this.report(e, "redact");
|
|
1238
|
+
return redactSecrets(text);
|
|
1239
|
+
}
|
|
1240
|
+
} : redactSecrets;
|
|
1171
1241
|
if (options.deliver !== void 0 && options.deliver !== "now" && options.deliver !== "check") {
|
|
1172
1242
|
throw new Error(`deliver must be "now" or "check", not ${JSON.stringify(options.deliver)}`);
|
|
1173
1243
|
}
|
|
@@ -1241,6 +1311,13 @@ var CronWatch = class {
|
|
|
1241
1311
|
this.warnedNoSecret = true;
|
|
1242
1312
|
this.onError(new Error("handler() refused a request because no CRON_SECRET is set; pass secret: null to allow unauthenticated requests"), "handler");
|
|
1243
1313
|
}
|
|
1314
|
+
/** onError, for places that must carry on even when onError itself throws. */
|
|
1315
|
+
report(error, where) {
|
|
1316
|
+
try {
|
|
1317
|
+
this.onError(error, where);
|
|
1318
|
+
} catch {
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1244
1321
|
async ensureReady() {
|
|
1245
1322
|
if (!this.ready) {
|
|
1246
1323
|
this.ready = (async () => {
|
|
@@ -1264,7 +1341,8 @@ var CronWatch = class {
|
|
|
1264
1341
|
/**
|
|
1265
1342
|
* Runs `fn` after every earlier state update for the same job has settled,
|
|
1266
1343
|
* so two runs (or a run and a check) in this process never read and write
|
|
1267
|
-
* the job's state over each other. Other processes are
|
|
1344
|
+
* the job's state over each other. Other processes are coordinated by
|
|
1345
|
+
* updateState() instead.
|
|
1268
1346
|
*/
|
|
1269
1347
|
serial(job, fn) {
|
|
1270
1348
|
const result = (this.queues.get(job) ?? Promise.resolve()).then(fn);
|
|
@@ -1280,6 +1358,38 @@ var CronWatch = class {
|
|
|
1280
1358
|
async readState(job) {
|
|
1281
1359
|
return normalizeState(await this.store.getState(job), job);
|
|
1282
1360
|
}
|
|
1361
|
+
/**
|
|
1362
|
+
* Every read-modify-write of a job's state goes through here. In turn with
|
|
1363
|
+
* this process's other updates to the job (serial()), it reads the state,
|
|
1364
|
+
* asks `change` for the next one, and writes it with the version one
|
|
1365
|
+
* higher, only if the stored version is still the one read. When another
|
|
1366
|
+
* process wrote in between, the write is refused and it starts again from
|
|
1367
|
+
* a fresh read, up to STATE_ATTEMPTS times. So `change` may run more than
|
|
1368
|
+
* once and must only compute: whatever it returns from the attempt that
|
|
1369
|
+
* was written is the result. Nothing is written when the state is
|
|
1370
|
+
* unchanged. Returns the state as stored.
|
|
1371
|
+
*/
|
|
1372
|
+
updateState(job, change) {
|
|
1373
|
+
return this.serial(job, async () => {
|
|
1374
|
+
for (let attempt = 1; ; attempt++) {
|
|
1375
|
+
const current = await this.readState(job);
|
|
1376
|
+
const { state, result } = await change(current);
|
|
1377
|
+
if (sameState(state, current)) return { state: current, result };
|
|
1378
|
+
const version = current.version ?? 0;
|
|
1379
|
+
const next = { ...state, version: version + 1 };
|
|
1380
|
+
if (await this.writeState(next, version)) return { state: next, result };
|
|
1381
|
+
if (attempt >= STATE_ATTEMPTS) {
|
|
1382
|
+
throw new Error(`the state of ${job} changed under ${STATE_ATTEMPTS} attempts in a row to update it; gave up`);
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
/** A conditional write, or for a store without compareAndSetState, a plain one that always succeeds. */
|
|
1388
|
+
async writeState(state, expectedVersion) {
|
|
1389
|
+
if (typeof this.store.compareAndSetState === "function") return this.store.compareAndSetState(state, expectedVersion);
|
|
1390
|
+
await this.store.setState(state);
|
|
1391
|
+
return true;
|
|
1392
|
+
}
|
|
1283
1393
|
/**
|
|
1284
1394
|
* Runs a function as a recorded run. The function always runs, whatever
|
|
1285
1395
|
* the store is doing: store errors go to onError, and the result is the
|
|
@@ -1308,11 +1418,8 @@ var CronWatch = class {
|
|
|
1308
1418
|
} catch (e) {
|
|
1309
1419
|
this.onError(e, `recording ${name}`);
|
|
1310
1420
|
}
|
|
1311
|
-
const started = recorded ? this.
|
|
1312
|
-
|
|
1313
|
-
const after = onRunStart(before);
|
|
1314
|
-
if (!sameState(before, after)) await this.store.setState(after);
|
|
1315
|
-
}).catch((e) => this.onError(e, `starting ${name}`)) : Promise.resolve();
|
|
1421
|
+
const started = recorded ? this.updateState(name, (before) => ({ state: onRunStart(before), result: void 0 })).then(() => {
|
|
1422
|
+
}, (e) => this.onError(e, `starting ${name}`)) : Promise.resolve();
|
|
1316
1423
|
const recorder = createRecorder(run);
|
|
1317
1424
|
const timer = setTimeout(() => recorder.abort(), timeoutMs(definition));
|
|
1318
1425
|
if (typeof timer === "object" && "unref" in timer) timer.unref();
|
|
@@ -1348,8 +1455,8 @@ var CronWatch = class {
|
|
|
1348
1455
|
run.status = "ok";
|
|
1349
1456
|
}
|
|
1350
1457
|
}
|
|
1351
|
-
if (run.output !== null) run.output = this.redact(run.output);
|
|
1352
|
-
if (run.error !== null) run.error = this.redact(run.error);
|
|
1458
|
+
if (run.output !== null) run.output = stripNul(this.redact(run.output));
|
|
1459
|
+
if (run.error !== null) run.error = stripNul(this.redact(run.error));
|
|
1353
1460
|
await started;
|
|
1354
1461
|
if (!recorded) {
|
|
1355
1462
|
try {
|
|
@@ -1388,11 +1495,12 @@ var CronWatch = class {
|
|
|
1388
1495
|
let drafts;
|
|
1389
1496
|
try {
|
|
1390
1497
|
if (write) await this.store.updateRun(run);
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1498
|
+
let history = null;
|
|
1499
|
+
({ result: drafts } = await this.updateState(run.job, async (previous) => {
|
|
1500
|
+
history ??= await this.history(run);
|
|
1501
|
+
const settled = applySilence(previous, onRunFinish(definition, run, previous, history, now), now);
|
|
1502
|
+
return { state: settled.state, result: settled.alerts };
|
|
1503
|
+
}));
|
|
1396
1504
|
} catch (e) {
|
|
1397
1505
|
this.onError(e, `evaluating ${run.job}`);
|
|
1398
1506
|
return [];
|
|
@@ -1431,26 +1539,39 @@ var CronWatch = class {
|
|
|
1431
1539
|
const now = this.now();
|
|
1432
1540
|
const alerts = [];
|
|
1433
1541
|
for (const run of await this.store.runningRuns()) {
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1542
|
+
try {
|
|
1543
|
+
const declared = this.definitions.get(run.job);
|
|
1544
|
+
const definition = declared ? toStored(declared) : (await this.store.getJob(run.job))?.definition;
|
|
1545
|
+
if (!definition || !isStuck(definition, run, now)) continue;
|
|
1546
|
+
const timeout = timeoutMs(definition);
|
|
1547
|
+
run.status = "timeout";
|
|
1548
|
+
run.finishedAt = now;
|
|
1549
|
+
run.durationMs = now - run.startedAt;
|
|
1550
|
+
run.error = `Still running after ${formatDuration(timeout)}; marked as timed out`;
|
|
1551
|
+
alerts.push(...await this.finishRun(definition, run, now, true));
|
|
1552
|
+
} catch (e) {
|
|
1553
|
+
this.onError(e, `checking ${run.job}`);
|
|
1554
|
+
}
|
|
1442
1555
|
}
|
|
1443
1556
|
const jobs = [];
|
|
1557
|
+
const retries = { spentMs: 0 };
|
|
1444
1558
|
for (const stored of await this.store.listJobs()) {
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
const
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1559
|
+
try {
|
|
1560
|
+
const recent = await this.store.listRuns(stored.name, BASELINE_WINDOW);
|
|
1561
|
+
let nextExpectedAt = null;
|
|
1562
|
+
const { state, result: drafts } = await this.updateState(stored.name, (previous) => {
|
|
1563
|
+
const evaluation = onCheck(stored.definition, stored, recent[0] ?? null, previous, now);
|
|
1564
|
+
nextExpectedAt = evaluation.nextExpectedAt;
|
|
1565
|
+
const settled = applySilence(previous, evaluation, now);
|
|
1566
|
+
return { state: settled.state, result: settled.alerts };
|
|
1567
|
+
});
|
|
1568
|
+
alerts.push(...await this.retryUndelivered(stored.name, state, now, retries));
|
|
1569
|
+
alerts.push(...await this.dispatch(drafts, stored.definition, now));
|
|
1570
|
+
jobs.push(summarize(stored, recent, state, nextExpectedAt, now));
|
|
1571
|
+
} catch (e) {
|
|
1572
|
+
this.onError(e, `checking ${stored.name}`);
|
|
1573
|
+
jobs.push(await this.unevaluable(stored, now));
|
|
1574
|
+
}
|
|
1454
1575
|
}
|
|
1455
1576
|
let pruned = 0;
|
|
1456
1577
|
if (now - this.lastPruneAt > PRUNE_INTERVAL_MS) {
|
|
@@ -1463,12 +1584,24 @@ var CronWatch = class {
|
|
|
1463
1584
|
}
|
|
1464
1585
|
return { checkedAt: now, jobs, alerts, pruned };
|
|
1465
1586
|
}
|
|
1466
|
-
/** A job's summary and its newest runs, without alerting. */
|
|
1587
|
+
/** A job's summary and its newest runs, without alerting. A job that cannot be evaluated is reported and shown as failing. */
|
|
1467
1588
|
async snapshot(stored, now, runs) {
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1589
|
+
let recent = [];
|
|
1590
|
+
try {
|
|
1591
|
+
recent = await this.store.listRuns(stored.name, Math.max(runs, BASELINE_WINDOW));
|
|
1592
|
+
const state = await this.readState(stored.name);
|
|
1593
|
+
const { nextExpectedAt } = onCheck(stored.definition, stored, recent[0] ?? null, state, now);
|
|
1594
|
+
return { job: summarize(stored, recent, state, nextExpectedAt, now), runs: recent.slice(0, runs) };
|
|
1595
|
+
} catch (e) {
|
|
1596
|
+
this.onError(e, `reading ${stored.name}`);
|
|
1597
|
+
return { job: await this.unevaluable(stored, now), runs: recent.slice(0, runs) };
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
/** The summary of a job whose evaluation failed, from whatever can still be read. */
|
|
1601
|
+
async unevaluable(stored, now) {
|
|
1602
|
+
const recent = await this.store.listRuns(stored.name, BASELINE_WINDOW).catch(() => []);
|
|
1603
|
+
const state = await this.readState(stored.name).catch(() => emptyState(stored.name));
|
|
1604
|
+
return unevaluableSummary(stored, recent, state, now);
|
|
1472
1605
|
}
|
|
1473
1606
|
/** Every job the store knows about, with its health. Does not send alerts. */
|
|
1474
1607
|
async jobs() {
|
|
@@ -1515,12 +1648,12 @@ var CronWatch = class {
|
|
|
1515
1648
|
/** Read, change and write one job's state, in turn with every other update to it. */
|
|
1516
1649
|
async patchState(name, change) {
|
|
1517
1650
|
await this.ensureReady();
|
|
1518
|
-
|
|
1519
|
-
const
|
|
1520
|
-
change(
|
|
1521
|
-
|
|
1522
|
-
return state;
|
|
1651
|
+
const { state } = await this.updateState(name, (current) => {
|
|
1652
|
+
const next = normalizeState(current, name);
|
|
1653
|
+
change(next);
|
|
1654
|
+
return { state: next, result: void 0 };
|
|
1523
1655
|
});
|
|
1656
|
+
return state;
|
|
1524
1657
|
}
|
|
1525
1658
|
/** Remove a job and its runs from the store. A job still declared in code comes back on its next run. */
|
|
1526
1659
|
async forget(name) {
|
|
@@ -1537,6 +1670,10 @@ var CronWatch = class {
|
|
|
1537
1670
|
start(every = "1m") {
|
|
1538
1671
|
if (this.timer) return;
|
|
1539
1672
|
const ms = Math.max(5e3, parseDuration(every, "check interval"));
|
|
1673
|
+
if (this.deferDelivery && !this.warnedDeferredStart) {
|
|
1674
|
+
this.warnedDeferredStart = true;
|
|
1675
|
+
console.warn('[cronwatch] start() was called with deliver: "check", so these checks send no alerts. Another process must run checks with deliver: "now" (the default) to send them.');
|
|
1676
|
+
}
|
|
1540
1677
|
const tick = () => this.check().catch((e) => this.onError(e, "check"));
|
|
1541
1678
|
this.timer = setInterval(tick, ms);
|
|
1542
1679
|
if (typeof this.timer === "object" && "unref" in this.timer) this.timer.unref();
|
|
@@ -1556,20 +1693,10 @@ var CronWatch = class {
|
|
|
1556
1693
|
this.stop();
|
|
1557
1694
|
if (this.store.close) await this.store.close();
|
|
1558
1695
|
}
|
|
1559
|
-
/** Save an evaluation's state, honouring silence, and return what should be sent. Call inside serial(). */
|
|
1560
|
-
async settle(previous, evaluation, now) {
|
|
1561
|
-
let { state, alerts } = evaluation;
|
|
1562
|
-
if (isSilenced(previous, now)) {
|
|
1563
|
-
state = muteOpens(previous, state);
|
|
1564
|
-
alerts = [];
|
|
1565
|
-
}
|
|
1566
|
-
if (!sameState(state, previous)) await this.store.setState(state);
|
|
1567
|
-
return { state, alerts };
|
|
1568
|
-
}
|
|
1569
1696
|
/**
|
|
1570
1697
|
* Compose, triage and send each draft. The state was saved before this
|
|
1571
|
-
* (
|
|
1572
|
-
* delivery fields are written back, onto a fresh read of the state.
|
|
1698
|
+
* (updateState), so a slow channel holds up nothing else; afterwards only
|
|
1699
|
+
* the delivery fields are written back, onto a fresh read of the state.
|
|
1573
1700
|
*/
|
|
1574
1701
|
async dispatch(drafts, definition, now) {
|
|
1575
1702
|
if (drafts.length === 0) return [];
|
|
@@ -1581,41 +1708,63 @@ var CronWatch = class {
|
|
|
1581
1708
|
if (this.deferDelivery) {
|
|
1582
1709
|
failed.push(alert);
|
|
1583
1710
|
} else {
|
|
1584
|
-
if (this.triage && alert.type !== "recovered") await this.addTriage(alert);
|
|
1711
|
+
if (this.triage && alert.type !== "recovered") await this.addTriage(alert, TRIAGE_TIMEOUT_MS);
|
|
1585
1712
|
(await this.deliver(alert) ? delivered : failed).push(alert);
|
|
1586
1713
|
}
|
|
1587
1714
|
composed.push(alert);
|
|
1588
1715
|
}
|
|
1589
|
-
await this.recordDelivery(definition.name, delivered, failed, now);
|
|
1716
|
+
await this.recordDelivery(definition.name, delivered, failed, [], now);
|
|
1590
1717
|
return composed;
|
|
1591
1718
|
}
|
|
1592
|
-
/**
|
|
1593
|
-
|
|
1719
|
+
/**
|
|
1720
|
+
* Send the alerts that no channel accepted last time, once each, oldest
|
|
1721
|
+
* first. `state` is the job's state as this check left it: an alert that
|
|
1722
|
+
* no longer describes it (staleAlert) is dropped instead. Retries across a
|
|
1723
|
+
* check share RETRY_BUDGET_MS of wall-clock time; once it is spent the rest
|
|
1724
|
+
* stay queued for the next check.
|
|
1725
|
+
*/
|
|
1726
|
+
async retryUndelivered(name, state, now, budget) {
|
|
1594
1727
|
const pending = state.undelivered ?? [];
|
|
1595
1728
|
if (pending.length === 0 || isSilenced(state, now) || this.deferDelivery) return [];
|
|
1596
1729
|
const delivered = [];
|
|
1597
1730
|
const failed = [];
|
|
1731
|
+
const dropped = pending.filter((alert) => staleAlert(alert, state));
|
|
1598
1732
|
for (const alert of pending) {
|
|
1599
|
-
if (
|
|
1733
|
+
if (dropped.includes(alert)) continue;
|
|
1734
|
+
const left = RETRY_BUDGET_MS - budget.spentMs;
|
|
1735
|
+
if (left <= 0) break;
|
|
1736
|
+
const started = Date.now();
|
|
1737
|
+
if (this.triage && alert.type !== "recovered" && alert.triage === void 0) await this.addTriage(alert, Math.min(TRIAGE_TIMEOUT_MS, left));
|
|
1600
1738
|
(await this.deliver(alert) ? delivered : failed).push(alert);
|
|
1739
|
+
budget.spentMs += Math.max(0, Date.now() - started);
|
|
1601
1740
|
}
|
|
1602
|
-
await this.recordDelivery(name, delivered, failed, now);
|
|
1741
|
+
await this.recordDelivery(name, delivered, failed, dropped, now);
|
|
1603
1742
|
return delivered;
|
|
1604
1743
|
}
|
|
1605
|
-
/**
|
|
1606
|
-
|
|
1744
|
+
/**
|
|
1745
|
+
* Mark delivered alerts done, drop stale ones, and keep failed ones for the
|
|
1746
|
+
* next check. A failed alert replaces its stored copy, so a triage made on
|
|
1747
|
+
* this attempt is kept. lastAlertAt moves only on a delivery.
|
|
1748
|
+
*/
|
|
1749
|
+
async recordDelivery(name, delivered, failed, dropped, now) {
|
|
1607
1750
|
try {
|
|
1608
|
-
await this.
|
|
1609
|
-
const previous = await this.readState(name);
|
|
1751
|
+
const { result: trimmed } = await this.updateState(name, (previous) => {
|
|
1610
1752
|
const state = normalizeState(previous, name);
|
|
1611
|
-
const done = new Set(delivered.map(alertKey));
|
|
1612
|
-
const
|
|
1753
|
+
const done = new Set([...delivered, ...dropped].map(alertKey));
|
|
1754
|
+
const retried = new Map(failed.map((a) => [alertKey(a), a]));
|
|
1755
|
+
const kept = state.undelivered.filter((a) => !done.has(alertKey(a))).map((a) => retried.get(alertKey(a)) ?? a);
|
|
1613
1756
|
const known = new Set(kept.map(alertKey));
|
|
1614
1757
|
kept.push(...failed.filter((a) => !known.has(alertKey(a))));
|
|
1615
1758
|
state.undelivered = kept.slice(-MAX_UNDELIVERED);
|
|
1616
1759
|
if (delivered.length > 0) state.lastAlertAt = now;
|
|
1617
|
-
|
|
1760
|
+
return { state, result: Math.max(0, kept.length - MAX_UNDELIVERED) };
|
|
1618
1761
|
});
|
|
1762
|
+
if (trimmed > 0) {
|
|
1763
|
+
this.onError(
|
|
1764
|
+
new Error(`${trimmed} undelivered alert${trimmed === 1 ? "" : "s"} for ${name} dropped: only the newest ${MAX_UNDELIVERED} are kept for retry`),
|
|
1765
|
+
`alert queue for ${name}`
|
|
1766
|
+
);
|
|
1767
|
+
}
|
|
1619
1768
|
} catch (e) {
|
|
1620
1769
|
this.onError(e, `recording alert delivery for ${name}`);
|
|
1621
1770
|
}
|
|
@@ -1636,14 +1785,16 @@ var CronWatch = class {
|
|
|
1636
1785
|
);
|
|
1637
1786
|
return results.includes(true);
|
|
1638
1787
|
}
|
|
1639
|
-
|
|
1788
|
+
/** Sets alert.triage to the diagnosis, or to null when there is none, so it is tried once per alert. */
|
|
1789
|
+
async addTriage(alert, timeout) {
|
|
1640
1790
|
const controller = new AbortController();
|
|
1641
1791
|
try {
|
|
1642
1792
|
const recentRuns = await this.store.listRuns(alert.job, 5);
|
|
1643
|
-
const diagnosis = await withTimeout(this.triage({ alert, recentRuns, signal: controller.signal }),
|
|
1644
|
-
|
|
1793
|
+
const diagnosis = await withTimeout(this.triage({ alert, recentRuns, signal: controller.signal }), timeout);
|
|
1794
|
+
alert.triage = typeof diagnosis === "string" && diagnosis !== "" ? diagnosis : null;
|
|
1645
1795
|
} catch (e) {
|
|
1646
1796
|
controller.abort();
|
|
1797
|
+
alert.triage = null;
|
|
1647
1798
|
this.onError(e, `triage for ${alert.job}`);
|
|
1648
1799
|
}
|
|
1649
1800
|
}
|