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
@@ -0,0 +1,469 @@
1
+ // Local web dashboard for cronfish. Bound to 127.0.0.1 (no auth).
2
+ //
3
+ // Serves the prebuilt ui/dist/ bundle + a JSON API + a Range-aware log
4
+ // streaming endpoint. The dashboard is read-only — no mutating endpoints.
5
+
6
+ import { existsSync, statSync } from "node:fs";
7
+ import { extname, join, normalize, resolve } from "node:path";
8
+ import { openDb } from "../db.ts";
9
+ import { dispatchSchedule } from "../schedule.ts";
10
+
11
+ export interface UiServerOptions {
12
+ consumerRoot: string;
13
+ port: number;
14
+ hostname?: string;
15
+ }
16
+
17
+ interface JobRow {
18
+ id: number;
19
+ slug: string;
20
+ kind: string;
21
+ schedule: string;
22
+ enabled: number;
23
+ timeout_s: number | null;
24
+ retries: number;
25
+ concurrency: string;
26
+ model: string | null;
27
+ description: string | null;
28
+ last_synced_at: string;
29
+ deleted_at: string | null;
30
+ }
31
+
32
+ function filenameFromSlug(slug: string): string {
33
+ // slug encodes the kind as `-<ext>` — reverse it to get the on-disk name.
34
+ return slug.replace(/-(md|ts|sh|py)$/, ".$1");
35
+ }
36
+
37
+ function nextRunIso(
38
+ schedule: string,
39
+ lastStartedAt: string | null,
40
+ ): string | null {
41
+ try {
42
+ const d = dispatchSchedule(schedule);
43
+ if (d.kind !== "seconds") return null;
44
+ const baseMs = lastStartedAt
45
+ ? new Date(lastStartedAt).getTime()
46
+ : Date.now();
47
+ const periodMs = d.value * 1000;
48
+ let next = baseMs + periodMs;
49
+ if (next <= Date.now()) {
50
+ // skip past missed fires
51
+ const elapsed = Date.now() - baseMs;
52
+ next = baseMs + Math.ceil(elapsed / periodMs) * periodMs;
53
+ }
54
+ return new Date(next).toISOString();
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ interface InvocationRow {
61
+ id: number;
62
+ job_id: number;
63
+ started_at: string;
64
+ finished_at: string | null;
65
+ status: string;
66
+ exit_code: number | null;
67
+ trigger: string;
68
+ log_path: string;
69
+ result_summary: string | null;
70
+ result_ok: number | null;
71
+ result_json: string | null;
72
+ result_truncated: number;
73
+ }
74
+
75
+ const MIME: Record<string, string> = {
76
+ ".html": "text/html; charset=utf-8",
77
+ ".js": "application/javascript; charset=utf-8",
78
+ ".mjs": "application/javascript; charset=utf-8",
79
+ ".css": "text/css; charset=utf-8",
80
+ ".json": "application/json; charset=utf-8",
81
+ ".svg": "image/svg+xml",
82
+ ".png": "image/png",
83
+ ".jpg": "image/jpeg",
84
+ ".jpeg": "image/jpeg",
85
+ ".gif": "image/gif",
86
+ ".ico": "image/x-icon",
87
+ ".woff": "font/woff",
88
+ ".woff2": "font/woff2",
89
+ ".map": "application/json; charset=utf-8",
90
+ ".txt": "text/plain; charset=utf-8",
91
+ };
92
+
93
+ function distRoot(): string {
94
+ // server.ts lives at src/ui/server.ts in source, but is consumed from the
95
+ // installed package layout. Walk up from this file to repo root and join
96
+ // "ui/dist". URL → pathname avoids node:url import.
97
+ const here = new URL(".", import.meta.url).pathname;
98
+ return resolve(here, "..", "..", "ui", "dist");
99
+ }
100
+
101
+ function json(body: unknown, init?: ResponseInit): Response {
102
+ return new Response(JSON.stringify(body), {
103
+ ...init,
104
+ headers: {
105
+ "content-type": "application/json; charset=utf-8",
106
+ "cache-control": "no-store",
107
+ ...(init?.headers ?? {}),
108
+ },
109
+ });
110
+ }
111
+
112
+ function notFound(msg = "not found"): Response {
113
+ return new Response(msg, { status: 404 });
114
+ }
115
+
116
+ function badRequest(msg: string): Response {
117
+ return new Response(msg, { status: 400 });
118
+ }
119
+
120
+ async function serveStatic(pathname: string): Promise<Response | null> {
121
+ const dist = distRoot();
122
+ if (!existsSync(dist)) return null;
123
+ const rel = pathname === "/" ? "/index.html" : pathname;
124
+ const safe = normalize(rel).replace(/^\/+/, "");
125
+ if (safe.startsWith("..")) return null;
126
+ const full = join(dist, safe);
127
+ if (!full.startsWith(dist)) return null;
128
+ if (!existsSync(full)) return null;
129
+ const st = statSync(full);
130
+ if (st.isDirectory()) return null;
131
+ const file = Bun.file(full);
132
+ const mime = MIME[extname(full).toLowerCase()] ?? "application/octet-stream";
133
+ return new Response(file, { headers: { "content-type": mime } });
134
+ }
135
+
136
+ async function spaFallback(): Promise<Response> {
137
+ const dist = distRoot();
138
+ const indexPath = join(dist, "index.html");
139
+ if (!existsSync(indexPath)) {
140
+ return new Response(
141
+ "cronfish ui bundle not found.\n\n" +
142
+ `Expected: ${indexPath}\n\n` +
143
+ "If you're running from source, build it first:\n" +
144
+ " cd ui && bun install && bun run build\n",
145
+ { status: 503, headers: { "content-type": "text/plain; charset=utf-8" } },
146
+ );
147
+ }
148
+ return new Response(Bun.file(indexPath), {
149
+ headers: { "content-type": "text/html; charset=utf-8" },
150
+ });
151
+ }
152
+
153
+ function parseRange(
154
+ header: string | null,
155
+ size: number,
156
+ ): { start: number; end: number } | null {
157
+ if (!header) return null;
158
+ const m = header.match(/^bytes=(\d*)-(\d*)$/);
159
+ if (!m) return null;
160
+ const startRaw = m[1];
161
+ const endRaw = m[2];
162
+ let start: number;
163
+ let end: number;
164
+ if (startRaw === "" && endRaw === "") return null;
165
+ if (startRaw === "") {
166
+ // suffix range: last N bytes
167
+ const n = parseInt(endRaw, 10);
168
+ if (Number.isNaN(n) || n <= 0) return null;
169
+ start = Math.max(0, size - n);
170
+ end = size - 1;
171
+ } else {
172
+ start = parseInt(startRaw, 10);
173
+ end = endRaw === "" ? size - 1 : parseInt(endRaw, 10);
174
+ }
175
+ if (Number.isNaN(start) || Number.isNaN(end)) return null;
176
+ if (start > end || start >= size) return null;
177
+ end = Math.min(end, size - 1);
178
+ return { start, end };
179
+ }
180
+
181
+ async function serveLog(
182
+ consumerRoot: string,
183
+ invocationId: number,
184
+ rangeHeader: string | null,
185
+ ): Promise<Response> {
186
+ const db = openDb(consumerRoot);
187
+ try {
188
+ const inv = db
189
+ .query("SELECT log_path FROM cron_invocations WHERE id = $id")
190
+ .get({ $id: invocationId }) as { log_path: string } | undefined;
191
+ if (!inv) return notFound("invocation not found");
192
+ const path = inv.log_path;
193
+ if (!existsSync(path)) {
194
+ return new Response("[log file missing]\n", {
195
+ status: 200,
196
+ headers: { "content-type": "text/plain; charset=utf-8" },
197
+ });
198
+ }
199
+ const st = statSync(path);
200
+ const size = st.size;
201
+ const range = parseRange(rangeHeader, size);
202
+ const file = Bun.file(path);
203
+ if (range) {
204
+ const sliced = file.slice(range.start, range.end + 1);
205
+ return new Response(sliced, {
206
+ status: 206,
207
+ headers: {
208
+ "content-type": "text/plain; charset=utf-8",
209
+ "content-range": `bytes ${range.start}-${range.end}/${size}`,
210
+ "content-length": String(range.end - range.start + 1),
211
+ "accept-ranges": "bytes",
212
+ "cache-control": "no-store",
213
+ },
214
+ });
215
+ }
216
+ return new Response(file, {
217
+ headers: {
218
+ "content-type": "text/plain; charset=utf-8",
219
+ "content-length": String(size),
220
+ "accept-ranges": "bytes",
221
+ "cache-control": "no-store",
222
+ },
223
+ });
224
+ } finally {
225
+ db.close();
226
+ }
227
+ }
228
+
229
+ function listJobs(consumerRoot: string): unknown {
230
+ const db = openDb(consumerRoot);
231
+ try {
232
+ const jobs = db
233
+ .query<
234
+ JobRow & {
235
+ last_status: string | null;
236
+ last_started_at: string | null;
237
+ last_finished_at: string | null;
238
+ last_exit_code: number | null;
239
+ last_duration_ms: number | null;
240
+ last_invocation_id: number | null;
241
+ },
242
+ []
243
+ >(
244
+ `
245
+ SELECT j.*,
246
+ last.status AS last_status,
247
+ last.started_at AS last_started_at,
248
+ last.finished_at AS last_finished_at,
249
+ last.exit_code AS last_exit_code,
250
+ last.id AS last_invocation_id,
251
+ CASE
252
+ WHEN last.finished_at IS NULL THEN NULL
253
+ ELSE CAST(
254
+ (julianday(last.finished_at) - julianday(last.started_at)) * 86400000 AS INTEGER
255
+ )
256
+ END AS last_duration_ms
257
+ FROM cron_jobs j
258
+ LEFT JOIN cron_invocations last
259
+ ON last.id = (
260
+ SELECT id FROM cron_invocations
261
+ WHERE job_id = j.id
262
+ ORDER BY started_at DESC
263
+ LIMIT 1
264
+ )
265
+ ORDER BY j.deleted_at IS NOT NULL, j.slug
266
+ `,
267
+ )
268
+ .all();
269
+ return jobs.map((j) => ({
270
+ ...j,
271
+ filename: filenameFromSlug(j.slug),
272
+ next_run: nextRunIso(j.schedule, j.last_started_at),
273
+ }));
274
+ } finally {
275
+ db.close();
276
+ }
277
+ }
278
+
279
+ function getJob(consumerRoot: string, slug: string): unknown {
280
+ const db = openDb(consumerRoot);
281
+ try {
282
+ const job = db
283
+ .query<JobRow, [string]>("SELECT * FROM cron_jobs WHERE slug = ?")
284
+ .get(slug);
285
+ if (!job) return null;
286
+ const lastInv = db
287
+ .query<{ started_at: string | null }, [string]>(
288
+ `SELECT i.started_at FROM cron_invocations i
289
+ JOIN cron_jobs j ON j.id = i.job_id
290
+ WHERE j.slug = ?
291
+ ORDER BY i.started_at DESC LIMIT 1`,
292
+ )
293
+ .get(slug);
294
+ return {
295
+ ...job,
296
+ filename: filenameFromSlug(job.slug),
297
+ next_run: nextRunIso(job.schedule, lastInv?.started_at ?? null),
298
+ };
299
+ } finally {
300
+ db.close();
301
+ }
302
+ }
303
+
304
+ // log_path holds an absolute filesystem path on the host (e.g.
305
+ // /Users/<user>/...) — strip it from API responses so consumers that proxy
306
+ // the dashboard to the public internet don't leak host paths. The bytes are
307
+ // still served via /api/invocations/:id/log, which looks log_path up
308
+ // server-side.
309
+ function stripLogPath<T extends { log_path?: string }>(row: T): Omit<T, "log_path"> {
310
+ const { log_path: _, ...rest } = row;
311
+ return rest;
312
+ }
313
+
314
+ function listInvocations(
315
+ consumerRoot: string,
316
+ slug: string,
317
+ limit: number,
318
+ ): unknown {
319
+ const db = openDb(consumerRoot);
320
+ try {
321
+ const rows = db
322
+ .query<InvocationRow & { duration_ms: number | null }, [string, number]>(
323
+ `
324
+ SELECT i.*,
325
+ CASE
326
+ WHEN i.finished_at IS NULL THEN NULL
327
+ ELSE CAST(
328
+ (julianday(i.finished_at) - julianday(i.started_at)) * 86400000 AS INTEGER
329
+ )
330
+ END AS duration_ms
331
+ FROM cron_invocations i
332
+ JOIN cron_jobs j ON j.id = i.job_id
333
+ WHERE j.slug = ?
334
+ ORDER BY i.started_at DESC
335
+ LIMIT ?
336
+ `,
337
+ )
338
+ .all(slug, limit);
339
+ return rows.map(stripLogPath);
340
+ } finally {
341
+ db.close();
342
+ }
343
+ }
344
+
345
+ function listAllInvocations(consumerRoot: string, limit: number): unknown {
346
+ const db = openDb(consumerRoot);
347
+ try {
348
+ const rows = db
349
+ .query<
350
+ InvocationRow & { slug: string; duration_ms: number | null },
351
+ [number]
352
+ >(
353
+ `
354
+ SELECT i.*, j.slug AS slug,
355
+ CASE
356
+ WHEN i.finished_at IS NULL THEN NULL
357
+ ELSE CAST(
358
+ (julianday(i.finished_at) - julianday(i.started_at)) * 86400000 AS INTEGER
359
+ )
360
+ END AS duration_ms
361
+ FROM cron_invocations i
362
+ JOIN cron_jobs j ON j.id = i.job_id
363
+ ORDER BY i.started_at DESC
364
+ LIMIT ?
365
+ `,
366
+ )
367
+ .all(limit);
368
+ return rows.map(stripLogPath);
369
+ } finally {
370
+ db.close();
371
+ }
372
+ }
373
+
374
+ function getInvocation(consumerRoot: string, id: number): unknown {
375
+ const db = openDb(consumerRoot);
376
+ try {
377
+ const row = db
378
+ .query<
379
+ InvocationRow & { slug: string; duration_ms: number | null },
380
+ [number]
381
+ >(
382
+ `
383
+ SELECT i.*, j.slug AS slug,
384
+ CASE
385
+ WHEN i.finished_at IS NULL THEN NULL
386
+ ELSE CAST(
387
+ (julianday(i.finished_at) - julianday(i.started_at)) * 86400000 AS INTEGER
388
+ )
389
+ END AS duration_ms
390
+ FROM cron_invocations i
391
+ JOIN cron_jobs j ON j.id = i.job_id
392
+ WHERE i.id = ?
393
+ `,
394
+ )
395
+ .get(id);
396
+ return row ? stripLogPath(row) : null;
397
+ } finally {
398
+ db.close();
399
+ }
400
+ }
401
+
402
+ export async function startUiServer(opts: UiServerOptions): Promise<string> {
403
+ const hostname = opts.hostname ?? "127.0.0.1";
404
+ const server = Bun.serve({
405
+ hostname,
406
+ port: opts.port,
407
+ fetch: async (req) => {
408
+ const url = new URL(req.url);
409
+ const { pathname } = url;
410
+
411
+ // --- API ---
412
+ if (pathname === "/api/jobs" && req.method === "GET") {
413
+ return json(listJobs(opts.consumerRoot));
414
+ }
415
+
416
+ const jobDetail = pathname.match(/^\/api\/jobs\/([^/]+)$/);
417
+ if (jobDetail && req.method === "GET") {
418
+ const slug = decodeURIComponent(jobDetail[1]);
419
+ const row = getJob(opts.consumerRoot, slug);
420
+ return row ? json(row) : notFound("job not found");
421
+ }
422
+
423
+ const jobInv = pathname.match(/^\/api\/jobs\/([^/]+)\/invocations$/);
424
+ if (jobInv && req.method === "GET") {
425
+ const slug = decodeURIComponent(jobInv[1]);
426
+ const limitParam = url.searchParams.get("limit");
427
+ const limit = limitParam ? parseInt(limitParam, 10) : 50;
428
+ if (Number.isNaN(limit) || limit <= 0 || limit > 1000) {
429
+ return badRequest("limit must be 1..1000");
430
+ }
431
+ return json(listInvocations(opts.consumerRoot, slug, limit));
432
+ }
433
+
434
+ if (pathname === "/api/invocations" && req.method === "GET") {
435
+ const limitParam = url.searchParams.get("limit");
436
+ const limit = limitParam ? parseInt(limitParam, 10) : 100;
437
+ if (Number.isNaN(limit) || limit <= 0 || limit > 1000) {
438
+ return badRequest("limit must be 1..1000");
439
+ }
440
+ return json(listAllInvocations(opts.consumerRoot, limit));
441
+ }
442
+
443
+ const invDetail = pathname.match(/^\/api\/invocations\/(\d+)$/);
444
+ if (invDetail && req.method === "GET") {
445
+ const id = parseInt(invDetail[1], 10);
446
+ const row = getInvocation(opts.consumerRoot, id);
447
+ return row ? json(row) : notFound("invocation not found");
448
+ }
449
+
450
+ const invLog = pathname.match(/^\/api\/invocations\/(\d+)\/log$/);
451
+ if (invLog && req.method === "GET") {
452
+ const id = parseInt(invLog[1], 10);
453
+ return serveLog(opts.consumerRoot, id, req.headers.get("range"));
454
+ }
455
+
456
+ if (pathname.startsWith("/api/")) return notFound("unknown api route");
457
+
458
+ // --- Static + SPA ---
459
+ const staticHit = await serveStatic(pathname);
460
+ if (staticHit) return staticHit;
461
+ return spaFallback();
462
+ },
463
+ error: (err) => {
464
+ console.error("[cronfish ui]", err);
465
+ return new Response(`server error: ${err.message}`, { status: 500 });
466
+ },
467
+ });
468
+ return `http://${server.hostname}:${server.port}`;
469
+ }
@@ -0,0 +1,201 @@
1
+ // Watchdog: detect crons that should have fired but didn't.
2
+ //
3
+ // One alert per miss window: after firing a `missed` for a job, stay quiet
4
+ // until the job runs successfully again (which resets the baseline by way of
5
+ // last_ok advancing past any earlier `missed` row).
6
+ //
7
+ // Cold-start: jobs with zero successful runs are skipped — brand-new crons
8
+ // don't ping until they've succeeded at least once.
9
+
10
+ import { Database } from "bun:sqlite";
11
+ import { join } from "node:path";
12
+ import {
13
+ buildRegistry,
14
+ loadConsumerAlertsConfig,
15
+ safeNotify,
16
+ type AlertPayload,
17
+ } from "./alerts/index.ts";
18
+ import { chooseAdapterName, buildUiUrl } from "./alerts/dispatch.ts";
19
+ import {
20
+ getJobIdBySlug,
21
+ getLastOkStartedAt,
22
+ getLatestMissedFiredAt,
23
+ listEnabledJobs,
24
+ openDb,
25
+ recordMissedAlert,
26
+ } from "./db.ts";
27
+ import { discoverJobs } from "./jobs.ts";
28
+ import {
29
+ intervalSecondsAt,
30
+ nextFireAfter,
31
+ parseMissedAfter,
32
+ } from "./schedule.ts";
33
+
34
+ const DEFAULT_GRACE_FLOOR_S = 600;
35
+
36
+ export interface WatchdogDecision {
37
+ slug: string;
38
+ outcome: "skipped-cold" | "skipped-manual" | "skipped-on-time" |
39
+ "skipped-already-fired" | "skipped-no-adapter" |
40
+ "fired" | "fire-failed";
41
+ expected_at?: string;
42
+ grace_s?: number;
43
+ error?: string;
44
+ }
45
+
46
+ export interface WatchdogInput {
47
+ consumerRoot: string;
48
+ now?: Date;
49
+ }
50
+
51
+ export async function runWatchdog(
52
+ input: WatchdogInput,
53
+ ): Promise<WatchdogDecision[]> {
54
+ const now = input.now ?? new Date();
55
+ const cronDir = join(input.consumerRoot, "cron");
56
+ const { jobs } = discoverJobs(cronDir);
57
+ const jobBySlug = new Map(jobs.map((j) => [j.slug, j]));
58
+
59
+ const db = openDb(input.consumerRoot);
60
+ const cfg = loadConsumerAlertsConfig(input.consumerRoot);
61
+ const registry = buildRegistry(cfg.alerts);
62
+ const decisions: WatchdogDecision[] = [];
63
+
64
+ try {
65
+ const rows = listEnabledJobs(db);
66
+ for (const row of rows) {
67
+ const job = jobBySlug.get(row.slug);
68
+ if (!job) continue;
69
+ if (row.schedule === "manual") {
70
+ decisions.push({ slug: row.slug, outcome: "skipped-manual" });
71
+ continue;
72
+ }
73
+ const lastOk = getLastOkStartedAt(db, row.id);
74
+ if (!lastOk) {
75
+ decisions.push({ slug: row.slug, outcome: "skipped-cold" });
76
+ continue;
77
+ }
78
+ const lastOkDate = new Date(lastOk);
79
+ const expected = nextFireAfter(job.schedule, lastOkDate);
80
+ if (!expected) {
81
+ decisions.push({ slug: row.slug, outcome: "skipped-manual" });
82
+ continue;
83
+ }
84
+ const intervalS = intervalSecondsAt(job.schedule, expected) ?? 60;
85
+ const overrideS = parseMissedAfter(job.missed_after);
86
+ const graceS = overrideS ?? Math.max(2 * intervalS, DEFAULT_GRACE_FLOOR_S);
87
+ const deadline = new Date(expected.getTime() + graceS * 1000);
88
+ if (now < deadline) {
89
+ decisions.push({
90
+ slug: row.slug,
91
+ outcome: "skipped-on-time",
92
+ expected_at: expected.toISOString(),
93
+ grace_s: graceS,
94
+ });
95
+ continue;
96
+ }
97
+ const lastMissed = getLatestMissedFiredAt(db, row.id);
98
+ if (lastMissed && new Date(lastMissed) > lastOkDate) {
99
+ decisions.push({
100
+ slug: row.slug,
101
+ outcome: "skipped-already-fired",
102
+ expected_at: expected.toISOString(),
103
+ grace_s: graceS,
104
+ });
105
+ continue;
106
+ }
107
+ const adapterName = chooseAdapterName(job.on_failure, cfg.alerts);
108
+ if (!adapterName || !registry.has(adapterName)) {
109
+ decisions.push({
110
+ slug: row.slug,
111
+ outcome: "skipped-no-adapter",
112
+ expected_at: expected.toISOString(),
113
+ grace_s: graceS,
114
+ });
115
+ continue;
116
+ }
117
+ const payload: AlertPayload = {
118
+ slug: row.slug,
119
+ status: "missed",
120
+ exit_code: null,
121
+ duration_ms: null,
122
+ started_at: expected.toISOString(),
123
+ log_tail: `expected at ${expected.toISOString()}, grace ${graceS}s, last ok ${lastOk}`,
124
+ ui_url: jobUiUrl(cfg, row.slug),
125
+ };
126
+ const outcome = await safeNotify(registry.get(adapterName), payload);
127
+ if (outcome.status === "sent") {
128
+ recordMissedAlert(db, row.id, expected.toISOString());
129
+ decisions.push({
130
+ slug: row.slug,
131
+ outcome: "fired",
132
+ expected_at: expected.toISOString(),
133
+ grace_s: graceS,
134
+ });
135
+ } else {
136
+ decisions.push({
137
+ slug: row.slug,
138
+ outcome: "fire-failed",
139
+ expected_at: expected.toISOString(),
140
+ grace_s: graceS,
141
+ error: outcome.error ?? "unknown",
142
+ });
143
+ }
144
+ }
145
+ } finally {
146
+ try {
147
+ db.close();
148
+ } catch {}
149
+ }
150
+ return decisions;
151
+ }
152
+
153
+ function jobUiUrl(
154
+ cfg: ReturnType<typeof loadConsumerAlertsConfig>,
155
+ slug: string,
156
+ ): string | null {
157
+ const base = cfg.ui?.public_url?.trim();
158
+ if (!base) return null;
159
+ return `${base.replace(/\/$/, "")}/jobs/${encodeURIComponent(slug)}`;
160
+ }
161
+
162
+ // Pure decision helper for unit tests — no DB / no IO.
163
+ export interface DecisionInput {
164
+ now: Date;
165
+ schedule: string | number | undefined;
166
+ lastOk: string | null;
167
+ lastMissedFiredAt: string | null;
168
+ missedAfter?: string;
169
+ adapterConfigured: boolean;
170
+ }
171
+
172
+ export function decideWatchdog(input: DecisionInput): {
173
+ outcome: WatchdogDecision["outcome"];
174
+ expected?: Date;
175
+ grace_s?: number;
176
+ } {
177
+ if (input.schedule === "manual" || input.schedule === undefined) {
178
+ return { outcome: "skipped-manual" };
179
+ }
180
+ if (!input.lastOk) return { outcome: "skipped-cold" };
181
+ const lastOkDate = new Date(input.lastOk);
182
+ const expected = nextFireAfter(input.schedule, lastOkDate);
183
+ if (!expected) return { outcome: "skipped-manual" };
184
+ const intervalS = intervalSecondsAt(input.schedule, expected) ?? 60;
185
+ const overrideS = parseMissedAfter(input.missedAfter);
186
+ const graceS = overrideS ?? Math.max(2 * intervalS, DEFAULT_GRACE_FLOOR_S);
187
+ const deadline = new Date(expected.getTime() + graceS * 1000);
188
+ if (input.now < deadline) {
189
+ return { outcome: "skipped-on-time", expected, grace_s: graceS };
190
+ }
191
+ if (
192
+ input.lastMissedFiredAt &&
193
+ new Date(input.lastMissedFiredAt) > lastOkDate
194
+ ) {
195
+ return { outcome: "skipped-already-fired", expected, grace_s: graceS };
196
+ }
197
+ if (!input.adapterConfigured) {
198
+ return { outcome: "skipped-no-adapter", expected, grace_s: graceS };
199
+ }
200
+ return { outcome: "fired", expected, grace_s: graceS };
201
+ }
@@ -0,0 +1,10 @@
1
+ ---
2
+ description: "Smoke test for cron/one-time/ — fires once at +10s and writes a tempfile"
3
+ run_at: "+10s"
4
+ model: haiku
5
+ timeout: 30
6
+ ---
7
+
8
+ Append the line `hello from one-time` to `/tmp/cronfish-one-time-smoke.txt` (create the file if needed). Then print exactly:
9
+
10
+ `__CRONFISH_RESULT_V1__::{"summary":"echo-at fired","ok":true}`