feat(skills): add debounced watcher foundation for phase 2

This commit is contained in:
William Valentin
2026-02-12 17:15:46 -08:00
parent 0a19f01639
commit 95091cc198
4 changed files with 236 additions and 4 deletions
+2
View File
@@ -2,3 +2,5 @@ export type { SkillTier, SkillRequirements, SkillManifest, Skill } from './types
export { checkRequirements, loadSkill, discoverSkills, loadAllSkills } from './loader.js';
export { SkillRegistry } from './registry.js';
export { SkillInstaller } from './installer.js';
export { SkillsWatcher } from './watcher.js';
export type { SkillsWatcherConfig, SkillsWatcherEvent } from './watcher.js';
+101
View File
@@ -0,0 +1,101 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, mkdirSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { SkillsWatcher } from './watcher.js';
describe('SkillsWatcher', () => {
const roots: string[] = [];
afterEach(() => {
vi.useRealTimers();
for (const root of roots.splice(0)) {
rmSync(root, { recursive: true, force: true });
}
});
function makeDir(prefix: string): string {
const root = mkdtempSync(join(tmpdir(), prefix));
roots.push(root);
return root;
}
it('starts only on existing directories', () => {
const existing = makeDir('flynn-watcher-');
const missing = join(existing, 'missing');
const watcher = new SkillsWatcher({ skillDirs: [existing, missing] });
watcher.start();
expect(watcher.isRunning).toBe(true);
expect(watcher.watchedDirectoryCount).toBe(1);
watcher.stop();
});
it('debounces multiple change notifications into one callback', () => {
vi.useFakeTimers();
const dir = makeDir('flynn-watcher-');
const events: string[][] = [];
const watcher = new SkillsWatcher({
skillDirs: [dir],
debounceMs: 100,
onSkillsChanged: (event) => {
events.push(event.changedPaths);
},
});
watcher.start();
watcher.notifyPathChanged(join(dir, 'one/SKILL.md'));
watcher.notifyPathChanged(join(dir, 'two/manifest.json'));
vi.advanceTimersByTime(99);
expect(events.length).toBe(0);
vi.advanceTimersByTime(1);
expect(events.length).toBe(1);
expect(events[0]?.length).toBe(2);
watcher.stop();
});
it('clears pending events on stop', () => {
vi.useFakeTimers();
const dir = makeDir('flynn-watcher-');
const onSkillsChanged = vi.fn();
const watcher = new SkillsWatcher({
skillDirs: [dir],
debounceMs: 50,
onSkillsChanged,
});
watcher.start();
watcher.notifyPathChanged(join(dir, 'sample/SKILL.md'));
watcher.stop();
vi.advanceTimersByTime(60);
expect(onSkillsChanged).not.toHaveBeenCalled();
expect(watcher.isRunning).toBe(false);
});
it('ignores notifyPathChanged calls before start', () => {
const dir = makeDir('flynn-watcher-');
const onSkillsChanged = vi.fn();
const watcher = new SkillsWatcher({ skillDirs: [dir], onSkillsChanged });
watcher.notifyPathChanged(join(dir, 'ignored'));
expect(onSkillsChanged).not.toHaveBeenCalled();
});
it('handles repeated start calls safely', () => {
const dir = makeDir('flynn-watcher-');
mkdirSync(join(dir, 'nested'), { recursive: true });
const watcher = new SkillsWatcher({ skillDirs: [dir] });
watcher.start();
watcher.start();
expect(watcher.watchedDirectoryCount).toBe(1);
watcher.stop();
});
});
+115
View File
@@ -0,0 +1,115 @@
import { existsSync, statSync, watch } from 'fs';
import { join, resolve } from 'path';
import type { FSWatcher } from 'fs';
export interface SkillsWatcherEvent {
changedPaths: string[];
triggeredAt: Date;
}
export interface SkillsWatcherConfig {
skillDirs: string[];
debounceMs?: number;
onSkillsChanged?: (event: SkillsWatcherEvent) => void;
}
export class SkillsWatcher {
private readonly _skillDirs: string[];
private readonly _debounceMs: number;
private readonly _onSkillsChanged?: (event: SkillsWatcherEvent) => void;
private readonly _watchers: FSWatcher[] = [];
private readonly _pendingPaths = new Set<string>();
private _timer?: NodeJS.Timeout;
private _running = false;
constructor(config: SkillsWatcherConfig) {
this._skillDirs = [...new Set(config.skillDirs.map((dir) => resolve(dir)))];
this._debounceMs = config.debounceMs ?? 250;
this._onSkillsChanged = config.onSkillsChanged;
}
get isRunning(): boolean {
return this._running;
}
get watchedDirectoryCount(): number {
return this._watchers.length;
}
start(): void {
if (this._running) {
return;
}
for (const dir of this._skillDirs) {
if (!existsSync(dir)) {
continue;
}
try {
if (!statSync(dir).isDirectory()) {
continue;
}
} catch {
continue;
}
const watcher = watch(dir, (_eventType, filename) => {
if (!this._running) {
return;
}
const changedPath = filename ? join(dir, String(filename)) : dir;
this.notifyPathChanged(changedPath);
});
this._watchers.push(watcher);
}
this._running = true;
}
stop(): void {
if (!this._running) {
return;
}
for (const watcher of this._watchers) {
watcher.close();
}
this._watchers.length = 0;
if (this._timer) {
clearTimeout(this._timer);
this._timer = undefined;
}
this._pendingPaths.clear();
this._running = false;
}
notifyPathChanged(path: string): void {
if (!this._running) {
return;
}
this._pendingPaths.add(resolve(path));
if (this._timer) {
clearTimeout(this._timer);
}
this._timer = setTimeout(() => {
this._flushPending();
}, this._debounceMs);
}
private _flushPending(): void {
this._timer = undefined;
if (this._pendingPaths.size === 0) {
return;
}
const changedPaths = Array.from(this._pendingPaths).sort();
this._pendingPaths.clear();
this._onSkillsChanged?.({
changedPaths,
triggeredAt: new Date(),
});
}
}