Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions src/cli-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1332,20 +1332,25 @@
snapshotStore: watchStore,
onSwap: newConfig => {
config = newConfig;
runner.updateConfig(newConfig);
logger.info('[Watch] Config updated');
},
});
const watcher = new ConfigWatcher(options.configPath, reloader);
watcher.start();
logger.info('Config watching enabled');

Check warning on line 1341 in src/cli-main.ts

View check run for this annotation

probelabs / Visor: quality

logic Issue

Signal handler removes listeners synchronously but then calls process.kill asynchronously. There's a small window where another signal could arrive after removal but before kill, potentially causing duplicate cleanup.
Raw output
Add a flag to track if cleanup is already in progress, and ignore subsequent signals while cleanup is running.

// Clean up watcher resources on process shutdown
const cleanup = () => {
// Clean up watcher resources on process shutdown, then re-raise
// the signal so the default handler (or other listeners) can terminate.
const onSignal = (sig: NodeJS.Signals) => {
watcher.stop();
watchStore.shutdown().catch(() => {});
process.removeListener('SIGINT', onSignal);
process.removeListener('SIGTERM', onSignal);
process.kill(process.pid, sig);
};
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
process.on('SIGINT', onSignal);
process.on('SIGTERM', onSignal);
} catch (watchErr: unknown) {
logger.warn(`Config watch setup failed (Slack mode continues without it): ${watchErr}`);
}
Expand Down
181 changes: 159 additions & 22 deletions src/config/config-watcher.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,108 @@
/**
* Config file watcher — watches for file changes and SIGUSR2 signals.
*
* Watches the main config file AND all local dependencies (extends, include,
* imports) so that changes to nested workflows, skills, or parent configs
* trigger a reload.
*
* Uses fs.watch (no external deps) with debouncing to handle editor quirks
* (multiple write events per save). SIGUSR2 is guarded for non-Windows only.
* The watcher uses persistent: false so it doesn't keep the process alive.
* All watchers use persistent: false so they don't keep the process alive.
*
* All reload calls are wrapped in error handling so that failures never
* propagate as unhandled promise rejections or crash the process.
* After each successful reload the watch list is refreshed so newly added
* or removed dependencies are tracked automatically.
*/
import fs from 'fs';
import path from 'path';
import { logger } from '../logger';
import { ConfigReloader } from './config-reloader';

const DEFAULT_DEBOUNCE_MS = 500;

