@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.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { C as CronwatchOptions, a as Cronwatch } from './client-CHjrNHH-.cjs';
2
- export { b as CronWatch, c as CronWatchOptions, F as FetchHandler, H as HandlerFn, d as HandlerOptions, J as JobContext, e as JobFn, f as JobHandle, R as RecordRunOptions, g as Routes, h as RoutesOptions, i as RunHandle, j as RunOutcome, S as Source, k as SourceHost, l as StartOptions, m as consoleChannel, n as createRoutes, o as custom } from './client-CHjrNHH-.cjs';
1
+ import { C as CronwatchOptions, a as Cronwatch } from './client-IuAn2-v9.cjs';
2
+ export { b as CronWatch, c as CronWatchOptions, F as FetchHandler, H as HandlerFn, d as HandlerOptions, J as JobContext, e as JobFn, f as JobHandle, R as RecordRunOptions, g as Routes, h as RoutesOptions, i as RunHandle, j as RunOutcome, S as Source, k as SourceHost, l as StartOptions, m as consoleChannel, n as createRoutes, o as custom } from './client-IuAn2-v9.cjs';
3
3
  import { S as Store, D as Duration, b as AlertDraft, c as StoredJobDefinition, A as Alert } from './types-B8IFVK5k.cjs';
4
4
  export { a as AlertChannel, d as AlertDetails, e as AlertType, B as BudgetBreach, C as ChannelContext, f as CheckResult, g as Condition, E as ExpectRule, H as HeldAlert, h as JobDefinition, i as JobHealth, J as JobOptions, j as JobState, k as JobSummary, R as Run, l as RunStatus, m as SendingAlert, n as StoredJob, T as TriageContext, o as TriageFn } from './types-B8IFVK5k.cjs';
5
5
 
@@ -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.15.0";
15
15
 
