/ scripts / run-cron-job.js
run-cron-job.js
  1  #!/usr/bin/env node
  2  
  3  /**
  4   * Run a single cron job on-demand
  5   *
  6   * Usage: node scripts/run-cron-job.js <task_key>
  7   */
  8  
  9  import { createDatabaseConnection } from '../src/utils/db.js';
 10  import { join, dirname } from 'path';
 11  import { fileURLToPath } from 'url';
 12  import Logger from '../src/utils/logger.js';
 13  import dotenv from 'dotenv';
 14  
 15  dotenv.config();
 16  
 17  const __filename = fileURLToPath(import.meta.url);
 18  const __dirname = dirname(__filename);
 19  const projectRoot = join(__dirname, '..');
 20  
 21  const logger = new Logger('RunCronJob');
 22  const dbPath = process.env.DATABASE_PATH || join(projectRoot, 'db/sites.db');
 23  
 24  /**
 25   * Log task execution to database
 26   */
 27  function logTaskStart(db, taskName) {
 28    const stmt = db.prepare(`
 29      INSERT INTO ops.cron_job_logs (job_name, started_at, status, items_processed, items_failed)
 30      VALUES (?, datetime('now'), 'running', 0, 0)
 31    `);
 32    const info = stmt.run(taskName);
 33    return info.lastInsertRowid;
 34  }
 35  
 36  /**
 37   * Update task log on completion
 38   */
 39  function logTaskComplete(db, logId, result) {
 40    const summary = result?.summary || 'Task completed';
 41    const fullLog = JSON.stringify(result, null, 2);
 42  
 43    let itemsProcessed = 1;
 44    if (result?.metrics?.processed !== undefined) {
 45      itemsProcessed = result.metrics.processed;
 46    } else if (result?.items_processed !== undefined) {
 47      itemsProcessed = result.items_processed;
 48    } else if (result?.processed !== undefined) {
 49      itemsProcessed = result.processed;
 50    }
 51  
 52    db.prepare(
 53      `
 54      UPDATE ops.cron_job_logs
 55      SET finished_at = datetime('now'),
 56          status = 'success',
 57          summary = ?,
 58          full_log = ?,
 59          items_processed = ?
 60      WHERE id = ?
 61    `
 62    ).run(summary, fullLog, itemsProcessed, logId);
 63  }
 64  
 65  /**
 66   * Update task log on failure
 67   */
 68  function logTaskFailed(db, logId, error) {
 69    const errorName = error.name || 'Error';
 70    const summary = `${errorName}: ${error.message}`;
 71  
 72    const fullLog = JSON.stringify(
 73      {
 74        summary,
 75        error: error.message,
 76        details: error.details || {
 77          stack: error.stack,
 78        },
 79        metrics: {
 80          success: 0,
 81          failed: 1,
 82        },
 83      },
 84      null,
 85      2
 86    );
 87  
 88    db.prepare(
 89      `
 90      UPDATE ops.cron_job_logs
 91      SET finished_at = datetime('now'),
 92          status = 'failed',
 93          summary = ?,
 94          full_log = ?,
 95          error_message = ?,
 96          items_failed = 1
 97      WHERE id = ?
 98    `
 99    ).run(summary, fullLog, error.message, logId);
100  }
101  
102  /**
103   * Execute a command-based job
104   */
105  async function executeCommand(command) {
106    const { spawn } = await import('child_process');
107    const startTime = Date.now();
108  
109    return new Promise((resolve, reject) => {
110      const [cmd, ...args] = command.split(' ');
111  
112      const proc = spawn(cmd, args, {
113        cwd: projectRoot,
114        stdio: 'inherit', // Show output in real-time
115        env: process.env,
116      });
117  
118      proc.on('close', code => {
119        const duration = ((Date.now() - startTime) / 1000).toFixed(2);
120  
121        if (code === 0) {
122          resolve({
123            summary: `Command completed in ${duration}s`,
124            details: {
125              duration_seconds: parseFloat(duration),
126              exit_code: code,
127              command,
128            },
129            metrics: {
130              duration_seconds: parseFloat(duration),
131            },
132          });
133        } else {
134          const error = new Error(`Command exited with code ${code}`);
135          error.details = {
136            exit_code: code,
137            command,
138          };
139          reject(error);
140        }
141      });
142    });
143  }
144  
145  /**
146   * Run a specific job by task_key
147   */
148  async function runJob(taskKey) {
149    const db = createDatabaseConnection(dbPath);
150  
151    try {
152      // Check circuit breaker - global kill switch for all cron jobs
153      // Check settings table first (dashboard-toggleable), fall back to .env
154      const circuitBreakerSetting = db
155        .prepare('SELECT value FROM ops.settings WHERE key = ?')
156        .get('cron_circuit_breaker_enabled');
157      const circuitBreakerEnabled =
158        circuitBreakerSetting?.value !== 'false' &&
159        process.env.CRON_CIRCUIT_BREAKER_ENABLED !== 'false';
160  
161      if (!circuitBreakerEnabled) {
162        logger.warn(
163          '⚠️ Circuit breaker is DISABLED - job execution blocked. Enable it via dashboard or CLI.'
164        );
165        return 1;
166      }
167  
168      // Get job details
169      const job = db.prepare('SELECT * FROM ops.cron_jobs WHERE task_key = ?').get(taskKey);
170  
171      if (!job) {
172        logger.error(`Job not found: ${taskKey}`);
173        return 1;
174      }
175  
176      logger.info(`Running job: ${job.name}`);
177  
178      // Log task start
179      const logId = logTaskStart(db, job.name);
180  
181      try {
182        const start = Date.now();
183        let result;
184  
185        if (job.handler_type === 'function') {
186          // Import cron to get HANDLERS
187          const cronModule = await import('../src/cron.js');
188          const handler = cronModule.default.HANDLERS[job.task_key];
189  
190          if (!handler) {
191            throw new Error(`Handler function not found: ${job.task_key}`);
192          }
193  
194          result = await handler();
195        } else if (job.handler_type === 'command') {
196          result = await executeCommand(job.handler_value);
197        } else {
198          throw new Error(`Unknown handler type: ${job.handler_type}`);
199        }
200  
201        const duration = ((Date.now() - start) / 1000).toFixed(2);
202  
203        // Log task completion
204        logTaskComplete(db, logId, result);
205  
206        // Update last_run_at
207        db.prepare("UPDATE ops.cron_jobs SET last_run_at = datetime('now') WHERE task_key = ?").run(
208          taskKey
209        );
210  
211        logger.success(`✓ ${job.name} completed in ${duration}s`);
212        return 0;
213      } catch (error) {
214        logger.error(`✗ ${job.name} failed:`, error);
215  
216        // Log task failure
217        logTaskFailed(db, logId, error);
218  
219        // Update last_run_at even on failure to prevent immediate retries
220        db.prepare("UPDATE ops.cron_jobs SET last_run_at = datetime('now') WHERE task_key = ?").run(
221          taskKey
222        );
223  
224        return 1;
225      }
226    } finally {
227      db.close();
228    }
229  }
230  
231  // Main
232  const taskKey = process.argv[2];
233  
234  if (!taskKey) {
235    console.error('Usage: node scripts/run-cron-job.js <task_key>');
236    process.exit(1);
237  }
238  
239  runJob(taskKey)
240    .then(code => process.exit(code))
241    .catch(error => {
242      console.error('Fatal error:', error);
243      process.exit(1);
244    });