daemon.ts 3.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
  1. import { Command } from "commander";
  2. import { Scheduler } from "@actalk/inkos-core";
  3. import { loadConfig, findProjectRoot, buildPipelineConfig, log, logError } from "../utils.js";
  4. import { createWriteStream, type WriteStream } from "node:fs";
  5. import { writeFile, readFile, unlink } from "node:fs/promises";
  6. import { join } from "node:path";
  7. const PID_FILE = "inkos.pid";
  8. export const upCommand = new Command("up")
  9. .description("Start the InkOS daemon (autonomous mode)")
  10. .option("-q, --quiet", "Suppress console output")
  11. .action(async (opts) => {
  12. let logStream: WriteStream | undefined;
  13. try {
  14. const config = await loadConfig();
  15. const root = findProjectRoot();
  16. // Check if already running
  17. const pidPath = join(root, PID_FILE);
  18. try {
  19. const existingPid = await readFile(pidPath, "utf-8");
  20. logError(`Daemon already running (PID: ${existingPid.trim()}). Run 'inkos down' first.`);
  21. process.exit(1);
  22. } catch {
  23. // No PID file, good
  24. }
  25. log("Starting InkOS daemon...");
  26. log(` Write cycle: ${config.daemon.schedule.writeCron}`);
  27. log(` Radar scan: ${config.daemon.schedule.radarCron}`);
  28. log(` Max concurrent books: ${config.daemon.maxConcurrentBooks}`);
  29. log("");
  30. // Write PID file
  31. await writeFile(pidPath, String(process.pid), "utf-8");
  32. // File logging for daemon
  33. const logPath = join(root, "inkos.log");
  34. logStream = createWriteStream(logPath, { flags: "a" });
  35. const scheduler = new Scheduler({
  36. ...buildPipelineConfig(config, root, { logFile: logStream, quiet: opts.quiet }),
  37. radarCron: config.daemon.schedule.radarCron,
  38. writeCron: config.daemon.schedule.writeCron,
  39. maxConcurrentBooks: config.daemon.maxConcurrentBooks,
  40. chaptersPerCycle: config.daemon.chaptersPerCycle,
  41. retryDelayMs: config.daemon.retryDelayMs,
  42. cooldownAfterChapterMs: config.daemon.cooldownAfterChapterMs,
  43. maxChaptersPerDay: config.daemon.maxChaptersPerDay,
  44. onChapterComplete: (bookId, chapter, status) => {
  45. const icon = status === "ready-for-review" ? "+" : "!";
  46. log(` [${icon}] ${bookId} Ch.${chapter} — ${status}`);
  47. },
  48. onError: (bookId, error) => {
  49. logError(`${bookId}: ${error.message}`);
  50. },
  51. });
  52. // Handle shutdown
  53. const shutdown = async () => {
  54. log("\nShutting down daemon...");
  55. scheduler.stop();
  56. logStream?.end();
  57. try {
  58. await unlink(pidPath);
  59. } catch {
  60. // ignore
  61. }
  62. process.exit(0);
  63. };
  64. process.on("SIGINT", shutdown);
  65. process.on("SIGTERM", shutdown);
  66. await scheduler.start();
  67. log("Daemon running. Press Ctrl+C to stop.");
  68. // Keep process alive
  69. await new Promise(() => {});
  70. } catch (e) {
  71. logStream?.end();
  72. logError(`Failed to start daemon: ${e}`);
  73. process.exit(1);
  74. }
  75. });
  76. export const downCommand = new Command("down")
  77. .description("Stop the InkOS daemon")
  78. .action(async () => {
  79. const root = findProjectRoot();
  80. const pidPath = join(root, PID_FILE);
  81. try {
  82. const pid = (await readFile(pidPath, "utf-8")).trim();
  83. try {
  84. process.kill(parseInt(pid, 10), "SIGTERM");
  85. log(`Daemon (PID: ${pid}) stopped.`);
  86. } catch {
  87. log(`Daemon (PID: ${pid}) not found. Cleaning up.`);
  88. }
  89. try { await unlink(pidPath); } catch { /* already cleaned up by daemon */ }
  90. } catch {
  91. log("No daemon running.");
  92. }
  93. });