16
16
  /**
17
17
  * "15m" -> 900000. Accepts a plain number of milliseconds, and compound
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { C as CronwatchOptions, a as Cronwatch } from './client-C8j7B2Bo.js';
2
- export { b as CronWatch, c as CronWatchOptions, F as FetchHandler, H as HandlerFn, d as HandlerOptions, J as JobContext, e as JobFn, f as JobHandle, R as RecordRunOptions, g as Routes, h as RoutesOptions, i as RunHandle, j as RunOutcome, S as Source, k as SourceHost, l as StartOptions, m as consoleChannel, n as createRoutes, o as custom } from './client-C8j7B2Bo.js';
1
+ import { C as CronwatchOptions, a as Cronwatch } from './client-MLN3rDFE.js';
2
+ export { b as CronWatch, c as CronWatchOptions, F as FetchHandler, H as HandlerFn, d as HandlerOptions, J as JobContext, e as JobFn, f as JobHandle, R as RecordRunOptions, g as Routes, h as RoutesOptions, i as RunHandle, j as RunOutcome, S as Source, k as SourceHost, l as StartOptions, m as consoleChannel, n as createRoutes, o as custom } from './client-MLN3rDFE.js';
3
3
  import { S as Store, D as Duration, b as AlertDraft, c as StoredJobDefinition, A as Alert } from './types-B8IFVK5k.js';
4
4
  export { a as AlertChannel, d as AlertDetails, e as AlertType, B as BudgetBreach, C as ChannelContext, f as CheckResult, g as Condition, E as ExpectRule, H as HeldAlert, h as JobDefinition, i as JobHealth, J as JobOptions, j as JobState, k as JobSummary, R as Run, l as RunStatus, m as SendingAlert, n as StoredJob, T as TriageContext, o as TriageFn } from './types-B8IFVK5k.js';
5
5
 
@@ -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.15.0";
15
15
 
16
16
  /**
17
17
  * "15m" -> 900000. Accepts a plain number of milliseconds, and compound
package/dist/index.js CHANGED
@@ -634,7 +634,6 @@ function catchesUp(def, run, now) {
634
634
  function onRunStart(def, run, state, now) {
635
635
  const next = cloneState(state);
636
636
  if (catchesUp(def, run, now)) closeCondition(next, "missed");
637
- closeCondition(next, "stuck");
638
637
  return next;
639
638
  }
640
639
  function onRunFinish(def, run, state, history, now) {
@@ -722,6 +721,9 @@ function onCheck(def, stored, lastRun, state, now) {
722
721
  function isStuck(def, run, now) {
723
722
  return run.status === "running" && run.finishedAt === null && now - run.startedAt > timeoutMs(def);
724
723
  }
724
+ function isStranded(run, now) {
725
+ return run.status === "running" && run.finishedAt !== null && now - run.finishedAt > SEND_LEASE_MS;
726
+ }
725
727
  function muteOpens(previous, next) {
726
728
  const muted = cloneState(next);
727
729
  for (const condition of openConditions(muted)) {
@@ -897,6 +899,10 @@ function constantTimeEqual(sent, secret) {
897
899
  for (let i = 0; i < sent.length; i++) diff |= sent.charCodeAt(i) ^ secret.charCodeAt(i % secret.length);
898
900
  return diff === 0;
899
901
  }
902
+ function bearerToken(header) {
903
+ const match = header === null ? null : /^Bearer\s+([\s\S]*)$/i.exec(header);
904
+ return match ? match[1] : null;
905
+ }
900
906
 
901
907
  // src/output.ts
902
908
  var OUTPUT_CAP = 16 * 1024;
@@ -990,15 +996,45 @@ function stringify(part) {
990
996
  return String(part);
991
997
  }
992
998
  }
999
+ var LineWindow = class {
1000
+ items = [];
1001
+ start = 0;
1002
+ size = 0;
1003
+ get count() {
1004
+ return this.items.length - this.start;
1005
+ }
1006
+ push(line) {
1007
+ this.items.push(line);
1008
+ this.size += line.length + 1;
1009
+ }
1010
+ /** Drops the oldest lines while over `max`, always keeping the newest. */
1011
+ keepAtMost(max) {
1012
+ while (this.size > max && this.count > 1) this.drop();
1013
+ }
1014
+ /** Drops the oldest lines only while what is left still holds `min`. */
1015
+ keepAtLeast(min) {
1016
+ while (this.count > 1 && this.size - (this.items[this.start].length + 1) > min) this.drop();
1017
+ }
1018
+ join() {
1019
+ return this.items.slice(this.start).join("\n");
1020
+ }
1021
+ drop() {
1022
+ this.size -= this.items[this.start].length + 1;
1023
+ this.start += 1;
1024
+ if (this.start >= 1024 && this.start * 2 >= this.items.length) {
1025
+ this.items = this.items.slice(this.start);
1026
+ this.start = 0;
1027
+ }
1028
+ }
1029
+ };
993
1030
  function createRecorder(run) {
994
1031
  const window = OUTPUT_CAP + REDACT_EDGE + 2;
995
1032
  const copy = (text) => JSON.parse(JSON.stringify(text));
996
- const lines = [];
997
- let size = 0;
1033
+ const lines = new LineWindow();
998
1034
  const head = [];
999
1035
  let headSize = 0;
1000
1036
  let whole = "";
1001
- let tail2 = "";
1037
+ const raw = new LineWindow();
1002
1038
  let logged = false;
1003
1039
  const metrics = {};
1004
1040
  const controller = new AbortController();
@@ -1012,7 +1048,8 @@ function createRecorder(run) {
1012
1048
  const separator = logged ? "\n" : "";
1013
1049
  logged = true;
1014
1050
  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));
1051
+ raw.push(copy(line.slice(-OUTPUT_CAP)));
1052
+ raw.keepAtLeast(OUTPUT_CAP);
1016
1053
  if (headSize < OUTPUT_CAP) {
1017
1054
  head.push(copy(line.slice(0, OUTPUT_CAP - headSize)));
1018
1055
  headSize += line.length + 1;
@@ -1022,10 +1059,7 @@ function createRecorder(run) {
1022
1059
  line = copy(line.slice(-68609));
1023
1060
  }
1024
1061
  lines.push(line);
1025
- size += line.length + 1;
1026
- while (size > window && lines.length > 1) {
1027
- size -= lines.shift().length + 1;
1028
- }
1062
+ lines.keepAtMost(window);
1029
1063
  },
