Source: worker.js

/**
 * @module worker
 * @description Standalone worker entrypoint for the distributed runner (AUTO-008).
 *
 * The `docker-compose.yml` `worker` service uses the same image as `web` but
 * overrides the command to `node src/worker.js`. This process:
 *
 *   - Boots the database + AI key cache (the BullMQ job handler needs both).
 *   - Starts the BullMQ Worker (`workers/runWorker.js`) that pulls jobs from
 *     the shared `sentri:runs` queue.
 *   - Does NOT bind any HTTP port — workers are queue consumers only, so
 *     scaling `worker` replicas with `--scale worker=N` doesn't fight over
 *     port 3001 the way running the full Express app would.
 *
 * Requires `REDIS_URL` to be set; without Redis the BullMQ Worker is a no-op
 * and the container exits cleanly (so misconfiguration surfaces fast in logs
 * rather than silently running no jobs).
 */
import dotenv from "dotenv";
import { getDatabase, closeDatabase } from "./database/sqlite.js";
import { migrateFromJsonIfNeeded } from "./database/migrate.js";
import { loadKeysFromDatabase } from "./aiProvider.js";
import { ensureDefaultWorkspaces } from "./database/repositories/workspaceRepo.js";
import { closeQueue } from "./queue.js";
import { startWorker, stopWorker } from "./workers/runWorker.js";
import { closeRedis } from "./utils/redisClient.js";
import { formatLogLine } from "./utils/logFormatter.js";

dotenv.config();

// Same crash guards as `index.js` — Playwright surfaces unhandled rejections
// from browser internals that must not crash a worker mid-shard.
process.on("uncaughtException", (err) => {
  try { console.error(formatLogLine("error", null, `[FATAL] Uncaught exception (worker kept alive): ${err?.stack || err?.message || err}`)); }
  catch { console.error("[FATAL] Uncaught exception (worker kept alive):", err); }
});
process.on("unhandledRejection", (reason) => {
  try { console.error(formatLogLine("error", null, `[FATAL] Unhandled rejection (worker kept alive): ${reason?.stack || reason?.message || reason}`)); }
  catch { console.error("[FATAL] Unhandled rejection (worker kept alive):", reason); }
});

if (!process.env.REDIS_URL) {
  console.error(formatLogLine("error", null,
    "[worker] REDIS_URL is not set — distributed worker requires a shared BullMQ queue. Exiting."));
  process.exit(1);
}

// 1. DB init — repositories used by the job handler need the schema present.
getDatabase();
migrateFromJsonIfNeeded();

// 2. AI keys — feedback loop / regeneration calls happen inside jobs.
loadKeysFromDatabase();

// 3. Workspace backfill — same one-shot as the web entry point.
ensureDefaultWorkspaces();

// 4. Start the BullMQ Worker.
startWorker();

console.log(formatLogLine("info", null,
  `[worker] Standalone worker process started (REDIS_URL configured, WORKER_CONCURRENCY=${process.env.WORKER_CONCURRENCY || process.env.MAX_WORKERS || 2})`));

// ─── Graceful shutdown ────────────────────────────────────────────────────────
let _shuttingDown = false;
async function gracefulShutdown(signal) {
  if (_shuttingDown) return;
  _shuttingDown = true;
  console.log(formatLogLine("info", null, `[worker] ${signal} received — draining BullMQ worker`));
  try {
    await stopWorker();
    await closeQueue();
    await closeRedis();
    await closeDatabase();
    process.exit(0);
  } catch (err) {
    console.error(formatLogLine("error", null, `[worker] Shutdown error: ${err?.message || err}`));
    process.exit(1);
  }
}
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));