@zerotal/scheduler 1.0.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/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog — @zerotal/scheduler
2
+
3
+ All notable changes to this package are documented here. The format is
4
+ based on [Keep a Changelog](https://keepachangelog.com/); this package
5
+ follows the Zerotal monorepo's unified versioning.
6
+
7
+ **Maturity: `stable`**
8
+
9
+ ## [Unreleased]
10
+
11
+ ## [1.0.0] — 2026-08-05
12
+
13
+ _First public release._
14
+
15
+ ### Notes
16
+
17
+ - Conforms to the Zerotal package conventions (provider in `src/provider/`, PascalCase config factory, `ZerotalError`-based errors, test coverage).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zerotal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # @zerotal/scheduler
2
+
3
+ > Cron-style task scheduling with class-based schedules and a fluent facade.
4
+
5
+ Run tasks on a cron-like cadence. Drop a `Schedule` subclass in `app/schedules/` for auto-registered, testable tasks, or use the `Scheduler` facade for quick inline definitions. Schedules run in the worker process (`bun zt worker`).
6
+
7
+ Part of the [Zerotal](../../README.md) framework. Requires **Bun ≥ 1.3.14**.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ bun add @zerotal/scheduler
13
+ ```
14
+
15
+ ## Setup
16
+
17
+ Register the provider in `bootstrap/providers.ts`:
18
+
19
+ ```ts
20
+ import { SchedulerProvider } from "@zerotal/scheduler";
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ The recommended approach is class-based — extend `Schedule`, declare the cadence, and put the work in `handle()`. Every subclass under `app/schedules/` is discovered automatically:
26
+
27
+ ```ts
28
+ import { Schedule } from "@zerotal/scheduler";
29
+ import { Queue } from "@zerotal/queue";
30
+
31
+ export class SendDailyReports extends Schedule {
32
+ cron = "0 8 * * *"; // every day at 08:00
33
+ withoutOverlapping = true;
34
+
35
+ async handle(): Promise<void> {
36
+ await Queue.dispatch(new SendReportsJob());
37
+ }
38
+ }
39
+ ```
40
+
41
+ Prefer the fluent frequency builder when it reads better — override `frequency()`:
42
+
43
+ ```ts
44
+ import { Schedule, type SchedulerBuilder } from "@zerotal/scheduler";
45
+
46
+ export class WarmCache extends Schedule {
47
+ override frequency(every: SchedulerBuilder) {
48
+ return every.everyFiveMinutes();
49
+ }
50
+ async handle(): Promise<void> {
51
+ await Cache.forget("posts:page:1");
52
+ }
53
+ }
54
+ ```
55
+
56
+ For one-liners, use the `Scheduler` facade — `job()` returns the `ScheduledTask` for fluent tuning:
57
+
58
+ ```ts
59
+ import { Scheduler } from "@zerotal/scheduler";
60
+
61
+ Scheduler.job("nightly-backup", () => runBackup())
62
+ .dailyAt("02:30")
63
+ .timezone("Africa/Johannesburg")
64
+ .withoutOverlapping({ expiresAfterMinutes: 30 })
65
+ .environments(["production"]);
66
+ ```
67
+
68
+ In tests, `runNow()` executes the handler immediately, bypassing the cron/time guards:
69
+
70
+ ```ts
71
+ const task = Scheduler.job("report", () => generateReport()).dailyAt("08:00");
72
+ await task.runNow();
73
+ expect(task.lastOk).toBe(true);
74
+ ```
75
+
76
+ ## Exports
77
+
78
+ | Export | Description |
79
+ | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
80
+ | `Scheduler` | Fluent inline scheduling facade. |
81
+ | `Schedule` | Base class for convention-based schedules in `app/schedules/`. |
82
+ | `SchedulerManager`, `SchedulerBuilder` | The manager and the cadence builder. |
83
+ | `ScheduledTask` | A single registered task — introspection, `runNow()`, lifecycle hooks. |
84
+ | `CronExpression` | Cron expression parser / next-run calculator. |
85
+ | `schedulesConcern`, `registerSchedule` | Convention loader hooks. |
86
+ | `SchedulerProvider` | Service provider — register in `bootstrap/providers.ts`. |
87
+ | `SchedulerConfig`, `SchedulerConfigShape` | Config factory and its type. |
88
+ | `TaskCallback`, `TaskGuard`, `TaskHook`, `OutputMailer`, `OverlapLockOptions` | Supporting types. |
89
+
90
+ ## Documentation
91
+
92
+ - [Scheduler](../../docs/scheduler.md)
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@zerotal/scheduler",
3
+ "version": "1.0.0",
4
+ "license": "MIT",
5
+ "maturity": "stable",
6
+ "private": false,
7
+ "type": "module",
8
+ "main": "./src/index.ts",
9
+ "types": "./src/index.ts",
10
+ "exports": {
11
+ ".": "./src/index.ts"
12
+ },
13
+ "files": [
14
+ "CHANGELOG.md",
15
+ "src",
16
+ "!src/**/*.test.ts",
17
+ "!src/**/*.test.tsx",
18
+ "!src/**/*.spec.ts",
19
+ "!src/**/__fixtures__/**"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "engines": {
25
+ "bun": ">=1.3.14"
26
+ },
27
+ "scripts": {
28
+ "test": "bun test",
29
+ "typecheck": "tsc --noEmit"
30
+ },
31
+ "dependencies": {
32
+ "@zerotal/core": "1.0.0"
33
+ },
34
+ "devDependencies": {
35
+ "typescript": "^5.8.0"
36
+ },
37
+ "description": "Cron-style task scheduling for Zerotal with overlap locking and output capture.",
38
+ "keywords": [
39
+ "zerotal",
40
+ "bun",
41
+ "typescript",
42
+ "framework",
43
+ "scheduler",
44
+ "cron"
45
+ ],
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/zerotaldev/zerotal.git",
49
+ "directory": "packages/scheduler"
50
+ },
51
+ "homepage": "https://github.com/zerotaldev/zerotal/tree/main/packages/scheduler#readme",
52
+ "bugs": "https://github.com/zerotaldev/zerotal/issues"
53
+ }
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Cron expression utility — builder, validator, human-readable describer and
3
+ * next-run calculator. Supports standard 5-field cron (m h dom mon dow) and
4
+ * tolerates an optional leading seconds field (6-field) for validation.
5
+ *
6
+ * Field syntax: star, n, a-b, a-b with step, star with step, and
7
+ * comma-separated lists of any of those (e.g. `1,15,30` or `1-5`).
8
+ */
9
+ export class CronExpression {
10
+ private fields: [string, string, string, string, string];
11
+
12
+ private static readonly RANGES: Record<number, [number, number]> = {
13
+ 0: [0, 59],
14
+ 1: [0, 23],
15
+ 2: [1, 31],
16
+ 3: [1, 12],
17
+ 4: [0, 7],
18
+ };
19
+
20
+ private static readonly DOW = [
21
+ "Sunday",
22
+ "Monday",
23
+ "Tuesday",
24
+ "Wednesday",
25
+ "Thursday",
26
+ "Friday",
27
+ "Saturday",
28
+ ];
29
+ private static readonly MONTHS = [
30
+ "January",
31
+ "February",
32
+ "March",
33
+ "April",
34
+ "May",
35
+ "June",
36
+ "July",
37
+ "August",
38
+ "September",
39
+ "October",
40
+ "November",
41
+ "December",
42
+ ];
43
+
44
+ constructor(expression?: string) {
45
+ if (expression) {
46
+ const parts = expression.trim().split(/\s+/);
47
+ const five = parts.length === 6 ? parts.slice(1) : parts;
48
+ this.fields = [
49
+ five[0] ?? "*",
50
+ five[1] ?? "*",
51
+ five[2] ?? "*",
52
+ five[3] ?? "*",
53
+ five[4] ?? "*",
54
+ ];
55
+ } else {
56
+ this.fields = ["*", "*", "*", "*", "*"];
57
+ }
58
+ }
59
+
60
+ minute(v: number | string): this {
61
+ this.fields[0] = String(v);
62
+ return this;
63
+ }
64
+ hour(v: number | string): this {
65
+ this.fields[1] = String(v);
66
+ return this;
67
+ }
68
+ dayOfMonth(v: number | string): this {
69
+ this.fields[2] = String(v);
70
+ return this;
71
+ }
72
+ month(v: number | string): this {
73
+ this.fields[3] = String(v);
74
+ return this;
75
+ }
76
+ weekday(v: number | string): this {
77
+ this.fields[4] = String(v);
78
+ return this;
79
+ }
80
+
81
+ toString(): string {
82
+ return this.fields.join(" ");
83
+ }
84
+
85
+ /**
86
+ * Whether `value` satisfies one cron field.
87
+ *
88
+ * @param pattern - The field text (`*`, `5`, `1-5`, `* /3`, `1,15`, or a comma list).
89
+ * @param value - The value to test.
90
+ * @param min - The field's smallest legal value: 0 for minute, hour and day-of-week,
91
+ * **1** for day-of-month and month. A `* /N` step counts from this, not from zero —
92
+ * `0 0 * /7 * *` fires on the 1st, 8th, 15th…, and `0 0 * * /3 *` in January, April,
93
+ * July and October. Anchoring at 0 regardless put the day-of-month step a day early
94
+ * and the month step two months out.
95
+ */
96
+ private static _matchField(pattern: string, value: number, min = 0): boolean {
97
+ if (pattern === "*") return true;
98
+ for (const token of pattern.split(",")) {
99
+ let range = token;
100
+ let step = 1;
101
+ if (token.includes("/")) {
102
+ const [r, s] = token.split("/");
103
+ range = r || "*";
104
+ step = parseInt(s || "1", 10) || 1;
105
+ }
106
+ let start: number;
107
+ let end: number;
108
+ if (range === "*") {
109
+ start = -Infinity;
110
+ end = Infinity;
111
+ } else if (range.includes("-")) {
112
+ const [a, b] = range.split("-");
113
+ start = parseInt(a || "0", 10);
114
+ end = parseInt(b || "0", 10);
115
+ } else {
116
+ start = end = parseInt(range, 10);
117
+ }
118
+ if (Number.isNaN(start) || Number.isNaN(end)) continue;
119
+ if (value < start || value > end) continue;
120
+ const base = range === "*" ? min : start;
121
+ if ((value - base) % step === 0) return true;
122
+ }
123
+ return false;
124
+ }
125
+
126
+ matches(date: Date = new Date()): boolean {
127
+ const [mi, h, dom, mo, dow] = this.fields;
128
+ const day = date.getDay();
129
+ const dowMatch =
130
+ CronExpression._matchField(dow, day) || (day === 0 && CronExpression._matchField(dow, 7));
131
+ const domMatch = CronExpression._matchField(dom, date.getDate(), 1);
132
+
133
+ // vixie-cron day semantics: when BOTH day-of-month and day-of-week are
134
+ // restricted (neither is `*`), the day matches if EITHER matches (OR). When
135
+ // only one is restricted, the `*` side is trivially true, so the AND below
136
+ // reduces to the restricted one. This matches croner (the real firing path);
137
+ // the previous unconditional AND made matches()/nextRunAfter() disagree with
138
+ // actual firing for expressions like `0 0 1 * 1`.
139
+ const domRestricted = dom !== "*";
140
+ const dowRestricted = dow !== "*";
141
+ const dayMatch = domRestricted && dowRestricted ? domMatch || dowMatch : domMatch && dowMatch;
142
+
143
+ return (
144
+ CronExpression._matchField(mi, date.getMinutes()) &&
145
+ CronExpression._matchField(h, date.getHours()) &&
146
+ dayMatch &&
147
+ CronExpression._matchField(mo, date.getMonth() + 1, 1)
148
+ );
149
+ }
150
+
151
+ nextRun(from: Date = new Date()): Date | null {
152
+ return CronExpression.nextRunAfter(this.toString(), from);
153
+ }
154
+
155
+ static isValid(expression: string): boolean {
156
+ if (typeof expression !== "string") return false;
157
+ const parts = expression.trim().split(/\s+/);
158
+ if (parts.length !== 5 && parts.length !== 6) return false;
159
+ const five = parts.length === 6 ? parts.slice(1) : parts;
160
+ for (let i = 0; i < 5; i++) {
161
+ if (!CronExpression._isValidField(five[i]!, CronExpression.RANGES[i]!)) return false;
162
+ }
163
+ return true;
164
+ }
165
+
166
+ private static _isValidField(field: string, [lo, hi]: [number, number]): boolean {
167
+ if (field === "*") return true;
168
+ for (const token of field.split(",")) {
169
+ if (token === "") return false;
170
+ let range = token;
171
+ if (token.includes("/")) {
172
+ const [r, s] = token.split("/");
173
+ const stepNum = Number(s);
174
+ if (!s || !Number.isInteger(stepNum) || stepNum < 1) return false;
175
+ range = r || "*";
176
+ if (range === "*") continue;
177
+ }
178
+ if (range.includes("-")) {
179
+ const [a, b] = range.split("-");
180
+ const an = Number(a);
181
+ const bn = Number(b);
182
+ if (!Number.isInteger(an) || !Number.isInteger(bn)) return false;
183
+ if (an < lo || bn > hi || an > bn) return false;
184
+ } else {
185
+ const n = Number(range);
186
+ if (!Number.isInteger(n) || n < lo || n > hi) return false;
187
+ }
188
+ }
189
+ return true;
190
+ }
191
+
192
+ /**
193
+ * The first time at or after `from` that `expression` fires, or `null` when it never does
194
+ * within the search horizon.
195
+ *
196
+ * The scan steps by minute, but skips a whole day at a time once the day itself cannot
197
+ * match — a day-scoped expression like `0 0 29 2 *` would otherwise mean 533,000 minute
198
+ * probes to reach the next leap year, which is why a 370-day horizon reported "never
199
+ * runs" for a perfectly valid expression while burning ~320 ms of blocking CPU per call.
200
+ *
201
+ * @param expression - A 5- or 6-field cron expression.
202
+ * @param from - Search start; the result is strictly after this minute.
203
+ * @returns The next firing time, or `null` for an invalid expression or one with no
204
+ * occurrence in the next {@link SEARCH_HORIZON_DAYS} days.
205
+ */
206
+ static nextRunAfter(expression: string, from: Date = new Date()): Date | null {
207
+ if (!CronExpression.isValid(expression)) return null;
208
+ const cron = new CronExpression(expression);
209
+ const cursor = new Date(from);
210
+ cursor.setSeconds(0, 0);
211
+ cursor.setMinutes(cursor.getMinutes() + 1);
212
+
213
+ const deadline = new Date(from);
214
+ deadline.setDate(deadline.getDate() + CronExpression.SEARCH_HORIZON_DAYS);
215
+
216
+ while (cursor <= deadline) {
217
+ if (cron._dayMatches(cursor)) {
218
+ if (cron.matches(cursor)) return new Date(cursor);
219
+ cursor.setMinutes(cursor.getMinutes() + 1);
220
+ } else {
221
+ // Nothing today can match — jump to 00:00 tomorrow rather than probing 1,440 minutes.
222
+ cursor.setDate(cursor.getDate() + 1);
223
+ cursor.setHours(0, 0, 0, 0);
224
+ }
225
+ }
226
+ return null;
227
+ }
228
+
229
+ /**
230
+ * How far ahead {@link nextRunAfter} looks. Four years and change, so a Feb-29 expression
231
+ * resolves to the next leap day instead of reporting that it never runs.
232
+ */
233
+ static readonly SEARCH_HORIZON_DAYS = 366 * 4 + 1;
234
+
235
+ /** Whether the date part of `date` satisfies the day-of-month / month / day-of-week fields. */
236
+ private _dayMatches(date: Date): boolean {
237
+ const [, , dom, mo, dow] = this.fields;
238
+ if (!CronExpression._matchField(mo, date.getMonth() + 1, 1)) return false;
239
+
240
+ const day = date.getDay();
241
+ const dowMatch =
242
+ CronExpression._matchField(dow, day) || (day === 0 && CronExpression._matchField(dow, 7));
243
+ const domMatch = CronExpression._matchField(dom, date.getDate(), 1);
244
+ return dom !== "*" && dow !== "*" ? domMatch || dowMatch : domMatch && dowMatch;
245
+ }
246
+
247
+ static describe(expression: string): string {
248
+ if (!CronExpression.isValid(expression)) return "Invalid cron expression";
249
+ const parts = expression.trim().split(/\s+/);
250
+ const [mi, h, dom, mo, dow] = parts.length === 6 ? parts.slice(1) : parts;
251
+
252
+ if (mi === "*" && h === "*" && dom === "*" && mo === "*" && dow === "*") return "Every minute";
253
+ const stepMin = mi!.match(/^\*\/(\d+)$/);
254
+ if (stepMin && h === "*" && dom === "*" && mo === "*" && dow === "*")
255
+ return `Every ${stepMin[1]} minutes`;
256
+ if (mi === "0" && h === "*" && dom === "*" && mo === "*" && dow === "*") return "Every hour";
257
+
258
+ const segments: string[] = [];
259
+ if (/^\d+$/.test(mi!) && /^\d+$/.test(h!)) {
260
+ segments.push(`At ${CronExpression._formatTime(Number(h), Number(mi))}`);
261
+ } else {
262
+ if (mi !== "*") segments.push(`At minute ${mi}`);
263
+ if (h !== "*") segments.push(`past hour ${h}`);
264
+ }
265
+ if (dom !== "*") segments.push(`on day-of-month ${dom}`);
266
+ if (mo !== "*") segments.push(`in ${CronExpression._describeMonth(mo!)}`);
267
+ if (dow !== "*") segments.push(CronExpression._describeDow(dow!));
268
+ return segments.join(", ");
269
+ }
270
+
271
+ private static _formatTime(hour: number, minute: number): string {
272
+ const period = hour < 12 ? "AM" : "PM";
273
+ let hr = hour % 12;
274
+ if (hr === 0) hr = 12;
275
+ return `${String(hr).padStart(2, "0")}:${String(minute).padStart(2, "0")} ${period}`;
276
+ }
277
+
278
+ private static _dowName(n: number): string {
279
+ return CronExpression.DOW[n === 7 ? 0 : n] ?? String(n);
280
+ }
281
+
282
+ private static _describeDow(dow: string): string {
283
+ if (/^\d+$/.test(dow)) return CronExpression._dowName(Number(dow));
284
+ const range = dow.match(/^(\d+)-(\d+)$/);
285
+ if (range)
286
+ return `${CronExpression._dowName(Number(range[1]))} through ${CronExpression._dowName(Number(range[2]))}`;
287
+ if (dow.includes(",")) {
288
+ const names = dow.split(",").map((d) => CronExpression._dowName(Number(d)));
289
+ if (names.length === 2) return `${names[0]} and ${names[1]}`;
290
+ return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
291
+ }
292
+ return `on ${dow}`;
293
+ }
294
+
295
+ private static _describeMonth(mo: string): string {
296
+ if (/^\d+$/.test(mo)) return CronExpression.MONTHS[Number(mo) - 1] ?? mo;
297
+ if (mo.includes(","))
298
+ return mo
299
+ .split(",")
300
+ .map((m) => CronExpression.MONTHS[Number(m) - 1] ?? m)
301
+ .join(", ");
302
+ return mo;
303
+ }
304
+ }
@@ -0,0 +1,90 @@
1
+ import type { OverlapLockOptions, ScheduledTask } from "./ScheduledTask.ts";
2
+ import type { SchedulerBuilder } from "./SchedulerManager.ts";
3
+
4
+ /**
5
+ * Base class for convention-based scheduled tasks. Drop a subclass in `app/schedules/` and it is
6
+ * auto-registered at boot (worker + console environments) by the scheduler's convention loader.
7
+ *
8
+ * Define the cadence with either `cron` or the fluent `frequency()` method, put the work in
9
+ * `handle()`, and tune behaviour with the declarative settings below.
10
+ *
11
+ * @example
12
+ * import { Schedule } from "@zerotal/scheduler";
13
+ *
14
+ * export class SendDailyReports extends Schedule {
15
+ * cron = "0 8 * * *"; // every day at 08:00
16
+ * timezone = "Africa/Johannesburg";
17
+ * withoutOverlapping = true;
18
+ *
19
+ * async handle(): Promise<void> {
20
+ * await Queue.dispatch(new SendReportsJob());
21
+ * }
22
+ * }
23
+ *
24
+ * @example // fluent cadence instead of a raw cron string
25
+ * export class PruneTempFiles extends Schedule {
26
+ * frequency(every) { return every.dailyAt("02:30"); }
27
+ * async handle() { ... }
28
+ * }
29
+ */
30
+ export abstract class Schedule {
31
+ /** The work performed on each run. Required. */
32
+ abstract handle(): void | Promise<void>;
33
+
34
+ /**
35
+ * Cron expression (5- or 6-field). Set this OR override {@link frequency}.
36
+ * Examples: `"* * * * *"` (every minute), `"0 8 * * 1"` (Mondays at 08:00).
37
+ */
38
+ cron?: string;
39
+
40
+ /**
41
+ * Build the cadence fluently using the scheduler's frequency helpers, instead of a raw
42
+ * cron string. Return the configured task.
43
+ *
44
+ * @example frequency(every) { return every.everyFiveMinutes(); }
45
+ */
46
+ frequency?(every: SchedulerBuilder): ScheduledTask;
47
+
48
+ /** Task name shown in `schedule:list` and logs. Defaults to the class name. */
49
+ name?: string;
50
+
51
+ /** IANA timezone the cron expression is evaluated in (default: system timezone). */
52
+ timezone?: string;
53
+
54
+ /**
55
+ * Prevent overlapping runs. `true` skips a tick while a previous run is still active and,
56
+ * when a lock driver is configured, also takes a cross-process lock. Pass
57
+ * `{ crossProcess: false }` to guard within this process only.
58
+ */
59
+ withoutOverlapping?: boolean | OverlapLockOptions;
60
+
61
+ /** Only run when `APP_ENV` is one of these values. */
62
+ environments?: string[];
63
+
64
+ /** Run the body without blocking the scheduler tick (fire-and-forget). */
65
+ inBackground?: boolean;
66
+
67
+ /** Only run between `["HH:MM", "HH:MM"]`. */
68
+ between?: [string, string];
69
+
70
+ /** Never run between `["HH:MM", "HH:MM"]`. */
71
+ unlessBetween?: [string, string];
72
+
73
+ /** Health-check pings (e.g. healthchecks.io). URLs are fetched at each lifecycle point. */
74
+ pingBefore?: string;
75
+ pingAfter?: string;
76
+ pingOnSuccess?: string;
77
+ pingOnFailure?: string;
78
+
79
+ /** Append captured console output to a file. */
80
+ appendOutputTo?: string;
81
+
82
+ /** Email captured console output (requires an output mailer to be configured). */
83
+ emailOutputTo?: string;
84
+
85
+ /** Dynamic guard — the task runs only when this resolves truthy. */
86
+ when?(): boolean | Promise<boolean>;
87
+
88
+ /** Dynamic guard — the task is skipped when this resolves truthy. */
89
+ skip?(): boolean | Promise<boolean>;
90
+ }