@zerotal/scheduler 1.4.0 → 1.5.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/CHANGELOG.md CHANGED
@@ -8,6 +8,46 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.5.0] — 2026-08-15
12
+
13
+ ### Changed
14
+
15
+ - **`withoutOverlapping`'s cross-process lock defaults to 5 minutes, not 24 hours.** The
16
+ lock could not be extended, so its TTL had to cover the longest the task might ever run
17
+ — and a scheduler that died mid-run therefore blocked that task for a full day. The lock
18
+ is now heartbeated while the task runs, so the TTL only has to outlive a missed beat.
19
+
20
+ `expiresAfterMinutes` keeps working and **changes meaning**: it is now "how long after a
21
+ crash before another host may take this task over", not "how long the task may take". A
22
+ long-running task no longer needs a long value; set one only if you want a crash to be
23
+ slower to recover from. `{ refresh: false }` restores the old behaviour.
24
+
25
+ A failed heartbeat does not kill the run. It is logged and the handle dropped — the task
26
+ is already in flight, and stopping it half-finished because another host may now also be
27
+ running does not un-overlap anything, it just adds a second failure.
28
+
29
+ ### Added
30
+
31
+ - **Durable run history.** Every completed execution (success or failure) is appended to
32
+ a capped JSONL file under `storage/framework/`, so "did the retention sweep run last
33
+ night?" has an answer that survives a restart — previously the only record was
34
+ in-memory task state and whatever the log rotation kept. Read it with the new
35
+ **`bun zt schedule:runs [name]`** (`--limit` to page), through the monitor panel, or
36
+ via `ScheduleRunStore.recent()`. The store is bound as `scheduler.runs` — rebind it to
37
+ move the history elsewhere. Configured under `scheduler.runLog` (`enabled`, `path`,
38
+ `keep`); on by default except under `APP_ENV=test`. A torn tail from a crash
39
+ mid-append is repaired on the next write instead of eating the following record.
40
+ - **The monitor panel survives restarts.** The scheduled-tasks section now falls back to
41
+ the recorded history when the in-memory state is empty, marking those values
42
+ "(recorded)", and gained a "Recent runs" table — a task that ran an hour ago no longer
43
+ reads "Never run" after a deploy.
44
+ - **Static schedule config is called out.** `static cron = "…"` typechecks (it merely
45
+ declares a new static) and registers nothing, while `static` is exactly the convention
46
+ models (`static fillable`) and Flow components (`static layout`) use — the natural
47
+ first attempt fails silently. Convention discovery now warns at registration, names
48
+ the misdeclared keys, and the finding is surfaced by `bun zt doctor` too
49
+ (`staticScheduleConfigKeys` is exported for reuse).
50
+
11
51
  ## [1.0.3] — 2026-08-07
12
52
 
13
53
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/scheduler",
3
- "version": "1.4.0",
3
+ "version": "1.5.1",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -29,7 +29,7 @@
29
29
  "typecheck": "tsc --noEmit"
30
30
  },
31
31
  "dependencies": {
32
- "@zerotal/core": "1.4.0"
32
+ "@zerotal/core": "1.5.1"
33
33
  },
