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/runner.ts ADDED
@@ -0,0 +1,827 @@
1
+ #!/usr/bin/env bun
2
+ // Cronfish runner. Invoked by launchd as:
3
+ // /usr/bin/env bun <runner.ts> <abs-path-to-job-file>
4
+ //
5
+ // Bun auto-loads .env from cwd (set to consumer root via plist
6
+ // WorkingDirectory), so no shell pre-step is needed.
7
+ //
8
+ // Per-run log: <consumer>/.cronfish/logs/<slug>/<invocation-id>.log
9
+ // Concurrency lock: <consumer>/.cronfish/locks/<slug>/runner.pid (atomic O_EXCL).
10
+ // Ledger DB: <consumer>/.cronfish/db.sqlite (failure-safe — DB errors warn
11
+ // once and never block the run).
12
+
13
+ import {
14
+ closeSync,
15
+ existsSync,
16
+ mkdirSync,
17
+ openSync,
18
+ readFileSync,
19
+ rmSync,
20
+ writeSync,
21
+ } from "node:fs";
22
+ import { extname, join, resolve } from "node:path";
23
+ import type { Database } from "bun:sqlite";
24
+ import {
25
+ finishInvocation,
26
+ getJobIdBySlug,
27
+ getPreviousFinishedStatus,
28
+ openDb,
29
+ setInvocationAlert,
30
+ startInvocation,
31
+ upsertJob,
32
+ type AlertLedgerStatus,
33
+ type InvocationResultRow,
34
+ type InvocationStatus,
35
+ type InvocationTrigger,
36
+ } from "./db.ts";
37
+ import { loadJob, slugFromPath, type JobMeta } from "./jobs.ts";
38
+ import { resolveModel, localClaudeEnv } from "./models.ts";
39
+ import {
40
+ archiveOneTime,
41
+ releaseFlock,
42
+ setTsExecutedAt,
43
+ tryFlockExclusive,
44
+ writeAndFsync,
45
+ writeSentinel,
46
+ type FlockHandle,
47
+ } from "./oneTime.ts";
48
+ import { setFrontmatterKey, setShellFrontmatterKey } from "./frontmatter.ts";
49
+ import { parseLastResult } from "./result.ts";
50
+ import {
51
+ alertStatusFor,
52
+ dispatchAlert,
53
+ type DispatchOutcome,
54
+ } from "./alerts/dispatch.ts";
55
+
56
+ const DEFAULT_TIMEOUT_S = 300;
57
+
58
+ const MD_RESULT_FOOTER = `---
59
+ When finished, print exactly one line, then nothing after it:
60
+ __CRONFISH_RESULT_V1__::{"summary":"...","ok":true|false,"metrics":{...}}
61
+ Set ok to false if the work did not complete (errors, blocked, skipped). Summary ≤140 chars. No markdown, no code fences.`;
62
+ const LOCK_POLL_MS = 2_000;
63
+ const KILL_GRACE_MS = 5_000;
64
+
65
+ const CLAUDE_BIN =
66
+ process.env.CLAUDE_BIN ??
67
+ join(process.env.HOME ?? "", ".local", "bin", "claude");
68
+
69
+ function consumerRoot(): string {
70
+ return process.env.CRONFISH_CONSUMER_ROOT || process.cwd();
71
+ }
72
+
73
+ function logsJobDir(slug: string): string {
74
+ const dir = join(consumerRoot(), ".cronfish", "logs", slug);
75
+ mkdirSync(dir, { recursive: true });
76
+ return dir;
77
+ }
78
+
79
+ function locksJobDir(slug: string): string {
80
+ const dir = join(consumerRoot(), ".cronfish", "locks", slug);
81
+ mkdirSync(dir, { recursive: true });
82
+ return dir;
83
+ }
84
+
85
+ function logPathFor(slug: string, invocationId: number | "preinit"): string {
86
+ return join(logsJobDir(slug), `${invocationId}.log`);
87
+ }
88
+
89
+ function appendLog(fd: number, msg: string): void {
90
+ writeSync(fd, msg.endsWith("\n") ? msg : msg + "\n");
91
+ }
92
+
93
+ function warn(msg: string): void {
94
+ console.error(`[runner] WARN: ${msg}`);
95
+ }
96
+
97
+ // --- Concurrency lock (atomic create-or-fail) ---
98
+
99
+ function lockPath(slug: string): string {
100
+ return join(locksJobDir(slug), "runner.pid");
101
+ }
102
+
103
+ function tryAcquireLock(slug: string): boolean {
104
+ const path = lockPath(slug);
105
+ try {
106
+ const fd = openSync(path, "wx");
107
+ writeSync(fd, String(process.pid));
108
+ closeSync(fd);
109
+ return true;
110
+ } catch {
111
+ if (!existsSync(path)) return false;
112
+ const pid = parseInt(readFileSync(path, "utf-8").trim(), 10);
113
+ if (Number.isNaN(pid)) {
114
+ safeRm(path);
115
+ return tryAcquireLock(slug);
116
+ }
117
+ try {
118
+ process.kill(pid, 0);
119
+ return false;
120
+ } catch {
121
+ safeRm(path);
122
+ return tryAcquireLock(slug);
123
+ }
124
+ }
125
+ }
126
+
127
+ function releaseLock(slug: string): void {
128
+ safeRm(lockPath(slug));
129
+ }
130
+
131
+ function safeRm(path: string): void {
132
+ try {
133
+ rmSync(path);
134
+ } catch {}
135
+ }
136
+
137
+ async function waitForLock(
138
+ slug: string,
139
+ mode: "skip" | "queue",
140
+ timeoutS: number,
141
+ ): Promise<boolean> {
142
+ if (tryAcquireLock(slug)) return true;
143
+ if (mode === "skip") return false;
144
+ const deadline = Date.now() + timeoutS * 1000;
145
+ while (Date.now() < deadline) {
146
+ await Bun.sleep(LOCK_POLL_MS);
147
+ if (tryAcquireLock(slug)) return true;
148
+ }
149
+ return false;
150
+ }
151
+
152
+ // --- Spawn helpers ---
153
+
154
+ interface SpawnSpec {
155
+ cmd: string[];
156
+ cwd: string;
157
+ stdin?: "ignore" | "pipe";
158
+ stdinPayload?: string;
159
+ env?: Record<string, string>;
160
+ }
161
+
162
+ interface SpawnResult {
163
+ code: number;
164
+ timedOut: boolean;
165
+ }
166
+
167
+ async function runSpawn(
168
+ spec: SpawnSpec,
169
+ fd: number,
170
+ timeoutS: number,
171
+ ): Promise<SpawnResult> {
172
+ const proc = Bun.spawn(spec.cmd, {
173
+ cwd: spec.cwd,
174
+ stdout: fd,
175
+ stderr: fd,
176
+ stdin: spec.stdin ?? "ignore",
177
+ env: spec.env
178
+ ? { ...(process.env as Record<string, string>), ...spec.env }
179
+ : undefined,
180
+ // @ts-expect-error — Bun supports detached on spawn options
181
+ detached: true,
182
+ });
183
+ if (spec.stdin === "pipe" && spec.stdinPayload && proc.stdin) {
184
+ const writer = proc.stdin as unknown as {
185
+ write: (s: string) => void;
186
+ end: () => void;
187
+ };
188
+ writer.write(spec.stdinPayload);
189
+ writer.end();
190
+ }
191
+
192
+ let timedOut = false;
193
+ const timer = setTimeout(() => {
194
+ timedOut = true;
195
+ appendLog(
196
+ fd,
197
+ `\n[runner] timeout after ${timeoutS}s — killing process group`,
198
+ );
199
+ killTree(proc.pid);
200
+ }, timeoutS * 1000);
201
+
202
+ const code = await proc.exited;
203
+ clearTimeout(timer);
204
+ return { code: timedOut ? 124 : code, timedOut };
205
+ }
206
+
207
+ function killTree(pid: number | undefined): void {
208
+ if (pid === undefined) return;
209
+ try {
210
+ process.kill(-pid, "SIGTERM");
211
+ } catch {
212
+ try {
213
+ process.kill(pid, "SIGTERM");
214
+ } catch {}
215
+ }
216
+ setTimeout(() => {
217
+ try {
218
+ process.kill(-pid, "SIGKILL");
219
+ } catch {
220
+ try {
221
+ process.kill(pid, "SIGKILL");
222
+ } catch {}
223
+ }
224
+ }, KILL_GRACE_MS).unref?.();
225
+ }
226
+
227
+ // --- Per-kind execution ---
228
+
229
+ // Custom runner registry loaded from .cronfish.json. Each entry maps a
230
+ // frontmatter `runner:` name to an executable script (resolved relative
231
+ // to consumer root). When a .md job declares `runner: <name>`, we spawn
232
+ // that script with the .md path as argv[2] instead of the default claude
233
+ // CLI path. The script is responsible for parsing the frontmatter + body,
234
+ // driving the model, and printing the __CRONFISH_RESULT_V1__ line.
235
+ //
236
+ // Failure-safe: malformed runners config logs a warning and falls back to
237
+ // claude CLI for that run. A typo in .cronfish.json shouldn't brick every
238
+ // .md cron.
239
+
240
+ interface RunnerSpec {
241
+ path: string;
242
+ }
243
+
244
+ function loadRunners(): Record<string, RunnerSpec> {
245
+ const cfgPath = join(consumerRoot(), ".cronfish.json");
246
+ if (!existsSync(cfgPath)) return {};
247
+ try {
248
+ const raw = JSON.parse(readFileSync(cfgPath, "utf-8")) as {
249
+ runners?: Record<string, { path?: string }>;
250
+ };
251
+ const out: Record<string, RunnerSpec> = {};
252
+ for (const [name, spec] of Object.entries(raw.runners ?? {})) {
253
+ if (!spec?.path || typeof spec.path !== "string") continue;
254
+ out[name] = { path: spec.path };
255
+ }
256
+ return out;
257
+ } catch (e) {
258
+ warn(`runners config: ${(e as Error).message}`);
259
+ return {};
260
+ }
261
+ }
262
+
263
+ async function execMarkdownCustomRunner(
264
+ job: JobMeta,
265
+ spec: RunnerSpec,
266
+ fd: number,
267
+ timeoutS: number,
268
+ ): Promise<SpawnResult> {
269
+ const runnerPath = resolve(consumerRoot(), spec.path);
270
+ appendLog(
271
+ fd,
272
+ `[runner] kind=md runner=${job.runner} path=${runnerPath} timeout=${timeoutS}s`,
273
+ );
274
+ if (!existsSync(runnerPath)) {
275
+ appendLog(
276
+ fd,
277
+ `[runner] ERROR: runner script not found at ${runnerPath} — check .cronfish.json runners.${job.runner}.path`,
278
+ );
279
+ return { code: 1, timedOut: false };
280
+ }
281
+ return runSpawn(
282
+ { cmd: ["bun", runnerPath, job.path], cwd: consumerRoot() },
283
+ fd,
284
+ timeoutS,
285
+ );
286
+ }
287
+
288
+ // Build the `claude` CLI argv for a .md job. Pure (no I/O) so it's unit
289
+ // testable. Default posture is `--dangerously-skip-permissions` (backward
290
+ // compatible). When the job declares `allowed_tools`, swap to a capability
291
+ // fence: `--permission-mode default --allowedTools <list>` — off-list tools
292
+ // auto-deny in headless `-p` mode.
293
+ // Mutating built-in tools denied by `read_only:`. Reading/searching and MCP
294
+ // tools stay available; MCP sends must be fenced via `allowed_tools`.
295
+ const READ_ONLY_DENY = ["Write", "Edit", "NotebookEdit", "Bash"];
296
+
297
+ export function buildClaudeArgs(
298
+ claudeBin: string,
299
+ job: Pick<JobMeta, "allowed_tools" | "max_cost" | "read_only">,
300
+ modelId: string,
301
+ prompt: string,
302
+ ): string[] {
303
+ const cmd = [claudeBin];
304
+ if (job.allowed_tools) {
305
+ cmd.push("--permission-mode", "default");
306
+ cmd.push("--allowedTools", ...job.allowed_tools);
307
+ } else {
308
+ cmd.push("--dangerously-skip-permissions");
309
+ }
310
+ if (job.read_only) {
311
+ // Hard-remove the mutating built-ins. `--disallowedTools` wins over both
312
+ // skip-permissions and an `--allowedTools` overlap, so this holds under
313
+ // either posture.
314
+ cmd.push("--disallowedTools", ...READ_ONLY_DENY);
315
+ }
316
+ if (job.max_cost !== undefined) {
317
+ // CLI stops making API calls once the budget is hit (works with -p/--print).
318
+ cmd.push("--max-budget-usd", String(job.max_cost));
319
+ }
320
+ cmd.push("--model", modelId, "-p", prompt);
321
+ return cmd;
322
+ }
323
+
324
+ async function execMarkdown(
325
+ job: JobMeta,
326
+ fd: number,
327
+ timeoutS: number,
328
+ ): Promise<SpawnResult> {
329
+ if (job.runner) {
330
+ const runners = loadRunners();
331
+ const spec = runners[job.runner];
332
+ if (spec) return execMarkdownCustomRunner(job, spec, fd, timeoutS);
333
+ // Hard-fail rather than fall back to claude CLI. The cron's prompt
334
+ // is shaped for a specific runner; sending an ai-sdk-shaped prompt
335
+ // (with tool-call protocol the CLI doesn't speak) through claude CLI
336
+ // can corrupt vault files. Better to alert than to silently misroute.
337
+ const known = Object.keys(runners).join(", ") || "(none)";
338
+ appendLog(
339
+ fd,
340
+ `[runner] ERROR: runner "${job.runner}" not in .cronfish.json#runners — known: ${known}. Refusing to fall back to claude CLI.`,
341
+ );
342
+ return { code: 2, timedOut: false };
343
+ }
344
+ const raw = await Bun.file(job.path).text();
345
+ const { parseFrontmatter } = await import("./frontmatter.ts");
346
+ const { body } = parseFrontmatter(raw);
347
+ const model = resolveModel(job.model);
348
+ const prompt = body.trim() + "\n\n" + MD_RESULT_FOOTER;
349
+ appendLog(
350
+ fd,
351
+ `[runner] kind=md model=${model.provider}:${model.id} timeout=${timeoutS}s`,
352
+ );
353
+ if (job.allowed_tools) {
354
+ appendLog(
355
+ fd,
356
+ `[runner] permission fence: allowedTools=[${job.allowed_tools.join(", ")}]`,
357
+ );
358
+ }
359
+ if (job.max_cost !== undefined) {
360
+ appendLog(fd, `[runner] budget cap: max_cost=$${job.max_cost}`);
361
+ }
362
+ if (job.read_only) {
363
+ appendLog(fd, `[runner] read-only: deny [${READ_ONLY_DENY.join(", ")}]`);
364
+ }
365
+ const cmd = buildClaudeArgs(CLAUDE_BIN, job, model.id, prompt);
366
+ const env = model.provider === "local" ? localClaudeEnv(model.id) : undefined;
367
+ if (env) {
368
+ appendLog(
369
+ fd,
370
+ `[runner] local base_url=${env.ANTHROPIC_BASE_URL} model=${model.id}`,
371
+ );
372
+ }
373
+ return runSpawn({ cmd, cwd: consumerRoot(), env }, fd, timeoutS);
374
+ }
375
+
376
+ async function execTypescript(
377
+ job: JobMeta,
378
+ fd: number,
379
+ timeoutS: number,
380
+ ): Promise<SpawnResult> {
381
+ const shim = resolve(import.meta.dir, "ts-shim.ts");
382
+ appendLog(fd, `[runner] kind=ts file=${job.path} timeout=${timeoutS}s`);
383
+ return runSpawn(
384
+ { cmd: ["bun", shim, job.path], cwd: consumerRoot() },
385
+ fd,
386
+ timeoutS,
387
+ );
388
+ }
389
+
390
+ async function execShell(
391
+ job: JobMeta,
392
+ fd: number,
393
+ timeoutS: number,
394
+ ): Promise<SpawnResult> {
395
+ appendLog(fd, `[runner] kind=sh file=${job.path} timeout=${timeoutS}s`);
396
+ return runSpawn(
397
+ { cmd: ["/bin/bash", job.path], cwd: consumerRoot() },
398
+ fd,
399
+ timeoutS,
400
+ );
401
+ }
402
+
403
+ async function execOnce(
404
+ job: JobMeta,
405
+ fd: number,
406
+ timeoutS: number,
407
+ ): Promise<SpawnResult> {
408
+ try {
409
+ if (job.kind === "md") return await execMarkdown(job, fd, timeoutS);
410
+ if (job.kind === "sh") return await execShell(job, fd, timeoutS);
411
+ return await execTypescript(job, fd, timeoutS);
412
+ } catch (e) {
413
+ appendLog(
414
+ fd,
415
+ `[runner] ERROR: ${(e as Error).stack ?? (e as Error).message}`,
416
+ );
417
+ return { code: 1, timedOut: false };
418
+ }
419
+ }
420
+
421
+ // --- Ledger helpers (failure-safe) ---
422
+
423
+ function tryOpenDb(): Database | null {
424
+ try {
425
+ return openDb(consumerRoot());
426
+ } catch (e) {
427
+ warn(`open ledger DB failed: ${(e as Error).message}`);
428
+ return null;
429
+ }
430
+ }
431
+
432
+ function tryStartInvocation(
433
+ db: Database | null,
434
+ job: JobMeta,
435
+ trigger: InvocationTrigger,
436
+ logPath: string,
437
+ ): number | null {
438
+ if (!db) return null;
439
+ try {
440
+ upsertJob(db, job);
441
+ const jobId = getJobIdBySlug(db, job.slug);
442
+ if (jobId === null) return null;
443
+ return startInvocation(db, jobId, trigger, logPath);
444
+ } catch (e) {
445
+ warn(`startInvocation failed: ${(e as Error).message}`);
446
+ return null;
447
+ }
448
+ }
449
+
450
+ function tryFinishInvocation(
451
+ db: Database | null,
452
+ invocationId: number | null,
453
+ status: InvocationStatus,
454
+ exitCode: number | null,
455
+ result?: InvocationResultRow,
456
+ ): void {
457
+ if (!db || invocationId === null) return;
458
+ try {
459
+ finishInvocation(db, invocationId, status, exitCode, result);
460
+ } catch (e) {
461
+ warn(`finishInvocation failed: ${(e as Error).message}`);
462
+ }
463
+ }
464
+
465
+ function trySetAlert(
466
+ db: Database | null,
467
+ invocationId: number | null,
468
+ status: AlertLedgerStatus,
469
+ error: string | null,
470
+ ): void {
471
+ if (!db || invocationId === null) return;
472
+ try {
473
+ setInvocationAlert(db, invocationId, status, error);
474
+ } catch (e) {
475
+ warn(`setInvocationAlert failed: ${(e as Error).message}`);
476
+ }
477
+ }
478
+
479
+ function tryPrevStatus(
480
+ db: Database | null,
481
+ jobSlug: string,
482
+ invocationId: number,
483
+ ): InvocationStatus | null {
484
+ if (!db) return null;
485
+ try {
486
+ const jobId = getJobIdBySlug(db, jobSlug);
487
+ if (jobId === null) return null;
488
+ return getPreviousFinishedStatus(db, jobId, invocationId);
489
+ } catch (e) {
490
+ warn(`getPreviousFinishedStatus failed: ${(e as Error).message}`);
491
+ return null;
492
+ }
493
+ }
494
+
495
+ function outcomeToLedger(o: DispatchOutcome): {
496
+ status: AlertLedgerStatus;
497
+ error: string | null;
498
+ } {
499
+ if (o.kind === "sent") return { status: "sent", error: null };
500
+ if (o.kind === "error") return { status: "error", error: o.error };
501
+ return { status: "skipped", error: null };
502
+ }
503
+
504
+ const FAILURE_STATUSES = new Set<InvocationStatus>([
505
+ "fail",
506
+ "timeout",
507
+ "crashed",
508
+ ]);
509
+
510
+ async function tryParseResult(
511
+ logPath: string,
512
+ exitCode: number,
513
+ ): Promise<InvocationResultRow> {
514
+ try {
515
+ const { result, truncated } = await parseLastResult(logPath);
516
+ if (!result) return { summary: null, ok: null, json: null, truncated };
517
+ const ok = result.ok ?? exitCode === 0;
518
+ return {
519
+ summary: result.summary,
520
+ ok,
521
+ json: JSON.stringify(result),
522
+ truncated,
523
+ };
524
+ } catch (e) {
525
+ warn(`parseLastResult failed: ${(e as Error).message}`);
526
+ return { summary: null, ok: null, json: null, truncated: false };
527
+ }
528
+ }
529
+
530
+ // --- One-shot completion ---
531
+ //
532
+ // On any termination of a one-time job (success or failure), stamp
533
+ // `executed_at: <ISO>` into the source file's frontmatter, fsync, and
534
+ // move the file to ~/Library/Application Support/cronfish/done/. The
535
+ // flock + executed_at re-fire guard depends on this write landing before
536
+ // the file is archived. Failures here surface as sentinels but never
537
+ // crash the runner.
538
+
539
+ function stampExecutedAt(job: JobMeta, iso: string): void {
540
+ if (!existsSync(job.path)) return;
541
+ const raw = readFileSync(job.path, "utf-8");
542
+ let next: string;
543
+ if (job.kind === "md") {
544
+ next = setFrontmatterKey(raw, "executed_at", iso);
545
+ } else if (job.kind === "sh") {
546
+ next = setShellFrontmatterKey(raw, "executed_at", iso);
547
+ } else {
548
+ next = setTsExecutedAt(raw, iso);
549
+ }
550
+ writeAndFsync(job.path, next);
551
+ }
552
+
553
+ function completeOneTime(job: JobMeta, fd: number): void {
554
+ if (!job.oneTime) return;
555
+ const iso = new Date().toISOString();
556
+ try {
557
+ stampExecutedAt(job, iso);
558
+ } catch (e) {
559
+ appendLog(fd, `[runner] one-time: stampExecutedAt failed: ${(e as Error).message}`);
560
+ try {
561
+ writeSentinel(
562
+ join(consumerRoot(), "cron"),
563
+ job.slug,
564
+ `stampExecutedAt failed: ${(e as Error).message}`,
565
+ );
566
+ } catch {}
567
+ return;
568
+ }
569
+ try {
570
+ const dest = archiveOneTime(job.path);
571
+ appendLog(fd, `[runner] one-time: archived to ${dest}`);
572
+ } catch (e) {
573
+ appendLog(fd, `[runner] one-time: archive failed: ${(e as Error).message}`);
574
+ try {
575
+ writeSentinel(
576
+ join(consumerRoot(), "cron"),
577
+ job.slug,
578
+ `archive failed: ${(e as Error).message}`,
579
+ );
580
+ } catch {}
581
+ }
582
+ }
583
+
584
+ // --- Top-level orchestration ---
585
+
586
+ async function main(): Promise<void> {
587
+ const jobPath = process.argv[2];
588
+ if (!jobPath) {
589
+ console.error("usage: runner.ts <abs-path-to-job-file>");
590
+ process.exit(2);
591
+ }
592
+ const abs = resolve(jobPath);
593
+ if (!existsSync(abs)) {
594
+ console.error(`runner: job file not found: ${abs}`);
595
+ process.exit(2);
596
+ }
597
+ const ext = extname(abs);
598
+ if (ext !== ".md" && ext !== ".ts" && ext !== ".sh") {
599
+ console.error(`runner: unsupported extension ${ext}`);
600
+ process.exit(2);
601
+ }
602
+
603
+ const cronDir = join(consumerRoot(), "cron");
604
+ const slug = existsSync(cronDir) ? slugFromPath(cronDir, abs) : undefined;
605
+ const job = loadJob(abs, slug, cronDir);
606
+ const timeoutS = job.timeout ?? DEFAULT_TIMEOUT_S;
607
+ const retries = job.retries ?? 0;
608
+ const trigger: InvocationTrigger =
609
+ (process.env.CRONFISH_TRIGGER as InvocationTrigger | undefined) ??
610
+ "schedule";
611
+
612
+ // One-time re-fire guard: flock the source file + re-check executed_at.
613
+ // Both checks must happen before any work, so launchd retries / system
614
+ // unsleep / crash recovery can't double-fire the job. Lock is held for
615
+ // the lifetime of this process; release is implicit on exit.
616
+ let oneTimeLock: FlockHandle | null = null;
617
+ if (job.oneTime) {
618
+ if (job.executedAt) {
619
+ console.log(
620
+ `[runner] one-time: ${job.slug} already executed at ${job.executedAt} — exit`,
621
+ );
622
+ process.exit(0);
623
+ }
624
+ oneTimeLock = tryFlockExclusive(abs);
625
+ if (!oneTimeLock) {
626
+ console.log(
627
+ `[runner] one-time: ${job.slug} lock held by another process — exit`,
628
+ );
629
+ process.exit(0);
630
+ }
631
+ // Re-parse under lock — the file could have been stamped between
632
+ // discovery and lock acquisition.
633
+ try {
634
+ const fresh = loadJob(abs, slug, cronDir);
635
+ if (fresh.executedAt) {
636
+ console.log(
637
+ `[runner] one-time: ${job.slug} stamped under-lock at ${fresh.executedAt} — exit`,
638
+ );
639
+ releaseFlock(oneTimeLock);
640
+ process.exit(0);
641
+ }
642
+ } catch {
643
+ // re-parse failed; proceed with the original meta.
644
+ }
645
+ }
646
+
647
+ if (job.concurrency) {
648
+ const got = await waitForLock(job.slug, job.concurrency, timeoutS);
649
+ if (!got) {
650
+ const reason =
651
+ job.concurrency === "skip"
652
+ ? "already running — skipping"
653
+ : "timed out waiting for previous run";
654
+ console.log(`[runner] concurrency=${job.concurrency}: ${reason}`);
655
+ process.exit(0);
656
+ }
657
+ }
658
+
659
+ const db = tryOpenDb();
660
+
661
+ // Open the log file BEFORE we know the invocation id (we need a real
662
+ // path to record). The DB row references the file path; if DB write
663
+ // failed, the file still lands at preinit-<ts>.log so the run is
664
+ // never blocked.
665
+ const tsTag = new Date().toISOString().replace(/:/g, "-");
666
+ const provisionalPath = logPathFor(job.slug, `preinit-${tsTag}`);
667
+ const invocationId = tryStartInvocation(db, job, trigger, provisionalPath);
668
+ const logFile =
669
+ invocationId !== null
670
+ ? logPathFor(job.slug, invocationId)
671
+ : provisionalPath;
672
+ if (invocationId !== null && logFile !== provisionalPath && db) {
673
+ try {
674
+ db.prepare(
675
+ "UPDATE cron_invocations SET log_path = $p WHERE id = $id",
676
+ ).run({ $p: logFile, $id: invocationId });
677
+ } catch (e) {
678
+ warn(`update log_path failed: ${(e as Error).message}`);
679
+ }
680
+ }
681
+
682
+ console.log(`[runner] logging to ${logFile}`);
683
+ const fd = openSync(logFile, "a");
684
+ appendLog(fd, `[runner] slug=${job.slug} start ${new Date().toISOString()}`);
685
+ appendLog(fd, `[runner] cwd=${consumerRoot()}`);
686
+ if (invocationId !== null) {
687
+ appendLog(fd, `[runner] invocation_id=${invocationId} trigger=${trigger}`);
688
+ }
689
+
690
+ // Signal handlers — release lock + record crash on launchd shutdown.
691
+ let releasing = false;
692
+ const cleanup = (sig: NodeJS.Signals): void => {
693
+ if (releasing) return;
694
+ releasing = true;
695
+ tryFinishInvocation(
696
+ db,
697
+ invocationId,
698
+ "crashed",
699
+ sig === "SIGTERM" ? 143 : 130,
700
+ );
701
+ if (job.concurrency) releaseLock(job.slug);
702
+ if (oneTimeLock) releaseFlock(oneTimeLock);
703
+ process.exit(sig === "SIGTERM" ? 143 : 130);
704
+ };
705
+ process.on("SIGTERM", cleanup);
706
+ process.on("SIGINT", cleanup);
707
+
708
+ const start = Date.now();
709
+ let lastResult: SpawnResult = { code: 1, timedOut: false };
710
+ let crashed = false;
711
+ try {
712
+ for (let attempt = 0; attempt <= retries; attempt++) {
713
+ if (attempt > 0) {
714
+ const delay = Math.min(5 * Math.pow(3, attempt - 1), 60);
715
+ appendLog(
716
+ fd,
717
+ `\n[runner] retry ${attempt}/${retries} — waiting ${delay}s`,
718
+ );
719
+ await Bun.sleep(delay * 1000);
720
+ }
721
+ lastResult = await execOnce(job, fd, timeoutS);
722
+ if (lastResult.code === 0) break;
723
+ }
724
+ } catch (e) {
725
+ crashed = true;
726
+ appendLog(
727
+ fd,
728
+ `\n[runner] CRASH: ${(e as Error).stack ?? (e as Error).message}`,
729
+ );
730
+ } finally {
731
+ const dur = ((Date.now() - start) / 1000).toFixed(1);
732
+ appendLog(fd, `\n[runner] exit=${lastResult.code} duration=${dur}s`);
733
+ // One-time completion: write executed_at + archive BEFORE we close the
734
+ // log fd, so the archive line lands in the log. Then release flock.
735
+ if (job.oneTime) {
736
+ completeOneTime(job, fd);
737
+ if (oneTimeLock) releaseFlock(oneTimeLock);
738
+ }
739
+ closeSync(fd);
740
+ if (job.concurrency) releaseLock(job.slug);
741
+ const status: InvocationStatus = crashed
742
+ ? "crashed"
743
+ : lastResult.timedOut
744
+ ? "timeout"
745
+ : lastResult.code === 0
746
+ ? "ok"
747
+ : "fail";
748
+ const resultRow = await tryParseResult(logFile, lastResult.code);
749
+ tryFinishInvocation(db, invocationId, status, lastResult.code, resultRow);
750
+ await maybeFireAlert({
751
+ db,
752
+ job,
753
+ invocationId,
754
+ status,
755
+ trigger,
756
+ exitCode: lastResult.code,
757
+ durationMs: Date.now() - start,
758
+ startedAtIso: new Date(start).toISOString(),
759
+ logPath: logFile,
760
+ });
761
+ try {
762
+ db?.close();
763
+ } catch {}
764
+ }
765
+ process.exit(lastResult.code);
766
+ }
767
+
768
+ interface AlertFireInput {
769
+ db: Database | null;
770
+ job: JobMeta;
771
+ invocationId: number | null;
772
+ status: InvocationStatus;
773
+ trigger: InvocationTrigger;
774
+ exitCode: number | null;
775
+ durationMs: number;
776
+ startedAtIso: string;
777
+ logPath: string;
778
+ }
779
+
780
+ async function maybeFireAlert(input: AlertFireInput): Promise<void> {
781
+ // Manual runs never fire alerts (debugging path).
782
+ if (input.trigger !== "schedule") return;
783
+ if (input.invocationId === null) return;
784
+ const failureStatus = alertStatusFor(input.status);
785
+ const isFailure = failureStatus !== null;
786
+ const isRecovery =
787
+ input.status === "ok" &&
788
+ FAILURE_STATUSES.has(
789
+ tryPrevStatus(input.db, input.job.slug, input.invocationId) ??
790
+ ("ok" as InvocationStatus),
791
+ );
792
+ if (!isFailure && !isRecovery) return;
793
+ try {
794
+ const outcome = await dispatchAlert({
795
+ job: input.job,
796
+ invocationId: input.invocationId,
797
+ invocationStatus: input.status,
798
+ alertStatus: isFailure ? failureStatus : "recovered",
799
+ exitCode: input.exitCode,
800
+ durationMs: input.durationMs,
801
+ startedAt: input.startedAtIso,
802
+ logPath: input.logPath,
803
+ consumerRoot: consumerRoot(),
804
+ });
805
+ if (isRecovery && outcome.kind === "sent") {
806
+ trySetAlert(input.db, input.invocationId, "recovered", null);
807
+ } else {
808
+ const ledger = outcomeToLedger(outcome);
809
+ trySetAlert(input.db, input.invocationId, ledger.status, ledger.error);
810
+ }
811
+ } catch (e) {
812
+ // dispatchAlert is meant to be failure-safe; this catch is a last-resort
813
+ // guard so a runner crash here never blocks the run.
814
+ warn(`maybeFireAlert: ${(e as Error).message}`);
815
+ trySetAlert(input.db, input.invocationId, "error", (e as Error).message);
816
+ }
817
+ }
818
+
819
+ // Guarded so the module can be imported (e.g. by unit tests for the pure
820
+ // helpers above) without launching a run. launchd invokes this file as the
821
+ // program entry, where import.meta.main is true.
822
+ if (import.meta.main) {
823
+ main().catch((e) => {
824
+ console.error("runner: fatal", e);
825
+ process.exit(1);
826
+ });
827
+ }