@cronwatch/sdk 0.14.2 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -636,7 +636,6 @@ function catchesUp(def, run, now) {
636
636
  function onRunStart(def, run, state, now) {
637
637
  const next = cloneState(state);
638
638
  if (catchesUp(def, run, now)) closeCondition(next, "missed");
639
- closeCondition(next, "stuck");
640
639
  return next;
641
640
  }
642
641
  function onRunFinish(def, run, state, history, now) {
@@ -724,6 +723,9 @@ function onCheck(def, stored, lastRun, state, now) {
724
723
  function isStuck(def, run, now) {
725
724
  return run.status === "running" && run.finishedAt === null && now - run.startedAt > timeoutMs(def);
726
725
  }
726
+ function isStranded(run, now) {
727
+ return run.status === "running" && run.finishedAt !== null && now - run.finishedAt > SEND_LEASE_MS;
728
+ }
727
729
  function muteOpens(previous, next) {
728
730
  const muted = cloneState(next);
729
731
  for (const condition of openConditions(muted)) {
@@ -899,6 +901,10 @@ function constantTimeEqual(sent, secret) {
899
901
  for (let i = 0; i < sent.length; i++) diff |= sent.charCodeAt(i) ^ secret.charCodeAt(i % secret.length);
900
902
  return diff === 0;
901
903
  }
904
+ function bearerToken(header) {
905
+ const match = header === null ? null : /^Bearer\s+([\s\S]*)$/i.exec(header);
906
+ return match ? match[1] : null;
907
+ }
902
908
 
903
909
  // src/output.ts
904
910
  var OUTPUT_CAP = 16 * 1024;
@@ -992,15 +998,45 @@ function stringify(part) {
992
998
  return String(part);
993
999
  }
994
1000
  }
1001
+ var LineWindow = class {
1002
+ items = [];
1003
+ start = 0;
1004
+ size = 0;
1005
+ get count() {
1006
+ return this.items.length - this.start;
1007
+ }
1008
+ push(line) {
1009
+ this.items.push(line);
1010
+ this.size += line.length + 1;
1011
+ }
1012
+ /** Drops the oldest lines while over `max`, always keeping the newest. */
1013
+ keepAtMost(max) {
1014
+ while (this.size > max && this.count > 1) this.drop();
1015
+ }
1016
+ /** Drops the oldest lines only while what is left still holds `min`. */
1017
+ keepAtLeast(min) {
1018
+ while (this.count > 1 && this.size - (this.items[this.start].length + 1) > min) this.drop();
1019
+ }
1020
+ join() {
1021
+ return this.items.slice(this.start).join("\n");
1022
+ }
1023
+ drop() {
1024
+ this.size -= this.items[this.start].length + 1;
1025
+ this.start += 1;
1026
+ if (this.start >= 1024 && this.start * 2 >= this.items.length) {
1027
+ this.items = this.items.slice(this.start);
1028
+ this.start = 0;
1029
+ }
1030
+ }
1031
+ };
995
1032
  function createRecorder(run) {
996
1033
  const window = OUTPUT_CAP + REDACT_EDGE + 2;
997
1034
  const copy = (text) => JSON.parse(JSON.stringify(text));
998
- const lines = [];
999
- let size = 0;
1035
+ const lines = new LineWindow();
1000
1036
  const head = [];
1001
1037
  let headSize = 0;
1002
1038
  let whole = "";
1003
- let tail2 = "";
1039
+ const raw = new LineWindow();
1004
1040
  let logged = false;
1005
1041
  const metrics = {};
1006
1042
  const controller = new AbortController();
@@ -1014,7 +1050,8 @@ function createRecorder(run) {
1014
1050
  const separator = logged ? "\n" : "";
1015
1051
  logged = true;
1016
1052
  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));
1053
+ raw.push(copy(line.slice(-OUTPUT_CAP)));
1054
+ raw.keepAtLeast(OUTPUT_CAP);
1018
1055
  if (headSize < OUTPUT_CAP) {
1019
1056
  head.push(copy(line.slice(0, OUTPUT_CAP - headSize)));
1020
1057
  headSize += line.length + 1;
@@ -1024,10 +1061,7 @@ function createRecorder(run) {
1024
1061
  line = copy(line.slice(-68609));
1025
1062
  }
1026
1063
  lines.push(line);
1027
- size += line.length + 1;
1028
- while (size > window && lines.length > 1) {
1029
- size -= lines.shift().length + 1;
1030
- }
1064
+ lines.keepAtMost(window);
1031
1065
  },
1032
1066
  metric(name, value) {
1033
1067
  if (typeof value !== "number" || !Number.isFinite(value)) {
@@ -1041,10 +1075,10 @@ function createRecorder(run) {
1041
1075
  };
1042
1076
  return {
1043
1077
  context,
1044
- output: () => lines.length === 0 ? null : lines.join("\n"),
1078
+ output: () => lines.count === 0 ? null : lines.join(),
1045
1079
  expectText: () => {
1046
1080
  if (!logged) return null;
1047
- return whole ?? head.join("\n").slice(0, OUTPUT_CAP) + "\n" + tail2;
1081
+ return whole ?? head.join("\n").slice(0, OUTPUT_CAP) + "\n" + raw.join().slice(-OUTPUT_CAP);
1048
1082
  },
1049
1083
  metrics: () => ({ ...metrics }),
1050
1084
  abort: () => controller.abort()
@@ -1218,7 +1252,7 @@ function staticAsset(path, base) {
1218
1252
  }
1219
1253
 
1220
1254
  // src/version.ts
1221
- var VERSION = "0.14.2";
1255
+ var VERSION = "0.15.0";
1222
1256
  var API_VERSION = 1;
1223
1257
 
1224
1258
  // src/routes/timeline.ts
@@ -1985,10 +2019,6 @@ function forwardedOrigin(request, url) {
1985
2019
  return url.origin;
1986
2020
  }
1987
2021
  }
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
2022
  function signInReturn(referer, publicOrigin, base) {
1993
2023
  if (referer === null || !referer.startsWith(publicOrigin + "/")) return `${base}/`;
1994
2024
  let url;
@@ -2157,13 +2187,14 @@ function buildRoutes(cw, options = {}) {
2157
2187
  return wantsHtml ? html(messagePage("Bad request", "The path is not valid.", base), 400) : api({ ok: false, error: "Bad path" }, 400);
2158
2188
  }
2159
2189
  const parts = decoded;
2160
- if (method === "GET" && path === "/") {
2190
+ const reads = method === "GET" || method === "HEAD";
2191
+ if (reads && path === "/") {
2161
2192
  const entries = await cw.jobsWithRuns(BOARD_PAGE_RUNS);
2162
2193
  const now = cw.now();
2163
2194
  const runsByJob = new Map(entries.map((entry) => [entry.job.name, entry.runs]));
2164
2195
  return html(dashboardPage(entries.map((entry) => entry.job), runsByJob, now, base, null, await boardLanes(cw, entries, now)));
2165
2196
  }
2166
- if (method === "GET" && parts[0] === "jobs" && parts.length === 2) {
2197
+ if (reads && parts[0] === "jobs" && parts.length === 2) {
2167
2198
  const job = await cw.jobSummary(parts[1]);
2168
2199
  if (!job) return html(messagePage("No such job", `${parts[1]} is not in the store.`, base), 404);
2169
2200
  const now = cw.now();
@@ -2199,15 +2230,15 @@ function buildRoutes(cw, options = {}) {
2199
2230
  }
2200
2231
  if (parts[0] === "api") {
2201
2232
  const rest = parts.slice(1);
2202
- if (method === "GET" && rest.length === 0) {
2233
+ if (reads && rest.length === 0) {
2203
2234
  return api({ ok: true, library: LIBRARY, language: LANGUAGE, version: VERSION, api: API_VERSION });
2204
2235
  }
2205
- if (method === "GET" && rest[0] === "jobs" && rest.length === 1) {
2236
+ if (reads && rest[0] === "jobs" && rest.length === 1) {
2206
2237
  return api({ ok: true, jobs: await cw.jobs() });
2207
2238
  }
2208
2239
  if (rest[0] === "jobs" && rest.length === 2) {
2209
2240
  const name = rest[1];
2210
- if (method === "GET") {
2241
+ if (reads) {
2211
2242
  const job = await cw.jobSummary(name);
2212
2243
  if (!job) return api({ ok: false, error: "No such job" }, 404);
2213
2244
  return api({ ok: true, job, runs: await cw.runs(name, runsLimit(url.searchParams.get("runs"))) });
@@ -2248,7 +2279,7 @@ function buildRoutes(cw, options = {}) {
2248
2279
  return api({ ok: true, ...result });
2249
2280
  }
2250
2281
  }
2251
- if (method === "GET" && rest[0] === "runs" && rest.length === 2) {
2282
+ if (reads && rest[0] === "runs" && rest.length === 2) {
2252
2283
  const run = await cw.getRun(rest[1]);
2253
2284
  return run ? api({ ok: true, run }) : api({ ok: false, error: "No such run" }, 404);
2254
2285
  }
@@ -2263,7 +2294,8 @@ function buildRoutes(cw, options = {}) {
2263
2294
  const path = stripBase(url.pathname, base);
2264
2295
  if (path === null) return html(messagePage("Not found", url.pathname, base), 404);
2265
2296
  wantsHtml = !path.startsWith("/api");
2266
- return await serve(request, url, path, wantsHtml);
2297
+ const response = await serve(request, url, path, wantsHtml);
2298
+ return request.method.toUpperCase() === "HEAD" ? new Response(null, { status: response.status, headers: response.headers }) : response;
2267
2299
  } catch (e) {
2268
2300
  try {
2269
2301
  cw.onError(e, "routes");
@@ -2407,9 +2439,11 @@ var TRIAGE_TIMEOUT_MS = 25e3;
2407
2439
  var CHANNEL_TIMEOUT_MS = 15e3;
2408
2440
  var PRUNE_INTERVAL_MS = 60 * 6e4;
2409
2441
  var RETRY_BUDGET_MS = 2e4;
2442
+ var START_DEADLINE_MS = 5e3;
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
  }
@@ -2649,8 +2683,8 @@ var Cronwatch = class {
2649
2683
  /**
2650
2684
  * Writes the declaration of `definition`'s name as it stands, unless the
2651
2685
  * store has it. A handle kept from an earlier declaration writes the one
2652
- * that replaced it, never its own over it, and one forgotten since writes
2653
- * its own. The writes of one name take turns, in the order they were asked
2686
+ * that replaced it, never its own over it, and one forgotten since
2687
+ * declares the job again as it was last declared here. The writes of one name take turns, in the order they were asked
2654
2688
  * for, so one still under way cannot land after a later one; and a name
2655
2689
  * declared again while its write was under way is still to be written. A
2656
2690
  * name is marked as written only while that same declaration stands, so a
@@ -2659,9 +2693,12 @@ var Cronwatch = class {
2659
2693
  *
2660
2694
  * With `confirm`, as a run starts, a name already written is read back:
2661
2695
  * another process may have forgotten the job since, and a job still
2662
- * declared here comes back on its next run.
2696
+ * declared here comes back on its next run. `running` says the write is
2697
+ * for a run (by default, whenever `confirm` is): a name forgotten here is
2698
+ * then declared again, where a check or a dashboard read, whose loop may
2699
+ * have picked the name up just before the forget, leaves it forgotten.
2663
2700
  */
2664
- async sync(definition, confirm = false) {
2701
+ async sync(definition, confirm = false, running = confirm) {
2665
2702
  await this.ensureReady();
2666
2703
  const name = definition.name;
2667
2704
  if (this.synced.has(name)) {
@@ -2670,6 +2707,10 @@ var Cronwatch = class {
2670
2707
  }
2671
2708
  await this.serial(name, async () => {
2672
2709
  if (this.synced.has(name)) return;
2710
+ if (!this.definitions.has(name)) {
2711
+ if (!running) return;
2712
+ this.declared(name);
2713
+ }
2673
2714
  const standing = this.definitions.get(name) ?? definition;
2674
2715
  await this.store.upsertJob(toStored(standing), this.now());
2675
2716
  if (this.definitions.get(name) === standing) this.synced.add(name);
@@ -2777,15 +2818,32 @@ var Cronwatch = class {
2777
2818
  metrics: {},
2778
2819
  trigger
2779
2820
  };
2821
+ const begun = { ...run };
2780
2822
  let recorded = false;
2781
- try {
2823
+ let landedLate = false;
2824
+ let concluded = false;
2825
+ const writing = (async () => {
2782
2826
  await this.sync(definition, true);
2783
- await this.store.insertRun({ ...run });
2784
- recorded = true;
2827
+ await this.store.insertRun({ ...begun });
2828
+ })();
2829
+ try {
2830
+ if (await within(writing, START_DEADLINE_MS) === TIMED_OUT) {
2831
+ this.report(new Error(`the store took longer than ${formatDuration(START_DEADLINE_MS)} to record the start of ${name}; running it unrecorded`), `recording ${name}`);
2832
+ void writing.then(
2833
+ () => {
2834
+ landedLate = true;
2835
+ if (concluded) return this.recordFinish(definition, run, true, run.finishedAt).then(() => {
2836
+ }, (e) => this.report(e, `recording ${name}`));
2837
+ },
2838
+ () => {
2839
+ }
2840
+ );
2841
+ } else {
2842
+ recorded = true;
2843
+ }
2785
2844
  } catch (e) {
2786
2845
  this.report(e, `recording ${name}`);
2787
2846
  }
2788
- const begun = { ...run };
2789
2847
  const started = recorded ? this.updateState(name, (before) => ({ state: holdRun(onRunStart(definition, begun, before, this.now()), run.id), result: void 0 })).then(() => {
2790
2848
  }, (e) => this.report(e, `starting ${name}`)) : Promise.resolve();
2791
2849
  const recorder = createRecorder(run);
@@ -2808,11 +2866,23 @@ var Cronwatch = class {
2808
2866
  run.output = recorder.output() ?? (typeof result === "string" ? result : null);
2809
2867
  this.conclude(definition, run, result, error, threw, recorder.expectText() ?? (typeof result === "string" ? result : null));
2810
2868
  await started;
2869
+ const ignore = (why) => {
2870
+ if (why) this.report(new Error(`run ${run.id} of ${name} ${why}; ignored`), `finishing ${name}`);
2871
+ };
2872
+ const over = recorded || landedLate;
2811
2873
  try {
2812
- const ignored = await this.recordFinish(definition, run, recorded, finishedAt);
2813
- if (ignored) this.report(new Error(`run ${run.id} of ${name} ${ignored}; ignored`), `finishing ${name}`);
2874
+ ignore(await this.recordFinish(definition, run, over, finishedAt));
2814
2875
  } catch (e) {
2815
2876
  this.report(e, `recording ${name}`);
2877
+ if (!over && landedLate) {
2878
+ try {
2879
+ ignore(await this.recordFinish(definition, run, true, finishedAt));
2880
+ } catch (again) {
2881
+ this.report(again, `recording ${name}`);
2882
+ }
2883
+ }
2884
+ } finally {
2885
+ concluded = true;
2816
2886
  }
2817
2887
  return { run, result, error, threw };
2818
2888
  }
@@ -2849,7 +2919,7 @@ var Cronwatch = class {
2849
2919
  */
2850
2920
  async recordFinish(definition, run, recorded, finishedAt) {
2851
2921
  if (!recorded) {
2852
- await this.sync(definition);
2922
+ await this.sync(definition, false, true);
2853
2923
  try {
2854
2924
  await this.store.insertRun(run);
2855
2925
  await this.finishRun(toStored(definition), run, finishedAt);
@@ -2905,7 +2975,7 @@ ${id}`;
2905
2975
  }
2906
2976
  /**
2907
2977
  * The start of execute() without the function: the run is inserted and
2908
- * missed and stuck close (onRunStart). A store that fails is reported and
2978
+ * missed closes (onRunStart). A store that fails is reported and
2909
2979
  * the handle inserts the finished run instead, as execute() does.
2910
2980
  */
2911
2981
  async recordStart(definition, trigger = "start", id) {
@@ -2983,6 +3053,21 @@ ${id}`;
2983
3053
  let finished = inactive !== null;
2984
3054
  let finishCalled = false;
2985
3055
  let head = null;
3056
+ let flushed = null;
3057
+ const appendRedacted = (stored, lines) => {
3058
+ const added = stripNul(lines);
3059
+ const joins = flushed !== null && stored !== null && stored.endsWith(flushed.redacted);
3060
+ let raw = joins ? `${flushed.raw}
3061
+ ${added}` : added;
3062
+ const redacted = redactAndCap(raw, self.redact);
3063
+ const output = joins ? stored.slice(0, stored.length - flushed.redacted.length) + redacted : joinLines(stored, redacted);
3064
+ if (raw.length <= OUTPUT_CAP) return { output, tail: { raw, redacted } };
3065
+ const cut = raw.indexOf("\n", raw.length - OUTPUT_CAP);
3066
+ if (cut === -1) return { output, tail: null };
3067
+ raw = raw.slice(cut + 1);
3068
+ const kept = redactAndCap(raw, self.redact);
3069
+ return { output, tail: redacted.endsWith(kept) ? { raw, redacted: kept } : null };
3070
+ };
2986
3071
  let queue = Promise.resolve();
2987
3072
  const inTurn = (fn) => {
2988
3073
  const result = queue.then(fn);
@@ -3042,7 +3127,7 @@ ${id}`;
3042
3127
  durationMs: runDuration(from.startedAt, finishedAt),
3043
3128
  error: null,
3044
3129
  // Capped by conclude(), after it is redacted.
3045
- output: joinLines(from.output, added),
3130
+ output: added === null ? from.output : appendRedacted(from.output, added).output,
3046
3131
  metrics: { ...from.metrics, ...recorder.metrics() }
3047
3132
  };
3048
3133
  const expectText = joinLines(head, joinLines(from.output, recorder.expectText() ?? (typeof result === "string" ? result : null)));
@@ -3091,8 +3176,10 @@ ${id}`;
3091
3176
  self.report(new Error(`run ${id} of ${name} belongs to job "${stored.job}"; ignored`), `flushing ${name}`);
3092
3177
  return;
3093
3178
  }
3094
- const output = lines === null ? stored.output : joinOutput(stored.output, redactAndCap(lines, self.redact));
3179
+ const next = lines === null ? null : appendRedacted(stored.output, lines);
3180
+ const output = next === null ? stored.output : next.output === null ? null : capOutput(next.output);
3095
3181
  if (!await self.writeRunIf({ ...stored, output, metrics: { ...stored.metrics, ...metrics } }, ["running"])) return putBack();
3182
+ if (next !== null) flushed = next.tail !== null && output !== null && output.endsWith(next.tail.redacted) ? next.tail : null;
3096
3183
  const text = taken.expectText();
3097
3184
  if (text !== null && (head === null || head.length < OUTPUT_CAP)) head = (joinLines(head, text) ?? "").slice(0, OUTPUT_CAP);
3098
3185
  } catch (e) {
@@ -3128,6 +3215,17 @@ ${id}`;
3128
3215
  throw new Error(`recordRun: run ids must be 1 to ${MAX_RUN_ID} characters (got ${got}; job "${input.job}")`);
3129
3216
  }
3130
3217
  if (input.id.includes("\0")) throw new Error(`recordRun: run ids cannot contain a NUL character (job "${input.job}")`);
3218
+ const where = `job "${input.job}", run "${input.id}"`;
3219
+ if (!["running", "ok", "failed", "timeout"].includes(input.status)) {
3220
+ throw new Error(`recordRun: status must be "running", "ok", "failed", or "timeout" (${where})`);
3221
+ }
3222
+ if (!Number.isFinite(input.startedAt)) throw new Error(`recordRun: startedAt must be a finite number (${where})`);
3223
+ if (input.finishedAt !== null && !Number.isFinite(input.finishedAt)) throw new Error(`recordRun: finishedAt must be a finite number or null (${where})`);
3224
+ if (input.durationMs !== null && !Number.isFinite(input.durationMs)) throw new Error(`recordRun: durationMs must be a finite number or null (${where})`);
3225
+ for (const field of ["error", "output"]) {
3226
+ if (input[field] !== null && typeof input[field] !== "string") throw new Error(`recordRun: ${field} must be a string or null (${where})`);
3227
+ }
3228
+ if (typeof input.trigger !== "string") throw new Error(`recordRun: trigger must be a string (${where})`);
3131
3229
  for (const [metric, value] of Object.entries(input.metrics ?? {})) {
3132
3230
  if (typeof value !== "number" || !Number.isFinite(value)) {
3133
3231
  throw new Error(`recordRun: metric "${metric}" must be a finite number (job "${input.job}", run "${input.id}")`);
@@ -3148,7 +3246,7 @@ ${id}`;
3148
3246
  const definition = toStored(declared);
3149
3247
  const stored = await this.store.getRun(run.id);
3150
3248
  if (stored) return this.recordOver(definition, stored, run, evaluate);
3151
- const inserted = evaluate ? { ...run, status: "running" } : run;
3249
+ const inserted = evaluate ? { ...run, status: "running", finishedAt: run.status === "running" ? null : this.now() } : run;
3152
3250
  try {
3153
3251
  await this.store.insertRun(inserted);
3154
3252
  } catch (e) {
@@ -3181,7 +3279,7 @@ ${id}`;
3181
3279
  return [];
3182
3280
  }
3183
3281
  if (!evaluate) {
3184
- await this.updateState(run.job, (current) => ({ state: releaseRun(current, run.id), result: void 0 })).catch((e) => this.onError(e, `recording ${run.job}`));
3282
+ await this.updateState(run.job, (current) => ({ state: releaseRun(current, run.id), result: void 0 })).catch((e) => this.report(e, `recording ${run.job}`));
3185
3283
  return [];
3186
3284
  }
3187
3285
  if (claim.lateAfterTimeout && run.status !== "ok") return [];
@@ -3261,7 +3359,7 @@ ${id}`;
3261
3359
  try {
3262
3360
  alerts.push(...await source.sync(this) ?? []);
3263
3361
  } catch (e) {
3264
- this.onError(e, `source ${source.name}`);
3362
+ this.report(e, `source ${source.name}`);
3265
3363
  }
3266
3364
  }
3267
3365
  for (const definition of this.definitions.values()) await this.sync(definition);
@@ -3273,18 +3371,27 @@ ${id}`;
3273
3371
  const stored = found ? this.readJob(found) : null;
3274
3372
  if (stored) this.evaluable(stored);
3275
3373
  const definition = declared ? toStored(declared) : stored?.definition;
3276
- if (!definition || !isStuck(definition, listed, now)) continue;
3374
+ const stranded = isStranded(listed, now);
3375
+ if (!definition || !(stranded || isStuck(definition, listed, now))) continue;
3277
3376
  const run = await this.store.getRun(listed.id);
3278
3377
  if (!run || run.status !== "running" || run.job !== listed.job) continue;
3279
- const timeout = timeoutMs(definition);
3280
3378
  run.status = "timeout";
3281
- run.finishedAt = now;
3282
- run.durationMs = runDuration(run.startedAt, now);
3283
- run.error = `Still running after ${formatDuration(timeout)}; marked as timed out`;
3379
+ if (stranded && run.error !== null) {
3380
+ run.status = "failed";
3381
+ if (run.durationMs !== null) run.finishedAt = run.startedAt + run.durationMs;
3382
+ else run.durationMs = runDuration(run.startedAt, run.finishedAt);
3383
+ } else if (stranded) {
3384
+ run.durationMs ??= runDuration(run.startedAt, run.finishedAt);
3385
+ run.error = STRANDED_ERROR;
3386
+ } else {
3387
+ run.finishedAt = now;
3388
+ run.durationMs = runDuration(run.startedAt, now);
3389
+ run.error = `Still running after ${formatDuration(timeoutMs(definition))}; marked as timed out`;
3390
+ }
3284
3391
  if (!await this.writeRunIf(run, ["running"])) continue;
3285
3392
  alerts.push(...await this.finishRun(definition, run, now));
3286
3393
  } catch (e) {
3287
- this.onError(e, `checking ${listed.job}`);
3394
+ this.report(e, `checking ${listed.job}`);
3288
3395
  }
3289
3396
  }
3290
3397
  const jobs = [];
@@ -3308,7 +3415,7 @@ ${id}`;
3308
3415
  alerts.push(...await this.evaluatePending(stored, recent, state, now));
3309
3416
  jobs.push(summarize(stored, recent, state, nextExpectedAt, now));
3310
3417
  } catch (e) {
3311
- this.onError(e, `checking ${stored.name}`);
3418
+ this.report(e, `checking ${stored.name}`);
3312
3419
  jobs.push(await this.unevaluable(stored, now));
3313
3420
  }
3314
3421
  }
@@ -3318,7 +3425,7 @@ ${id}`;
3318
3425
  try {
3319
3426
  pruned = await this.store.prune(now - this.retentionMs);
3320
3427
  } catch (e) {
3321
- this.onError(e, "pruning");
3428
+ this.report(e, "pruning");
3322
3429
  }
3323
3430
  }
3324
3431
  return { checkedAt: now, jobs, alerts, pruned };
@@ -3344,7 +3451,7 @@ ${id}`;
3344
3451
  if (run.status === "running" || typeof run.finishedAt !== "number" || run.finishedAt > now - SEND_LEASE_MS) continue;
3345
3452
  alerts.push(...await this.finishRun(stored.definition, run, now, true));
3346
3453
  } catch (e) {
3347
- this.onError(e, `evaluating run ${id} of ${stored.name}`);
3454
+ this.report(e, `evaluating run ${id} of ${stored.name}`);
3348
3455
  }
3349
3456
  }
3350
3457
  return alerts;
@@ -3359,7 +3466,7 @@ ${id}`;
3359
3466
  const { nextExpectedAt } = onCheck(stored.definition, stored, recent[0] ?? null, state, now);
3360
3467
  return { job: summarize(stored, recent, state, nextExpectedAt, now), runs: recent.slice(0, runs) };
3361
3468
  } catch (e) {
3362
- this.onError(e, `reading ${stored.name}`);
3469
+ this.report(e, `reading ${stored.name}`);
3363
3470
  return { job: await this.unevaluable(stored, now), runs: recent.slice(0, runs) };
3364
3471
  }
3365
3472
  }
@@ -3386,7 +3493,7 @@ ${id}`;
3386
3493
  async jobSummary(name) {
3387
3494
  await this.ensureReady();
3388
3495
  const definition = this.definitions.get(name);
3389
- if (definition) await this.sync(definition, true);
3496
+ if (definition) await this.sync(definition, true, false);
3390
3497
  const stored = await this.store.getJob(name);
3391
3498
  if (!stored) return null;
3392
3499
  return (await this.snapshot(this.readJob(stored), this.now(), 0)).job;
@@ -3645,10 +3752,6 @@ function joinLines(before, after2) {
3645
3752
  return `${before}
3646
3753
  ${after2}`;
3647
3754
  }
3648
- function joinOutput(before, after2) {
3649
- const joined = joinLines(before, after2);
3650
- return joined === null ? null : capOutput(joined);
3651
- }
3652
3755
  function clampLimit(limit, fallback, min) {
3653
3756
  const n = typeof limit === "number" && Number.isFinite(limit) ? Math.trunc(limit) : fallback;
3654
3757
  return Math.min(500, Math.max(min, n));
@@ -3663,6 +3766,20 @@ function after(ms, fn) {
3663
3766
  arm(ms);
3664
3767
  return () => clearTimeout(timer);
3665
3768
  }
3769
+ var TIMED_OUT = /* @__PURE__ */ Symbol("timed out");
3770
+ function within(promise, ms) {
3771
+ return new Promise((resolve, reject) => {
3772
+ const t = setTimeout(() => resolve(TIMED_OUT), ms);
3773
+ if (typeof t === "object" && "unref" in t) t.unref();
3774
+ promise.then((v) => {
3775
+ clearTimeout(t);
3776
+ resolve(v);
3777
+ }, (e) => {
3778
+ clearTimeout(t);
3779
+ reject(e);
3780
+ });
3781
+ });
3782
+ }
3666
3783
  function withTimeout(promise, ms) {
3667
3784
  return new Promise((resolve, reject) => {
3668
3785
  const t = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);