@apisurf/ngn-core 0.2.9 → 0.3.1

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/core.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { RunStage } from '@apisurf/ngn-schema/describe';
2
+ export { RunStage } from '@apisurf/ngn-schema/describe';
1
3
  import { FsNodeFileDescriptor } from '@apisurf/ngn-os';
2
4
  import { Client, InArgs, ResultSet, InStatement, InValue } from '@apisurf/ngn-persistence';
3
5
  export { initDbFileIfNotExists } from '@apisurf/ngn-persistence';
@@ -120,6 +122,13 @@ interface TaskSqlite {
120
122
  * Close every database opened by a task. Called by the CLI on shutdown.
121
123
  */
122
124
  declare function closeTaskDatabases(): void;
125
+ /**
126
+ * Let go of the databases one task file opened, closing those no other task
127
+ * holds. For a one-off run inside a process that keeps scheduling others (the
128
+ * editor's): `closeTaskDatabases` there would close the connections of
129
+ * scheduled runs still in progress, and their next query would fail.
130
+ */
131
+ declare function releaseTaskDatabases(taskSourcePath: string): void;
123
132
 
124
133
  /**
125
134
  * A length of time as tasks write it: milliseconds, or a string such as
@@ -484,7 +493,8 @@ interface TaskContext {
484
493
  file: FsNodeFileDescriptor;
485
494
  tasksRootDir: string;
486
495
  };
487
- env: Record<string, string> | null;
496
+ /** The variables from the config's `envFile`; empty when there is none. */
497
+ env: Record<string, string>;
488
498
  kv: {
489
499
  set(key: string, value: string): Promise<void>;
490
500
  get(key: string): Promise<string | null>;
@@ -541,9 +551,6 @@ interface CompiledTaskCallbacks {
541
551
  onTaskExportsLoaded?: () => Promise<void>;
542
552
  }
543
553
  interface TaskRuntimeCallbacks {
544
- onNotFound?: (meta: {
545
- path: string;
546
- }) => Promise<void>;
547
554
  /** `reason` is set when ngn skipped the run, rather than the task's `shouldSkip`. */
548
555
  onSkip?: (meta: {
549
556
  path: string;
@@ -556,10 +563,31 @@ interface TaskRuntimeCallbacks {
556
563
  path: string;
557
564
  result?: unknown;
558
565
  }) => Promise<void>;
566
+ /** The run failed: `error` was thrown in `stage`. Never throws itself. */
559
567
  onFailure?: (meta: {
560
568
  path: string;
569
+ stage: RunStage;
570
+ error: unknown;
571
+ }) => Promise<void>;
572
+ /** A hook threw; the run's outcome stands. Never throws itself. */
573
+ onHookError?: (meta: {
574
+ path: string;
575
+ stage: RunStage;
576
+ error: unknown;
561
577
  }) => Promise<void>;
562
578
  }
579
+ /**
580
+ * Something a run threw, once it is recorded against the run: what the CLI
581
+ * prints. `fatal` is false for a hook's error, which leaves the run's outcome
582
+ * as it was.
583
+ */
584
+ interface RunProblem {
585
+ path: string;
586
+ runId: number;
587
+ stage: RunStage;
588
+ error: unknown;
589
+ fatal: boolean;
590
+ }
563
591
  /**
564
592
  * One run's bookkeeping: its row in `task_runs`, and everything that writes
565
593
  * against that row. Made fresh for every execution, so two runs of one task
@@ -607,6 +635,17 @@ interface ExecuteSchedule {
607
635
  /** When the next tick is due, epoch ms. */
608
636
  nextAt?: number | null;
609
637
  }
638
+ /** The `task_runs.id` of the run that threw `error`, when a run recorded it. */
639
+ declare function failedRunId(error: unknown): number | null;
640
+ /**
641
+ * The run that recorded `error` and the stage it was thrown in; null for an
642
+ * error no run recorded (a task file that did not compile, a database ngn
643
+ * could not open), which only its thrower knows about.
644
+ */
645
+ declare function runFailure(error: unknown): {
646
+ runId: number;
647
+ stage: RunStage;
648
+ } | null;
610
649
  declare class Task {
611
650
  private taskCallbacks;
612
651
  private startRun;
@@ -652,8 +691,26 @@ declare class Task {
652
691
  hasTaskExport(): boolean;
653
692
  hasTimingExport(): boolean;
654
693
  loadEntry(): Promise<void>;
694
+ /**
695
+ * Load the task file's code and register its version. A failure here has no
696
+ * run to mark yet, so it is thrown to whoever called execute(). Evaluating
697
+ * the code comes after the run exists — see `readExports`.
698
+ */
655
699
  private loadAndRegister;
656
- private runTask;
700
+ /**
701
+ * Evaluate the task file and check the exports a run depends on. Throws
702
+ * what the module threw, or what is wrong with an export, for the run to
703
+ * record as failed in `load`.
704
+ */
705
+ private readExports;
706
+ /** Mark the run failed in `stage`, remembering where for whoever reports the error. */
707
+ private failRun;
708
+ /**
709
+ * Call one of the task's hooks. What it throws is recorded on the run and
710
+ * goes no further: the run's outcome was decided before the hook ran, and
711
+ * `onError`'s error must not replace the one it was called with.
712
+ */
713
+ private runHook;
657
714
  /**
658
715
  * Run the task for one tick, retrying as its `retry` export allows. A
659
716
  * scheduled tick passes when it fired and when the next one is due, which
@@ -680,6 +737,17 @@ declare class Task {
680
737
  */
681
738
  private holdBack;
682
739
  private buildContext;
740
+ /**
741
+ * Load the task file, open the attempt's `task_runs` row, then evaluate the
742
+ * file's code; what that throws is recorded on the row as failed in `load`.
743
+ */
744
+ private openRun;
745
+ /**
746
+ * One attempt: a `task_runs` row, then the task, with every failure recorded
747
+ * against that row as the stage it was thrown in. Only a task file whose code
748
+ * cannot be loaded at all, or a run row that cannot be written, fails before
749
+ * there is a row to record it on.
750
+ */
683
751
  private executeOnce;
684
752
  }
685
753
 
@@ -703,12 +771,26 @@ declare class TaskLibrary {
703
771
 
704
772
  type Options$1 = {
705
773
  taskLibrary: TaskLibrary;
774
+ /** Called when a scheduled run throws, with the run it was recorded as when there is one. */
775
+ onRunError?: (path: string, error: unknown, runId: number | null) => void;
706
776
  };
777
+ /** What `start()` did with each task: scheduled on its pattern, or left out and why. */
778
+ interface ScheduleReport {
779
+ scheduled: {
780
+ path: string;
781
+ pattern: string;
782
+ }[];
783
+ failed: {
784
+ path: string;
785
+ reason: string;
786
+ }[];
787
+ }
707
788
  declare class TaskLibraryScheduler {
708
789
  private taskLibrary;
790
+ private onRunError;
709
791
  private scheduledTasks;
710
792
  constructor(options: Options$1);
711
- start(): Promise<void>;
793
+ start(): Promise<ScheduleReport>;
712
794
  stop(): Promise<void>;
713
795
  scheduleTask(task: Task): Promise<void>;
714
796
  unscheduleTask(task: Task): void;
@@ -734,9 +816,12 @@ declare function scheduleTask(task: Task, pattern: string, onError: (error: unkn
734
816
  declare function setupDbClient(dbPath: string): Promise<Client>;
735
817
  declare function getDbClient(): Client;
736
818
 
737
- declare function createControlsGenerator({ dbClient, env, }: {
819
+ declare function createControlsGenerator({ dbClient, env, report, }: {
738
820
  dbClient: Client;
739
- env: TaskContext["env"];
821
+ /** A missing env file is null from the readers; tasks always get an object. */
822
+ env: TaskContext["env"] | null | undefined;
823
+ /** Told about everything a run throws, after it is recorded. The CLI prints it. */
824
+ report?: (problem: RunProblem) => void;
740
825
  }): BuildCompiledTaskConfigFn;
741
826
 
742
- export { type AddJob, type Backoff, type ClaimOptions, Compiler, type DBInstance, type Duration, type InitDBOptions, type Job, type JobFilter, type JobStatus, LockBusyError, type LockOptions, type Migration, type OverlapPolicy, Queue, type QueueOptions, type QueueStats, Task, type TaskContext, TaskLibrary, TaskLibraryScheduler, TaskOnceRunner, type TaskRun, type TaskSqlite, type WorkHandler, type WorkOptions, type WorkSummary, type WorkerHandle, closeTaskDatabases, createControlsGenerator, executeEsm, getDbClient, isCronPatternValid, scheduleTask, setupDbClient };
827
+ export { type AddJob, type Backoff, type ClaimOptions, Compiler, type DBInstance, type Duration, type InitDBOptions, type Job, type JobFilter, type JobStatus, LockBusyError, type LockOptions, type Migration, type OverlapPolicy, Queue, type QueueOptions, type QueueStats, type RunProblem, type ScheduleReport, Task, type TaskContext, TaskLibrary, TaskLibraryScheduler, TaskOnceRunner, type TaskRun, type TaskSqlite, type WorkHandler, type WorkOptions, type WorkSummary, type WorkerHandle, closeTaskDatabases, createControlsGenerator, executeEsm, failedRunId, getDbClient, isCronPatternValid, releaseTaskDatabases, runFailure, scheduleTask, setupDbClient };
package/dist/core.js CHANGED
@@ -297,36 +297,36 @@ var Entry = class {
297
297
  isLoaded() {
298
298
  return this.entry.isLoaded();
299
299
  }
300
+ /**
301
+ * Evaluate the task file and read its exports. Whatever the module throws
302
+ * while it is evaluated is thrown from here, so the run it was for can
303
+ * record it.
304
+ */
300
305
  async getExports() {
301
306
  invariant4(this.entry.code, "Entry code not loaded. Load entry first.");
302
- try {
303
- const taskExports = await executeEsm(this.entry.code);
304
- this.callbacks.onTaskCodeLoaded?.(this.entry.codeHash);
305
- let taskFunction;
306
- if (typeof taskExports.default === "function") {
307
- taskFunction = taskExports.default;
308
- } else if (taskExports.default && typeof taskExports.default === "object" && "task" in taskExports.default) {
309
- taskFunction = taskExports.default.task;
310
- } else {
311
- taskFunction = taskExports.task;
312
- }
313
- return {
314
- task: taskFunction,
315
- timing: taskExports.timing,
316
- overlap: taskExports.overlap,
317
- budget: taskExports.budget,
318
- exclusive: taskExports.exclusive,
319
- retry: taskExports.retry,
320
- shouldSkip: taskExports.shouldSkip,
321
- shouldRetry: taskExports.shouldRetry,
322
- onSuccess: taskExports.onSuccess,
323
- onError: taskExports.onError,
324
- onComplete: taskExports.onComplete
325
- };
326
- } catch (error) {
327
- console.error("Error getting task exports:", error);
328
- return null;
307
+ const taskExports = await executeEsm(this.entry.code);
308
+ this.callbacks.onTaskCodeLoaded?.(this.entry.codeHash);
309
+ let taskFunction;
310
+ if (typeof taskExports.default === "function") {
311
+ taskFunction = taskExports.default;
312
+ } else if (taskExports.default && typeof taskExports.default === "object" && "task" in taskExports.default) {
313
+ taskFunction = taskExports.default.task;
314
+ } else {
315
+ taskFunction = taskExports.task;
329
316
  }
317
+ return {
318
+ task: taskFunction,
319
+ timing: taskExports.timing,
320
+ overlap: taskExports.overlap,
321
+ budget: taskExports.budget,
322
+ exclusive: taskExports.exclusive,
323
+ retry: taskExports.retry,
324
+ shouldSkip: taskExports.shouldSkip,
325
+ shouldRetry: taskExports.shouldRetry,
326
+ onSuccess: taskExports.onSuccess,
327
+ onError: taskExports.onError,
328
+ onComplete: taskExports.onComplete
329
+ };
330
330
  }
331
331
  get paths() {
332
332
  return this.entry.path;
@@ -530,27 +530,28 @@ function resolveTaskDbPath(taskDir, file) {
530
530
  }
531
531
  return resolved;
532
532
  }
533
- function getClient(absolutePath) {
533
+ function getClient(absolutePath, owner) {
534
534
  const existing = openClients.get(absolutePath);
535
535
  if (existing) {
536
- return existing;
536
+ existing.owners.add(owner);
537
+ return existing.client;
537
538
  }
538
539
  mkdirSync(dirname(absolutePath), { recursive: true });
539
540
  const client = openDbClient(`file:${absolutePath}`);
540
- openClients.set(absolutePath, client);
541
+ openClients.set(absolutePath, { client, owners: /* @__PURE__ */ new Set([owner]) });
541
542
  return client;
542
543
  }
543
544
  function closeClient(absolutePath) {
544
- const client = openClients.get(absolutePath);
545
- if (client) {
546
- client.close();
545
+ const open = openClients.get(absolutePath);
546
+ if (open) {
547
+ open.client.close();
547
548
  openClients.delete(absolutePath);
548
549
  }
549
550
  }
550
551
  function createTaskSqlite(taskSourcePath) {
551
552
  const taskDir = dirname(taskSourcePath);
552
553
  const defaultDbPath = join(taskDir, `${basename(taskSourcePath, extname(taskSourcePath))}.db`);
553
- const defaultClient = () => getClient(defaultDbPath);
554
+ const defaultClient = () => getClient(defaultDbPath, taskSourcePath);
554
555
  return {
555
556
  get client() {
556
557
  return defaultClient();
@@ -562,7 +563,7 @@ function createTaskSqlite(taskSourcePath) {
562
563
  batch: (statements) => defaultClient().batch(statements),
563
564
  async initDB(options) {
564
565
  const dbPath = resolveTaskDbPath(taskDir, options.file);
565
- const client = getClient(dbPath);
566
+ const client = getClient(dbPath, taskSourcePath);
566
567
  if (options.migrations?.length) {
567
568
  await runMigrations(client, options.migrations);
568
569
  }
@@ -582,11 +583,18 @@ function createTaskSqlite(taskSourcePath) {
582
583
  };
583
584
  }
584
585
  function closeTaskDatabases() {
585
- for (const client of openClients.values()) {
586
+ for (const { client } of openClients.values()) {
586
587
  client.close();
587
588
  }
588
589
  openClients.clear();
589
590
  }
591
+ function releaseTaskDatabases(taskSourcePath) {
592
+ for (const [path, { client, owners }] of openClients) {
593
+ if (!owners.delete(taskSourcePath) || owners.size > 0) continue;
594
+ client.close();
595
+ openClients.delete(path);
596
+ }
597
+ }
590
598
 
591
599
  // src/runtime/queue/schema.ts
592
600
  var QUEUE_MIGRATIONS = [
@@ -1170,6 +1178,13 @@ function breakerSkips(failedInARow, breaker) {
1170
1178
 
1171
1179
  // src/runtime/task.ts
1172
1180
  var sleep2 = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
1181
+ var failedRuns = /* @__PURE__ */ new WeakMap();
1182
+ function failedRunId(error) {
1183
+ return runFailure(error)?.runId ?? null;
1184
+ }
1185
+ function runFailure(error) {
1186
+ return error && typeof error === "object" ? failedRuns.get(error) ?? null : null;
1187
+ }
1173
1188
  var Task = class {
1174
1189
  taskCallbacks;
1175
1190
  startRun;
@@ -1280,12 +1295,15 @@ var Task = class {
1280
1295
  await this.entry.load();
1281
1296
  this.entryExports = await this.entry.getExports();
1282
1297
  }
1298
+ /**
1299
+ * Load the task file's code and register its version. A failure here has no
1300
+ * run to mark yet, so it is thrown to whoever called execute(). Evaluating
1301
+ * the code comes after the run exists — see `readExports`.
1302
+ */
1283
1303
  async loadAndRegister() {
1284
1304
  invariant5(this.entry, "Entry not initialized.");
1285
- await this.loadEntry();
1305
+ await this.entry.load();
1286
1306
  invariant5(this.entry.isLoaded(), "Entry failed to load.");
1287
- invariant5(this.entryExports, "Entry failed to get exports.");
1288
- this.taskCallbacks?.onTaskExportsLoaded?.();
1289
1307
  const loadResult = await this.taskCallbacks?.onTaskLoadComplete?.({
1290
1308
  paths: this.entry.paths,
1291
1309
  md5Hash: this.entry.codeHash,
@@ -1294,21 +1312,44 @@ var Task = class {
1294
1312
  this.meta.fileTaskId = loadResult?.fileTaskId ?? -1;
1295
1313
  this.meta.fileTaskVersionId = loadResult?.fileTaskVersionId ?? -1;
1296
1314
  }
1297
- async runTask(task, taskContext, hooks, run) {
1315
+ /**
1316
+ * Evaluate the task file and check the exports a run depends on. Throws
1317
+ * what the module threw, or what is wrong with an export, for the run to
1318
+ * record as failed in `load`.
1319
+ */
1320
+ async readExports(path) {
1321
+ invariant5(this.entry, "Entry not initialized.");
1322
+ const exports = await this.entry.getExports();
1323
+ this.entryExports = exports;
1324
+ this.taskCallbacks?.onTaskExportsLoaded?.();
1325
+ if (typeof exports.task !== "function") {
1326
+ throw new Error(`${path} exports no task function`);
1327
+ }
1328
+ return {
1329
+ exports,
1330
+ task: exports.task,
1331
+ budgetMs: exports.budget == null ? null : toMs(exports.budget, `budget for ${path}`),
1332
+ exclusiveLocks: exclusiveNames(exports.exclusive, path),
1333
+ retry: resolveRetry(exports.retry, path)
1334
+ };
1335
+ }
1336
+ /** Mark the run failed in `stage`, remembering where for whoever reports the error. */
1337
+ async failRun(run, stage, error) {
1338
+ invariant5(this.entry, "Entry not initialized.");
1339
+ if (error && typeof error === "object") failedRuns.set(error, { runId: run.id, stage });
1340
+ await run.callbacks.onFailure?.({ path: this.entry.paths.relativeEntry, stage, error });
1341
+ }
1342
+ /**
1343
+ * Call one of the task's hooks. What it throws is recorded on the run and
1344
+ * goes no further: the run's outcome was decided before the hook ran, and
1345
+ * `onError`'s error must not replace the one it was called with.
1346
+ */
1347
+ async runHook(run, stage, hook) {
1298
1348
  invariant5(this.entry, "Entry not initialized.");
1299
- const path = this.entry.paths.relativeEntry;
1300
1349
  try {
1301
- await run.callbacks.onStart?.({ path });
1302
- const taskReturnValue = await task(taskContext);
1303
- await run.callbacks.onSuccess?.({ path, result: taskReturnValue });
1304
- await hooks.onSuccess?.(taskContext);
1305
- return taskReturnValue;
1306
- } catch (err) {
1307
- await run.callbacks.onFailure?.({ path });
1308
- await hooks.onError?.(err, taskContext);
1309
- throw err;
1310
- } finally {
1311
- await hooks.onComplete?.(taskContext);
1350
+ await hook();
1351
+ } catch (error) {
1352
+ await run.callbacks.onHookError?.({ path: this.entry.paths.relativeEntry, stage, error });
1312
1353
  }
1313
1354
  }
1314
1355
  /**
@@ -1398,27 +1439,38 @@ var Task = class {
1398
1439
  queue: (name, options = {}) => openQueue(options.db?.client ?? this.sqlite.client, name, options, { nextAt, signal })
1399
1440
  };
1400
1441
  }
1401
- async executeOnce({ scheduledAt = null, nextAt = null }, tick2) {
1442
+ /**
1443
+ * Load the task file, open the attempt's `task_runs` row, then evaluate the
1444
+ * file's code; what that throws is recorded on the row as failed in `load`.
1445
+ */
1446
+ async openRun(tick2) {
1402
1447
  invariant5(this.entry, "Entry not initialized.");
1403
1448
  await this.loadAndRegister();
1404
- invariant5(this.entryExports, "Entry failed to get exports.");
1405
- const exports = this.entryExports;
1406
1449
  const meta = { ...this.meta };
1407
1450
  const path = this.entry.paths.relativeEntry;
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
1451
  const run = await this.startRun({ retryOf: tick2.firstRunId });
1413
1452
  const firstAttempt = tick2.firstRunId == null;
1414
1453
  tick2.firstRunId ??= run.id;
1415
- if (!exports.task) {
1416
- await run.callbacks.onNotFound?.({ path });
1417
- throw new Error("Task function not found");
1454
+ try {
1455
+ return { run, meta, path, firstAttempt, ...await this.readExports(path) };
1456
+ } catch (error) {
1457
+ await this.failRun(run, "load", error);
1458
+ throw error;
1418
1459
  }
1460
+ }
1461
+ /**
1462
+ * One attempt: a `task_runs` row, then the task, with every failure recorded
1463
+ * against that row as the stage it was thrown in. Only a task file whose code
1464
+ * cannot be loaded at all, or a run row that cannot be written, fails before
1465
+ * there is a row to record it on.
1466
+ */
1467
+ async executeOnce({ scheduledAt = null, nextAt = null }, tick2) {
1468
+ const { run, meta, path, firstAttempt, exports, task, budgetMs, exclusiveLocks, retry } = await this.openRun(tick2);
1469
+ tick2.retry = retry;
1419
1470
  const deadline = runDeadline({ startedAt: Date.now(), scheduledAt, nextAt, budgetMs });
1420
1471
  const { signal, dispose } = deadlineSignal(deadline);
1421
1472
  const taskContext = this.buildContext(run, meta, { scheduledAt, nextAt, deadline }, signal);
1473
+ let stage = "start";
1422
1474
  let exclusiveHeld = null;
1423
1475
  try {
1424
1476
  const gate = await this.holdBack(run, {
@@ -1432,20 +1484,29 @@ var Task = class {
1432
1484
  return;
1433
1485
  }
1434
1486
  exclusiveHeld = gate.held;
1487
+ stage = "shouldSkip";
1435
1488
  if (await exports.shouldSkip?.(taskContext)) {
1436
1489
  await run.callbacks.onSkip?.({ path });
1437
1490
  return;
1438
1491
  }
1492
+ stage = "start";
1493
+ await run.callbacks.onStart?.({ path });
1494
+ stage = "task";
1439
1495
  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
- );
1496
+ const value = await task(taskContext);
1447
1497
  tick2.failedContext = null;
1498
+ stage = "record";
1499
+ await run.callbacks.onSuccess?.({ path, result: value });
1500
+ await this.runHook(run, "onSuccess", () => exports.onSuccess?.(taskContext));
1501
+ await this.runHook(run, "onComplete", () => exports.onComplete?.(taskContext));
1448
1502
  return value;
1503
+ } catch (error) {
1504
+ await this.failRun(run, stage, error);
1505
+ if (stage === "task" || stage === "record") {
1506
+ await this.runHook(run, "onError", () => exports.onError?.(error, taskContext));
1507
+ await this.runHook(run, "onComplete", () => exports.onComplete?.(taskContext));
1508
+ }
1509
+ throw error;
1449
1510
  } finally {
1450
1511
  dispose();
1451
1512
  await exclusiveHeld?.release();
@@ -1553,23 +1614,30 @@ function scheduleTask(task, pattern, onError) {
1553
1614
  // src/runtime/taskLibraryScheduler.ts
1554
1615
  var TaskLibraryScheduler = class {
1555
1616
  taskLibrary;
1617
+ onRunError;
1556
1618
  scheduledTasks = /* @__PURE__ */ new Map();
1557
1619
  constructor(options) {
1558
1620
  this.taskLibrary = options.taskLibrary;
1621
+ this.onRunError = options.onRunError;
1559
1622
  }
1560
1623
  async start() {
1561
1624
  logTaskLibraryScheduler("Starting scheduler");
1625
+ const report = { scheduled: [], failed: [] };
1562
1626
  const scheduledTasks = this.taskLibrary.getTasks().map(async (task) => {
1563
1627
  try {
1564
- const res = await this.scheduleTask(task);
1565
- return res;
1628
+ await this.scheduleTask(task);
1629
+ report.scheduled.push({ path: task.taskPath, pattern: task.timingPattern ?? "" });
1566
1630
  } catch (error) {
1567
1631
  logTaskLibrarySchedulerError(`Error scheduling task: ${task.taskPath}`);
1568
1632
  logTaskLibrarySchedulerError(error);
1633
+ report.failed.push({ path: task.taskPath, reason: reasonOf(error, task.taskPath) });
1569
1634
  }
1570
1635
  });
1571
1636
  await Promise.all(scheduledTasks);
1572
1637
  logTaskLibraryScheduler("Scheduler started");
1638
+ report.scheduled.sort(byPath);
1639
+ report.failed.sort(byPath);
1640
+ return report;
1573
1641
  }
1574
1642
  async stop() {
1575
1643
  logTaskLibraryScheduler("Stopping scheduler");
@@ -1591,6 +1659,7 @@ var TaskLibraryScheduler = class {
1591
1659
  const scheduledTask = scheduleTask(task, task.timingPattern, (error) => {
1592
1660
  logTaskLibrarySchedulerError(`Run failed: ${task.taskPath}`);
1593
1661
  logTaskLibrarySchedulerError(error);
1662
+ this.onRunError?.(task.taskPath, error, failedRunId(error));
1594
1663
  });
1595
1664
  this.scheduledTasks.set(task.taskPath, scheduledTask);
1596
1665
  logTaskLibraryScheduler(`Scheduled: ${task.taskPath}`);
@@ -1623,6 +1692,13 @@ var TaskLibraryScheduler = class {
1623
1692
  // task.stop();
1624
1693
  // }
1625
1694
  };
1695
+ function byPath(a, b) {
1696
+ return a.path.localeCompare(b.path);
1697
+ }
1698
+ function reasonOf(error, path) {
1699
+ const message = error instanceof Error ? error.message : String(error);
1700
+ return message.replace(/^Invariant failed: /, "").replace(` for ${path}`, "").replace(`: ${path}`, "");
1701
+ }
1626
1702
 
1627
1703
  // src/runtime/taskOnceRunner.ts
1628
1704
  import invariant9 from "tiny-invariant";
@@ -1678,6 +1754,7 @@ import {
1678
1754
  logTaskConsoleWarn
1679
1755
  } from "@apisurf/ngn-os";
1680
1756
  import invariant11 from "tiny-invariant";
1757
+ import { errorSummary, failureLine } from "@apisurf/ngn-schema/describe";
1681
1758
 
1682
1759
  // src/db/services/fileTask.ts
1683
1760
  import { $fileTaskInsert } from "@apisurf/ngn-schema";
@@ -2069,9 +2146,17 @@ var LockService = class {
2069
2146
  };
2070
2147
 
2071
2148
  // src/runtime/generateControls.ts
2149
+ async function recordSafely(runId, write) {
2150
+ try {
2151
+ await write();
2152
+ } catch (error) {
2153
+ logTaskCallback(`Could not record what run ${runId} threw: ${errorSummary(error)}`);
2154
+ }
2155
+ }
2072
2156
  function createControlsGenerator({
2073
2157
  dbClient,
2074
- env
2158
+ env,
2159
+ report
2075
2160
  }) {
2076
2161
  const taskRunService = new TaskRunService(dbClient);
2077
2162
  const logService = new LogService(dbClient);
@@ -2114,10 +2199,6 @@ function createControlsGenerator({
2114
2199
  retry_of: retryOf
2115
2200
  });
2116
2201
  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
2202
  async onSkip({ reason }) {
2122
2203
  await taskRunService.skipTaskRun(runId, reason ?? null);
2123
2204
  await logService.logInfo(taskId, runId, "Task skipped");
@@ -2129,10 +2210,22 @@ function createControlsGenerator({
2129
2210
  await logService.logInfo(taskId, runId, "Task started");
2130
2211
  logTaskCallback(`Task started: ${taskId}, run ID: ${runId}`);
2131
2212
  },
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}`);
2213
+ async onFailure({ path, stage, error }) {
2214
+ await recordSafely(runId, async () => {
2215
+ await taskRunService.endTaskRun(runId, "failure");
2216
+ await logService.logError(taskId, runId, failureLine(stage, error));
2217
+ await logService.logError(taskId, runId, "Task failed");
2218
+ });
2219
+ logTaskCallback(`Task failed in ${stage}: ${taskId}, run ID: ${runId}`);
2220
+ report?.({ path, runId, stage, error, fatal: true });
2221
+ },
2222
+ async onHookError({ path, stage, error }) {
2223
+ await recordSafely(
2224
+ runId,
2225
+ () => logService.logError(taskId, runId, failureLine(stage, error))
2226
+ );
2227
+ logTaskCallback(`Hook ${stage} threw: ${taskId}, run ID: ${runId}`);
2228
+ report?.({ path, runId, stage, error, fatal: false });
2136
2229
  },
2137
2230
  async onSuccess({ result }) {
2138
2231
  await taskRunService.endTaskRun(runId, "success", serializeResult(result));
@@ -2180,7 +2273,7 @@ function createControlsGenerator({
2180
2273
  return taskRunService.countSkipsSince(fileTaskId, reason, afterId);
2181
2274
  }
2182
2275
  },
2183
- env,
2276
+ env: env ?? {},
2184
2277
  // Scoped to this task's own folder — see createTaskSqlite
2185
2278
  sqlite: createTaskSqlite(sourcePath),
2186
2279
  kv: {
@@ -2209,9 +2302,12 @@ export {
2209
2302
  closeTaskDatabases,
2210
2303
  createControlsGenerator,
2211
2304
  executeEsm,
2305
+ failedRunId,
2212
2306
  getDbClient,
2213
2307
  initDbFileIfNotExists,
2214
2308
  isCronPatternValid,
2309
+ releaseTaskDatabases,
2310
+ runFailure,
2215
2311
  scheduleTask,
2216
2312
  setupDbClient
2217
2313
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apisurf/ngn-core",
3
- "version": "0.2.9",
3
+ "version": "0.3.1",
4
4
  "description": "ngn runtime: task compilation, scheduling and the run/log/timing services.",
5
5
  "type": "module",
6
6
  "author": "Luka Vidakovic",
@@ -38,9 +38,9 @@
38
38
  "esbuild": "^0.27.1",
39
39
  "tiny-invariant": "^1.3.3",
40
40
  "zod": "^3.24.2",
41
- "@apisurf/ngn-schema": "0.2.9",
42
- "@apisurf/ngn-os": "0.2.9",
43
- "@apisurf/ngn-persistence": "0.2.9"
41
+ "@apisurf/ngn-os": "0.3.1",
42
+ "@apisurf/ngn-schema": "0.3.1",
43
+ "@apisurf/ngn-persistence": "0.3.1"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/node": "^22.8.1",