1030
1064
  metric(name, value) {
1031
1065
  if (typeof value !== "number" || !Number.isFinite(value)) {
@@ -1039,10 +1073,10 @@ function createRecorder(run) {
1039
1073
  };
1040
1074
  return {
1041
1075
  context,
1042
- output: () => lines.length === 0 ? null : lines.join("\n"),
1076
+ output: () => lines.count === 0 ? null : lines.join(),
1043
1077
  expectText: () => {
1044
1078
  if (!logged) return null;
1045
- return whole ?? head.join("\n").slice(0, OUTPUT_CAP) + "\n" + tail2;
1079
+ return whole ?? head.join("\n").slice(0, OUTPUT_CAP) + "\n" + raw.join().slice(-OUTPUT_CAP);
1046
1080
  },
1047
1081
  metrics: () => ({ ...metrics }),
1048
1082
  abort: () => controller.abort()
@@ -1216,7 +1250,7 @@ function staticAsset(path, base) {
1216
1250
  }
1217
1251
 
1218
1252
  // src/version.ts
1219
- var VERSION = "0.14.2";
1253
+ var VERSION = "0.15.0";
1220
1254
  var API_VERSION = 1;
1221
1255
 
1222
1256
  // src/routes/timeline.ts
@@ -1983,10 +2017,6 @@ function forwardedOrigin(request, url) {
1983
2017
  return url.origin;
1984
2018
  }
1985
2019
  }
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
2020
  function signInReturn(referer, publicOrigin, base) {
1991
2021
  if (referer === null || !referer.startsWith(publicOrigin + "/")) return `${base}/`;
1992
2022
  let url;
@@ -2155,13 +2185,14 @@ function buildRoutes(cw, options = {}) {
2155
2185
  return wantsHtml ? html(messagePage("Bad request", "The path is not valid.", base), 400) : api({ ok: false, error: "Bad path" }, 400);
2156
2186
  }
2157
2187
  const parts = decoded;
2158
- if (method === "GET" && path === "/") {
2188
+ const reads = method === "GET" || method === "HEAD";
2189
+ if (reads && path === "/") {
2159
2190
  const entries = await cw.jobsWithRuns(BOARD_PAGE_RUNS);
2160
2191
  const now = cw.now();
2161
2192
  const runsByJob = new Map(entries.map((entry) => [entry.job.name, entry.runs]));
2162
2193
  return html(dashboardPage(entries.map((entry) => entry.job), runsByJob, now, base, null, await boardLanes(cw, entries, now)));
2163
2194
  }
2164
- if (method === "GET" && parts[0] === "jobs" && parts.length === 2) {
2195
+ if (reads && parts[0] === "jobs" && parts.length === 2) {
2165
2196
  const job = await cw.jobSummary(parts[1]);
2166
2197
  if (!job) return html(messagePage("No such job", `${parts[1]} is not in the store.`, base), 404);
2167
2198
  const now = cw.now();
@@ -2197,15 +2228,15 @@ function buildRoutes(cw, options = {}) {
2197
2228
  }
2198
2229
  if (parts[0] === "api") {
2199
2230
  const rest = parts.slice(1);
2200
- if (method === "GET" && rest.length === 0) {
2231
+ if (reads && rest.length === 0) {
2201
2232
  return api({ ok: true, library: LIBRARY, language: LANGUAGE, version: VERSION, api: API_VERSION });
2202
2233
  }
2203
- if (method === "GET" && rest[0] === "jobs" && rest.length === 1) {
2234
+ if (reads && rest[0] === "jobs" && rest.length === 1) {
2204
2235
  return api({ ok: true, jobs: await cw.jobs() });
2205
2236
  }
2206
2237
  if (rest[0] === "jobs" && rest.length === 2) {
2207
2238
  const name = rest[1];
2208
- if (method === "GET") {
2239
+ if (reads) {
2209
2240
  const job = await cw.jobSummary(name);
2210
2241
  if (!job) return api({ ok: false, error: "No such job" }, 404);
2211
2242
  return api({ ok: true, job, runs: await cw.runs(name, runsLimit(url.searchParams.get("runs"))) });
@@ -2246,7 +2277,7 @@ function buildRoutes(cw, options = {}) {
2246
2277
  return api({ ok: true, ...result });
2247
2278
  }
2248
2279
  }
2249
- if (method === "GET" && rest[0] === "runs" && rest.length === 2) {
2280
+ if (reads && rest[0] === "runs" && rest.length === 2) {
2250
2281
  const run = await cw.getRun(rest[1]);
2251
2282
  return run ? api({ ok: true, run }) : api({ ok: false, error: "No such run" }, 404);
2252
2283
  }
@@ -2261,7 +2292,8 @@ function buildRoutes(cw, options = {}) {
2261
2292
  const path = stripBase(url.pathname, base);
2262
2293
  if (path === null) return html(messagePage("Not found", url.pathname, base), 404);
2263
2294
  wantsHtml = !path.startsWith("/api");
2264
- return await serve(request, url, path, wantsHtml);
2295
+ const response = await serve(request, url, path, wantsHtml);
2296
+ return request.method.toUpperCase() === "HEAD" ? new Response(null, { status: response.status, headers: response.headers }) : response;
2265
2297
  } catch (e) {
2266
2298
  try {
2267
2299
  cw.onError(e, "routes");
@@ -2405,9 +2437,11 @@ var TRIAGE_TIMEOUT_MS = 25e3;
2405
2437
  var CHANNEL_TIMEOUT_MS = 15e3;
2406
2438
  var PRUNE_INTERVAL_MS = 60 * 6e4;
2407
2439
  var RETRY_BUDGET_MS = 2e4;
2440
+ var START_DEADLINE_MS = 5e3;
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
  }
@@ -2647,8 +2681,8 @@ var Cronwatch = class {
2647
2681
  /**
2648
2682
  * Writes the declaration of `definition`'s name as it stands, unless the
2649
2683
  * store has it. A handle kept from an earlier declaration writes the one
2650
- * that replaced it, never its own over it, and one forgotten since writes
2651
- * its own. The writes of one name take turns, in the order they were asked
2684
+ * that replaced it, never its own over it, and one forgotten since
2685
+ * declares the job again as it was last declared here. The writes of one name take turns, in the order they were asked
2652
2686
  * for, so one still under way cannot land after a later one; and a name
2653
2687
  * declared again while its write was under way is still to be written. A
2654
2688
  * name is marked as written only while that same declaration stands, so a
@@ -2657,9 +2691,12 @@ var Cronwatch = class {
2657
2691
  *
2658
2692
  * With `confirm`, as a run starts, a name already written is read back:
2659
2693
  * another process may have forgotten the job since, and a job still
2660
- * declared here comes back on its next run.
2694
+ * declared here comes back on its next run. `running` says the write is
2695
+ * for a run (by default, whenever `confirm` is): a name forgotten here is
2696
+ * then declared again, where a check or a dashboard read, whose loop may
2697
+ * have picked the name up just before the forget, leaves it forgotten.
2661
2698
  */
2662
- async sync(definition, confirm = false) {
2699
+ async sync(definition, confirm = false, running = confirm) {
2663
2700
  await this.ensureReady();
2664
2701
  const name = definition.name;
2665
2702
  if (this.synced.has(name)) {
@@ -2668,6 +2705,10 @@ var Cronwatch = class {
2668
2705
  }
2669
2706
  await this.serial(name, async () => {
2670
2707
  if (this.synced.has(name)) return;
2708
+ if (!this.definitions.has(name)) {
2709
+ if (!running) return;
2710
+ this.declared(name);
2711
+ }
2671
2712
  const standing = this.definitions.get(name) ?? definition;
2672
2713
  await this.store.upsertJob(toStored(standing), this.now());
2673
2714
  if (this.definitions.get(name) === standing) this.synced.add(name);
@@ -2775,15 +2816,32 @@ var Cronwatch = class {
2775
2816
  metrics: {},
2776
2817
  trigger
2777
2818
  };
2819
+ const begun = { ...run };
2778
2820
  let recorded = false;
2779
- try {
2821
+ let landedLate = false;
2822
+ let concluded = false;
2823
+ const writing = (async () => {
2780
2824
  await this.sync(definition, true);
2781
- await this.store.insertRun({ ...run });
2782
- recorded = true;
2825
+ await this.store.insertRun({ ...begun });
2826
+ })();
2827
+ try {
2828
+ if (await within(writing, START_DEADLINE_MS) === TIMED_OUT) {
2829
+ this.report(new Error(`the store took longer than ${formatDuration(START_DEADLINE_MS)} to record the start of ${name}; running it unrecorded`), `recording ${name}`);
2830
+ void writing.then(
2831
+ () => {
2832
+ landedLate = true;
2833
+ if (concluded) return this.recordFinish(definition, run, true, run.finishedAt).then(() => {
2834
+ }, (e) => this.report(e, `recording ${name}`));
2835
+ },
2836
+ () => {
2837
+ }
2838
+ );
2839
+ } else {
2840
+ recorded = true;
2841
+ }
2783
2842
  } catch (e) {
2784
2843
  this.report(e, `recording ${name}`);
2785
2844
  }
2786
- const begun = { ...run };
2787
2845
  const started = recorded ? this.updateState(name, (before) => ({ state: holdRun(onRunStart(definition, begun, before, this.now()), run.id), result: void 0 })).then(() => {
2788
2846
  }, (e) => this.report(e, `starting ${name}`)) : Promise.resolve();
2789
2847
  const recorder = createRecorder(run);
@@ -2806,11 +2864,23 @@ var Cronwatch = class {
2806
2864
  run.output = recorder.output() ?? (typeof result === "string" ? result : null);
2807
2865
  this.conclude(definition, run, result, error, threw, recorder.expectText() ?? (typeof result === "string" ? result : null));
2808
2866
  await started;
2867
+ const ignore = (why) => {
2868
+ if (why) this.report(new Error(`run ${run.id} of ${name} ${why}; ignored`), `finishing ${name}`);
2869
+ };
2870
+ const over = recorded || landedLate;
2809
2871
  try {
2810
- const ignored = await this.recordFinish(definition, run, recorded, finishedAt);
2811
- if (ignored) this.report(new Error(`run ${run.id} of ${name} ${ignored}; ignored`), `finishing ${name}`);
2872
+ ignore(await this.recordFinish(definition, run, over, finishedAt));
2812
2873
  } catch (e) {
2813
2874
  this.report(e, `recording ${name}`);
2875
+ if (!over && landedLate) {
2876
+ try {
2877
+ ignore(await this.recordFinish(definition, run, true, finishedAt));
2878
+ } catch (again) {
2879
+ this.report(again, `recording ${name}`);
2880
+ }
2881
+ }
2882
+ } finally {
2883
+ concluded = true;
2814
2884
  }
2815
2885
  return { run, result, error, threw };
2816
2886
  }
@@ -2847,7 +2917,7 @@ var Cronwatch = class {
2847
2917
  */
2848
2918
  async recordFinish(definition, run, recorded, finishedAt) {
2849
2919
  if (!recorded) {
2850
- await this.sync(definition);
2920
+ await this.sync(definition, false, true);
2851
2921
  try {
2852
2922
  await this.store.insertRun(run);
2853
2923
  await this.finishRun(toStored(definition), run, finishedAt);
@@ -2903,7 +2973,7 @@ ${id}`;
2903
2973
  }
2904
2974
  /**
2905
2975
  * The start of execute() without the function: the run is inserted and
2906
- * missed and stuck close (onRunStart). A store that fails is reported and
2976
+ * missed closes (onRunStart). A store that fails is reported and
2907
2977
  * the handle inserts the finished run instead, as execute() does.
2908
2978
  */
2909
2979
  async recordStart(definition, trigger = "start", id) {
@@ -2981,6 +3051,21 @@ ${id}`;
2981
3051
  let finished = inactive !== null;
2982
3052
  let finishCalled = false;
2983
3053
  let head = null;
3054
+ let flushed = null;
3055
+ const appendRedacted = (stored, lines) => {
3056
+ const added = stripNul(lines);
3057
+ const joins = flushed !== null && stored !== null && stored.endsWith(flushed.redacted);
3058
+ let raw = joins ? `${flushed.raw}
3059
+ ${added}` : added;
3060
+ const redacted = redactAndCap(raw, self.redact);
3061
+ const output = joins ? stored.slice(0, stored.length - flushed.redacted.length) + redacted : joinLines(stored, redacted);
3062
+ if (raw.length <= OUTPUT_CAP) return { output, tail: { raw, redacted } };
3063
+ const cut = raw.indexOf("\n", raw.length - OUTPUT_CAP);
3064
+ if (cut === -1) return { output, tail: null };
3065
+ raw = raw.slice(cut + 1);
3066
+ const kept = redactAndCap(raw, self.redact);
3067
+ return { output, tail: redacted.endsWith(kept) ? { raw, redacted: kept } : null };
3068
+ };
2984
3069
  let queue = Promise.resolve();
2985
3070
  const inTurn = (fn) => {
2986
3071
  const result = queue.then(fn);
@@ -3040,7 +3125,7 @@ ${id}`;
3040
3125
  durationMs: runDuration(from.startedAt, finishedAt),
3041
3126
  error: null,
3042
3127
  // Capped by conclude(), after it is redacted.
3043
- output: joinLines(from.output, added),
3128
+ output: added === null ? from.output : appendRedacted(from.output, added).output,
3044
3129
  metrics: { ...from.metrics, ...recorder.metrics() }
3045
3130
  };
3046
3131
  const expectText = joinLines(head, joinLines(from.output, recorder.expectText() ?? (typeof result === "string" ? result : null)));
@@ -3089,8 +3174,10 @@ ${id}`;
3089
3174
  self.report(new Error(`run ${id} of ${name} belongs to job "${stored.job}"; ignored`), `flushing ${name}`);
3090
3175
  return;
3091
3176
  }
3092
- const output = lines === null ? stored.output : joinOutput(stored.output, redactAndCap(lines, self.redact));
3177
+ const next = lines === null ? null : appendRedacted(stored.output, lines);
3178
+ const output = next === null ? stored.output : next.output === null ? null : capOutput(next.output);
3093
3179
  if (!await self.writeRunIf({ ...stored, output, metrics: { ...stored.metrics, ...metrics } }, ["running"])) return putBack();
3180
+ if (next !== null) flushed = next.tail !== null && output !== null && output.endsWith(next.tail.redacted) ? next.tail : null;
3094
3181
  const text = taken.expectText();
3095
3182
  if (text !== null && (head === null || head.length < OUTPUT_CAP)) head = (joinLines(head, text) ?? "").slice(0, OUTPUT_CAP);
3096
3183
  } catch (e) {
@@ -3126,6 +3213,17 @@ ${id}`;
3126
3213
  throw new Error(`recordRun: run ids must be 1 to ${MAX_RUN_ID} characters (got ${got}; job "${input.job}")`);
3127
3214
  }
3128
3215
  if (input.id.includes("\0")) throw new Error(`recordRun: run ids cannot contain a NUL character (job "${input.job}")`);
3216
+ const where = `job "${input.job}", run "${input.id}"`;
3217
+ if (!["running", "ok", "failed", "timeout"].includes(input.status)) {
3218
+ throw new Error(`recordRun: status must be "running", "ok", "failed", or "timeout" (${where})`);
3219
+ }
3220
+ if (!Number.isFinite(input.startedAt)) throw new Error(`recordRun: startedAt must be a finite number (${where})`);
3221
+ if (input.finishedAt !== null && !Number.isFinite(input.finishedAt)) throw new Error(`recordRun: finishedAt must be a finite number or null (${where})`);
3222
+ if (input.durationMs !== null && !Number.isFinite(input.durationMs)) throw new Error(`recordRun: durationMs must be a finite number or null (${where})`);
3223
+ for (const field of ["error", "output"]) {
3224
+ if (input[field] !== null && typeof input[field] !== "string") throw new Error(`recordRun: ${field} must be a string or null (${where})`);
3225
+ }
3226
+ if (typeof input.trigger !== "string") throw new Error(`recordRun: trigger must be a string (${where})`);
3129
3227
  for (const [metric, value] of Object.entries(input.metrics ?? {})) {
3130
3228
  if (typeof value !== "number" || !Number.isFinite(value)) {
3131
3229
  throw new Error(`recordRun: metric "${metric}" must be a finite number (job "${input.job}", run "${input.id}")`);
@@ -3146,7 +3244,7 @@ ${id}`;
3146
3244
  const definition = toStored(declared);
3147
3245
  const stored = await this.store.getRun(run.id);
3148
3246
  if (stored) return this.recordOver(definition, stored, run, evaluate);
3149
- const inserted = evaluate ? { ...run, status: "running" } : run;
3247
+ const inserted = evaluate ? { ...run, status: "running", finishedAt: run.status === "running" ? null : this.now() } : run;
3150
3248
  try {
3151
3249
  await this.store.insertRun(inserted);
3152
3250
  } catch (e) {
@@ -3179,7 +3277,7 @@ ${id}`;
3179
3277
  return [];
3180
3278
  }
3181
3279
  if (!evaluate) {
3182
- await this.updateState(run.job, (current) => ({ state: releaseRun(current, run.id), result: void 0 })).catch((e) => this.onError(e, `recording ${run.job}`));
3280
+ await this.updateState(run.job, (current) => ({ state: releaseRun(current, run.id), result: void 0 })).catch((e) => this.report(e, `recording ${run.job}`));
3183
3281
  return [];
3184
3282
  }
3185
3283
  if (claim.lateAfterTimeout && run.status !== "ok") return [];
@@ -3259,7 +3357,7 @@ ${id}`;
3259
3357
  try {
3260
3358
  alerts.push(...await source.sync(this) ?? []);
3261
3359
  } catch (e) {
3262
- this.onError(e, `source ${source.name}`);
3360
+ this.report(e, `source ${source.name}`);
3263
3361
  }
3264
3362
  }
3265
3363
  for (const definition of this.definitions.values()) await this.sync(definition);
@@ -3271,18 +3369,27 @@ ${id}`;
3271
3369
  const stored = found ? this.readJob(found) : null;
3272
3370
  if (stored) this.evaluable(stored);
3273
3371
  const definition = declared ? toStored(declared) : stored?.definition;
3274
- if (!definition || !isStuck(definition, listed, now)) continue;
3372
+ const stranded = isStranded(listed, now);
3373
+ if (!definition || !(stranded || isStuck(definition, listed, now))) continue;
3275
3374
  const run = await this.store.getRun(listed.id);
3276
3375
  if (!run || run.status !== "running" || run.job !== listed.job) continue;
3277
- const timeout = timeoutMs(definition);
3278
3376
  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`;
3377
+ if (stranded && run.error !== null) {
3378
+ run.status = "failed";
3379
+ if (run.durationMs !== null) run.finishedAt = run.startedAt + run.durationMs;
3380
+ else run.durationMs = runDuration(run.startedAt, run.finishedAt);
3381
+ } else if (stranded) {
3382
+ run.durationMs ??= runDuration(run.startedAt, run.finishedAt);
3383
+ run.error = STRANDED_ERROR;
3384
+ } else {
3385
+ run.finishedAt = now;
3386
+ run.durationMs = runDuration(run.startedAt, now);
3387
+ run.error = `Still running after ${formatDuration(timeoutMs(definition))}; marked as timed out`;
3388
+ }
3282
3389
  if (!await this.writeRunIf(run, ["running"])) continue;
3283
3390
  alerts.push(...await this.finishRun(definition, run, now));
3284
3391
  } catch (e) {
3285
- this.onError(e, `checking ${listed.job}`);
3392
+ this.report(e, `checking ${listed.job}`);
3286
3393
  }
3287
3394
  }
3288
3395
  const jobs = [];
@@ -3306,7 +3413,7 @@ ${id}`;
3306
3413
  alerts.push(...await this.evaluatePending(stored, recent, state, now));
3307
3414
  jobs.push(summarize(stored, recent, state, nextExpectedAt, now));
3308
3415
  } catch (e) {
3309
- this.onError(e, `checking ${stored.name}`);
3416
+ this.report(e, `checking ${stored.name}`);
3310
3417
  jobs.push(await this.unevaluable(stored, now));
3311
3418
  }
3312
3419
  }
@@ -3316,7 +3423,7 @@ ${id}`;
3316
3423
  try {
3317
3424
  pruned = await this.store.prune(now - this.retentionMs);
3318
3425
  } catch (e) {
3319
- this.onError(e, "pruning");
3426
+ this.report(e, "pruning");
3320
3427
  }
3321
3428
  }
3322
3429
  return { checkedAt: now, jobs, alerts, pruned };
@@ -3342,7 +3449,7 @@ ${id}`;
3342
3449
  if (run.status === "running" || typeof run.finishedAt !== "number" || run.finishedAt > now - SEND_LEASE_MS) continue;
3343
3450
  alerts.push(...await this.finishRun(stored.definition, run, now, true));
3344
3451
  } catch (e) {
3345
- this.onError(e, `evaluating run ${id} of ${stored.name}`);
3452
+ this.report(e, `evaluating run ${id} of ${stored.name}`);
3346
3453
  }
3347
3454
  }
3348
3455
  return alerts;
@@ -3357,7 +3464,7 @@ ${id}`;
3357
3464
  const { nextExpectedAt } = onCheck(stored.definition, stored, recent[0] ?? null, state, now);
3358
3465
  return { job: summarize(stored, recent, state, nextExpectedAt, now), runs: recent.slice(0, runs) };
3359
3466
  } catch (e) {
3360
- this.onError(e, `reading ${stored.name}`);
3467
+ this.report(e, `reading ${stored.name}`);
3361
3468
  return { job: await this.unevaluable(stored, now), runs: recent.slice(0, runs) };
3362
3469
  }
3363
3470
  }
@@ -3384,7 +3491,7 @@ ${id}`;
3384
3491
  async jobSummary(name) {
3385
3492
  await this.ensureReady();
3386
3493
  const definition = this.definitions.get(name);
3387
- if (definition) await this.sync(definition, true);
3494
+ if (definition) await this.sync(definition, true, false);
3388
3495
  const stored = await this.store.getJob(name);
3389
3496
  if (!stored) return null;
3390
3497
  return (await this.snapshot(this.readJob(stored), this.now(), 0)).job;
@@ -3643,10 +3750,6 @@ function joinLines(before, after2) {
3643
3750
  return `${before}
3644
3751
  ${after2}`;
3645
3752
  }
3646
- function joinOutput(before, after2) {
3647
- const joined = joinLines(before, after2);
3648
- return joined === null ? null : capOutput(joined);
3649
- }
3650
3753
  function clampLimit(limit, fallback, min) {
3651
3754
  const n = typeof limit === "number" && Number.isFinite(limit) ? Math.trunc(limit) : fallback;
3652
3755
  return Math.min(500, Math.max(min, n));
@@ -3661,6 +3764,20 @@ function after(ms, fn) {
3661
3764
  arm(ms);
3662
3765
  return () => clearTimeout(timer);
3663
3766
  }
3767
+ var TIMED_OUT = /* @__PURE__ */ Symbol("timed out");
3768
+ function within(promise, ms) {
3769
+ return new Promise((resolve, reject) => {
3770
+ const t = setTimeout(() => resolve(TIMED_OUT), ms);
3771
+ if (typeof t === "object" && "unref" in t) t.unref();
3772
+ promise.then((v) => {
3773
+ clearTimeout(t);
3774
+ resolve(v);
3775
+ }, (e) => {
3776
+ clearTimeout(t);
3777
+ reject(e);
3778
+ });
3779
+ });
3780
+ }
3664
3781
  function withTimeout(promise, ms) {
3665
3782
  return new Promise((resolve, reject) => {
3666
3783
  const t = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);