@apisurf/ngn-core 0.2.6 → 0.2.8

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.
Files changed (3) hide show
  1. package/dist/core.d.ts +459 -11
  2. package/dist/core.js +1244 -238
  3. package/package.json +8 -7
package/dist/core.js CHANGED
@@ -133,7 +133,6 @@ __exports;`);
133
133
  ...buildGlobals(),
134
134
  require: createRequire(requireResolutionRoot),
135
135
  // Provide CommonJS-style globals for bundled dependencies that expect them
136
- // (e.g., node-cron uses __dirname for its daemon path)
137
136
  __dirname: process.cwd(),
138
137
  __filename: `${process.cwd()}/vm-script.js`
139
138
  });
@@ -314,6 +313,10 @@ var Entry = class {
314
313
  return {
315
314
  task: taskFunction,
316
315
  timing: taskExports.timing,
316
+ overlap: taskExports.overlap,
317
+ budget: taskExports.budget,
318
+ exclusive: taskExports.exclusive,
319
+ retry: taskExports.retry,
317
320
  shouldSkip: taskExports.shouldSkip,
318
321
  shouldRetry: taskExports.shouldRetry,
319
322
  onSuccess: taskExports.onSuccess,
@@ -342,18 +345,843 @@ var Entry = class {
342
345
  }
343
346
  };
344
347
 
348
+ // src/runtime/duration.ts
349
+ var UNIT_MS = { ms: 1, s: 1e3, m: 6e4, h: 36e5, d: 864e5 };
350
+ function toMs(value, what = "duration") {
351
+ if (typeof value === "number") {
352
+ if (Number.isFinite(value) && value >= 0) return value;
353
+ } else {
354
+ const match = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)\s*$/.exec(value);
355
+ if (match) return Number(match[1]) * UNIT_MS[match[2]];
356
+ }
357
+ throw new Error(
358
+ `Invalid ${what} ${JSON.stringify(value)}: expected milliseconds or a string like "30s", "4m"`
359
+ );
360
+ }
361
+
362
+ // src/runtime/runWindow.ts
363
+ function runDeadline({
364
+ startedAt,
365
+ scheduledAt,
366
+ nextAt,
367
+ budgetMs
368
+ }) {
369
+ const candidates = [];
370
+ if (nextAt != null) {
371
+ const period = nextAt - (scheduledAt ?? startedAt);
372
+ candidates.push(nextAt - Math.min(5e3, Math.max(0, period) * 0.1));
373
+ }
374
+ if (budgetMs != null) candidates.push(startedAt + budgetMs);
375
+ return candidates.length ? Math.floor(Math.min(...candidates)) : null;
376
+ }
377
+ function deadlineSignal(deadline) {
378
+ const controller = new AbortController();
379
+ if (deadline == null) return { signal: controller.signal, dispose: () => {
380
+ } };
381
+ const abort = () => controller.abort(new Error("The run reached its deadline"));
382
+ const delay = deadline - Date.now();
383
+ if (delay <= 0) {
384
+ abort();
385
+ return { signal: controller.signal, dispose: () => {
386
+ } };
387
+ }
388
+ const timer = setTimeout(abort, delay);
389
+ timer.unref();
390
+ return { signal: controller.signal, dispose: () => clearTimeout(timer) };
391
+ }
392
+
393
+ // src/runtime/locks.ts
394
+ var LockBusyError = class extends Error {
395
+ constructor(lockName) {
396
+ super(`Lock "${lockName}" is held by another run`);
397
+ this.lockName = lockName;
398
+ }
399
+ name = "LockBusyError";
400
+ };
401
+ var DEFAULT_TTL_MS = 6e4;
402
+ var MAX_POLL_MS = 1e3;
403
+ var sleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
404
+ function assertLockName(name) {
405
+ if (typeof name !== "string" || !name.trim()) {
406
+ throw new Error(`A lock name must be a non-empty string, got ${JSON.stringify(name)}`);
407
+ }
408
+ }
409
+ function createRunLocks(service, taskRunId) {
410
+ async function tryHold(name, ttlMs) {
411
+ const holder = await service.tryAcquire(name, ttlMs, taskRunId);
412
+ if (!holder) return null;
413
+ const renewal = setInterval(() => {
414
+ service.renew(name, holder, ttlMs).catch(() => {
415
+ });
416
+ }, ttlMs / 3);
417
+ renewal.unref();
418
+ return {
419
+ async release() {
420
+ clearInterval(renewal);
421
+ await service.release(name, holder);
422
+ }
423
+ };
424
+ }
425
+ async function hold(name, ttlMs, waitUntil) {
426
+ for (let pause = 25; ; pause = Math.min(pause * 2, MAX_POLL_MS)) {
427
+ const held = await tryHold(name, ttlMs);
428
+ if (held) return held;
429
+ const left = waitUntil - Date.now();
430
+ if (left <= 0) return null;
431
+ await sleep(Math.min(pause, left));
432
+ }
433
+ }
434
+ return {
435
+ /** `ctx.lock`: run `fn` holding `name`, released however `fn` ends. */
436
+ async lock(name, fn, options, deadline) {
437
+ assertLockName(name);
438
+ const ttlMs = options.ttl == null ? DEFAULT_TTL_MS : toMs(options.ttl, "lock ttl");
439
+ const { wait = true } = options;
440
+ const waitUntil = wait === true ? deadline ?? Number.POSITIVE_INFINITY : wait === false ? Date.now() : Date.now() + toMs(wait, "lock wait");
441
+ const held = await hold(name, ttlMs, waitUntil);
442
+ if (!held) throw new LockBusyError(name);
443
+ try {
444
+ return await fn();
445
+ } finally {
446
+ await held.release();
447
+ }
448
+ },
449
+ /**
450
+ * Take every lock in `names` without waiting, for `exclusive`. Names are
451
+ * taken in sorted order so two runs asking for overlapping sets cannot
452
+ * each hold half. If any is busy, the ones already taken are given back.
453
+ */
454
+ async holdAll(names) {
455
+ const taken = [];
456
+ const releaseAll = async () => {
457
+ await Promise.all(taken.map((held) => held.release()));
458
+ };
459
+ for (const name of [...new Set(names)].toSorted()) {
460
+ const held = await tryHold(name, DEFAULT_TTL_MS);
461
+ if (!held) {
462
+ await releaseAll();
463
+ return { busy: name };
464
+ }
465
+ taken.push(held);
466
+ }
467
+ return { release: releaseAll };
468
+ }
469
+ };
470
+ }
471
+ function exclusiveNames(value, taskPath) {
472
+ if (value == null) return [];
473
+ const names = Array.isArray(value) ? value : [value];
474
+ for (const name of names) {
475
+ if (typeof name !== "string" || !name.trim()) {
476
+ throw new Error(
477
+ `Invalid exclusive for ${taskPath}: expected a lock name or a list of them, got ${JSON.stringify(value)}`
478
+ );
479
+ }
480
+ }
481
+ return names;
482
+ }
483
+
484
+ // src/runtime/taskSqlite.ts
485
+ import { mkdirSync } from "node:fs";
486
+ import { unlink } from "node:fs/promises";
487
+ import { basename, dirname, extname, isAbsolute, join, resolve, sep } from "node:path";
488
+ import {
489
+ openDbClient
490
+ } from "@apisurf/ngn-persistence";
491
+ var openClients = /* @__PURE__ */ new Map();
492
+ async function runMigrations(client, migrations) {
493
+ if (!migrations.length) return;
494
+ await client.execute(`
495
+ CREATE TABLE IF NOT EXISTS _ngn_migrations (
496
+ id TEXT PRIMARY KEY,
497
+ applied_at TEXT NOT NULL
498
+ )
499
+ `);
500
+ const applied = await client.execute("SELECT id FROM _ngn_migrations");
501
+ const appliedIds = new Set(applied.rows.map((r) => r.id));
502
+ for (const migration of migrations) {
503
+ if (appliedIds.has(migration.id)) continue;
504
+ await client.execute(migration.up);
505
+ await client.execute({
506
+ sql: "INSERT INTO _ngn_migrations (id, applied_at) VALUES (?, ?)",
507
+ args: [migration.id, (/* @__PURE__ */ new Date()).toISOString()]
508
+ });
509
+ }
510
+ }
511
+ function resolveTaskDbPath(taskDir, file) {
512
+ if (typeof file !== "string" || !file.trim()) {
513
+ throw new Error("A database file name is required");
514
+ }
515
+ const relativePath = file.startsWith("file:") ? file.slice(5) : file;
516
+ if (!relativePath) {
517
+ throw new Error("A database file name is required");
518
+ }
519
+ if (isAbsolute(relativePath)) {
520
+ throw new Error(
521
+ `Database file must be relative to the task folder, got an absolute path: "${file}"`
522
+ );
523
+ }
524
+ const resolved = resolve(taskDir, relativePath);
525
+ const taskDirRoot = taskDir.endsWith(sep) ? taskDir : `${taskDir}${sep}`;
526
+ if (!resolved.startsWith(taskDirRoot)) {
527
+ throw new Error(
528
+ `Database file "${file}" resolves outside the task folder (${taskDir}). A task can only manage databases in its own folder.`
529
+ );
530
+ }
531
+ return resolved;
532
+ }
533
+ function getClient(absolutePath) {
534
+ const existing = openClients.get(absolutePath);
535
+ if (existing) {
536
+ return existing;
537
+ }
538
+ mkdirSync(dirname(absolutePath), { recursive: true });
539
+ const client = openDbClient(`file:${absolutePath}`);
540
+ openClients.set(absolutePath, client);
541
+ return client;
542
+ }
543
+ function closeClient(absolutePath) {
544
+ const client = openClients.get(absolutePath);
545
+ if (client) {
546
+ client.close();
547
+ openClients.delete(absolutePath);
548
+ }
549
+ }
550
+ function createTaskSqlite(taskSourcePath) {
551
+ const taskDir = dirname(taskSourcePath);
552
+ const defaultDbPath = join(taskDir, `${basename(taskSourcePath, extname(taskSourcePath))}.db`);
553
+ const defaultClient = () => getClient(defaultDbPath);
554
+ return {
555
+ get client() {
556
+ return defaultClient();
557
+ },
558
+ get path() {
559
+ return defaultDbPath;
560
+ },
561
+ execute: (sql, args) => defaultClient().execute(args ? { sql, args } : sql),
562
+ batch: (statements) => defaultClient().batch(statements),
563
+ async initDB(options) {
564
+ const dbPath = resolveTaskDbPath(taskDir, options.file);
565
+ const client = getClient(dbPath);
566
+ if (options.migrations?.length) {
567
+ await runMigrations(client, options.migrations);
568
+ }
569
+ return {
570
+ client,
571
+ path: dbPath,
572
+ execute: (sql, args) => client.execute(args ? { sql, args } : sql),
573
+ batch: (statements) => client.batch(statements),
574
+ close: () => closeClient(dbPath)
575
+ };
576
+ },
577
+ async destroyDB(file) {
578
+ const dbPath = resolveTaskDbPath(taskDir, file);
579
+ closeClient(dbPath);
580
+ await unlink(dbPath);
581
+ }
582
+ };
583
+ }
584
+ function closeTaskDatabases() {
585
+ for (const client of openClients.values()) {
586
+ client.close();
587
+ }
588
+ openClients.clear();
589
+ }
590
+
591
+ // src/runtime/queue/schema.ts
592
+ var QUEUE_MIGRATIONS = [
593
+ {
594
+ id: "ngn-queue-0001",
595
+ up: `CREATE TABLE IF NOT EXISTS _ngn_jobs (
596
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
597
+ queue TEXT NOT NULL,
598
+ key TEXT,
599
+ grp TEXT,
600
+ payload TEXT,
601
+ priority INTEGER NOT NULL DEFAULT 0,
602
+ status TEXT NOT NULL DEFAULT 'ready',
603
+ attempts INTEGER NOT NULL DEFAULT 0,
604
+ max_attempts INTEGER NOT NULL,
605
+ run_after INTEGER NOT NULL,
606
+ lease_until INTEGER,
607
+ last_error TEXT,
608
+ created_at INTEGER NOT NULL,
609
+ updated_at INTEGER NOT NULL,
610
+ UNIQUE (queue, key)
611
+ )`
612
+ },
613
+ {
614
+ id: "ngn-queue-0002",
615
+ up: "CREATE INDEX IF NOT EXISTS _ngn_jobs_claim_idx ON _ngn_jobs (queue, status, run_after)"
616
+ }
617
+ ];
618
+
619
+ // src/runtime/queue/work.ts
620
+ function untilSignal(until) {
621
+ if (until === void 0) return new AbortController().signal;
622
+ if (typeof until !== "number") return until;
623
+ const left = until - Date.now();
624
+ return left <= 0 ? AbortSignal.abort() : AbortSignal.timeout(left);
625
+ }
626
+ var WorkRun = class {
627
+ constructor(queue, handler, options) {
628
+ this.queue = queue;
629
+ this.handler = handler;
630
+ this.options = options;
631
+ this.signal = untilSignal(options.until);
632
+ this.concurrency = Math.max(1, Math.floor(options.concurrency ?? 1));
633
+ this.max = options.max ?? Number.POSITIVE_INFINITY;
634
+ }
635
+ buffer = [];
636
+ /** Groups with a job buffered or being worked. */
637
+ busyGroups = /* @__PURE__ */ new Set();
638
+ stopped = /* @__PURE__ */ new Set();
639
+ perGroupCount = /* @__PURE__ */ new Map();
640
+ capped = /* @__PURE__ */ new Set();
641
+ /**
642
+ * Every job this call has claimed. Never claimed twice: without a next tick
643
+ * to back off to (run:once), a failed or released job is due again at once,
644
+ * and the call would spend all its attempts in one go.
645
+ */
646
+ seen = /* @__PURE__ */ new Set();
647
+ signal;
648
+ concurrency;
649
+ max;
650
+ claiming = null;
651
+ claimed = 0;
652
+ working = 0;
653
+ /** Workers waiting for a job in hand to finish and free its group. */
654
+ waiting = [];
655
+ stoppedBy = "empty";
656
+ /** Set when a worker breaks: the others finish what they hold and stop. */
657
+ broken = false;
658
+ tally = { done: 0, failed: 0, dead: 0, released: 0 };
659
+ async run() {
660
+ const workers = Array.from({ length: this.concurrency }, (_, index) => this.worker(index));
661
+ try {
662
+ const results = await Promise.allSettled(workers);
663
+ const failure = results.find((result) => result.status === "rejected");
664
+ if (failure) throw failure.reason;
665
+ } finally {
666
+ await this.releaseJobs(this.buffer.splice(0), { kind: "release" });
667
+ }
668
+ const stats = await this.queue.stats();
669
+ return {
670
+ ...this.tally,
671
+ remaining: stats.due,
672
+ oldestAgeMs: stats.oldestReadyAgeMs,
673
+ stoppedGroups: [...this.stopped],
674
+ stoppedBy: this.stoppedBy
675
+ };
676
+ }
677
+ shouldStop() {
678
+ if (this.broken) return true;
679
+ if (this.signal.aborted) this.stoppedBy = "deadline";
680
+ return this.signal.aborted;
681
+ }
682
+ /**
683
+ * The next job for a worker: one from the buffer, refilled when empty.
684
+ * `"empty"` when nothing is claimable and no job in hand could change that;
685
+ * null to ask again (after a refill, a wait, or a job put back).
686
+ */
687
+ async nextJob() {
688
+ const job = this.buffer.shift();
689
+ if (job && this.atCap(job.group)) {
690
+ this.capped.add(job.group);
691
+ await this.releaseJobs([job], { kind: "release" });
692
+ return null;
693
+ }
694
+ if (job) return job;
695
+ if (await this.refill()) return null;
696
+ if (this.working === 0) return "empty";
697
+ await new Promise((resolve2) => this.waiting.push(resolve2));
698
+ return null;
699
+ }
700
+ async worker(index) {
701
+ let resource = null;
702
+ try {
703
+ for (; ; ) {
704
+ if (this.shouldStop()) return;
705
+ const job = await this.nextJob();
706
+ if (job === "empty") return;
707
+ if (!job) continue;
708
+ try {
709
+ resource ??= { value: await this.options.worker?.setup?.(index) };
710
+ } catch (error) {
711
+ this.buffer.unshift(job);
712
+ throw error;
713
+ }
714
+ await this.work(job, index, resource.value);
715
+ }
716
+ } catch (error) {
717
+ this.broken = true;
718
+ throw error;
719
+ } finally {
720
+ if (resource) await this.options.worker?.teardown?.(resource.value, index);
721
+ }
722
+ }
723
+ /** Claim up to one job per free worker. Shared by workers that ask at once. */
724
+ refill() {
725
+ this.claiming ??= this.claim().finally(() => this.claiming = null);
726
+ return this.claiming;
727
+ }
728
+ async claim() {
729
+ const room = Math.min(this.concurrency, this.max - this.claimed);
730
+ if (room <= 0) {
731
+ this.stoppedBy = "max";
732
+ return 0;
733
+ }
734
+ const jobs = await this.queue.claim({
735
+ limit: room,
736
+ perGroup: 1,
737
+ skipGroups: [...this.busyGroups, ...this.stopped, ...this.capped],
738
+ skipIds: [...this.seen]
739
+ });
740
+ this.claimed += jobs.length;
741
+ for (const job of jobs) {
742
+ this.seen.add(job.id);
743
+ if (job.group != null) this.busyGroups.add(job.group);
744
+ this.buffer.push(job);
745
+ }
746
+ if (this.claimed >= this.max) this.stoppedBy = "max";
747
+ return jobs.length;
748
+ }
749
+ async work(job, index, resource) {
750
+ this.working++;
751
+ this.countForGroup(job.group);
752
+ let settle = null;
753
+ const handle = {
754
+ index,
755
+ resource,
756
+ signal: this.signal,
757
+ release: (options = {}) => {
758
+ settle ??= { kind: "release", ...options };
759
+ },
760
+ stopGroup: (options = {}) => {
761
+ settle ??= { kind: "stopGroup", ...options };
762
+ }
763
+ };
764
+ try {
765
+ try {
766
+ await this.handler(job, handle);
767
+ const asked = settle;
768
+ if (asked) await this.settleAsked(job, asked);
769
+ else if (await this.queue.complete(job)) this.tally.done++;
770
+ } catch (error) {
771
+ await this.failJob(job, error);
772
+ }
773
+ } finally {
774
+ if (job.group != null) this.busyGroups.delete(job.group);
775
+ this.working--;
776
+ for (const resolve2 of this.waiting.splice(0)) resolve2();
777
+ }
778
+ }
779
+ async failJob(job, error) {
780
+ if (!await this.queue.fail(job, error)) return;
781
+ this.tally.failed++;
782
+ if (job.attempts >= job.maxAttempts) this.tally.dead++;
783
+ }
784
+ async settleAsked(job, settle) {
785
+ if (settle.kind === "stopGroup" && job.group != null) {
786
+ this.stopped.add(job.group);
787
+ const rest = this.buffer.filter((other) => other.group === job.group);
788
+ const keep = this.buffer.filter((other) => other.group !== job.group);
789
+ this.buffer.splice(0, this.buffer.length, ...keep);
790
+ await this.releaseJobs(rest, settle);
791
+ }
792
+ await this.releaseJobs([job], settle);
793
+ }
794
+ async releaseJobs(jobs, { delay, refund }) {
795
+ for (const job of jobs) {
796
+ if (await this.queue.release(job, { delay, refund })) this.tally.released++;
797
+ if (job.group != null) this.busyGroups.delete(job.group);
798
+ }
799
+ }
800
+ groupLimit(group) {
801
+ const { perGroup } = this.options;
802
+ if (perGroup == null) return Number.POSITIVE_INFINITY;
803
+ return typeof perGroup === "function" ? perGroup(group) : perGroup;
804
+ }
805
+ atCap(group) {
806
+ return group != null && (this.perGroupCount.get(group) ?? 0) >= this.groupLimit(group);
807
+ }
808
+ countForGroup(group) {
809
+ if (group == null) return;
810
+ const count = (this.perGroupCount.get(group) ?? 0) + 1;
811
+ this.perGroupCount.set(group, count);
812
+ if (count >= this.groupLimit(group)) this.capped.add(group);
813
+ }
814
+ };
815
+ function work(queue, handler, options) {
816
+ return new WorkRun(queue, handler, options).run();
817
+ }
818
+
819
+ // src/runtime/queue/queue.ts
820
+ var DEFAULT_MAX_ATTEMPTS = 3;
821
+ var DEFAULT_LEASE_MS = 10 * 6e4;
822
+ var MAX_ERROR_LENGTH = 2e3;
823
+ var placeholders = (n) => Array.from({ length: n }, () => "?").join(", ");
824
+ var asList = (value) => typeof value === "string" ? [value] : value;
825
+ function toJob(row) {
826
+ return {
827
+ id: row.id,
828
+ key: row.key,
829
+ group: row.grp,
830
+ payload: row.payload == null ? void 0 : JSON.parse(row.payload),
831
+ priority: row.priority,
832
+ attempts: row.attempts,
833
+ maxAttempts: row.max_attempts,
834
+ lastError: row.last_error,
835
+ createdAt: row.created_at
836
+ };
837
+ }
838
+ function errorText(error) {
839
+ const text = error instanceof Error ? error.message : String(error);
840
+ return text.slice(0, MAX_ERROR_LENGTH);
841
+ }
842
+ function filterSql(filter) {
843
+ const parts = [];
844
+ const args = [];
845
+ if (filter.group != null) {
846
+ const groups = asList(filter.group);
847
+ parts.push(groups.length ? `grp IN (${placeholders(groups.length)})` : "0");
848
+ args.push(...groups);
849
+ }
850
+ if (filter.groupNotIn?.length) {
851
+ parts.push(`(grp IS NULL OR grp NOT IN (${placeholders(filter.groupNotIn.length)}))`);
852
+ args.push(...filter.groupNotIn);
853
+ }
854
+ if (filter.key != null) {
855
+ const keys = asList(filter.key);
856
+ parts.push(keys.length ? `key IN (${placeholders(keys.length)})` : "0");
857
+ args.push(...keys);
858
+ }
859
+ return { sql: parts.length ? ` AND ${parts.join(" AND ")}` : "", args };
860
+ }
861
+ var Queue = class {
862
+ constructor(client, name, options, run) {
863
+ this.client = client;
864
+ this.name = name;
865
+ this.options = options;
866
+ this.run = run;
867
+ this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
868
+ this.leaseMs = options.lease == null ? DEFAULT_LEASE_MS : toMs(options.lease, "queue lease");
869
+ if (!Number.isInteger(this.maxAttempts) || this.maxAttempts < 1) {
870
+ throw new Error(`Invalid maxAttempts for queue "${name}": expected a whole number above 0`);
871
+ }
872
+ }
873
+ maxAttempts;
874
+ leaseMs;
875
+ /**
876
+ * The INSERT for one job, to run in your own `batch` beside the row the job
877
+ * is about. A key already in the queue makes it a no-op.
878
+ */
879
+ addStatement(job) {
880
+ const now = Date.now();
881
+ return {
882
+ sql: `INSERT INTO _ngn_jobs
883
+ (queue, key, grp, payload, priority, status, attempts, max_attempts, run_after, created_at, updated_at)
884
+ VALUES (?, ?, ?, ?, ?, 'ready', 0, ?, ?, ?, ?)
885
+ ON CONFLICT (queue, key) DO NOTHING`,
886
+ args: [
887
+ this.name,
888
+ job.key ?? null,
889
+ job.group ?? null,
890
+ job.payload === void 0 ? null : JSON.stringify(job.payload),
891
+ job.priority ?? 0,
892
+ job.maxAttempts ?? this.maxAttempts,
893
+ now + (job.delay == null ? 0 : toMs(job.delay, "job delay")),
894
+ now,
895
+ now
896
+ ]
897
+ };
898
+ }
899
+ /** Add one job or several. Returns how many were new; the rest had keys already queued. */
900
+ async add(jobs) {
901
+ const list = Array.isArray(jobs) ? jobs : [jobs];
902
+ if (list.length === 0) return 0;
903
+ const results = await this.client.batch(
904
+ list.map((job) => this.addStatement(job)),
905
+ "write"
906
+ );
907
+ return results.reduce((sum, result) => sum + result.rowsAffected, 0);
908
+ }
909
+ /**
910
+ * Take up to `limit` jobs that are due, counting an attempt on each and
911
+ * leasing them for the queue's `lease`.
912
+ *
913
+ * With groups, the jobs are drawn a group at a time: every group's first job
914
+ * before any group's second, groups in a fresh random order each claim. So
915
+ * a claim spreads over as many groups as it can, and when `limit` cuts a
916
+ * round short it is not always the same groups left out.
917
+ */
918
+ async claim({ limit, perGroup, skipGroups = [], skipIds = [] }) {
919
+ if (limit <= 0) return [];
920
+ const now = Date.now();
921
+ const skip = skipGroups.length ? `AND (grp IS NULL OR grp NOT IN (${placeholders(skipGroups.length)}))` : "";
922
+ const [, claimed] = await this.client.batch(
923
+ [
924
+ this.retireStatement(now),
925
+ {
926
+ sql: `UPDATE _ngn_jobs
927
+ SET status = 'leased', lease_until = ?, attempts = attempts + 1, updated_at = ?
928
+ WHERE id IN (
929
+ SELECT id FROM (
930
+ SELECT id, grp, priority, run_after,
931
+ ROW_NUMBER() OVER (
932
+ PARTITION BY COALESCE(grp, 'id:' || id)
933
+ ORDER BY priority DESC, run_after, id
934
+ ) AS turn
935
+ FROM _ngn_jobs
936
+ WHERE queue = ?
937
+ AND attempts < max_attempts
938
+ AND ((status = 'ready' AND run_after <= ?)
939
+ OR (status = 'leased' AND lease_until <= ?))
940
+ ${skip}
941
+ AND id NOT IN (SELECT value FROM json_each(?))
942
+ )
943
+ WHERE turn <= ?
944
+ ORDER BY turn, CASE WHEN grp IS NULL THEN NULL ELSE random() END,
945
+ priority DESC, run_after, id
946
+ LIMIT ?
947
+ )
948
+ RETURNING *`,
949
+ args: [
950
+ now + this.leaseMs,
951
+ now,
952
+ this.name,
953
+ now,
954
+ now,
955
+ ...skipGroups,
956
+ // One JSON parameter however many ids, rather than one per id.
957
+ JSON.stringify(skipIds),
958
+ perGroup ?? Number.MAX_SAFE_INTEGER,
959
+ limit
960
+ ]
961
+ }
962
+ ],
963
+ "write"
964
+ );
965
+ return claimed.rows.toSorted((a, b) => b.priority - a.priority || a.run_after - b.run_after || a.id - b.id).map((row) => toJob(row));
966
+ }
967
+ /**
968
+ * Jobs whose lease ran out on their last attempt: no claim will take them
969
+ * again, so they are marked `dead` rather than left looking leased forever.
970
+ */
971
+ retireStatement(now) {
972
+ return {
973
+ sql: `UPDATE _ngn_jobs
974
+ SET status = 'dead', lease_until = NULL, updated_at = ?,
975
+ last_error = COALESCE(last_error, 'lease expired')
976
+ WHERE queue = ? AND status = 'leased' AND lease_until <= ? AND attempts >= max_attempts`,
977
+ args: [now, this.name, now]
978
+ };
979
+ }
980
+ /**
981
+ * Settle a claimed job, if the claim is still ours: same job, same attempt,
982
+ * still leased. False when the lease expired and the job moved on.
983
+ */
984
+ async settle(job, set, args) {
985
+ const { rowsAffected } = await this.client.execute({
986
+ sql: `UPDATE _ngn_jobs SET ${set}, updated_at = ?
987
+ WHERE id = ? AND queue = ? AND status = 'leased' AND attempts = ?`,
988
+ args: [...args, Date.now(), job.id, this.name, job.attempts]
989
+ });
990
+ return rowsAffected > 0;
991
+ }
992
+ /** The job is finished. */
993
+ complete(job) {
994
+ return this.settle(job, "status = 'done', lease_until = NULL, last_error = NULL", []);
995
+ }
996
+ /**
997
+ * The attempt failed. The job backs off and is tried again, or is `dead`
998
+ * once it has had all its attempts. `delay` overrides the backoff.
999
+ */
1000
+ fail(job, error, { delay } = {}) {
1001
+ const runAfter = Date.now() + (delay == null ? this.backoffMs(job.attempts) : toMs(delay));
1002
+ return this.settle(
1003
+ job,
1004
+ `status = CASE WHEN attempts >= max_attempts THEN 'dead' ELSE 'ready' END,
1005
+ lease_until = NULL, last_error = ?, run_after = ?`,
1006
+ [errorText(error), runAfter]
1007
+ );
1008
+ }
1009
+ /**
1010
+ * Hand a claimed job back unworked. By default the attempt is refunded: for
1011
+ * a job that never got a fair try, such as a site that walled the run.
1012
+ */
1013
+ release(job, { delay, refund = true } = {}) {
1014
+ const runAfter = Date.now() + (delay == null ? 0 : toMs(delay));
1015
+ return this.settle(
1016
+ job,
1017
+ `status = 'ready', lease_until = NULL, run_after = ?,
1018
+ attempts = ${refund ? "MAX(attempts - 1, 0)" : "attempts"}`,
1019
+ [runAfter]
1020
+ );
1021
+ }
1022
+ /** How long a job that just failed its `attempts`th attempt waits. */
1023
+ backoffMs(attempts) {
1024
+ const { backoff = "next-tick" } = this.options;
1025
+ if (backoff === "next-tick") {
1026
+ const { nextAt } = this.run;
1027
+ return nextAt == null ? 0 : Math.max(0, nextAt - Date.now());
1028
+ }
1029
+ if (typeof backoff === "object") {
1030
+ const base = toMs(backoff.base, "backoff base");
1031
+ const max = backoff.max == null ? Number.POSITIVE_INFINITY : toMs(backoff.max, "backoff max");
1032
+ return Math.min(max, base * 2 ** Math.max(0, attempts - 1));
1033
+ }
1034
+ return toMs(backoff, "backoff");
1035
+ }
1036
+ /**
1037
+ * Set ready jobs aside, e.g. ones no handler can read yet. Parked jobs are
1038
+ * never claimed until `reopen`. Returns how many were parked.
1039
+ */
1040
+ async park(filter, reason) {
1041
+ const where = filterSql(filter);
1042
+ const { rowsAffected } = await this.client.execute({
1043
+ sql: `UPDATE _ngn_jobs SET status = 'parked', last_error = COALESCE(?, last_error), updated_at = ?
1044
+ WHERE queue = ? AND status = 'ready'${where.sql}`,
1045
+ args: [reason ?? null, Date.now(), this.name, ...where.args]
1046
+ });
1047
+ return rowsAffected;
1048
+ }
1049
+ /**
1050
+ * Make jobs ready again with their attempts reset: parked ones by default,
1051
+ * or any of `from`, e.g. `["dead"]` to retry what gave up.
1052
+ */
1053
+ async reopen(filter = {}, { from = ["parked"] } = {}) {
1054
+ if (from.length === 0) return 0;
1055
+ const where = filterSql(filter);
1056
+ const now = Date.now();
1057
+ const { rowsAffected } = await this.client.execute({
1058
+ sql: `UPDATE _ngn_jobs SET status = 'ready', attempts = 0, run_after = ?, updated_at = ?
1059
+ WHERE queue = ? AND status IN (${placeholders(from.length)})${where.sql}`,
1060
+ args: [now, now, this.name, ...from, ...where.args]
1061
+ });
1062
+ return rowsAffected;
1063
+ }
1064
+ /**
1065
+ * Delete finished jobs last touched more than `olderThan` ago, `done` ones by
1066
+ * default. A pruned key can be added again.
1067
+ */
1068
+ async prune({
1069
+ olderThan,
1070
+ status = ["done"]
1071
+ }) {
1072
+ if (status.length === 0) return 0;
1073
+ const { rowsAffected } = await this.client.execute({
1074
+ sql: `DELETE FROM _ngn_jobs
1075
+ WHERE queue = ? AND status IN (${placeholders(status.length)}) AND updated_at < ?`,
1076
+ args: [this.name, ...status, Date.now() - toMs(olderThan, "prune age")]
1077
+ });
1078
+ return rowsAffected;
1079
+ }
1080
+ /**
1081
+ * Work through the queue until it is empty, `max` is reached or the run's
1082
+ * deadline passes, `concurrency` jobs at a time and never two of one group
1083
+ * at once. A handler that returns completes its job; one that throws fails
1084
+ * it. Whatever is left waits for the next run.
1085
+ */
1086
+ // NoInfer: the resource type comes from `worker.setup`, not from the handler's parameter.
1087
+ work(handler, options = {}) {
1088
+ return work(this, handler, { until: this.run.signal, ...options });
1089
+ }
1090
+ /** The job with `key`, in whatever state it is in. */
1091
+ async get(key) {
1092
+ const { rows } = await this.client.execute({
1093
+ sql: "SELECT * FROM _ngn_jobs WHERE queue = ? AND key = ?",
1094
+ args: [this.name, key]
1095
+ });
1096
+ const row = rows[0];
1097
+ return row ? { ...toJob(row), status: row.status } : null;
1098
+ }
1099
+ async stats() {
1100
+ const now = Date.now();
1101
+ const { rows } = await this.client.execute({
1102
+ sql: `SELECT
1103
+ COUNT(*) FILTER (WHERE status = 'ready') AS ready,
1104
+ COUNT(*) FILTER (WHERE status = 'ready' AND run_after <= ?) AS due,
1105
+ COUNT(*) FILTER (WHERE status = 'leased') AS leased,
1106
+ COUNT(*) FILTER (WHERE status = 'done') AS done,
1107
+ COUNT(*) FILTER (WHERE status = 'dead') AS dead,
1108
+ COUNT(*) FILTER (WHERE status = 'parked') AS parked,
1109
+ MIN(created_at) FILTER (WHERE status = 'ready') AS oldest
1110
+ FROM _ngn_jobs WHERE queue = ?`,
1111
+ args: [now, this.name]
1112
+ });
1113
+ const row = rows[0];
1114
+ return {
1115
+ ready: Number(row.ready),
1116
+ due: Number(row.due),
1117
+ leased: Number(row.leased),
1118
+ done: Number(row.done),
1119
+ dead: Number(row.dead),
1120
+ parked: Number(row.parked),
1121
+ oldestReadyAgeMs: row.oldest == null ? null : now - Number(row.oldest)
1122
+ };
1123
+ }
1124
+ };
1125
+ async function openQueue(client, name, options, run) {
1126
+ if (typeof name !== "string" || !name.trim()) {
1127
+ throw new Error(`A queue name must be a non-empty string, got ${JSON.stringify(name)}`);
1128
+ }
1129
+ await runMigrations(client, QUEUE_MIGRATIONS);
1130
+ return new Queue(client, name, options, run);
1131
+ }
1132
+
1133
+ // src/runtime/retry.ts
1134
+ var DEFAULT_MAX_SKIP = 32;
1135
+ var isCount = (value) => typeof value === "number" && Number.isInteger(value) && value >= 1;
1136
+ function resolveRetry(value, taskPath) {
1137
+ if (value == null) return { attempts: 1, delayMs: 0, breaker: null };
1138
+ const fail = (what) => {
1139
+ throw new Error(`Invalid retry for ${taskPath}: ${what}`);
1140
+ };
1141
+ if (typeof value !== "object") fail('expected an object like { attempts: 3, delay: "30s" }');
1142
+ const { attempts = 1, delay = 0, breaker } = value;
1143
+ if (!isCount(attempts)) fail("attempts must be a whole number above 0");
1144
+ if (breaker != null) {
1145
+ if (!isCount(breaker.after)) fail("breaker.after must be a whole number above 0");
1146
+ if (breaker.maxSkip != null && !isCount(breaker.maxSkip)) {
1147
+ fail("breaker.maxSkip must be a whole number above 0");
1148
+ }
1149
+ }
1150
+ return {
1151
+ attempts,
1152
+ delayMs: toMs(delay, `retry delay for ${taskPath}`),
1153
+ breaker: breaker ? { after: breaker.after, maxSkip: breaker.maxSkip ?? DEFAULT_MAX_SKIP } : null
1154
+ };
1155
+ }
1156
+ function retryFits({
1157
+ attempt,
1158
+ retry,
1159
+ nextAt,
1160
+ now = Date.now()
1161
+ }) {
1162
+ if (attempt >= retry.attempts) return false;
1163
+ return nextAt == null || now + retry.delayMs < nextAt;
1164
+ }
1165
+ var BREAKER_SKIP_REASON = "circuit open after repeated failures";
1166
+ function breakerSkips(failedInARow, breaker) {
1167
+ if (!breaker || failedInARow < breaker.after) return 0;
1168
+ return Math.min(breaker.maxSkip, 2 ** (failedInARow - breaker.after));
1169
+ }
1170
+
345
1171
  // src/runtime/task.ts
1172
+ var sleep2 = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
346
1173
  var Task = class {
347
1174
  taskCallbacks;
348
- runtimeCallbacks;
1175
+ startRun;
1176
+ history;
349
1177
  env;
350
1178
  kv;
351
- log;
352
- timing;
353
1179
  sqlite;
354
1180
  meta;
355
1181
  entry = null;
356
1182
  entryExports = null;
1183
+ /** Executions started and not yet settled. */
1184
+ inFlight = 0;
357
1185
  constructor({
358
1186
  entryParams,
359
1187
  buildConfigFn
@@ -362,12 +1190,13 @@ var Task = class {
362
1190
  this.entry = new Entry(entryParams, {
363
1191
  onTaskCodeLoaded: this.taskCallbacks?.onTaskCodeLoaded
364
1192
  });
365
- const { taskCallbacks, runtimeCallbacks, env, kv, log, timing, sqlite } = buildConfigFn({
1193
+ const { taskCallbacks, startRun, history, env, kv, sqlite } = buildConfigFn({
366
1194
  sourcePath: this.entry.paths.source,
367
1195
  compiledPath: this.entry.paths.compiled
368
1196
  });
369
1197
  this.taskCallbacks = taskCallbacks;
370
- this.runtimeCallbacks = runtimeCallbacks;
1198
+ this.startRun = startRun;
1199
+ this.history = history;
371
1200
  this.sqlite = sqlite;
372
1201
  this.meta = {
373
1202
  fileTaskId: -1,
@@ -377,8 +1206,6 @@ var Task = class {
377
1206
  };
378
1207
  this.env = env;
379
1208
  this.kv = kv;
380
- this.log = log;
381
- this.timing = timing;
382
1209
  }
383
1210
  get sourcePath() {
384
1211
  invariant5(this.entry, "Entry not initialized.");
@@ -403,6 +1230,45 @@ var Task = class {
403
1230
  invariant5(this.entryExports, "Entry exports not initialized.");
404
1231
  return this.entryExports.timing;
405
1232
  }
1233
+ /**
1234
+ * The task's `overlap` export, `"skip"` when it has none. Throws on anything
1235
+ * else, naming the task, so a typo fails at schedule time.
1236
+ */
1237
+ get overlapPolicy() {
1238
+ invariant5(this.entryExports, "Entry exports not initialized.");
1239
+ const { overlap = "skip" } = this.entryExports;
1240
+ invariant5(
1241
+ overlap === "skip" || overlap === "allow",
1242
+ `Invalid overlap "${String(overlap)}" for ${this.taskPath}: expected "skip" or "allow"`
1243
+ );
1244
+ return overlap;
1245
+ }
1246
+ /**
1247
+ * Check the exports that shape how the task is scheduled, so a bad value
1248
+ * fails when the task is scheduled rather than on its first tick.
1249
+ */
1250
+ validateExports() {
1251
+ invariant5(this.entryExports, "Entry exports not initialized.");
1252
+ void this.overlapPolicy;
1253
+ const { budget, exclusive, retry } = this.entryExports;
1254
+ if (budget != null) toMs(budget, `budget for ${this.taskPath}`);
1255
+ exclusiveNames(exclusive, this.taskPath);
1256
+ resolveRetry(retry, this.taskPath);
1257
+ }
1258
+ /** Whether an execution of this task is in progress. */
1259
+ get isRunning() {
1260
+ return this.inFlight > 0;
1261
+ }
1262
+ /**
1263
+ * Record a run that ngn decided not to start, without loading or calling any
1264
+ * of the task's code. Needs the task to have been registered by an earlier
1265
+ * execution, which is always so for a run skipped because one is in progress.
1266
+ */
1267
+ async skip(reason) {
1268
+ invariant5(this.entry, "Entry not initialized.");
1269
+ const run = await this.startRun();
1270
+ await run.callbacks.onSkip?.({ path: this.entry.paths.relativeEntry, reason });
1271
+ }
406
1272
  hasTaskExport() {
407
1273
  return this.entryExports?.task !== void 0;
408
1274
  }
@@ -416,68 +1282,174 @@ var Task = class {
416
1282
  }
417
1283
  async loadAndRegister() {
418
1284
  invariant5(this.entry, "Entry not initialized.");
419
- try {
420
- await this.loadEntry();
421
- invariant5(this.entry.isLoaded(), "Entry failed to load.");
422
- invariant5(this.entryExports, "Entry failed to get exports.");
423
- this.taskCallbacks?.onTaskExportsLoaded?.();
424
- const loadResult = await this.taskCallbacks?.onTaskLoadComplete?.({
425
- paths: this.entry.paths,
426
- md5Hash: this.entry.codeHash,
427
- compiledCode: this.entry.code
428
- });
429
- this.meta.fileTaskId = loadResult?.fileTaskId ?? -1;
430
- this.meta.fileTaskVersionId = loadResult?.fileTaskVersionId ?? -1;
431
- } catch (error) {
432
- if (this.entry.paths.relativeEntry) {
433
- await this.runtimeCallbacks?.onFailure?.({
434
- path: this.entry.paths.relativeEntry
435
- });
436
- }
437
- throw error;
438
- }
1285
+ await this.loadEntry();
1286
+ invariant5(this.entry.isLoaded(), "Entry failed to load.");
1287
+ invariant5(this.entryExports, "Entry failed to get exports.");
1288
+ this.taskCallbacks?.onTaskExportsLoaded?.();
1289
+ const loadResult = await this.taskCallbacks?.onTaskLoadComplete?.({
1290
+ paths: this.entry.paths,
1291
+ md5Hash: this.entry.codeHash,
1292
+ compiledCode: this.entry.code
1293
+ });
1294
+ this.meta.fileTaskId = loadResult?.fileTaskId ?? -1;
1295
+ this.meta.fileTaskVersionId = loadResult?.fileTaskVersionId ?? -1;
439
1296
  }
440
- async runTask(task, taskContext, hooks) {
1297
+ async runTask(task, taskContext, hooks, run) {
441
1298
  invariant5(this.entry, "Entry not initialized.");
442
1299
  const path = this.entry.paths.relativeEntry;
443
1300
  try {
444
- await this.runtimeCallbacks?.onStart?.({ path });
1301
+ await run.callbacks.onStart?.({ path });
445
1302
  const taskReturnValue = await task(taskContext);
446
- await this.runtimeCallbacks?.onSuccess?.({ path });
1303
+ await run.callbacks.onSuccess?.({ path, result: taskReturnValue });
447
1304
  await hooks.onSuccess?.(taskContext);
448
1305
  return taskReturnValue;
449
1306
  } catch (err) {
450
- await this.runtimeCallbacks?.onFailure?.({ path });
1307
+ await run.callbacks.onFailure?.({ path });
451
1308
  await hooks.onError?.(err, taskContext);
452
1309
  throw err;
453
1310
  } finally {
454
1311
  await hooks.onComplete?.(taskContext);
455
1312
  }
456
1313
  }
457
- async execute() {
1314
+ /**
1315
+ * Run the task for one tick, retrying as its `retry` export allows. A
1316
+ * scheduled tick passes when it fired and when the next one is due, which
1317
+ * `ctx.run.deadline` and the retry window are worked out from.
1318
+ */
1319
+ async execute(schedule = {}) {
1320
+ this.inFlight++;
1321
+ try {
1322
+ return await this.executeWithRetries(schedule);
1323
+ } finally {
1324
+ this.inFlight--;
1325
+ }
1326
+ }
1327
+ async executeWithRetries(schedule) {
1328
+ const tick2 = {
1329
+ firstRunId: null,
1330
+ retry: resolveRetry(void 0, this.taskPath),
1331
+ failedContext: null
1332
+ };
1333
+ for (let attempt = 1; ; attempt++) {
1334
+ tick2.failedContext = null;
1335
+ try {
1336
+ return await this.executeOnce(schedule, tick2);
1337
+ } catch (error) {
1338
+ if (!await this.shouldRetry(error, attempt, schedule, tick2)) throw error;
1339
+ await sleep2(tick2.retry.delayMs);
1340
+ }
1341
+ }
1342
+ }
1343
+ /**
1344
+ * Retry only a run that got as far as running: a module that will not load
1345
+ * fails the same way every time. Then it has to fit before the next tick,
1346
+ * and the task's `shouldRetry` gets the last word.
1347
+ */
1348
+ async shouldRetry(error, attempt, { nextAt = null }, tick2) {
1349
+ if (!tick2.failedContext || !retryFits({ attempt, retry: tick2.retry, nextAt })) return false;
1350
+ const { shouldRetry } = this.entryExports ?? {};
1351
+ return shouldRetry ? await shouldRetry(error, tick2.failedContext) !== false : true;
1352
+ }
1353
+ /**
1354
+ * Whether the breaker holds this tick back: after `after` failed ticks in a
1355
+ * row it skips 1, 2, 4… ticks (counted since the latest failure), letting
1356
+ * one through in between to see whether things have recovered.
1357
+ */
1358
+ async breakerOpen(retry) {
1359
+ if (!retry.breaker) return false;
1360
+ const outcomes = await this.history.recentTickOutcomes(retry.breaker.after + 64);
1361
+ const failedInARow = outcomes.findIndex((tick2) => tick2.outcome !== "failure");
1362
+ const wanted = breakerSkips(
1363
+ failedInARow === -1 ? outcomes.length : failedInARow,
1364
+ retry.breaker
1365
+ );
1366
+ if (wanted === 0) return false;
1367
+ return await this.history.countSkipsSince(BREAKER_SKIP_REASON, outcomes[0].id) < wanted;
1368
+ }
1369
+ /**
1370
+ * Why ngn will not start this run, or null. Checked in order: the breaker
1371
+ * (scheduled first attempts only), then the `exclusive` locks, which are
1372
+ * left held on success.
1373
+ */
1374
+ async holdBack(run, {
1375
+ firstAttempt,
1376
+ scheduled,
1377
+ exclusiveLocks,
1378
+ retry
1379
+ }) {
1380
+ if (firstAttempt && scheduled && await this.breakerOpen(retry)) {
1381
+ return { reason: BREAKER_SKIP_REASON };
1382
+ }
1383
+ if (!exclusiveLocks.length) return { held: null };
1384
+ const held = await run.locks.holdAll(exclusiveLocks);
1385
+ return "busy" in held ? { reason: `lock "${held.busy}" is held` } : { held };
1386
+ }
1387
+ buildContext(run, meta, window, signal) {
1388
+ const { scheduledAt, nextAt, deadline } = window;
1389
+ return {
1390
+ env: this.env,
1391
+ meta,
1392
+ kv: this.kv,
1393
+ log: run.log,
1394
+ timing: run.timing,
1395
+ sqlite: this.sqlite,
1396
+ run: { id: run.id, scheduledAt, nextAt, deadline, signal },
1397
+ lock: (name, fn, options = {}) => run.locks.lock(name, fn, options, deadline),
1398
+ queue: (name, options = {}) => openQueue(options.db?.client ?? this.sqlite.client, name, options, { nextAt, signal })
1399
+ };
1400
+ }
1401
+ async executeOnce({ scheduledAt = null, nextAt = null }, tick2) {
458
1402
  invariant5(this.entry, "Entry not initialized.");
459
1403
  await this.loadAndRegister();
460
1404
  invariant5(this.entryExports, "Entry failed to get exports.");
461
- await this.taskCallbacks?.onTaskExecutionStart?.();
462
- const { shouldSkip, onSuccess, onError, onComplete, task } = this.entryExports;
1405
+ const exports = this.entryExports;
1406
+ const meta = { ...this.meta };
463
1407
  const path = this.entry.paths.relativeEntry;
464
- if (!task) {
465
- await this.runtimeCallbacks?.onNotFound?.({ path });
1408
+ const budgetMs = exports.budget == null ? null : toMs(exports.budget, `budget for ${path}`);
1409
+ const exclusiveLocks = exclusiveNames(exports.exclusive, path);
1410
+ const retry = resolveRetry(exports.retry, path);
1411
+ tick2.retry = retry;
1412
+ const run = await this.startRun({ retryOf: tick2.firstRunId });
1413
+ const firstAttempt = tick2.firstRunId == null;
1414
+ tick2.firstRunId ??= run.id;
1415
+ if (!exports.task) {
1416
+ await run.callbacks.onNotFound?.({ path });
466
1417
  throw new Error("Task function not found");
467
1418
  }
468
- const taskContext = {
469
- env: this.env,
470
- meta: this.meta,
471
- kv: this.kv,
472
- log: this.log,
473
- timing: this.timing,
474
- sqlite: this.sqlite
475
- };
476
- if (await shouldSkip?.(taskContext)) {
477
- await this.runtimeCallbacks?.onSkip?.({ path });
478
- return;
1419
+ const deadline = runDeadline({ startedAt: Date.now(), scheduledAt, nextAt, budgetMs });
1420
+ const { signal, dispose } = deadlineSignal(deadline);
1421
+ const taskContext = this.buildContext(run, meta, { scheduledAt, nextAt, deadline }, signal);
1422
+ let exclusiveHeld = null;
1423
+ try {
1424
+ const gate = await this.holdBack(run, {
1425
+ firstAttempt,
1426
+ scheduled: scheduledAt != null,
1427
+ exclusiveLocks,
1428
+ retry
1429
+ });
1430
+ if ("reason" in gate) {
1431
+ await run.callbacks.onSkip?.({ path, reason: gate.reason });
1432
+ return;
1433
+ }
1434
+ exclusiveHeld = gate.held;
1435
+ if (await exports.shouldSkip?.(taskContext)) {
1436
+ await run.callbacks.onSkip?.({ path });
1437
+ return;
1438
+ }
1439
+ tick2.failedContext = taskContext;
1440
+ const { onSuccess, onError, onComplete } = exports;
1441
+ const value = await this.runTask(
1442
+ exports.task,
1443
+ taskContext,
1444
+ { onSuccess, onError, onComplete },
1445
+ run
1446
+ );
1447
+ tick2.failedContext = null;
1448
+ return value;
1449
+ } finally {
1450
+ dispose();
1451
+ await exclusiveHeld?.release();
479
1452
  }
480
- return this.runTask(task, taskContext, { onSuccess, onError, onComplete });
481
1453
  }
482
1454
  };
483
1455
 
@@ -539,9 +1511,46 @@ var TaskLibrary = class {
539
1511
  };
540
1512
 
541
1513
  // src/runtime/taskLibraryScheduler.ts
542
- import { validate, schedule } from "node-cron";
543
- import invariant7 from "tiny-invariant";
1514
+ import invariant8 from "tiny-invariant";
544
1515
  import { logTaskLibraryScheduler, logTaskLibrarySchedulerError } from "@apisurf/ngn-os";
1516
+
1517
+ // src/runtime/schedule.ts
1518
+ import { Cron } from "croner";
1519
+ import invariant7 from "tiny-invariant";
1520
+ var CRON_OPTIONS = { mode: "5-or-6-parts" };
1521
+ function isCronPatternValid(pattern) {
1522
+ try {
1523
+ new Cron(pattern, { ...CRON_OPTIONS, paused: true }).stop();
1524
+ return true;
1525
+ } catch {
1526
+ return false;
1527
+ }
1528
+ }
1529
+ async function tick(task, onError, schedule = {}) {
1530
+ try {
1531
+ if (task.isRunning && task.overlapPolicy === "skip") {
1532
+ await task.skip("previous run still in progress");
1533
+ return;
1534
+ }
1535
+ await task.execute(schedule);
1536
+ } catch (error) {
1537
+ onError(error);
1538
+ }
1539
+ }
1540
+ function scheduleTask(task, pattern, onError) {
1541
+ invariant7(isCronPatternValid(pattern), `Invalid cron pattern: ${pattern} for ${task.taskPath}`);
1542
+ task.validateExports();
1543
+ return new Cron(
1544
+ pattern,
1545
+ CRON_OPTIONS,
1546
+ (job) => tick(task, onError, {
1547
+ scheduledAt: job.currentRun()?.getTime() ?? Date.now(),
1548
+ nextAt: job.nextRun()?.getTime() ?? null
1549
+ })
1550
+ );
1551
+ }
1552
+
1553
+ // src/runtime/taskLibraryScheduler.ts
545
1554
  var TaskLibraryScheduler = class {
546
1555
  taskLibrary;
547
1556
  scheduledTasks = /* @__PURE__ */ new Map();
@@ -573,14 +1582,16 @@ var TaskLibraryScheduler = class {
573
1582
  logTaskLibraryScheduler(`Scheduling task: ${task.taskPath}`);
574
1583
  await task.loadEntry();
575
1584
  logTaskLibraryScheduler(`Scheduling: ${task.taskPath} with pattern: ${task.timingPattern}`);
576
- invariant7(task.hasTimingExport(), `timing pattern is missing for ${task.taskPath}`);
577
- invariant7(task.hasTaskExport(), `task function is missing for ${task.taskPath}`);
578
- invariant7(
1585
+ invariant8(task.hasTimingExport(), `timing pattern is missing for ${task.taskPath}`);
1586
+ invariant8(task.hasTaskExport(), `task function is missing for ${task.taskPath}`);
1587
+ invariant8(
579
1588
  task.timingPattern && this.isCronPatternValid(task.timingPattern),
580
1589
  `Invalid cron pattern: ${task.timingPattern} for ${task.taskPath}`
581
1590
  );
582
- const wrappedUserTask = async () => task.execute();
583
- const scheduledTask = schedule(task.timingPattern, wrappedUserTask);
1591
+ const scheduledTask = scheduleTask(task, task.timingPattern, (error) => {
1592
+ logTaskLibrarySchedulerError(`Run failed: ${task.taskPath}`);
1593
+ logTaskLibrarySchedulerError(error);
1594
+ });
584
1595
  this.scheduledTasks.set(task.taskPath, scheduledTask);
585
1596
  logTaskLibraryScheduler(`Scheduled: ${task.taskPath}`);
586
1597
  }
@@ -595,7 +1606,7 @@ var TaskLibraryScheduler = class {
595
1606
  logTaskLibraryScheduler(`Unscheduled: ${task.taskPath}`);
596
1607
  }
597
1608
  isCronPatternValid(cronPattern) {
598
- return validate(cronPattern);
1609
+ return isCronPatternValid(cronPattern);
599
1610
  }
600
1611
  // startScheduledTask(path: string) {
601
1612
  // const task = this.scheduledTasks.get(path);
@@ -614,7 +1625,7 @@ var TaskLibraryScheduler = class {
614
1625
  };
615
1626
 
616
1627
  // src/runtime/taskOnceRunner.ts
617
- import invariant8 from "tiny-invariant";
1628
+ import invariant9 from "tiny-invariant";
618
1629
  import { logTaskOnceRunner, logTaskOnceRunnerError } from "@apisurf/ngn-os";
619
1630
  var TaskOnceRunner = class {
620
1631
  task;
@@ -625,7 +1636,7 @@ var TaskOnceRunner = class {
625
1636
  logTaskOnceRunner(`Preparing to run task: ${this.task.taskPath}`);
626
1637
  await this.task.loadEntry();
627
1638
  logTaskOnceRunner(`Loaded task code: ${this.task.taskPath}`);
628
- invariant8(this.task.hasTaskExport(), `task function is missing for ${this.task.taskPath}`);
1639
+ invariant9(this.task.hasTaskExport(), `task function is missing for ${this.task.taskPath}`);
629
1640
  logTaskOnceRunner(`Running task: ${this.task.taskPath}`);
630
1641
  try {
631
1642
  await this.task.execute();
@@ -644,10 +1655,10 @@ import {
644
1655
  initDbFileIfNotExists,
645
1656
  isDbPath
646
1657
  } from "@apisurf/ngn-persistence";
647
- import invariant9 from "tiny-invariant";
1658
+ import invariant10 from "tiny-invariant";
648
1659
  var db;
649
1660
  async function setupDbClient(dbPath) {
650
- invariant9(isDbPath(dbPath), "Invalid db path");
1661
+ invariant10(isDbPath(dbPath), "Invalid db path");
651
1662
  if (db) {
652
1663
  return db;
653
1664
  }
@@ -655,7 +1666,7 @@ async function setupDbClient(dbPath) {
655
1666
  return db;
656
1667
  }
657
1668
  function getDbClient() {
658
- invariant9(db, "DB client not initialized");
1669
+ invariant10(db, "DB client not initialized");
659
1670
  return db;
660
1671
  }
661
1672
 
@@ -666,114 +1677,7 @@ import {
666
1677
  logTaskConsoleInfo,
667
1678
  logTaskConsoleWarn
668
1679
  } from "@apisurf/ngn-os";
669
- import invariant10 from "tiny-invariant";
670
-
671
- // src/runtime/taskSqlite.ts
672
- import { mkdirSync } from "node:fs";
673
- import { unlink } from "node:fs/promises";
674
- import { basename, dirname, extname, isAbsolute, join, resolve, sep } from "node:path";
675
- import {
676
- openDbClient
677
- } from "@apisurf/ngn-persistence";
678
- var openClients = /* @__PURE__ */ new Map();
679
- async function runMigrations(client, migrations) {
680
- if (!migrations.length) return;
681
- await client.execute(`
682
- CREATE TABLE IF NOT EXISTS _ngn_migrations (
683
- id TEXT PRIMARY KEY,
684
- applied_at TEXT NOT NULL
685
- )
686
- `);
687
- const applied = await client.execute("SELECT id FROM _ngn_migrations");
688
- const appliedIds = new Set(applied.rows.map((r) => r.id));
689
- for (const migration of migrations) {
690
- if (appliedIds.has(migration.id)) continue;
691
- await client.execute(migration.up);
692
- await client.execute({
693
- sql: "INSERT INTO _ngn_migrations (id, applied_at) VALUES (?, ?)",
694
- args: [migration.id, (/* @__PURE__ */ new Date()).toISOString()]
695
- });
696
- }
697
- }
698
- function resolveTaskDbPath(taskDir, file) {
699
- if (typeof file !== "string" || !file.trim()) {
700
- throw new Error("A database file name is required");
701
- }
702
- const relativePath = file.startsWith("file:") ? file.slice(5) : file;
703
- if (!relativePath) {
704
- throw new Error("A database file name is required");
705
- }
706
- if (isAbsolute(relativePath)) {
707
- throw new Error(
708
- `Database file must be relative to the task folder, got an absolute path: "${file}"`
709
- );
710
- }
711
- const resolved = resolve(taskDir, relativePath);
712
- const taskDirRoot = taskDir.endsWith(sep) ? taskDir : `${taskDir}${sep}`;
713
- if (!resolved.startsWith(taskDirRoot)) {
714
- throw new Error(
715
- `Database file "${file}" resolves outside the task folder (${taskDir}). A task can only manage databases in its own folder.`
716
- );
717
- }
718
- return resolved;
719
- }
720
- function getClient(absolutePath) {
721
- const existing = openClients.get(absolutePath);
722
- if (existing) {
723
- return existing;
724
- }
725
- mkdirSync(dirname(absolutePath), { recursive: true });
726
- const client = openDbClient(`file:${absolutePath}`);
727
- openClients.set(absolutePath, client);
728
- return client;
729
- }
730
- function closeClient(absolutePath) {
731
- const client = openClients.get(absolutePath);
732
- if (client) {
733
- client.close();
734
- openClients.delete(absolutePath);
735
- }
736
- }
737
- function createTaskSqlite(taskSourcePath) {
738
- const taskDir = dirname(taskSourcePath);
739
- const defaultDbPath = join(taskDir, `${basename(taskSourcePath, extname(taskSourcePath))}.db`);
740
- const defaultClient = () => getClient(defaultDbPath);
741
- return {
742
- get client() {
743
- return defaultClient();
744
- },
745
- get path() {
746
- return defaultDbPath;
747
- },
748
- execute: (sql, args) => defaultClient().execute(args ? { sql, args } : sql),
749
- batch: (statements) => defaultClient().batch(statements),
750
- async initDB(options) {
751
- const dbPath = resolveTaskDbPath(taskDir, options.file);
752
- const client = getClient(dbPath);
753
- if (options.migrations?.length) {
754
- await runMigrations(client, options.migrations);
755
- }
756
- return {
757
- client,
758
- path: dbPath,
759
- execute: (sql, args) => client.execute(args ? { sql, args } : sql),
760
- batch: (statements) => client.batch(statements),
761
- close: () => closeClient(dbPath)
762
- };
763
- },
764
- async destroyDB(file) {
765
- const dbPath = resolveTaskDbPath(taskDir, file);
766
- closeClient(dbPath);
767
- await unlink(dbPath);
768
- }
769
- };
770
- }
771
- function closeTaskDatabases() {
772
- for (const client of openClients.values()) {
773
- client.close();
774
- }
775
- openClients.clear();
776
- }
1680
+ import invariant11 from "tiny-invariant";
777
1681
 
778
1682
  // src/db/services/fileTask.ts
779
1683
  import { $fileTaskInsert } from "@apisurf/ngn-schema";
@@ -933,25 +1837,67 @@ async function registerFileTask(dbClient, md5Hash, compiledCode, paths) {
933
1837
  }
934
1838
 
935
1839
  // src/db/services/taskRun.ts
1840
+ function serializeResult(value) {
1841
+ if (value === void 0) return null;
1842
+ try {
1843
+ return JSON.stringify(value) ?? null;
1844
+ } catch {
1845
+ try {
1846
+ return String(value);
1847
+ } catch {
1848
+ return null;
1849
+ }
1850
+ }
1851
+ }
936
1852
  var TaskRunService = class {
937
1853
  constructor(db2) {
938
1854
  this.db = db2;
939
1855
  }
940
1856
  async initTaskRun(data) {
941
1857
  const { rows } = await this.db.execute({
942
- sql: "INSERT INTO task_runs (file_task_id, file_task_version_id, status) VALUES (:file_task_id, :file_task_version_id, 'pending') RETURNING id",
1858
+ sql: "INSERT INTO task_runs (file_task_id, file_task_version_id, status, retry_of) VALUES (:file_task_id, :file_task_version_id, 'pending', :retry_of) RETURNING id",
943
1859
  args: {
944
1860
  file_task_id: data.file_task_id,
945
- file_task_version_id: data.file_task_version_id
1861
+ file_task_version_id: data.file_task_version_id,
1862
+ retry_of: data.retry_of ?? null
946
1863
  }
947
1864
  });
948
1865
  return rows[0].id;
949
1866
  }
950
- async skipTaskRun(taskRunId) {
1867
+ async skipTaskRun(taskRunId, reason = null) {
951
1868
  await this.db.execute({
952
- sql: "UPDATE task_runs SET status = 'skipped', ended_at = :ended_at WHERE id = :id",
953
- args: { id: taskRunId, ended_at: Date.now() }
1869
+ sql: "UPDATE task_runs SET status = 'skipped', ended_at = :ended_at, skip_reason = :reason WHERE id = :id",
1870
+ args: { id: taskRunId, ended_at: Date.now(), reason }
1871
+ });
1872
+ }
1873
+ /**
1874
+ * How the task's latest ticks ended, newest first: `failure` or `success`
1875
+ * per tick, a tick's retries folded into it (its last attempt decides).
1876
+ * Skipped ticks are left out; they neither break nor extend a streak.
1877
+ */
1878
+ async recentTickOutcomes(fileTaskId, limit) {
1879
+ const { rows } = await this.db.execute({
1880
+ sql: `SELECT r.id,
1881
+ COALESCE(
1882
+ (SELECT a.status FROM task_runs a WHERE a.retry_of = r.id ORDER BY a.id DESC LIMIT 1),
1883
+ r.status
1884
+ ) AS outcome
1885
+ FROM task_runs r
1886
+ WHERE r.file_task_id = :fileTaskId AND r.retry_of IS NULL
1887
+ AND r.status IN ('success', 'failure')
1888
+ ORDER BY r.id DESC LIMIT :limit`,
1889
+ args: { fileTaskId, limit }
954
1890
  });
1891
+ return rows;
1892
+ }
1893
+ /** Runs skipped with `reason` after run `afterId`. */
1894
+ async countSkipsSince(fileTaskId, reason, afterId) {
1895
+ const { rows } = await this.db.execute({
1896
+ sql: `SELECT COUNT(*) AS n FROM task_runs
1897
+ WHERE file_task_id = :fileTaskId AND status = 'skipped' AND skip_reason = :reason AND id > :afterId`,
1898
+ args: { fileTaskId, reason, afterId }
1899
+ });
1900
+ return Number(rows[0].n);
955
1901
  }
956
1902
  async startTaskRun(taskRunId) {
957
1903
  await this.db.execute({
@@ -962,12 +1908,13 @@ var TaskRunService = class {
962
1908
  }
963
1909
  });
964
1910
  }
965
- async endTaskRun(taskRunId, status) {
1911
+ async endTaskRun(taskRunId, status, result = null) {
966
1912
  await this.db.execute({
967
- sql: "UPDATE task_runs SET ended_at = :ended_at, status = :status WHERE id = :id",
1913
+ sql: "UPDATE task_runs SET ended_at = :ended_at, status = :status, result = :result WHERE id = :id",
968
1914
  args: {
969
1915
  ended_at: Date.now(),
970
1916
  status,
1917
+ result,
971
1918
  id: taskRunId
972
1919
  }
973
1920
  });
@@ -1081,6 +2028,46 @@ var TimingService = class {
1081
2028
  }
1082
2029
  };
1083
2030
 
2031
+ // src/db/services/lock.ts
2032
+ import { randomUUID } from "node:crypto";
2033
+ var LockService = class {
2034
+ constructor(db2) {
2035
+ this.db = db2;
2036
+ }
2037
+ /** The holder token on success, null when someone else holds an unexpired lease. */
2038
+ async tryAcquire(name, ttlMs, taskRunId) {
2039
+ const holder = `${process.pid}:${randomUUID()}`;
2040
+ const now = Date.now();
2041
+ const { rows } = await this.db.execute({
2042
+ sql: `INSERT INTO locks (name, holder, task_run_id, acquired_at, expires_at)
2043
+ VALUES (:name, :holder, :taskRunId, :now, :expiresAt)
2044
+ ON CONFLICT(name) DO UPDATE SET
2045
+ holder = excluded.holder,
2046
+ task_run_id = excluded.task_run_id,
2047
+ acquired_at = excluded.acquired_at,
2048
+ expires_at = excluded.expires_at
2049
+ WHERE locks.expires_at <= excluded.acquired_at
2050
+ RETURNING holder`,
2051
+ args: { name, holder, taskRunId, now, expiresAt: now + ttlMs }
2052
+ });
2053
+ return rows.length ? holder : null;
2054
+ }
2055
+ /** Push the lease out; false when it was lost (it expired and someone took it). */
2056
+ async renew(name, holder, ttlMs) {
2057
+ const { rowsAffected } = await this.db.execute({
2058
+ sql: "UPDATE locks SET expires_at = :expiresAt WHERE name = :name AND holder = :holder",
2059
+ args: { name, holder, expiresAt: Date.now() + ttlMs }
2060
+ });
2061
+ return rowsAffected > 0;
2062
+ }
2063
+ async release(name, holder) {
2064
+ await this.db.execute({
2065
+ sql: "DELETE FROM locks WHERE name = :name AND holder = :holder",
2066
+ args: { name, holder }
2067
+ });
2068
+ }
2069
+ };
2070
+
1084
2071
  // src/runtime/generateControls.ts
1085
2072
  function createControlsGenerator({
1086
2073
  dbClient,
@@ -1090,13 +2077,13 @@ function createControlsGenerator({
1090
2077
  const logService = new LogService(dbClient);
1091
2078
  const kvService = new KVService(dbClient);
1092
2079
  const timingService = new TimingService(dbClient);
2080
+ const lockService = new LockService(dbClient);
1093
2081
  const generateControls = ({ sourcePath, compiledPath }) => {
1094
2082
  let fileTaskId;
1095
2083
  let fileTaskVersionId;
1096
- let fileTaskRunId;
1097
2084
  const assertMandatoryIds = () => {
1098
- invariant10(!!fileTaskId, "Task ID not found");
1099
- invariant10(!!fileTaskVersionId, "Task version ID not found");
2085
+ invariant11(!!fileTaskId, "Task ID not found");
2086
+ invariant11(!!fileTaskVersionId, "Task version ID not found");
1100
2087
  };
1101
2088
  const taskCallbacks = {
1102
2089
  async onTaskLoadComplete({ paths, md5Hash, compiledCode }) {
@@ -1111,54 +2098,88 @@ function createControlsGenerator({
1111
2098
  fileTaskVersionId
1112
2099
  };
1113
2100
  },
1114
- async onTaskExecutionStart() {
1115
- assertMandatoryIds();
1116
- fileTaskRunId = await taskRunService.initTaskRun({
1117
- file_task_id: fileTaskId,
1118
- file_task_version_id: fileTaskVersionId
1119
- });
1120
- },
1121
2101
  async onTaskCodeLoaded() {
1122
2102
  },
1123
2103
  async onTaskExportsLoaded() {
1124
2104
  }
1125
2105
  };
1126
- const runtimeCallbacks = {
1127
- async onNotFound() {
1128
- assertMandatoryIds();
1129
- await logService.logError(fileTaskId, fileTaskRunId, "Task not found");
1130
- logTaskCallback(`Task not found: ${fileTaskId}, run ID: ${fileTaskRunId}`);
1131
- },
1132
- async onSkip() {
1133
- assertMandatoryIds();
1134
- await taskRunService.skipTaskRun(fileTaskRunId);
1135
- await logService.logInfo(fileTaskId, fileTaskRunId, "Task skipped");
1136
- logTaskCallback(`Task skipped: ${fileTaskId}, run ID: ${fileTaskRunId}`);
1137
- },
1138
- async onStart() {
1139
- assertMandatoryIds();
1140
- await taskRunService.startTaskRun(fileTaskRunId);
1141
- await logService.logInfo(fileTaskId, fileTaskRunId, "Task started");
1142
- logTaskCallback(`Task started: ${fileTaskId}, run ID: ${fileTaskRunId}`);
1143
- },
1144
- async onFailure() {
1145
- assertMandatoryIds();
1146
- await taskRunService.endTaskRun(fileTaskRunId, "failure");
1147
- await logService.logError(fileTaskId, fileTaskRunId, "Task failed");
1148
- logTaskCallback(`Task failed: ${fileTaskId}, run ID: ${fileTaskRunId}`);
1149
- },
1150
- async onSuccess() {
1151
- assertMandatoryIds();
1152
- await taskRunService.endTaskRun(fileTaskRunId, "success");
1153
- await logService.logInfo(fileTaskId, fileTaskRunId, "Task succeeded");
1154
- logTaskCallback(`Task succeeded: ${fileTaskId}, run ID: ${fileTaskRunId}`);
1155
- }
1156
- };
2106
+ async function startRun({
2107
+ retryOf = null
2108
+ } = {}) {
2109
+ assertMandatoryIds();
2110
+ const taskId = fileTaskId;
2111
+ const runId = await taskRunService.initTaskRun({
2112
+ file_task_id: taskId,
2113
+ file_task_version_id: fileTaskVersionId,
2114
+ retry_of: retryOf
2115
+ });
2116
+ const callbacks = {
2117
+ async onNotFound() {
2118
+ await logService.logError(taskId, runId, "Task not found");
2119
+ logTaskCallback(`Task not found: ${taskId}, run ID: ${runId}`);
2120
+ },
2121
+ async onSkip({ reason }) {
2122
+ await taskRunService.skipTaskRun(runId, reason ?? null);
2123
+ await logService.logInfo(taskId, runId, "Task skipped");
2124
+ if (reason) await logService.logInfo(taskId, runId, `Skipped: ${reason}`);
2125
+ logTaskCallback(`Task skipped: ${taskId}, run ID: ${runId}`);
2126
+ },
2127
+ async onStart() {
2128
+ await taskRunService.startTaskRun(runId);
2129
+ await logService.logInfo(taskId, runId, "Task started");
2130
+ logTaskCallback(`Task started: ${taskId}, run ID: ${runId}`);
2131
+ },
2132
+ async onFailure() {
2133
+ await taskRunService.endTaskRun(runId, "failure");
2134
+ await logService.logError(taskId, runId, "Task failed");
2135
+ logTaskCallback(`Task failed: ${taskId}, run ID: ${runId}`);
2136
+ },
2137
+ async onSuccess({ result }) {
2138
+ await taskRunService.endTaskRun(runId, "success", serializeResult(result));
2139
+ await logService.logInfo(taskId, runId, "Task succeeded");
2140
+ logTaskCallback(`Task succeeded: ${taskId}, run ID: ${runId}`);
2141
+ }
2142
+ };
2143
+ return {
2144
+ id: runId,
2145
+ callbacks,
2146
+ locks: createRunLocks(lockService, runId),
2147
+ log: {
2148
+ async info(value) {
2149
+ logTaskConsoleInfo(value);
2150
+ await logService.logInfo(taskId, runId, value);
2151
+ },
2152
+ async error(value) {
2153
+ logTaskConsoleError(value);
2154
+ await logService.logError(taskId, runId, value);
2155
+ },
2156
+ async warning(value) {
2157
+ logTaskConsoleWarn(value);
2158
+ await logService.logWarning(taskId, runId, value);
2159
+ }
2160
+ },
2161
+ timing: {
2162
+ start(label) {
2163
+ return timingService.start(taskId, runId, label);
2164
+ }
2165
+ }
2166
+ };
2167
+ }
1157
2168
  return {
1158
2169
  compiledPath,
1159
2170
  sourcePath,
1160
2171
  taskCallbacks,
1161
- runtimeCallbacks,
2172
+ startRun,
2173
+ history: {
2174
+ recentTickOutcomes(limit) {
2175
+ assertMandatoryIds();
2176
+ return taskRunService.recentTickOutcomes(fileTaskId, limit);
2177
+ },
2178
+ countSkipsSince(reason, afterId) {
2179
+ assertMandatoryIds();
2180
+ return taskRunService.countSkipsSince(fileTaskId, reason, afterId);
2181
+ }
2182
+ },
1162
2183
  env,
1163
2184
  // Scoped to this task's own folder — see createTaskSqlite
1164
2185
  sqlite: createTaskSqlite(sourcePath),
@@ -1172,25 +2193,6 @@ function createControlsGenerator({
1172
2193
  async delete(key) {
1173
2194
  await kvService.deleteValue(fileTaskId, key);
1174
2195
  }
1175
- },
1176
- log: {
1177
- async info(value) {
1178
- logTaskConsoleInfo(value);
1179
- await logService.logInfo(fileTaskId, fileTaskRunId, value);
1180
- },
1181
- async error(value) {
1182
- logTaskConsoleError(value);
1183
- await logService.logError(fileTaskId, fileTaskRunId, value);
1184
- },
1185
- async warning(value) {
1186
- logTaskConsoleWarn(value);
1187
- await logService.logWarning(fileTaskId, fileTaskRunId, value);
1188
- }
1189
- },
1190
- timing: {
1191
- start(label) {
1192
- return timingService.start(fileTaskId, fileTaskRunId, label);
1193
- }
1194
2196
  }
1195
2197
  };
1196
2198
  };
@@ -1198,6 +2200,8 @@ function createControlsGenerator({
1198
2200
  }
1199
2201
  export {
1200
2202
  Compiler,
2203
+ LockBusyError,
2204
+ Queue,
1201
2205
  Task,
1202
2206
  TaskLibrary,
1203
2207
  TaskLibraryScheduler,
@@ -1207,5 +2211,7 @@ export {
1207
2211
  executeEsm,
1208
2212
  getDbClient,
1209
2213
  initDbFileIfNotExists,
2214
+ isCronPatternValid,
2215
+ scheduleTask,
1210
2216
  setupDbClient
1211
2217
  };