@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.
@@ -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();
@@ -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());