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/prune.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Per-run log retention. Logs accumulate under `.cronfish/logs/<slug>/<id>.log`
|
|
2
|
+
// forever; on an always-on machine that grows unbounded. `pruneLogs` is the
|
|
3
|
+
// pure, fs-only core (no DB, no launchd) so it's trivially testable: point it
|
|
4
|
+
// at a consumer root, hand it retention settings, get back a report of what
|
|
5
|
+
// was (or would be) deleted.
|
|
6
|
+
|
|
7
|
+
import { readdirSync, rmSync, statSync } from "node:fs";
|
|
8
|
+
import { join, relative } from "node:path";
|
|
9
|
+
import { logsRoot } from "./db.ts";
|
|
10
|
+
|
|
11
|
+
export interface SlugRetention {
|
|
12
|
+
// Delete logs whose mtime is older than this many days.
|
|
13
|
+
maxAgeDays?: number;
|
|
14
|
+
// Keep at most this many most-recent logs per slug; delete older ones.
|
|
15
|
+
maxRuns?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PruneOptions {
|
|
19
|
+
consumerRoot: string;
|
|
20
|
+
// Default retention applied to every slug.
|
|
21
|
+
global: SlugRetention;
|
|
22
|
+
// Per-slug overrides, keyed by forward-slash slug (e.g. "email/triage-ts").
|
|
23
|
+
// A slug present here fully replaces `global` for that slug.
|
|
24
|
+
perSlug?: Record<string, SlugRetention>;
|
|
25
|
+
// Limit pruning to a single slug.
|
|
26
|
+
onlySlug?: string;
|
|
27
|
+
// Report what would be deleted without touching disk.
|
|
28
|
+
dryRun?: boolean;
|
|
29
|
+
// Injectable clock for tests (ms since epoch). Defaults to Date.now().
|
|
30
|
+
nowMs?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PruneSlugResult {
|
|
34
|
+
slug: string;
|
|
35
|
+
deleted: string[]; // absolute paths
|
|
36
|
+
bytesFreed: number;
|
|
37
|
+
kept: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface PruneReport {
|
|
41
|
+
slugs: PruneSlugResult[];
|
|
42
|
+
totalDeleted: number;
|
|
43
|
+
totalBytes: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface LogFile {
|
|
47
|
+
path: string; // absolute
|
|
48
|
+
mtimeMs: number;
|
|
49
|
+
size: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Recursively collect every `*.log` file under `dir`, grouped by the slug its
|
|
53
|
+
// parent directory encodes (path relative to the logs root, forward slashes).
|
|
54
|
+
// `ui.log` and anything sitting directly in the logs root (parent === root) is
|
|
55
|
+
// skipped — those are daemon logs, not per-run logs.
|
|
56
|
+
function collectBySlug(logsDir: string): Map<string, LogFile[]> {
|
|
57
|
+
const bySlug = new Map<string, LogFile[]>();
|
|
58
|
+
const visit = (dir: string): void => {
|
|
59
|
+
let entries: import("node:fs").Dirent[];
|
|
60
|
+
try {
|
|
61
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
62
|
+
} catch {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
for (const e of entries) {
|
|
66
|
+
const full = join(dir, e.name);
|
|
67
|
+
if (e.isDirectory()) {
|
|
68
|
+
visit(full);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (!e.name.endsWith(".log")) continue;
|
|
72
|
+
const slug = relative(logsDir, dir).split("\\").join("/");
|
|
73
|
+
if (slug === "") continue; // ui.log etc. live at the root — not per-run
|
|
74
|
+
let st: import("node:fs").Stats;
|
|
75
|
+
try {
|
|
76
|
+
st = statSync(full);
|
|
77
|
+
} catch {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const list = bySlug.get(slug) ?? [];
|
|
81
|
+
list.push({ path: full, mtimeMs: st.mtimeMs, size: st.size });
|
|
82
|
+
bySlug.set(slug, list);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
visit(logsDir);
|
|
86
|
+
return bySlug;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Decide which of a slug's log files to delete: anything older than maxAgeDays,
|
|
90
|
+
// plus anything beyond the newest maxRuns. Returns the doomed files.
|
|
91
|
+
function selectVictims(
|
|
92
|
+
files: LogFile[],
|
|
93
|
+
retention: SlugRetention,
|
|
94
|
+
nowMs: number,
|
|
95
|
+
): LogFile[] {
|
|
96
|
+
const doomed = new Set<LogFile>();
|
|
97
|
+
if (retention.maxAgeDays !== undefined) {
|
|
98
|
+
const cutoff = nowMs - retention.maxAgeDays * 86_400_000;
|
|
99
|
+
for (const f of files) if (f.mtimeMs < cutoff) doomed.add(f);
|
|
100
|
+
}
|
|
101
|
+
if (retention.maxRuns !== undefined) {
|
|
102
|
+
const newestFirst = [...files].sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
103
|
+
for (const f of newestFirst.slice(retention.maxRuns)) doomed.add(f);
|
|
104
|
+
}
|
|
105
|
+
return [...doomed];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function pruneLogs(opts: PruneOptions): PruneReport {
|
|
109
|
+
const logsDir = logsRoot(opts.consumerRoot);
|
|
110
|
+
const nowMs = opts.nowMs ?? Date.now();
|
|
111
|
+
const bySlug = collectBySlug(logsDir);
|
|
112
|
+
|
|
113
|
+
const slugs: PruneSlugResult[] = [];
|
|
114
|
+
for (const [slug, files] of [...bySlug.entries()].sort()) {
|
|
115
|
+
if (opts.onlySlug && slug !== opts.onlySlug) continue;
|
|
116
|
+
const retention = opts.perSlug?.[slug] ?? opts.global;
|
|
117
|
+
// No limits set for this slug → nothing to do.
|
|
118
|
+
if (retention.maxAgeDays === undefined && retention.maxRuns === undefined) {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const victims = selectVictims(files, retention, nowMs);
|
|
122
|
+
if (victims.length === 0) continue;
|
|
123
|
+
let bytesFreed = 0;
|
|
124
|
+
const deleted: string[] = [];
|
|
125
|
+
for (const v of victims) {
|
|
126
|
+
if (!opts.dryRun) {
|
|
127
|
+
try {
|
|
128
|
+
rmSync(v.path);
|
|
129
|
+
} catch {
|
|
130
|
+
continue; // already gone / racing runner — skip silently
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
deleted.push(v.path);
|
|
134
|
+
bytesFreed += v.size;
|
|
135
|
+
}
|
|
136
|
+
slugs.push({
|
|
137
|
+
slug,
|
|
138
|
+
deleted,
|
|
139
|
+
bytesFreed,
|
|
140
|
+
kept: files.length - deleted.length,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
slugs,
|
|
146
|
+
totalDeleted: slugs.reduce((n, s) => n + s.deleted.length, 0),
|
|
147
|
+
totalBytes: slugs.reduce((n, s) => n + s.bytesFreed, 0),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function formatBytes(n: number): string {
|
|
152
|
+
if (n < 1024) return `${n} B`;
|
|
153
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
154
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
155
|
+
}
|
package/src/result.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Per-run structured result parsing.
|
|
2
|
+
//
|
|
3
|
+
// Every job may print one tagged sentinel line on stdout:
|
|
4
|
+
// __CRONFISH_RESULT_V1__::{"summary":"...","ok":true,"metrics":{...},"links":[...]}
|
|
5
|
+
//
|
|
6
|
+
// The runner reads the last 64KB of the log file post-exit, scans backward for
|
|
7
|
+
// the LAST sentinel line, and validates it. Anything that fails (missing,
|
|
8
|
+
// malformed JSON, schema violation) returns { result: null } and warns to
|
|
9
|
+
// stderr — never blocks the run.
|
|
10
|
+
|
|
11
|
+
import { openSync, closeSync, fstatSync, readSync } from "node:fs";
|
|
12
|
+
|
|
13
|
+
export const SENTINEL_PREFIX = "__CRONFISH_RESULT_V1__::";
|
|
14
|
+
const TAIL_BYTES = 64 * 1024;
|
|
15
|
+
const MAX_SUMMARY = 140;
|
|
16
|
+
|
|
17
|
+
export interface JobResult {
|
|
18
|
+
summary: string;
|
|
19
|
+
ok?: boolean;
|
|
20
|
+
metrics?: Record<string, number | string | boolean>;
|
|
21
|
+
links?: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ParsedResult {
|
|
25
|
+
result: JobResult | null;
|
|
26
|
+
truncated: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function warn(msg: string): void {
|
|
30
|
+
console.error(`[result] WARN: ${msg}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function validate(raw: unknown): JobResult | null {
|
|
34
|
+
if (!raw || typeof raw !== "object") return null;
|
|
35
|
+
const o = raw as Record<string, unknown>;
|
|
36
|
+
if (typeof o.summary !== "string") return null;
|
|
37
|
+
if (o.summary.length === 0 || o.summary.length > MAX_SUMMARY) return null;
|
|
38
|
+
const out: JobResult = { summary: o.summary };
|
|
39
|
+
if (o.ok !== undefined) {
|
|
40
|
+
if (typeof o.ok !== "boolean") return null;
|
|
41
|
+
out.ok = o.ok;
|
|
42
|
+
}
|
|
43
|
+
if (o.metrics !== undefined) {
|
|
44
|
+
if (!o.metrics || typeof o.metrics !== "object" || Array.isArray(o.metrics))
|
|
45
|
+
return null;
|
|
46
|
+
const m: Record<string, number | string | boolean> = {};
|
|
47
|
+
for (const [k, v] of Object.entries(o.metrics as Record<string, unknown>)) {
|
|
48
|
+
const t = typeof v;
|
|
49
|
+
if (t !== "number" && t !== "string" && t !== "boolean") return null;
|
|
50
|
+
m[k] = v as number | string | boolean;
|
|
51
|
+
}
|
|
52
|
+
out.metrics = m;
|
|
53
|
+
}
|
|
54
|
+
if (o.links !== undefined) {
|
|
55
|
+
if (!Array.isArray(o.links)) return null;
|
|
56
|
+
if (!o.links.every((l) => typeof l === "string")) return null;
|
|
57
|
+
out.links = o.links as string[];
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readTail(logPath: string): { text: string; truncated: boolean } {
|
|
63
|
+
const fd = openSync(logPath, "r");
|
|
64
|
+
try {
|
|
65
|
+
const size = fstatSync(fd).size;
|
|
66
|
+
const start = Math.max(0, size - TAIL_BYTES);
|
|
67
|
+
const len = size - start;
|
|
68
|
+
const buf = Buffer.alloc(len);
|
|
69
|
+
readSync(fd, buf, 0, len, start);
|
|
70
|
+
return { text: buf.toString("utf-8"), truncated: start > 0 };
|
|
71
|
+
} finally {
|
|
72
|
+
closeSync(fd);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function parseLastResult(logPath: string): Promise<ParsedResult> {
|
|
77
|
+
let tail: { text: string; truncated: boolean };
|
|
78
|
+
try {
|
|
79
|
+
tail = readTail(logPath);
|
|
80
|
+
} catch (e) {
|
|
81
|
+
warn(`read ${logPath} failed: ${(e as Error).message}`);
|
|
82
|
+
return { result: null, truncated: false };
|
|
83
|
+
}
|
|
84
|
+
const lines = tail.text.split("\n");
|
|
85
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
86
|
+
const line = lines[i];
|
|
87
|
+
const idx = line.indexOf(SENTINEL_PREFIX);
|
|
88
|
+
if (idx === -1) continue;
|
|
89
|
+
// If our tail started mid-line, the first line may be partial. Skip it
|
|
90
|
+
// if it's at index 0 and the tail was truncated.
|
|
91
|
+
if (i === 0 && tail.truncated) {
|
|
92
|
+
warn(`sentinel found on possibly-truncated first line of tail`);
|
|
93
|
+
return { result: null, truncated: tail.truncated };
|
|
94
|
+
}
|
|
95
|
+
const payload = line.slice(idx + SENTINEL_PREFIX.length).trim();
|
|
96
|
+
let parsed: unknown;
|
|
97
|
+
try {
|
|
98
|
+
parsed = JSON.parse(payload);
|
|
99
|
+
} catch (e) {
|
|
100
|
+
warn(`invalid JSON after sentinel: ${(e as Error).message}`);
|
|
101
|
+
return { result: null, truncated: tail.truncated };
|
|
102
|
+
}
|
|
103
|
+
const validated = validate(parsed);
|
|
104
|
+
if (!validated) {
|
|
105
|
+
warn(`sentinel payload failed schema validation`);
|
|
106
|
+
return { result: null, truncated: tail.truncated };
|
|
107
|
+
}
|
|
108
|
+
return { result: validated, truncated: tail.truncated };
|
|
109
|
+
}
|
|
110
|
+
return { result: null, truncated: tail.truncated };
|
|
111
|
+
}
|