feat(skills): add debounced watcher foundation for phase 2
This commit is contained in:
+18
-4
@@ -1269,9 +1269,23 @@
|
|||||||
},
|
},
|
||||||
"phase_2_skills_watcher": {
|
"phase_2_skills_watcher": {
|
||||||
"priority": "P1",
|
"priority": "P1",
|
||||||
"status": "not_started",
|
"status": "in_progress",
|
||||||
"description": "Auto-reload skills with chokidar file watcher, configurable debounce",
|
"description": "Auto-reload skills with chokidar file watcher, configurable debounce",
|
||||||
"effort": "3-4 hours"
|
"effort": "3-4 hours",
|
||||||
|
"sub_slices": {
|
||||||
|
"watcher_bootstrap": {
|
||||||
|
"status": "completed",
|
||||||
|
"description": "Added core `SkillsWatcher` class with debounced change batching, lifecycle controls, and foundational tests",
|
||||||
|
"files_created": [
|
||||||
|
"src/skills/watcher.ts",
|
||||||
|
"src/skills/watcher.test.ts"
|
||||||
|
],
|
||||||
|
"files_modified": [
|
||||||
|
"src/skills/index.ts"
|
||||||
|
],
|
||||||
|
"test_status": "typecheck + targeted watcher tests + full test suite + lint (warnings only, 0 errors) + build passing"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"phase_3_installer_specs": {
|
"phase_3_installer_specs": {
|
||||||
"priority": "P1",
|
"priority": "P1",
|
||||||
@@ -1304,7 +1318,7 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
"overall_progress": {
|
"overall_progress": {
|
||||||
"total_test_count": 1504,
|
"total_test_count": 1509,
|
||||||
"all_tests_passing": true,
|
"all_tests_passing": true,
|
||||||
"p0_completion": "3/3 (100%)",
|
"p0_completion": "3/3 (100%)",
|
||||||
"p1_completion": "4/4 (100%)",
|
"p1_completion": "4/4 (100%)",
|
||||||
@@ -1324,7 +1338,7 @@
|
|||||||
"gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram",
|
"gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram",
|
||||||
"native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback",
|
"native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback",
|
||||||
"remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 2/2 (100%) — component registry, confidence routing. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening",
|
"remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 2/2 (100%) — component registry, confidence routing. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening",
|
||||||
"next_up": "Skills infrastructure Phase 2: implement skill auto-reload watcher with configurable debounce"
|
"next_up": "Skills infrastructure Phase 2: wire SkillsWatcher into daemon init/shutdown and config toggle"
|
||||||
},
|
},
|
||||||
"soul_md_and_cron_create": {
|
"soul_md_and_cron_create": {
|
||||||
"date": "2026-02-11",
|
"date": "2026-02-11",
|
||||||
|
|||||||
@@ -2,3 +2,5 @@ export type { SkillTier, SkillRequirements, SkillManifest, Skill } from './types
|
|||||||
export { checkRequirements, loadSkill, discoverSkills, loadAllSkills } from './loader.js';
|
export { checkRequirements, loadSkill, discoverSkills, loadAllSkills } from './loader.js';
|
||||||
export { SkillRegistry } from './registry.js';
|
export { SkillRegistry } from './registry.js';
|
||||||
export { SkillInstaller } from './installer.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