@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.
@@ -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
- // Session may already be gone
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) {