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/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
|
+
}
|