crontick 0.1.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 +56 -0
- package/dist/chunk-35FFLWP3.js +79 -0
- package/dist/chunk-35FFLWP3.js.map +1 -0
- package/dist/chunk-FMGZ3SSS.js +57 -0
- package/dist/chunk-FMGZ3SSS.js.map +1 -0
- package/dist/chunk-LPAFZNXK.js +214 -0
- package/dist/chunk-LPAFZNXK.js.map +1 -0
- package/dist/chunk-YMAT5MON.js +22 -0
- package/dist/chunk-YMAT5MON.js.map +1 -0
- package/dist/cli/index.cjs +879 -0
- package/dist/cli/index.cjs.map +1 -0
- package/dist/cli/index.d.cts +2 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +554 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/daemon/index.cjs +1655 -0
- package/dist/daemon/index.cjs.map +1 -0
- package/dist/daemon/index.d.cts +2 -0
- package/dist/daemon/index.d.ts +2 -0
- package/dist/daemon/index.js +1306 -0
- package/dist/daemon/index.js.map +1 -0
- package/dist/dashboard/.gitkeep +0 -0
- package/dist/dashboard/dashboard.css +192 -0
- package/dist/dashboard/dashboard.js +316 -0
- package/dist/dashboard/index.html +108 -0
- package/dist/index.cjs +180 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +121 -0
- package/dist/index.d.ts +121 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/job-JQGLDEJV.js +26 -0
- package/dist/job-JQGLDEJV.js.map +1 -0
- package/dist/mcp/index.cjs +1000 -0
- package/dist/mcp/index.cjs.map +1 -0
- package/dist/mcp/index.d.cts +13 -0
- package/dist/mcp/index.d.ts +13 -0
- package/dist/mcp/index.js +876 -0
- package/dist/mcp/index.js.map +1 -0
- package/package.json +81 -0
- package/plugin/.gitkeep +0 -0
- package/plugin/README.md +50 -0
- package/plugin/install.mjs +155 -0
- package/plugin/plugin.json +11 -0
- package/plugin/uninstall.mjs +68 -0
- package/src/skill/SKILL.md +227 -0
|
@@ -0,0 +1,1306 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
NotImplementedInV1Error,
|
|
4
|
+
createAutostart
|
|
5
|
+
} from "../chunk-LPAFZNXK.js";
|
|
6
|
+
import {
|
|
7
|
+
CrontickError
|
|
8
|
+
} from "../chunk-YMAT5MON.js";
|
|
9
|
+
import {
|
|
10
|
+
VERSION,
|
|
11
|
+
ensureDirs,
|
|
12
|
+
jobsDir,
|
|
13
|
+
logsDir,
|
|
14
|
+
pidFilePath,
|
|
15
|
+
portFilePath,
|
|
16
|
+
runsDbPath
|
|
17
|
+
} from "../chunk-FMGZ3SSS.js";
|
|
18
|
+
import {
|
|
19
|
+
JobSchema
|
|
20
|
+
} from "../chunk-35FFLWP3.js";
|
|
21
|
+
|
|
22
|
+
// src/daemon/index.ts
|
|
23
|
+
import { spawn as spawn2 } from "child_process";
|
|
24
|
+
import { readFileSync as readFileSync3, writeFileSync as writeFileSync3, unlinkSync as unlinkSync3, existsSync as existsSync3, appendFileSync } from "fs";
|
|
25
|
+
import { join as join3 } from "path";
|
|
26
|
+
|
|
27
|
+
// src/daemon/store.ts
|
|
28
|
+
import { DatabaseSync } from "node:sqlite";
|
|
29
|
+
import { randomUUID } from "crypto";
|
|
30
|
+
import { writeFileSync, readFileSync, unlinkSync, readdirSync, existsSync } from "fs";
|
|
31
|
+
import { join } from "path";
|
|
32
|
+
var MIGRATIONS = [
|
|
33
|
+
{
|
|
34
|
+
name: "001_initial",
|
|
35
|
+
sql: `
|
|
36
|
+
CREATE TABLE IF NOT EXISTS jobs (
|
|
37
|
+
id TEXT PRIMARY KEY,
|
|
38
|
+
json TEXT NOT NULL,
|
|
39
|
+
updated_at INTEGER NOT NULL
|
|
40
|
+
);
|
|
41
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
42
|
+
id TEXT PRIMARY KEY,
|
|
43
|
+
job_id TEXT NOT NULL,
|
|
44
|
+
started_at INTEGER NOT NULL,
|
|
45
|
+
ended_at INTEGER,
|
|
46
|
+
status TEXT NOT NULL,
|
|
47
|
+
exit_code INTEGER,
|
|
48
|
+
error TEXT,
|
|
49
|
+
duration_ms INTEGER
|
|
50
|
+
);
|
|
51
|
+
CREATE TABLE IF NOT EXISTS run_logs (
|
|
52
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
53
|
+
run_id TEXT NOT NULL,
|
|
54
|
+
stream TEXT NOT NULL,
|
|
55
|
+
ts INTEGER NOT NULL,
|
|
56
|
+
chunk BLOB NOT NULL
|
|
57
|
+
);
|
|
58
|
+
CREATE INDEX IF NOT EXISTS idx_runs_job_id ON runs(job_id);
|
|
59
|
+
CREATE INDEX IF NOT EXISTS idx_runs_started_at ON runs(started_at);
|
|
60
|
+
CREATE INDEX IF NOT EXISTS idx_run_logs_run_id ON run_logs(run_id);
|
|
61
|
+
`
|
|
62
|
+
}
|
|
63
|
+
];
|
|
64
|
+
var Store = class {
|
|
65
|
+
db;
|
|
66
|
+
dbPath;
|
|
67
|
+
jobsPath;
|
|
68
|
+
constructor(dbPath, jobsPath) {
|
|
69
|
+
this.dbPath = dbPath ?? runsDbPath();
|
|
70
|
+
this.jobsPath = jobsPath ?? jobsDir();
|
|
71
|
+
}
|
|
72
|
+
open() {
|
|
73
|
+
this.db = new DatabaseSync(this.dbPath);
|
|
74
|
+
this.db.exec("PRAGMA journal_mode=WAL;");
|
|
75
|
+
this.db.exec("PRAGMA foreign_keys=ON;");
|
|
76
|
+
this.runMigrations();
|
|
77
|
+
}
|
|
78
|
+
close() {
|
|
79
|
+
try {
|
|
80
|
+
this.db.close();
|
|
81
|
+
} catch {
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
runMigrations() {
|
|
85
|
+
this.db.exec(`
|
|
86
|
+
CREATE TABLE IF NOT EXISTS migrations (
|
|
87
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
88
|
+
name TEXT NOT NULL UNIQUE,
|
|
89
|
+
applied_at INTEGER NOT NULL
|
|
90
|
+
);
|
|
91
|
+
`);
|
|
92
|
+
const applied = this.db.prepare("SELECT name FROM migrations").all();
|
|
93
|
+
const appliedSet = new Set(applied.map((r) => r.name));
|
|
94
|
+
for (const migration of MIGRATIONS) {
|
|
95
|
+
if (!appliedSet.has(migration.name)) {
|
|
96
|
+
this.db.exec(migration.sql);
|
|
97
|
+
this.db.prepare("INSERT INTO migrations (name, applied_at) VALUES (?, ?)").run(migration.name, Date.now());
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// ── Job CRUD ────────────────────────────────────────────────────────────────
|
|
102
|
+
upsertJob(job) {
|
|
103
|
+
const json = JSON.stringify(job);
|
|
104
|
+
const now = Date.now();
|
|
105
|
+
this.db.prepare(
|
|
106
|
+
"INSERT INTO jobs (id, json, updated_at) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET json=excluded.json, updated_at=excluded.updated_at"
|
|
107
|
+
).run(job.id, json, now);
|
|
108
|
+
const filePath = join(this.jobsPath, `${job.id}.json`);
|
|
109
|
+
writeFileSync(filePath, json, "utf-8");
|
|
110
|
+
}
|
|
111
|
+
getJob(id) {
|
|
112
|
+
const row = this.db.prepare("SELECT json FROM jobs WHERE id = ?").get(id);
|
|
113
|
+
if (!row) return void 0;
|
|
114
|
+
return JSON.parse(row.json);
|
|
115
|
+
}
|
|
116
|
+
listJobs() {
|
|
117
|
+
const rows = this.db.prepare("SELECT json FROM jobs ORDER BY id").all();
|
|
118
|
+
return rows.map((r) => JSON.parse(r.json));
|
|
119
|
+
}
|
|
120
|
+
deleteJob(id) {
|
|
121
|
+
const changes = this.db.prepare("DELETE FROM jobs WHERE id = ?").run(id).changes;
|
|
122
|
+
const filePath = join(this.jobsPath, `${id}.json`);
|
|
123
|
+
if (existsSync(filePath)) {
|
|
124
|
+
try {
|
|
125
|
+
unlinkSync(filePath);
|
|
126
|
+
} catch {
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return changes > 0;
|
|
130
|
+
}
|
|
131
|
+
/** Load jobs from the jobs directory (disk is source of truth on daemon start). */
|
|
132
|
+
loadJobsFromDisk() {
|
|
133
|
+
if (!existsSync(this.jobsPath)) return;
|
|
134
|
+
const files = readdirSync(this.jobsPath).filter((f) => f.endsWith(".json"));
|
|
135
|
+
for (const file of files) {
|
|
136
|
+
try {
|
|
137
|
+
const raw = readFileSync(join(this.jobsPath, file), "utf-8");
|
|
138
|
+
const parsed = JobSchema.safeParse(JSON.parse(raw));
|
|
139
|
+
if (parsed.success) {
|
|
140
|
+
const json = JSON.stringify(parsed.data);
|
|
141
|
+
this.db.prepare(
|
|
142
|
+
"INSERT INTO jobs (id, json, updated_at) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET json=excluded.json, updated_at=excluded.updated_at"
|
|
143
|
+
).run(parsed.data.id, json, Date.now());
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// ── Run CRUD ────────────────────────────────────────────────────────────────
|
|
150
|
+
insertRun(jobId, startedAt) {
|
|
151
|
+
const id = randomUUID();
|
|
152
|
+
const now = startedAt ?? Date.now();
|
|
153
|
+
this.db.prepare(
|
|
154
|
+
"INSERT INTO runs (id, job_id, started_at, status) VALUES (?, ?, ?, ?)"
|
|
155
|
+
).run(id, jobId, now, "queued");
|
|
156
|
+
return { id, jobId, startedAt: now, status: "queued" };
|
|
157
|
+
}
|
|
158
|
+
updateRun(id, update) {
|
|
159
|
+
const run = this.getRun(id);
|
|
160
|
+
if (!run) throw new CrontickError("NOT_FOUND", `Run ${id} not found`);
|
|
161
|
+
const fields = [];
|
|
162
|
+
const values = [];
|
|
163
|
+
if (update.status !== void 0) {
|
|
164
|
+
fields.push("status = ?");
|
|
165
|
+
values.push(update.status);
|
|
166
|
+
}
|
|
167
|
+
if (update.exitCode !== void 0) {
|
|
168
|
+
fields.push("exit_code = ?");
|
|
169
|
+
values.push(update.exitCode ?? null);
|
|
170
|
+
}
|
|
171
|
+
if (update.error !== void 0) {
|
|
172
|
+
fields.push("error = ?");
|
|
173
|
+
values.push(update.error ?? null);
|
|
174
|
+
}
|
|
175
|
+
if (update.endedAt !== void 0) {
|
|
176
|
+
fields.push("ended_at = ?");
|
|
177
|
+
values.push(update.endedAt ?? null);
|
|
178
|
+
}
|
|
179
|
+
if (update.durationMs !== void 0) {
|
|
180
|
+
fields.push("duration_ms = ?");
|
|
181
|
+
values.push(update.durationMs ?? null);
|
|
182
|
+
}
|
|
183
|
+
if (fields.length === 0) return;
|
|
184
|
+
values.push(id);
|
|
185
|
+
this.db.prepare(`UPDATE runs SET ${fields.join(", ")} WHERE id = ?`).run(...values);
|
|
186
|
+
}
|
|
187
|
+
getRun(id) {
|
|
188
|
+
const row = this.db.prepare("SELECT * FROM runs WHERE id = ?").get(id);
|
|
189
|
+
return row ? rowToRun(row) : void 0;
|
|
190
|
+
}
|
|
191
|
+
listRuns(opts = {}) {
|
|
192
|
+
const conditions = [];
|
|
193
|
+
const params = [];
|
|
194
|
+
if (opts.jobId) {
|
|
195
|
+
conditions.push("job_id = ?");
|
|
196
|
+
params.push(opts.jobId);
|
|
197
|
+
}
|
|
198
|
+
if (opts.since !== void 0) {
|
|
199
|
+
conditions.push("started_at >= ?");
|
|
200
|
+
params.push(opts.since);
|
|
201
|
+
}
|
|
202
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
203
|
+
const limit = opts.limit !== void 0 ? `LIMIT ${opts.limit}` : "";
|
|
204
|
+
const rows = this.db.prepare(`SELECT * FROM runs ${where} ORDER BY started_at DESC ${limit}`).all(...params);
|
|
205
|
+
return rows.map(rowToRun);
|
|
206
|
+
}
|
|
207
|
+
/** Get the most recent successful run for a job (for catchup calculation). */
|
|
208
|
+
getLastRun(jobId) {
|
|
209
|
+
const row = this.db.prepare(
|
|
210
|
+
"SELECT * FROM runs WHERE job_id = ? AND status IN ('success','failed','timeout') ORDER BY started_at DESC LIMIT 1"
|
|
211
|
+
).get(jobId);
|
|
212
|
+
return row ? rowToRun(row) : void 0;
|
|
213
|
+
}
|
|
214
|
+
/** Count runs for a job started after a given epoch ms. */
|
|
215
|
+
countRunsSince(jobId, since, excludeId) {
|
|
216
|
+
if (excludeId) {
|
|
217
|
+
const result2 = this.db.prepare("SELECT COUNT(*) as cnt FROM runs WHERE job_id = ? AND started_at >= ? AND id != ?").get(jobId, since, excludeId);
|
|
218
|
+
return result2.cnt;
|
|
219
|
+
}
|
|
220
|
+
const result = this.db.prepare("SELECT COUNT(*) as cnt FROM runs WHERE job_id = ? AND started_at >= ?").get(jobId, since);
|
|
221
|
+
return result.cnt;
|
|
222
|
+
}
|
|
223
|
+
// ── Log CRUD ────────────────────────────────────────────────────────────────
|
|
224
|
+
appendLog(runId, stream, chunk) {
|
|
225
|
+
this.db.prepare("INSERT INTO run_logs (run_id, stream, ts, chunk) VALUES (?, ?, ?, ?)").run(runId, stream, Date.now(), chunk);
|
|
226
|
+
}
|
|
227
|
+
getLogs(runId) {
|
|
228
|
+
const rows = this.db.prepare("SELECT * FROM run_logs WHERE run_id = ? ORDER BY id").all(runId);
|
|
229
|
+
return rows.map(rowToLog);
|
|
230
|
+
}
|
|
231
|
+
tailLogs(runId, sinceTs) {
|
|
232
|
+
const rows = this.db.prepare("SELECT * FROM run_logs WHERE run_id = ? AND ts > ? ORDER BY id").all(runId, sinceTs);
|
|
233
|
+
return rows.map(rowToLog);
|
|
234
|
+
}
|
|
235
|
+
/** On daemon startup, cancel any runs that were left in 'running' or 'queued' state (daemon crashed). */
|
|
236
|
+
reconcileOrphanRuns() {
|
|
237
|
+
const result = this.db.prepare(
|
|
238
|
+
"UPDATE runs SET status = 'canceled', error = 'daemon-restart', ended_at = ? WHERE status IN ('running', 'queued')"
|
|
239
|
+
).run(Date.now());
|
|
240
|
+
return result.changes;
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
function rowToRun(row) {
|
|
244
|
+
const r = {
|
|
245
|
+
id: row.id,
|
|
246
|
+
jobId: row.job_id,
|
|
247
|
+
startedAt: row.started_at,
|
|
248
|
+
status: row.status
|
|
249
|
+
};
|
|
250
|
+
if (row.ended_at !== null) r.endedAt = row.ended_at;
|
|
251
|
+
if (row.exit_code !== null) r.exitCode = row.exit_code;
|
|
252
|
+
if (row.error !== null) r.error = row.error;
|
|
253
|
+
if (row.duration_ms !== null) r.durationMs = row.duration_ms;
|
|
254
|
+
return r;
|
|
255
|
+
}
|
|
256
|
+
function rowToLog(row) {
|
|
257
|
+
return {
|
|
258
|
+
runId: row.run_id,
|
|
259
|
+
stream: row.stream,
|
|
260
|
+
ts: row.ts,
|
|
261
|
+
chunk: Buffer.from(row.chunk)
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// src/daemon/scheduler.ts
|
|
266
|
+
import { EventEmitter } from "events";
|
|
267
|
+
import { Cron } from "croner";
|
|
268
|
+
var CRON_ALIASES = {
|
|
269
|
+
"@yearly": "0 0 1 1 *",
|
|
270
|
+
"@annually": "0 0 1 1 *",
|
|
271
|
+
"@monthly": "0 0 1 * *",
|
|
272
|
+
"@weekly": "0 0 * * 0",
|
|
273
|
+
"@daily": "0 0 * * *",
|
|
274
|
+
"@midnight": "0 0 * * *",
|
|
275
|
+
"@noon": "0 12 * * *",
|
|
276
|
+
"@hourly": "0 * * * *",
|
|
277
|
+
"@every_minute": "* * * * *"
|
|
278
|
+
};
|
|
279
|
+
function expandCronAlias(expr) {
|
|
280
|
+
return CRON_ALIASES[expr.toLowerCase()] ?? expr;
|
|
281
|
+
}
|
|
282
|
+
var Scheduler = class extends EventEmitter {
|
|
283
|
+
entries = /* @__PURE__ */ new Map();
|
|
284
|
+
/**
|
|
285
|
+
* Schedule a job, handling catchup based on the last run time from the store.
|
|
286
|
+
* On daemon start pass `store` so catchup can be resolved; at runtime it's optional.
|
|
287
|
+
*/
|
|
288
|
+
schedule(job, store) {
|
|
289
|
+
this.unschedule(job.id);
|
|
290
|
+
if (!job.enabled) return;
|
|
291
|
+
const { schedule } = job;
|
|
292
|
+
const lastRun = store?.getLastRun(job.id);
|
|
293
|
+
const lastRunAt = lastRun ? new Date(lastRun.startedAt) : void 0;
|
|
294
|
+
if (schedule.kind === "cron") {
|
|
295
|
+
this.scheduleCron(job, expandCronAlias(schedule.cron), schedule.tz, lastRunAt);
|
|
296
|
+
} else if (schedule.kind === "interval") {
|
|
297
|
+
this.scheduleInterval(job, schedule.everySec, schedule.startAt, lastRunAt);
|
|
298
|
+
} else if (schedule.kind === "one-shot") {
|
|
299
|
+
this.scheduleOneShot(job, schedule.runAt);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
unschedule(jobId) {
|
|
303
|
+
const entry = this.entries.get(jobId);
|
|
304
|
+
if (entry) {
|
|
305
|
+
entry.stop();
|
|
306
|
+
this.entries.delete(jobId);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
unscheduleAll() {
|
|
310
|
+
for (const jobId of [...this.entries.keys()]) {
|
|
311
|
+
this.unschedule(jobId);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// ── Preview / Validate ─────────────────────────────────────────────────────
|
|
315
|
+
previewNext(schedule, opts = {}) {
|
|
316
|
+
const n = opts.n ?? 5;
|
|
317
|
+
if (schedule.kind === "cron") {
|
|
318
|
+
return cronNextN(expandCronAlias(schedule.cron), opts.tz ?? schedule.tz, n);
|
|
319
|
+
}
|
|
320
|
+
if (schedule.kind === "interval") {
|
|
321
|
+
const now = Date.now();
|
|
322
|
+
const intervalMs = schedule.everySec * 1e3;
|
|
323
|
+
const results = [];
|
|
324
|
+
for (let i = 1; i <= n; i++) {
|
|
325
|
+
results.push(new Date(now + i * intervalMs).toISOString());
|
|
326
|
+
}
|
|
327
|
+
return results;
|
|
328
|
+
}
|
|
329
|
+
if (schedule.kind === "one-shot") {
|
|
330
|
+
const t = new Date(schedule.runAt);
|
|
331
|
+
if (isNaN(t.getTime())) return [];
|
|
332
|
+
return t > /* @__PURE__ */ new Date() ? [t.toISOString()] : [];
|
|
333
|
+
}
|
|
334
|
+
return [];
|
|
335
|
+
}
|
|
336
|
+
validateSchedule(schedule) {
|
|
337
|
+
if (schedule.kind === "cron") {
|
|
338
|
+
try {
|
|
339
|
+
const cron = new Cron(expandCronAlias(schedule.cron), { paused: true });
|
|
340
|
+
cron.stop();
|
|
341
|
+
return { ok: true };
|
|
342
|
+
} catch (err) {
|
|
343
|
+
return { ok: false, error: String(err) };
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (schedule.kind === "interval") {
|
|
347
|
+
if (schedule.everySec <= 0) {
|
|
348
|
+
return { ok: false, error: "everySec must be positive" };
|
|
349
|
+
}
|
|
350
|
+
if (schedule.startAt && isNaN(new Date(schedule.startAt).getTime())) {
|
|
351
|
+
return { ok: false, error: "startAt is not a valid ISO-8601 date" };
|
|
352
|
+
}
|
|
353
|
+
return { ok: true };
|
|
354
|
+
}
|
|
355
|
+
if (schedule.kind === "one-shot") {
|
|
356
|
+
const t = new Date(schedule.runAt);
|
|
357
|
+
if (isNaN(t.getTime())) {
|
|
358
|
+
return { ok: false, error: "runAt is not a valid ISO-8601 date" };
|
|
359
|
+
}
|
|
360
|
+
return { ok: true };
|
|
361
|
+
}
|
|
362
|
+
return { ok: false, error: "Unknown schedule kind" };
|
|
363
|
+
}
|
|
364
|
+
// ── Private helpers ────────────────────────────────────────────────────────
|
|
365
|
+
fireTick(jobId, plannedAt) {
|
|
366
|
+
this.emit("tick", { jobId, plannedAt });
|
|
367
|
+
}
|
|
368
|
+
scheduleCron(job, pattern, tz, lastRunAt) {
|
|
369
|
+
this.handleCronCatchup(job, pattern, tz, lastRunAt);
|
|
370
|
+
const options = {};
|
|
371
|
+
if (tz) options.timezone = tz;
|
|
372
|
+
const cron = new Cron(pattern, options, () => {
|
|
373
|
+
this.fireTick(job.id, /* @__PURE__ */ new Date());
|
|
374
|
+
});
|
|
375
|
+
this.entries.set(job.id, { stop: () => cron.stop() });
|
|
376
|
+
}
|
|
377
|
+
handleCronCatchup(job, pattern, tz, lastRunAt) {
|
|
378
|
+
if (job.catchup === "skip" || !lastRunAt) return;
|
|
379
|
+
const now = /* @__PURE__ */ new Date();
|
|
380
|
+
const missed = missedCronFires(pattern, tz, lastRunAt, now);
|
|
381
|
+
if (missed.length === 0) return;
|
|
382
|
+
if (job.catchup === "run-once") {
|
|
383
|
+
setImmediate(() => this.fireTick(job.id, missed[missed.length - 1]));
|
|
384
|
+
} else if (job.catchup === "run-all") {
|
|
385
|
+
for (const t of missed) {
|
|
386
|
+
const capturedT = t;
|
|
387
|
+
setImmediate(() => this.fireTick(job.id, capturedT));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
scheduleInterval(job, everySec, startAt, lastRunAt) {
|
|
392
|
+
const intervalMs = everySec * 1e3;
|
|
393
|
+
if (lastRunAt && job.catchup !== "skip") {
|
|
394
|
+
const elapsed = Date.now() - lastRunAt.getTime();
|
|
395
|
+
const missedCount = Math.floor(elapsed / intervalMs);
|
|
396
|
+
if (missedCount > 0) {
|
|
397
|
+
if (job.catchup === "run-once") {
|
|
398
|
+
setImmediate(() => this.fireTick(job.id, /* @__PURE__ */ new Date()));
|
|
399
|
+
} else if (job.catchup === "run-all") {
|
|
400
|
+
for (let i = 0; i < missedCount; i++) {
|
|
401
|
+
setImmediate(() => this.fireTick(job.id, /* @__PURE__ */ new Date()));
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
let delay = intervalMs;
|
|
407
|
+
if (startAt) {
|
|
408
|
+
const startTime = new Date(startAt);
|
|
409
|
+
if (!isNaN(startTime.getTime())) {
|
|
410
|
+
const now = Date.now();
|
|
411
|
+
const startMs = startTime.getTime();
|
|
412
|
+
if (startMs > now) {
|
|
413
|
+
delay = startMs - now;
|
|
414
|
+
} else {
|
|
415
|
+
const elapsed = now - startMs;
|
|
416
|
+
delay = intervalMs - elapsed % intervalMs;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
const timer = safeSetTimeout(() => {
|
|
421
|
+
this.fireTick(job.id, /* @__PURE__ */ new Date());
|
|
422
|
+
const interval = setInterval(() => this.fireTick(job.id, /* @__PURE__ */ new Date()), intervalMs);
|
|
423
|
+
this.entries.set(job.id, { stop: () => clearInterval(interval) });
|
|
424
|
+
}, delay);
|
|
425
|
+
this.entries.set(job.id, {
|
|
426
|
+
stop: () => timer.clear()
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
scheduleOneShot(job, runAt) {
|
|
430
|
+
const t = new Date(runAt);
|
|
431
|
+
if (isNaN(t.getTime())) return;
|
|
432
|
+
const delay = t.getTime() - Date.now();
|
|
433
|
+
if (delay <= 0) {
|
|
434
|
+
if (job.catchup !== "skip") {
|
|
435
|
+
setImmediate(() => {
|
|
436
|
+
this.fireTick(job.id, t);
|
|
437
|
+
this.entries.delete(job.id);
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
const timer = safeSetTimeout(() => {
|
|
443
|
+
this.fireTick(job.id, t);
|
|
444
|
+
this.entries.delete(job.id);
|
|
445
|
+
}, delay);
|
|
446
|
+
this.entries.set(job.id, { stop: () => timer.clear() });
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
var MAX_SAFE_TIMEOUT_MS = 2e9;
|
|
450
|
+
function safeSetTimeout(cb, ms) {
|
|
451
|
+
if (ms <= MAX_SAFE_TIMEOUT_MS) {
|
|
452
|
+
const t2 = setTimeout(cb, ms);
|
|
453
|
+
return { clear: () => clearTimeout(t2) };
|
|
454
|
+
}
|
|
455
|
+
let inner;
|
|
456
|
+
const t = setTimeout(() => {
|
|
457
|
+
inner = safeSetTimeout(cb, ms - MAX_SAFE_TIMEOUT_MS);
|
|
458
|
+
}, MAX_SAFE_TIMEOUT_MS);
|
|
459
|
+
return {
|
|
460
|
+
clear: () => {
|
|
461
|
+
clearTimeout(t);
|
|
462
|
+
inner?.clear();
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
function cronNextN(pattern, tz, n) {
|
|
467
|
+
try {
|
|
468
|
+
const options = { paused: true };
|
|
469
|
+
if (tz) options.timezone = tz;
|
|
470
|
+
const cron = new Cron(pattern, options);
|
|
471
|
+
const results = [];
|
|
472
|
+
let ref;
|
|
473
|
+
for (let i = 0; i < n; i++) {
|
|
474
|
+
const next = cron.nextRun(ref);
|
|
475
|
+
if (!next) break;
|
|
476
|
+
results.push(next.toISOString());
|
|
477
|
+
ref = new Date(next.getTime() + 1);
|
|
478
|
+
}
|
|
479
|
+
cron.stop();
|
|
480
|
+
return results;
|
|
481
|
+
} catch {
|
|
482
|
+
return [];
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
function missedCronFires(pattern, tz, from, to) {
|
|
486
|
+
try {
|
|
487
|
+
const options = { paused: true };
|
|
488
|
+
if (tz) options.timezone = tz;
|
|
489
|
+
const cron = new Cron(pattern, options);
|
|
490
|
+
const missed = [];
|
|
491
|
+
let ref = from;
|
|
492
|
+
for (; ; ) {
|
|
493
|
+
const next = cron.nextRun(ref);
|
|
494
|
+
if (!next || next > to) break;
|
|
495
|
+
missed.push(next);
|
|
496
|
+
ref = new Date(next.getTime() + 1);
|
|
497
|
+
if (missed.length > 1e3) break;
|
|
498
|
+
}
|
|
499
|
+
cron.stop();
|
|
500
|
+
return missed;
|
|
501
|
+
} catch {
|
|
502
|
+
return [];
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// src/daemon/runner.ts
|
|
507
|
+
import { spawn } from "child_process";
|
|
508
|
+
import { writeFileSync as writeFileSync2, unlinkSync as unlinkSync2, existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2 } from "fs";
|
|
509
|
+
import { tmpdir, platform } from "os";
|
|
510
|
+
import { join as join2, isAbsolute } from "path";
|
|
511
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
512
|
+
var SECRET_PATTERNS = [
|
|
513
|
+
/(?:AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN)=[^\s]*/gi,
|
|
514
|
+
/(?:GITHUB_TOKEN|GH_TOKEN)=[^\s]*/gi,
|
|
515
|
+
/(?:GCLOUD_SERVICE_KEY|GOOGLE_CREDENTIALS)=[^\s]*/gi,
|
|
516
|
+
/Bearer\s+[A-Za-z0-9\-._~+/]+=*/gi,
|
|
517
|
+
/ghp_[A-Za-z0-9]{36}/g,
|
|
518
|
+
/AKIA[0-9A-Z]{16}/g
|
|
519
|
+
];
|
|
520
|
+
function redact(text) {
|
|
521
|
+
let out = text;
|
|
522
|
+
for (const pat of SECRET_PATTERNS) {
|
|
523
|
+
out = out.replace(pat, "[REDACTED]");
|
|
524
|
+
}
|
|
525
|
+
return out;
|
|
526
|
+
}
|
|
527
|
+
function safeRedact(chunk) {
|
|
528
|
+
if (chunk.includes(0)) return chunk;
|
|
529
|
+
const str = chunk.toString("utf8");
|
|
530
|
+
if (!Buffer.from(str, "utf8").equals(chunk)) return chunk;
|
|
531
|
+
const cleaned = redact(str);
|
|
532
|
+
return Buffer.from(cleaned, "utf8");
|
|
533
|
+
}
|
|
534
|
+
function parseEnvFile(contents) {
|
|
535
|
+
const result = {};
|
|
536
|
+
for (const raw of contents.split(/\r?\n/)) {
|
|
537
|
+
const line = raw.trim();
|
|
538
|
+
if (!line || line.startsWith("#")) continue;
|
|
539
|
+
const eq = line.indexOf("=");
|
|
540
|
+
if (eq === -1) continue;
|
|
541
|
+
const key = line.slice(0, eq).trim();
|
|
542
|
+
let value = line.slice(eq + 1).trim();
|
|
543
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
544
|
+
value = value.slice(1, -1);
|
|
545
|
+
}
|
|
546
|
+
if (key) result[key] = value;
|
|
547
|
+
}
|
|
548
|
+
return result;
|
|
549
|
+
}
|
|
550
|
+
var Runner = class {
|
|
551
|
+
/** Per-job queues for overlap=queue policy */
|
|
552
|
+
queues = /* @__PURE__ */ new Map();
|
|
553
|
+
/** Abort controllers for currently active runs per job */
|
|
554
|
+
activeAborts = /* @__PURE__ */ new Map();
|
|
555
|
+
/** Active run IDs per job */
|
|
556
|
+
activeRunIds = /* @__PURE__ */ new Map();
|
|
557
|
+
/** Whether a job's queue is currently being drained */
|
|
558
|
+
draining = /* @__PURE__ */ new Set();
|
|
559
|
+
/**
|
|
560
|
+
* Execute a job run, honouring overlap + retry + budget policies.
|
|
561
|
+
* The run record must already exist in the store (status=queued).
|
|
562
|
+
*/
|
|
563
|
+
async run(job, runId, store) {
|
|
564
|
+
const overlap = job.overlap ?? "skip";
|
|
565
|
+
if (job.budgets?.maxRunsPerDay != null) {
|
|
566
|
+
const todayStart = /* @__PURE__ */ new Date();
|
|
567
|
+
todayStart.setUTCHours(0, 0, 0, 0);
|
|
568
|
+
const count = store.countRunsSince(job.id, todayStart.getTime(), runId);
|
|
569
|
+
if (count >= job.budgets.maxRunsPerDay) {
|
|
570
|
+
await this.finalizeRun(store, runId, {
|
|
571
|
+
status: "canceled",
|
|
572
|
+
error: `budget: maxRunsPerDay (${job.budgets.maxRunsPerDay}) exceeded`
|
|
573
|
+
});
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
const isActive = this.activeRunIds.has(job.id);
|
|
578
|
+
if (overlap === "skip" && isActive) {
|
|
579
|
+
await this.finalizeRun(store, runId, {
|
|
580
|
+
status: "canceled",
|
|
581
|
+
error: "overlap=skip: another run is already active"
|
|
582
|
+
});
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (overlap === "cancel-previous" && isActive) {
|
|
586
|
+
const ctrl = this.activeAborts.get(job.id);
|
|
587
|
+
if (ctrl) ctrl.abort();
|
|
588
|
+
}
|
|
589
|
+
if (overlap === "queue") {
|
|
590
|
+
await this.enqueue(job, runId, store);
|
|
591
|
+
} else {
|
|
592
|
+
await this.execute(job, runId, store);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
enqueue(job, runId, store) {
|
|
596
|
+
return new Promise((resolve) => {
|
|
597
|
+
const queue = this.queues.get(job.id) ?? [];
|
|
598
|
+
queue.push(async () => {
|
|
599
|
+
await this.execute(job, runId, store);
|
|
600
|
+
resolve();
|
|
601
|
+
});
|
|
602
|
+
this.queues.set(job.id, queue);
|
|
603
|
+
if (!this.draining.has(job.id)) {
|
|
604
|
+
this.drainQueue(job.id);
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
async drainQueue(jobId) {
|
|
609
|
+
this.draining.add(jobId);
|
|
610
|
+
const queue = this.queues.get(jobId);
|
|
611
|
+
if (!queue || queue.length === 0) {
|
|
612
|
+
this.draining.delete(jobId);
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
const next = queue.shift();
|
|
616
|
+
try {
|
|
617
|
+
await next();
|
|
618
|
+
} catch {
|
|
619
|
+
}
|
|
620
|
+
await this.drainQueue(jobId);
|
|
621
|
+
}
|
|
622
|
+
async execute(job, runId, store) {
|
|
623
|
+
const maxRetries = job.retry?.max ?? 0;
|
|
624
|
+
const backoffSec = job.retry?.backoffSec ?? 30;
|
|
625
|
+
let lastResult = { status: "failed", error: "not started" };
|
|
626
|
+
store.updateRun(runId, { status: "running" });
|
|
627
|
+
const ctrl = new AbortController();
|
|
628
|
+
this.activeAborts.set(job.id, ctrl);
|
|
629
|
+
this.activeRunIds.set(job.id, runId);
|
|
630
|
+
try {
|
|
631
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
632
|
+
if (attempt > 0) {
|
|
633
|
+
await sleep(backoffSec * 1e3);
|
|
634
|
+
}
|
|
635
|
+
if (ctrl.signal.aborted) {
|
|
636
|
+
lastResult = { status: "canceled", error: "canceled before retry" };
|
|
637
|
+
break;
|
|
638
|
+
}
|
|
639
|
+
lastResult = await this.spawn(job, runId, store, ctrl.signal);
|
|
640
|
+
if (lastResult.status === "success") break;
|
|
641
|
+
if (lastResult.status === "canceled" || lastResult.status === "timeout") break;
|
|
642
|
+
}
|
|
643
|
+
} finally {
|
|
644
|
+
if (this.activeAborts.get(job.id) === ctrl) this.activeAborts.delete(job.id);
|
|
645
|
+
if (this.activeRunIds.get(job.id) === runId) this.activeRunIds.delete(job.id);
|
|
646
|
+
}
|
|
647
|
+
await this.finalizeRun(store, runId, lastResult);
|
|
648
|
+
}
|
|
649
|
+
async spawn(job, runId, store, signal) {
|
|
650
|
+
const { action } = job;
|
|
651
|
+
let tmpFile;
|
|
652
|
+
try {
|
|
653
|
+
let cmd;
|
|
654
|
+
let args;
|
|
655
|
+
if (action.kind === "script") {
|
|
656
|
+
const ext = resolveShellExt(action.shell ?? "auto");
|
|
657
|
+
const tmpDir = join2(tmpdir(), "crontick");
|
|
658
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
659
|
+
tmpFile = join2(tmpDir, `${randomUUID2()}${ext}`);
|
|
660
|
+
writeFileSync2(tmpFile, action.script, { encoding: "utf-8", mode: 448 });
|
|
661
|
+
const resolved = resolveShell(action.shell ?? "auto");
|
|
662
|
+
if (resolved === "pwsh") {
|
|
663
|
+
cmd = "pwsh";
|
|
664
|
+
args = ["-NoProfile", "-NonInteractive", "-File", tmpFile];
|
|
665
|
+
} else if (resolved === "cmd") {
|
|
666
|
+
cmd = "cmd";
|
|
667
|
+
args = ["/c", tmpFile];
|
|
668
|
+
} else {
|
|
669
|
+
cmd = "bash";
|
|
670
|
+
args = [tmpFile];
|
|
671
|
+
}
|
|
672
|
+
} else {
|
|
673
|
+
cmd = action.command;
|
|
674
|
+
args = action.args ?? [];
|
|
675
|
+
}
|
|
676
|
+
const spawnOpts = {
|
|
677
|
+
cwd: action.cwd ?? process.cwd(),
|
|
678
|
+
env: { ...process.env, ...action.env ?? {} },
|
|
679
|
+
signal,
|
|
680
|
+
shell: false
|
|
681
|
+
};
|
|
682
|
+
if (action.envFile) {
|
|
683
|
+
const envFilePath = isAbsolute(action.envFile) ? action.envFile : join2(action.cwd ?? process.cwd(), action.envFile);
|
|
684
|
+
try {
|
|
685
|
+
const fileContents = readFileSync2(envFilePath, "utf-8");
|
|
686
|
+
const envFileVars = parseEnvFile(fileContents);
|
|
687
|
+
spawnOpts.env = {
|
|
688
|
+
...process.env,
|
|
689
|
+
...envFileVars,
|
|
690
|
+
...action.env ?? {}
|
|
691
|
+
};
|
|
692
|
+
} catch (err) {
|
|
693
|
+
throw new CrontickError("ENV_FILE_ERROR", `Failed to load envFile: ${String(err)}`);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
if (action.timeoutSec) {
|
|
697
|
+
spawnOpts.timeout = action.timeoutSec * 1e3;
|
|
698
|
+
}
|
|
699
|
+
const result = await new Promise((resolve) => {
|
|
700
|
+
const child = spawn(cmd, args, spawnOpts);
|
|
701
|
+
const startedAt = Date.now();
|
|
702
|
+
child.stdout?.on("data", (chunk) => {
|
|
703
|
+
store.appendLog(runId, "stdout", safeRedact(chunk));
|
|
704
|
+
});
|
|
705
|
+
child.stderr?.on("data", (chunk) => {
|
|
706
|
+
store.appendLog(runId, "stderr", safeRedact(chunk));
|
|
707
|
+
});
|
|
708
|
+
child.on("close", (code, sig) => {
|
|
709
|
+
const durationMs = Date.now() - startedAt;
|
|
710
|
+
if (sig === "SIGTERM" || sig === "SIGKILL") {
|
|
711
|
+
resolve({ status: "canceled", error: `killed by signal ${sig}` });
|
|
712
|
+
} else if (code === null) {
|
|
713
|
+
resolve({ status: "failed", error: "process exited without code" });
|
|
714
|
+
} else {
|
|
715
|
+
resolve({
|
|
716
|
+
status: code === 0 ? "success" : "failed",
|
|
717
|
+
exitCode: code
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
void durationMs;
|
|
721
|
+
});
|
|
722
|
+
child.on("error", (err) => {
|
|
723
|
+
if (err.code === "ABORT_ERR" || signal.aborted) {
|
|
724
|
+
resolve({ status: "canceled", error: "aborted" });
|
|
725
|
+
} else if (err.code === "ETIMEDOUT") {
|
|
726
|
+
resolve({ status: "timeout", error: "timed out" });
|
|
727
|
+
} else {
|
|
728
|
+
resolve({ status: "failed", error: err.message });
|
|
729
|
+
}
|
|
730
|
+
});
|
|
731
|
+
});
|
|
732
|
+
return result;
|
|
733
|
+
} finally {
|
|
734
|
+
if (tmpFile && existsSync2(tmpFile)) {
|
|
735
|
+
try {
|
|
736
|
+
unlinkSync2(tmpFile);
|
|
737
|
+
} catch {
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
async finalizeRun(store, runId, result) {
|
|
743
|
+
const run = store.getRun(runId);
|
|
744
|
+
const now = Date.now();
|
|
745
|
+
store.updateRun(runId, {
|
|
746
|
+
status: result.status,
|
|
747
|
+
exitCode: result.exitCode,
|
|
748
|
+
error: result.error,
|
|
749
|
+
endedAt: now,
|
|
750
|
+
durationMs: run ? now - run.startedAt : void 0
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
/** Cancel any active run for a job. */
|
|
754
|
+
cancelJob(jobId) {
|
|
755
|
+
const ctrl = this.activeAborts.get(jobId);
|
|
756
|
+
if (ctrl) {
|
|
757
|
+
ctrl.abort();
|
|
758
|
+
return true;
|
|
759
|
+
}
|
|
760
|
+
return false;
|
|
761
|
+
}
|
|
762
|
+
/** Cancel an active run by run ID. */
|
|
763
|
+
cancelRun(runId) {
|
|
764
|
+
for (const [jobId, rId] of this.activeRunIds.entries()) {
|
|
765
|
+
if (rId === runId) {
|
|
766
|
+
return this.cancelJob(jobId);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
return false;
|
|
770
|
+
}
|
|
771
|
+
};
|
|
772
|
+
function resolveShell(shell) {
|
|
773
|
+
if (shell === "auto") {
|
|
774
|
+
return platform() === "win32" ? "pwsh" : "bash";
|
|
775
|
+
}
|
|
776
|
+
if (shell === "pwsh") return "pwsh";
|
|
777
|
+
if (shell === "cmd") return "cmd";
|
|
778
|
+
return "bash";
|
|
779
|
+
}
|
|
780
|
+
function resolveShellExt(shell) {
|
|
781
|
+
const resolved = resolveShell(shell);
|
|
782
|
+
if (resolved === "pwsh") return ".ps1";
|
|
783
|
+
if (resolved === "cmd") return ".bat";
|
|
784
|
+
return ".sh";
|
|
785
|
+
}
|
|
786
|
+
function sleep(ms) {
|
|
787
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// src/daemon/api.ts
|
|
791
|
+
import http from "http";
|
|
792
|
+
import { createReadStream, existsSync as fsExistsSync, statSync } from "fs";
|
|
793
|
+
import {
|
|
794
|
+
extname,
|
|
795
|
+
resolve as pathResolve,
|
|
796
|
+
join as pathJoin,
|
|
797
|
+
normalize,
|
|
798
|
+
sep as pathSep
|
|
799
|
+
} from "path";
|
|
800
|
+
import { URL } from "url";
|
|
801
|
+
import { fileURLToPath } from "url";
|
|
802
|
+
var LOOPBACK = /* @__PURE__ */ new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
|
|
803
|
+
var SSE_POLL_MS = 200;
|
|
804
|
+
var MIME_TYPES = {
|
|
805
|
+
".html": "text/html; charset=utf-8",
|
|
806
|
+
".js": "application/javascript; charset=utf-8",
|
|
807
|
+
".css": "text/css; charset=utf-8",
|
|
808
|
+
".json": "application/json; charset=utf-8",
|
|
809
|
+
".png": "image/png",
|
|
810
|
+
".svg": "image/svg+xml",
|
|
811
|
+
".ico": "image/x-icon"
|
|
812
|
+
};
|
|
813
|
+
function createApiServer(ctx) {
|
|
814
|
+
const server = http.createServer((req, res) => {
|
|
815
|
+
const remote = req.socket.remoteAddress ?? "";
|
|
816
|
+
if (!LOOPBACK.has(remote)) {
|
|
817
|
+
return sendError(res, 403, "FORBIDDEN", "Only localhost connections are allowed");
|
|
818
|
+
}
|
|
819
|
+
void handleRequest(req, res, ctx);
|
|
820
|
+
});
|
|
821
|
+
return server;
|
|
822
|
+
}
|
|
823
|
+
async function handleRequest(req, res, ctx) {
|
|
824
|
+
const method = req.method ?? "GET";
|
|
825
|
+
const rawUrl = req.url ?? "/";
|
|
826
|
+
const baseUrl = `http://127.0.0.1`;
|
|
827
|
+
const url = new URL(rawUrl, baseUrl);
|
|
828
|
+
const path = url.pathname;
|
|
829
|
+
try {
|
|
830
|
+
if (method === "GET" && path === "/health") {
|
|
831
|
+
const jobs = ctx.store.listJobs();
|
|
832
|
+
const since24h = Date.now() - 24 * 60 * 60 * 1e3;
|
|
833
|
+
const runs24h = ctx.store.listRuns({ since: since24h });
|
|
834
|
+
return sendJson(res, 200, {
|
|
835
|
+
ok: true,
|
|
836
|
+
version: VERSION,
|
|
837
|
+
uptimeSec: Math.floor((Date.now() - ctx.startedAt.getTime()) / 1e3),
|
|
838
|
+
pid: process.pid,
|
|
839
|
+
port: ctx.port,
|
|
840
|
+
jobs: {
|
|
841
|
+
total: jobs.length,
|
|
842
|
+
enabled: jobs.filter((j) => j.enabled).length
|
|
843
|
+
},
|
|
844
|
+
runs: {
|
|
845
|
+
last24h: runs24h.length,
|
|
846
|
+
failures24h: runs24h.filter((r) => r.status === "failed").length
|
|
847
|
+
},
|
|
848
|
+
node: process.versions.node,
|
|
849
|
+
platform: process.platform
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
if (method === "GET" && path === "/api/jobs") {
|
|
853
|
+
return sendJson(res, 200, ctx.store.listJobs());
|
|
854
|
+
}
|
|
855
|
+
if (method === "POST" && path === "/api/jobs") {
|
|
856
|
+
const body = await readBody(req);
|
|
857
|
+
const parsed = JobSchema.safeParse(body);
|
|
858
|
+
if (!parsed.success) {
|
|
859
|
+
return sendError(res, 400, "VALIDATION_ERROR", "Invalid job", parsed.error.format());
|
|
860
|
+
}
|
|
861
|
+
ctx.store.upsertJob(parsed.data);
|
|
862
|
+
ctx.scheduler.schedule(parsed.data, ctx.store);
|
|
863
|
+
return sendJson(res, 201, parsed.data);
|
|
864
|
+
}
|
|
865
|
+
const jobMatch = path.match(/^\/api\/jobs\/([^/]+)(\/.*)?$/);
|
|
866
|
+
if (jobMatch) {
|
|
867
|
+
const id = decodeURIComponent(jobMatch[1]);
|
|
868
|
+
const sub = jobMatch[2] ?? "";
|
|
869
|
+
if (method === "GET" && sub === "") {
|
|
870
|
+
const job = ctx.store.getJob(id);
|
|
871
|
+
if (!job) return sendError(res, 404, "NOT_FOUND", `Job ${id} not found`);
|
|
872
|
+
return sendJson(res, 200, job);
|
|
873
|
+
}
|
|
874
|
+
if (method === "PUT" && sub === "") {
|
|
875
|
+
const existing = ctx.store.getJob(id);
|
|
876
|
+
if (!existing) return sendError(res, 404, "NOT_FOUND", `Job ${id} not found`);
|
|
877
|
+
const body = await readBody(req);
|
|
878
|
+
const parsed = JobSchema.safeParse({ ...existing, ...body, id });
|
|
879
|
+
if (!parsed.success) {
|
|
880
|
+
return sendError(res, 400, "VALIDATION_ERROR", "Invalid job", parsed.error.format());
|
|
881
|
+
}
|
|
882
|
+
ctx.store.upsertJob(parsed.data);
|
|
883
|
+
ctx.scheduler.schedule(parsed.data, ctx.store);
|
|
884
|
+
return sendJson(res, 200, parsed.data);
|
|
885
|
+
}
|
|
886
|
+
if (method === "DELETE" && sub === "") {
|
|
887
|
+
const deleted = ctx.store.deleteJob(id);
|
|
888
|
+
if (!deleted) return sendError(res, 404, "NOT_FOUND", `Job ${id} not found`);
|
|
889
|
+
ctx.scheduler.unschedule(id);
|
|
890
|
+
return sendJson(res, 200, { ok: true });
|
|
891
|
+
}
|
|
892
|
+
if (method === "POST" && sub === "/enable") {
|
|
893
|
+
const job = ctx.store.getJob(id);
|
|
894
|
+
if (!job) return sendError(res, 404, "NOT_FOUND", `Job ${id} not found`);
|
|
895
|
+
const updated = { ...job, enabled: true };
|
|
896
|
+
ctx.store.upsertJob(updated);
|
|
897
|
+
ctx.scheduler.schedule(updated, ctx.store);
|
|
898
|
+
return sendJson(res, 200, updated);
|
|
899
|
+
}
|
|
900
|
+
if (method === "POST" && sub === "/disable") {
|
|
901
|
+
const job = ctx.store.getJob(id);
|
|
902
|
+
if (!job) return sendError(res, 404, "NOT_FOUND", `Job ${id} not found`);
|
|
903
|
+
const updated = { ...job, enabled: false };
|
|
904
|
+
ctx.store.upsertJob(updated);
|
|
905
|
+
ctx.scheduler.unschedule(id);
|
|
906
|
+
return sendJson(res, 200, updated);
|
|
907
|
+
}
|
|
908
|
+
if (method === "POST" && sub === "/run") {
|
|
909
|
+
const job = ctx.store.getJob(id);
|
|
910
|
+
if (!job) return sendError(res, 404, "NOT_FOUND", `Job ${id} not found`);
|
|
911
|
+
const run = ctx.store.insertRun(id);
|
|
912
|
+
ctx.runner.run(job, run.id, ctx.store).catch(() => {
|
|
913
|
+
});
|
|
914
|
+
return sendJson(res, 202, { runId: run.id });
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
if (method === "GET" && path === "/api/runs") {
|
|
918
|
+
const jobId = url.searchParams.get("jobId") ?? void 0;
|
|
919
|
+
const limit = url.searchParams.has("limit") ? parseInt(url.searchParams.get("limit"), 10) : void 0;
|
|
920
|
+
const since = url.searchParams.has("since") ? parseInt(url.searchParams.get("since"), 10) : void 0;
|
|
921
|
+
return sendJson(res, 200, ctx.store.listRuns({ jobId, limit, since }));
|
|
922
|
+
}
|
|
923
|
+
const runMatch = path.match(/^\/api\/runs\/([^/]+)(\/.*)?$/);
|
|
924
|
+
if (runMatch) {
|
|
925
|
+
const id = decodeURIComponent(runMatch[1]);
|
|
926
|
+
const sub = runMatch[2] ?? "";
|
|
927
|
+
if (method === "GET" && sub === "") {
|
|
928
|
+
const run = ctx.store.getRun(id);
|
|
929
|
+
if (!run) return sendError(res, 404, "NOT_FOUND", `Run ${id} not found`);
|
|
930
|
+
return sendJson(res, 200, run);
|
|
931
|
+
}
|
|
932
|
+
if (method === "POST" && sub === "/cancel") {
|
|
933
|
+
const run = ctx.store.getRun(id);
|
|
934
|
+
if (!run) return sendError(res, 404, "NOT_FOUND", `Run ${id} not found`);
|
|
935
|
+
const canceled = ctx.runner.cancelRun(id);
|
|
936
|
+
return sendJson(res, 200, { ok: true, canceled });
|
|
937
|
+
}
|
|
938
|
+
if (method === "GET" && sub === "/logs") {
|
|
939
|
+
const run = ctx.store.getRun(id);
|
|
940
|
+
if (!run) return sendError(res, 404, "NOT_FOUND", `Run ${id} not found`);
|
|
941
|
+
const logs = ctx.store.getLogs(id);
|
|
942
|
+
return sendJson(res, 200, logs.map((l) => ({
|
|
943
|
+
runId: l.runId,
|
|
944
|
+
stream: l.stream,
|
|
945
|
+
ts: l.ts,
|
|
946
|
+
data: l.chunk.toString("utf-8")
|
|
947
|
+
})));
|
|
948
|
+
}
|
|
949
|
+
if (method === "GET" && sub === "/logs/stream") {
|
|
950
|
+
const run = ctx.store.getRun(id);
|
|
951
|
+
if (!run) return sendError(res, 404, "NOT_FOUND", `Run ${id} not found`);
|
|
952
|
+
return streamLogs(req, res, id, ctx);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
if (method === "POST" && path === "/api/schedules/validate") {
|
|
956
|
+
const body = await readBody(req);
|
|
957
|
+
const { ScheduleSchema } = await import("../job-JQGLDEJV.js");
|
|
958
|
+
const parsed = ScheduleSchema.safeParse(body);
|
|
959
|
+
if (!parsed.success) {
|
|
960
|
+
return sendJson(res, 200, { ok: false, error: JSON.stringify(parsed.error.format()) });
|
|
961
|
+
}
|
|
962
|
+
const result = ctx.scheduler.validateSchedule(parsed.data);
|
|
963
|
+
return sendJson(res, 200, result);
|
|
964
|
+
}
|
|
965
|
+
if (method === "POST" && path === "/api/schedules/preview") {
|
|
966
|
+
const body = await readBody(req);
|
|
967
|
+
const { ScheduleSchema } = await import("../job-JQGLDEJV.js");
|
|
968
|
+
const scheduleResult = ScheduleSchema.safeParse(body?.schedule ?? body);
|
|
969
|
+
if (!scheduleResult.success) {
|
|
970
|
+
return sendError(res, 400, "VALIDATION_ERROR", "Invalid schedule");
|
|
971
|
+
}
|
|
972
|
+
const n = typeof body?.n === "number" ? body.n : 5;
|
|
973
|
+
const tz = body?.tz;
|
|
974
|
+
const next = ctx.scheduler.previewNext(scheduleResult.data, { n, tz });
|
|
975
|
+
return sendJson(res, 200, { next });
|
|
976
|
+
}
|
|
977
|
+
if (method === "GET" && path === "/api/stats/summary") {
|
|
978
|
+
const jobs = ctx.store.listJobs();
|
|
979
|
+
const runs = ctx.store.listRuns({ limit: 1e3 });
|
|
980
|
+
const failed = runs.filter((r) => r.status === "failed").length;
|
|
981
|
+
const succeeded = runs.filter((r) => r.status === "success").length;
|
|
982
|
+
return sendJson(res, 200, {
|
|
983
|
+
totalJobs: jobs.length,
|
|
984
|
+
enabledJobs: jobs.filter((j) => j.enabled).length,
|
|
985
|
+
totalRuns: runs.length,
|
|
986
|
+
succeeded,
|
|
987
|
+
failed,
|
|
988
|
+
avgDurationMs: runs.length > 0 ? Math.round(runs.reduce((s, r) => s + (r.durationMs ?? 0), 0) / runs.length) : null
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
const statsJobMatch = path.match(/^\/api\/stats\/jobs\/([^/]+)$/);
|
|
992
|
+
if (method === "GET" && statsJobMatch) {
|
|
993
|
+
const id = decodeURIComponent(statsJobMatch[1]);
|
|
994
|
+
const job = ctx.store.getJob(id);
|
|
995
|
+
if (!job) return sendError(res, 404, "NOT_FOUND", `Job ${id} not found`);
|
|
996
|
+
const runs = ctx.store.listRuns({ jobId: id, limit: 100 });
|
|
997
|
+
return sendJson(res, 200, {
|
|
998
|
+
jobId: id,
|
|
999
|
+
totalRuns: runs.length,
|
|
1000
|
+
succeeded: runs.filter((r) => r.status === "success").length,
|
|
1001
|
+
failed: runs.filter((r) => r.status === "failed").length,
|
|
1002
|
+
lastStatus: runs[0]?.status ?? null,
|
|
1003
|
+
lastRunAt: runs[0]?.startedAt ?? null
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
if (method === "GET" && path === "/api/daemon/status") {
|
|
1007
|
+
return sendJson(res, 200, {
|
|
1008
|
+
pid: process.pid,
|
|
1009
|
+
version: VERSION,
|
|
1010
|
+
uptimeSec: Math.floor((Date.now() - ctx.startedAt.getTime()) / 1e3),
|
|
1011
|
+
jobs: ctx.store.listJobs().length
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
if (method === "POST" && path === "/api/daemon/reload") {
|
|
1015
|
+
await ctx.reload();
|
|
1016
|
+
return sendJson(res, 200, { ok: true });
|
|
1017
|
+
}
|
|
1018
|
+
if (method === "GET" && path === "/api/export") {
|
|
1019
|
+
return sendJson(res, 200, { jobs: ctx.store.listJobs() });
|
|
1020
|
+
}
|
|
1021
|
+
if (method === "POST" && path === "/api/import") {
|
|
1022
|
+
const body = await readBody(req);
|
|
1023
|
+
const jobs = Array.isArray(body?.jobs) ? body.jobs : [];
|
|
1024
|
+
const results = [];
|
|
1025
|
+
for (const raw of jobs) {
|
|
1026
|
+
const parsed = JobSchema.safeParse(raw);
|
|
1027
|
+
if (parsed.success) {
|
|
1028
|
+
ctx.store.upsertJob(parsed.data);
|
|
1029
|
+
ctx.scheduler.schedule(parsed.data, ctx.store);
|
|
1030
|
+
results.push({ id: parsed.data.id, ok: true });
|
|
1031
|
+
} else {
|
|
1032
|
+
results.push({ id: String(raw?.id ?? "?"), ok: false, error: "validation failed" });
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
return sendJson(res, 200, { imported: results.filter((r) => r.ok).length, results });
|
|
1036
|
+
}
|
|
1037
|
+
if (method === "GET" && path === "/api/autostart/status") {
|
|
1038
|
+
const backend = url.searchParams.get("backend") ?? void 0;
|
|
1039
|
+
const autostart = createAutostart({ backend });
|
|
1040
|
+
const result = await autostart.status();
|
|
1041
|
+
return sendJson(res, 200, result);
|
|
1042
|
+
}
|
|
1043
|
+
if (method === "POST" && path === "/api/autostart/install") {
|
|
1044
|
+
const body = await readBody(req);
|
|
1045
|
+
const backend = body?.["backend"];
|
|
1046
|
+
const autostart = createAutostart({ backend });
|
|
1047
|
+
const result = await autostart.install();
|
|
1048
|
+
return sendJson(res, 200, result);
|
|
1049
|
+
}
|
|
1050
|
+
if (method === "POST" && path === "/api/autostart/remove") {
|
|
1051
|
+
const body = await readBody(req);
|
|
1052
|
+
const backend = body?.["backend"];
|
|
1053
|
+
const autostart = createAutostart({ backend });
|
|
1054
|
+
const result = await autostart.remove();
|
|
1055
|
+
return sendJson(res, 200, result);
|
|
1056
|
+
}
|
|
1057
|
+
if (method === "GET" && (path === "/" || path === "/dashboard" || path.startsWith("/dashboard/"))) {
|
|
1058
|
+
return serveDashboard(res, path);
|
|
1059
|
+
}
|
|
1060
|
+
return sendError(res, 404, "NOT_FOUND", `${method} ${path} not found`);
|
|
1061
|
+
} catch (err) {
|
|
1062
|
+
if (err instanceof NotImplementedInV1Error) {
|
|
1063
|
+
return sendError(res, 501, "NOT_IMPLEMENTED_V1", err.message);
|
|
1064
|
+
}
|
|
1065
|
+
if (err instanceof CrontickError) {
|
|
1066
|
+
return sendError(res, 400, err.code, err.message, err.details);
|
|
1067
|
+
}
|
|
1068
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1069
|
+
return sendError(res, 500, "INTERNAL_ERROR", msg);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
function streamLogs(req, res, runId, ctx) {
|
|
1073
|
+
res.writeHead(200, {
|
|
1074
|
+
"Content-Type": "text/event-stream",
|
|
1075
|
+
"Cache-Control": "no-cache",
|
|
1076
|
+
Connection: "keep-alive"
|
|
1077
|
+
});
|
|
1078
|
+
let lastTs = 0;
|
|
1079
|
+
const existing = ctx.store.getLogs(runId);
|
|
1080
|
+
for (const log of existing) {
|
|
1081
|
+
sseEvent(res, { stream: log.stream, ts: log.ts, data: log.chunk.toString("utf-8") });
|
|
1082
|
+
if (log.ts > lastTs) lastTs = log.ts;
|
|
1083
|
+
}
|
|
1084
|
+
const poll = setInterval(() => {
|
|
1085
|
+
const run = ctx.store.getRun(runId);
|
|
1086
|
+
const newLogs = ctx.store.tailLogs(runId, lastTs);
|
|
1087
|
+
for (const log of newLogs) {
|
|
1088
|
+
sseEvent(res, { stream: log.stream, ts: log.ts, data: log.chunk.toString("utf-8") });
|
|
1089
|
+
if (log.ts > lastTs) lastTs = log.ts;
|
|
1090
|
+
}
|
|
1091
|
+
const terminal = /* @__PURE__ */ new Set(["success", "failed", "canceled", "timeout"]);
|
|
1092
|
+
if (!run || terminal.has(run.status)) {
|
|
1093
|
+
sseEvent(res, { done: true, status: run?.status });
|
|
1094
|
+
clearInterval(poll);
|
|
1095
|
+
res.end();
|
|
1096
|
+
}
|
|
1097
|
+
}, SSE_POLL_MS);
|
|
1098
|
+
req.on("close", () => {
|
|
1099
|
+
clearInterval(poll);
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
function dashboardDir() {
|
|
1103
|
+
const moduleDir = pathResolve(fileURLToPath(import.meta.url), "..");
|
|
1104
|
+
return pathResolve(moduleDir, "../dashboard");
|
|
1105
|
+
}
|
|
1106
|
+
function serveDashboard(res, reqPath) {
|
|
1107
|
+
const dashDir = dashboardDir();
|
|
1108
|
+
const indexFile = pathJoin(dashDir, "index.html");
|
|
1109
|
+
let filePath;
|
|
1110
|
+
if (reqPath === "/" || reqPath === "/dashboard" || reqPath === "/dashboard/") {
|
|
1111
|
+
filePath = indexFile;
|
|
1112
|
+
} else {
|
|
1113
|
+
const sub = reqPath.startsWith("/dashboard/") ? reqPath.slice("/dashboard".length) : reqPath;
|
|
1114
|
+
const normalizedSub = normalize(sub).replace(/^[/\\]+/, "");
|
|
1115
|
+
filePath = pathResolve(dashDir, normalizedSub);
|
|
1116
|
+
}
|
|
1117
|
+
if (filePath !== indexFile && !filePath.startsWith(`${dashDir}${pathSep}`)) {
|
|
1118
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
1119
|
+
res.end("Bad Request");
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
if (!fsExistsSync(filePath)) {
|
|
1123
|
+
filePath = indexFile;
|
|
1124
|
+
}
|
|
1125
|
+
if (!fsExistsSync(filePath)) {
|
|
1126
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
1127
|
+
res.end("Not Found");
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
const stat = statSync(filePath);
|
|
1131
|
+
if (!stat.isFile()) {
|
|
1132
|
+
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
1133
|
+
res.end("Forbidden");
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
const ext = extname(filePath).toLowerCase();
|
|
1137
|
+
const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
|
|
1138
|
+
res.writeHead(200, {
|
|
1139
|
+
"Content-Type": contentType,
|
|
1140
|
+
"Content-Length": stat.size,
|
|
1141
|
+
"Cache-Control": "no-cache"
|
|
1142
|
+
});
|
|
1143
|
+
createReadStream(filePath).pipe(res);
|
|
1144
|
+
}
|
|
1145
|
+
function sseEvent(res, data) {
|
|
1146
|
+
res.write(`data: ${JSON.stringify(data)}
|
|
1147
|
+
|
|
1148
|
+
`);
|
|
1149
|
+
}
|
|
1150
|
+
async function readBody(req) {
|
|
1151
|
+
return new Promise((resolve, reject) => {
|
|
1152
|
+
const chunks = [];
|
|
1153
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
1154
|
+
req.on("end", () => {
|
|
1155
|
+
const raw = Buffer.concat(chunks).toString("utf-8");
|
|
1156
|
+
if (!raw) {
|
|
1157
|
+
resolve({});
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
try {
|
|
1161
|
+
resolve(JSON.parse(raw));
|
|
1162
|
+
} catch {
|
|
1163
|
+
resolve({});
|
|
1164
|
+
}
|
|
1165
|
+
});
|
|
1166
|
+
req.on("error", reject);
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
function sendJson(res, status, body) {
|
|
1170
|
+
const json = JSON.stringify(body);
|
|
1171
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
1172
|
+
res.end(json);
|
|
1173
|
+
}
|
|
1174
|
+
function sendError(res, status, code, message, details) {
|
|
1175
|
+
sendJson(res, status, { error: { code, message, details } });
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// src/daemon/index.ts
|
|
1179
|
+
var nodeMajor = parseInt(process.versions.node.split(".")[0], 10);
|
|
1180
|
+
var needsSqliteShim = nodeMajor < 24 && !process.execArgv.includes("--experimental-sqlite");
|
|
1181
|
+
if (needsSqliteShim) {
|
|
1182
|
+
const child = spawn2(process.execPath, ["--experimental-sqlite", ...process.argv.slice(1)], {
|
|
1183
|
+
stdio: "inherit",
|
|
1184
|
+
env: process.env,
|
|
1185
|
+
detached: false
|
|
1186
|
+
});
|
|
1187
|
+
child.on("exit", (code) => {
|
|
1188
|
+
process.exit(code ?? 0);
|
|
1189
|
+
});
|
|
1190
|
+
} else {
|
|
1191
|
+
let log = function(level, msg, data) {
|
|
1192
|
+
const line = JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), level, msg, ...data ? { data } : {} });
|
|
1193
|
+
process.stderr.write(line + "\n");
|
|
1194
|
+
if (logFile) {
|
|
1195
|
+
try {
|
|
1196
|
+
appendFileSync(logFile, line + "\n");
|
|
1197
|
+
} catch {
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
}, checkSingleInstance = function() {
|
|
1201
|
+
const pidPath = pidFilePath();
|
|
1202
|
+
if (!existsSync3(pidPath)) return;
|
|
1203
|
+
try {
|
|
1204
|
+
const existingPid = parseInt(readFileSync3(pidPath, "utf-8").trim(), 10);
|
|
1205
|
+
if (!isNaN(existingPid)) {
|
|
1206
|
+
try {
|
|
1207
|
+
process.kill(existingPid, 0);
|
|
1208
|
+
log("error", "Daemon already running", { pid: existingPid });
|
|
1209
|
+
process.exit(1);
|
|
1210
|
+
} catch {
|
|
1211
|
+
log("warn", "Removing stale PID file", { pid: existingPid });
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
} catch {
|
|
1215
|
+
}
|
|
1216
|
+
}, cleanup = function() {
|
|
1217
|
+
for (const p of [pidFilePath(), portFilePath()]) {
|
|
1218
|
+
try {
|
|
1219
|
+
if (existsSync3(p)) unlinkSync3(p);
|
|
1220
|
+
} catch {
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
};
|
|
1224
|
+
log2 = log, checkSingleInstance2 = checkSingleInstance, cleanup2 = cleanup;
|
|
1225
|
+
let logFile = null;
|
|
1226
|
+
async function main() {
|
|
1227
|
+
ensureDirs();
|
|
1228
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
1229
|
+
logFile = join3(logsDir(), `daemon-${today}.log`);
|
|
1230
|
+
log("info", "Starting crontick daemon", { pid: process.pid, node: process.version });
|
|
1231
|
+
checkSingleInstance();
|
|
1232
|
+
writeFileSync3(pidFilePath(), String(process.pid), "utf-8");
|
|
1233
|
+
const store = new Store(runsDbPath(), jobsDir());
|
|
1234
|
+
store.open();
|
|
1235
|
+
const reconciled = store.reconcileOrphanRuns();
|
|
1236
|
+
if (reconciled > 0) {
|
|
1237
|
+
log("warn", `Reconciled ${reconciled} orphaned run(s) from previous daemon session`);
|
|
1238
|
+
}
|
|
1239
|
+
store.loadJobsFromDisk();
|
|
1240
|
+
const jobs = store.listJobs();
|
|
1241
|
+
log("info", `Loaded ${jobs.length} job(s) from disk`);
|
|
1242
|
+
const scheduler = new Scheduler();
|
|
1243
|
+
const runner = new Runner();
|
|
1244
|
+
for (const job of jobs) {
|
|
1245
|
+
if (job.enabled) scheduler.schedule(job, store);
|
|
1246
|
+
}
|
|
1247
|
+
scheduler.on("tick", ({ jobId, plannedAt }) => {
|
|
1248
|
+
const job = store.getJob(jobId);
|
|
1249
|
+
if (!job || !job.enabled) return;
|
|
1250
|
+
const run = store.insertRun(jobId, plannedAt.getTime());
|
|
1251
|
+
runner.run(job, run.id, store).catch((err) => {
|
|
1252
|
+
log("error", "Runner error", { jobId, error: String(err) });
|
|
1253
|
+
});
|
|
1254
|
+
});
|
|
1255
|
+
async function reload() {
|
|
1256
|
+
log("info", "Reloading jobs from disk");
|
|
1257
|
+
scheduler.unscheduleAll();
|
|
1258
|
+
store.loadJobsFromDisk();
|
|
1259
|
+
const reloaded = store.listJobs();
|
|
1260
|
+
for (const job of reloaded) {
|
|
1261
|
+
if (job.enabled) scheduler.schedule(job, store);
|
|
1262
|
+
}
|
|
1263
|
+
log("info", `Reloaded ${reloaded.length} job(s)`);
|
|
1264
|
+
}
|
|
1265
|
+
const startedAt = /* @__PURE__ */ new Date();
|
|
1266
|
+
const ctx = { store, scheduler, runner, startedAt, port: 0, reload };
|
|
1267
|
+
const server = createApiServer(ctx);
|
|
1268
|
+
await new Promise((resolve, reject) => {
|
|
1269
|
+
server.listen(0, "127.0.0.1", () => {
|
|
1270
|
+
const addr = server.address();
|
|
1271
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
1272
|
+
ctx.port = port;
|
|
1273
|
+
writeFileSync3(portFilePath(), String(port), "utf-8");
|
|
1274
|
+
log("info", `API listening on 127.0.0.1:${port}`);
|
|
1275
|
+
resolve();
|
|
1276
|
+
});
|
|
1277
|
+
server.on("error", reject);
|
|
1278
|
+
});
|
|
1279
|
+
let shuttingDown = false;
|
|
1280
|
+
async function shutdown(signal) {
|
|
1281
|
+
if (shuttingDown) return;
|
|
1282
|
+
shuttingDown = true;
|
|
1283
|
+
log("info", `Received ${signal}, shutting down`);
|
|
1284
|
+
server.close();
|
|
1285
|
+
scheduler.unscheduleAll();
|
|
1286
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
1287
|
+
store.close();
|
|
1288
|
+
cleanup();
|
|
1289
|
+
log("info", "Daemon stopped");
|
|
1290
|
+
process.exit(0);
|
|
1291
|
+
}
|
|
1292
|
+
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
1293
|
+
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
1294
|
+
log("info", "Daemon ready");
|
|
1295
|
+
}
|
|
1296
|
+
main().catch((err) => {
|
|
1297
|
+
process.stderr.write(`Fatal daemon error: ${String(err)}
|
|
1298
|
+
`);
|
|
1299
|
+
cleanup();
|
|
1300
|
+
process.exit(1);
|
|
1301
|
+
});
|
|
1302
|
+
}
|
|
1303
|
+
var log2;
|
|
1304
|
+
var checkSingleInstance2;
|
|
1305
|
+
var cleanup2;
|
|
1306
|
+
//# sourceMappingURL=index.js.map
|