mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-17 02:48:14 +00:00
f3f4c67b36
Multi-agent AI gateway with WebSocket RPC, HTTP API, and messaging channel integrations. Go port of OpenClaw with multi-tenant PostgreSQL, per-user isolation, security hardening, and production observability. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
174 lines
3.5 KiB
Go
174 lines
3.5 KiB
Go
package skills
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/fsnotify/fsnotify"
|
|
)
|
|
|
|
// watchDebounce is the delay before processing skill directory changes.
|
|
// Shorter than memory watcher (500ms vs 1500ms) because skill changes are lightweight.
|
|
const watchDebounce = 500 * time.Millisecond
|
|
|
|
// Watcher monitors skill directories for SKILL.md changes and bumps the loader version.
|
|
// Reuses the same fsnotify + debounce pattern as memory.Watcher.
|
|
type Watcher struct {
|
|
loader *Loader
|
|
fsw *fsnotify.Watcher
|
|
cancel context.CancelFunc
|
|
wg sync.WaitGroup
|
|
|
|
// debounce state
|
|
mu sync.Mutex
|
|
timer *time.Timer
|
|
pending bool // any change detected since last flush?
|
|
}
|
|
|
|
// NewWatcher creates a skills directory watcher.
|
|
func NewWatcher(loader *Loader) (*Watcher, error) {
|
|
fsw, err := fsnotify.NewWatcher()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Watcher{
|
|
loader: loader,
|
|
fsw: fsw,
|
|
}, nil
|
|
}
|
|
|
|
// Start begins watching all skill directories for changes.
|
|
func (w *Watcher) Start(ctx context.Context) error {
|
|
dirs := w.loader.Dirs()
|
|
watched := 0
|
|
|
|
for _, dir := range dirs {
|
|
// Watch the skill root dir (detects new skill folders)
|
|
if err := w.fsw.Add(dir); err != nil {
|
|
// Directory may not exist yet — that's fine
|
|
if !os.IsNotExist(err) {
|
|
slog.Warn("skills watcher: cannot watch dir", "path", dir, "error", err)
|
|
}
|
|
continue
|
|
}
|
|
watched++
|
|
|
|
// Watch each existing skill subdirectory (detects SKILL.md changes)
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
continue
|
|
}
|
|
subDir := filepath.Join(dir, e.Name())
|
|
if err := w.fsw.Add(subDir); err == nil {
|
|
watched++
|
|
}
|
|
}
|
|
}
|
|
|
|
ctx, w.cancel = context.WithCancel(ctx)
|
|
w.wg.Add(1)
|
|
go w.loop(ctx)
|
|
|
|
slog.Info("skills watcher started", "dirs", len(dirs), "watched", watched)
|
|
return nil
|
|
}
|
|
|
|
// Stop shuts down the watcher.
|
|
func (w *Watcher) Stop() {
|
|
if w.cancel != nil {
|
|
w.cancel()
|
|
}
|
|
w.wg.Wait()
|
|
w.fsw.Close()
|
|
|
|
w.mu.Lock()
|
|
if w.timer != nil {
|
|
w.timer.Stop()
|
|
}
|
|
w.mu.Unlock()
|
|
}
|
|
|
|
func (w *Watcher) loop(ctx context.Context) {
|
|
defer w.wg.Done()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
|
|
case event, ok := <-w.fsw.Events:
|
|
if !ok {
|
|
return
|
|
}
|
|
w.handleEvent(event)
|
|
|
|
case err, ok := <-w.fsw.Errors:
|
|
if !ok {
|
|
return
|
|
}
|
|
slog.Warn("skills watcher error", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (w *Watcher) handleEvent(event fsnotify.Event) {
|
|
path := event.Name
|
|
|
|
// New directory created inside a skill root → start watching it
|
|
// (e.g. user creates ~/.goclaw/skills/new-skill/)
|
|
if event.Has(fsnotify.Create) {
|
|
if info, err := os.Stat(path); err == nil && info.IsDir() {
|
|
_ = w.fsw.Add(path)
|
|
slog.Debug("skills watcher: watching new dir", "path", path)
|
|
}
|
|
}
|
|
|
|
// Only care about SKILL.md file events
|
|
base := filepath.Base(path)
|
|
if !strings.EqualFold(base, "SKILL.md") && !event.Has(fsnotify.Create) {
|
|
// Also trigger on directory delete (skill folder removed)
|
|
if !event.Has(fsnotify.Remove) && !event.Has(fsnotify.Rename) {
|
|
return
|
|
}
|
|
}
|
|
|
|
w.scheduleBump()
|
|
}
|
|
|
|
// scheduleBump debounces version bumps.
|
|
func (w *Watcher) scheduleBump() {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
|
|
w.pending = true
|
|
|
|
if w.timer != nil {
|
|
w.timer.Stop()
|
|
}
|
|
w.timer = time.AfterFunc(watchDebounce, func() {
|
|
w.flush()
|
|
})
|
|
}
|
|
|
|
func (w *Watcher) flush() {
|
|
w.mu.Lock()
|
|
if !w.pending {
|
|
w.mu.Unlock()
|
|
return
|
|
}
|
|
w.pending = false
|
|
w.mu.Unlock()
|
|
|
|
w.loader.BumpVersion()
|
|
slog.Info("skills changed, version bumped", "version", w.loader.Version())
|
|
}
|