@iletai/nzb 1.7.3 → 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/api/server.js +23 -1
- 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 +177 -92
- package/dist/copilot/tools.js +145 -7
- 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 +32 -0
- package/dist/telegram/bot.js +11 -0
- package/dist/telegram/handlers/commands.js +5 -0
- package/dist/telegram/handlers/cron.js +354 -0
- package/package.json +2 -1
package/dist/copilot/tools.js
CHANGED
|
@@ -4,9 +4,13 @@ import { homedir } from "os";
|
|
|
4
4
|
import { join, resolve, sep } from "path";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { config, persistModel } from "../config.js";
|
|
7
|
+
import { scheduleJob, triggerJob, unscheduleJob, getSchedulerStatus } from "../cron/scheduler.js";
|
|
7
8
|
import { SESSIONS_DIR } from "../paths.js";
|
|
8
9
|
import { getDb } from "../store/db.js";
|
|
10
|
+
import { createCronJob, deleteCronJob, getCronJob, getRecentRuns, updateCronJob, } from "../store/cron-store.js";
|
|
11
|
+
import { withTimeout } from "../utils.js";
|
|
9
12
|
import { addMemory, removeMemory, searchMemories } from "../store/memory.js";
|
|
13
|
+
import { getFailoverManager } from "./orchestrator.js";
|
|
10
14
|
import { createSkill, listSkills, removeSkill } from "./skills.js";
|
|
11
15
|
function isTimeoutError(err) {
|
|
12
16
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -111,7 +115,9 @@ export function createTools(deps) {
|
|
|
111
115
|
})
|
|
112
116
|
.finally(() => {
|
|
113
117
|
// Auto-destroy background workers after completion to free memory (~400MB per worker)
|
|
114
|
-
session.disconnect().catch(() => {
|
|
118
|
+
withTimeout(session.disconnect(), 5_000, `worker '${args.name}' disconnect`).catch((err) => {
|
|
119
|
+
console.error(`[nzb] Worker '${args.name}' disconnect timeout/error:`, err instanceof Error ? err.message : err);
|
|
120
|
+
});
|
|
115
121
|
deps.workers.delete(args.name);
|
|
116
122
|
try {
|
|
117
123
|
getDb().prepare(`DELETE FROM worker_sessions WHERE name = ?`).run(args.name);
|
|
@@ -162,7 +168,9 @@ export function createTools(deps) {
|
|
|
162
168
|
})
|
|
163
169
|
.finally(() => {
|
|
164
170
|
// Auto-destroy after each send_to_worker dispatch to free memory
|
|
165
|
-
worker.session.disconnect().catch(() => {
|
|
171
|
+
withTimeout(worker.session.disconnect(), 5_000, `worker '${args.name}' disconnect`).catch((err) => {
|
|
172
|
+
console.error(`[nzb] Worker '${args.name}' disconnect timeout/error:`, err instanceof Error ? err.message : err);
|
|
173
|
+
});
|
|
166
174
|
deps.workers.delete(args.name);
|
|
167
175
|
try {
|
|
168
176
|
getDb().prepare(`DELETE FROM worker_sessions WHERE name = ?`).run(args.name);
|
|
@@ -210,10 +218,10 @@ export function createTools(deps) {
|
|
|
210
218
|
return `No worker named '${args.name}'.`;
|
|
211
219
|
}
|
|
212
220
|
try {
|
|
213
|
-
await worker.session.disconnect();
|
|
221
|
+
await withTimeout(worker.session.disconnect(), 5_000, `worker '${args.name}' disconnect`);
|
|
214
222
|
}
|
|
215
|
-
catch {
|
|
216
|
-
|
|
223
|
+
catch (err) {
|
|
224
|
+
console.error(`[nzb] Worker '${args.name}' disconnect timeout/error:`, err instanceof Error ? err.message : err);
|
|
217
225
|
}
|
|
218
226
|
deps.workers.delete(args.name);
|
|
219
227
|
const db = getDb();
|
|
@@ -232,7 +240,9 @@ export function createTools(deps) {
|
|
|
232
240
|
if (!worker)
|
|
233
241
|
return `No worker found with name '${args.name}'.`;
|
|
234
242
|
try {
|
|
235
|
-
worker.session.disconnect().catch(() => {
|
|
243
|
+
withTimeout(worker.session.disconnect(), 5_000, `worker '${args.name}' disconnect`).catch((err) => {
|
|
244
|
+
console.error(`[nzb] Worker '${args.name}' disconnect timeout/error:`, err instanceof Error ? err.message : err);
|
|
245
|
+
});
|
|
236
246
|
}
|
|
237
247
|
catch {
|
|
238
248
|
// Session may already be destroyed
|
|
@@ -347,7 +357,9 @@ export function createTools(deps) {
|
|
|
347
357
|
deps.onWorkerComplete(member.name, errMsg);
|
|
348
358
|
})
|
|
349
359
|
.finally(() => {
|
|
350
|
-
session.disconnect().catch(() => {
|
|
360
|
+
withTimeout(session.disconnect(), 5_000, `worker '${member.name}' disconnect`).catch((err) => {
|
|
361
|
+
console.error(`[nzb] Worker '${member.name}' disconnect timeout/error:`, err instanceof Error ? err.message : err);
|
|
362
|
+
});
|
|
351
363
|
deps.workers.delete(member.name);
|
|
352
364
|
try {
|
|
353
365
|
getDb().prepare(`DELETE FROM worker_sessions WHERE name = ?`).run(member.name);
|
|
@@ -643,6 +655,27 @@ export function createTools(deps) {
|
|
|
643
655
|
}
|
|
644
656
|
},
|
|
645
657
|
}),
|
|
658
|
+
defineTool("model_health", {
|
|
659
|
+
description: "Show health status of all models in the failover chain. " +
|
|
660
|
+
"Displays model name, provider, status (healthy/cooldown/degraded), failure count, and last failure time. " +
|
|
661
|
+
"Use when the user asks about model health, failover status, or which models are available in the chain.",
|
|
662
|
+
parameters: z.object({}),
|
|
663
|
+
handler: async () => {
|
|
664
|
+
const manager = getFailoverManager();
|
|
665
|
+
if (!manager || !manager.enabled) {
|
|
666
|
+
return (`Model failover is not configured. Current model: ${config.copilotModel}\n\n` +
|
|
667
|
+
`To enable failover, set MODEL_FAILOVER_CHAIN in ~/.nzb/.env with a comma-separated list of models.\n` +
|
|
668
|
+
`Example: MODEL_FAILOVER_CHAIN=claude-sonnet-4.6,gpt-4.1,gemini-2.0-flash`);
|
|
669
|
+
}
|
|
670
|
+
const statuses = manager.getHealthStatus();
|
|
671
|
+
const lines = statuses.map((s) => {
|
|
672
|
+
const icon = s.status === "healthy" ? "✅" : s.status === "cooldown" ? "⏳" : "⚠️";
|
|
673
|
+
const lastFail = s.lastFailure ? ` (last fail: ${s.lastFailure})` : "";
|
|
674
|
+
return `${icon} ${s.model} [${s.provider}] — ${s.status} | failures: ${s.failures} | successes: ${s.successCount}${lastFail}`;
|
|
675
|
+
});
|
|
676
|
+
return `Model Failover Health (active: ${config.copilotModel}):\n${lines.join("\n")}`;
|
|
677
|
+
},
|
|
678
|
+
}),
|
|
646
679
|
defineTool("remember", {
|
|
647
680
|
description: "Save something to NZB's long-term memory. Use when the user says 'remember that...', " +
|
|
648
681
|
"states a preference, shares a fact about themselves, or mentions something important " +
|
|
@@ -697,6 +730,111 @@ export function createTools(deps) {
|
|
|
697
730
|
: `Memory #${args.memory_id} not found — it may have already been removed.`;
|
|
698
731
|
},
|
|
699
732
|
}),
|
|
733
|
+
defineTool("manage_cron", {
|
|
734
|
+
description: "Manage scheduled cron jobs. Use to create, list, remove, enable, disable, or trigger " +
|
|
735
|
+
"recurring tasks like health checks, backups, notifications, webhook calls, or scheduled prompts.",
|
|
736
|
+
parameters: z.object({
|
|
737
|
+
action: z
|
|
738
|
+
.enum(["list", "add", "remove", "enable", "disable", "trigger", "history"])
|
|
739
|
+
.describe("Action to perform"),
|
|
740
|
+
job_id: z.string().optional().describe("Job ID (required for remove/enable/disable/trigger/history)"),
|
|
741
|
+
name: z.string().optional().describe("Job name (required for add)"),
|
|
742
|
+
cron_expression: z
|
|
743
|
+
.string()
|
|
744
|
+
.optional()
|
|
745
|
+
.describe("Cron expression, e.g. '0 */6 * * *' for every 6 hours (required for add)"),
|
|
746
|
+
task_type: z
|
|
747
|
+
.enum(["prompt", "health_check", "backup", "notification", "webhook"])
|
|
748
|
+
.optional()
|
|
749
|
+
.describe("Task type (required for add)"),
|
|
750
|
+
payload: z.string().optional().describe("JSON payload for the task (optional for add)"),
|
|
751
|
+
notify_telegram: z.boolean().optional().describe("Send result to Telegram (default: true)"),
|
|
752
|
+
}),
|
|
753
|
+
handler: async (args) => {
|
|
754
|
+
try {
|
|
755
|
+
switch (args.action) {
|
|
756
|
+
case "list": {
|
|
757
|
+
const status = getSchedulerStatus();
|
|
758
|
+
if (status.length === 0)
|
|
759
|
+
return "No cron jobs configured.";
|
|
760
|
+
const lines = status.map((j) => `• ${j.id} — ${j.name} [${j.taskType}] ${j.enabled ? "✅" : "⏸️"} ` +
|
|
761
|
+
`${j.active ? "active" : "inactive"} | ${j.cronExpression}` +
|
|
762
|
+
(j.nextRun ? ` | next: ${j.nextRun}` : ""));
|
|
763
|
+
return `${status.length} cron job(s):\n${lines.join("\n")}`;
|
|
764
|
+
}
|
|
765
|
+
case "add": {
|
|
766
|
+
if (!args.job_id || !args.name || !args.cron_expression || !args.task_type) {
|
|
767
|
+
return "Missing required fields: job_id, name, cron_expression, task_type";
|
|
768
|
+
}
|
|
769
|
+
const job = createCronJob({
|
|
770
|
+
id: args.job_id,
|
|
771
|
+
name: args.name,
|
|
772
|
+
cronExpression: args.cron_expression,
|
|
773
|
+
taskType: args.task_type,
|
|
774
|
+
payload: args.payload,
|
|
775
|
+
notifyTelegram: args.notify_telegram,
|
|
776
|
+
});
|
|
777
|
+
if (job.enabled)
|
|
778
|
+
scheduleJob(job);
|
|
779
|
+
return `Cron job '${job.id}' (${job.name}) created and scheduled: ${job.cronExpression}`;
|
|
780
|
+
}
|
|
781
|
+
case "remove": {
|
|
782
|
+
if (!args.job_id)
|
|
783
|
+
return "Missing job_id";
|
|
784
|
+
unscheduleJob(args.job_id);
|
|
785
|
+
const deleted = deleteCronJob(args.job_id);
|
|
786
|
+
return deleted
|
|
787
|
+
? `Cron job '${args.job_id}' deleted.`
|
|
788
|
+
: `Job '${args.job_id}' not found.`;
|
|
789
|
+
}
|
|
790
|
+
case "enable": {
|
|
791
|
+
if (!args.job_id)
|
|
792
|
+
return "Missing job_id";
|
|
793
|
+
const enabled = updateCronJob(args.job_id, { enabled: true });
|
|
794
|
+
if (!enabled)
|
|
795
|
+
return `Job '${args.job_id}' not found.`;
|
|
796
|
+
scheduleJob(enabled);
|
|
797
|
+
return `Cron job '${args.job_id}' enabled and scheduled.`;
|
|
798
|
+
}
|
|
799
|
+
case "disable": {
|
|
800
|
+
if (!args.job_id)
|
|
801
|
+
return "Missing job_id";
|
|
802
|
+
const disabled = updateCronJob(args.job_id, { enabled: false });
|
|
803
|
+
if (!disabled)
|
|
804
|
+
return `Job '${args.job_id}' not found.`;
|
|
805
|
+
unscheduleJob(args.job_id);
|
|
806
|
+
return `Cron job '${args.job_id}' disabled.`;
|
|
807
|
+
}
|
|
808
|
+
case "trigger": {
|
|
809
|
+
if (!args.job_id)
|
|
810
|
+
return "Missing job_id";
|
|
811
|
+
const result = await triggerJob(args.job_id);
|
|
812
|
+
return `Triggered '${args.job_id}':\n${result}`;
|
|
813
|
+
}
|
|
814
|
+
case "history": {
|
|
815
|
+
if (!args.job_id)
|
|
816
|
+
return "Missing job_id";
|
|
817
|
+
const job = getCronJob(args.job_id);
|
|
818
|
+
if (!job)
|
|
819
|
+
return `Job '${args.job_id}' not found.`;
|
|
820
|
+
const runs = getRecentRuns(args.job_id);
|
|
821
|
+
if (runs.length === 0)
|
|
822
|
+
return `No runs recorded for '${args.job_id}'.`;
|
|
823
|
+
const lines = runs.map((r) => `• ${r.status} at ${r.startedAt}` +
|
|
824
|
+
(r.durationMs !== null ? ` (${r.durationMs}ms)` : "") +
|
|
825
|
+
(r.error ? ` — ${r.error}` : "") +
|
|
826
|
+
(r.result ? ` — ${r.result.slice(0, 100)}` : ""));
|
|
827
|
+
return `Recent runs for '${job.name}':\n${lines.join("\n")}`;
|
|
828
|
+
}
|
|
829
|
+
default:
|
|
830
|
+
return `Unknown action: ${args.action}`;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
catch (err) {
|
|
834
|
+
return `Cron error: ${err instanceof Error ? err.message : String(err)}`;
|
|
835
|
+
}
|
|
836
|
+
},
|
|
837
|
+
}),
|
|
700
838
|
defineTool("restart_nzb", {
|
|
701
839
|
description: "Restart the NZB daemon process. Use when the user asks NZB to restart himself, " +
|
|
702
840
|
"or when a restart is needed to pick up configuration changes. " +
|
|
@@ -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) {
|