import fs from 'fs';
import fsPromises from 'fs/promises';
import path from 'path';
import {
configToYaml,
getPilotDeckConfigPath,
maskSecrets,
rawYamlToMaskedString,
readPilotDeckConfigFile,
validatePilotDeckConfig,
} from './pilotdeckConfig.js';
import { reloadPilotDeckConfig } from './pilotdeckConfigReloader.js';
let watcher = null;
let debounceTimer = null;
let suppressCount = 0;
let lastSignature = '';
let onEventHandler = null;
function signatureForFile(filePath) {
try {
const stat = fs.statSync(filePath);
return `${stat.size}:${stat.mtimeMs}`;
} catch {
return 'missing';
}
}
export function suppressNextWatchEvent() {
suppressCount += 1;
setTimeout(() => {
suppressCount = Math.max(0, suppressCount - 1);
}, 1500);
}
async function handleChange(configPath) {
if (suppressCount > 0) return;
const signature = signatureForFile(configPath);
if (signature === lastSignature) return;
lastSignature = signature;
let record;
try {
record = readPilotDeckConfigFile();
} catch (error) {
onEventHandler?.({
source: 'watcher',
path: configPath,
error: error instanceof Error ? error.message : String(error),
validation: {
valid: false,
errors: [error instanceof Error ? error.message : String(error)],
warnings: [],
},
timestamp: new Date().toISOString(),
});
return;
}
const validation = validatePilotDeckConfig(record.config);
const hasDiskYaml = record.rawYaml && typeof record.rawYaml === 'object' && Object.keys(record.rawYaml).length > 0;
const maskedRaw = hasDiskYaml ? rawYamlToMaskedString(record.rawYaml) : configToYaml(maskSecrets(record.config));
if (!validation.valid) {
onEventHandler?.({
source: 'watcher',
path: record.configPath,
raw: maskedRaw,
validation: { valid: false, errors: validation.errors, warnings: validation.warnings },
reload: null,
timestamp: new Date().toISOString(),
});
return;
}
let reloadResult = null;
try {
reloadResult = await reloadPilotDeckConfig(record.config);
} catch (error) {
onEventHandler?.({
source: 'watcher',
path: record.configPath,
raw: maskedRaw,
validation: { valid: true, errors: [], warnings: validation.warnings },
reload: null,
error: error instanceof Error ? error.message : String(error),
timestamp: new Date().toISOString(),
});
return;
}
onEventHandler?.({
source: 'watcher',
path: record.configPath,
raw: maskedRaw,
validation: { valid: true, errors: [], warnings: validation.warnings },
reload: reloadResult,
timestamp: new Date().toISOString(),
});
}
export async function startPilotDeckConfigWatcher({ onEvent } = {}) {
stopPilotDeckConfigWatcher();
onEventHandler = typeof onEvent === 'function' ? onEvent : null;
const configPath = getPilotDeckConfigPath();
const configDir = path.dirname(configPath);
const configBase = path.basename(configPath);
try {
await fsPromises.mkdir(configDir, { recursive: true });
} catch (error) {
console.warn('[pilotdeck-config-watcher] failed to ensure config dir:', error?.message || error);
return;
}
lastSignature = signatureForFile(configPath);
try {
watcher = fs.watch(configDir, { persistent: false }, (eventType, filename) => {
if (filename && filename !== configBase) return;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
debounceTimer = null;
void handleChange(configPath);
}, 250);
});
watcher.on('error', (error) => {
console.warn('[pilotdeck-config-watcher] watch error:', error?.message || error);
});
console.log(`[pilotdeck-config-watcher] watching ${configPath}`);
} catch (error) {
console.warn('[pilotdeck-config-watcher] failed to start:', error?.message || error);
}
}
export function stopPilotDeckConfigWatcher() {
if (watcher) {
try {
watcher.close();
} catch {
}
watcher = null;
}
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
}