cron-daemon-startup.js 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. import path from 'path';
  2. import { promises as fs, openSync } from 'fs';
  3. import { mkdirSync } from 'fs';
  4. import os from 'os';
  5. import { spawn } from 'child_process';
  6. import { sendCronDaemonRequest } from './cron-daemon-owner.js';
  7. // Cron daemon entry point. The launcher script is discoverable on PATH
  8. // or supplied via PILOTDECK_CRON_DAEMON_BIN. Returning `null` falls back
  9. // to the in-tree fallback path that handles missing binaries gracefully.
  10. function resolvePilotDeckMainRoot() {
  11. return null;
  12. }
  13. const DEFAULT_RETRY_ATTEMPTS = 20;
  14. const DEFAULT_RETRY_DELAY_MS = 250;
  15. const START_LOCK_STALE_MS = 30000;
  16. const CCR_SENTINEL = 'http://ccr.local';
  17. const CCR_DAEMON_FETCH_INTERCEPTOR = 'CCR_DAEMON_FETCH_INTERCEPTOR';
  18. function getPilotDeckConfigHomeDir() {
  19. return process.env.PILOTDECK_CONFIG_DIR || process.env.PILOT_HOME || path.join(os.homedir(), '.pilotdeck');
  20. }
  21. function getCronDaemonStartLockPath() {
  22. return path.join(getPilotDeckConfigHomeDir(), 'cron-daemon', 'start.lock');
  23. }
  24. /**
  25. * Resolve a log file path for the detached cron daemon.
  26. *
  27. * Prior to this, the daemon spawned with `stdio: 'ignore'` so all of its
  28. * lifecycle output, errors, and discovery-scheduler trace was silently
  29. * discarded — making post-mortem debugging on the PilotDeck Desktop install
  30. * basically impossible (`~/.pilotdeck/desktop.server.log` only captured the
  31. * UI server's own output, not its detached children).
  32. *
  33. * We honour an explicit override via `PILOTDECK_CRON_DAEMON_LOG`; otherwise we
  34. * default to `~/.pilotdeck/cron-daemon.log` (parallel to `desktop.server.log`).
  35. * The directory is created on demand so this works pre-onboarding too.
  36. */
  37. function resolveCronDaemonLogPath() {
  38. const override = process.env.PILOTDECK_CRON_DAEMON_LOG?.trim();
  39. if (override) return override;
  40. return path.join(process.env.PILOT_HOME || path.join(os.homedir(), '.pilotdeck'), 'cron-daemon.log');
  41. }
  42. function openCronDaemonLogFd() {
  43. const logPath = resolveCronDaemonLogPath();
  44. try {
  45. mkdirSync(path.dirname(logPath), { recursive: true });
  46. const fd = openSync(logPath, 'a');
  47. return { fd, logPath };
  48. } catch (err) {
  49. // Fall back to ignore — better to lose stdout than to fail to spawn.
  50. console.warn(`[WARN] Cron daemon log unavailable (${logPath}): ${err?.message ?? err}`);
  51. return { fd: null, logPath };
  52. }
  53. }
  54. function sleep(ms) {
  55. return new Promise((resolve) => {
  56. setTimeout(resolve, ms);
  57. });
  58. }
  59. export function isCronDaemonUnavailableError(error) {
  60. return Boolean(
  61. error instanceof Error &&
  62. 'code' in error &&
  63. (error.code === 'ENOENT' || error.code === 'ECONNREFUSED')
  64. );
  65. }
  66. export function buildCronDaemonEnv(baseEnv = process.env) {
  67. const env = { ...baseEnv };
  68. if (env.ANTHROPIC_BASE_URL === CCR_SENTINEL) {
  69. env[CCR_DAEMON_FETCH_INTERCEPTOR] = '1';
  70. }
  71. return env;
  72. }
  73. export function buildCronDaemonSpawnCommand({
  74. resolvePilotDeckMainRootFn = resolvePilotDeckMainRoot,
  75. cliPath = process.env.PILOTDECK_CLI_PATH
  76. } = {}) {
  77. const localMainRoot = resolvePilotDeckMainRootFn();
  78. if (localMainRoot) {
  79. const preloadPath = path.join(localMainRoot, 'preload.ts');
  80. const daemonMainPath = path.join(localMainRoot, 'src', 'daemon', 'main.ts');
  81. return {
  82. command: 'bun',
  83. args: [
  84. '--preload',
  85. preloadPath,
  86. '-e',
  87. `const { daemonMain } = await import(${JSON.stringify(daemonMainPath)}); await daemonMain(['serve'])`
  88. ]
  89. };
  90. }
  91. return {
  92. command: typeof cliPath === 'string' && cliPath.trim().length > 0 ? cliPath.trim() : 'pilotdeck',
  93. args: ['daemon', 'serve']
  94. };
  95. }
  96. async function acquireStartLock() {
  97. const lockPath = getCronDaemonStartLockPath();
  98. await fs.mkdir(path.dirname(lockPath), { recursive: true });
  99. try {
  100. const handle = await fs.open(lockPath, 'wx');
  101. await handle.writeFile(`${process.pid}\n`, 'utf8');
  102. await handle.close();
  103. return async () => {
  104. await fs.rm(lockPath, { force: true }).catch(() => {});
  105. };
  106. } catch (error) {
  107. if (error?.code !== 'EEXIST') {
  108. throw error;
  109. }
  110. }
  111. const ageMs = await fs.stat(lockPath)
  112. .then((stats) => Date.now() - stats.mtimeMs)
  113. .catch(() => 0);
  114. if (ageMs > START_LOCK_STALE_MS) {
  115. await fs.rm(lockPath, { force: true }).catch(() => {});
  116. return await acquireStartLock();
  117. }
  118. return null;
  119. }
  120. export async function pingCronDaemon({
  121. sendCronDaemonRequestFn = sendCronDaemonRequest
  122. } = {}) {
  123. const response = await sendCronDaemonRequestFn({ type: 'ping' });
  124. if (!response?.ok || response.data?.type !== 'pong') {
  125. throw new Error('Unexpected Cron daemon ping response');
  126. }
  127. return response;
  128. }
  129. export function startCronDaemonDetached({
  130. spawnFn = spawn,
  131. buildCronDaemonSpawnCommandFn = buildCronDaemonSpawnCommand,
  132. openLogFdFn = openCronDaemonLogFd
  133. } = {}) {
  134. const { command, args } = buildCronDaemonSpawnCommandFn();
  135. const { fd, logPath } = openLogFdFn();
  136. // Detach so multiple ui servers (e.g. dev + PilotDeck Desktop side-by-side)
  137. // can share state through ~/.pilotdeck/cron-daemon.sock, but pipe stdout/stderr
  138. // into a real log file instead of /dev/null so the daemon is debuggable
  139. // post-mortem. Stdin stays 'ignore' (the daemon never reads input).
  140. const stdio = fd === null ? 'ignore' : ['ignore', fd, fd];
  141. let child;
  142. try {
  143. child = spawnFn(command, args, {
  144. cwd: process.cwd(),
  145. env: buildCronDaemonEnv(),
  146. detached: true,
  147. stdio
  148. });
  149. } catch (err) {
  150. console.warn(`[WARN] Cron daemon spawn failed: ${err.message}`);
  151. return null;
  152. }
  153. child.on('error', (err) => {
  154. console.warn(`[WARN] Cron daemon process error: ${err.message}`);
  155. });
  156. if (typeof child?.unref === 'function') {
  157. child.unref();
  158. }
  159. if (fd !== null) {
  160. console.log(`[INFO] Cron daemon spawned, output → ${logPath}`);
  161. }
  162. return child;
  163. }
  164. export async function ensureCronDaemonForUiStartup({
  165. sendCronDaemonRequestFn = sendCronDaemonRequest,
  166. spawnFn = spawn,
  167. buildCronDaemonSpawnCommandFn = buildCronDaemonSpawnCommand,
  168. openLogFdFn = openCronDaemonLogFd,
  169. sleepFn = sleep,
  170. retryAttempts = DEFAULT_RETRY_ATTEMPTS,
  171. retryDelayMs = DEFAULT_RETRY_DELAY_MS
  172. } = {}) {
  173. try {
  174. return await pingCronDaemon({ sendCronDaemonRequestFn });
  175. } catch (error) {
  176. if (!isCronDaemonUnavailableError(error)) {
  177. throw error;
  178. }
  179. }
  180. const releaseStartLock = await acquireStartLock();
  181. if (releaseStartLock) {
  182. try {
  183. try {
  184. return await pingCronDaemon({ sendCronDaemonRequestFn });
  185. } catch {
  186. // We own startup now; any unhealthy ping means this process should spawn.
  187. }
  188. startCronDaemonDetached({
  189. spawnFn,
  190. buildCronDaemonSpawnCommandFn,
  191. openLogFdFn
  192. });
  193. let lastError = null;
  194. for (let attempt = 0; attempt < retryAttempts; attempt += 1) {
  195. try {
  196. return await pingCronDaemon({ sendCronDaemonRequestFn });
  197. } catch (error) {
  198. lastError = error;
  199. if (attempt < retryAttempts - 1) {
  200. await sleepFn(retryDelayMs);
  201. }
  202. }
  203. }
  204. throw lastError instanceof Error ? lastError : new Error('Cron daemon failed to start');
  205. } finally {
  206. await releaseStartLock();
  207. }
  208. }
  209. let lastError = null;
  210. for (let attempt = 0; attempt < retryAttempts; attempt += 1) {
  211. try {
  212. return await pingCronDaemon({ sendCronDaemonRequestFn });
  213. } catch (error) {
  214. lastError = error;
  215. if (attempt < retryAttempts - 1) {
  216. await sleepFn(retryDelayMs);
  217. }
  218. }
  219. }
  220. throw lastError instanceof Error ? lastError : new Error('Cron daemon failed to start');
  221. }