diff --git a/cmd/root.go b/cmd/root.go index 33383366eacb..a866f5bbfa93 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -125,6 +125,7 @@ func NewCommand(opts *internal.ToolboxOptions) *cobra.Command { // TODO: Insecure by default. Might consider updating this for v1.0.0 flags.StringSliceVar(&opts.Cfg.AllowedOrigins, "allowed-origins", []string{"*"}, "Specifies a list of origins permitted to access this server. Defaults to '*'.") flags.StringSliceVar(&opts.Cfg.AllowedHosts, "allowed-hosts", []string{"*"}, "Specifies a list of hosts permitted to access this server. Defaults to '*'.") + flags.IntVar(&opts.Cfg.PollInterval, "poll-interval", 0, "Specifies the polling frequency (seconds) for configuration file updates.") // wrap RunE command so that we have access to original Command object cmd.RunE = func(*cobra.Command, []string) error { return run(cmd, opts) } @@ -195,8 +196,50 @@ func validateReloadEdits( return sourcesMap, authServicesMap, embeddingModelsMap, toolsMap, toolsetsMap, promptsMap, promptsetsMap, nil } +// Helper to check if a file has a newer ModTime than stored in the map +func checkModTime(path string, mTime time.Time, lastSeen map[string]time.Time) bool { + if mTime.After(lastSeen[path]) { + lastSeen[path] = mTime + return true + } + return false +} + +// Helper to scan watched files and check their modification times in polling system +func scanWatchedFiles(watchingFolder bool, folderToWatch string, watchedFiles map[string]bool, lastSeen map[string]time.Time) (map[string]bool, bool, error) { + changed := false + currentDiskFiles := make(map[string]bool) + if watchingFolder { + files, err := os.ReadDir(folderToWatch) + if err != nil { + return nil, changed, fmt.Errorf("error reading tools folder %w", err) + } + for _, f := range files { + if !f.IsDir() && (strings.HasSuffix(f.Name(), ".yaml") || strings.HasSuffix(f.Name(), ".yml")) { + fullPath := filepath.Join(folderToWatch, f.Name()) + currentDiskFiles[fullPath] = true + if info, err := f.Info(); err == nil { + if checkModTime(fullPath, info.ModTime(), lastSeen) { + changed = true + } + } + } + } + } else { + for f := range watchedFiles { + if info, err := os.Stat(f); err == nil { + currentDiskFiles[f] = true + if checkModTime(f, info.ModTime(), lastSeen) { + changed = true + } + } + } + } + return currentDiskFiles, changed, nil +} + // watchChanges checks for changes in the provided yaml tools file(s) or folder. -func watchChanges(ctx context.Context, watchDirs map[string]bool, watchedFiles map[string]bool, s *server.Server) { +func watchChanges(ctx context.Context, watchDirs map[string]bool, watchedFiles map[string]bool, s *server.Server, pollTickerSecond int) { logger, err := util.LoggerFromContext(ctx) if err != nil { panic(err) @@ -204,7 +247,7 @@ func watchChanges(ctx context.Context, watchDirs map[string]bool, watchedFiles m w, err := fsnotify.NewWatcher() if err != nil { - logger.WarnContext(ctx, "error setting up new watcher %s", err) + logger.WarnContext(ctx, fmt.Sprintf("error setting up new watcher %s", err)) return } @@ -238,6 +281,23 @@ func watchChanges(ctx context.Context, watchDirs map[string]bool, watchedFiles m logger.DebugContext(ctx, fmt.Sprintf("Added directory %s to watcher.", dir)) } + lastSeen := make(map[string]time.Time) + var pollTickerChan <-chan time.Time + if pollTickerSecond > 0 { + ticker := time.NewTicker(time.Duration(pollTickerSecond) * time.Second) + defer ticker.Stop() + pollTickerChan = ticker.C // Assign the channel + logger.DebugContext(ctx, fmt.Sprintf("NFS polling enabled every %v", pollTickerSecond)) + + // Pre-populate lastSeen to avoid an initial spurious reload + _, _, err = scanWatchedFiles(watchingFolder, folderToWatch, watchedFiles, lastSeen) + if err != nil { + logger.WarnContext(ctx, err.Error()) + } + } else { + logger.DebugContext(ctx, "NFS polling disabled (interval is 0)") + } + // debounce timer is used to prevent multiple writes triggering multiple reloads debounceDelay := 100 * time.Millisecond debounce := time.NewTimer(1 * time.Minute) @@ -248,13 +308,36 @@ func watchChanges(ctx context.Context, watchDirs map[string]bool, watchedFiles m case <-ctx.Done(): logger.DebugContext(ctx, "file watcher context cancelled") return + case <-pollTickerChan: + // Get files that are currently on disk + currentDiskFiles, changed, err := scanWatchedFiles(watchingFolder, folderToWatch, watchedFiles, lastSeen) + if err != nil { + logger.WarnContext(ctx, err.Error()) + continue + } + + // Check for Deletions + // If it was in lastSeen but is NOT in currentDiskFiles, it's + // deleted; we will need to reload the server. + for path := range lastSeen { + if !currentDiskFiles[path] { + logger.DebugContext(ctx, fmt.Sprintf("File deleted (detected via polling): %s", path)) + delete(lastSeen, path) + changed = true + } + } + if changed { + logger.DebugContext(ctx, "File change detected via polling") + // once this timer runs out, it will trigger debounce.C + debounce.Reset(debounceDelay) + } case err, ok := <-w.Errors: if !ok { logger.WarnContext(ctx, "file watcher was closed unexpectedly") return } if err != nil { - logger.WarnContext(ctx, "file watcher error %s", err) + logger.WarnContext(ctx, fmt.Sprintf("file watcher error %s", err)) return } @@ -289,14 +372,14 @@ func watchChanges(ctx context.Context, watchDirs map[string]bool, watchedFiles m logger.DebugContext(ctx, "Reloading tools folder.") reloadedToolsFile, err = internal.LoadAndMergeToolsFolder(ctx, folderToWatch) if err != nil { - logger.WarnContext(ctx, "error loading tools folder %s", err) + logger.WarnContext(ctx, fmt.Sprintf("error loading tools folder %s", err)) continue } } else { logger.DebugContext(ctx, "Reloading tools file(s).") reloadedToolsFile, err = internal.LoadAndMergeToolsFiles(ctx, slices.Collect(maps.Keys(watchedFiles))) if err != nil { - logger.WarnContext(ctx, "error loading tools files %s", err) + logger.WarnContext(ctx, fmt.Sprintf("error loading tools files %s", err)) continue } } @@ -417,7 +500,7 @@ func run(cmd *cobra.Command, opts *internal.ToolboxOptions) error { if isCustomConfigured && !opts.Cfg.DisableReload { watchDirs, watchedFiles := resolveWatcherInputs(opts.ToolsFile, opts.ToolsFiles, opts.ToolsFolder) // start watching the file(s) or folder for changes to trigger dynamic reloading - go watchChanges(ctx, watchDirs, watchedFiles, s) + go watchChanges(ctx, watchDirs, watchedFiles, s, opts.Cfg.PollInterval) } // wait for either the server to error out or the command's context to be canceled diff --git a/cmd/root_test.go b/cmd/root_test.go index e85aaa3d261d..288274a1c543 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -590,7 +590,7 @@ func TestSingleEdit(t *testing.T) { watchedFiles := map[string]bool{cleanFileToWatch: true} watchDirs := map[string]bool{watchDir: true} - go watchChanges(ctx, watchDirs, watchedFiles, mockServer) + go watchChanges(ctx, watchDirs, watchedFiles, mockServer, 0) // escape backslash so regex doesn't fail on windows filepaths regexEscapedPathFile := strings.ReplaceAll(cleanFileToWatch, `\`, `\\\\*\\`) diff --git a/docs/en/reference/cli.md b/docs/en/reference/cli.md index 11549c283015..ca3acc43cb50 100644 --- a/docs/en/reference/cli.md +++ b/docs/en/reference/cli.md @@ -16,7 +16,7 @@ description: > | | `--log-level` | Specify the minimum level logged. Allowed: 'DEBUG', 'INFO', 'WARN', 'ERROR'. | `info` | | | `--logging-format` | Specify logging format to use. Allowed: 'standard' or 'JSON'. | `standard` | | `-p` | `--port` | Port the server will listen on. | `5000` | -| | `--prebuilt` | Use one or more prebuilt tool configuration by source type. See [Prebuilt Tools Reference](prebuilt-tools.md) for allowed values. | | +| | `--prebuilt` | Use one or more prebuilt tool configuration by source type. See [Prebuilt Tools Reference](prebuilt-tools.md) for allowed values. | | | | `--stdio` | Listens via MCP STDIO instead of acting as a remote HTTP server. | | | | `--telemetry-gcp` | Enable exporting directly to Google Cloud Monitoring. | | | | `--telemetry-otlp` | Enable exporting using OpenTelemetry Protocol (OTLP) to the specified endpoint (e.g. 'http://127.0.0.1:4318') | | @@ -28,6 +28,7 @@ description: > | | `--allowed-origins` | Specifies a list of origins permitted to access this server for CORs access. | `*` | | | `--allowed-hosts` | Specifies a list of hosts permitted to access this server to prevent DNS rebinding attacks. | `*` | | | `--user-agent-metadata` | Appends additional metadata to the User-Agent. | | +| | `--poll-interval` | Specifies the polling frequency (seconds) for configuration file updates. | `0` | | `-v` | `--version` | version for toolbox | | ## Sub Commands @@ -133,8 +134,18 @@ used at a time. ### Hot Reload -Toolbox enables dynamic reloading by default. To disable, use the -`--disable-reload` flag. +Toolbox supports two methods for detecting configuration changes: **Push** +(event-driven) and **Poll** (interval-based). To completely disable all hot +reloading, use the `--disable-reload` flag. + +* **Push (Default):** Toolbox uses a highly efficient push system that listens + for instant OS-level file events to reload configurations the moment you save. +* **Poll (Fallback):** Alternatively, you can use the + `--poll-interval=` flag to actively check for updates at a set + cadence. Unlike the push system, polling "pulls" the file status manually, + which is a great fallback for network drives or container volumes where OS + events might get dropped. Set the interval to `0` to disable the polling + system. ### Toolbox UI diff --git a/internal/server/config.go b/internal/server/config.go index 48f623b0eab5..25dfb04f402b 100644 --- a/internal/server/config.go +++ b/internal/server/config.go @@ -75,6 +75,8 @@ type ServerConfig struct { AllowedHosts []string // UserAgentMetadata specifies additional metadata to append to the User-Agent string. UserAgentMetadata []string + // PollInterval sets the polling frequency for configuration file updates. + PollInterval int } type logFormat string