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
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// One-shot TypeScript job. Like a recurring `.ts` it exports a top-level `config`
|
|
2
|
+
// object and a default async function — but the config carries `run_at:` instead of
|
|
3
|
+
// `schedule:`. Fires exactly once at `run_at`, then archives itself to
|
|
4
|
+
// `~/Library/Application Support/cronfish/done/` (outside the repo).
|
|
5
|
+
//
|
|
6
|
+
// One-time jobs MUST be idempotent: launchd can re-fire on restart, unsleep, or load
|
|
7
|
+
// spikes. Cronfish takes a flock and checks `executed_at` before invoking you, but the
|
|
8
|
+
// window between "start" and "stamp" can still repeat — so make the work safe to redo.
|
|
9
|
+
|
|
10
|
+
import { rm } from "node:fs/promises";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
|
|
14
|
+
export const config = {
|
|
15
|
+
run_at: "+1h", // relative to file mtime (s|m|h|d), OR an absolute ISO timestamp
|
|
16
|
+
enabled: false, // flip on with `cronfish enable one-time/cleanup-ts`
|
|
17
|
+
timeout: 120,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export default async function run(): Promise<void> {
|
|
21
|
+
// Deleting an already-deleted path is a no-op — naturally idempotent.
|
|
22
|
+
const stale = join(tmpdir(), "my-app-scratch");
|
|
23
|
+
await rm(stale, { recursive: true, force: true });
|
|
24
|
+
console.log(`cleaned ${stale}`);
|
|
25
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
run_at: "+10s" # relative to file mtime (s|m|h|d), OR an absolute ISO timestamp
|
|
3
|
+
model: haiku
|
|
4
|
+
enabled: false # flip on with `cronfish enable one-time/reminder-md`
|
|
5
|
+
timeout: 60
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
One-shot reminder. Fires once at `run_at`, then archives itself to
|
|
9
|
+
`~/Library/Application Support/cronfish/done/` — outside the repo.
|
|
10
|
+
|
|
11
|
+
Swap the relative `+10s` for an absolute time when you mean a real reminder, e.g.:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
run_at: 2026-07-01T15:00:00-04:00
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Body (this is the Claude prompt that runs once):
|
|
18
|
+
|
|
19
|
+
Post a Slack DM / write a note that says: "Reminder: stand up and stretch." Then stop.
|
|
20
|
+
Do nothing else. This job must tolerate being invoked twice and do no harm on the second
|
|
21
|
+
run — cronfish's flock + `executed_at` guard normally prevents that, but write idempotent
|
|
22
|
+
bodies anyway.
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cronfish",
|
|
3
|
+
"version": "0.12.0",
|
|
4
|
+
"description": "A file-based job scheduler for macOS. Drop a script in a folder; launchd runs it on a schedule.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cronfish": "./src/cli.ts"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"bun": ">=1.0"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "bun test",
|
|
14
|
+
"build:ui": "cd ui && bun install && bun run build"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src",
|
|
18
|
+
"templates",
|
|
19
|
+
"examples",
|
|
20
|
+
"ui/dist",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"keywords": [
|
|
25
|
+
"cron",
|
|
26
|
+
"scheduler",
|
|
27
|
+
"launchd",
|
|
28
|
+
"macos",
|
|
29
|
+
"bun",
|
|
30
|
+
"automation",
|
|
31
|
+
"claude",
|
|
32
|
+
"task-scheduler"
|
|
33
|
+
],
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/goldcaddy77/cronfish.git"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/goldcaddy77/cronfish#readme",
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/goldcaddy77/cronfish/issues"
|
|
41
|
+
},
|
|
42
|
+
"license": "MIT",
|
|
43
|
+
"author": "Dan Caddigan"
|
|
44
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Runner-side alert dispatch. Reads .cronfish.json alerts config, resolves
|
|
2
|
+
// the adapter for a job, builds the payload, and fires safely.
|
|
3
|
+
|
|
4
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import type { InvocationStatus } from "../db.ts";
|
|
7
|
+
import type { JobMeta, OnFailure } from "../jobs.ts";
|
|
8
|
+
import { buildRegistry } from "./registry.ts";
|
|
9
|
+
import { safeNotify, type AlertOutcome } from "./safe.ts";
|
|
10
|
+
import type { AlertPayload, AlertStatus, AlertsConfig } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
export interface ConsumerAlertsConfig {
|
|
13
|
+
alerts?: AlertsConfig;
|
|
14
|
+
ui?: { public_url?: string };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function loadConsumerAlertsConfig(
|
|
18
|
+
consumerRoot: string,
|
|
19
|
+
): ConsumerAlertsConfig {
|
|
20
|
+
const path = join(consumerRoot, ".cronfish.json");
|
|
21
|
+
if (!existsSync(path)) return {};
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(readFileSync(path, "utf-8")) as ConsumerAlertsConfig;
|
|
24
|
+
} catch {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function chooseAdapterName(
|
|
30
|
+
onFailure: OnFailure | undefined,
|
|
31
|
+
cfg: AlertsConfig | undefined,
|
|
32
|
+
): string | null {
|
|
33
|
+
const fromJob = onFailure?.notify?.trim();
|
|
34
|
+
if (fromJob) return fromJob === "none" ? null : fromJob;
|
|
35
|
+
const fromFleet = cfg?.on_failure?.notify?.trim();
|
|
36
|
+
if (fromFleet) return fromFleet === "none" ? null : fromFleet;
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function alertStatusFor(
|
|
41
|
+
invocationStatus: InvocationStatus,
|
|
42
|
+
): AlertStatus | null {
|
|
43
|
+
if (invocationStatus === "fail") return "fail";
|
|
44
|
+
if (invocationStatus === "timeout") return "timeout";
|
|
45
|
+
if (invocationStatus === "crashed") return "crashed";
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const LOG_TAIL_LINES = 20;
|
|
50
|
+
const LOG_TAIL_MAX_BYTES = 4 * 1024;
|
|
51
|
+
|
|
52
|
+
export function readLogTail(logPath: string): string {
|
|
53
|
+
try {
|
|
54
|
+
if (!existsSync(logPath)) return "";
|
|
55
|
+
const stat = statSync(logPath);
|
|
56
|
+
const start = Math.max(0, stat.size - 64 * 1024);
|
|
57
|
+
const fd = readFileSync(logPath);
|
|
58
|
+
const slice = fd.subarray(start).toString("utf-8");
|
|
59
|
+
const lines = slice.split(/\r?\n/);
|
|
60
|
+
if (lines.length === 0) return "";
|
|
61
|
+
// Drop the partial first line if we sliced mid-file.
|
|
62
|
+
const trimmed = start > 0 ? lines.slice(1) : lines;
|
|
63
|
+
const tail = trimmed.slice(-LOG_TAIL_LINES).join("\n");
|
|
64
|
+
if (Buffer.byteLength(tail, "utf-8") <= LOG_TAIL_MAX_BYTES) return tail;
|
|
65
|
+
// Truncate from the front (keep tail end) until under the cap.
|
|
66
|
+
let lo = 0;
|
|
67
|
+
let hi = tail.length;
|
|
68
|
+
while (lo < hi) {
|
|
69
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
70
|
+
const candidate = tail.slice(mid);
|
|
71
|
+
if (Buffer.byteLength(candidate, "utf-8") <= LOG_TAIL_MAX_BYTES) hi = mid;
|
|
72
|
+
else lo = mid + 1;
|
|
73
|
+
}
|
|
74
|
+
return tail.slice(lo);
|
|
75
|
+
} catch {
|
|
76
|
+
return "";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function buildUiUrl(
|
|
81
|
+
cfg: ConsumerAlertsConfig,
|
|
82
|
+
invocationId: number,
|
|
83
|
+
): string | null {
|
|
84
|
+
const base = cfg.ui?.public_url?.trim();
|
|
85
|
+
if (!base) return null;
|
|
86
|
+
return `${base.replace(/\/$/, "")}/runs/${invocationId}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface DispatchInput {
|
|
90
|
+
job: JobMeta;
|
|
91
|
+
invocationId: number;
|
|
92
|
+
invocationStatus: InvocationStatus;
|
|
93
|
+
alertStatus: AlertStatus; // override for `recovered` / `test`
|
|
94
|
+
exitCode: number | null;
|
|
95
|
+
durationMs: number;
|
|
96
|
+
startedAt: string;
|
|
97
|
+
logPath: string;
|
|
98
|
+
consumerRoot: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type DispatchOutcome =
|
|
102
|
+
| { kind: "skipped" }
|
|
103
|
+
| { kind: "sent" }
|
|
104
|
+
| { kind: "error"; error: string };
|
|
105
|
+
|
|
106
|
+
export async function dispatchAlert(
|
|
107
|
+
input: DispatchInput,
|
|
108
|
+
): Promise<DispatchOutcome> {
|
|
109
|
+
const cfg = loadConsumerAlertsConfig(input.consumerRoot);
|
|
110
|
+
const adapterName = chooseAdapterName(input.job.on_failure, cfg.alerts);
|
|
111
|
+
if (!adapterName) return { kind: "skipped" };
|
|
112
|
+
const registry = buildRegistry(cfg.alerts);
|
|
113
|
+
if (!registry.has(adapterName)) {
|
|
114
|
+
// Unknown adapter — treat as an error outcome (configuration bug).
|
|
115
|
+
const msg = `unknown adapter "${adapterName}"`;
|
|
116
|
+
console.error(`[cronfish] alert dispatch: ${msg}`);
|
|
117
|
+
return { kind: "error", error: msg };
|
|
118
|
+
}
|
|
119
|
+
const payload: AlertPayload = {
|
|
120
|
+
slug: input.job.slug,
|
|
121
|
+
status: input.alertStatus,
|
|
122
|
+
exit_code: input.exitCode,
|
|
123
|
+
duration_ms: input.durationMs,
|
|
124
|
+
started_at: input.startedAt,
|
|
125
|
+
log_tail: readLogTail(input.logPath),
|
|
126
|
+
ui_url: buildUiUrl(cfg, input.invocationId),
|
|
127
|
+
};
|
|
128
|
+
const outcome: AlertOutcome = await safeNotify(
|
|
129
|
+
registry.get(adapterName),
|
|
130
|
+
payload,
|
|
131
|
+
);
|
|
132
|
+
if (outcome.status === "sent") return { kind: "sent" };
|
|
133
|
+
return { kind: "error", error: outcome.error ?? "unknown error" };
|
|
134
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export { buildRegistry, type AdapterRegistry } from "./registry.ts";
|
|
2
|
+
export { safeNotify, type AlertOutcome, type AlertOutcomeStatus } from "./safe.ts";
|
|
3
|
+
export { createSlackAdapter, buildSlackBlocks } from "./slack.ts";
|
|
4
|
+
export { createShellAdapter, payloadEnv } from "./shell.ts";
|
|
5
|
+
export {
|
|
6
|
+
alertStatusFor,
|
|
7
|
+
buildUiUrl,
|
|
8
|
+
chooseAdapterName,
|
|
9
|
+
dispatchAlert,
|
|
10
|
+
loadConsumerAlertsConfig,
|
|
11
|
+
readLogTail,
|
|
12
|
+
type ConsumerAlertsConfig,
|
|
13
|
+
type DispatchInput,
|
|
14
|
+
type DispatchOutcome,
|
|
15
|
+
} from "./dispatch.ts";
|
|
16
|
+
export type {
|
|
17
|
+
Adapter,
|
|
18
|
+
AlertPayload,
|
|
19
|
+
AlertStatus,
|
|
20
|
+
AlertsConfig,
|
|
21
|
+
} from "./types.ts";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createShellAdapter } from "./shell.ts";
|
|
2
|
+
import { createSlackAdapter } from "./slack.ts";
|
|
3
|
+
import type { Adapter, AlertsConfig } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
export interface AdapterRegistry {
|
|
6
|
+
get(name: string): Adapter;
|
|
7
|
+
has(name: string): boolean;
|
|
8
|
+
defaultName(): string | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function buildRegistry(cfg: AlertsConfig = {}): AdapterRegistry {
|
|
12
|
+
const map = new Map<string, Adapter>();
|
|
13
|
+
map.set("slack", createSlackAdapter(cfg.slack));
|
|
14
|
+
map.set("shell", createShellAdapter(cfg.shell));
|
|
15
|
+
return {
|
|
16
|
+
get(name) {
|
|
17
|
+
const a = map.get(name);
|
|
18
|
+
if (!a) {
|
|
19
|
+
const known = [...map.keys()].join(", ");
|
|
20
|
+
throw new Error(
|
|
21
|
+
`unknown alert adapter "${name}" (known: ${known}). ` +
|
|
22
|
+
`Check on_failure.notify / alerts.default in .cronfish.json.`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
return a;
|
|
26
|
+
},
|
|
27
|
+
has(name) {
|
|
28
|
+
return map.has(name);
|
|
29
|
+
},
|
|
30
|
+
defaultName() {
|
|
31
|
+
return cfg.default ?? null;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Adapter, AlertPayload } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export type AlertOutcomeStatus = "sent" | "error";
|
|
4
|
+
|
|
5
|
+
export interface AlertOutcome {
|
|
6
|
+
status: AlertOutcomeStatus;
|
|
7
|
+
error: string | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Failure-safe wrapper. Adapter throws → swallow, log to stderr, return error
|
|
11
|
+
// outcome. Never re-raises. Caller persists outcome to cron_invocations.
|
|
12
|
+
export async function safeNotify(
|
|
13
|
+
adapter: Adapter,
|
|
14
|
+
payload: AlertPayload,
|
|
15
|
+
): Promise<AlertOutcome> {
|
|
16
|
+
try {
|
|
17
|
+
await adapter.notify(payload);
|
|
18
|
+
return { status: "sent", error: null };
|
|
19
|
+
} catch (e) {
|
|
20
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
21
|
+
console.error(
|
|
22
|
+
`[cronfish] alert adapter "${adapter.name}" failed for ${payload.slug}: ${msg}`,
|
|
23
|
+
);
|
|
24
|
+
return { status: "error", error: msg };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import type { Adapter, AlertPayload, AlertsConfig } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export function payloadEnv(payload: AlertPayload): Record<string, string> {
|
|
5
|
+
return {
|
|
6
|
+
CRONFISH_ALERT_SLUG: payload.slug,
|
|
7
|
+
CRONFISH_ALERT_STATUS: payload.status,
|
|
8
|
+
CRONFISH_ALERT_EXIT_CODE: payload.exit_code == null ? "" : String(payload.exit_code),
|
|
9
|
+
CRONFISH_ALERT_DURATION_MS: payload.duration_ms == null ? "" : String(payload.duration_ms),
|
|
10
|
+
CRONFISH_ALERT_STARTED_AT: payload.started_at,
|
|
11
|
+
CRONFISH_ALERT_UI_URL: payload.ui_url ?? "",
|
|
12
|
+
CRONFISH_ALERT_LOG_TAIL: payload.log_tail,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ShellAdapterOptions {
|
|
17
|
+
// Inject a custom runner for tests. Receives the resolved command + env vars
|
|
18
|
+
// + JSON stdin and resolves on completion.
|
|
19
|
+
run?: (
|
|
20
|
+
command: string,
|
|
21
|
+
env: Record<string, string>,
|
|
22
|
+
stdin: string,
|
|
23
|
+
) => Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function defaultRun(
|
|
27
|
+
command: string,
|
|
28
|
+
env: Record<string, string>,
|
|
29
|
+
stdin: string,
|
|
30
|
+
): Promise<void> {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const child = spawn(command, {
|
|
33
|
+
shell: true,
|
|
34
|
+
env: { ...process.env, ...env },
|
|
35
|
+
stdio: ["pipe", "inherit", "inherit"],
|
|
36
|
+
});
|
|
37
|
+
child.on("error", reject);
|
|
38
|
+
child.on("exit", (code) => {
|
|
39
|
+
if (code === 0) resolve();
|
|
40
|
+
else reject(new Error(`shell adapter: command exited ${code}`));
|
|
41
|
+
});
|
|
42
|
+
child.stdin.end(stdin);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createShellAdapter(
|
|
47
|
+
cfg: AlertsConfig["shell"] = {},
|
|
48
|
+
opts: ShellAdapterOptions = {},
|
|
49
|
+
): Adapter {
|
|
50
|
+
const command = cfg.command;
|
|
51
|
+
const run = opts.run ?? defaultRun;
|
|
52
|
+
return {
|
|
53
|
+
name: "shell",
|
|
54
|
+
async notify(payload: AlertPayload): Promise<void> {
|
|
55
|
+
if (!command) {
|
|
56
|
+
throw new Error("shell adapter: alerts.shell.command not configured");
|
|
57
|
+
}
|
|
58
|
+
const env = payloadEnv(payload);
|
|
59
|
+
const stdin = JSON.stringify(payload);
|
|
60
|
+
await run(command, env, stdin);
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { Adapter, AlertPayload, AlertsConfig } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
const STATUS_EMOJI: Record<AlertPayload["status"], string> = {
|
|
4
|
+
fail: ":red_circle:",
|
|
5
|
+
timeout: ":hourglass:",
|
|
6
|
+
crashed: ":boom:",
|
|
7
|
+
missed: ":zzz:",
|
|
8
|
+
recovered: ":large_green_circle:",
|
|
9
|
+
test: ":test_tube:",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const STATUS_LABEL: Record<AlertPayload["status"], string> = {
|
|
13
|
+
fail: "FAIL",
|
|
14
|
+
timeout: "TIMEOUT",
|
|
15
|
+
crashed: "CRASHED",
|
|
16
|
+
missed: "MISSED",
|
|
17
|
+
recovered: "RECOVERED",
|
|
18
|
+
test: "TEST",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function buildSlackBlocks(payload: AlertPayload): unknown[] {
|
|
22
|
+
const emoji = STATUS_EMOJI[payload.status] ?? ":grey_question:";
|
|
23
|
+
const label = STATUS_LABEL[payload.status] ?? payload.status.toUpperCase();
|
|
24
|
+
const ctxParts = [
|
|
25
|
+
`started ${payload.started_at}`,
|
|
26
|
+
payload.duration_ms != null ? `duration ${payload.duration_ms}ms` : null,
|
|
27
|
+
payload.exit_code != null ? `exit ${payload.exit_code}` : null,
|
|
28
|
+
].filter((s): s is string => s != null);
|
|
29
|
+
|
|
30
|
+
const blocks: unknown[] = [
|
|
31
|
+
{
|
|
32
|
+
type: "header",
|
|
33
|
+
text: { type: "plain_text", text: `${emoji} ${label} — ${payload.slug}` },
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
type: "context",
|
|
37
|
+
elements: [{ type: "mrkdwn", text: ctxParts.join(" · ") }],
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
if (payload.log_tail && payload.log_tail.trim().length > 0) {
|
|
42
|
+
blocks.push({
|
|
43
|
+
type: "section",
|
|
44
|
+
text: { type: "mrkdwn", text: "```\n" + payload.log_tail + "\n```" },
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (payload.ui_url) {
|
|
49
|
+
blocks.push({
|
|
50
|
+
type: "actions",
|
|
51
|
+
elements: [
|
|
52
|
+
{
|
|
53
|
+
type: "button",
|
|
54
|
+
text: { type: "plain_text", text: "View run" },
|
|
55
|
+
url: payload.ui_url,
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return blocks;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SlackAdapterOptions {
|
|
65
|
+
webhookUrl?: string;
|
|
66
|
+
fetchFn?: typeof fetch;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createSlackAdapter(
|
|
70
|
+
cfg: AlertsConfig["slack"] = {},
|
|
71
|
+
opts: SlackAdapterOptions = {},
|
|
72
|
+
): Adapter {
|
|
73
|
+
const envName = cfg.webhook_url_env ?? "CRONFISH_SLACK_WEBHOOK";
|
|
74
|
+
const fetchFn = opts.fetchFn ?? fetch;
|
|
75
|
+
return {
|
|
76
|
+
name: "slack",
|
|
77
|
+
async notify(payload: AlertPayload): Promise<void> {
|
|
78
|
+
const url = opts.webhookUrl ?? process.env[envName];
|
|
79
|
+
if (!url) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`slack adapter: env var ${envName} not set`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
const blocks = buildSlackBlocks(payload);
|
|
85
|
+
const res = await fetchFn(url, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers: { "content-type": "application/json" },
|
|
88
|
+
body: JSON.stringify({ blocks }),
|
|
89
|
+
});
|
|
90
|
+
if (!res.ok) {
|
|
91
|
+
const body = await res.text().catch(() => "");
|
|
92
|
+
throw new Error(
|
|
93
|
+
`slack adapter: webhook returned ${res.status} ${body.slice(0, 200)}`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Alert payload + adapter interface. Pure types — no runtime imports.
|
|
2
|
+
|
|
3
|
+
export type AlertStatus = "fail" | "timeout" | "crashed" | "missed" | "recovered" | "test";
|
|
4
|
+
|
|
5
|
+
export interface AlertPayload {
|
|
6
|
+
slug: string;
|
|
7
|
+
status: AlertStatus;
|
|
8
|
+
exit_code: number | null;
|
|
9
|
+
duration_ms: number | null;
|
|
10
|
+
started_at: string; // ISO-8601
|
|
11
|
+
log_tail: string; // last 20 lines, truncated to 4 KB
|
|
12
|
+
ui_url: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface Adapter {
|
|
16
|
+
readonly name: string;
|
|
17
|
+
notify(payload: AlertPayload): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AlertsConfig {
|
|
21
|
+
// Default adapter for `cronfish alerts test` (CLI) when no adapter arg is given.
|
|
22
|
+
default?: string;
|
|
23
|
+
// Fleet-wide on_failure default. Per-job frontmatter `on_failure` wins.
|
|
24
|
+
// Per-job `on_failure: { notify: "none" }` opts out of the fleet default.
|
|
25
|
+
on_failure?: {
|
|
26
|
+
notify?: string;
|
|
27
|
+
};
|
|
28
|
+
slack?: {
|
|
29
|
+
webhook_url_env?: string;
|
|
30
|
+
};
|
|
31
|
+
shell?: {
|
|
32
|
+
command?: string;
|
|
33
|
+
};
|
|
34
|
+
}
|