@cronwatch/sdk 0.14.2 → 0.14.3

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/dist/index.d.cts CHANGED
@@ -11,7 +11,7 @@ export { a as AlertChannel, d as AlertDetails, e as AlertType, B as BudgetBreach
11
11
  declare function memory(): Store & Required<Pick<Store, "lastRun">>;
12
12
 
13
13
  /** This release of @cronwatch/sdk. scripts/release.mjs keeps it in step with package.json. */
14
- declare const VERSION = "0.14.2";
14
+ declare const VERSION = "0.14.3";
15
15
 
16
16
  /**
17
17
  * "15m" -> 900000. Accepts a plain number of milliseconds, and compound
package/dist/index.d.ts CHANGED
@@ -11,7 +11,7 @@ export { a as AlertChannel, d as AlertDetails, e as AlertType, B as BudgetBreach
11
11
  declare function memory(): Store & Required<Pick<Store, "lastRun">>;
12
12
 
13
13
  /** This release of @cronwatch/sdk. scripts/release.mjs keeps it in step with package.json. */
14
- declare const VERSION = "0.14.2";
14
+ declare const VERSION = "0.14.3";
15
15
 
16
16
  /**
17
17
  * "15m" -> 900000. Accepts a plain number of milliseconds, and compound
package/dist/index.js CHANGED
@@ -722,6 +722,9 @@ function onCheck(def, stored, lastRun, state, now) {
722
722
  function isStuck(def, run, now) {
723
723
  return run.status === "running" && run.finishedAt === null && now - run.startedAt > timeoutMs(def);
724
724
  }
725
+ function isStranded(run, now) {
726
+ return run.status === "running" && run.finishedAt !== null && now - run.finishedAt > SEND_LEASE_MS;
727
+ }
725
728
  function muteOpens(previous, next) {
726
729
  const muted = cloneState(next);
727
730
  for (const condition of openConditions(muted)) {
@@ -897,6 +900,10 @@ function constantTimeEqual(sent, secret) {
897
900
  for (let i = 0; i < sent.length; i++) diff |= sent.charCodeAt(i) ^ secret.charCodeAt(i % secret.length);
898
901
  return diff === 0;
899
902
  }
903
+ function bearerToken(header) {
904
+ const match = header === null ? null : /^Bearer\s+([\s\S]*)$/i.exec(header);
905
+ return match ? match[1] : null;
906
+ }
900
907
 
901
908
  // src/output.ts
902
909
  var OUTPUT_CAP = 16 * 1024;
@@ -990,15 +997,45 @@ function stringify(part) {
990
997
  return String(part);
991
998
  }
992
999
  }
1000
+ var LineWindow = class {
1001
+ items = [];
1002
+ start = 0;
1003
+ size = 0;
1004
+ get count() {
1005
+ return this.items.length - this.start;
1006
+ }
1007
+ push(line) {
1008
+ this.items.push(line);
1009
+ this.size += line.length + 1;
1010
+ }
1011
+ /** Drops the oldest lines while over `max`, always keeping the newest. */
1012
+ keepAtMost(max) {
1013
+ while (this.size > max && this.count > 1) this.drop();
1014
+ }
1015
+ /** Drops the oldest lines only while what is left still holds `min`. */
1016
+ keepAtLeast(min) {
1017
+ while (this.count > 1 && this.size - (this.items[this.start].length + 1) > min) this.drop();
1018
+ }
1019
+ join() {
1020
+ return this.items.slice(this.start).join("\n");
1021
+ }
1022
+ drop() {
1023
+ this.size -= this.items[this.start].length + 1;
1024
+ this.start += 1;
1025
+ if (this.start >= 1024 && this.start * 2 >= this.items.length) {
1026
+ this.items = this.items.slice(this.start);
1027
+ this.start = 0;
1028
+ }
1029
+ }
1030
+ };
993
1031
  function createRecorder(run) {
994
1032
  const window = OUTPUT_CAP + REDACT_EDGE + 2;
995
1033
  const copy = (text) => JSON.parse(JSON.stringify(text));
996
- const lines = [];
997
- let size = 0;
1034
+ const lines = new LineWindow();
998
1035
  const head = [];
999
1036
  let headSize = 0;
1000
1037
  let whole = "";
1001
- let tail2 = "";
1038
+ const raw = new LineWindow();
1002
1039
  let logged = false;
1003
1040
  const metrics = {};
1004
1041
  const controller = new AbortController();
@@ -1012,7 +1049,8 @@ function createRecorder(run) {
1012
1049
  const separator = logged ? "\n" : "";
1013
1050
  logged = true;
1014
1051
  if (whole !== null) whole = whole.length + separator.length + line.length <= 2 * OUTPUT_CAP ? whole + separator + line : null;
1015
- tail2 = copy((tail2 + separator + copy(line.slice(-OUTPUT_CAP))).slice(-OUTPUT_CAP));
1052
+ raw.push(copy(line.slice(-OUTPUT_CAP)));
1053
+ raw.keepAtLeast(OUTPUT_CAP);
1016
1054
  if (headSize < OUTPUT_CAP) {
1017
1055
  head.push(copy(line.slice(0, OUTPUT_CAP - headSize)));
1018
1056
  headSize += line.length + 1;
@@ -1022,10 +1060,7 @@ function createRecorder(run) {
1022
1060
  line = copy(line.slice(-68609));
1023
1061
  }
1024
1062
  lines.push(line);
1025
- size += line.length + 1;
1026
- while (size > window && lines.length > 1) {
1027
- size -= lines.shift().length + 1;
1028
- }
1063
+ lines.keepAtMost(window);
1029
1064
  },
1030
1065
  metric(name, value) {
1031
1066
  if (typeof value !== "number" || !Number.isFinite(value)) {
@@ -1039,10 +1074,10 @@ function createRecorder(run) {
1039
1074
  };
1040
1075
  return {
1041
1076
  context,
1042
- output: () => lines.length === 0 ? null : lines.join("\n"),
1077
+ output: () => lines.count === 0 ? null : lines.join(),
1043
1078
  expectText: () => {
1044
1079
  if (!logged) return null;
1045
- return whole ?? head.join("\n").slice(0, OUTPUT_CAP) + "\n" + tail2;
1080
+ return whole ?? head.join("\n").slice(0, OUTPUT_CAP) + "\n" + raw.join().slice(-OUTPUT_CAP);
1046
1081
  },
1047
1082
  metrics: () => ({ ...metrics }),
1048
1083
  abort: () => controller.abort()
@@ -1216,7 +1251,7 @@ function staticAsset(path, base) {
1216
1251
  }
1217
1252
 
1218
1253
  // src/version.ts
1219
- var VERSION = "0.14.2";
1254
+ var VERSION = "0.14.3";
1220
1255
  var API_VERSION = 1;
1221
1256
 
1222
1257
  // src/routes/timeline.ts
@@ -1983,10 +2018,6 @@ function forwardedOrigin(request, url) {
1983
2018
  return url.origin;
1984
2019
  }
1985
2020
  }
1986
- function bearerToken(header) {
1987
- const match = header === null ? null : /^Bearer\s+([\s\S]*)$/i.exec(header);
1988
- return match ? match[1] : null;
1989
- }
1990
2021
  function signInReturn(referer, publicOrigin, base) {
1991
2022
  if (referer === null || !referer.startsWith(publicOrigin + "/")) return `${base}/`;
1992
2023
  let url;
@@ -2155,13 +2186,14 @@ function buildRoutes(cw, options = {}) {
2155
2186
  return wantsHtml ? html(messagePage("Bad request", "The path is not valid.", base), 400) : api({ ok: false, error: "Bad path" }, 400);
2156
2187
  }
2157
2188
  const parts = decoded;
2158
- if (method === "GET" && path === "/") {
2189
+ const reads = method === "GET" || method === "HEAD";
2190
+ if (reads && path === "/") {
2159
2191
  const entries = await cw.jobsWithRuns(BOARD_PAGE_RUNS);
2160
2192
  const now = cw.now();
2161
2193
  const runsByJob = new Map(entries.map((entry) => [entry.job.name, entry.runs]));
2162
2194
  return html(dashboardPage(entries.map((entry) => entry.job), runsByJob, now, base, null, await boardLanes(cw, entries, now)));
2163
2195
  }
2164
- if (method === "GET" && parts[0] === "jobs" && parts.length === 2) {
2196
+ if (reads && parts[0] === "jobs" && parts.length === 2) {
2165
2197
  const job = await cw.jobSummary(parts[1]);
2166
2198
  if (!job) return html(messagePage("No such job", `${parts[1]} is not in the store.`, base), 404);
2167
2199
  const now = cw.now();
@@ -2197,15 +2229,15 @@ function buildRoutes(cw, options = {}) {
2197
2229
  }
2198
2230
  if (parts[0] === "api") {
2199
2231
  const rest = parts.slice(1);
2200
- if (method === "GET" && rest.length === 0) {
2232
+ if (reads && rest.length === 0) {
2201
2233
  return api({ ok: true, library: LIBRARY, language: LANGUAGE, version: VERSION, api: API_VERSION });
2202
2234
  }
2203
- if (method === "GET" && rest[0] === "jobs" && rest.length === 1) {
2235
+ if (reads && rest[0] === "jobs" && rest.length === 1) {
2204
2236
  return api({ ok: true, jobs: await cw.jobs() });
2205
2237
  }
2206
2238
  if (rest[0] === "jobs" && rest.length === 2) {
2207
2239
  const name = rest[1];
2208
- if (method === "GET") {
2240
+ if (reads) {
2209
2241
  const job = await cw.jobSummary(name);
2210
2242
  if (!job) return api({ ok: false, error: "No such job" }, 404);
2211
2243
  return api({ ok: true, job, runs: await cw.runs(name, runsLimit(url.searchParams.get("runs"))) });
@@ -2246,7 +2278,7 @@ function buildRoutes(cw, options = {}) {
2246
2278
  return api({ ok: true, ...result });
2247
2279
  }
2248
2280
  }
2249
- if (method === "GET" && rest[0] === "runs" && rest.length === 2) {
2281
+ if (reads && rest[0] === "runs" && rest.length === 2) {
2250
2282
  const run = await cw.getRun(rest[1]);
2251
2283
  return run ? api({ ok: true, run }) : api({ ok: false, error: "No such run" }, 404);
2252
2284
  }
@@ -2261,7 +2293,8 @@ function buildRoutes(cw, options = {}) {
2261
2293
  const path = stripBase(url.pathname, base);
2262
2294
  if (path === null) return html(messagePage("Not found", url.pathname, base), 404);
2263
2295
  wantsHtml = !path.startsWith("/api");
2264
- return await serve(request, url, path, wantsHtml);
2296
+ const response = await serve(request, url, path, wantsHtml);
2297
+ return request.method.toUpperCase() === "HEAD" ? new Response(null, { status: response.status, headers: response.headers }) : response;
2265
2298
  } catch (e) {
2266
2299
  try {
2267
2300
  cw.onError(e, "routes");
@@ -2408,6 +2441,7 @@ var RETRY_BUDGET_MS = 2e4;
2408
2441
  var STATE_ATTEMPTS = 10;
2409
2442
  var HISTORY_PAGE = BASELINE_WINDOW + 5;
2410
2443
  var HISTORY_MAX = 200;
2444
+ var STRANDED_ERROR = "Imported, but its finish was never written; marked as timed out";
2411
2445
  function validateDefinition(def) {
2412
2446
  const name = def.name;
2413
2447
  if (def.schedule !== void 0) {
@@ -2584,8 +2618,8 @@ var Cronwatch = class {
2584
2618
  return json({ ok: false, error: "CRON_SECRET is not set, so this job will not run for an unauthenticated request. Set it, or pass secret: null to handler() to allow anyone." }, 503);
2585
2619
  }
2586
2620
  if (secret) {
2587
- const header = request.headers.get("authorization") ?? "";
2588
- if (!constantTimeEqual(header, `Bearer ${secret}`)) {
2621
+ const sent = bearerToken(request.headers.get("authorization"));
2622
+ if (sent === null || !constantTimeEqual(sent, secret)) {
2589
2623
  return json({ ok: false, error: "Unauthorized" }, 401);
2590
2624
  }
2591
2625
  }
@@ -3126,6 +3160,17 @@ ${id}`;
3126
3160
  throw new Error(`recordRun: run ids must be 1 to ${MAX_RUN_ID} characters (got ${got}; job "${input.job}")`);
3127
3161
  }
3128
3162
  if (input.id.includes("\0")) throw new Error(`recordRun: run ids cannot contain a NUL character (job "${input.job}")`);
3163
+ const where = `job "${input.job}", run "${input.id}"`;
3164
+ if (!["running", "ok", "failed", "timeout"].includes(input.status)) {
3165
+ throw new Error(`recordRun: status must be "running", "ok", "failed", or "timeout" (${where})`);
3166
+ }
3167
+ if (!Number.isFinite(input.startedAt)) throw new Error(`recordRun: startedAt must be a finite number (${where})`);
3168
+ if (input.finishedAt !== null && !Number.isFinite(input.finishedAt)) throw new Error(`recordRun: finishedAt must be a finite number or null (${where})`);
3169
+ if (input.durationMs !== null && !Number.isFinite(input.durationMs)) throw new Error(`recordRun: durationMs must be a finite number or null (${where})`);
3170
+ for (const field of ["error", "output"]) {
3171
+ if (input[field] !== null && typeof input[field] !== "string") throw new Error(`recordRun: ${field} must be a string or null (${where})`);
3172
+ }
3173
+ if (typeof input.trigger !== "string") throw new Error(`recordRun: trigger must be a string (${where})`);
3129
3174
  for (const [metric, value] of Object.entries(input.metrics ?? {})) {
3130
3175
  if (typeof value !== "number" || !Number.isFinite(value)) {
3131
3176
  throw new Error(`recordRun: metric "${metric}" must be a finite number (job "${input.job}", run "${input.id}")`);
@@ -3146,7 +3191,7 @@ ${id}`;
3146
3191
  const definition = toStored(declared);
3147
3192
  const stored = await this.store.getRun(run.id);
3148
3193
  if (stored) return this.recordOver(definition, stored, run, evaluate);
3149
- const inserted = evaluate ? { ...run, status: "running" } : run;
3194
+ const inserted = evaluate ? { ...run, status: "running", finishedAt: run.status === "running" ? null : this.now() } : run;
3150
3195
  try {
3151
3196
  await this.store.insertRun(inserted);
3152
3197
  } catch (e) {
@@ -3271,14 +3316,20 @@ ${id}`;
3271
3316
  const stored = found ? this.readJob(found) : null;
3272
3317
  if (stored) this.evaluable(stored);
3273
3318
  const definition = declared ? toStored(declared) : stored?.definition;
3274
- if (!definition || !isStuck(definition, listed, now)) continue;
3319
+ const stranded = isStranded(listed, now);
3320
+ if (!definition || !(stranded || isStuck(definition, listed, now))) continue;
3275
3321
  const run = await this.store.getRun(listed.id);
3276
3322
  if (!run || run.status !== "running" || run.job !== listed.job) continue;
3277
- const timeout = timeoutMs(definition);
3278
3323
  run.status = "timeout";
3279
- run.finishedAt = now;
3280
- run.durationMs = runDuration(run.startedAt, now);
3281
- run.error = `Still running after ${formatDuration(timeout)}; marked as timed out`;
3324
+ if (stranded) {
3325
+ run.durationMs ??= runDuration(run.startedAt, run.finishedAt);
3326
+ run.error = STRANDED_ERROR + (run.error === null ? "" : `
3327
+ ${run.error}`);
3328
+ } else {
3329
+ run.finishedAt = now;
3330
+ run.durationMs = runDuration(run.startedAt, now);
3331
+ run.error = `Still running after ${formatDuration(timeoutMs(definition))}; marked as timed out`;
3332
+ }
3282
3333
  if (!await this.writeRunIf(run, ["running"])) continue;
3283
3334
  alerts.push(...await this.finishRun(definition, run, now));
3284
3335
  } catch (e) {