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