feat(skills): add debounced watcher foundation for phase 2
This commit is contained in:
+18
-4
@@ -1269,9 +1269,23 @@
|
||||
},
|
||||
"phase_2_skills_watcher": {
|
||||
"priority": "P1",
|
||||
"status": "not_started",
|
||||
"status": "in_progress",
|
||||
"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": {
|
||||
"priority": "P1",
|
||||
@@ -1304,7 +1318,7 @@
|
||||
},
|
||||
|
||||
"overall_progress": {
|
||||
"total_test_count": 1504,
|
||||
"total_test_count": 1509,
|
||||
"all_tests_passing": true,
|
||||
"p0_completion": "3/3 (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",
|
||||
"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",
|
||||
"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": {
|
||||
"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 { 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