@cronwatch/sdk 0.14.1 → 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.cjs CHANGED
@@ -722,7 +722,10 @@ function onCheck(def, stored, lastRun, state, now) {
722
722
  return { state: next, alerts, nextExpectedAt, dueAt: exp.dueAt };
723
723
  }
724
724
  function isStuck(def, run, now) {
725
- return run.status === "running" && now - run.startedAt > timeoutMs(def);
725
+ return run.status === "running" && run.finishedAt === null && now - run.startedAt > timeoutMs(def);
726
+ }
727
+ function isStranded(run, now) {
728
+ return run.status === "running" && run.finishedAt !== null && now - run.finishedAt > SEND_LEASE_MS;
726
729
  }
727
730
  function muteOpens(previous, next) {
728
731
  const muted = cloneState(next);
@@ -899,6 +902,10 @@ function constantTimeEqual(sent, secret) {
899
902
  for (let i = 0; i < sent.length; i++) diff |= sent.charCodeAt(i) ^ secret.charCodeAt(i % secret.length);
900
903
  return diff === 0;
901
904
  }
905
+ function bearerToken(header) {
906
+ const match = header === null ? null : /^Bearer\s+([\s\S]*)$/i.exec(header);
907
+ return match ? match[1] : null;
908
+ }
902
909
 
903
910
  // src/output.ts
904
911
  var OUTPUT_CAP = 16 * 1024;
@@ -992,15 +999,45 @@ function stringify(part) {
992
999
  return String(part);
993
1000
  }
994
1001
  }