34
34
  "devDependencies": {
35
35
  "typescript": "^5.8.0"
@@ -48,8 +48,31 @@ export interface OverlapLockOptions {
48
48
  */
49
49
  crossProcess?: boolean;
50
50
 
51
- /** Lock TTL safety net, in minutes, so a crashed run can't deadlock the key. Default: 1440 (24h). */
51
+ /**
52
+ * **How long after a crash before another host may take the task over**, in
53
+ * minutes. Default: 5.
54
+ *
55
+ * This used to mean "how long the task might possibly run", which is why it
56
+ * defaulted to a day: the lock could not be extended, so the TTL had to cover
57
+ * the worst case, and a scheduler that died mid-run blocked that task until
58
+ * the next afternoon. The lock is now heartbeated while the task runs, so the
59
+ * TTL only has to outlive one missed heartbeat — and the number you are
60
+ * choosing is a recovery time rather than a guess about duration.
61
+ *
62
+ * A long-running task no longer needs a long value here. Set one only if you
63
+ * want a crash to be *slower* to recover from, which is rarely what anyone
64
+ * wants.
65
+ */
52
66
  expiresAfterMinutes?: number;
67
+
68
+ /**
69
+ * Heartbeat the lock for as long as the task runs. Default: `true`.
70
+ *
71
+ * Turning it off restores the old behaviour, where the task must finish
72
+ * inside {@link expiresAfterMinutes} or lose its lock — and where you must
73
+ * therefore size that value for the worst case.
74
+ */
75
+ refresh?: boolean;
53
76
  }
54
77
 
55
78
  export class ScheduledTask {
@@ -85,8 +108,15 @@ export class ScheduledTask {
85
108
  private _emailOutputTo: string | undefined = undefined;
86
109
 
87
110
  private _crossProcess: boolean = false;
88
- private _lockTtlMs: number = 24 * 60 * 60 * 1000;
111
+ /**
112
+ * Minutes, not a day. The lock is heartbeated while the task runs, so this is
113
+ * the window in which a crashed scheduler still holds the key — not a bound on
114
+ * how long the task may take.
115
+ */
116
+ private _lockTtlMs: number = 5 * 60 * 1000;
117
+ private _lockRefresh: boolean = true;
89
118
  private _lockHandle: ManagedLock | undefined = undefined;
119
+ private _lockHeartbeat: ReturnType<typeof setInterval> | undefined = undefined;
90
120
 
91
121
  static outputMailer: OutputMailer | undefined = undefined;
92
122
 
@@ -126,6 +156,7 @@ export class ScheduledTask {
126
156
  withoutOverlapping(options?: OverlapLockOptions): this {
127
157
  this._skipIfStillRunning = true;
128
158
  this._crossProcess = options?.crossProcess ?? true;
159
+ this._lockRefresh = options?.refresh ?? true;
129
160
  if (options?.expiresAfterMinutes !== undefined) {
130
161
  this._lockTtlMs = Math.max(1, options.expiresAfterMinutes) * 60 * 1000;
131
162
  }
@@ -278,11 +309,62 @@ export class ScheduledTask {
278
309
  const ttlSeconds = Math.max(1, Math.ceil(this._lockTtlMs / 1000));
279
310
  const handle = manager.lock(`schedule:${this._name}`, ttlSeconds);
280
311
  const acquired = await handle.acquire();
281
- if (acquired) this._lockHandle = handle;
282
- return acquired;
312
+ if (!acquired) return false;
313
+
314
+ this._lockHandle = handle;
315
+ if (this._lockRefresh) this._startHeartbeat(handle, ttlSeconds);
316
+ return true;
317
+ }
318
+
319
+ /**
320
+ * Keep the overlap lock alive while the task runs.
321
+ *
322
+ * A third of the TTL, so one missed beat is survivable. A failed refresh is
323
+ * *not* escalated: the run is already in flight, and killing a half-finished
324
+ * task because another host may now also be running it does not make the
325
+ * overlap un-happen — it just adds a second failure. It is logged, the handle
326
+ * is dropped so the release cannot touch a lock we no longer own, and the task
327
+ * is left to finish.
328
+ */
329
+ private _startHeartbeat(handle: ManagedLock, ttlSeconds: number): void {
330
+ // A third of the TTL, floored only low enough to stop a pathological TTL
331
+ // spinning. The floor must never approach the interval itself: at 1000ms it
332
+ // made every TTL of three seconds or less refresh at the moment of expiry —
333
+ // a heartbeat that reliably lost the race it existed to win.
334
+ const everyMs = Math.max(50, Math.floor((ttlSeconds * 1000) / 3));
335
+ const timer = setInterval(() => {
336
+ void handle.refresh().then(
337
+ (ok) => {
338
+ if (ok) return;
339
+ frameworkLog("scheduler").warn(
340
+ `Lost the overlap lock for "${this._name}" — another host may now run it too.`,
341
+ { task: this._name },
342
+ );
343
+ this._stopHeartbeat();
344
+ this._lockHandle = undefined;
345
+ },
346
+ () => {
347
+ /* transient driver error — the next beat tries again */
348
+ },
349
+ );
350
+ }, everyMs);
351
+ // The scheduler outlives any one task, but a CLI or a dev-mode process that
352
+ // stops mid-run must still be able to exit.
353
+ timer.unref?.();
354
+ this._lockHeartbeat = timer;
355
+ }
356
+
357
+ private _stopHeartbeat(): void {
358
+ if (this._lockHeartbeat) {
359
+ clearInterval(this._lockHeartbeat);
360
+ this._lockHeartbeat = undefined;
361
+ }
283
362
  }
284
363
 
285
364
  private async _releaseLock(): Promise<void> {
365
+ // Stopped first and unconditionally: a beat that fires after the release
366
+ // would re-acquire the key this task has just finished with.
367
+ this._stopHeartbeat();
286
368
  if (this._lockHandle) {
287
369
  await this._lockHandle.release();
288
370
  this._lockHandle = undefined;
@@ -6,6 +6,13 @@ export class SchedulerManager {
6
6
  private _tasks: Map<string, ScheduledTask> = new Map();
7
7
  private _started: boolean = false;
8
8
 
9
+ /**
10
+ * Schedule classes found declaring config as `static` during convention
11
+ * discovery (which registers nothing — config is instance properties).
12
+ * Written by the schedules concern, read by the scheduler's doctor check.
13
+ */
14
+ readonly staticConfigFindings: Array<{ className: string; keys: string[] }> = [];
15
+
9
16
  add(name: string, cronExpression: string, callback: TaskCallback): ScheduledTask {
10
17
  const task = new ScheduledTask(name, cronExpression, callback);
11
18
  this._tasks.set(name, task);
@@ -0,0 +1,63 @@
1
+ import type { Application } from "@zerotal/core";
2
+ import { Command } from "@zerotal/core";
3
+ import type { ArgDef, FlagDef } from "@zerotal/core";
4
+ import type { ScheduleRunStore } from "../runLog.ts";
5
+
6
+ /**
7
+ * `bun zt schedule:runs [name]` — the durable run history. Answers "did the
8
+ * retention sweep run last night?" from the on-disk record, which survives
9
+ * restarts — unlike the in-memory last-run state `schedule:list` reflects.
10
+ */
11
+ export class ScheduleRunsCommand extends Command {
12
+ static override commandName = "schedule:runs";
13
+ static override description = "Show recent scheduled-task runs (durable, survives restarts)";
14
+ static override needsApp = true;
15
+
16
+ // The optional positional arg filters to one task's runs.
17
+ static override args: ArgDef[] = [{ name: "name", required: false }];
18
+
19
+ static override flags: FlagDef[] = [
20
+ {
21
+ name: "limit",
22
+ short: "n",
23
+ type: "number",
24
+ description: "How many runs to show",
25
+ default: 20,
26
+ },
27
+ ];
28
+
29
+ async run(): Promise<void> {
30
+ const app = this.app as Application | undefined;
31
+ const store = app?.container.tryMake("scheduler.runs") as ScheduleRunStore | undefined;
32
+
33
+ if (!store) {
34
+ this.error("Run store not registered. Add SchedulerProvider to your providers.");
35
+ return;
36
+ }
37
+
38
+ const name = this.args["name"];
39
+ const limit = Number(this.flags["limit"] ?? 20);
40
+ const runs = store.recent(limit, name);
41
+
42
+ if (runs.length === 0) {
43
+ this.info(
44
+ name
45
+ ? `No recorded runs for "${name}". (The run log records completed executions; ` +
46
+ `it is off under APP_ENV=test.)`
47
+ : "No recorded runs yet.",
48
+ );
49
+ return;
50
+ }
51
+
52
+ this.section(name ? `Runs of ${name} (${runs.length})` : `Recent runs (${runs.length})`);
53
+ for (const run of runs) {
54
+ this.table([
55
+ ["Task", run.name],
56
+ ["Started", run.startedAt],
57
+ ["Duration", `${run.durationMs} ms`],
58
+ ["Result", run.ok ? "OK" : `FAILED — ${run.error ?? "unknown error"}`],
59
+ ]);
60
+ this.newLine();
61
+ }
62
+ }
63
+ }
@@ -1 +1,2 @@
1
1
  export { ScheduleListCommand } from "./ScheduleListCommand.ts";
2
+ export { ScheduleRunsCommand } from "./ScheduleRunsCommand.ts";
package/src/config.ts CHANGED
@@ -1,13 +1,27 @@
1
1
  import { deepMerge } from "@zerotal/core";
2
+ import { DEFAULT_RUN_LOG_KEEP, DEFAULT_RUN_LOG_PATH } from "./runLog.ts";
2
3
 
3
4
  export interface SchedulerConfigShape {
4
5
  /** Timezone for cron expressions. Informational only - Bun.cron uses
5
6
  * the system timezone. Default: 'UTC' */
6
7
  timezone: string;
8
+ /** Durable run history — read with `schedule:runs` (see runLog.ts). */
9
+ runLog: {
10
+ /** Default: on, except under `APP_ENV=test`. */
11
+ enabled?: boolean;
12
+ /** JSONL file path, relative to the app root. */
13
+ path: string;
14
+ /** Records kept after compaction; the file is compacted at 2× this. */
15
+ keep: number;
16
+ };
7
17
  }
8
18
 
9
19
  const defaults: SchedulerConfigShape = {
10
20
  timezone: "UTC",
21
+ runLog: {
22
+ path: DEFAULT_RUN_LOG_PATH,
23
+ keep: DEFAULT_RUN_LOG_KEEP,
24
+ },
11
25
  };
12
26
 
13
27
  /**
@@ -12,6 +12,46 @@ function isScheduleClass(v: unknown): v is new () => Schedule {
12
12
  );
13
13
  }
14
14
 
15
+ /** Every declarative setting `registerSchedule` reads off a Schedule instance. */
16
+ const SCHEDULE_CONFIG_KEYS = [
17
+ "name",
18
+ "cron",
19
+ "frequency",
20
+ "timezone",
21
+ "withoutOverlapping",
22
+ "environments",
23
+ "inBackground",
24
+ "between",
25
+ "unlessBetween",
26
+ "pingBefore",
27
+ "pingAfter",
28
+ "pingOnSuccess",
29
+ "pingOnFailure",
30
+ "appendOutputTo",
31
+ "emailOutputTo",
32
+ "when",
33
+ "skip",
34
+ ] as const;
35
+
36
+ /**
37
+ * Config keys declared as `static` on a Schedule class. Schedule config is instance
38
+ * properties, but `static cron = "…"` typechecks (it simply declares a new static) and
39
+ * registers nothing — matching the `static` convention used by models (`static fillable`)
40
+ * and Flow components (`static layout`) closely enough to be the natural first attempt.
41
+ * Exported for the doctor.
42
+ */
43
+ export function staticScheduleConfigKeys(cls: abstract new () => Schedule): string[] {
44
+ return SCHEDULE_CONFIG_KEYS.filter((key) => {
45
+ const desc = Object.getOwnPropertyDescriptor(cls, key);
46
+ if (desc === undefined) return false;
47
+ // Every class carries an intrinsic non-enumerable `name`; a `static name = "…"`
48
+ // field is enumerable, which is what tells the two apart. Static *methods*
49
+ // (`static frequency() {}`) are non-enumerable by spec, so the other keys are
50
+ // checked by presence alone — no intrinsic shares their names.
51
+ return key === "name" ? desc.enumerable === true : true;
52
+ });
53
+ }
54
+
15
55
  /**
16
56
  * Register one Schedule instance with the scheduler manager, translating its declarative
17
57
  * settings into a configured ScheduledTask. Exported for testing.
@@ -75,6 +115,15 @@ export const schedulesConcern: ConcernDescriptor = {
75
115
  }
76
116
  for (const exported of Object.values(mod)) {
77
117
  if (!isScheduleClass(exported)) continue;
118
+ const misdeclared = staticScheduleConfigKeys(exported);
119
+ if (misdeclared.length > 0) {
120
+ frameworkLog("scheduler").warn(
121
+ `Schedule "${exported.name}" declares static ${misdeclared.join(", ")} — ` +
122
+ `schedule config is instance properties, so static values register nothing. ` +
123
+ `Drop the \`static\` keyword (e.g. \`override cron = "0 3 * * *"\`).`,
124
+ );
125
+ manager.staticConfigFindings.push({ className: exported.name, keys: misdeclared });
126
+ }
78
127
  registerSchedule(manager, new exported(), exported.name);
79
128
  }
80
129
  },
package/src/index.ts CHANGED
@@ -24,3 +24,13 @@ export type { SchedulerConfigShape } from "./config.ts";
24
24
 
25
25
  // Framework instrumentation events (emitted on the core FrameworkEvents bus)
26
26
  export { TaskRan, TaskFailed, TaskSkipped } from "./events.ts";
27
+
28
+ // Durable run history (schedule:runs, the monitor panel, or ScheduleRunStore.recent()).
29
+ export {
30
+ FileScheduleRunStore,
31
+ installScheduleRunLog,
32
+ resolveRunLogConfig,
33
+ DEFAULT_RUN_LOG_PATH,
34
+ DEFAULT_RUN_LOG_KEEP,
35
+ } from "./runLog.ts";
36
+ export type { ScheduleRunStore, ScheduleRunRecord, RunLogConfig } from "./runLog.ts";
package/src/monitor.ts CHANGED
@@ -80,8 +80,20 @@ export function installSchedulerMonitor(app: Application): void {
80
80
  resolve: () => {
81
81
  const scheduler = app.container.tryMake("scheduler") as SchedulerManager | null;
82
82
  const tasks = scheduler ? [...scheduler.tasks.values()] : [];
83
- const failing = tasks.filter((t) => t.lastOk === false);
84
- const neverRun = tasks.filter((t) => t.lastRunAt === undefined);
83
+ // The durable run log fills what the in-memory state cannot: after a restart
84
+ // every task reads "never run" in memory even when it ran an hour ago.
85
+ const runStore = app.container.tryMake("scheduler.runs") ?? undefined;
86
+ const lastRecorded = (name: string) => {
87
+ try {
88
+ return runStore?.lastFor(name);
89
+ } catch {
90
+ return undefined;
91
+ }
92
+ };
93
+ const failing = tasks.filter((t) => (t.lastOk ?? lastRecorded(t.name)?.ok) === false);
94
+ const neverRun = tasks.filter(
95
+ (t) => t.lastRunAt === undefined && lastRecorded(t.name) === undefined,
96
+ );
85
97
 
86
98
  return {
87
99
  stats: [
@@ -112,25 +124,62 @@ export function installSchedulerMonitor(app: Application): void {
112
124
  {
113
125
  key: "status",
114
126
  label: "Last result",
115
- tone: (v) => (v === "Failed" ? "bad" : v === "OK" ? "good" : null),
127
+ tone: (v) =>
128
+ String(v).startsWith("Failed")
129
+ ? "bad"
130
+ : String(v).startsWith("OK")
131
+ ? "good"
132
+ : null,
116
133
  },
117
134
  { key: "lastRunAt", label: "Last run", format: formatWhen },
118
135
  { key: "durationMs", label: "Duration", align: "end", format: formatDuration },
119
136
  { key: "nextRunAt", label: "Next run", format: formatWhen },
120
137
  ],
121
- rows: tasks.map((t) => ({
122
- name: t.name,
123
- schedule: t.schedule,
124
- status: t.isRunning
125
- ? "Running"
126
- : t.lastOk === undefined
127
- ? "Never run"
128
- : t.lastOk
129
- ? "OK"
130
- : "Failed",
131
- lastRunAt: t.lastRunAt ?? null,
132
- durationMs: t.lastDurationMs ?? null,
133
- nextRunAt: t.nextRunAt(),
138
+ rows: tasks.map((t) => {
139
+ // Fall back to the recorded run when this process has not run the
140
+ // task yet — "(recorded)" marks the value as pre-restart history.
141
+ const recorded = t.lastRunAt === undefined ? lastRecorded(t.name) : undefined;
142
+ const lastOk = t.lastOk ?? recorded?.ok;
143
+ return {
144
+ name: t.name,
145
+ schedule: t.schedule,
146
+ status: t.isRunning
147
+ ? "Running"
148
+ : lastOk === undefined
149
+ ? "Never run"
150
+ : lastOk
151
+ ? recorded
152
+ ? "OK (recorded)"
153
+ : "OK"
154
+ : recorded
155
+ ? "Failed (recorded)"
156
+ : "Failed",
157
+ lastRunAt: t.lastRunAt ?? recorded?.finishedAt ?? null,
158
+ durationMs: t.lastDurationMs ?? recorded?.durationMs ?? null,
159
+ nextRunAt: t.nextRunAt(),
160
+ };
161
+ }),
162
+ },
163
+ {
164
+ title: "Recent runs",
165
+ empty: "No runs recorded yet.",
166
+ columns: [
167
+ { key: "name", label: "Task" },
168
+ {
169
+ key: "result",
170
+ label: "Result",
171
+ tone: (v) => (v === "OK" ? "good" : "bad"),
172
+ },
173
+ { key: "startedAt", label: "Started", format: formatWhen },
174
+ { key: "durationMs", label: "Duration", align: "end", format: formatDuration },
175
+ { key: "error", label: "Error" },
176
+ ],
177
+ rows: (runStore?.recent(15) ?? []).map((r) => ({
178
+ name: r.name,
179
+ result: r.ok ? "OK" : "Failed",
180
+ startedAt: r.startedAt,
181
+ durationMs: r.durationMs,
182
+ error: r.error ?? "—",
134
183
  })),
135
184
  },
136
185
  ],
@@ -1,11 +1,13 @@
1
1
  import { ServiceProvider } from "@zerotal/core";
2
2
  import type { AppEnvironment } from "@zerotal/core";
3
3
  import type { LockManager } from "@zerotal/core/lock";
4
+ import { resolve } from "node:path";
4
5
  import { SchedulerManager } from "../SchedulerManager.ts";
5
6
  import { ScheduledTask } from "../ScheduledTask.ts";
6
7
  import { schedulesConcern } from "../conventions.ts";
7
8
  import { installSchedulerObservability } from "../observability.ts";
8
9
  import { installSchedulerMonitor } from "../monitor.ts";
10
+ import { FileScheduleRunStore, installScheduleRunLog, resolveRunLogConfig } from "../runLog.ts";
9
11
 
10
12
  declare module "@zerotal/core" {
11
13
  interface ContainerBindings {
@@ -18,13 +20,42 @@ export class SchedulerProvider extends ServiceProvider {
18
20
  static override environments: AppEnvironment[] = ["web", "console", "worker"];
19
21
 
20
22
  private _disposeObservability: (() => void) | undefined = undefined;
23
+ private _disposeRunLog: (() => void) | undefined = undefined;
21
24
 
22
25
  override onRegister(): void {
23
26
  // Convention-based auto-discovery of app/schedules. (Optional-chained so bare-container
24
27
  // unit tests with a minimal app stub don't need to stub registerConcern.)
25
28
  this.app.registerConcern?.(schedulesConcern);
26
29
 
30
+ // `zt doctor`: surface Schedule classes whose config is declared static —
31
+ // convention discovery warns at boot, but a boot log line scrolls away; the
32
+ // doctor holds the finding still.
33
+ this.app.registerDoctorCheck?.({
34
+ id: "schedule-static-config",
35
+ label: "Schedule config",
36
+ run: () => {
37
+ const manager = this.app.container.tryMake("scheduler") as SchedulerManager | undefined;
38
+ const findings = manager?.staticConfigFindings ?? [];
39
+ if (findings.length === 0) return { status: "ok", message: "instance properties" };
40
+ return {
41
+ status: "warn",
42
+ message:
43
+ findings.map((f) => `${f.className} declares static ${f.keys.join(", ")}`).join("; ") +
44
+ " — static schedule config registers nothing.",
45
+ fix: 'Drop the `static` keyword (e.g. `override cron = "0 3 * * *"`).',
46
+ };
47
+ },
48
+ });
49
+
27
50
  this.app.container.singleton("scheduler", () => new SchedulerManager());
51
+
52
+ // Durable run history (see runLog.ts). Registered as its own binding so an app
53
+ // can rebind `scheduler.runs` (e.g. onto Redis) without touching the scheduler.
54
+ // The factory reads config lazily — config is loaded by the time anything makes it.
55
+ this.app.container.singleton("scheduler.runs", () => {
56
+ const config = resolveRunLogConfig(this.app);
57
+ return new FileScheduleRunStore(resolve(process.cwd(), config.path), config.keep);
58
+ });
28
59
  }
29
60
 
30
61
  override async onBooting(): Promise<void> {
@@ -38,6 +69,7 @@ export class SchedulerProvider extends ServiceProvider {
38
69
 
39
70
  override async onBooted(): Promise<void> {
40
71
  this._disposeObservability = installSchedulerObservability(this.app);
72
+ this._disposeRunLog = installScheduleRunLog(this.app);
41
73
 
42
74
  // Wire the distributed lock for cross-process withoutOverlapping. Falls back to
43
75
  // the in-process guard when no LockProvider is registered.
@@ -48,6 +80,9 @@ export class SchedulerProvider extends ServiceProvider {
48
80
  runner.registerLazy("schedule:list", () =>
49
81
  import("../commands/ScheduleListCommand.ts").then((m) => m.ScheduleListCommand),
50
82
  );
83
+ runner.registerLazy("schedule:runs", () =>
84
+ import("../commands/ScheduleRunsCommand.ts").then((m) => m.ScheduleRunsCommand),
85
+ );
51
86
  }
52
87
 
53
88
  override async onStarted(): Promise<void> {
@@ -58,6 +93,8 @@ export class SchedulerProvider extends ServiceProvider {
58
93
  override async onStopped(): Promise<void> {
59
94
  this._disposeObservability?.();
60
95
  this._disposeObservability = undefined;
96
+ this._disposeRunLog?.();
97
+ this._disposeRunLog = undefined;
61
98
 
62
99
  const scheduler = this.app.container.tryMake("scheduler") as SchedulerManager | undefined;
63
100
  scheduler?.stop();
package/src/runLog.ts ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Durable schedule run history.
3
+ *
4
+ * A cron task that quietly stopped firing looks identical to one with nothing to
5
+ * do, and the in-memory `lastRunAt` on each task dies with the process — so
6
+ * "did the retention sweep run last night?" had no answer after a restart. Every
7
+ * run (success or failure) is appended to a capped JSONL file under `storage/`,
8
+ * the same storage-root convention the framework's own log trail uses: durable
9
+ * across restarts, no database dependency, greppable in an emergency.
10
+ *
11
+ * Reading it: `bun zt schedule:runs`, the monitor panel's scheduled-tasks
12
+ * section, or {@link ScheduleRunStore.recent} in code. Swap the store by
13
+ * rebinding `scheduler.runs` in the container.
14
+ */
15
+ import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
16
+ import { dirname } from "node:path";
17
+ import { FrameworkEvents } from "@zerotal/core";
18
+ import type { Application } from "@zerotal/core";
19
+ import { TaskRan, TaskFailed } from "./events.ts";
20
+
21
+ /** One completed execution of a scheduled task. */
22
+ export interface ScheduleRunRecord {
23
+ /** Task name, as shown by `schedule:list`. */
24
+ name: string;
25
+ /** When the run started (ISO 8601). */
26
+ startedAt: string;
27
+ /** When the run finished (ISO 8601). */
28
+ finishedAt: string;
29
+ durationMs: number;
30
+ ok: boolean;
31
+ /** The thrown error's message, present only when `ok` is false. */
32
+ error?: string;
33
+ }
34
+
35
+ /** Where completed runs are recorded and read back. Rebind `scheduler.runs` to replace. */
36
+ export interface ScheduleRunStore {
37
+ record(run: ScheduleRunRecord): void;
38
+ /** Most recent runs, newest first, optionally filtered to one task name. */
39
+ recent(limit?: number, name?: string): ScheduleRunRecord[];
40
+ /** The most recent run of one task, or undefined if none is on record. */
41
+ lastFor(name: string): ScheduleRunRecord | undefined;
42
+ }
43
+
44
+ // The binding is declared here, next to its type, so every consumer resolves it typed.
45
+ declare module "@zerotal/core" {
46
+ interface ContainerBindings {
47
+ "scheduler.runs": ScheduleRunStore;
48
+ }
49
+ }
50
+
51
+ /** `scheduler.runLog` config (read from `config/scheduler.ts` when present). */
52
+ export interface RunLogConfig {
53
+ /** Default: on, except under `APP_ENV=test` (mirroring the file log trail). */
54
+ enabled: boolean;
55
+ /** JSONL file path, relative to the app root. */
56
+ path: string;
57
+ /** Records kept after compaction; the file is compacted at 2× this. */
58
+ keep: number;
59
+ }
60
+
61
+ export const DEFAULT_RUN_LOG_PATH = "storage/framework/schedule-runs.jsonl";
62
+ export const DEFAULT_RUN_LOG_KEEP = 500;
63
+
64
+ /** Resolve `scheduler.runLog` config with defaults, tolerating an absent config binding. */
65
+ export function resolveRunLogConfig(app: Application): RunLogConfig {
66
+ let raw: { enabled?: boolean; path?: string; keep?: number } = {};
67
+ try {
68
+ const config = app.container.makeSync("config") as { get(key: string): unknown };
69
+ raw = (config.get("scheduler.runLog") ?? {}) as typeof raw;
70
+ } catch {
71
+ /* config not resolvable — use the defaults */
72
+ }
73
+ return {
74
+ enabled: raw.enabled ?? Bun.env["APP_ENV"] !== "test",
75
+ path: raw.path ?? DEFAULT_RUN_LOG_PATH,
76
+ keep: Math.max(1, raw.keep ?? DEFAULT_RUN_LOG_KEEP),
77
+ };
78
+ }
79
+
80
+ /**
81
+ * JSONL-backed run store. Appends are synchronous — runs happen at cron cadence,
82
+ * not request cadence, and a record that survives a crash is the whole point.
83
+ */
84
+ export class FileScheduleRunStore implements ScheduleRunStore {
85
+ constructor(
86
+ private readonly _path: string,
87
+ private readonly _keep: number = DEFAULT_RUN_LOG_KEEP,
88
+ ) {}
89
+
90
+ record(run: ScheduleRunRecord): void {
91
+ mkdirSync(dirname(this._path), { recursive: true });
92
+ // Read-before-append: the file is capped and appends happen at cron cadence,
93
+ // so the read is cheap — and it repairs a torn tail every time. A crash
94
+ // mid-append (this process or a sibling) leaves no trailing newline, and
95
+ // appending straight on would merge this record into the torn line and lose
96
+ // them both.
97
+ let text = "";
98
+ try {
99
+ text = readFileSync(this._path, "utf8");
100
+ } catch {
101
+ /* no file yet */
102
+ }
103
+ const prefix = text.length > 0 && !text.endsWith("\n") ? "\n" : "";
104
+ appendFileSync(this._path, prefix + JSON.stringify(run) + "\n");
105
+ // Compact at 2× the cap so the rewrite cost is amortised, not per-append.
106
+ if (FileScheduleRunStore._parse(text).length + 1 >= this._keep * 2) this._compact();
107
+ }
108
+
109
+ recent(limit = 50, name?: string): ScheduleRunRecord[] {
110
+ let runs = this._readAll();
111
+ if (name !== undefined) runs = runs.filter((r) => r.name === name);
112
+ return runs.slice(-Math.max(1, limit)).reverse();
113
+ }
114
+
115
+ lastFor(name: string): ScheduleRunRecord | undefined {
116
+ return this.recent(1, name)[0];
117
+ }
118
+
119
+ private _readAll(): ScheduleRunRecord[] {
120
+ let text: string;
121
+ try {
122
+ text = readFileSync(this._path, "utf8");
123
+ } catch {
124
+ return []; // no file yet
125
+ }
126
+ return FileScheduleRunStore._parse(text);
127
+ }
128
+
129
+ private static _parse(text: string): ScheduleRunRecord[] {
130
+ const runs: ScheduleRunRecord[] = [];
131
+ for (const line of text.split("\n")) {
132
+ if (!line.trim()) continue;
133
+ try {
134
+ runs.push(JSON.parse(line) as ScheduleRunRecord);
135
+ } catch {
136
+ /* a torn line (crash mid-append) is dropped, not fatal */
137
+ }
138
+ }
139
+ return runs;
140
+ }
141
+
142
+ private _compact(): void {
143
+ const keep = this._readAll().slice(-this._keep);
144
+ writeFileSync(this._path, keep.map((r) => JSON.stringify(r)).join("\n") + "\n");
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Subscribe the run store to the scheduler's completion events. `TaskRan` fires on
150
+ * success and `TaskFailed` on a throw, so together they are exactly one record per
151
+ * completed execution (skips are deliberate non-runs and are not recorded). Returns
152
+ * a disposer, or undefined when the run log is disabled.
153
+ */
154
+ export function installScheduleRunLog(app: Application): (() => void) | undefined {
155
+ const config = resolveRunLogConfig(app);
156
+ if (!config.enabled) return undefined;
157
+
158
+ const store = app.container.tryMake("scheduler.runs");
159
+ if (!store) return undefined; // no SchedulerProvider — nothing to subscribe
160
+ const toRecord = (name: string, durationMs: number, ok: boolean, error?: string) => {
161
+ const finished = Date.now();
162
+ const record: ScheduleRunRecord = {
163
+ name,
164
+ startedAt: new Date(finished - durationMs).toISOString(),
165
+ finishedAt: new Date(finished).toISOString(),
166
+ durationMs: Math.round(durationMs),
167
+ ok,
168
+ ...(error !== undefined ? { error } : {}),
169
+ };
170
+ try {
171
+ store.record(record);
172
+ } catch (err) {
173
+ // A full disk must not take the scheduler down with it.
174
+ console.warn(`[Zerotal scheduler] failed to record run of "${name}":`, err);
175
+ }
176
+ };
177
+
178
+ const unsubs = [
179
+ FrameworkEvents.on(TaskRan, (e) => toRecord(e.name, e.durationMs, e.ok)),
180
+ FrameworkEvents.on(TaskFailed, (e) => toRecord(e.name, e.durationMs, false, e.error)),
181
+ ];
182
+ return () => {
183
+ for (const unsub of unsubs) unsub();
184
+ };
185
+ }