diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index 3562f03ef..557fd32d1 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -2,6 +2,7 @@ package gateway import ( "context" + "crypto/sha256" "fmt" "os" "os/signal" @@ -522,9 +523,7 @@ func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Conf go func() { defer wg.Done() - // Get initial file info - lastModTime := getFileModTime(configPath) - lastSize := getFileSize(configPath) + lastFingerprint := getFileFingerprint(configPath) ticker := time.NewTicker(2 * time.Second) // Check every 2 seconds defer ticker.Stop() @@ -532,11 +531,12 @@ func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Conf for { select { case <-ticker.C: - currentModTime := getFileModTime(configPath) - currentSize := getFileSize(configPath) + currentFingerprint := getFileFingerprint(configPath) - // Check if file changed (modification time or size changed) - if currentModTime.After(lastModTime) || currentSize != lastSize { + // Detect changes by file fingerprint instead of mtime/size alone. + // This avoids missing edits that land within the same filesystem + // timestamp granularity window and keep the same file size. + if currentFingerprint != lastFingerprint { if debug { logger.Debugf("🔍 Config file change detected") } @@ -562,8 +562,7 @@ func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Conf logger.Info("✓ Config file validated and loaded") // Update last known state - lastModTime = currentModTime - lastSize = currentSize + lastFingerprint = currentFingerprint // Send new config to main loop (non-blocking) select { @@ -588,22 +587,34 @@ func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Conf return configChan, stopFunc } -// getFileModTime returns the modification time of a file, or zero time if file doesn't exist -func getFileModTime(path string) time.Time { - info, err := os.Stat(path) - if err != nil { - return time.Time{} - } - return info.ModTime() +type fileFingerprint struct { + ModTime time.Time + Size int64 + Hash [32]byte } -// getFileSize returns the size of a file, or 0 if file doesn't exist -func getFileSize(path string) int64 { +// getFileFingerprint returns a stable fingerprint for config change detection. +// Hashing the content closes the gap where mtime granularity and unchanged size +// would otherwise hide rapid successive edits from the polling watcher. +func getFileFingerprint(path string) fileFingerprint { info, err := os.Stat(path) if err != nil { - return 0 + return fileFingerprint{} + } + + data, err := os.ReadFile(path) + if err != nil { + return fileFingerprint{ + ModTime: info.ModTime(), + Size: info.Size(), + } + } + + return fileFingerprint{ + ModTime: info.ModTime(), + Size: info.Size(), + Hash: sha256.Sum256(data), } - return info.Size() } func setupCronTool( diff --git a/cmd/picoclaw/internal/gateway/helpers_test.go b/cmd/picoclaw/internal/gateway/helpers_test.go new file mode 100644 index 000000000..abc7380c5 --- /dev/null +++ b/cmd/picoclaw/internal/gateway/helpers_test.go @@ -0,0 +1,46 @@ +package gateway + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetFileFingerprintDetectsSameSizeSameTimestampChanges(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + initialContent := []byte(`{"model":"aaaa"}`) + require.NoError(t, os.WriteFile(configPath, initialContent, 0o600)) + + original := getFileFingerprint(configPath) + require.NotZero(t, original.ModTime) + require.NotZero(t, original.Size) + + updatedContent := []byte(`{"model":"bbbb"}`) + require.Len(t, updatedContent, len(initialContent)) + require.NoError(t, os.WriteFile(configPath, updatedContent, 0o600)) + + // Simulate a coarse-grained filesystem timestamp where two writes inside the + // same second end up with the same reported modification time. + require.NoError(t, os.Chtimes(configPath, original.ModTime, original.ModTime)) + + updated := getFileFingerprint(configPath) + + assert.Equal(t, original.ModTime, updated.ModTime) + assert.Equal(t, original.Size, updated.Size) + assert.NotEqual(t, original.Hash, updated.Hash) + assert.NotEqual(t, original, updated) +} + +func TestGetFileFingerprintMissingFile(t *testing.T) { + fp := getFileFingerprint(filepath.Join(t.TempDir(), "missing.json")) + + assert.Equal(t, fileFingerprint{}, fp) + assert.True(t, fp.ModTime.IsZero()) + assert.Equal(t, int64(0), fp.Size) + assert.Equal(t, [32]byte{}, fp.Hash) +}