pilotdeckConfigWatcher.js 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  1. import fs from 'fs';
  2. import fsPromises from 'fs/promises';
  3. import path from 'path';
  4. import {
  5. configToYaml,
  6. getPilotDeckConfigPath,
  7. maskSecrets,
  8. rawYamlToMaskedString,
  9. readPilotDeckConfigFile,
  10. validatePilotDeckConfig,
  11. } from './pilotdeckConfig.js';
  12. import { reloadPilotDeckConfig } from './pilotdeckConfigReloader.js';
  13. // Watches ~/.pilotdeck/pilotdeck.yaml for external edits (vim, Cursor, other IDEs)
  14. // and triggers the same reload path the UI uses on save, so *any* edit takes
  15. // effect live. When the UI itself writes the file it calls
  16. // suppressNextWatchEvent() first to avoid a redundant second reload.
  17. let watcher = null;
  18. let debounceTimer = null;
  19. let suppressCount = 0;
  20. let lastSignature = '';
  21. let onEventHandler = null;
  22. function signatureForFile(filePath) {
  23. try {
  24. const stat = fs.statSync(filePath);
  25. return `${stat.size}:${stat.mtimeMs}`;
  26. } catch {
  27. return 'missing';
  28. }
  29. }
  30. export function suppressNextWatchEvent() {
  31. suppressCount += 1;
  32. setTimeout(() => {
  33. suppressCount = Math.max(0, suppressCount - 1);
  34. }, 1500);
  35. }
  36. async function handleChange(configPath) {
  37. if (suppressCount > 0) return;
  38. const signature = signatureForFile(configPath);
  39. if (signature === lastSignature) return;
  40. lastSignature = signature;
  41. let record;
  42. try {
  43. record = readPilotDeckConfigFile();
  44. } catch (error) {
  45. onEventHandler?.({
  46. source: 'watcher',
  47. path: configPath,
  48. error: error instanceof Error ? error.message : String(error),
  49. validation: {
  50. valid: false,
  51. errors: [error instanceof Error ? error.message : String(error)],
  52. warnings: [],
  53. },
  54. timestamp: new Date().toISOString(),
  55. });
  56. return;
  57. }
  58. const validation = validatePilotDeckConfig(record.config);
  59. // Mirror serializeConfigResponse: emit the masked disk YAML so the
  60. // UI's hot-reload sees full router/gateway/adapters/etc. segments
  61. // when the file changes from any source (UI save, vim, external tool).
  62. const hasDiskYaml = record.rawYaml && typeof record.rawYaml === 'object' && Object.keys(record.rawYaml).length > 0;
  63. const maskedRaw = hasDiskYaml ? rawYamlToMaskedString(record.rawYaml) : configToYaml(maskSecrets(record.config));
  64. if (!validation.valid) {
  65. onEventHandler?.({
  66. source: 'watcher',
  67. path: record.configPath,
  68. raw: maskedRaw,
  69. validation: { valid: false, errors: validation.errors, warnings: validation.warnings },
  70. reload: null,
  71. timestamp: new Date().toISOString(),
  72. });
  73. return;
  74. }
  75. let reloadResult = null;
  76. try {
  77. reloadResult = await reloadPilotDeckConfig(record.config);
  78. } catch (error) {
  79. onEventHandler?.({
  80. source: 'watcher',
  81. path: record.configPath,
  82. raw: maskedRaw,
  83. validation: { valid: true, errors: [], warnings: validation.warnings },
  84. reload: null,
  85. error: error instanceof Error ? error.message : String(error),
  86. timestamp: new Date().toISOString(),
  87. });
  88. return;
  89. }
  90. onEventHandler?.({
  91. source: 'watcher',
  92. path: record.configPath,
  93. raw: maskedRaw,
  94. validation: { valid: true, errors: [], warnings: validation.warnings },
  95. reload: reloadResult,
  96. timestamp: new Date().toISOString(),
  97. });
  98. }
  99. export async function startPilotDeckConfigWatcher({ onEvent } = {}) {
  100. stopPilotDeckConfigWatcher();
  101. onEventHandler = typeof onEvent === 'function' ? onEvent : null;
  102. const configPath = getPilotDeckConfigPath();
  103. const configDir = path.dirname(configPath);
  104. const configBase = path.basename(configPath);
  105. try {
  106. await fsPromises.mkdir(configDir, { recursive: true });
  107. } catch (error) {
  108. console.warn('[pilotdeck-config-watcher] failed to ensure config dir:', error?.message || error);
  109. return;
  110. }
  111. lastSignature = signatureForFile(configPath);
  112. try {
  113. watcher = fs.watch(configDir, { persistent: false }, (eventType, filename) => {
  114. if (filename && filename !== configBase) return;
  115. if (debounceTimer) clearTimeout(debounceTimer);
  116. debounceTimer = setTimeout(() => {
  117. debounceTimer = null;
  118. void handleChange(configPath);
  119. }, 250);
  120. });
  121. watcher.on('error', (error) => {
  122. console.warn('[pilotdeck-config-watcher] watch error:', error?.message || error);
  123. });
  124. console.log(`[pilotdeck-config-watcher] watching ${configPath}`);
  125. } catch (error) {
  126. console.warn('[pilotdeck-config-watcher] failed to start:', error?.message || error);
  127. }
  128. }
  129. export function stopPilotDeckConfigWatcher() {
  130. if (watcher) {
  131. try {
  132. watcher.close();
  133. } catch {
  134. // noop
  135. }
  136. watcher = null;
  137. }
  138. if (debounceTimer) {
  139. clearTimeout(debounceTimer);
  140. debounceTimer = null;
  141. }
  142. }