@@ -22,6 +22,7 @@ import (
2222 "syscall"
2323 "time"
2424
25+ "github.com/fsnotify/fsnotify"
2526 "github.com/sirupsen/logrus"
2627 "github.com/spf13/cobra"
2728 "github.com/spf13/viper"
@@ -282,9 +283,9 @@ var devCmd = &cobra.Command{
282283 Short : "Start a development server" ,
283284 Long : `Start a development server for testing and debugging agents.
284285
285- Includes an interactive debugging interface and an agent playground. Hot-reload
286- is not implemented: --hot-reload only reports that, so restart the server to
287- pick up changes.` ,
286+ Includes an interactive debugging interface and an agent playground. With
287+ --hot-reload and --agent-config, the server atomically replaces configured
288+ agents whenever that configuration file changes.` ,
288289 RunE : func (cmd * cobra.Command , args []string ) error {
289290 ctx , stop := signal .NotifyContext (cmd .Context (), syscall .SIGINT , syscall .SIGTERM )
290291 defer stop ()
@@ -427,7 +428,7 @@ func init() {
427428 devCmd .Flags ().StringP ("host" , "H" , "localhost" , "Host to bind to" )
428429 devCmd .Flags ().IntP ("port" , "p" , 8080 , "Port to bind to" )
429430 devCmd .Flags ().String ("agent-config" , "" , "Agent configuration file" )
430- devCmd .Flags ().Bool ("hot-reload" , true , "Enable hot-reload (not implemented) " )
431+ devCmd .Flags ().Bool ("hot-reload" , false , "Reload --agent-config when its file changes " )
431432 devCmd .Flags ().Bool ("debug" , true , "Enable the server's development mode (debug interface and playground)" )
432433 devCmd .Flags ().String ("log-level" , "info" , "Log level (debug, info, warn, error)" )
433434
@@ -530,8 +531,8 @@ type serverOptions struct {
530531 // AgentConfig is an optional agent configuration file whose agents are
531532 // created on the server before it starts serving.
532533 AgentConfig string
533- // HotReload is the dev command's --hot-reload flag. File watching is not
534- // implemented; the flag is reported honestly rather than acted on .
534+ // HotReload watches --agent-config and atomically replaces its agents when
535+ // its file changes .
535536 HotReload bool
536537 // LogLevel is applied to the server's logger.
537538 LogLevel string
@@ -588,29 +589,35 @@ func runServer(ctx context.Context, out io.Writer, opts serverOptions) error {
588589
589590 // --agent-config used to be declared and never read. Load it for real.
590591 if opts .AgentConfig != "" {
591- configs , err := loadAgentConfigs (opts .AgentConfig )
592- if err != nil {
593- return fmt .Errorf ("agent config %s: %w" , opts .AgentConfig , err )
594- }
595- for _ , cfg := range configs {
596- if _ , err := agentManager .CreateAgent (cfg .toAgentConfig ()); err != nil {
597- return fmt .Errorf ("failed to create agent %s: %w" , cfg .Name , err )
598- }
599- _ , _ = fmt .Fprintf (out , "Loaded agent %s (%s)\n " , cfg .Name , cfg .Type )
592+ if err := replaceAgentsFromConfig (out , agentManager , opts .AgentConfig ); err != nil {
593+ return err
600594 }
601595 }
602596
603- if opts .HotReload {
604- // The previous implementation printed "Hot-reload enabled - watching
605- // for changes..." next to a comment saying the watcher "would go here".
606- _ , _ = fmt .Fprintln (out , "Note: hot-reload is not implemented; restart the server to pick up changes" )
607- }
608-
609597 // Fail before announcing success if the address cannot be bound.
610598 if err := checkAddressAvailable (opts .Host , opts .Port ); err != nil {
611599 return err
612600 }
613601
602+ watchCtx , stopWatching := context .WithCancel (ctx )
603+ var waitForWatcher func ()
604+ defer func () {
605+ stopWatching ()
606+ if waitForWatcher != nil {
607+ waitForWatcher ()
608+ }
609+ }()
610+ if opts .HotReload {
611+ if opts .AgentConfig == "" {
612+ return fmt .Errorf ("--hot-reload requires --agent-config" )
613+ }
614+ wait , err := watchAgentConfig (watchCtx , out , agentManager , opts .AgentConfig )
615+ if err != nil {
616+ return err
617+ }
618+ waitForWatcher = wait
619+ }
620+
614621 errCh := make (chan error , 1 )
615622 go func () { errCh <- srv .Start () }()
616623
@@ -643,6 +650,99 @@ func runServer(ctx context.Context, out io.Writer, opts serverOptions) error {
643650 return nil
644651}
645652
653+ // replaceAgentsFromConfig parses a complete replacement set before atomically
654+ // swapping it into the manager. It is shared by the initial load and reload
655+ // path so both reject the same malformed configuration.
656+ func replaceAgentsFromConfig (out io.Writer , manager * server.AgentManager , configPath string ) error {
657+ configs , err := loadAgentConfigs (configPath )
658+ if err != nil {
659+ return fmt .Errorf ("agent config %s: %w" , configPath , err )
660+ }
661+
662+ agentConfigs := make ([]* agent.AgentConfig , 0 , len (configs ))
663+ for _ , cfg := range configs {
664+ agentConfigs = append (agentConfigs , cfg .toAgentConfig ())
665+ }
666+ if err := manager .ReplaceAgents (agentConfigs ); err != nil {
667+ return fmt .Errorf ("agent config %s: %w" , configPath , err )
668+ }
669+ for _ , cfg := range configs {
670+ _ , _ = fmt .Fprintf (out , "Loaded agent %s (%s)\n " , cfg .Name , cfg .Type )
671+ }
672+ return nil
673+ }
674+
675+ // watchAgentConfig watches both the configuration file's directory and its
676+ // filename. Watching the directory is important because editors commonly save
677+ // by replacing a temporary file, which invalidates a file-only watch.
678+ func watchAgentConfig (ctx context.Context , out io.Writer , manager * server.AgentManager , configPath string ) (func (), error ) {
679+ absPath , err := filepath .Abs (configPath )
680+ if err != nil {
681+ return nil , fmt .Errorf ("resolve agent config for hot-reload: %w" , err )
682+ }
683+ absPath = filepath .Clean (absPath )
684+
685+ watcher , err := fsnotify .NewWatcher ()
686+ if err != nil {
687+ return nil , fmt .Errorf ("create hot-reload watcher: %w" , err )
688+ }
689+ if err := watcher .Add (filepath .Dir (absPath )); err != nil {
690+ _ = watcher .Close ()
691+ return nil , fmt .Errorf ("watch agent config directory: %w" , err )
692+ }
693+
694+ _ , _ = fmt .Fprintf (out , "Watching agent config for changes: %s\n " , absPath )
695+ done := make (chan struct {})
696+ go func () {
697+ defer close (done )
698+ defer func () { _ = watcher .Close () }()
699+ var debounce <- chan time.Time
700+ var timer * time.Timer
701+ for {
702+ select {
703+ case <- ctx .Done ():
704+ if timer != nil {
705+ timer .Stop ()
706+ }
707+ return
708+ case event , ok := <- watcher .Events :
709+ if ! ok {
710+ return
711+ }
712+ if filepath .Clean (event .Name ) != absPath || event .Op & (fsnotify .Write | fsnotify .Create | fsnotify .Rename ) == 0 {
713+ continue
714+ }
715+ if timer == nil {
716+ timer = time .NewTimer (150 * time .Millisecond )
717+ debounce = timer .C
718+ continue
719+ }
720+ if ! timer .Stop () {
721+ select {
722+ case <- timer .C :
723+ default :
724+ }
725+ }
726+ timer .Reset (150 * time .Millisecond )
727+ case <- debounce :
728+ timer = nil
729+ debounce = nil
730+ if err := replaceAgentsFromConfig (out , manager , absPath ); err != nil {
731+ _ , _ = fmt .Fprintf (out , "Hot-reload failed; keeping existing agents: %v\n " , err )
732+ continue
733+ }
734+ _ , _ = fmt .Fprintln (out , "Reloaded agent configuration" )
735+ case err , ok := <- watcher .Errors :
736+ if ! ok {
737+ return
738+ }
739+ _ , _ = fmt .Fprintf (out , "Hot-reload watcher error: %v\n " , err )
740+ }
741+ }
742+ }()
743+ return func () { <- done }, nil
744+ }
745+
646746// checkAddressAvailable reports whether the server can bind host:port. Without
647747// this the CLI announced a listening server whose bind had already failed.
648748func checkAddressAvailable (host string , port int ) error {
0 commit comments