/**
* Collect all local file paths that a config depends on by parsing YAML
* and following extends/include/imports chains recursively.
* Remote URLs and the special "default" source are skipped.
*/
export function collectLocalConfigDeps(configPath: string, visited?: Set<string>): string[] {
visited = visited || new Set();

Check warning on line 28 in src/config/config-watcher.ts

View check run for this annotation

probelabs / Visor: quality

logic Issue

collectLocalConfigDeps silently returns empty array on file read errors, making debugging difficult. Errors are caught but not logged, so users won't know why dependencies aren't being watched.
Raw output
Log a warning when file read fails so users can diagnose missing dependencies. Consider differentiating between 'file not found' (expected for optional deps) and 'permission denied' (actual error).

Check warning on line 28 in src/config/config-watcher.ts

View check run for this annotation

probelabs / Visor: quality

logic Issue

YAML parse errors are silently swallowed. Invalid YAML in dependency files will cause those dependencies to not be watched without any indication to the user.
Raw output
Log a warning when YAML parsing fails, including the file path and parse error. This helps users identify malformed config files.

Check warning on line 28 in src/config/config-watcher.ts

View check run for this annotation

probelabs / Visor: quality

style Issue

Using 'any' type for parsed YAML loses type safety and makes the code harder to maintain. The function should use proper typing or at least 'unknown'.
Raw output
Use 'unknown' instead of 'any' and add type guards or validation. Consider defining a minimal interface for config objects with extends/include/imports properties.

Check warning on line 28 in src/config/config-watcher.ts

View check run for this annotation

probelabs / Visor: quality

logic Issue

No tests verify error handling in collectLocalConfigDeps. The function silently returns empty arrays on errors, but there are no tests confirming this behavior or that errors are properly handled.
Raw output
Add tests for: permission denied errors, malformed YAML in dependencies, circular references with errors, and verify that error paths don't crash the watcher.
const absPath = path.resolve(configPath);
if (visited.has(absPath)) return [];
visited.add(absPath);

let content: string;
try {
content = fs.readFileSync(absPath, 'utf8');
} catch {
return [];
}

// Use a lightweight YAML parse — js-yaml is already a bundled dependency
let parsed: any;
try {
const yaml = require('js-yaml') as typeof import('js-yaml');
parsed = yaml.load(content);
} catch {
return [];
}

if (!parsed || typeof parsed !== 'object') return [];

const deps: string[] = [];
const baseDir = path.dirname(absPath);

const isLocal = (src: string): boolean =>
typeof src === 'string' &&
src !== 'default' &&
!src.startsWith('http://') &&
!src.startsWith('https://');

Check notice on line 59 in src/config/config-watcher.ts

View check run for this annotation

probelabs / Visor: quality

style Issue

The isLocal check logic is duplicated inline. This validation pattern appears multiple times and should be extracted to a reusable function.
Raw output
Extract isLocal to a named function at module level for better testability and reusability.

Check notice on line 59 in src/config/config-watcher.ts

View check run for this annotation

probelabs / Visor: quality

architecture Issue

collectLocalConfigDeps has deeply nested logic for handling different dependency types (extends, imports, workflow configs). The function is becoming complex and hard to maintain.
Raw output
Consider extracting dependency collection for each type (extends, imports, workflow refs) into separate helper functions to improve readability and testability.
// extends / include
const extendsVal = parsed.extends || parsed.include;
if (extendsVal) {
const sources: string[] = Array.isArray(extendsVal) ? extendsVal : [extendsVal];
for (const src of sources) {
if (!isLocal(src)) continue;
const resolved = path.resolve(baseDir, src);
deps.push(resolved);
deps.push(...collectLocalConfigDeps(resolved, visited));
}
}

// imports (workflow / skill files)
if (Array.isArray(parsed.imports)) {
for (const src of parsed.imports) {
if (!isLocal(src)) continue;
const resolved = path.resolve(baseDir, src);
deps.push(resolved);
deps.push(...collectLocalConfigDeps(resolved, visited));
}
}

// checks/steps that reference external workflow configs via `config:` field
const checks = parsed.checks || parsed.steps;
if (checks && typeof checks === 'object') {
for (const check of Object.values(checks) as any[]) {
if (
check?.type === 'workflow' &&
typeof check?.config === 'string' &&
isLocal(check.config)
) {
const resolved = path.resolve(baseDir, check.config);
deps.push(resolved);
deps.push(...collectLocalConfigDeps(resolved, visited));
}
}
}

return deps;
}

export class ConfigWatcher {
private configPath: string;
private reloader: ConfigReloader;
private debounceMs: number;
private watcher: fs.FSWatcher | null = null;
private watchers: Map<string, fs.FSWatcher> = new Map();
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
private signalHandler: (() => void) | null = null;

Expand All @@ -32,18 +116,11 @@
// Remove any previous listeners in case start() is called after stop()
this.stop();

// Watch the config file
try {
this.watcher = fs.watch(this.configPath, { persistent: false }, _eventType => {
this.debouncedReload();
});
// Collect all files to watch: main config + dependencies
const filesToWatch = this.collectWatchTargets();

this.watcher.on('error', err => {
logger.warn(`[ConfigWatcher] File watch error: ${err.message}`);
});
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn(`[ConfigWatcher] Could not watch file: ${msg}`);
for (const filePath of filesToWatch) {
this.watchFile(filePath);
}

// Listen for SIGUSR2 (non-Windows)
Expand All @@ -55,14 +132,16 @@
process.on('SIGUSR2', this.signalHandler);
}

logger.info(`[ConfigWatcher] Watching ${this.configPath} for changes`);
const depCount = filesToWatch.length - 1;
const depMsg = depCount > 0 ? ` (+ ${depCount} dependencies)` : '';
logger.info(`[ConfigWatcher] Watching ${this.configPath}${depMsg} for changes`);
}

