cronfish 0.12.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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +541 -0
  3. package/examples/.cronfish.json +17 -0
  4. package/examples/README.md +28 -0
  5. package/examples/disk-space.sh +25 -0
  6. package/examples/healthcheck.ts +27 -0
  7. package/examples/hello.md +18 -0
  8. package/examples/one-time/cleanup.ts +25 -0
  9. package/examples/one-time/reminder.md +22 -0
  10. package/package.json +44 -0
  11. package/src/alerts/dispatch.ts +134 -0
  12. package/src/alerts/index.ts +21 -0
  13. package/src/alerts/registry.ts +34 -0
  14. package/src/alerts/safe.ts +26 -0
  15. package/src/alerts/shell.ts +63 -0
  16. package/src/alerts/slack.ts +98 -0
  17. package/src/alerts/types.ts +34 -0
  18. package/src/cli.ts +1097 -0
  19. package/src/db.ts +365 -0
  20. package/src/frontmatter.ts +654 -0
  21. package/src/jobs.ts +536 -0
  22. package/src/models.ts +54 -0
  23. package/src/oneTime.ts +259 -0
  24. package/src/parsers/friendly.ts +53 -0
  25. package/src/platform/index.ts +14 -0
  26. package/src/platform/launchd.ts +564 -0
  27. package/src/prune.ts +155 -0
  28. package/src/result.ts +111 -0
  29. package/src/runner.ts +827 -0
  30. package/src/schedule.ts +188 -0
  31. package/src/state.ts +55 -0
  32. package/src/ts-shim.ts +44 -0
  33. package/src/ui/server.ts +469 -0
  34. package/src/watchdog.ts +201 -0
  35. package/templates/_examples/one-time/echo-at.md +10 -0
  36. package/templates/plist.template +35 -0
  37. package/ui/README.md +73 -0
  38. package/ui/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  39. package/ui/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  40. package/ui/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  41. package/ui/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  42. package/ui/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  43. package/ui/dist/assets/index-DNE046Zp.js +9 -0
  44. package/ui/dist/assets/index-DmWTmu9X.css +2 -0
  45. package/ui/dist/favicon.svg +1 -0
  46. package/ui/dist/icons.svg +24 -0
  47. package/ui/dist/index.html +14 -0
