@@ -125,6 +125,7 @@ func NewCommand(opts *internal.ToolboxOptions) *cobra.Command {
125125 // TODO: Insecure by default. Might consider updating this for v1.0.0
126126 flags .StringSliceVar (& opts .Cfg .AllowedOrigins , "allowed-origins" , []string {"*" }, "Specifies a list of origins permitted to access this server. Defaults to '*'." )
127127 flags .StringSliceVar (& opts .Cfg .AllowedHosts , "allowed-hosts" , []string {"*" }, "Specifies a list of hosts permitted to access this server. Defaults to '*'." )
128+ flags .IntVar (& opts .Cfg .PollInterval , "poll-interval" , 0 , "Specifies the polling frequency (seconds) for configuration file updates." )
128129
129130 // wrap RunE command so that we have access to original Command object
130131 cmd .RunE = func (* cobra.Command , []string ) error { return run (cmd , opts ) }
@@ -195,16 +196,58 @@ func validateReloadEdits(
195196 return sourcesMap , authServicesMap , embeddingModelsMap , toolsMap , toolsetsMap , promptsMap , promptsetsMap , nil
196197}
197198
199+ // Helper to check if a file has a newer ModTime than stored in the map
200+ func checkModTime (path string , mTime time.Time , lastSeen map [string ]time.Time ) bool {
201+ if mTime .After (lastSeen [path ]) {
202+ lastSeen [path ] = mTime
203+ return true
204+ }
205+ return false
206+ }
207+
208+ // Helper to scan watched files and check their modification times in polling system
209+ func scanWatchedFiles (watchingFolder bool , folderToWatch string , watchedFiles map [string ]bool , lastSeen map [string ]time.Time ) (map [string ]bool , bool , error ) {
210+ changed := false
211+ currentDiskFiles := make (map [string ]bool )
212+ if watchingFolder {
213+ files , err := os .ReadDir (folderToWatch )
214+ if err != nil {
215+ return nil , changed , fmt .Errorf ("error reading tools folder %w" , err )
216+ }
217+ for _ , f := range files {
218+ if ! f .IsDir () && (strings .HasSuffix (f .Name (), ".yaml" ) || strings .HasSuffix (f .Name (), ".yml" )) {
219+ fullPath := filepath .Join (folderToWatch , f .Name ())
220+ currentDiskFiles [fullPath ] = true
221+ if info , err := f .Info (); err == nil {
222+ if checkModTime (fullPath , info .ModTime (), lastSeen ) {
223+ changed = true
224+ }
225+ }
226+ }
227+ }
228+ } else {
229+ for f := range watchedFiles {
230+ if info , err := os .Stat (f ); err == nil {
231+ currentDiskFiles [f ] = true
232+ if checkModTime (f , info .ModTime (), lastSeen ) {
233+ changed = true
234+ }
235+ }
236+ }
237+ }
238+ return currentDiskFiles , changed , nil
239+ }
240+
198241// watchChanges checks for changes in the provided yaml tools file(s) or folder.
199- func watchChanges (ctx context.Context , watchDirs map [string ]bool , watchedFiles map [string ]bool , s * server.Server ) {
242+ func watchChanges (ctx context.Context , watchDirs map [string ]bool , watchedFiles map [string ]bool , s * server.Server , pollTickerSecond int ) {
200243 logger , err := util .LoggerFromContext (ctx )
201244 if err != nil {
202245 panic (err )
203246 }
204247
205248 w , err := fsnotify .NewWatcher ()
206249 if err != nil {
207- logger .WarnContext (ctx , "error setting up new watcher %s" , err )
250+ logger .WarnContext (ctx , fmt . Sprintf ( "error setting up new watcher %s" , err ) )
208251 return
209252 }
210253
@@ -238,6 +281,23 @@ func watchChanges(ctx context.Context, watchDirs map[string]bool, watchedFiles m
238281 logger .DebugContext (ctx , fmt .Sprintf ("Added directory %s to watcher." , dir ))
239282 }
240283
284+ lastSeen := make (map [string ]time.Time )
285+ var pollTickerChan <- chan time.Time
286+ if pollTickerSecond > 0 {
287+ ticker := time .NewTicker (time .Duration (pollTickerSecond ) * time .Second )
288+ defer ticker .Stop ()
289+ pollTickerChan = ticker .C // Assign the channel
290+ logger .DebugContext (ctx , fmt .Sprintf ("NFS polling enabled every %v" , pollTickerSecond ))
291+
292+ // Pre-populate lastSeen to avoid an initial spurious reload
293+ _ , _ , err = scanWatchedFiles (watchingFolder , folderToWatch , watchedFiles , lastSeen )
294+ if err != nil {
295+ logger .WarnContext (ctx , err .Error ())
296+ }
297+ } else {
298+ logger .DebugContext (ctx , "NFS polling disabled (interval is 0)" )
299+ }
300+
241301 // debounce timer is used to prevent multiple writes triggering multiple reloads
242302 debounceDelay := 100 * time .Millisecond
243303 debounce := time .NewTimer (1 * time .Minute )
@@ -248,13 +308,36 @@ func watchChanges(ctx context.Context, watchDirs map[string]bool, watchedFiles m
248308 case <- ctx .Done ():
249309 logger .DebugContext (ctx , "file watcher context cancelled" )
250310 return
311+ case <- pollTickerChan :
312+ // Get files that are currently on disk
313+ currentDiskFiles , changed , err := scanWatchedFiles (watchingFolder , folderToWatch , watchedFiles , lastSeen )
314+ if err != nil {
315+ logger .WarnContext (ctx , err .Error ())
316+ continue
317+ }
318+
319+ // Check for Deletions
320+ // If it was in lastSeen but is NOT in currentDiskFiles, it's
321+ // deleted; we will need to reload the server.
322+ for path := range lastSeen {
323+ if ! currentDiskFiles [path ] {
324+ logger .DebugContext (ctx , fmt .Sprintf ("File deleted (detected via polling): %s" , path ))
325+ delete (lastSeen , path )
326+ changed = true
327+ }
328+ }
329+ if changed {
330+ logger .DebugContext (ctx , "File change detected via polling" )
331+ // once this timer runs out, it will trigger debounce.C
332+ debounce .Reset (debounceDelay )
333+ }
251334 case err , ok := <- w .Errors :
252335 if ! ok {
253336 logger .WarnContext (ctx , "file watcher was closed unexpectedly" )
254337 return
255338 }
256339 if err != nil {
257- logger .WarnContext (ctx , "file watcher error %s" , err )
340+ logger .WarnContext (ctx , fmt . Sprintf ( "file watcher error %s" , err ) )
258341 return
259342 }
260343
@@ -289,14 +372,14 @@ func watchChanges(ctx context.Context, watchDirs map[string]bool, watchedFiles m
289372 logger .DebugContext (ctx , "Reloading tools folder." )
290373 reloadedToolsFile , err = internal .LoadAndMergeToolsFolder (ctx , folderToWatch )
291374 if err != nil {
292- logger .WarnContext (ctx , "error loading tools folder %s" , err )
375+ logger .WarnContext (ctx , fmt . Sprintf ( "error loading tools folder %s" , err ) )
293376 continue
294377 }
295378 } else {
296379 logger .DebugContext (ctx , "Reloading tools file(s)." )
297380 reloadedToolsFile , err = internal .LoadAndMergeToolsFiles (ctx , slices .Collect (maps .Keys (watchedFiles )))
298381 if err != nil {
299- logger .WarnContext (ctx , "error loading tools files %s" , err )
382+ logger .WarnContext (ctx , fmt . Sprintf ( "error loading tools files %s" , err ) )
300383 continue
301384 }
302385 }
@@ -417,7 +500,7 @@ func run(cmd *cobra.Command, opts *internal.ToolboxOptions) error {
417500 if isCustomConfigured && ! opts .Cfg .DisableReload {
418501 watchDirs , watchedFiles := resolveWatcherInputs (opts .ToolsFile , opts .ToolsFiles , opts .ToolsFolder )
419502 // start watching the file(s) or folder for changes to trigger dynamic reloading
420- go watchChanges (ctx , watchDirs , watchedFiles , s )
503+ go watchChanges (ctx , watchDirs , watchedFiles , s , opts . Cfg . PollInterval )
421504 }
422505
423506 // wait for either the server to error out or the command's context to be canceled
0 commit comments