@apisurf/ngn-core 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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,9 +563,30 @@ 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;
561
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;
577
+ }) => Promise<void>;
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;
562
590
  }
563
591
  /**
564
592
  * One run's bookkeeping: its row in `task_runs`, and everything that writes
@@ -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
 
@@ -708,6 +774,13 @@ type Options$1 = {
708
774
  /** Called when a scheduled run throws, with the run it was recorded as when there is one. */
709
775
  onRunError?: (path: string, error: unknown, runId: number | null) => void;
710
776
  };
777
+ /** What `runNow()` did: started a run, or why it did not. */
778
+ type RunNowOutcome = {
779
+ started: true;
780
+ } | {
781
+ started: false;
782
+ reason: string;
783
+ };
711
784
  /** What `start()` did with each task: scheduled on its pattern, or left out and why. */
712
785
  interface ScheduleReport {
713
786
  scheduled: {
@@ -727,6 +800,17 @@ declare class TaskLibraryScheduler {
727
800
  start(): Promise<ScheduleReport>;
728
801
  stop(): Promise<void>;
729
802
  scheduleTask(task: Task): Promise<void>;
803
+ /**
804
+ * Start one run of a scheduled task now, outside its schedule, the way a tick
805
+ * would: retries, budget, locks and hooks all apply, and errors go where a
806
+ * tick's go. It is not a tick, so the breaker does not hold it back. Resolves
807
+ * once the run is under way, not when it ends: a run can take longer than
808
+ * anyone waits on a button.
809
+ *
810
+ * A run still going is reported rather than recorded as a skip, since nothing
811
+ * was due: the person asking can simply wait.
812
+ */
813
+ runNow(taskPath: string): RunNowOutcome;
730
814
  unscheduleTask(task: Task): void;
731
815
  isCronPatternValid(cronPattern: string): boolean;
732
816
  }
@@ -750,9 +834,12 @@ declare function scheduleTask(task: Task, pattern: string, onError: (error: unkn
750
834
  declare function setupDbClient(dbPath: string): Promise<Client>;
751
835
  declare function getDbClient(): Client;
752
836
 
753
- declare function createControlsGenerator({ dbClient, env, }: {
837
+ declare function createControlsGenerator({ dbClient, env, report, }: {
754
838
  dbClient: Client;
755
- env: TaskContext["env"];
839
+ /** A missing env file is null from the readers; tasks always get an object. */
840
+ env: TaskContext["env"] | null | undefined;
841
+ /** Told about everything a run throws, after it is recorded. The CLI prints it. */
842
+ report?: (problem: RunProblem) => void;
756
843
  }): BuildCompiledTaskConfigFn;
757
844
 
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 };
845
+ 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 RunNowOutcome, 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;
@@ -385,10 +385,17 @@ function deadlineSignal(deadline) {
385
385
  return { signal: controller.signal, dispose: () => {
386
386
  } };
387
387
  }
388
- const timer = setTimeout(abort, delay);
389
- timer.unref();
388
+ let timer;
389
+ const arm = () => {
390
+ const left = deadline - Date.now();
391
+ if (left <= 0) return abort();
392
+ timer = setTimeout(arm, Math.min(left, MAX_TIMER_MS));
393
+ timer.unref();
394
+ };
395
+ arm();
390
396
  return { signal: controller.signal, dispose: () => clearTimeout(timer) };
391
397
  }
398
+ var MAX_TIMER_MS = 2 ** 31 - 1;
392
399
 
393
400
  // src/runtime/locks.ts
394
401
  var LockBusyError = class extends Error {
@@ -530,27 +537,28 @@ function resolveTaskDbPath(taskDir, file) {
530
537
  }
531
538
  return resolved;
532
539
  }
533
- function getClient(absolutePath) {
540
+ function getClient(absolutePath, owner) {
534
541
  const existing = openClients.get(absolutePath);
535
542
  if (existing) {
536
- return existing;
543
+ existing.owners.add(owner);
544
+ return existing.client;
537
545
  }
538
546
  mkdirSync(dirname(absolutePath), { recursive: true });
539
547
  const client = openDbClient(`file:${absolutePath}`);
540
- openClients.set(absolutePath, client);
548
+ openClients.set(absolutePath, { client, owners: /* @__PURE__ */ new Set([owner]) });
541
549
  return client;
542
550
  }
543
551
  function closeClient(absolutePath) {
544
- const client = openClients.get(absolutePath);
545
- if (client) {
546
- client.close();
552
+ const open = openClients.get(absolutePath);
553
+ if (open) {
554
+ open.client.close();
547
555
  openClients.delete(absolutePath);
548
556
  }
549
557
  }
550
558
  function createTaskSqlite(taskSourcePath) {
551
559
  const taskDir = dirname(taskSourcePath);
552
560
  const defaultDbPath = join(taskDir, `${basename(taskSourcePath, extname(taskSourcePath))}.db`);
553
- const defaultClient = () => getClient(defaultDbPath);
561
+ const defaultClient = () => getClient(defaultDbPath, taskSourcePath);
554
562
  return {
555
563
  get client() {
556
564
  return defaultClient();
@@ -562,7 +570,7 @@ function createTaskSqlite(taskSourcePath) {
562
570
  batch: (statements) => defaultClient().batch(statements),
563
571
  async initDB(options) {
564
572
  const dbPath = resolveTaskDbPath(taskDir, options.file);
565
- const client = getClient(dbPath);
573
+ const client = getClient(dbPath, taskSourcePath);
566
574
  if (options.migrations?.length) {
567
575
  await runMigrations(client, options.migrations);
568
576
  }
@@ -582,11 +590,18 @@ function createTaskSqlite(taskSourcePath) {
582
590
  };
583
591
  }
584
592
  function closeTaskDatabases() {
585
- for (const client of openClients.values()) {
593
+ for (const { client } of openClients.values()) {
586
594
  client.close();
587
595
  }
588
596
  openClients.clear();
589
597
  }
598
+ function releaseTaskDatabases(taskSourcePath) {
599
+ for (const [path, { client, owners }] of openClients) {
600
+ if (!owners.delete(taskSourcePath) || owners.size > 0) continue;
601
+ client.close();
602
+ openClients.delete(path);
603
+ }
604
+ }
590
605
 
591
606
  // src/runtime/queue/schema.ts
592
607
  var QUEUE_MIGRATIONS = [
@@ -1172,6 +1187,9 @@ function breakerSkips(failedInARow, breaker) {
1172
1187
  var sleep2 = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
1173
1188
  var failedRuns = /* @__PURE__ */ new WeakMap();
1174
1189
  function failedRunId(error) {
1190
+ return runFailure(error)?.runId ?? null;
1191
+ }
1192
+ function runFailure(error) {
1175
1193
  return error && typeof error === "object" ? failedRuns.get(error) ?? null : null;
1176
1194
  }
1177
1195
  var Task = class {
@@ -1284,12 +1302,15 @@ var Task = class {
1284
1302
  await this.entry.load();
1285
1303
  this.entryExports = await this.entry.getExports();
1286
1304
  }
1305
+ /**
1306
+ * Load the task file's code and register its version. A failure here has no
1307
+ * run to mark yet, so it is thrown to whoever called execute(). Evaluating
1308
+ * the code comes after the run exists — see `readExports`.
1309
+ */
1287
1310
  async loadAndRegister() {
1288
1311
  invariant5(this.entry, "Entry not initialized.");
1289
- await this.loadEntry();
1312
+ await this.entry.load();
1290
1313
  invariant5(this.entry.isLoaded(), "Entry failed to load.");
1291
- invariant5(this.entryExports, "Entry failed to get exports.");
1292
- this.taskCallbacks?.onTaskExportsLoaded?.();
1293
1314
  const loadResult = await this.taskCallbacks?.onTaskLoadComplete?.({
1294
1315
  paths: this.entry.paths,
1295
1316
  md5Hash: this.entry.codeHash,
@@ -1298,22 +1319,44 @@ var Task = class {
1298
1319
  this.meta.fileTaskId = loadResult?.fileTaskId ?? -1;
1299
1320
  this.meta.fileTaskVersionId = loadResult?.fileTaskVersionId ?? -1;
1300
1321
  }
1301
- async runTask(task, taskContext, hooks, run) {
1322
+ /**
1323
+ * Evaluate the task file and check the exports a run depends on. Throws
1324
+ * what the module threw, or what is wrong with an export, for the run to
1325
+ * record as failed in `load`.
1326
+ */
1327
+ async readExports(path) {
1328
+ invariant5(this.entry, "Entry not initialized.");
1329
+ const exports = await this.entry.getExports();
1330
+ this.entryExports = exports;
1331
+ this.taskCallbacks?.onTaskExportsLoaded?.();
1332
+ if (typeof exports.task !== "function") {
1333
+ throw new Error(`${path} exports no task function`);
1334
+ }
1335
+ return {
1336
+ exports,
1337
+ task: exports.task,
1338
+ budgetMs: exports.budget == null ? null : toMs(exports.budget, `budget for ${path}`),
1339
+ exclusiveLocks: exclusiveNames(exports.exclusive, path),
1340
+ retry: resolveRetry(exports.retry, path)
1341
+ };
1342
+ }
1343
+ /** Mark the run failed in `stage`, remembering where for whoever reports the error. */
1344
+ async failRun(run, stage, error) {
1345
+ invariant5(this.entry, "Entry not initialized.");
1346
+ if (error && typeof error === "object") failedRuns.set(error, { runId: run.id, stage });
1347
+ await run.callbacks.onFailure?.({ path: this.entry.paths.relativeEntry, stage, error });
1348
+ }
1349
+ /**
1350
+ * Call one of the task's hooks. What it throws is recorded on the run and
1351
+ * goes no further: the run's outcome was decided before the hook ran, and
1352
+ * `onError`'s error must not replace the one it was called with.
1353
+ */
1354
+ async runHook(run, stage, hook) {
1302
1355
  invariant5(this.entry, "Entry not initialized.");
1303
- const path = this.entry.paths.relativeEntry;
1304
1356
  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);
1357
+ await hook();
1358
+ } catch (error) {
1359
+ await run.callbacks.onHookError?.({ path: this.entry.paths.relativeEntry, stage, error });
1317
1360
  }
1318
1361
  }
1319
1362
  /**
@@ -1403,27 +1446,38 @@ var Task = class {
1403
1446
  queue: (name, options = {}) => openQueue(options.db?.client ?? this.sqlite.client, name, options, { nextAt, signal })
1404
1447
  };
1405
1448
  }
1406
- async executeOnce({ scheduledAt = null, nextAt = null }, tick2) {
1449
+ /**
1450
+ * Load the task file, open the attempt's `task_runs` row, then evaluate the
1451
+ * file's code; what that throws is recorded on the row as failed in `load`.
1452
+ */
1453
+ async openRun(tick2) {
1407
1454
  invariant5(this.entry, "Entry not initialized.");
1408
1455
  await this.loadAndRegister();
1409
- invariant5(this.entryExports, "Entry failed to get exports.");
1410
- const exports = this.entryExports;
1411
1456
  const meta = { ...this.meta };
1412
1457
  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
1458
  const run = await this.startRun({ retryOf: tick2.firstRunId });
1418
1459
  const firstAttempt = tick2.firstRunId == null;
1419
1460
  tick2.firstRunId ??= run.id;
1420
- if (!exports.task) {
1421
- await run.callbacks.onNotFound?.({ path });
1422
- throw new Error("Task function not found");
1461
+ try {
1462
+ return { run, meta, path, firstAttempt, ...await this.readExports(path) };
1463
+ } catch (error) {
1464
+ await this.failRun(run, "load", error);
1465
+ throw error;
1423
1466
  }
1467
+ }
1468
+ /**
1469
+ * One attempt: a `task_runs` row, then the task, with every failure recorded
1470
+ * against that row as the stage it was thrown in. Only a task file whose code
1471
+ * cannot be loaded at all, or a run row that cannot be written, fails before
1472
+ * there is a row to record it on.
1473
+ */
1474
+ async executeOnce({ scheduledAt = null, nextAt = null }, tick2) {
1475
+ const { run, meta, path, firstAttempt, exports, task, budgetMs, exclusiveLocks, retry } = await this.openRun(tick2);
1476
+ tick2.retry = retry;
1424
1477
  const deadline = runDeadline({ startedAt: Date.now(), scheduledAt, nextAt, budgetMs });
1425
1478
  const { signal, dispose } = deadlineSignal(deadline);
1426
1479
  const taskContext = this.buildContext(run, meta, { scheduledAt, nextAt, deadline }, signal);
1480
+ let stage = "start";
1427
1481
  let exclusiveHeld = null;
1428
1482
  try {
1429
1483
  const gate = await this.holdBack(run, {
@@ -1437,20 +1491,29 @@ var Task = class {
1437
1491
  return;
1438
1492
  }
1439
1493
  exclusiveHeld = gate.held;
1494
+ stage = "shouldSkip";
1440
1495
  if (await exports.shouldSkip?.(taskContext)) {
1441
1496
  await run.callbacks.onSkip?.({ path });
1442
1497
  return;
1443
1498
  }
1499
+ stage = "start";
1500
+ await run.callbacks.onStart?.({ path });
1501
+ stage = "task";
1444
1502
  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
- );
1503
+ const value = await task(taskContext);
1452
1504
  tick2.failedContext = null;
1505
+ stage = "record";
1506
+ await run.callbacks.onSuccess?.({ path, result: value });
1507
+ await this.runHook(run, "onSuccess", () => exports.onSuccess?.(taskContext));
1508
+ await this.runHook(run, "onComplete", () => exports.onComplete?.(taskContext));
1453
1509
  return value;
1510
+ } catch (error) {
1511
+ await this.failRun(run, stage, error);
1512
+ if (stage === "task" || stage === "record") {
1513
+ await this.runHook(run, "onError", () => exports.onError?.(error, taskContext));
1514
+ await this.runHook(run, "onComplete", () => exports.onComplete?.(taskContext));
1515
+ }
1516
+ throw error;
1454
1517
  } finally {
1455
1518
  dispose();
1456
1519
  await exclusiveHeld?.release();
@@ -1608,6 +1671,36 @@ var TaskLibraryScheduler = class {
1608
1671
  this.scheduledTasks.set(task.taskPath, scheduledTask);
1609
1672
  logTaskLibraryScheduler(`Scheduled: ${task.taskPath}`);
1610
1673
  }
1674
+ /**
1675
+ * Start one run of a scheduled task now, outside its schedule, the way a tick
1676
+ * would: retries, budget, locks and hooks all apply, and errors go where a
1677
+ * tick's go. It is not a tick, so the breaker does not hold it back. Resolves
1678
+ * once the run is under way, not when it ends: a run can take longer than
1679
+ * anyone waits on a button.
1680
+ *
1681
+ * A run still going is reported rather than recorded as a skip, since nothing
1682
+ * was due: the person asking can simply wait.
1683
+ */
1684
+ runNow(taskPath) {
1685
+ const task = this.taskLibrary.getTasks().find((t) => t.taskPath === taskPath);
1686
+ const cron = this.scheduledTasks.get(taskPath);
1687
+ if (!task || !cron) {
1688
+ return { started: false, reason: `${taskPath} is not scheduled by this ngn run` };
1689
+ }
1690
+ if (task.isRunning && task.overlapPolicy === "skip") {
1691
+ return { started: false, reason: "a run of it is still going" };
1692
+ }
1693
+ void tick(
1694
+ task,
1695
+ (error) => {
1696
+ logTaskLibrarySchedulerError(`Run failed: ${task.taskPath}`);
1697
+ logTaskLibrarySchedulerError(error);
1698
+ this.onRunError?.(task.taskPath, error, failedRunId(error));
1699
+ },
1700
+ { scheduledAt: null, nextAt: cron.nextRun()?.getTime() ?? null }
1701
+ );
1702
+ return { started: true };
1703
+ }
1611
1704
  unscheduleTask(task) {
1612
1705
  const existingTask = this.scheduledTasks.get(task.taskPath);
1613
1706
  if (!existingTask) {
@@ -1698,6 +1791,7 @@ import {
1698
1791
  logTaskConsoleWarn
1699
1792
  } from "@apisurf/ngn-os";
1700
1793
  import invariant11 from "tiny-invariant";
1794
+ import { errorSummary, failureLine } from "@apisurf/ngn-schema/describe";
1701
1795
 
1702
1796
  // src/db/services/fileTask.ts
1703
1797
  import { $fileTaskInsert } from "@apisurf/ngn-schema";
@@ -2089,9 +2183,17 @@ var LockService = class {
2089
2183
  };
2090
2184
 
2091
2185
  // src/runtime/generateControls.ts
2186
+ async function recordSafely(runId, write) {
2187
+ try {
2188
+ await write();
2189
+ } catch (error) {
2190
+ logTaskCallback(`Could not record what run ${runId} threw: ${errorSummary(error)}`);
2191
+ }
2192
+ }
2092
2193
  function createControlsGenerator({
2093
2194
  dbClient,
2094
- env
2195
+ env,
2196
+ report
2095
2197
  }) {
2096
2198
  const taskRunService = new TaskRunService(dbClient);
2097
2199
  const logService = new LogService(dbClient);
@@ -2134,10 +2236,6 @@ function createControlsGenerator({
2134
2236
  retry_of: retryOf
2135
2237
  });
2136
2238
  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
2239
  async onSkip({ reason }) {
2142
2240
  await taskRunService.skipTaskRun(runId, reason ?? null);
2143
2241
  await logService.logInfo(taskId, runId, "Task skipped");
@@ -2149,10 +2247,22 @@ function createControlsGenerator({
2149
2247
  await logService.logInfo(taskId, runId, "Task started");
2150
2248
  logTaskCallback(`Task started: ${taskId}, run ID: ${runId}`);
2151
2249
  },
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}`);
2250
+ async onFailure({ path, stage, error }) {
2251
+ await recordSafely(runId, async () => {
2252
+ await taskRunService.endTaskRun(runId, "failure");
2253
+ await logService.logError(taskId, runId, failureLine(stage, error));
2254
+ await logService.logError(taskId, runId, "Task failed");
2255
+ });
2256
+ logTaskCallback(`Task failed in ${stage}: ${taskId}, run ID: ${runId}`);
2257
+ report?.({ path, runId, stage, error, fatal: true });
2258
+ },
2259
+ async onHookError({ path, stage, error }) {
2260
+ await recordSafely(
2261
+ runId,
2262
+ () => logService.logError(taskId, runId, failureLine(stage, error))
2263
+ );
2264
+ logTaskCallback(`Hook ${stage} threw: ${taskId}, run ID: ${runId}`);
2265
+ report?.({ path, runId, stage, error, fatal: false });
2156
2266
  },
2157
2267
  async onSuccess({ result }) {
2158
2268
  await taskRunService.endTaskRun(runId, "success", serializeResult(result));
@@ -2200,7 +2310,7 @@ function createControlsGenerator({
2200
2310
  return taskRunService.countSkipsSince(fileTaskId, reason, afterId);
2201
2311
  }
2202
2312
  },
2203
- env,
2313
+ env: env ?? {},
2204
2314
  // Scoped to this task's own folder — see createTaskSqlite
2205
2315
  sqlite: createTaskSqlite(sourcePath),
2206
2316
  kv: {
@@ -2233,6 +2343,8 @@ export {
2233
2343
  getDbClient,
2234
2344
  initDbFileIfNotExists,
2235
2345
  isCronPatternValid,
2346
+ releaseTaskDatabases,
2347
+ runFailure,
2236
2348
  scheduleTask,
2237
2349
  setupDbClient
2238
2350
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apisurf/ngn-core",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
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.4.0",
42
+ "@apisurf/ngn-persistence": "0.4.0",
43
+ "@apisurf/ngn-schema": "0.4.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/node": "^22.8.1",