@cronvello/sdk 0.1.3 → 0.2.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.js CHANGED
@@ -526,7 +526,7 @@ function createDispatcher(state) {
526
526
  throw error;
527
527
  }
528
528
  }
529
- function buildContext(key, job, req, body, isAsync, source) {
529
+ function buildContext(key, job, req, body, isAsync, source, signal) {
530
530
  return {
531
531
  key,
532
532
  schedule: typeof body["schedule"] === "string" ? body["schedule"] : job.config.schedule ?? "",
@@ -536,7 +536,7 @@ function createDispatcher(state) {
536
536
  isAsync,
537
537
  source,
538
538
  logger: log,
539
- signal: req?.signal
539
+ signal: req?.signal ?? signal
540
540
  };
541
541
  }
542
542
  async function handle(req) {
@@ -586,11 +586,11 @@ function createDispatcher(state) {
586
586
  return resp(500, { ok: false, job: key, error: err instanceof Error ? err.message : String(err) });
587
587
  }
588
588
  }
589
- async function runLocal(key, payload) {
589
+ async function runLocal(key, payload, opts) {
590
590
  const job = state.jobs.get(key);
591
591
  if (!job) throw new Error(`Unknown job '${key}'. Known: ${[...state.jobs.keys()].join(", ") || "(none)"}`);
592
592
  const body = { job: key, schedule: job.config.schedule, ...payload ?? {} };
593
- const ctx = buildContext(key, job, null, body, false, "local");
593
+ const ctx = buildContext(key, job, null, body, false, "local", opts?.signal);
594
594
  const { result } = await invokeHandler(job, ctx);
595
595
  return result;
596
596
  }
@@ -877,6 +877,610 @@ function nextHandler(app) {
877
877
  };
878
878
  }
879
879
 
880
+ // src/internal/cron-schedule.ts
881
+ var MACRO_EXPANSIONS = {
882
+ "@yearly": "0 0 1 1 *",
883
+ "@annually": "0 0 1 1 *",
884
+ "@monthly": "0 0 1 * *",
885
+ "@weekly": "0 0 * * 0",
886
+ "@daily": "0 0 * * *",
887
+ "@midnight": "0 0 * * *",
888
+ "@hourly": "0 * * * *"
889
+ };
890
+ var MONTH_NAMES = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
891
+ var DOW_NAMES = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
892
+ var SECOND_SPEC = { min: 0, max: 59, nameOffset: 0, label: "second" };
893
+ var MINUTE_SPEC = { min: 0, max: 59, nameOffset: 0, label: "minute" };
894
+ var HOUR_SPEC = { min: 0, max: 23, nameOffset: 0, label: "hour" };
895
+ var DOM_SPEC = { min: 1, max: 31, nameOffset: 0, label: "day-of-month" };
896
+ var MONTH_SPEC = { min: 1, max: 12, names: MONTH_NAMES, nameOffset: 1, label: "month" };
897
+ var DOW_SPEC = { min: 0, max: 7, names: DOW_NAMES, nameOffset: 0, fold: (v) => v % 7, label: "day-of-week" };
898
+ function parseCron(expr) {
899
+ if (typeof expr !== "string" || !expr.trim()) throw new Error("cron expression is empty");
900
+ let trimmed = expr.trim();
901
+ if (trimmed.startsWith("@")) {
902
+ const macro = trimmed.toLowerCase();
903
+ if (macro === "@reboot") {
904
+ return {
905
+ seconds: /* @__PURE__ */ new Set([0]),
906
+ minutes: /* @__PURE__ */ new Set(),
907
+ hours: /* @__PURE__ */ new Set(),
908
+ daysOfMonth: /* @__PURE__ */ new Set(),
909
+ months: /* @__PURE__ */ new Set(),
910
+ daysOfWeek: /* @__PURE__ */ new Set(),
911
+ domRestricted: false,
912
+ dowRestricted: false,
913
+ reboot: true
914
+ };
915
+ }
916
+ const expanded = MACRO_EXPANSIONS[macro];
917
+ if (!expanded) throw new Error(`unknown cron macro "${trimmed}" (try @daily, @hourly, \u2026)`);
918
+ trimmed = expanded;
919
+ }
920
+ const parts = trimmed.split(/\s+/);
921
+ if (parts.length !== 5 && parts.length !== 6) {
922
+ throw new Error(`expected 5 fields (min hour dom month dow) or 6 with seconds, got ${parts.length}: "${trimmed}"`);
923
+ }
924
+ const hasSeconds = parts.length === 6;
925
+ const [secRaw, minRaw, hourRaw, domRaw, monthRaw, dowRaw] = hasSeconds ? parts : ["0", ...parts];
926
+ return {
927
+ seconds: expandField(secRaw, SECOND_SPEC),
928
+ minutes: expandField(minRaw, MINUTE_SPEC),
929
+ hours: expandField(hourRaw, HOUR_SPEC),
930
+ daysOfMonth: expandField(domRaw, DOM_SPEC),
931
+ months: expandField(monthRaw, MONTH_SPEC),
932
+ daysOfWeek: expandField(dowRaw, DOW_SPEC),
933
+ domRestricted: isRestricted(domRaw),
934
+ dowRestricted: isRestricted(dowRaw),
935
+ reboot: false
936
+ };
937
+ }
938
+ function isRestricted(field2) {
939
+ const f = field2.trim();
940
+ return f !== "*" && f !== "?";
941
+ }
942
+ function expandField(raw, spec) {
943
+ const set = /* @__PURE__ */ new Set();
944
+ for (const term of raw.split(",")) {
945
+ if (term === "") throw new Error(`empty term in ${spec.label} field "${raw}"`);
946
+ let base = term;
947
+ let step = 1;
948
+ const slash = term.indexOf("/");
949
+ if (slash >= 0) {
950
+ base = term.slice(0, slash);
951
+ const stepStr = term.slice(slash + 1);
952
+ if (!/^\d+$/.test(stepStr) || Number(stepStr) === 0) {
953
+ throw new Error(`invalid step "${stepStr}" in ${spec.label} field "${raw}"`);
954
+ }
955
+ step = Number(stepStr);
956
+ }
957
+ let lo;
958
+ let hi;
959
+ if (base === "*" || base === "?") {
960
+ lo = spec.min;
961
+ hi = spec.max;
962
+ } else {
963
+ const dash = base.indexOf("-");
964
+ if (dash > 0) {
965
+ lo = resolveValue2(base.slice(0, dash), spec);
966
+ hi = resolveValue2(base.slice(dash + 1), spec);
967
+ } else {
968
+ lo = resolveValue2(base, spec);
969
+ hi = step > 1 ? spec.max : lo;
970
+ }
971
+ }
972
+ if (lo === null || hi === null) {
973
+ throw new Error(`value out of range in ${spec.label} field "${raw}" (${spec.min}-${spec.max})`);
974
+ }
975
+ if (lo > hi) throw new Error(`range start ${lo} is greater than end ${hi} in ${spec.label} field "${raw}"`);
976
+ for (let v = lo; v <= hi; v += step) set.add(spec.fold ? spec.fold(v) : v);
977
+ }
978
+ return set;
979
+ }
980
+ function resolveValue2(token, spec) {
981
+ const t = token.trim();
982
+ if (/^\d+$/.test(t)) {
983
+ const n = Number(t);
984
+ return n >= spec.min && n <= spec.max ? n : null;
985
+ }
986
+ if (spec.names) {
987
+ const idx = spec.names.indexOf(t.toLowerCase());
988
+ if (idx >= 0) return idx + spec.nameOffset;
989
+ }
990
+ return null;
991
+ }
992
+ var SEARCH_HORIZON_YEARS = 5;
993
+ function nextOccurrence(expr, opts = {}) {
994
+ const parsed = typeof expr === "string" ? parseCron(expr) : expr;
995
+ if (parsed.reboot) throw new Error("@reboot has no scheduled next occurrence (the engine fires it once at start)");
996
+ const tz = opts.timeZone ?? "UTC";
997
+ const fromMs = opts.from === void 0 ? Date.now() : typeof opts.from === "number" ? opts.from : opts.from.getTime();
998
+ const cursor = Math.floor(fromMs / 1e3) * 1e3 + 1e3;
999
+ const p = getZonedParts(cursor, tz);
1000
+ const startYear = p.y;
1001
+ const hourArr = sorted(parsed.hours);
1002
+ const minuteArr = sorted(parsed.minutes);
1003
+ const secondArr = sorted(parsed.seconds);
1004
+ let guard = 0;
1005
+ while (guard++ < 1e6) {
1006
+ if (p.y > startYear + SEARCH_HORIZON_YEARS) return null;
1007
+ if (!parsed.months.has(p.mo)) {
1008
+ bumpMonth(p);
1009
+ continue;
1010
+ }
1011
+ if (p.d > daysInMonth(p.y, p.mo)) {
1012
+ bumpMonth(p);
1013
+ continue;
1014
+ }
1015
+ if (!dayMatches(parsed, p.y, p.mo, p.d)) {
1016
+ bumpDay(p);
1017
+ continue;
1018
+ }
1019
+ const nh = firstAtLeast(hourArr, p.h);
1020
+ if (nh === null) {
1021
+ bumpDay(p);
1022
+ continue;
1023
+ }
1024
+ if (nh !== p.h) {
1025
+ p.h = nh;
1026
+ p.mi = 0;
1027
+ p.s = 0;
1028
+ }
1029
+ const nmi = firstAtLeast(minuteArr, p.mi);
1030
+ if (nmi === null) {
1031
+ p.h += 1;
1032
+ p.mi = 0;
1033
+ p.s = 0;
1034
+ continue;
1035
+ }
1036
+ if (nmi !== p.mi) {
1037
+ p.mi = nmi;
1038
+ p.s = 0;
1039
+ }
1040
+ const ns = firstAtLeast(secondArr, p.s);
1041
+ if (ns === null) {
1042
+ p.mi += 1;
1043
+ p.s = 0;
1044
+ continue;
1045
+ }
1046
+ p.s = ns;
1047
+ const epoch = zonedWallToEpoch(p, tz);
1048
+ if (epoch <= fromMs) {
1049
+ p.s += 1;
1050
+ continue;
1051
+ }
1052
+ return new Date(epoch);
1053
+ }
1054
+ throw new Error(`nextOccurrence: search exceeded its iteration bound for "${typeof expr === "string" ? expr : "(parsed)"}"`);
1055
+ }
1056
+ function dayMatches(parsed, y, mo, d) {
1057
+ const domOk = parsed.daysOfMonth.has(d);
1058
+ const dowOk = parsed.daysOfWeek.has(weekdayOf(y, mo, d));
1059
+ if (parsed.domRestricted && parsed.dowRestricted) return domOk || dowOk;
1060
+ if (parsed.domRestricted) return domOk;
1061
+ if (parsed.dowRestricted) return dowOk;
1062
+ return true;
1063
+ }
1064
+ function bumpMonth(p) {
1065
+ p.mo += 1;
1066
+ if (p.mo > 12) {
1067
+ p.mo = 1;
1068
+ p.y += 1;
1069
+ }
1070
+ p.d = 1;
1071
+ p.h = 0;
1072
+ p.mi = 0;
1073
+ p.s = 0;
1074
+ }
1075
+ function bumpDay(p) {
1076
+ p.d += 1;
1077
+ p.h = 0;
1078
+ p.mi = 0;
1079
+ p.s = 0;
1080
+ }
1081
+ function sorted(set) {
1082
+ return [...set].sort((a, b) => a - b);
1083
+ }
1084
+ function firstAtLeast(arr, v) {
1085
+ for (const x of arr) if (x >= v) return x;
1086
+ return null;
1087
+ }
1088
+ function daysInMonth(year, month) {
1089
+ return new Date(Date.UTC(year, month, 0)).getUTCDate();
1090
+ }
1091
+ function weekdayOf(year, month, day) {
1092
+ return new Date(Date.UTC(year, month - 1, day)).getUTCDay();
1093
+ }
1094
+ var FORMATTER_CACHE = /* @__PURE__ */ new Map();
1095
+ function formatterFor(timeZone) {
1096
+ let fmt = FORMATTER_CACHE.get(timeZone);
1097
+ if (!fmt) {
1098
+ fmt = new Intl.DateTimeFormat("en-US", {
1099
+ timeZone,
1100
+ year: "numeric",
1101
+ month: "2-digit",
1102
+ day: "2-digit",
1103
+ hour: "2-digit",
1104
+ minute: "2-digit",
1105
+ second: "2-digit",
1106
+ hour12: false
1107
+ });
1108
+ FORMATTER_CACHE.set(timeZone, fmt);
1109
+ }
1110
+ return fmt;
1111
+ }
1112
+ function getZonedParts(epochMs, timeZone) {
1113
+ const parts = formatterFor(timeZone).formatToParts(new Date(epochMs));
1114
+ const m = {};
1115
+ for (const part of parts) if (part.type !== "literal") m[part.type] = part.value;
1116
+ let h = Number(m["hour"]);
1117
+ if (h === 24) h = 0;
1118
+ return { y: Number(m["year"]), mo: Number(m["month"]), d: Number(m["day"]), h, mi: Number(m["minute"]), s: Number(m["second"]) };
1119
+ }
1120
+ function offsetAt(epochMs, timeZone) {
1121
+ const p = getZonedParts(epochMs, timeZone);
1122
+ const asNaive = Date.UTC(p.y, p.mo - 1, p.d, p.h, p.mi, p.s);
1123
+ return asNaive - epochMs;
1124
+ }
1125
+ function zonedWallToEpoch(p, timeZone) {
1126
+ const asUTC = Date.UTC(p.y, p.mo - 1, p.d, p.h, p.mi, p.s);
1127
+ const o1 = offsetAt(asUTC, timeZone);
1128
+ let epoch = asUTC - o1;
1129
+ const o2 = offsetAt(epoch, timeZone);
1130
+ if (o2 !== o1) epoch = asUTC - o2;
1131
+ return epoch;
1132
+ }
1133
+ function previewSchedule(expr, opts = {}) {
1134
+ const parsed = parseCron(expr);
1135
+ const timeZone = opts.timeZone ?? localTimeZone();
1136
+ const count = Math.min(Math.max(Math.trunc(opts.count ?? 5), 1), 100);
1137
+ const out = [];
1138
+ let from = opts.from === void 0 ? Date.now() : typeof opts.from === "number" ? opts.from : opts.from.getTime();
1139
+ for (let i = 0; i < count; i++) {
1140
+ const next = nextOccurrence(parsed, { from, timeZone });
1141
+ if (!next) break;
1142
+ out.push(next);
1143
+ from = next.getTime();
1144
+ }
1145
+ return out;
1146
+ }
1147
+ function localTimeZone() {
1148
+ try {
1149
+ return new Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
1150
+ } catch {
1151
+ return "UTC";
1152
+ }
1153
+ }
1154
+
1155
+ // src/dev/engine.ts
1156
+ var DEFAULT_HISTORY_LIMIT = 100;
1157
+ var MAX_TIMER_MS = 2 ** 31 - 1;
1158
+ function defaultBackoff(attempt) {
1159
+ return Math.min(3e4, 500 * 2 ** (attempt - 1));
1160
+ }
1161
+ var realClock = {
1162
+ now: () => Date.now(),
1163
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
1164
+ clearTimeout: (handle) => clearTimeout(handle)
1165
+ };
1166
+ var RunTimeoutError = class extends Error {
1167
+ constructor(key, ms) {
1168
+ super(`job '${key}' timed out after ${ms}ms`);
1169
+ this.name = "RunTimeoutError";
1170
+ }
1171
+ };
1172
+ function createLocalEngine(jobs, runner, options = {}) {
1173
+ return new LocalEngine(jobs, runner, options);
1174
+ }
1175
+ var LocalEngine = class {
1176
+ clock;
1177
+ /** Every event listener. The constructor's `onEvent` is registered as one of them. */
1178
+ listeners = /* @__PURE__ */ new Set();
1179
+ /** Run after the engine has drained, e.g. to close a dashboard server. */
1180
+ closeHooks = [];
1181
+ historyLimit;
1182
+ backoff;
1183
+ runner;
1184
+ states;
1185
+ history = [];
1186
+ inFlight = /* @__PURE__ */ new Set();
1187
+ started = false;
1188
+ stopped = false;
1189
+ signalCleanup = null;
1190
+ constructor(jobs, runner, options = {}) {
1191
+ this.clock = options.clock ?? realClock;
1192
+ if (options.onEvent) this.listeners.add(options.onEvent);
1193
+ this.historyLimit = Math.max(1, options.historyLimit ?? DEFAULT_HISTORY_LIMIT);
1194
+ this.backoff = options.backoff ?? defaultBackoff;
1195
+ this.runner = runner;
1196
+ this.states = jobs.map((job) => ({
1197
+ job,
1198
+ nextFire: null,
1199
+ timer: null,
1200
+ running: false,
1201
+ isReboot: job.schedule.trim().toLowerCase() === "@reboot"
1202
+ }));
1203
+ if (options.installSignalHandlers) this.installSignalHandlers();
1204
+ }
1205
+ /**
1206
+ * Subscribe to every lifecycle event (in addition to the constructor's `onEvent`). Returns an
1207
+ * unsubscribe function. Used by the local dashboard to fan events out to many SSE clients without
1208
+ * disturbing the scheduler. A listener that throws is isolated — it can't break the loop.
1209
+ */
1210
+ subscribe(listener) {
1211
+ this.listeners.add(listener);
1212
+ return () => {
1213
+ this.listeners.delete(listener);
1214
+ };
1215
+ }
1216
+ /**
1217
+ * Register a hook to run once, after {@link stop} has drained in-flight runs — e.g. to close a
1218
+ * dashboard server so `engine.stop()` tears everything down together. Hooks are awaited.
1219
+ */
1220
+ onStop(hook) {
1221
+ this.closeHooks.push(hook);
1222
+ return this;
1223
+ }
1224
+ /** Deliver an event to every listener, isolating each so one bad listener can't stall the loop. */
1225
+ emit(event) {
1226
+ for (const listener of this.listeners) {
1227
+ try {
1228
+ listener(event);
1229
+ } catch {
1230
+ }
1231
+ }
1232
+ }
1233
+ /** Begin scheduling. Idempotent — a second call is a no-op. */
1234
+ start() {
1235
+ if (this.started) return this;
1236
+ this.started = true;
1237
+ this.stopped = false;
1238
+ this.emit({ type: "engine-start", jobs: this.states.length, at: this.clock.now() });
1239
+ for (const state of this.states) {
1240
+ if (state.isReboot) {
1241
+ this.launch(state);
1242
+ } else {
1243
+ this.scheduleNext(state);
1244
+ }
1245
+ }
1246
+ return this;
1247
+ }
1248
+ /**
1249
+ * Stop scheduling and wait for in-flight runs to settle. After this resolves no further handlers
1250
+ * will start. Safe to call from a signal handler.
1251
+ */
1252
+ async stop() {
1253
+ if (this.stopped) {
1254
+ await Promise.allSettled([...this.inFlight]);
1255
+ return;
1256
+ }
1257
+ this.stopped = true;
1258
+ for (const state of this.states) {
1259
+ if (state.timer !== null) {
1260
+ this.clock.clearTimeout(state.timer);
1261
+ state.timer = null;
1262
+ }
1263
+ }
1264
+ this.signalCleanup?.();
1265
+ this.signalCleanup = null;
1266
+ await Promise.allSettled([...this.inFlight]);
1267
+ this.emit({ type: "engine-stop", at: this.clock.now() });
1268
+ for (const hook of this.closeHooks.splice(0)) {
1269
+ try {
1270
+ await hook();
1271
+ } catch {
1272
+ }
1273
+ }
1274
+ }
1275
+ /** The run history, newest first (a copy — safe to keep). */
1276
+ runs() {
1277
+ return [...this.history].reverse();
1278
+ }
1279
+ /** History entries for one job, newest first. */
1280
+ runsFor(key) {
1281
+ return this.runs().filter((r) => r.key === key);
1282
+ }
1283
+ /** Current scheduling state of every job, for the dev table. */
1284
+ snapshot() {
1285
+ return this.states.map((s) => ({
1286
+ key: s.job.key,
1287
+ schedule: s.job.schedule,
1288
+ timeZone: s.job.timeZone,
1289
+ ...s.job.description !== void 0 ? { description: s.job.description } : {},
1290
+ nextFire: s.nextFire !== null ? new Date(s.nextFire) : null,
1291
+ running: s.running
1292
+ }));
1293
+ }
1294
+ /** The jobs this engine manages (read-only view). */
1295
+ jobs() {
1296
+ return this.states.map((s) => s.job);
1297
+ }
1298
+ /** Number of runs currently in flight. */
1299
+ get activeRuns() {
1300
+ return this.inFlight.size;
1301
+ }
1302
+ // ── Scheduling ─────────────────────────────────────────────────────────────
1303
+ scheduleNext(state) {
1304
+ if (this.stopped) return;
1305
+ const now = this.clock.now();
1306
+ let next;
1307
+ try {
1308
+ next = nextOccurrence(state.job.schedule, { from: now, timeZone: state.job.timeZone });
1309
+ } catch {
1310
+ state.nextFire = null;
1311
+ return;
1312
+ }
1313
+ if (!next) {
1314
+ state.nextFire = null;
1315
+ return;
1316
+ }
1317
+ state.nextFire = next.getTime();
1318
+ const delay = Math.max(0, Math.min(state.nextFire - now, MAX_TIMER_MS));
1319
+ state.timer = this.clock.setTimeout(() => this.onDue(state), delay);
1320
+ this.emit({ type: "scheduled", key: state.job.key, at: state.nextFire });
1321
+ }
1322
+ onDue(state) {
1323
+ state.timer = null;
1324
+ if (this.stopped) return;
1325
+ const now = this.clock.now();
1326
+ if (state.nextFire !== null && now < state.nextFire) {
1327
+ const delay = Math.max(0, Math.min(state.nextFire - now, MAX_TIMER_MS));
1328
+ state.timer = this.clock.setTimeout(() => this.onDue(state), delay);
1329
+ return;
1330
+ }
1331
+ if (state.running && !state.job.allowConcurrentRuns) {
1332
+ this.emit({ type: "skipped", key: state.job.key, reason: "overlap", at: now });
1333
+ this.record({
1334
+ key: state.job.key,
1335
+ source: "local",
1336
+ startedAt: now,
1337
+ finishedAt: now,
1338
+ durationMs: 0,
1339
+ status: "skipped",
1340
+ attempts: 0
1341
+ });
1342
+ } else {
1343
+ this.launch(state);
1344
+ }
1345
+ this.scheduleNext(state);
1346
+ }
1347
+ /**
1348
+ * Run a job once, right now, by key — the local equivalent of "run now" in the cloud. Goes through
1349
+ * the exact same execution path as a scheduled fire (overlap protection, timeout, retry/backoff,
1350
+ * history + events), so a manual run shows up in the feed just like any other. Resolves with the
1351
+ * resulting {@link RunRecord}. Throws for an unknown key or after the engine has stopped.
1352
+ */
1353
+ async trigger(key) {
1354
+ if (this.stopped) throw new Error("cannot trigger a job on a stopped engine");
1355
+ const state = this.states.find((s) => s.job.key === key);
1356
+ if (!state) throw new Error(`unknown job '${key}'`);
1357
+ if (state.running && !state.job.allowConcurrentRuns) {
1358
+ const now = this.clock.now();
1359
+ const skipped = { key, source: "local", startedAt: now, finishedAt: now, durationMs: 0, status: "skipped", attempts: 0 };
1360
+ this.emit({ type: "skipped", key, reason: "overlap", at: now });
1361
+ this.record(skipped);
1362
+ return skipped;
1363
+ }
1364
+ return this.launch(state);
1365
+ }
1366
+ /**
1367
+ * The run history as newline-delimited JSON (NDJSON), oldest run first — one record per line, the
1368
+ * natural shape for piping to a file or another tool. No trailing newline.
1369
+ */
1370
+ toNdjson() {
1371
+ return this.history.map((r) => JSON.stringify(r)).join("\n");
1372
+ }
1373
+ /** Start an execution and track it so {@link stop} can await it. Resolves with the run's record. */
1374
+ launch(state) {
1375
+ const promise = this.execute(state);
1376
+ const tracked = promise.finally(() => {
1377
+ this.inFlight.delete(tracked);
1378
+ });
1379
+ this.inFlight.add(tracked);
1380
+ return tracked;
1381
+ }
1382
+ // ── Execution with timeout + retry/backoff ──────────────────────────────────
1383
+ async execute(state) {
1384
+ const job = state.job;
1385
+ state.running = true;
1386
+ const startedAt = this.clock.now();
1387
+ const maxRetries = Math.max(0, job.maxRetries ?? 0);
1388
+ let attempt = 0;
1389
+ let status = "error";
1390
+ let error;
1391
+ let result;
1392
+ while (!this.stopped) {
1393
+ attempt++;
1394
+ this.emit({ type: "fire", key: job.key, attempt, at: this.clock.now() });
1395
+ try {
1396
+ result = await this.runOnce(job);
1397
+ status = "success";
1398
+ this.emit({ type: "success", key: job.key, durationMs: this.clock.now() - startedAt, attempts: attempt, result, at: this.clock.now() });
1399
+ error = void 0;
1400
+ break;
1401
+ } catch (err) {
1402
+ const timedOut = err instanceof RunTimeoutError;
1403
+ status = timedOut ? "timed_out" : "error";
1404
+ error = err instanceof Error ? err.message : String(err);
1405
+ const willRetry = attempt <= maxRetries && !this.stopped;
1406
+ const at = this.clock.now();
1407
+ if (timedOut) {
1408
+ this.emit({ type: "timeout", key: job.key, durationMs: at - startedAt, attempt, willRetry, at });
1409
+ } else {
1410
+ this.emit({ type: "error", key: job.key, durationMs: at - startedAt, attempt, willRetry, error, at });
1411
+ }
1412
+ if (!willRetry) break;
1413
+ const delayMs = this.backoff(attempt);
1414
+ this.emit({ type: "retry", key: job.key, attempt: attempt + 1, delayMs, at });
1415
+ await this.sleep(delayMs);
1416
+ }
1417
+ }
1418
+ const finishedAt = this.clock.now();
1419
+ const record = {
1420
+ key: job.key,
1421
+ source: "local",
1422
+ startedAt,
1423
+ finishedAt,
1424
+ durationMs: finishedAt - startedAt,
1425
+ status,
1426
+ attempts: attempt,
1427
+ ...error !== void 0 ? { error } : {},
1428
+ ...status === "success" ? { result } : {}
1429
+ };
1430
+ this.record(record);
1431
+ state.running = false;
1432
+ return record;
1433
+ }
1434
+ /** A single attempt: run the handler, racing it against the per-job timeout. */
1435
+ runOnce(job) {
1436
+ const controller = new AbortController();
1437
+ const handlerPromise = this.runner(job.key, controller.signal);
1438
+ const timeoutMs = job.timeoutMs;
1439
+ if (!timeoutMs || timeoutMs <= 0) return handlerPromise;
1440
+ return new Promise((resolve, reject) => {
1441
+ let settled = false;
1442
+ const timer = this.clock.setTimeout(() => {
1443
+ if (settled) return;
1444
+ settled = true;
1445
+ controller.abort();
1446
+ reject(new RunTimeoutError(job.key, timeoutMs));
1447
+ }, timeoutMs);
1448
+ handlerPromise.then(
1449
+ (value) => {
1450
+ if (settled) return;
1451
+ settled = true;
1452
+ this.clock.clearTimeout(timer);
1453
+ resolve(value);
1454
+ },
1455
+ (err) => {
1456
+ if (settled) return;
1457
+ settled = true;
1458
+ this.clock.clearTimeout(timer);
1459
+ reject(err);
1460
+ }
1461
+ );
1462
+ });
1463
+ }
1464
+ sleep(ms) {
1465
+ return new Promise((resolve) => this.clock.setTimeout(resolve, ms));
1466
+ }
1467
+ record(record) {
1468
+ this.history.push(record);
1469
+ if (this.history.length > this.historyLimit) this.history.shift();
1470
+ }
1471
+ installSignalHandlers() {
1472
+ const handler = () => {
1473
+ void this.stop().then(() => process.exit(0));
1474
+ };
1475
+ process.once("SIGINT", handler);
1476
+ process.once("SIGTERM", handler);
1477
+ this.signalCleanup = () => {
1478
+ process.removeListener("SIGINT", handler);
1479
+ process.removeListener("SIGTERM", handler);
1480
+ };
1481
+ }
1482
+ };
1483
+
880
1484
  // src/registry/define.ts
881
1485
  var DEFAULT_DISPATCH_PATH = "/cronvello/dispatch";
882
1486
  var DEFAULT_TIME_ZONE = "Europe/Berlin";
@@ -970,10 +1574,43 @@ function defineCronvello(config) {
970
1574
  handle: dispatcher.handle,
971
1575
  expressHandler: () => expressHandler(app),
972
1576
  nextHandler: () => nextHandler(app),
1577
+ dev(options) {
1578
+ const { autoStart = true, dashboard, ...engineOptions } = options ?? {};
1579
+ const engineJobs = buildEngineJobs([...jobs.values()], defaultTimeZone);
1580
+ const runner = (key, signal) => dispatcher.runLocal(key, void 0, { signal });
1581
+ const engine = createLocalEngine(engineJobs, runner, engineOptions);
1582
+ if (autoStart) engine.start();
1583
+ if (dashboard) {
1584
+ const dashboardOptions = dashboard === true ? {} : dashboard;
1585
+ const devSubpath = "@cronvello/sdk/dev";
1586
+ void import(devSubpath).then(({ startDashboard }) => startDashboard(engine, dashboardOptions)).then((handle) => {
1587
+ engine.onStop(() => handle.close());
1588
+ console.log(`Cronvello dashboard \u2192 ${handle.url}`);
1589
+ }).catch((err) => console.error(`Cronvello dashboard failed to start: ${err.message}`));
1590
+ }
1591
+ return engine;
1592
+ },
973
1593
  keys: () => [...jobs.keys()]
974
1594
  };
975
1595
  return app;
976
1596
  }
1597
+ function buildEngineJobs(jobs, defaultTimeZone) {
1598
+ const engineJobs = [];
1599
+ for (const { key, config } of jobs) {
1600
+ if (config.enabled === false) continue;
1601
+ const job = {
1602
+ key,
1603
+ schedule: config.schedule,
1604
+ timeZone: config.timeZone ?? defaultTimeZone,
1605
+ allowConcurrentRuns: config.allowConcurrentRuns ?? false,
1606
+ maxRetries: config.maxRetries ?? 0
1607
+ };
1608
+ if (config.callbackTimeoutMs !== void 0) job.timeoutMs = config.callbackTimeoutMs;
1609
+ if (config.description !== void 0) job.description = config.description;
1610
+ engineJobs.push(job);
1611
+ }
1612
+ return engineJobs;
1613
+ }
977
1614
  ((defineCronvello2) => {
978
1615
  function fromEnv(config, env = typeof process !== "undefined" ? process.env : {}) {
979
1616
  const apiKey = config.apiKey ?? env["CRONVELLO_API_KEY"];
@@ -1102,8 +1739,8 @@ function formatSyncResult(result, options = {}) {
1102
1739
  `${paint("Cronvello", "cyan")} ${verb} ${paint(`"${result.jobName}"`, "bold")} ${paint(`(${result.jobId})`, "gray")}`
1103
1740
  );
1104
1741
  const order = ["created", "updated", "deleted", "skipped", "unchanged"];
1105
- const sorted = [...result.changes].sort((a, b) => order.indexOf(a.action) - order.indexOf(b.action));
1106
- for (const c of sorted) {
1742
+ const sorted2 = [...result.changes].sort((a, b) => order.indexOf(a.action) - order.indexOf(b.action));
1743
+ for (const c of sorted2) {
1107
1744
  const g = GLYPH[c.action];
1108
1745
  const detail = c.changedFields && c.changedFields.length ? paint(` (${c.changedFields.join(", ")})`, "gray") : c.reason ? paint(` (${c.reason})`, "gray") : "";
1109
1746
  lines.push(` ${paint(g.sign, g.color)} ${paint(c.action.padEnd(9), g.color)} ${c.key}${detail}`);
@@ -1225,6 +1862,6 @@ function generateDispatchSecret(bytes = 32) {
1225
1862
  return Array.from(buf).map((b) => b.toString(16).padStart(2, "0")).join("");
1226
1863
  }
1227
1864
 
1228
- export { CRONVELLO_DEFAULT_BASE_URL, CronvelloApiError, CronvelloClient, CronvelloConfigError, CronvelloError, CronvelloNetworkError, cron, daily, defineCronvello, every, everyHours, everyMinutes, formatSyncResult, generateDispatchSecret, hourly, isValidTimeZone, monthly, schedule, validateCron, weekdays, weekends, weekly };
1865
+ export { CRONVELLO_DEFAULT_BASE_URL, CronvelloApiError, CronvelloClient, CronvelloConfigError, CronvelloError, CronvelloNetworkError, cron, daily, defineCronvello, every, everyHours, everyMinutes, formatSyncResult, generateDispatchSecret, hourly, isValidTimeZone, monthly, nextOccurrence, previewSchedule, schedule, validateCron, weekdays, weekends, weekly };
1229
1866
  //# sourceMappingURL=index.js.map
1230
1867
  //# sourceMappingURL=index.js.map