1002
+ var LineWindow = class {
1003
+ items = [];
1004
+ start = 0;
1005
+ size = 0;
1006
+ get count() {
1007
+ return this.items.length - this.start;
1008
+ }
1009
+ push(line) {
1010
+ this.items.push(line);
1011
+ this.size += line.length + 1;
1012
+ }
1013
+ /** Drops the oldest lines while over `max`, always keeping the newest. */
1014
+ keepAtMost(max) {
1015
+ while (this.size > max && this.count > 1) this.drop();
1016
+ }
1017
+ /** Drops the oldest lines only while what is left still holds `min`. */
1018
+ keepAtLeast(min) {
1019
+ while (this.count > 1 && this.size - (this.items[this.start].length + 1) > min) this.drop();
1020
+ }
1021
+ join() {
1022
+ return this.items.slice(this.start).join("\n");
1023
+ }
1024
+ drop() {
1025
+ this.size -= this.items[this.start].length + 1;
1026
+ this.start += 1;
1027
+ if (this.start >= 1024 && this.start * 2 >= this.items.length) {
1028
+ this.items = this.items.slice(this.start);
1029
+ this.start = 0;
1030
+ }
1031
+ }
1032
+ };
995
1033
  function createRecorder(run) {
996
1034
  const window = OUTPUT_CAP + REDACT_EDGE + 2;
997
1035
  const copy = (text) => JSON.parse(JSON.stringify(text));
998
- const lines = [];
999
- let size = 0;
1036
+ const lines = new LineWindow();
1000
1037
  const head = [];
1001
1038
  let headSize = 0;
1002
1039
  let whole = "";
1003
- let tail2 = "";
1040
+ const raw = new LineWindow();
1004
1041
  let logged = false;
1005
1042
  const metrics = {};
1006
1043
  const controller = new AbortController();
@@ -1014,7 +1051,8 @@ function createRecorder(run) {
1014
1051
  const separator = logged ? "\n" : "";
1015
1052
  logged = true;
1016
1053
  if (whole !== null) whole = whole.length + separator.length + line.length <= 2 * OUTPUT_CAP ? whole + separator + line : null;
1017
- tail2 = copy((tail2 + separator + copy(line.slice(-OUTPUT_CAP))).slice(-OUTPUT_CAP));
1054
+ raw.push(copy(line.slice(-OUTPUT_CAP)));
1055
+ raw.keepAtLeast(OUTPUT_CAP);
1018
1056
  if (headSize < OUTPUT_CAP) {
1019
1057
  head.push(copy(line.slice(0, OUTPUT_CAP - headSize)));
1020
1058
  headSize += line.length + 1;
@@ -1024,10 +1062,7 @@ function createRecorder(run) {
1024
1062
  line = copy(line.slice(-68609));
1025
1063
  }
1026
1064
  lines.push(line);
1027
- size += line.length + 1;
1028
- while (size > window && lines.length > 1) {
1029
- size -= lines.shift().length + 1;
1030
- }
1065
+ lines.keepAtMost(window);
1031
1066
  },
1032
1067
  metric(name, value) {
1033
1068
  if (typeof value !== "number" || !Number.isFinite(value)) {
@@ -1041,10 +1076,10 @@ function createRecorder(run) {
1041
1076
  };
1042
1077
  return {
1043
1078
  context,
1044
- output: () => lines.length === 0 ? null : lines.join("\n"),
1079
+ output: () => lines.count === 0 ? null : lines.join(),
1045
1080
  expectText: () => {
1046
1081
  if (!logged) return null;
1047
- return whole ?? head.join("\n").slice(0, OUTPUT_CAP) + "\n" + tail2;
1082
+ return whole ?? head.join("\n").slice(0, OUTPUT_CAP) + "\n" + raw.join().slice(-OUTPUT_CAP);
1048
1083
  },
1049
1084
  metrics: () => ({ ...metrics }),
1050
1085
  abort: () => controller.abort()
@@ -1218,7 +1253,7 @@ function staticAsset(path, base) {
1218
1253
  }
1219
1254
 
1220
1255
  // src/version.ts
1221
- var VERSION = "0.14.1";
1256
+ var VERSION = "0.14.3";
1222
1257
  var API_VERSION = 1;
1223
1258
 
1224
1259
  // src/routes/timeline.ts
@@ -1985,10 +2020,6 @@ function forwardedOrigin(request, url) {
1985
2020
  return url.origin;
1986
2021
  }
1987
2022
  }
1988
- function bearerToken(header) {
1989
- const match = header === null ? null : /^Bearer\s+([\s\S]*)$/i.exec(header);
1990
- return match ? match[1] : null;
1991
- }
1992
2023
  function signInReturn(referer, publicOrigin, base) {
1993
2024
  if (referer === null || !referer.startsWith(publicOrigin + "/")) return `${base}/`;
1994
2025
  let url;
@@ -2157,13 +2188,14 @@ function buildRoutes(cw, options = {}) {
2157
2188
  return wantsHtml ? html(messagePage("Bad request", "The path is not valid.", base), 400) : api({ ok: false, error: "Bad path" }, 400);
2158
2189
  }
2159
2190
  const parts = decoded;
2160
- if (method === "GET" && path === "/") {
2191
+ const reads = method === "GET" || method === "HEAD";
2192
+ if (reads && path === "/") {
2161
2193
  const entries = await cw.jobsWithRuns(BOARD_PAGE_RUNS);
2162
2194
  const now = cw.now();
2163
2195
  const runsByJob = new Map(entries.map((entry) => [entry.job.name, entry.runs]));
2164
2196
  return html(dashboardPage(entries.map((entry) => entry.job), runsByJob, now, base, null, await boardLanes(cw, entries, now)));
2165
2197
  }
2166
- if (method === "GET" && parts[0] === "jobs" && parts.length === 2) {
2198
+ if (reads && parts[0] === "jobs" && parts.length === 2) {
2167
2199
  const job = await cw.jobSummary(parts[1]);
2168
2200
  if (!job) return html(messagePage("No such job", `${parts[1]} is not in the store.`, base), 404);
2169
2201
  const now = cw.now();
@@ -2199,15 +2231,15 @@ function buildRoutes(cw, options = {}) {
2199
2231
  }
2200
2232
  if (parts[0] === "api") {
2201
2233
  const rest = parts.slice(1);
2202
- if (method === "GET" && rest.length === 0) {
2234
+ if (reads && rest.length === 0) {
2203
2235
  return api({ ok: true, library: LIBRARY, language: LANGUAGE, version: VERSION, api: API_VERSION });
2204
2236
  }
2205
- if (method === "GET" && rest[0] === "jobs" && rest.length === 1) {
2237
+ if (reads && rest[0] === "jobs" && rest.length === 1) {
2206
2238
  return api({ ok: true, jobs: await cw.jobs() });
2207
2239
  }
2208
2240
  if (rest[0] === "jobs" && rest.length === 2) {
2209
2241
  const name = rest[1];
2210
- if (method === "GET") {
2242
+ if (reads) {
2211
2243
  const job = await cw.jobSummary(name);
2212
2244
  if (!job) return api({ ok: false, error: "No such job" }, 404);
2213
2245
  return api({ ok: true, job, runs: await cw.runs(name, runsLimit(url.searchParams.get("runs"))) });
@@ -2248,7 +2280,7 @@ function buildRoutes(cw, options = {}) {
2248
2280
  return api({ ok: true, ...result });
2249
2281
  }
2250
2282
  }
2251
- if (method === "GET" && rest[0] === "runs" && rest.length === 2) {
2283
+ if (reads && rest[0] === "runs" && rest.length === 2) {
2252
2284
  const run = await cw.getRun(rest[1]);
2253
2285
  return run ? api({ ok: true, run }) : api({ ok: false, error: "No such run" }, 404);
2254
2286
  }
@@ -2263,7 +2295,8 @@ function buildRoutes(cw, options = {}) {
2263
2295
  const path = stripBase(url.pathname, base);
2264
2296
  if (path === null) return html(messagePage("Not found", url.pathname, base), 404);
2265
2297
  wantsHtml = !path.startsWith("/api");
2266
- return await serve(request, url, path, wantsHtml);
2298
+ const response = await serve(request, url, path, wantsHtml);
2299
+ return request.method.toUpperCase() === "HEAD" ? new Response(null, { status: response.status, headers: response.headers }) : response;
2267
2300
  } catch (e) {
2268
2301
  try {
2269
2302
  cw.onError(e, "routes");
@@ -2410,6 +2443,7 @@ var RETRY_BUDGET_MS = 2e4;
2410
2443
  var STATE_ATTEMPTS = 10;
2411
2444
  var HISTORY_PAGE = BASELINE_WINDOW + 5;
2412
2445
  var HISTORY_MAX = 200;
2446
+ var STRANDED_ERROR = "Imported, but its finish was never written; marked as timed out";
2413
2447
  function validateDefinition(def) {
2414
2448
  const name = def.name;
2415
2449
  if (def.schedule !== void 0) {
@@ -2586,8 +2620,8 @@ var Cronwatch = class {
2586
2620
  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);
2587
2621
  }
2588
2622
  if (secret) {
2589
- const header = request.headers.get("authorization") ?? "";
2590
- if (!constantTimeEqual(header, `Bearer ${secret}`)) {
2623
+ const sent = bearerToken(request.headers.get("authorization"));
2624
+ if (sent === null || !constantTimeEqual(sent, secret)) {
2591
2625
  return json({ ok: false, error: "Unauthorized" }, 401);
2592
2626
  }
2593
2627
  }
@@ -3128,6 +3162,17 @@ ${id}`;
3128
3162
  throw new Error(`recordRun: run ids must be 1 to ${MAX_RUN_ID} characters (got ${got}; job "${input.job}")`);
3129
3163
  }
3130
3164
  if (input.id.includes("\0")) throw new Error(`recordRun: run ids cannot contain a NUL character (job "${input.job}")`);
3165
+ const where = `job "${input.job}", run "${input.id}"`;
3166
+ if (!["running", "ok", "failed", "timeout"].includes(input.status)) {
3167
+ throw new Error(`recordRun: status must be "running", "ok", "failed", or "timeout" (${where})`);
3168
+ }
3169
+ if (!Number.isFinite(input.startedAt)) throw new Error(`recordRun: startedAt must be a finite number (${where})`);
3170
+ if (input.finishedAt !== null && !Number.isFinite(input.finishedAt)) throw new Error(`recordRun: finishedAt must be a finite number or null (${where})`);
3171
+ if (input.durationMs !== null && !Number.isFinite(input.durationMs)) throw new Error(`recordRun: durationMs must be a finite number or null (${where})`);
3172
+ for (const field of ["error", "output"]) {
3173
+ if (input[field] !== null && typeof input[field] !== "string") throw new Error(`recordRun: ${field} must be a string or null (${where})`);
3174
+ }
3175
+ if (typeof input.trigger !== "string") throw new Error(`recordRun: trigger must be a string (${where})`);
3131
3176
  for (const [metric, value] of Object.entries(input.metrics ?? {})) {
3132
3177
  if (typeof value !== "number" || !Number.isFinite(value)) {
3133
3178
  throw new Error(`recordRun: metric "${metric}" must be a finite number (job "${input.job}", run "${input.id}")`);
@@ -3148,17 +3193,16 @@ ${id}`;
3148
3193
  const definition = toStored(declared);
3149
3194
  const stored = await this.store.getRun(run.id);
3150
3195
  if (stored) return this.recordOver(definition, stored, run, evaluate);
3196
+ const inserted = evaluate ? { ...run, status: "running", finishedAt: run.status === "running" ? null : this.now() } : run;
3151
3197
  try {
3152
- await this.store.insertRun(run);
3198
+ await this.store.insertRun(inserted);
3153
3199
  } catch (e) {
3154
3200
  const again = await this.store.getRun(run.id).catch(() => null);
3155
3201
  if (again) return this.recordOver(definition, again, run, evaluate);
3156
3202
  throw e;
3157
3203
  }
3158
3204
  if (!evaluate) return [];
3159
- await this.updateState(run.job, (before) => ({ state: holdRun(onRunStart(definition, run, before, this.now()), run.id), result: void 0 }));
3160
- if (run.status === "running") return [];
3161
- return this.finishRun(definition, run, this.now(), true);
3205
+ return this.recordOver(definition, inserted, run, true);
3162
3206
  }
3163
3207
  /** recordRun() for a run already stored. */
3164
3208
  async recordOver(definition, stored, run, evaluate) {
@@ -3166,7 +3210,15 @@ ${id}`;
3166
3210
  this.report(new Error(`run ${run.id} of ${run.job} belongs to job "${stored.job}"; ignored`), `recording ${run.job}`);
3167
3211
  return [];
3168
3212
  }
3169
- if (stored.status !== "running" && stored.status !== "timeout" || run.status === "running") return [];
3213
+ if (stored.status !== "running" && stored.status !== "timeout") return [];
3214
+ if (evaluate && stored.status === "running") {
3215
+ await this.updateState(run.job, async (before) => {
3216
+ const current = await this.store.getRun(run.id);
3217
+ if (!current || current.job !== run.job || current.status !== "running" || before.pendingRuns?.includes(run.id)) return { state: before, result: void 0 };
3218
+ return { state: holdRun(onRunStart(definition, run, before, this.now()), run.id), result: void 0 };
3219
+ });
3220
+ }
3221
+ if (run.status === "running") return [];
3170
3222
  const tracked = (await this.readState(run.job)).pendingRuns?.includes(run.id) ?? false;
3171
3223
  const claim = await this.claimFinish(run);
3172
3224
  if ("ignored" in claim) {
@@ -3266,14 +3318,20 @@ ${id}`;
3266
3318
  const stored = found ? this.readJob(found) : null;
3267
3319
  if (stored) this.evaluable(stored);
3268
3320
  const definition = declared ? toStored(declared) : stored?.definition;
3269
- if (!definition || !isStuck(definition, listed, now)) continue;
3321
+ const stranded = isStranded(listed, now);
3322
+ if (!definition || !(stranded || isStuck(definition, listed, now))) continue;
3270
3323
  const run = await this.store.getRun(listed.id);
3271
3324
  if (!run || run.status !== "running" || run.job !== listed.job) continue;
3272
- const timeout = timeoutMs(definition);
3273
3325
  run.status = "timeout";
3274
- run.finishedAt = now;
3275
- run.durationMs = runDuration(run.startedAt, now);
3276
- run.error = `Still running after ${formatDuration(timeout)}; marked as timed out`;
3326
+ if (stranded) {
3327
+ run.durationMs ??= runDuration(run.startedAt, run.finishedAt);
3328
+ run.error = STRANDED_ERROR + (run.error === null ? "" : `
3329
+ ${run.error}`);
3330
+ } else {
3331
+ run.finishedAt = now;
3332
+ run.durationMs = runDuration(run.startedAt, now);
3333
+ run.error = `Still running after ${formatDuration(timeoutMs(definition))}; marked as timed out`;
3334
+ }
3277
3335
  if (!await this.writeRunIf(run, ["running"])) continue;
3278
3336
  alerts.push(...await this.finishRun(definition, run, now));
3279
3337
  } catch (e) {