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.
- package/LICENSE +21 -0
- package/README.md +541 -0
- package/examples/.cronfish.json +17 -0
- package/examples/README.md +28 -0
- package/examples/disk-space.sh +25 -0
- package/examples/healthcheck.ts +27 -0
- package/examples/hello.md +18 -0
- package/examples/one-time/cleanup.ts +25 -0
- package/examples/one-time/reminder.md +22 -0
- package/package.json +44 -0
- package/src/alerts/dispatch.ts +134 -0
- package/src/alerts/index.ts +21 -0
- package/src/alerts/registry.ts +34 -0
- package/src/alerts/safe.ts +26 -0
- package/src/alerts/shell.ts +63 -0
- package/src/alerts/slack.ts +98 -0
- package/src/alerts/types.ts +34 -0
- package/src/cli.ts +1097 -0
- package/src/db.ts +365 -0
- package/src/frontmatter.ts +654 -0
- package/src/jobs.ts +536 -0
- package/src/models.ts +54 -0
- package/src/oneTime.ts +259 -0
- package/src/parsers/friendly.ts +53 -0
- package/src/platform/index.ts +14 -0
- package/src/platform/launchd.ts +564 -0
- package/src/prune.ts +155 -0
- package/src/result.ts +111 -0
- package/src/runner.ts +827 -0
- package/src/schedule.ts +188 -0
- package/src/state.ts +55 -0
- package/src/ts-shim.ts +44 -0
- package/src/ui/server.ts +469 -0
- package/src/watchdog.ts +201 -0
- package/templates/_examples/one-time/echo-at.md +10 -0
- package/templates/plist.template +35 -0
- package/ui/README.md +73 -0
- package/ui/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/ui/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/ui/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/ui/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/ui/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/ui/dist/assets/index-DNE046Zp.js +9 -0
- package/ui/dist/assets/index-DmWTmu9X.css +2 -0
- package/ui/dist/favicon.svg +1 -0
- package/ui/dist/icons.svg +24 -0
- 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
|
+
}
|