cronfish 0.12.0 → 0.13.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/README.md +35 -12
- package/package.json +1 -1
- package/src/cli.ts +81 -5
- package/src/config.ts +29 -0
- package/src/frontmatter.ts +27 -1
- package/src/oneTime.ts +92 -7
- package/src/platform/launchd.ts +19 -0
- package/src/runner.ts +55 -0
package/README.md
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
<p align="center"><strong>A file-based job scheduler for macOS. Drop a script in a folder; launchd runs it on a schedule.</strong></p>
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
10
|
+
<a href="https://www.npmjs.com/package/cronfish"><img src="https://img.shields.io/npm/v/cronfish?logo=npm" alt="npm"></a>
|
|
10
11
|
<a href="https://github.com/goldcaddy77/cronfish/actions/workflows/ci.yml"><img src="https://github.com/goldcaddy77/cronfish/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
11
12
|
<img src="https://img.shields.io/badge/runtime-Bun%20%E2%89%A5%201.0-black?logo=bun" alt="Bun >= 1.0">
|
|
12
13
|
<img src="https://img.shields.io/badge/platform-macOS-lightgrey?logo=apple" alt="macOS">
|
|
@@ -172,23 +173,34 @@ Sync behavior:
|
|
|
172
173
|
| ----------------------------- | ------------------------------------------------- |
|
|
173
174
|
| Future | plist installed with `StartCalendarInterval` for the exact minute |
|
|
174
175
|
| Within `grace_seconds` of now | plist installed with `RunAtLoad: true` — fires on bootstrap |
|
|
175
|
-
| Past `grace_seconds` | **refused**; sentinel written
|
|
176
|
+
| Past `grace_seconds` | **refused**; sentinel written + file archived out of `cron/one-time/` |
|
|
176
177
|
| `executed_at:` already set | skipped (file should already be archived) |
|
|
177
178
|
|
|
178
|
-
After firing, the runner stamps `executed_at: <ISO
|
|
179
|
-
`~/Library/Application Support/cronfish/done/`
|
|
180
|
-
|
|
181
|
-
|
|
179
|
+
After firing, the runner stamps `executed_at: <ISO>`, moves the file to
|
|
180
|
+
`~/Library/Application Support/cronfish/done/` (outside the repo, so the audit
|
|
181
|
+
trail doesn't bloat git), and **removes its own plist** so a reboot/login in
|
|
182
|
+
the window before the next `cronfish sync` can't reload and re-fire it. A
|
|
183
|
+
`flock` plus the `executed_at` re-check guard against double-fires.
|
|
182
184
|
|
|
183
|
-
**
|
|
184
|
-
|
|
185
|
+
The runner also **re-checks `grace_seconds` at fire time**: launchd runs a
|
|
186
|
+
`StartCalendarInterval` job once on wake if the machine slept through the
|
|
187
|
+
scheduled minute (a coalesced missed fire), which can land long after `run_at`.
|
|
188
|
+
A fire that arrives past grace is refused (sentinel) instead of running late.
|
|
189
|
+
|
|
190
|
+
**One-time jobs must be idempotent.** The guards above catch the common
|
|
185
191
|
double-fire, but only after the file is stamped. Anything destructive between
|
|
186
192
|
"start" and "stamp" can repeat. Write handlers that tolerate two invocations.
|
|
187
193
|
|
|
188
|
-
**Failure surface — `cron/.errors/`.** Any
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
194
|
+
**Failure surface — `cron/.errors/`.** Any refusal (past-grace, bad YAML,
|
|
195
|
+
missing `run_at`) and any runner-side failure (archive failed, `executed_at`
|
|
196
|
+
write failed) writes a sentinel there with slug, timestamp, and reason. Wire a
|
|
197
|
+
heartbeat cron to alert on non-empty. Two properties keep the folder bounded:
|
|
198
|
+
sentinels **dedup** (the same recurring error overwrites one file, not one per
|
|
199
|
+
sync), and sync-time sentinels **self-heal** — the next clean `cronfish sync`
|
|
200
|
+
clears any whose error no longer occurs. Inspect or clear by hand with
|
|
201
|
+
`cronfish errors` / `cronfish errors --clear [slug]`. cronfish only manages
|
|
202
|
+
files it wrote (`*.cronfish.txt`); a consumer can drop its own sentinels in the
|
|
203
|
+
same folder without them being reaped.
|
|
192
204
|
|
|
193
205
|
Smoke-test template: `templates/_examples/one-time/echo-at.md`.
|
|
194
206
|
|
|
@@ -206,6 +218,11 @@ Smoke-test template: `templates/_examples/one-time/echo-at.md`.
|
|
|
206
218
|
fires them. Use it for scheduling candidates — jobs you're staging in `cron/` before flipping on
|
|
207
219
|
a real schedule. Pure on-demand scripts that aren't scheduling candidates belong outside `cron/`.
|
|
208
220
|
|
|
221
|
+
**Sub-10s schedules don't work.** launchd enforces a ~10s floor between relaunches of the same
|
|
222
|
+
job (its implicit `ThrottleInterval`). A `schedule:` faster than `10s` fires no quicker than every
|
|
223
|
+
10s; `cronfish sync` warns when it sees one. Need true high-frequency work? Run a long-lived loop
|
|
224
|
+
as a single job instead of many fast fires.
|
|
225
|
+
|
|
209
226
|
## Config — `.cronfish.json` (optional, at repo root)
|
|
210
227
|
|
|
211
228
|
```json
|
|
@@ -244,6 +261,7 @@ cronfish enable <slug> flip enabled, then sync
|
|
|
244
261
|
cronfish disable <slug> flip disabled, then sync
|
|
245
262
|
cronfish delete <slug> --yes bootout + remove plist + job file
|
|
246
263
|
cronfish status [slug] launchctl print + tail of latest log
|
|
264
|
+
cronfish errors [--clear] [slug] list error sentinels (cron/.errors/); --clear removes them
|
|
247
265
|
cronfish run <slug> invoke runner directly (no launchd) — for testing
|
|
248
266
|
cronfish watchdog detect missed schedules → fire alerts
|
|
249
267
|
cronfish alerts test [adapter] send a test alert via the named (or default) adapter
|
|
@@ -530,10 +548,15 @@ via plist `WorkingDirectory`), so no shell wrapper is needed.
|
|
|
530
548
|
|
|
531
549
|
- macOS (launchd). Linux (systemd) and Windows (Task Scheduler) are on the backlog.
|
|
532
550
|
- Bun ≥ 1.0.
|
|
551
|
+
- **A logged-in GUI (Aqua) session.** cronfish installs per-user LaunchAgents under
|
|
552
|
+
`~/Library/LaunchAgents`, which only load while the user is logged into the desktop. On a
|
|
553
|
+
headless box reached only over SSH (no console login), agents never load and jobs never fire —
|
|
554
|
+
enable auto-login, or keep a desktop session active. (A system-wide `LaunchDaemon` backend that
|
|
555
|
+
runs without a login session is on the backlog.)
|
|
533
556
|
|
|
534
557
|
## Status
|
|
535
558
|
|
|
536
|
-
v0.
|
|
559
|
+
v0.x — used in production by the author. API may still break before v1. File issues if you hit
|
|
537
560
|
something rough.
|
|
538
561
|
|
|
539
562
|
## License
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -15,7 +15,15 @@ import { basename, join } from "node:path";
|
|
|
15
15
|
import { setFrontmatterKey, setShellFrontmatterKey } from "./frontmatter.ts";
|
|
16
16
|
import { discoverJobs, findJobFile, loadJob, type JobMeta } from "./jobs.ts";
|
|
17
17
|
import { dispatchSchedule, type Dispatched } from "./schedule.ts";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
archiveOneTime,
|
|
20
|
+
clearSentinels,
|
|
21
|
+
listSentinels,
|
|
22
|
+
reapStaleSentinels,
|
|
23
|
+
resolveOneTime,
|
|
24
|
+
writeSentinel,
|
|
25
|
+
} from "./oneTime.ts";
|
|
26
|
+
import { defaultBundlePrefix } from "./config.ts";
|
|
19
27
|
import { platform } from "./platform/index.ts";
|
|
20
28
|
import { loadState, rememberPrefix } from "./state.ts";
|
|
21
29
|
import { dbPath, markDeleted, openDb, upsertJob } from "./db.ts";
|
|
@@ -27,8 +35,11 @@ import {
|
|
|
27
35
|
} from "./prune.ts";
|
|
28
36
|
import { Database } from "bun:sqlite";
|
|
29
37
|
import { startUiServer } from "./ui/server.ts";
|
|
38
|
+
import pkg from "../package.json" with { type: "json" };
|
|
30
39
|
|
|
31
|
-
|
|
40
|
+
// Single source of truth: package.json. semantic-release bumps it on release,
|
|
41
|
+
// so `cronfish --version` can never drift from the published version again.
|
|
42
|
+
const VERSION = pkg.version;
|
|
32
43
|
|
|
33
44
|
const CONSUMER_ROOT = process.env.CRONFISH_CONSUMER_ROOT || process.cwd();
|
|
34
45
|
const CRON_DIR = join(CONSUMER_ROOT, "cron");
|
|
@@ -94,7 +105,7 @@ function parseRetention(raw: unknown): RetentionConfig | undefined {
|
|
|
94
105
|
|
|
95
106
|
function loadConfig(): CronfishConfig {
|
|
96
107
|
const path = join(CONSUMER_ROOT, ".cronfish.json");
|
|
97
|
-
const defaultPrefix =
|
|
108
|
+
const defaultPrefix = defaultBundlePrefix(CONSUMER_ROOT);
|
|
98
109
|
if (!existsSync(path)) return { bundle_prefix: defaultPrefix };
|
|
99
110
|
let parsed: Partial<CronfishConfig>;
|
|
100
111
|
try {
|
|
@@ -324,6 +335,9 @@ function loadRunnerNames(): Set<string> {
|
|
|
324
335
|
function cmdSync(): void {
|
|
325
336
|
const p = platform();
|
|
326
337
|
const { jobs, errors } = discoverJobs(CRON_DIR);
|
|
338
|
+
// "sync"-class sentinel filenames written this run. Anything still on disk
|
|
339
|
+
// from a prior sync that ISN'T in here gets reaped at the end (self-heal).
|
|
340
|
+
const writtenSentinels = new Set<string>();
|
|
327
341
|
for (const e of errors) {
|
|
328
342
|
console.error(`[cronfish] ${e.path}: ${e.message}`);
|
|
329
343
|
// Bad YAML / invalid run_at on a one-time file → sentinel. Any other
|
|
@@ -331,7 +345,13 @@ function cmdSync(): void {
|
|
|
331
345
|
// cron/one-time/ since silent-skip is the failure mode we're killing.
|
|
332
346
|
if (e.path.includes(`/${"one-time"}/`)) {
|
|
333
347
|
const slug = e.path.split("/").pop() ?? "unknown";
|
|
334
|
-
|
|
348
|
+
const written = writeSentinel(
|
|
349
|
+
CRON_DIR,
|
|
350
|
+
slug,
|
|
351
|
+
`discovery error: ${e.message}`,
|
|
352
|
+
"sync",
|
|
353
|
+
);
|
|
354
|
+
writtenSentinels.add(basename(written));
|
|
335
355
|
}
|
|
336
356
|
}
|
|
337
357
|
|
|
@@ -346,6 +366,15 @@ function cmdSync(): void {
|
|
|
346
366
|
`[cronfish] WARN ${j.slug}: runner "${j.runner}" not in .cronfish.json#runners — known: ${known}`,
|
|
347
367
|
);
|
|
348
368
|
}
|
|
369
|
+
// launchd enforces a ~10s floor between relaunches (its implicit
|
|
370
|
+
// ThrottleInterval). A faster `schedule:` silently fires no quicker than
|
|
371
|
+
// every 10s, so warn rather than let it look like it works.
|
|
372
|
+
const d = safeDispatch(j.schedule);
|
|
373
|
+
if (d.kind === "seconds" && d.value < 10) {
|
|
374
|
+
console.error(
|
|
375
|
+
`[cronfish] WARN ${j.slug}: schedule ${d.value}s is below launchd's ~10s relaunch floor — it will fire no faster than every 10s`,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
349
378
|
}
|
|
350
379
|
|
|
351
380
|
const state = rememberPrefix(CONSUMER_ROOT, PREFIX);
|
|
@@ -364,7 +393,20 @@ function cmdSync(): void {
|
|
|
364
393
|
) {
|
|
365
394
|
console.error(`[cronfish] ${j.slug}: ${decision.reason}`);
|
|
366
395
|
if (j.oneTime && decision.reason?.startsWith("one-time past grace:")) {
|
|
367
|
-
|
|
396
|
+
// A past-grace one-time file would otherwise be re-discovered on
|
|
397
|
+
// EVERY sync and re-write a sentinel forever. Record one durable
|
|
398
|
+
// ("run"-class, never reaped) sentinel so the missed window is
|
|
399
|
+
// visible, then archive the file out of cron/one-time/ so it stops
|
|
400
|
+
// recurring.
|
|
401
|
+
writeSentinel(CRON_DIR, j.slug, decision.reason, "run");
|
|
402
|
+
try {
|
|
403
|
+
const dest = archiveOneTime(j.path);
|
|
404
|
+
console.error(`[cronfish] ${j.slug}: archived past-grace one-time → ${dest}`);
|
|
405
|
+
} catch (e) {
|
|
406
|
+
console.error(
|
|
407
|
+
`[cronfish] ${j.slug}: archive of past-grace one-time failed: ${(e as Error).message}`,
|
|
408
|
+
);
|
|
409
|
+
}
|
|
368
410
|
}
|
|
369
411
|
}
|
|
370
412
|
}
|
|
@@ -437,6 +479,11 @@ function cmdSync(): void {
|
|
|
437
479
|
}
|
|
438
480
|
}
|
|
439
481
|
|
|
482
|
+
// Self-heal: drop any "sync"-class sentinel that was NOT re-written this
|
|
483
|
+
// run (its error no longer occurs). "run"-class + foreign files survive.
|
|
484
|
+
const reaped = reapStaleSentinels(CRON_DIR, writtenSentinels);
|
|
485
|
+
if (reaped > 0) console.log(`[cronfish] cleared ${reaped} resolved sentinel(s)`);
|
|
486
|
+
|
|
440
487
|
console.log("[cronfish] sync complete");
|
|
441
488
|
}
|
|
442
489
|
|
|
@@ -946,6 +993,28 @@ function cmdInit(): void {
|
|
|
946
993
|
);
|
|
947
994
|
}
|
|
948
995
|
|
|
996
|
+
function cmdErrors(clear: boolean, slug?: string): void {
|
|
997
|
+
if (clear) {
|
|
998
|
+
const n = clearSentinels(CRON_DIR, slug);
|
|
999
|
+
console.log(
|
|
1000
|
+
`[cronfish] cleared ${n} sentinel(s)${slug ? ` for ${slug}` : ""}`,
|
|
1001
|
+
);
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
const files = listSentinels(CRON_DIR);
|
|
1005
|
+
if (files.length === 0) {
|
|
1006
|
+
console.log("[cronfish] no error sentinels");
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
console.log(
|
|
1010
|
+
`[cronfish] ${files.length} error sentinel(s) in cron/.errors/ (clear with \`cronfish errors --clear\`):\n`,
|
|
1011
|
+
);
|
|
1012
|
+
for (const f of files.sort()) {
|
|
1013
|
+
const body = readFileSync(join(CRON_DIR, ".errors", f), "utf-8").trim();
|
|
1014
|
+
console.log(`--- ${f} ---\n${body}\n`);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
949
1018
|
function usage(): void {
|
|
950
1019
|
console.log(
|
|
951
1020
|
`cronfish ${VERSION} — drop a file, schedule it.
|
|
@@ -961,6 +1030,7 @@ usage:
|
|
|
961
1030
|
cronfish disable <slug> flip disabled, then sync
|
|
962
1031
|
cronfish delete <slug> --yes bootout + remove plist + job file
|
|
963
1032
|
cronfish status [slug] all jobs (no arg) or one slug's launchctl + log tail
|
|
1033
|
+
cronfish errors [--clear] [slug] list error sentinels (cron/.errors/); --clear removes them
|
|
964
1034
|
cronfish run <slug> invoke runner directly (no launchd)
|
|
965
1035
|
cronfish watchdog check enabled jobs for missed schedules → fire alerts
|
|
966
1036
|
cronfish alerts test [adapter] send a test alert via the named (or default) adapter
|
|
@@ -1052,6 +1122,12 @@ async function main(): Promise<void> {
|
|
|
1052
1122
|
case "status":
|
|
1053
1123
|
cmdStatus(rest[0]);
|
|
1054
1124
|
return;
|
|
1125
|
+
case "errors": {
|
|
1126
|
+
const clear = rest.includes("--clear");
|
|
1127
|
+
const slug = rest.find((a) => !a.startsWith("-"));
|
|
1128
|
+
cmdErrors(clear, slug);
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1055
1131
|
case "run":
|
|
1056
1132
|
if (!rest[0]) throw new Error("usage: cronfish run <slug>");
|
|
1057
1133
|
await cmdRun(rest[0]);
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Shared config helpers. The bundle-prefix formula has to be identical
|
|
2
|
+
// everywhere a launchd label is computed — cli.ts (install/bootout) and
|
|
3
|
+
// runner.ts (one-time self-removal) both derive labels from it, so it lives
|
|
4
|
+
// here rather than being duplicated and drifting.
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { basename, join } from "node:path";
|
|
8
|
+
|
|
9
|
+
export function defaultBundlePrefix(consumerRoot: string): string {
|
|
10
|
+
return `com.cronfish.${basename(consumerRoot)}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Tolerant prefix read for non-CLI callers (the runner). Returns the default
|
|
14
|
+
// on a missing/unparseable .cronfish.json — the strict validation that the
|
|
15
|
+
// CLI applies at install time has already gated the value by the time a job
|
|
16
|
+
// is running, so the runner only needs to reproduce the same string.
|
|
17
|
+
export function loadBundlePrefix(consumerRoot: string): string {
|
|
18
|
+
const def = defaultBundlePrefix(consumerRoot);
|
|
19
|
+
const path = join(consumerRoot, ".cronfish.json");
|
|
20
|
+
if (!existsSync(path)) return def;
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8")) as {
|
|
23
|
+
bundle_prefix?: string;
|
|
24
|
+
};
|
|
25
|
+
return (parsed.bundle_prefix ?? "").trim() || def;
|
|
26
|
+
} catch {
|
|
27
|
+
return def;
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/frontmatter.ts
CHANGED
|
@@ -99,7 +99,33 @@ function splitTopLevelCommas(inner: string): string[] {
|
|
|
99
99
|
function parseInlineList(raw: string, lineNo: number): string[] {
|
|
100
100
|
const t = raw.trim();
|
|
101
101
|
const lb = t.indexOf("[");
|
|
102
|
-
|
|
102
|
+
// Find the matching close bracket by scanning from `[`, respecting quotes
|
|
103
|
+
// and nesting — not lastIndexOf("]"), which a `]` inside a trailing comment
|
|
104
|
+
// (`[A, B] # arr[0]`) or a quoted item would defeat. Everything after the
|
|
105
|
+
// matched `]` (e.g. a trailing comment) is ignored.
|
|
106
|
+
let rb = -1;
|
|
107
|
+
let s: string | null = null;
|
|
108
|
+
let d = 0;
|
|
109
|
+
for (let i = lb; i >= 0 && i < t.length; i++) {
|
|
110
|
+
const c = t[i];
|
|
111
|
+
const prev = t[i - 1];
|
|
112
|
+
if (s) {
|
|
113
|
+
if (c === s && prev !== "\\") s = null;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
117
|
+
s = c;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (c === "[") d++;
|
|
121
|
+
else if (c === "]") {
|
|
122
|
+
d--;
|
|
123
|
+
if (d === 0) {
|
|
124
|
+
rb = i;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
103
129
|
if (lb < 0 || rb < lb) {
|
|
104
130
|
throw new FrontmatterError(
|
|
105
131
|
`line ${lineNo}: inline array must open with "[" and close with "]" on the same line`,
|
package/src/oneTime.ts
CHANGED
|
@@ -13,12 +13,15 @@ import {
|
|
|
13
13
|
mkdirSync,
|
|
14
14
|
openSync,
|
|
15
15
|
readFileSync,
|
|
16
|
+
readdirSync,
|
|
16
17
|
renameSync,
|
|
18
|
+
rmSync,
|
|
17
19
|
statSync,
|
|
18
20
|
writeFileSync,
|
|
19
21
|
writeSync,
|
|
20
22
|
fsyncSync,
|
|
21
23
|
} from "node:fs";
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
22
25
|
import { homedir } from "node:os";
|
|
23
26
|
import { basename, join } from "node:path";
|
|
24
27
|
import { dlopen, FFIType, suffix } from "bun:ffi";
|
|
@@ -111,23 +114,105 @@ export function resolveOneTime(
|
|
|
111
114
|
|
|
112
115
|
// --- Error sentinel surface ---
|
|
113
116
|
//
|
|
114
|
-
//
|
|
115
|
-
// heartbeat
|
|
117
|
+
// A failure for a one-time job writes a sentinel here; consumers (e.g. a
|
|
118
|
+
// heartbeat job) sweep the folder and alert on non-empty. Two invariants
|
|
119
|
+
// keep the folder from running away:
|
|
120
|
+
//
|
|
121
|
+
// 1. Dedup — the filename is derived from (slug, hash(reason)), so the
|
|
122
|
+
// SAME error recurring on every sync overwrites one file instead of
|
|
123
|
+
// writing a new one each minute. An unfixed config error is 1 file,
|
|
124
|
+
// not thousands.
|
|
125
|
+
// 2. Ownership + class — cronfish-written files carry a `.<class>.cronfish.txt`
|
|
126
|
+
// suffix so a consumer can drop its own sentinels alongside without
|
|
127
|
+
// cronfish reaping them. Class "sync" = re-derived every reconcile
|
|
128
|
+
// (self-heals: reapStaleSentinels removes it once the error stops
|
|
129
|
+
// recurring). Class "run" = a one-shot runtime failure that is NOT
|
|
130
|
+
// re-detected at sync, so it persists until `cronfish errors --clear`.
|
|
131
|
+
|
|
132
|
+
export type SentinelClass = "sync" | "run";
|
|
133
|
+
|
|
134
|
+
const SENTINEL_EXT = ".cronfish.txt";
|
|
116
135
|
|
|
117
|
-
|
|
136
|
+
function safeSlug(slug: string): string {
|
|
137
|
+
return slug.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Deterministic dedup key: same (slug, reason) → same filename → overwrite.
|
|
141
|
+
export function sentinelFilename(
|
|
142
|
+
slug: string,
|
|
143
|
+
reason: string,
|
|
144
|
+
cls: SentinelClass,
|
|
145
|
+
): string {
|
|
146
|
+
const hash = createHash("sha1").update(reason).digest("hex").slice(0, 8);
|
|
147
|
+
return `${safeSlug(slug)}--${hash}.${cls}${SENTINEL_EXT}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function writeSentinel(
|
|
151
|
+
cronDir: string,
|
|
152
|
+
slug: string,
|
|
153
|
+
reason: string,
|
|
154
|
+
cls: SentinelClass = "run",
|
|
155
|
+
): string {
|
|
118
156
|
const dir = errorsDir(cronDir);
|
|
119
157
|
mkdirSync(dir, { recursive: true });
|
|
120
|
-
const
|
|
121
|
-
const safeSlug = slug.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
122
|
-
const path = join(dir, `${ts}-${safeSlug}.txt`);
|
|
158
|
+
const path = join(dir, sentinelFilename(slug, reason, cls));
|
|
123
159
|
const body =
|
|
124
160
|
`slug: ${slug}\n` +
|
|
125
|
-
`at: ${new Date(
|
|
161
|
+
`at: ${new Date().toISOString()}\n` +
|
|
126
162
|
`reason:\n${reason}\n`;
|
|
127
163
|
writeFileSync(path, body, "utf-8");
|
|
128
164
|
return path;
|
|
129
165
|
}
|
|
130
166
|
|
|
167
|
+
// Every file currently in cron/.errors/ (basenames), or [] when absent.
|
|
168
|
+
export function listSentinels(cronDir: string): string[] {
|
|
169
|
+
const dir = errorsDir(cronDir);
|
|
170
|
+
if (!existsSync(dir)) return [];
|
|
171
|
+
return readdirSync(dir).filter((f) => f.endsWith(".txt"));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Remove sentinels. With no slug, clears the whole folder (cronfish-owned
|
|
175
|
+
// AND any legacy/consumer .txt files — the manual `errors --clear` escape
|
|
176
|
+
// hatch). With a slug, clears only files for that slug. Returns the count.
|
|
177
|
+
export function clearSentinels(cronDir: string, slug?: string): number {
|
|
178
|
+
const dir = errorsDir(cronDir);
|
|
179
|
+
if (!existsSync(dir)) return 0;
|
|
180
|
+
const prefix = slug ? `${safeSlug(slug)}--` : null;
|
|
181
|
+
let n = 0;
|
|
182
|
+
for (const f of readdirSync(dir)) {
|
|
183
|
+
if (!f.endsWith(".txt")) continue;
|
|
184
|
+
if (prefix && !f.startsWith(prefix)) continue;
|
|
185
|
+
try {
|
|
186
|
+
rmSync(join(dir, f));
|
|
187
|
+
n++;
|
|
188
|
+
} catch {}
|
|
189
|
+
}
|
|
190
|
+
return n;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Self-heal for "sync"-class sentinels: after a reconcile, delete any
|
|
194
|
+
// cronfish "sync" sentinel whose filename was NOT (re)written this run —
|
|
195
|
+
// i.e. the error it recorded no longer occurs. "run"-class and foreign
|
|
196
|
+
// files are never touched here.
|
|
197
|
+
export function reapStaleSentinels(
|
|
198
|
+
cronDir: string,
|
|
199
|
+
keep: Set<string>,
|
|
200
|
+
): number {
|
|
201
|
+
const dir = errorsDir(cronDir);
|
|
202
|
+
if (!existsSync(dir)) return 0;
|
|
203
|
+
const suffix = `.sync${SENTINEL_EXT}`;
|
|
204
|
+
let n = 0;
|
|
205
|
+
for (const f of readdirSync(dir)) {
|
|
206
|
+
if (!f.endsWith(suffix)) continue;
|
|
207
|
+
if (keep.has(f)) continue;
|
|
208
|
+
try {
|
|
209
|
+
rmSync(join(dir, f));
|
|
210
|
+
n++;
|
|
211
|
+
} catch {}
|
|
212
|
+
}
|
|
213
|
+
return n;
|
|
214
|
+
}
|
|
215
|
+
|
|
131
216
|
// --- flock-based re-fire guard ---
|
|
132
217
|
//
|
|
133
218
|
// Real OS-level advisory lock via libc flock(2). Returns the fd on success
|
package/src/platform/launchd.ts
CHANGED
|
@@ -360,6 +360,25 @@ export function uninstall(prefix: string, slug: string): boolean {
|
|
|
360
360
|
return existed;
|
|
361
361
|
}
|
|
362
362
|
|
|
363
|
+
// Self-removal for a one-time job from inside its own runner process. The
|
|
364
|
+
// runner IS the launchd-managed process here, so `bootout` may SIGTERM us
|
|
365
|
+
// mid-call — remove the plist FILE first so that even if we're killed before
|
|
366
|
+
// bootout returns, launchd can't reload (and RunAtLoad-refire) the job on the
|
|
367
|
+
// next login/reboot. The live bootout is best-effort cleanup of the already-
|
|
368
|
+
// fired service.
|
|
369
|
+
export function removeOneTimeSelf(prefix: string, slug: string): boolean {
|
|
370
|
+
const label = labelFor(prefix, slug);
|
|
371
|
+
const dest = plistPathFor(label);
|
|
372
|
+
const existed = existsSync(dest) || isLoaded(label);
|
|
373
|
+
if (existsSync(dest)) {
|
|
374
|
+
try {
|
|
375
|
+
rmSync(dest);
|
|
376
|
+
} catch {}
|
|
377
|
+
}
|
|
378
|
+
sh(["launchctl", "bootout", `${gui()}/${label}`]);
|
|
379
|
+
return existed;
|
|
380
|
+
}
|
|
381
|
+
|
|
363
382
|
export function statusOf(prefix: string, slug: string): string {
|
|
364
383
|
const label = labelFor(prefix, slug);
|
|
365
384
|
const { code, out, err } = sh(["launchctl", "print", `${gui()}/${label}`]);
|
package/src/runner.ts
CHANGED
|
@@ -37,14 +37,18 @@ import {
|
|
|
37
37
|
import { loadJob, slugFromPath, type JobMeta } from "./jobs.ts";
|
|
38
38
|
import { resolveModel, localClaudeEnv } from "./models.ts";
|
|
39
39
|
import {
|
|
40
|
+
DEFAULT_GRACE_SECONDS,
|
|
40
41
|
archiveOneTime,
|
|
41
42
|
releaseFlock,
|
|
43
|
+
resolveOneTime,
|
|
42
44
|
setTsExecutedAt,
|
|
43
45
|
tryFlockExclusive,
|
|
44
46
|
writeAndFsync,
|
|
45
47
|
writeSentinel,
|
|
46
48
|
type FlockHandle,
|
|
47
49
|
} from "./oneTime.ts";
|
|
50
|
+
import { loadBundlePrefix } from "./config.ts";
|
|
51
|
+
import { platform } from "./platform/index.ts";
|
|
48
52
|
import { setFrontmatterKey, setShellFrontmatterKey } from "./frontmatter.ts";
|
|
49
53
|
import { parseLastResult } from "./result.ts";
|
|
50
54
|
import {
|
|
@@ -550,6 +554,27 @@ function stampExecutedAt(job: JobMeta, iso: string): void {
|
|
|
550
554
|
writeAndFsync(job.path, next);
|
|
551
555
|
}
|
|
552
556
|
|
|
557
|
+
// Remove this one-time job's own launchd plist after it has fired. Without
|
|
558
|
+
// this, a fire-now plist (RunAtLoad=true, no calendar) lingers in
|
|
559
|
+
// ~/Library/LaunchAgents until the NEXT `cronfish sync` boots it out — and a
|
|
560
|
+
// reboot/login in that window reloads it and re-fires (against an already-
|
|
561
|
+
// archived file). Self-removing here closes that window regardless of when
|
|
562
|
+
// the consumer next syncs. Best-effort: failures never block completion.
|
|
563
|
+
function selfRemoveOneTimePlist(job: JobMeta): void {
|
|
564
|
+
// Logs to console (→ launchd.out/err), not the per-run fd: this runs as the
|
|
565
|
+
// last act after the log fd is already closed, and the bootout it performs
|
|
566
|
+
// may terminate this process, so it must follow all ledger/alert work.
|
|
567
|
+
try {
|
|
568
|
+
const prefix = loadBundlePrefix(consumerRoot());
|
|
569
|
+
const existed = platform().removeOneTimeSelf(prefix, job.slug);
|
|
570
|
+
console.log(`[runner] one-time: removed own plist (existed=${existed})`);
|
|
571
|
+
} catch (e) {
|
|
572
|
+
console.error(
|
|
573
|
+
`[runner] one-time: self-remove plist failed: ${(e as Error).message}`,
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
553
578
|
function completeOneTime(job: JobMeta, fd: number): void {
|
|
554
579
|
if (!job.oneTime) return;
|
|
555
580
|
const iso = new Date().toISOString();
|
|
@@ -642,6 +667,33 @@ async function main(): Promise<void> {
|
|
|
642
667
|
} catch {
|
|
643
668
|
// re-parse failed; proceed with the original meta.
|
|
644
669
|
}
|
|
670
|
+
|
|
671
|
+
// Re-assert grace at RUN time. launchd fires a StartCalendarInterval job
|
|
672
|
+
// once on wake if the machine was asleep/off through the scheduled minute
|
|
673
|
+
// (coalesced) — which can land long past run_at. Grace is checked at sync
|
|
674
|
+
// time, but nothing re-checks it when launchd actually fires. Refuse a
|
|
675
|
+
// stale wake-up fire so a one-time job can't run hours/days late, and
|
|
676
|
+
// remove the plist so it won't try again on the next wake.
|
|
677
|
+
if (job.runAtMs !== undefined) {
|
|
678
|
+
const grace = job.graceSeconds ?? DEFAULT_GRACE_SECONDS;
|
|
679
|
+
const status = resolveOneTime(job.runAtMs, grace, Date.now(), undefined);
|
|
680
|
+
if (status.kind === "past-grace") {
|
|
681
|
+
console.error(
|
|
682
|
+
`[runner] one-time: ${job.slug} fired past grace — refusing. ${status.reason}`,
|
|
683
|
+
);
|
|
684
|
+
try {
|
|
685
|
+
writeSentinel(
|
|
686
|
+
join(consumerRoot(), "cron"),
|
|
687
|
+
job.slug,
|
|
688
|
+
`runtime past grace: ${status.reason}`,
|
|
689
|
+
"run",
|
|
690
|
+
);
|
|
691
|
+
} catch {}
|
|
692
|
+
if (oneTimeLock) releaseFlock(oneTimeLock);
|
|
693
|
+
selfRemoveOneTimePlist(job);
|
|
694
|
+
process.exit(0);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
645
697
|
}
|
|
646
698
|
|
|
647
699
|
if (job.concurrency) {
|
|
@@ -761,6 +813,9 @@ async function main(): Promise<void> {
|
|
|
761
813
|
try {
|
|
762
814
|
db?.close();
|
|
763
815
|
} catch {}
|
|
816
|
+
// LAST act for a one-time job: remove its own plist. May SIGTERM us via
|
|
817
|
+
// bootout, so everything above (ledger, alert) is already done.
|
|
818
|
+
if (job.oneTime) selfRemoveOneTimePlist(job);
|
|
764
819
|
}
|
|
765
820
|
process.exit(lastResult.code);
|
|
766
821
|
}
|