@apisurf/ngn-core 0.3.0 → 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,8 +635,17 @@ interface ExecuteSchedule {
607
635
  /** When the next tick is due, epoch ms. */
608
636
  nextAt?: number | null;
609
637
  }
610
- /** The `task_runs.id` of the run that threw `error`, when a task run did. */
638
+ /** The `task_runs.id` of the run that threw `error`, when a run recorded it. */
611
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;
612
649
  declare class Task {
613
650
  private taskCallbacks;
614
651
  private startRun;
@@ -654,8 +691,26 @@ declare class Task {
654
691
  hasTaskExport(): boolean;
655
692
  hasTimingExport(): boolean;
656
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
+ */
657
699
  private loadAndRegister;
658
- 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;
659
714
  /**
660
715
  * Run the task for one tick, retrying as its `retry` export allows. A
661
716
  * scheduled tick passes when it fired and when the next one is due, which
@@ -682,6 +737,17 @@ declare class Task {
682
737
  */
683
738
  private holdBack;
684
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
+ */
685
751
  private executeOnce;
686
752
  }
687
753
 
@@ -750,9 +816,12 @@ declare function scheduleTask(task: Task, pattern: string, onError: (error: unkn
750
816
  declare function setupDbClient(dbPath: string): Promise<Client>;
751
817
  declare function getDbClient(): Client;
752
818
 
753
- declare function createControlsGenerator({ dbClient, env, }: {
819
+ declare function createControlsGenerator({ dbClient, env, report, }: {
754
820
  dbClient: Client;
755
- 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;
756
825
  }): BuildCompiledTaskConfigFn;
757
826
 
758
- 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 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, 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 = [
@@ -1172,6 +1180,9 @@ function breakerSkips(failedInARow, breaker) {
1172
1180
  var sleep2 = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
1173
1181
  var failedRuns = /* @__PURE__ */ new WeakMap();
1174
1182
  function failedRunId(error) {
1183
+ return runFailure(error)?.runId ?? null;
1184
+ }
1185
+ function runFailure(error) {
1175
1186
  return error && typeof error === "object" ? failedRuns.get(error) ?? null : null;
1176
1187
  }
1177
1188
  var Task = class {
@@ -1284,12 +1295,15 @@ var Task = class {
1284
1295
  await this.entry.load();
1285
1296
  this.entryExports = await this.entry.getExports();
1286
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
+ */
1287
1303
  async loadAndRegister() {
1288
1304
  invariant5(this.entry, "Entry not initialized.");
1289
- await this.loadEntry();
1305
+ await this.entry.load();
1290
1306
  invariant5(this.entry.isLoaded(), "Entry failed to load.");
1291
- invariant5(this.entryExports, "Entry failed to get exports.");
1292
- this.taskCallbacks?.onTaskExportsLoaded?.();
1293
1307
  const loadResult = await this.taskCallbacks?.onTaskLoadComplete?.({
1294
1308
  paths: this.entry.paths,
1295
1309
  md5Hash: this.entry.codeHash,
@@ -1298,22 +1312,44 @@ var Task = class {
1298
1312
  this.meta.fileTaskId = loadResult?.fileTaskId ?? -1;
1299
1313
  this.meta.fileTaskVersionId = loadResult?.fileTaskVersionId ?? -1;
1300
1314
  }
1301
- 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) {
1302
1348
  invariant5(this.entry, "Entry not initialized.");
1303
- const path = this.entry.paths.relativeEntry;
1304
1349
  try {
1305
- await run.callbacks.onStart?.({ path });
1306
- const taskReturnValue = await task(taskContext);
1307
- await run.callbacks.onSuccess?.({ path, result: taskReturnValue });
1308
- await hooks.onSuccess?.(taskContext);
1309
- return taskReturnValue;
1310
- } catch (err) {
1311
- if (err && typeof err === "object") failedRuns.set(err, run.id);
1312
- await run.callbacks.onFailure?.({ path });
1313
- await hooks.onError?.(err, taskContext);
1314
- throw err;
1315
- } finally {
1316
- await hooks.onComplete?.(taskContext);
1350
+ await hook();
1351
+ } catch (error) {
1352
+ await run.callbacks.onHookError?.({ path: this.entry.paths.relativeEntry, stage, error });
1317
1353
  }
1318
1354
  }
1319
1355
  /**
@@ -1403,27 +1439,38 @@ var Task = class {
1403
1439
  queue: (name, options = {}) => openQueue(options.db?.client ?? this.sqlite.client, name, options, { nextAt, signal })
1404
1440
  };
1405
1441
  }
1406
- 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) {
1407
1447
  invariant5(this.entry, "Entry not initialized.");
1408
1448
  await this.loadAndRegister();
1409
- invariant5(this.entryExports, "Entry failed to get exports.");
1410
- const exports = this.entryExports;
1411
1449
  const meta = { ...this.meta };
1412
1450
  const path = this.entry.paths.relativeEntry;
1413
- const budgetMs = exports.budget == null ? null : toMs(exports.budget, `budget for ${path}`);
1414
- const exclusiveLocks = exclusiveNames(exports.exclusive, path);
1415
- const retry = resolveRetry(exports.retry, path);
1416
- tick2.retry = retry;
1417
1451
  const run = await this.startRun({ retryOf: tick2.firstRunId });
1418
1452
  const firstAttempt = tick2.firstRunId == null;
1419
1453
  tick2.firstRunId ??= run.id;
1420
- if (!exports.task) {
1421
- await run.callbacks.onNotFound?.({ path });
1422
- 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;
1423
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;
1424
1470
  const deadline = runDeadline({ startedAt: Date.now(), scheduledAt, nextAt, budgetMs });
1425
1471
  const { signal, dispose } = deadlineSignal(deadline);
1426
1472
  const taskContext = this.buildContext(run, meta, { scheduledAt, nextAt, deadline }, signal);
1473
+ let stage = "start";
1427
1474
  let exclusiveHeld = null;
1428
1475
  try {
1429
1476
  const gate = await this.holdBack(run, {
@@ -1437,20 +1484,29 @@ var Task = class {
1437
1484
  return;
1438
1485
  }
1439
1486
  exclusiveHeld = gate.held;
1487
+ stage = "shouldSkip";
1440
1488
  if (await exports.shouldSkip?.(taskContext)) {
1441
1489
  await run.callbacks.onSkip?.({ path });
1442
1490
  return;
1443
1491
  }
1492
+ stage = "start";
1493
+ await run.callbacks.onStart?.({ path });
1494
+ stage = "task";
1444
1495
  tick2.failedContext = taskContext;
1445
- const { onSuccess, onError, onComplete } = exports;
1446
- const value = await this.runTask(
1447
- exports.task,
1448
- taskContext,
1449
- { onSuccess, onError, onComplete },
1450
- run
1451
- );
1496
+ const value = await task(taskContext);
1452
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));
1453
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;
1454
1510
  } finally {
1455
1511
  dispose();
1456
1512
  await exclusiveHeld?.release();
@@ -1698,6 +1754,7 @@ import {
1698
1754
  logTaskConsoleWarn
1699
1755
  } from "@apisurf/ngn-os";
1700
1756
  import invariant11 from "tiny-invariant";
1757
+ import { errorSummary, failureLine } from "@apisurf/ngn-schema/describe";
1701
1758
 
1702
1759
  // src/db/services/fileTask.ts
1703
1760
  import { $fileTaskInsert } from "@apisurf/ngn-schema";
@@ -2089,9 +2146,17 @@ var LockService = class {
2089
2146
  };
2090
2147
 
2091
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
+ }
2092
2156
  function createControlsGenerator({
2093
2157
  dbClient,
2094
- env
2158
+ env,
2159
+ report
2095
2160
  }) {
2096
2161
  const taskRunService = new TaskRunService(dbClient);
2097
2162
  const logService = new LogService(dbClient);
@@ -2134,10 +2199,6 @@ function createControlsGenerator({
2134
2199
  retry_of: retryOf
2135
2200
  });
2136
2201
  const callbacks = {
2137
- async onNotFound() {
2138
- await logService.logError(taskId, runId, "Task not found");
2139
- logTaskCallback(`Task not found: ${taskId}, run ID: ${runId}`);
2140
- },
2141
2202
  async onSkip({ reason }) {
2142
2203
  await taskRunService.skipTaskRun(runId, reason ?? null);
2143
2204
  await logService.logInfo(taskId, runId, "Task skipped");
@@ -2149,10 +2210,22 @@ function createControlsGenerator({
2149
2210
  await logService.logInfo(taskId, runId, "Task started");
2150
2211
  logTaskCallback(`Task started: ${taskId}, run ID: ${runId}`);
2151
2212
  },
2152
- async onFailure() {
2153
- await taskRunService.endTaskRun(runId, "failure");
2154
- await logService.logError(taskId, runId, "Task failed");
2155
- 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 });
2156
2229
  },
2157
2230
  async onSuccess({ result }) {
2158
2231
  await taskRunService.endTaskRun(runId, "success", serializeResult(result));
@@ -2200,7 +2273,7 @@ function createControlsGenerator({
2200
2273
  return taskRunService.countSkipsSince(fileTaskId, reason, afterId);
2201
2274
  }
2202
2275
  },
2203
- env,
2276
+ env: env ?? {},
2204
2277
  // Scoped to this task's own folder — see createTaskSqlite
2205
2278
  sqlite: createTaskSqlite(sourcePath),
2206
2279
  kv: {
@@ -2233,6 +2306,8 @@ export {
2233
2306
  getDbClient,
2234
2307
  initDbFileIfNotExists,
2235
2308
  isCronPatternValid,
2309
+ releaseTaskDatabases,
2310
+ runFailure,
2236
2311
  scheduleTask,
2237
2312
  setupDbClient
2238
2313
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apisurf/ngn-core",
3
- "version": "0.3.0",
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-os": "0.3.0",
42
- "@apisurf/ngn-persistence": "0.3.0",
43
- "@apisurf/ngn-schema": "0.3.0"
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",