stop(): void {
if (this.watcher) {
this.watcher.close();
this.watcher = null;
for (const [, watcher] of this.watchers) {
watcher.close();
}
this.watchers.clear();

if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
Expand All @@ -79,6 +158,56 @@
logger.debug('[ConfigWatcher] Stopped');
}

/**
* Refresh the set of watched files after a successful reload.
* New dependencies are watched; removed ones are unwatched.
*/
private refreshWatches(): void {
const newTargets = new Set(this.collectWatchTargets());
const current = new Set(this.watchers.keys());

Check notice on line 168 in src/config/config-watcher.ts

View check run for this annotation

probelabs / Visor: quality

architecture Issue

refreshWatches is called from safeReload after successful reload, but there's no mechanism to handle the case where refreshWatches itself fails (e.g., if new dependencies can't be watched).
Raw output
Consider wrapping refreshWatches in try-catch and logging errors. The watcher should continue watching old files even if adding new watches fails.
// Stop watching removed files
for (const filePath of current) {
if (!newTargets.has(filePath)) {
this.watchers.get(filePath)?.close();
this.watchers.delete(filePath);
logger.debug(`[ConfigWatcher] Unwatched removed dep: ${filePath}`);
}
}

// Start watching new files
for (const filePath of newTargets) {
if (!current.has(filePath)) {
this.watchFile(filePath);
logger.debug(`[ConfigWatcher] Watching new dep: ${filePath}`);
}
}
}

private collectWatchTargets(): string[] {
const mainPath = path.resolve(this.configPath);
const deps = collectLocalConfigDeps(this.configPath);
// Deduplicate
return [...new Set([mainPath, ...deps])];
}

private watchFile(filePath: string): void {
try {

Check warning on line 195 in src/config/config-watcher.ts

View check run for this annotation

probelabs / Visor: quality

logic Issue

watchFile catches errors but only logs warnings. If watching fails for a critical dependency (like the main config), the watcher will appear to work but won't detect changes.
Raw output
Consider tracking watch failures and exposing a method to check if all critical files are being watched successfully. Log at higher severity for main config watch failures.
const watcher = fs.watch(filePath, { persistent: false }, _eventType => {
this.debouncedReload();
});

watcher.on('error', err => {
logger.warn(`[ConfigWatcher] Watch error on ${filePath}: ${err.message}`);
});

this.watchers.set(filePath, watcher);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn(`[ConfigWatcher] Could not watch ${filePath}: ${msg}`);
}
}

private debouncedReload(): void {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
Expand All @@ -92,11 +221,19 @@

/**
* Fire-and-forget reload with full error handling.
* On success, refreshes the watch list to pick up new/removed dependencies.
* Ensures unhandled promise rejections never escape.
*/
private safeReload(): void {
this.reloader.reload().catch(err => {
logger.error(`[ConfigWatcher] Unhandled reload error: ${err}`);
});
this.reloader
.reload()
.then(success => {
if (success) {
this.refreshWatches();
}
})
.catch(err => {
logger.error(`[ConfigWatcher] Unhandled reload error: ${err}`);
});
}
}
5 changes: 5 additions & 0 deletions src/slack/socket-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ export class SlackSocketRunner {
this.cfg = cfg;
}

/** Hot-swap the config used for future requests (does not affect in-flight ones). */
updateConfig(cfg: VisorConfig): void {
this.cfg = cfg;
}

/**
* Lazily initialize the SlackClient if not already set.
* Called by both start() and handleMessage() to ensure the client is available.
Expand Down
Loading
Loading