feat(skills): add debounced watcher foundation for phase 2
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user