package/src/db.ts ADDED
@@ -0,0 +1,365 @@
1
+ // SQLite ledger for cronfish.
2
+ //
3
+ // Lives at <consumer-root>/.cronfish/db.sqlite via bun:sqlite (zero native dep).
4
+ // Migrations are a hand-rolled PRAGMA user_version step ladder — do NOT bring
5
+ // in Drizzle. Every write is failure-safe: callers wrap in try/catch and a DB
6
+ // failure logs one stderr warning, never aborts a cron run.
7
+
8
+ import { Database } from "bun:sqlite";
9
+ import { mkdirSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import type { JobMeta } from "./jobs.ts";
12
+
13
+ export type InvocationStatus =
14
+ | "running"
15
+ | "ok"
16
+ | "fail"
17
+ | "timeout"
18
+ | "crashed";
19
+ export type InvocationTrigger = "schedule" | "manual" | "retry";
20
+
21
+ export function dbPath(consumerRoot: string): string {
22
+ return join(consumerRoot, ".cronfish", "db.sqlite");
23
+ }
24
+
25
+ export function logsRoot(consumerRoot: string): string {
26
+ return join(consumerRoot, ".cronfish", "logs");
27
+ }
28
+
29
+ export function openDb(consumerRoot: string): Database {
30
+ const path = dbPath(consumerRoot);
31
+ mkdirSync(dirname(path), { recursive: true });
32
+ const db = new Database(path);
33
+ db.exec("PRAGMA journal_mode = WAL");
34
+ db.exec("PRAGMA foreign_keys = ON");
35
+ migrate(db);
36
+ return db;
37
+ }
38
+
39
+ type Migration = (db: Database) => void;
40
+
41
+ const MIGRATIONS: Migration[] = [
42
+ // v1 — initial ledger
43
+ (db) => {
44
+ db.exec(`
45
+ CREATE TABLE IF NOT EXISTS cron_jobs (
46
+ id INTEGER PRIMARY KEY,
47
+ slug TEXT NOT NULL UNIQUE,
48
+ kind TEXT NOT NULL CHECK (kind IN ('md','ts','sh','py')),
49
+ schedule TEXT NOT NULL,
50
+ enabled INTEGER NOT NULL,
51
+ timeout_s INTEGER,
52
+ retries INTEGER NOT NULL DEFAULT 0,
53
+ concurrency TEXT NOT NULL DEFAULT 'skip' CHECK (concurrency IN ('skip','queue')),
54
+ model TEXT,
55
+ last_synced_at TEXT NOT NULL,
56
+ deleted_at TEXT
57
+ );
58
+
59
+ CREATE TABLE IF NOT EXISTS cron_invocations (
60
+ id INTEGER PRIMARY KEY,
61
+ job_id INTEGER NOT NULL REFERENCES cron_jobs(id),
62
+ started_at TEXT NOT NULL,
63
+ finished_at TEXT,
64
+ status TEXT NOT NULL CHECK (status IN ('running','ok','fail','timeout','crashed')),
65
+ exit_code INTEGER,
66
+ trigger TEXT NOT NULL CHECK (trigger IN ('schedule','manual','retry')),
67
+ log_path TEXT NOT NULL
68
+ );
69
+ CREATE INDEX IF NOT EXISTS idx_inv_job_started ON cron_invocations(job_id, started_at DESC);
70
+ CREATE INDEX IF NOT EXISTS idx_inv_status_running ON cron_invocations(status) WHERE status = 'running';
71
+ `);
72
+ },
73
+ // v2 — one-line description from frontmatter
74
+ (db) => {
75
+ // ALTER TABLE ADD COLUMN — wrapped because IF NOT EXISTS isn't supported
76
+ // for ADD COLUMN in older sqlite. Check the column first.
77
+ const cols = db.query("PRAGMA table_info(cron_jobs)").all() as {
78
+ name: string;
79
+ }[];
80
+ if (!cols.some((c) => c.name === "description")) {
81
+ db.exec("ALTER TABLE cron_jobs ADD COLUMN description TEXT");
82
+ }
83
+ },
84
+ // v3 — structured per-run results
85
+ (db) => {
86
+ const cols = db.query("PRAGMA table_info(cron_invocations)").all() as {
87
+ name: string;
88
+ }[];
89
+ const have = new Set(cols.map((c) => c.name));
90
+ if (!have.has("result_summary"))
91
+ db.exec("ALTER TABLE cron_invocations ADD COLUMN result_summary TEXT");
92
+ if (!have.has("result_ok"))
93
+ db.exec("ALTER TABLE cron_invocations ADD COLUMN result_ok INTEGER");
94
+ if (!have.has("result_json"))
95
+ db.exec("ALTER TABLE cron_invocations ADD COLUMN result_json TEXT");
96
+ if (!have.has("result_truncated"))
97
+ db.exec(
98
+ "ALTER TABLE cron_invocations ADD COLUMN result_truncated INTEGER NOT NULL DEFAULT 0",
99
+ );
100
+ },
101
+ // v4 — alert outcome per invocation
102
+ (db) => {
103
+ const cols = db.query("PRAGMA table_info(cron_invocations)").all() as {
104
+ name: string;
105
+ }[];
106
+ const have = new Set(cols.map((c) => c.name));
107
+ if (!have.has("alert_status"))
108
+ db.exec("ALTER TABLE cron_invocations ADD COLUMN alert_status TEXT");
109
+ if (!have.has("alert_error"))
110
+ db.exec("ALTER TABLE cron_invocations ADD COLUMN alert_error TEXT");
111
+ },
112
+ // v5 — missed-schedule alerts table (watchdog dedup)
113
+ (db) => {
114
+ db.exec(`
115
+ CREATE TABLE IF NOT EXISTS cron_missed_alerts (
116
+ id INTEGER PRIMARY KEY,
117
+ job_id INTEGER NOT NULL REFERENCES cron_jobs(id),
118
+ expected_at TEXT NOT NULL,
119
+ fired_at TEXT NOT NULL
120
+ );
121
+ CREATE INDEX IF NOT EXISTS idx_missed_job_fired
122
+ ON cron_missed_alerts(job_id, fired_at DESC);
123
+ `);
124
+ },
125
+ ];
126
+
127
+ export type AlertLedgerStatus = "sent" | "skipped" | "error" | "recovered";
128
+
129
+ export function migrate(db: Database): void {
130
+ const current = (
131
+ db.query("PRAGMA user_version").get() as { user_version: number }
132
+ ).user_version;
133
+ for (let v = current; v < MIGRATIONS.length; v++) {
134
+ const step = MIGRATIONS[v];
135
+ db.transaction(() => {
136
+ step(db);
137
+ db.exec(`PRAGMA user_version = ${v + 1}`);
138
+ })();
139
+ }
140
+ }
141
+
142
+ function nowIso(): string {
143
+ return new Date().toISOString();
144
+ }
145
+
146
+ function scheduleAsText(schedule: JobMeta["schedule"]): string {
147
+ if (schedule === undefined) return "manual";
148
+ return String(schedule);
149
+ }
150
+
151
+ export function upsertJob(db: Database, job: JobMeta): void {
152
+ const stmt = db.prepare(`
153
+ INSERT INTO cron_jobs (
154
+ slug, kind, schedule, enabled, timeout_s, retries, concurrency,
155
+ model, description, last_synced_at, deleted_at
156
+ ) VALUES (
157
+ $slug, $kind, $schedule, $enabled, $timeout_s, $retries, $concurrency,
158
+ $model, $description, $now, NULL
159
+ )
160
+ ON CONFLICT(slug) DO UPDATE SET
161
+ kind = excluded.kind,
162
+ schedule = excluded.schedule,
163
+ enabled = excluded.enabled,
164
+ timeout_s = excluded.timeout_s,
165
+ retries = excluded.retries,
166
+ concurrency = excluded.concurrency,
167
+ model = excluded.model,
168
+ description = excluded.description,
169
+ last_synced_at = excluded.last_synced_at,
170
+ deleted_at = NULL
171
+ `);
172
+ stmt.run({
173
+ $slug: job.slug,
174
+ $kind: job.kind,
175
+ $schedule: scheduleAsText(job.schedule),
176
+ $enabled: job.enabled ? 1 : 0,
177
+ $timeout_s: job.timeout ?? null,
178
+ $retries: job.retries ?? 0,
179
+ $concurrency: job.concurrency ?? "skip",
180
+ $model: job.model ?? null,
181
+ $description: job.description ?? null,
182
+ $now: nowIso(),
183
+ });
184
+ }
185
+
186
+ export function markDeleted(db: Database, slugsPresent: string[]): void {
187
+ const now = nowIso();
188
+ if (slugsPresent.length === 0) {
189
+ db.prepare(
190
+ "UPDATE cron_jobs SET deleted_at = $now WHERE deleted_at IS NULL",
191
+ ).run({
192
+ $now: now,
193
+ });
194
+ return;
195
+ }
196
+ const placeholders = slugsPresent.map((_, i) => `$s${i}`).join(",");
197
+ const params: Record<string, string> = { $now: now };
198
+ slugsPresent.forEach((s, i) => (params[`$s${i}`] = s));
199
+ db.prepare(
200
+ `UPDATE cron_jobs SET deleted_at = $now WHERE deleted_at IS NULL AND slug NOT IN (${placeholders})`,
201
+ ).run(params);
202
+ }
203
+
204
+ export function getJobIdBySlug(db: Database, slug: string): number | null {
205
+ const row = db
206
+ .query("SELECT id FROM cron_jobs WHERE slug = $slug")
207
+ .get({ $slug: slug }) as { id: number } | undefined;
208
+ return row?.id ?? null;
209
+ }
210
+
211
+ export function startInvocation(
212
+ db: Database,
213
+ jobId: number,
214
+ trigger: InvocationTrigger,
215
+ logPath: string,
216
+ ): number {
217
+ const res = db
218
+ .prepare(
219
+ `INSERT INTO cron_invocations (job_id, started_at, status, trigger, log_path)
220
+ VALUES ($job_id, $now, 'running', $trigger, $log_path)`,
221
+ )
222
+ .run({
223
+ $job_id: jobId,
224
+ $now: nowIso(),
225
+ $trigger: trigger,
226
+ $log_path: logPath,
227
+ });
228
+ return Number(res.lastInsertRowid);
229
+ }
230
+
231
+ export interface InvocationResultRow {
232
+ summary: string | null;
233
+ ok: boolean | null;
234
+ json: string | null;
235
+ truncated: boolean;
236
+ }
237
+
238
+ export function finishInvocation(
239
+ db: Database,
240
+ invocationId: number,
241
+ status: InvocationStatus,
242
+ exitCode: number | null,
243
+ result?: InvocationResultRow,
244
+ ): void {
245
+ db.prepare(
246
+ `UPDATE cron_invocations
247
+ SET finished_at = $now,
248
+ status = $status,
249
+ exit_code = $exit_code,
250
+ result_summary = $result_summary,
251
+ result_ok = $result_ok,
252
+ result_json = $result_json,
253
+ result_truncated = $result_truncated
254
+ WHERE id = $id`,
255
+ ).run({
256
+ $id: invocationId,
257
+ $now: nowIso(),
258
+ $status: status,
259
+ $exit_code: exitCode,
260
+ $result_summary: result?.summary ?? null,
261
+ $result_ok:
262
+ result?.ok === undefined || result?.ok === null
263
+ ? null
264
+ : result.ok
265
+ ? 1
266
+ : 0,
267
+ $result_json: result?.json ?? null,
268
+ $result_truncated: result?.truncated ? 1 : 0,
269
+ });
270
+ }
271
+
272
+ export function setInvocationAlert(
273
+ db: Database,
274
+ invocationId: number,
275
+ status: AlertLedgerStatus,
276
+ error: string | null,
277
+ ): void {
278
+ db.prepare(
279
+ `UPDATE cron_invocations
280
+ SET alert_status = $status, alert_error = $error
281
+ WHERE id = $id`,
282
+ ).run({ $id: invocationId, $status: status, $error: error });
283
+ }
284
+
285
+ export interface EnabledJobRow {
286
+ id: number;
287
+ slug: string;
288
+ schedule: string;
289
+ }
290
+
291
+ export function listEnabledJobs(db: Database): EnabledJobRow[] {
292
+ return db
293
+ .query(
294
+ `SELECT id, slug, schedule FROM cron_jobs
295
+ WHERE enabled = 1 AND deleted_at IS NULL`,
296
+ )
297
+ .all() as EnabledJobRow[];
298
+ }
299
+
300
+ export function getLastOkStartedAt(
301
+ db: Database,
302
+ jobId: number,
303
+ ): string | null {
304
+ const row = db
305
+ .query(
306
+ `SELECT started_at FROM cron_invocations
307
+ WHERE job_id = $job_id AND status = 'ok'
308
+ ORDER BY started_at DESC LIMIT 1`,
309
+ )
310
+ .get({ $job_id: jobId }) as { started_at: string } | undefined;
311
+ return row?.started_at ?? null;
312
+ }
313
+
314
+ export function getLatestMissedFiredAt(
315
+ db: Database,
316
+ jobId: number,
317
+ ): string | null {
318
+ const row = db
319
+ .query(
320
+ `SELECT fired_at FROM cron_missed_alerts
321
+ WHERE job_id = $job_id
322
+ ORDER BY fired_at DESC LIMIT 1`,
323
+ )
324
+ .get({ $job_id: jobId }) as { fired_at: string } | undefined;
325
+ return row?.fired_at ?? null;
326
+ }
327
+
328
+ export function recordMissedAlert(
329
+ db: Database,
330
+ jobId: number,
331
+ expectedAtIso: string,
332
+ ): number {
333
+ const res = db
334
+ .prepare(
335
+ `INSERT INTO cron_missed_alerts (job_id, expected_at, fired_at)
336
+ VALUES ($job_id, $expected_at, $now)`,
337
+ )
338
+ .run({
339
+ $job_id: jobId,
340
+ $expected_at: expectedAtIso,
341
+ $now: nowIso(),
342
+ });
343
+ return Number(res.lastInsertRowid);
344
+ }
345
+
346
+ // Most recent finished invocation for a job (excluding the given id).
347
+ // Used by the runner to decide whether the current ok run is a recovery.
348
+ export function getPreviousFinishedStatus(
349
+ db: Database,
350
+ jobId: number,
351
+ excludingId: number,
352
+ ): InvocationStatus | null {
353
+ const row = db
354
+ .query(
355
+ `SELECT status FROM cron_invocations
356
+ WHERE job_id = $job_id
357
+ AND id <> $id
358
+ AND finished_at IS NOT NULL
359
+ ORDER BY started_at DESC LIMIT 1`,
360
+ )
361
+ .get({ $job_id: jobId, $id: excludingId }) as
362
+ | { status: InvocationStatus }
363
+ | undefined;
364
+ return row?.status ?? null;
365
+ }