feat(skills): reload registry on watcher change events
This commit is contained in:
+13
-2
@@ -1299,6 +1299,17 @@
|
|||||||
"src/skills/index.ts"
|
"src/skills/index.ts"
|
||||||
],
|
],
|
||||||
"test_status": "typecheck + targeted schema/services tests + full test suite + lint (warnings only, 0 errors) + build passing"
|
"test_status": "typecheck + targeted schema/services tests + full test suite + lint (warnings only, 0 errors) + build passing"
|
||||||
|
},
|
||||||
|
"watcher_callback_full_reload": {
|
||||||
|
"status": "completed",
|
||||||
|
"description": "Implemented watcher callback behavior to fully reload skills from disk and repopulate SkillRegistry on debounced change events",
|
||||||
|
"files_modified": [
|
||||||
|
"src/daemon/services.ts",
|
||||||
|
"src/daemon/services.test.ts",
|
||||||
|
"src/skills/registry.ts",
|
||||||
|
"src/skills/registry.test.ts"
|
||||||
|
],
|
||||||
|
"test_status": "pnpm typecheck + pnpm test:run src/daemon/services.test.ts src/skills/registry.test.ts + pnpm test:run + pnpm lint (warnings only, 0 errors) + pnpm build passing"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1333,7 +1344,7 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
"overall_progress": {
|
"overall_progress": {
|
||||||
"total_test_count": 1513,
|
"total_test_count": 1515,
|
||||||
"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%)",
|
||||||
@@ -1353,7 +1364,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 watcher callback to reload skill registry on add/update/remove"
|
"next_up": "Skills infrastructure Phase 2: optimize watcher reload path to targeted skill-level add/update/remove with safe fallback to full reload"
|
||||||
},
|
},
|
||||||
"soul_md_and_cron_create": {
|
"soul_md_and_cron_create": {
|
||||||
"date": "2026-02-11",
|
"date": "2026-02-11",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { mkdtempSync, mkdirSync, rmSync } from 'fs';
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'os';
|
||||||
import { configSchema } from '../config/schema.js';
|
import { configSchema } from '../config/schema.js';
|
||||||
@@ -10,11 +10,18 @@ describe('initSkills watcher wiring', () => {
|
|||||||
const roots: string[] = [];
|
const roots: string[] = [];
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
for (const root of roots.splice(0)) {
|
for (const root of roots.splice(0)) {
|
||||||
rmSync(root, { recursive: true, force: true });
|
rmSync(root, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function writeSkill(rootDir: string, name: string): void {
|
||||||
|
const skillDir = join(rootDir, name);
|
||||||
|
mkdirSync(skillDir, { recursive: true });
|
||||||
|
writeFileSync(join(skillDir, 'SKILL.md'), `# ${name}\n\nTest skill.`);
|
||||||
|
}
|
||||||
|
|
||||||
function makeConfig(overrides: Record<string, unknown> = {}) {
|
function makeConfig(overrides: Record<string, unknown> = {}) {
|
||||||
return configSchema.parse({
|
return configSchema.parse({
|
||||||
telegram: { bot_token: 'test-token', allowed_chat_ids: [1] },
|
telegram: { bot_token: 'test-token', allowed_chat_ids: [1] },
|
||||||
@@ -54,4 +61,40 @@ describe('initSkills watcher wiring', () => {
|
|||||||
await lifecycle.shutdown();
|
await lifecycle.shutdown();
|
||||||
expect(result.skillsWatcher?.isRunning).toBe(false);
|
expect(result.skillsWatcher?.isRunning).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reloads registry from disk when watcher callback fires', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'flynn-services-'));
|
||||||
|
roots.push(root);
|
||||||
|
const managedDir = join(root, 'skills');
|
||||||
|
mkdirSync(managedDir, { recursive: true });
|
||||||
|
writeSkill(managedDir, 'alpha');
|
||||||
|
|
||||||
|
const config = makeConfig({
|
||||||
|
skills: {
|
||||||
|
managed_dir: managedDir,
|
||||||
|
load: { watch: true, watch_debounce_ms: 20 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const lifecycle = new Lifecycle();
|
||||||
|
|
||||||
|
const result = initSkills(config, lifecycle);
|
||||||
|
expect(result.skillRegistry.get('alpha')).toBeDefined();
|
||||||
|
expect(result.skillRegistry.get('beta')).toBeUndefined();
|
||||||
|
|
||||||
|
writeSkill(managedDir, 'beta');
|
||||||
|
result.skillsWatcher?.notifyPathChanged(join(managedDir, 'beta', 'SKILL.md'));
|
||||||
|
vi.advanceTimersByTime(20);
|
||||||
|
|
||||||
|
expect(result.skillRegistry.get('beta')).toBeDefined();
|
||||||
|
|
||||||
|
rmSync(join(managedDir, 'alpha'), { recursive: true, force: true });
|
||||||
|
result.skillsWatcher?.notifyPathChanged(join(managedDir, 'alpha'));
|
||||||
|
vi.advanceTimersByTime(20);
|
||||||
|
|
||||||
|
expect(result.skillRegistry.get('alpha')).toBeUndefined();
|
||||||
|
expect(result.skillRegistry.get('beta')).toBeDefined();
|
||||||
|
|
||||||
|
result.skillsWatcher?.stop();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,12 +30,13 @@ export function initSkills(config: Config, lifecycle?: Lifecycle): SkillsResult
|
|||||||
const defaultManagedDir = resolve(homedir(), '.flynn/workspace/skills');
|
const defaultManagedDir = resolve(homedir(), '.flynn/workspace/skills');
|
||||||
const skillRegistry = new SkillRegistry();
|
const skillRegistry = new SkillRegistry();
|
||||||
const skillInstaller = new SkillInstaller(config.skills.managed_dir ?? defaultManagedDir);
|
const skillInstaller = new SkillInstaller(config.skills.managed_dir ?? defaultManagedDir);
|
||||||
|
const skillLoadConfig = {
|
||||||
const skills = loadAllSkills({
|
|
||||||
bundledDir: config.skills.bundled_dir,
|
bundledDir: config.skills.bundled_dir,
|
||||||
managedDir: config.skills.managed_dir ?? defaultManagedDir,
|
managedDir: config.skills.managed_dir ?? defaultManagedDir,
|
||||||
workspaceDir: config.skills.workspace_dir,
|
workspaceDir: config.skills.workspace_dir,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const skills = loadAllSkills(skillLoadConfig);
|
||||||
|
|
||||||
for (const skill of skills) {
|
for (const skill of skills) {
|
||||||
skillRegistry.register(skill);
|
skillRegistry.register(skill);
|
||||||
@@ -61,7 +62,9 @@ export function initSkills(config: Config, lifecycle?: Lifecycle): SkillsResult
|
|||||||
skillDirs,
|
skillDirs,
|
||||||
debounceMs: config.skills.load.watch_debounce_ms,
|
debounceMs: config.skills.load.watch_debounce_ms,
|
||||||
onSkillsChanged: ({ changedPaths }) => {
|
onSkillsChanged: ({ changedPaths }) => {
|
||||||
console.log(`Skills watcher detected changes in ${changedPaths.length} path(s)`);
|
const reloadedSkills = loadAllSkills(skillLoadConfig);
|
||||||
|
skillRegistry.reset(reloadedSkills);
|
||||||
|
console.log(`Skills watcher reloaded ${reloadedSkills.length} skill(s) after ${changedPaths.length} change(s)`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
skillsWatcher.start();
|
skillsWatcher.start();
|
||||||
|
|||||||
@@ -106,6 +106,23 @@ describe('SkillRegistry', () => {
|
|||||||
expect(registry.unregister('ghost')).toBe(false);
|
expect(registry.unregister('ghost')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reset replaces all existing skills with a new set', () => {
|
||||||
|
const registry = new SkillRegistry();
|
||||||
|
registry.register(makeSkill({ name: 'old-one' }));
|
||||||
|
registry.register(makeSkill({ name: 'old-two' }));
|
||||||
|
|
||||||
|
registry.reset([
|
||||||
|
makeSkill({ name: 'new-one' }),
|
||||||
|
makeSkill({ name: 'new-two', available: false }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(registry.get('old-one')).toBeUndefined();
|
||||||
|
expect(registry.get('old-two')).toBeUndefined();
|
||||||
|
expect(registry.get('new-one')).toBeDefined();
|
||||||
|
expect(registry.get('new-two')).toBeDefined();
|
||||||
|
expect(registry.list()).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
it('getSystemPromptAdditions returns empty string with no skills', () => {
|
it('getSystemPromptAdditions returns empty string with no skills', () => {
|
||||||
// Negative: with nothing registered the prompt additions should be empty.
|
// Negative: with nothing registered the prompt additions should be empty.
|
||||||
const registry = new SkillRegistry();
|
const registry = new SkillRegistry();
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ import type { Skill } from './types.js';
|
|||||||
export class SkillRegistry {
|
export class SkillRegistry {
|
||||||
private skills: Map<string, Skill> = new Map();
|
private skills: Map<string, Skill> = new Map();
|
||||||
|
|
||||||
|
/** Replace all registered skills with the provided set. */
|
||||||
|
reset(skills: Skill[]): void {
|
||||||
|
this.skills.clear();
|
||||||
|
for (const skill of skills) {
|
||||||
|
this.skills.set(skill.manifest.name, skill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Register a skill. Replaces any existing skill with the same name. */
|
/** Register a skill. Replaces any existing skill with the same name. */
|
||||||
register(skill: Skill): void {
|
register(skill: Skill): void {
|
||||||
this.skills.set(skill.manifest.name, skill);
|
this.skills.set(skill.manifest.name, skill);
|
||||||
|
|||||||
Reference in New Issue
Block a user