@iletai/nzb 1.7.4 → 1.8.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/dist/cli.js +96 -0
- package/dist/config.js +4 -0
- package/dist/copilot/model-failover.js +154 -0
- package/dist/copilot/orchestrator.js +51 -0
- package/dist/copilot/tools.js +129 -0
- package/dist/cron/scheduler.js +159 -0
- package/dist/cron/task-runner.js +170 -0
- package/dist/daemon.js +5 -0
- package/dist/store/cron-store.js +142 -0
- package/dist/store/db.js +31 -0
- package/dist/telegram/bot.js +3 -0
- package/dist/telegram/handlers/commands.js +5 -0
- package/dist/telegram/handlers/cron.js +354 -0
- package/package.json +2 -1
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { Cron } from "croner";
|
|
2
|
+
import { getCronJob, listCronJobs, recordCronRun, updateCronJob, } from "../store/cron-store.js";
|
|
3
|
+
import { executeCronTask, notifyResult } from "./task-runner.js";
|
|
4
|
+
const activeTimers = new Map();
|
|
5
|
+
const runningJobs = new Set();
|
|
6
|
+
/** Start the cron scheduler: load all enabled jobs from DB and schedule them. */
|
|
7
|
+
export function startCronScheduler() {
|
|
8
|
+
const jobs = listCronJobs(true);
|
|
9
|
+
console.log(`[nzb] Cron scheduler starting with ${jobs.length} enabled job(s)`);
|
|
10
|
+
for (const job of jobs) {
|
|
11
|
+
scheduleJob(job);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** Stop the cron scheduler: cancel all active timers. */
|
|
15
|
+
export function stopCronScheduler() {
|
|
16
|
+
for (const [id, cron] of activeTimers) {
|
|
17
|
+
cron.stop();
|
|
18
|
+
console.log(`[nzb] Cron job '${id}' stopped`);
|
|
19
|
+
}
|
|
20
|
+
activeTimers.clear();
|
|
21
|
+
console.log("[nzb] Cron scheduler stopped");
|
|
22
|
+
}
|
|
23
|
+
/** Schedule (or reschedule) a single job. */
|
|
24
|
+
export function scheduleJob(job) {
|
|
25
|
+
// Unschedule if already active
|
|
26
|
+
unscheduleJob(job.id);
|
|
27
|
+
if (!job.enabled)
|
|
28
|
+
return;
|
|
29
|
+
try {
|
|
30
|
+
const cron = new Cron(job.cronExpression, { name: job.id }, () => {
|
|
31
|
+
void runJob(job.id);
|
|
32
|
+
});
|
|
33
|
+
activeTimers.set(job.id, cron);
|
|
34
|
+
// Update nextRunAt in DB
|
|
35
|
+
const nextDate = cron.nextRun();
|
|
36
|
+
if (nextDate) {
|
|
37
|
+
updateCronJob(job.id, { nextRunAt: nextDate.toISOString() });
|
|
38
|
+
}
|
|
39
|
+
console.log(`[nzb] Cron job '${job.id}' (${job.name}) scheduled: ${job.cronExpression}`);
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
console.error(`[nzb] Failed to schedule cron job '${job.id}':`, err instanceof Error ? err.message : err);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Unschedule a single job by ID. */
|
|
46
|
+
export function unscheduleJob(jobId) {
|
|
47
|
+
const existing = activeTimers.get(jobId);
|
|
48
|
+
if (existing) {
|
|
49
|
+
existing.stop();
|
|
50
|
+
activeTimers.delete(jobId);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Manually trigger a job immediately. */
|
|
54
|
+
export async function triggerJob(jobId) {
|
|
55
|
+
const job = getCronJob(jobId);
|
|
56
|
+
if (!job)
|
|
57
|
+
return `Job '${jobId}' not found.`;
|
|
58
|
+
return await runJob(jobId);
|
|
59
|
+
}
|
|
60
|
+
/** Get status of all scheduled jobs. */
|
|
61
|
+
export function getSchedulerStatus() {
|
|
62
|
+
const allJobs = listCronJobs();
|
|
63
|
+
return allJobs.map((job) => {
|
|
64
|
+
const timer = activeTimers.get(job.id);
|
|
65
|
+
const nextRun = timer?.nextRun()?.toISOString() ?? job.nextRunAt;
|
|
66
|
+
return {
|
|
67
|
+
id: job.id,
|
|
68
|
+
name: job.name,
|
|
69
|
+
cronExpression: job.cronExpression,
|
|
70
|
+
taskType: job.taskType,
|
|
71
|
+
enabled: job.enabled,
|
|
72
|
+
active: activeTimers.has(job.id),
|
|
73
|
+
nextRun,
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/** Internal: execute a job with retry, timeout, recording, and notification. */
|
|
78
|
+
async function runJob(jobId) {
|
|
79
|
+
if (runningJobs.has(jobId)) {
|
|
80
|
+
console.log(`[nzb] Cron "${jobId}" already running, skipping`);
|
|
81
|
+
return `Job '${jobId}' already running.`;
|
|
82
|
+
}
|
|
83
|
+
const job = getCronJob(jobId);
|
|
84
|
+
if (!job)
|
|
85
|
+
return `Job '${jobId}' not found.`;
|
|
86
|
+
runningJobs.add(jobId);
|
|
87
|
+
console.log(`[nzb] Cron job '${job.id}' (${job.name}) executing...`);
|
|
88
|
+
const startedAt = new Date();
|
|
89
|
+
let lastError = null;
|
|
90
|
+
const maxAttempts = job.maxRetries + 1;
|
|
91
|
+
try {
|
|
92
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
93
|
+
try {
|
|
94
|
+
// Apply timeout
|
|
95
|
+
const result = await withTaskTimeout(executeCronTask(job), job.timeoutMs);
|
|
96
|
+
// Record success
|
|
97
|
+
const finishedAt = new Date();
|
|
98
|
+
recordCronRun(jobId, "success", startedAt, finishedAt, result, null);
|
|
99
|
+
updateCronJob(jobId, { lastRunAt: finishedAt.toISOString() });
|
|
100
|
+
// Update nextRunAt
|
|
101
|
+
const timer = activeTimers.get(jobId);
|
|
102
|
+
if (timer) {
|
|
103
|
+
const nextDate = timer.nextRun();
|
|
104
|
+
if (nextDate) {
|
|
105
|
+
updateCronJob(jobId, { nextRunAt: nextDate.toISOString() });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
console.log(`[nzb] Cron job '${job.id}' completed in ${finishedAt.getTime() - startedAt.getTime()}ms`);
|
|
109
|
+
await notifyResult(job, `✅ Completed\n${result}`);
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
lastError = err instanceof Error ? err.message : String(err);
|
|
114
|
+
const isTimeout = lastError.includes("timed out");
|
|
115
|
+
if (attempt < maxAttempts && !isTimeout) {
|
|
116
|
+
console.log(`[nzb] Cron job '${job.id}' attempt ${attempt}/${maxAttempts} failed: ${lastError}. Retrying...`);
|
|
117
|
+
// Brief delay before retry
|
|
118
|
+
await new Promise((r) => setTimeout(r, 1000 * attempt));
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const finishedAt = new Date();
|
|
122
|
+
const status = isTimeout ? "timeout" : "error";
|
|
123
|
+
recordCronRun(jobId, status, startedAt, finishedAt, null, lastError);
|
|
124
|
+
updateCronJob(jobId, { lastRunAt: finishedAt.toISOString() });
|
|
125
|
+
// Update nextRunAt even on failure
|
|
126
|
+
const timer = activeTimers.get(jobId);
|
|
127
|
+
if (timer) {
|
|
128
|
+
const nextDate = timer.nextRun();
|
|
129
|
+
if (nextDate) {
|
|
130
|
+
updateCronJob(jobId, { nextRunAt: nextDate.toISOString() });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
console.error(`[nzb] Cron job '${job.id}' failed: ${lastError}`);
|
|
134
|
+
await notifyResult(job, `❌ Failed: ${lastError}`);
|
|
135
|
+
return `Error: ${lastError}`;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return `Error: ${lastError ?? "Unknown error"}`;
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
runningJobs.delete(jobId);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** Wrap a promise with a timeout. */
|
|
145
|
+
function withTaskTimeout(promise, ms) {
|
|
146
|
+
return new Promise((resolve, reject) => {
|
|
147
|
+
const timer = setTimeout(() => {
|
|
148
|
+
reject(new Error(`Task timed out after ${ms}ms`));
|
|
149
|
+
}, ms);
|
|
150
|
+
promise.then((v) => {
|
|
151
|
+
clearTimeout(timer);
|
|
152
|
+
resolve(v);
|
|
153
|
+
}, (e) => {
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
reject(e);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
//# sourceMappingURL=scheduler.js.map
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "fs";
|
|
2
|
+
import { freemem, totalmem } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { config } from "../config.js";
|
|
5
|
+
import { DB_PATH, NZB_HOME } from "../paths.js";
|
|
6
|
+
const BACKUPS_DIR = join(NZB_HOME, "backups");
|
|
7
|
+
/** Notify result to Telegram if the job has notifyTelegram enabled. */
|
|
8
|
+
export async function notifyResult(job, message) {
|
|
9
|
+
if (!job.notifyTelegram || !config.telegramEnabled)
|
|
10
|
+
return;
|
|
11
|
+
try {
|
|
12
|
+
const { sendProactiveMessage } = await import("../telegram/bot.js");
|
|
13
|
+
await sendProactiveMessage(`⏰ [${job.name}] ${message}`);
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
console.error("[nzb] Cron notify failed:", err instanceof Error ? err.message : err);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Execute a cron task based on its taskType. Returns a result string. */
|
|
20
|
+
export async function executeCronTask(job) {
|
|
21
|
+
const payload = JSON.parse(job.payload);
|
|
22
|
+
switch (job.taskType) {
|
|
23
|
+
case "prompt":
|
|
24
|
+
return await executePromptTask(payload);
|
|
25
|
+
case "health_check":
|
|
26
|
+
return await executeHealthCheckTask();
|
|
27
|
+
case "backup":
|
|
28
|
+
return await executeBackupTask();
|
|
29
|
+
case "notification":
|
|
30
|
+
return await executeNotificationTask(job, payload);
|
|
31
|
+
case "webhook":
|
|
32
|
+
return await executeWebhookTask(payload, job.timeoutMs);
|
|
33
|
+
default:
|
|
34
|
+
throw new Error(`Unknown task type: ${job.taskType}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async function executePromptTask(payload) {
|
|
38
|
+
const prompt = payload.prompt || "Scheduled check-in. Anything to report?";
|
|
39
|
+
try {
|
|
40
|
+
const { sendToOrchestrator } = await import("../copilot/orchestrator.js");
|
|
41
|
+
return await new Promise((resolve, reject) => {
|
|
42
|
+
const timeout = setTimeout(() => reject(new Error("Prompt task timed out after 120s")), 120_000);
|
|
43
|
+
let fullResponse = "";
|
|
44
|
+
sendToOrchestrator(`[Scheduled task] ${prompt}`, { type: "background" }, (text, done) => {
|
|
45
|
+
if (done) {
|
|
46
|
+
clearTimeout(timeout);
|
|
47
|
+
fullResponse = text;
|
|
48
|
+
resolve(fullResponse);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
throw new Error(`Prompt task failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function executeHealthCheckTask() {
|
|
58
|
+
const checks = [];
|
|
59
|
+
// Memory check
|
|
60
|
+
const free = freemem();
|
|
61
|
+
const total = totalmem();
|
|
62
|
+
const usedPct = Math.round(((total - free) / total) * 100);
|
|
63
|
+
checks.push(`Memory: ${usedPct}% used (${formatBytes(free)} free / ${formatBytes(total)} total)`);
|
|
64
|
+
// Disk check (database size)
|
|
65
|
+
try {
|
|
66
|
+
if (existsSync(DB_PATH)) {
|
|
67
|
+
const dbSize = statSync(DB_PATH).size;
|
|
68
|
+
checks.push(`Database: ${formatBytes(dbSize)}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
checks.push("Database: unable to check");
|
|
73
|
+
}
|
|
74
|
+
// Process uptime
|
|
75
|
+
const uptimeSeconds = Math.floor(process.uptime());
|
|
76
|
+
const hours = Math.floor(uptimeSeconds / 3600);
|
|
77
|
+
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
|
|
78
|
+
checks.push(`Uptime: ${hours}h ${minutes}m`);
|
|
79
|
+
// Node.js memory usage
|
|
80
|
+
const mem = process.memoryUsage();
|
|
81
|
+
checks.push(`Heap: ${formatBytes(mem.heapUsed)} / ${formatBytes(mem.heapTotal)}`);
|
|
82
|
+
checks.push(`RSS: ${formatBytes(mem.rss)}`);
|
|
83
|
+
return checks.join("\n");
|
|
84
|
+
}
|
|
85
|
+
async function executeBackupTask() {
|
|
86
|
+
mkdirSync(BACKUPS_DIR, { recursive: true });
|
|
87
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
88
|
+
const backed = [];
|
|
89
|
+
// Backup database
|
|
90
|
+
if (existsSync(DB_PATH)) {
|
|
91
|
+
const dest = join(BACKUPS_DIR, `nzb-${timestamp}.db`);
|
|
92
|
+
copyFileSync(DB_PATH, dest);
|
|
93
|
+
backed.push(`Database → ${dest}`);
|
|
94
|
+
}
|
|
95
|
+
// Backup memories (WAL file if exists)
|
|
96
|
+
const walPath = DB_PATH + "-wal";
|
|
97
|
+
if (existsSync(walPath)) {
|
|
98
|
+
const dest = join(BACKUPS_DIR, `nzb-${timestamp}.db-wal`);
|
|
99
|
+
copyFileSync(walPath, dest);
|
|
100
|
+
backed.push(`WAL → ${dest}`);
|
|
101
|
+
}
|
|
102
|
+
// Prune old backups: keep last 10
|
|
103
|
+
try {
|
|
104
|
+
const files = readdirSync(BACKUPS_DIR)
|
|
105
|
+
.filter((f) => f.startsWith("nzb-") && f.endsWith(".db"))
|
|
106
|
+
.sort()
|
|
107
|
+
.reverse();
|
|
108
|
+
for (const old of files.slice(10)) {
|
|
109
|
+
const { unlinkSync } = await import("fs");
|
|
110
|
+
unlinkSync(join(BACKUPS_DIR, old));
|
|
111
|
+
// Also remove corresponding WAL
|
|
112
|
+
const walFile = old + "-wal";
|
|
113
|
+
if (existsSync(join(BACKUPS_DIR, walFile))) {
|
|
114
|
+
unlinkSync(join(BACKUPS_DIR, walFile));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// Prune failure is non-critical
|
|
120
|
+
}
|
|
121
|
+
return backed.length > 0 ? `Backup complete:\n${backed.join("\n")}` : "Nothing to backup";
|
|
122
|
+
}
|
|
123
|
+
async function executeNotificationTask(job, payload) {
|
|
124
|
+
const message = payload.message || "Scheduled notification";
|
|
125
|
+
if (config.telegramEnabled) {
|
|
126
|
+
const { sendProactiveMessage } = await import("../telegram/bot.js");
|
|
127
|
+
await sendProactiveMessage(message);
|
|
128
|
+
return `Notification sent: ${message}`;
|
|
129
|
+
}
|
|
130
|
+
return `Notification skipped (Telegram not configured): ${message}`;
|
|
131
|
+
}
|
|
132
|
+
async function executeWebhookTask(payload, timeoutMs) {
|
|
133
|
+
const url = payload.url;
|
|
134
|
+
if (!url)
|
|
135
|
+
throw new Error("Webhook task requires 'url' in payload");
|
|
136
|
+
const method = (payload.method || "GET").toUpperCase();
|
|
137
|
+
const headers = payload.headers || {};
|
|
138
|
+
const body = payload.body ? JSON.stringify(payload.body) : undefined;
|
|
139
|
+
const controller = new AbortController();
|
|
140
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
141
|
+
try {
|
|
142
|
+
const response = await fetch(url, {
|
|
143
|
+
method,
|
|
144
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
145
|
+
body: method !== "GET" ? body : undefined,
|
|
146
|
+
signal: controller.signal,
|
|
147
|
+
});
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
const text = await response.text();
|
|
150
|
+
const truncated = text.length > 500 ? text.slice(0, 500) + "…" : text;
|
|
151
|
+
return `Webhook ${method} ${url} → ${response.status} ${response.statusText}\n${truncated}`;
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
156
|
+
throw new Error(`Webhook timed out after ${timeoutMs}ms`);
|
|
157
|
+
}
|
|
158
|
+
throw err;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function formatBytes(bytes) {
|
|
162
|
+
if (bytes < 1024)
|
|
163
|
+
return `${bytes}B`;
|
|
164
|
+
if (bytes < 1024 * 1024)
|
|
165
|
+
return `${(bytes / 1024).toFixed(1)}KB`;
|
|
166
|
+
if (bytes < 1024 * 1024 * 1024)
|
|
167
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
168
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`;
|
|
169
|
+
}
|
|
170
|
+
//# sourceMappingURL=task-runner.js.map
|
package/dist/daemon.js
CHANGED
|
@@ -7,6 +7,7 @@ import { getWorkers, initOrchestrator, setMessageLogger, setProactiveNotify, set
|
|
|
7
7
|
import { PID_FILE_PATH } from "./paths.js";
|
|
8
8
|
import { closeDb, getDb } from "./store/db.js";
|
|
9
9
|
import { createBot, sendProactiveMessage, sendWorkerNotification, startBot, stopBot } from "./telegram/bot.js";
|
|
10
|
+
import { startCronScheduler, stopCronScheduler } from "./cron/scheduler.js";
|
|
10
11
|
import { checkForUpdate } from "./update.js";
|
|
11
12
|
// Log the active CA bundle (injected by cli.ts via re-exec).
|
|
12
13
|
if (process.env.NODE_EXTRA_CA_CERTS) {
|
|
@@ -134,6 +135,8 @@ async function main() {
|
|
|
134
135
|
});
|
|
135
136
|
// Start HTTP API for TUI
|
|
136
137
|
await startApiServer();
|
|
138
|
+
// Start cron scheduler
|
|
139
|
+
startCronScheduler();
|
|
137
140
|
// Start Telegram bot (if configured)
|
|
138
141
|
if (config.telegramEnabled) {
|
|
139
142
|
createBot();
|
|
@@ -194,6 +197,7 @@ async function shutdown() {
|
|
|
194
197
|
forceTimer.unref();
|
|
195
198
|
// Stop health check timer first
|
|
196
199
|
stopHealthCheck();
|
|
200
|
+
stopCronScheduler();
|
|
197
201
|
if (config.telegramEnabled) {
|
|
198
202
|
try {
|
|
199
203
|
await stopBot();
|
|
@@ -220,6 +224,7 @@ async function shutdown() {
|
|
|
220
224
|
export async function restartDaemon() {
|
|
221
225
|
console.log("[nzb] Restarting...");
|
|
222
226
|
stopHealthCheck();
|
|
227
|
+
stopCronScheduler();
|
|
223
228
|
const activeWorkers = getWorkers();
|
|
224
229
|
const runningCount = Array.from(activeWorkers.values()).filter((w) => w.status === "running").length;
|
|
225
230
|
if (runningCount > 0) {
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { getDb } from "./db.js";
|
|
2
|
+
export function createCronJob(input) {
|
|
3
|
+
const db = getDb();
|
|
4
|
+
const now = new Date().toISOString();
|
|
5
|
+
db.prepare(`INSERT INTO cron_jobs (id, name, cron_expression, task_type, payload, enabled, notify_telegram, max_retries, timeout_ms, created_at, updated_at)
|
|
6
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(input.id, input.name, input.cronExpression, input.taskType, input.payload ?? "{}", input.enabled !== false ? 1 : 0, input.notifyTelegram !== false ? 1 : 0, input.maxRetries ?? 0, input.timeoutMs ?? 300_000, now, now);
|
|
7
|
+
return getCronJob(input.id);
|
|
8
|
+
}
|
|
9
|
+
export function getCronJob(id) {
|
|
10
|
+
const db = getDb();
|
|
11
|
+
const row = db.prepare(`SELECT * FROM cron_jobs WHERE id = ?`).get(id);
|
|
12
|
+
return row ? mapCronJobRow(row) : undefined;
|
|
13
|
+
}
|
|
14
|
+
export function listCronJobs(enabledOnly = false) {
|
|
15
|
+
const db = getDb();
|
|
16
|
+
const sql = enabledOnly ? `SELECT * FROM cron_jobs WHERE enabled = 1` : `SELECT * FROM cron_jobs`;
|
|
17
|
+
const rows = db.prepare(sql).all();
|
|
18
|
+
return rows.map(mapCronJobRow);
|
|
19
|
+
}
|
|
20
|
+
export function updateCronJob(id, updates) {
|
|
21
|
+
const db = getDb();
|
|
22
|
+
const existing = getCronJob(id);
|
|
23
|
+
if (!existing)
|
|
24
|
+
return undefined;
|
|
25
|
+
const fields = [];
|
|
26
|
+
const values = [];
|
|
27
|
+
if (updates.name !== undefined) {
|
|
28
|
+
fields.push("name = ?");
|
|
29
|
+
values.push(updates.name);
|
|
30
|
+
}
|
|
31
|
+
if (updates.cronExpression !== undefined) {
|
|
32
|
+
fields.push("cron_expression = ?");
|
|
33
|
+
values.push(updates.cronExpression);
|
|
34
|
+
}
|
|
35
|
+
if (updates.taskType !== undefined) {
|
|
36
|
+
fields.push("task_type = ?");
|
|
37
|
+
values.push(updates.taskType);
|
|
38
|
+
}
|
|
39
|
+
if (updates.payload !== undefined) {
|
|
40
|
+
fields.push("payload = ?");
|
|
41
|
+
values.push(updates.payload);
|
|
42
|
+
}
|
|
43
|
+
if (updates.enabled !== undefined) {
|
|
44
|
+
fields.push("enabled = ?");
|
|
45
|
+
values.push(updates.enabled ? 1 : 0);
|
|
46
|
+
}
|
|
47
|
+
if (updates.notifyTelegram !== undefined) {
|
|
48
|
+
fields.push("notify_telegram = ?");
|
|
49
|
+
values.push(updates.notifyTelegram ? 1 : 0);
|
|
50
|
+
}
|
|
51
|
+
if (updates.maxRetries !== undefined) {
|
|
52
|
+
fields.push("max_retries = ?");
|
|
53
|
+
values.push(updates.maxRetries);
|
|
54
|
+
}
|
|
55
|
+
if (updates.timeoutMs !== undefined) {
|
|
56
|
+
fields.push("timeout_ms = ?");
|
|
57
|
+
values.push(updates.timeoutMs);
|
|
58
|
+
}
|
|
59
|
+
if (updates.lastRunAt !== undefined) {
|
|
60
|
+
fields.push("last_run_at = ?");
|
|
61
|
+
values.push(updates.lastRunAt);
|
|
62
|
+
}
|
|
63
|
+
if (updates.nextRunAt !== undefined) {
|
|
64
|
+
fields.push("next_run_at = ?");
|
|
65
|
+
values.push(updates.nextRunAt);
|
|
66
|
+
}
|
|
67
|
+
if (fields.length === 0)
|
|
68
|
+
return existing;
|
|
69
|
+
fields.push("updated_at = ?");
|
|
70
|
+
values.push(new Date().toISOString());
|
|
71
|
+
values.push(id);
|
|
72
|
+
db.prepare(`UPDATE cron_jobs SET ${fields.join(", ")} WHERE id = ?`).run(...values);
|
|
73
|
+
return getCronJob(id);
|
|
74
|
+
}
|
|
75
|
+
export function deleteCronJob(id) {
|
|
76
|
+
const db = getDb();
|
|
77
|
+
const result = db.prepare(`DELETE FROM cron_jobs WHERE id = ?`).run(id);
|
|
78
|
+
if (result.changes > 0) {
|
|
79
|
+
db.prepare(`DELETE FROM cron_runs WHERE job_id = ?`).run(id);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
export function recordCronRun(jobId, status, startedAt, finishedAt, result, error) {
|
|
85
|
+
const db = getDb();
|
|
86
|
+
const durationMs = finishedAt ? finishedAt.getTime() - startedAt.getTime() : null;
|
|
87
|
+
const info = db
|
|
88
|
+
.prepare(`INSERT INTO cron_runs (job_id, status, started_at, finished_at, result, error, duration_ms)
|
|
89
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
90
|
+
.run(jobId, status, startedAt.toISOString(), finishedAt?.toISOString() ?? null, result, error, durationMs);
|
|
91
|
+
// Auto-prune: keep only 50 most recent runs per job
|
|
92
|
+
db.prepare(`DELETE FROM cron_runs WHERE job_id = ? AND id NOT IN (
|
|
93
|
+
SELECT id FROM cron_runs WHERE job_id = ? ORDER BY id DESC LIMIT 50
|
|
94
|
+
)`).run(jobId, jobId);
|
|
95
|
+
return {
|
|
96
|
+
id: Number(info.lastInsertRowid),
|
|
97
|
+
jobId,
|
|
98
|
+
status,
|
|
99
|
+
startedAt: startedAt.toISOString(),
|
|
100
|
+
finishedAt: finishedAt?.toISOString() ?? null,
|
|
101
|
+
result,
|
|
102
|
+
error,
|
|
103
|
+
durationMs,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
export function getRecentRuns(jobId, limit = 10) {
|
|
107
|
+
const db = getDb();
|
|
108
|
+
const rows = db
|
|
109
|
+
.prepare(`SELECT * FROM cron_runs WHERE job_id = ? ORDER BY id DESC LIMIT ?`)
|
|
110
|
+
.all(jobId, limit);
|
|
111
|
+
return rows.map(mapCronRunRow);
|
|
112
|
+
}
|
|
113
|
+
function mapCronJobRow(row) {
|
|
114
|
+
return {
|
|
115
|
+
id: row.id,
|
|
116
|
+
name: row.name,
|
|
117
|
+
cronExpression: row.cron_expression,
|
|
118
|
+
taskType: row.task_type,
|
|
119
|
+
payload: row.payload,
|
|
120
|
+
enabled: row.enabled === 1,
|
|
121
|
+
notifyTelegram: row.notify_telegram === 1,
|
|
122
|
+
maxRetries: row.max_retries,
|
|
123
|
+
timeoutMs: row.timeout_ms,
|
|
124
|
+
lastRunAt: row.last_run_at,
|
|
125
|
+
nextRunAt: row.next_run_at,
|
|
126
|
+
createdAt: row.created_at,
|
|
127
|
+
updatedAt: row.updated_at,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function mapCronRunRow(row) {
|
|
131
|
+
return {
|
|
132
|
+
id: row.id,
|
|
133
|
+
jobId: row.job_id,
|
|
134
|
+
status: row.status,
|
|
135
|
+
startedAt: row.started_at,
|
|
136
|
+
finishedAt: row.finished_at,
|
|
137
|
+
result: row.result,
|
|
138
|
+
error: row.error,
|
|
139
|
+
durationMs: row.duration_ms,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
//# sourceMappingURL=cron-store.js.map
|
package/dist/store/db.js
CHANGED
|
@@ -82,6 +82,37 @@ export function getDb() {
|
|
|
82
82
|
FOREIGN KEY(team_id) REFERENCES agent_teams(id)
|
|
83
83
|
)
|
|
84
84
|
`);
|
|
85
|
+
db.exec(`
|
|
86
|
+
CREATE TABLE IF NOT EXISTS cron_jobs (
|
|
87
|
+
id TEXT PRIMARY KEY,
|
|
88
|
+
name TEXT NOT NULL,
|
|
89
|
+
cron_expression TEXT NOT NULL,
|
|
90
|
+
task_type TEXT NOT NULL,
|
|
91
|
+
payload TEXT NOT NULL DEFAULT '{}',
|
|
92
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
93
|
+
notify_telegram INTEGER NOT NULL DEFAULT 1,
|
|
94
|
+
max_retries INTEGER NOT NULL DEFAULT 0,
|
|
95
|
+
timeout_ms INTEGER NOT NULL DEFAULT 300000,
|
|
96
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
97
|
+
updated_at TEXT DEFAULT (datetime('now')),
|
|
98
|
+
last_run_at TEXT,
|
|
99
|
+
next_run_at TEXT
|
|
100
|
+
)
|
|
101
|
+
`);
|
|
102
|
+
db.exec(`
|
|
103
|
+
CREATE TABLE IF NOT EXISTS cron_runs (
|
|
104
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
105
|
+
job_id TEXT NOT NULL,
|
|
106
|
+
status TEXT NOT NULL,
|
|
107
|
+
started_at TEXT DEFAULT (datetime('now')),
|
|
108
|
+
finished_at TEXT,
|
|
109
|
+
result TEXT,
|
|
110
|
+
error TEXT,
|
|
111
|
+
duration_ms INTEGER,
|
|
112
|
+
FOREIGN KEY(job_id) REFERENCES cron_jobs(id) ON DELETE CASCADE
|
|
113
|
+
)
|
|
114
|
+
`);
|
|
115
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_cron_runs_job ON cron_runs(job_id, started_at)`);
|
|
85
116
|
// Migrate: if the table already existed with a stricter CHECK, recreate it
|
|
86
117
|
try {
|
|
87
118
|
db.prepare(`INSERT INTO conversation_log (role, content, source) VALUES ('system', '__migration_test__', 'test')`).run();
|
package/dist/telegram/bot.js
CHANGED
|
@@ -10,6 +10,7 @@ import { config } from "../config.js";
|
|
|
10
10
|
import { getPersistedUpdateOffset, isUpdateDuplicate, persistUpdateOffset } from "./dedup.js";
|
|
11
11
|
import { registerCallbackHandlers } from "./handlers/callbacks.js";
|
|
12
12
|
import { registerCommandHandlers } from "./handlers/commands.js";
|
|
13
|
+
import { registerCronHandlers } from "./handlers/cron.js";
|
|
13
14
|
import { sendFormattedReply } from "./handlers/helpers.js";
|
|
14
15
|
import { registerInlineQueryHandler } from "./handlers/inline.js";
|
|
15
16
|
import { registerMediaHandlers } from "./handlers/media.js";
|
|
@@ -104,6 +105,7 @@ export function createBot() {
|
|
|
104
105
|
bot.use(mainMenu);
|
|
105
106
|
// --- Handler registrations ---
|
|
106
107
|
registerCallbackHandlers(bot);
|
|
108
|
+
registerCronHandlers(bot);
|
|
107
109
|
registerInlineQueryHandler(bot);
|
|
108
110
|
registerSmartSuggestionHandlers(bot);
|
|
109
111
|
registerReactionHandlers(bot);
|
|
@@ -152,6 +154,7 @@ export async function startBot() {
|
|
|
152
154
|
{ command: "workers", description: "List active workers" },
|
|
153
155
|
{ command: "skills", description: "List installed skills" },
|
|
154
156
|
{ command: "memory", description: "Show stored memories" },
|
|
157
|
+
{ command: "cron", description: "Manage cron jobs" },
|
|
155
158
|
{ command: "settings", description: "Bot settings" },
|
|
156
159
|
{ command: "restart", description: "Restart NZB" },
|
|
157
160
|
]);
|
|
@@ -5,6 +5,7 @@ import { restartDaemon } from "../../daemon.js";
|
|
|
5
5
|
import { searchMemories } from "../../store/memory.js";
|
|
6
6
|
import { chunkMessage } from "../formatter.js";
|
|
7
7
|
import { buildSettingsText, formatMemoryList } from "../menus.js";
|
|
8
|
+
import { sendCronMenu } from "./cron.js";
|
|
8
9
|
import { getReactionHelpText } from "./reactions.js";
|
|
9
10
|
export function registerCommandHandlers(bot, deps) {
|
|
10
11
|
const { replyKeyboard, mainMenu, settingsMenu, getUptimeStr } = deps;
|
|
@@ -30,6 +31,7 @@ export function registerCommandHandlers(bot, deps) {
|
|
|
30
31
|
"/memory — Stored memories\n" +
|
|
31
32
|
"/skills — Installed skills\n" +
|
|
32
33
|
"/workers — Active worker sessions\n" +
|
|
34
|
+
"/cron — Manage cron jobs\n" +
|
|
33
35
|
"/restart — Restart NZB\n\n" +
|
|
34
36
|
"⚡ Breakthrough Features:\n" +
|
|
35
37
|
"• @bot query — Use me inline in any chat!\n" +
|
|
@@ -194,6 +196,9 @@ export function registerCommandHandlers(bot, deps) {
|
|
|
194
196
|
bot.command("settings", async (ctx) => {
|
|
195
197
|
await ctx.reply(buildSettingsText(getUptimeStr), { reply_markup: settingsMenu });
|
|
196
198
|
});
|
|
199
|
+
bot.command("cron", async (ctx) => {
|
|
200
|
+
await sendCronMenu(ctx);
|
|
201
|
+
});
|
|
197
202
|
// Reply keyboard button handlers — intercept before general text handler
|
|
198
203
|
bot.hears("📊 Status", async (ctx) => {
|
|
199
204
|
const workers = Array.from(getWorkers().values());
|