From 045927f6d1a7b1eceb3a794331bb40366779e7cc Mon Sep 17 00:00:00 2001 From: LPF Date: Wed, 11 Mar 2026 22:14:45 +0800 Subject: [PATCH] Fix config hot reload and release v0.2.1 --- cmd/cmd_gateway.go | 54 +++++++++----- cmd/main.go | 2 +- pkg/api/server.go | 44 +++++++---- pkg/api/server_test.go | 74 +++++++++++++++++++ webui/package.json | 2 +- .../components/config/useConfigSaveAction.ts | 12 ++- webui/src/context/AppContext.tsx | 22 +++++- webui/src/pages/Config.tsx | 9 ++- webui/src/pages/Providers.tsx | 9 ++- 9 files changed, 184 insertions(+), 44 deletions(-) diff --git a/cmd/cmd_gateway.go b/cmd/cmd_gateway.go index 36368dd..6e5d33b 100644 --- a/cmd/cmd_gateway.go +++ b/cmd/cmd_gateway.go @@ -12,6 +12,7 @@ import ( "reflect" "runtime" "strings" + "sync" "time" "github.com/YspCoder/clawgo/pkg/agent" @@ -176,6 +177,7 @@ func gatewayCmd() { registryServer.SetGatewayVersion(version) registryServer.SetWebUIVersion(version) registryServer.SetConfigPath(getConfigPath()) + registryServer.SetToken(cfg.Gateway.Token) registryServer.SetWorkspacePath(cfg.WorkspacePath()) registryServer.SetLogFilePath(cfg.LogFilePath()) registryServer.SetWebUIDir(filepath.Join(cfg.WorkspacePath(), "webui")) @@ -200,12 +202,15 @@ func gatewayCmd() { } return out }) - reloadReqCh := make(chan struct{}, 1) - registryServer.SetConfigAfterHook(func() { - select { - case reloadReqCh <- struct{}{}: - default: + var reloadMu sync.Mutex + var applyReload func() error + registryServer.SetConfigAfterHook(func() error { + reloadMu.Lock() + defer reloadMu.Unlock() + if applyReload == nil { + return fmt.Errorf("reload handler not ready") } + return applyReload() }) registryServer.SetSubagentHandler(func(cctx context.Context, action string, args map[string]interface{}) (interface{}, error) { return agentLoop.HandleSubagentRuntime(cctx, action, args) @@ -373,12 +378,11 @@ func gatewayCmd() { sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, gatewayNotifySignals()...) - applyReload := func() { + applyReload = func() error { fmt.Println("\nReloading config...") newCfg, err := config.LoadConfig(getConfigPath()) if err != nil { - fmt.Printf("Reload failed (load config): %v\n", err) - return + return fmt.Errorf("load config: %w", err) } if strings.EqualFold(strings.TrimSpace(os.Getenv(envRootGranted)), "1") || strings.EqualFold(strings.TrimSpace(os.Getenv(envRootGranted)), "true") { applyMaximumPermissionPolicy(newCfg) @@ -392,7 +396,12 @@ func gatewayCmd() { if reflect.DeepEqual(cfg, newCfg) { fmt.Println("Config unchanged, skip reload") - return + return nil + } + + if cfg.Gateway.Host != newCfg.Gateway.Host || cfg.Gateway.Port != newCfg.Gateway.Port { + fmt.Printf("Warning: gateway host/port change detected (%s:%d -> %s:%d); restart required to rebind listener\n", + cfg.Gateway.Host, cfg.Gateway.Port, newCfg.Gateway.Host, newCfg.Gateway.Port) } if shouldEmbedWhatsAppBridge(newCfg) { @@ -421,15 +430,18 @@ func gatewayCmd() { } cfg = newCfg runtimecfg.Set(cfg) + registryServer.SetToken(cfg.Gateway.Token) + registryServer.SetWorkspacePath(cfg.WorkspacePath()) + registryServer.SetLogFilePath(cfg.LogFilePath()) + registryServer.SetWebUIDir(filepath.Join(cfg.WorkspacePath(), "webui")) configureGatewayNodeP2P(agentLoop, registryServer, cfg) fmt.Println("Config hot-reload applied (logging/metadata only)") - return + return nil } newAgentLoop, newChannelManager, err := buildGatewayRuntime(ctx, newCfg, msgBus, cronService) if err != nil { - fmt.Printf("Reload failed (init runtime): %v\n", err) - return + return fmt.Errorf("init runtime: %w", err) } channelManager.StopAll(ctx) @@ -446,6 +458,11 @@ func gatewayCmd() { whatsAppBridge = newWhatsAppBridge whatsAppEmbedded = newWhatsAppBridge != nil runtimecfg.Set(cfg) + configureLogging(newCfg) + registryServer.SetToken(cfg.Gateway.Token) + registryServer.SetWorkspacePath(cfg.WorkspacePath()) + registryServer.SetLogFilePath(cfg.LogFilePath()) + registryServer.SetWebUIDir(filepath.Join(cfg.WorkspacePath(), "webui")) configureGatewayNodeP2P(agentLoop, registryServer, cfg) registryServer.SetWhatsAppBridge(whatsAppBridge, embeddedWhatsAppBridgeBasePath) sentinelService.Stop() @@ -462,21 +479,24 @@ func gatewayCmd() { sentinelService.SetManager(channelManager) if err := channelManager.StartAll(ctx); err != nil { - fmt.Printf("Reload failed (start channels): %v\n", err) - return + return fmt.Errorf("start channels: %w", err) } go agentLoop.Run(ctx) fmt.Println("Config hot-reload applied") + return nil } for { select { - case <-reloadReqCh: - applyReload() case sig := <-sigChan: switch { case isGatewayReloadSignal(sig): - applyReload() + reloadMu.Lock() + err := applyReload() + reloadMu.Unlock() + if err != nil { + fmt.Printf("Reload failed: %v\n", err) + } default: fmt.Println("\nShutting down...") cancel() diff --git a/cmd/main.go b/cmd/main.go index 08f3ab9..94acc16 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -19,7 +19,7 @@ import ( //go:embed workspace var embeddedFiles embed.FS -var version = "0.2.0" +var version = "0.2.1" var buildTime = "unknown" const logo = ">" diff --git a/pkg/api/server.go b/pkg/api/server.go index 467638a..dfeef99 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -56,7 +56,7 @@ type Server struct { logFilePath string onChat func(ctx context.Context, sessionKey, content string) (string, error) onChatHistory func(sessionKey string) []map[string]interface{} - onConfigAfter func() + onConfigAfter func() error onCron func(action string, args map[string]interface{}) (interface{}, error) onSubagents func(ctx context.Context, action string, args map[string]interface{}) (interface{}, error) onNodeDispatch func(ctx context.Context, req nodes.Request, mode string) (nodes.Response, error) @@ -291,13 +291,14 @@ func (s *Server) publishSubagentLiveSnapshot(ctx context.Context, key, taskID, p func (s *Server) SetConfigPath(path string) { s.configPath = strings.TrimSpace(path) } func (s *Server) SetWorkspacePath(path string) { s.workspacePath = strings.TrimSpace(path) } func (s *Server) SetLogFilePath(path string) { s.logFilePath = strings.TrimSpace(path) } +func (s *Server) SetToken(token string) { s.token = strings.TrimSpace(token) } func (s *Server) SetChatHandler(fn func(ctx context.Context, sessionKey, content string) (string, error)) { s.onChat = fn } func (s *Server) SetChatHistoryHandler(fn func(sessionKey string) []map[string]interface{}) { s.onChatHistory = fn } -func (s *Server) SetConfigAfterHook(fn func()) { s.onConfigAfter = fn } +func (s *Server) SetConfigAfterHook(fn func() error) { s.onConfigAfter = fn } func (s *Server) SetCronHandler(fn func(action string, args map[string]interface{}) (interface{}, error)) { s.onCron = fn } @@ -489,16 +490,14 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("/webui/api/logs/stream", s.handleWebUILogsStream) mux.HandleFunc("/webui/api/logs/live", s.handleWebUILogsLive) mux.HandleFunc("/webui/api/logs/recent", s.handleWebUILogsRecent) - if strings.TrimSpace(s.whatsAppBase) != "" { - base := strings.TrimRight(strings.TrimSpace(s.whatsAppBase), "/") - if base == "" { - base = "/whatsapp" - } - mux.HandleFunc(base, s.handleWhatsAppBridgeWS) - mux.HandleFunc(joinServerRoute(base, "ws"), s.handleWhatsAppBridgeWS) - mux.HandleFunc(joinServerRoute(base, "status"), s.handleWhatsAppBridgeStatus) - mux.HandleFunc(joinServerRoute(base, "logout"), s.handleWhatsAppBridgeLogout) + base := strings.TrimRight(strings.TrimSpace(s.whatsAppBase), "/") + if base == "" { + base = "/whatsapp" } + mux.HandleFunc(base, s.handleWhatsAppBridgeWS) + mux.HandleFunc(joinServerRoute(base, "ws"), s.handleWhatsAppBridgeWS) + mux.HandleFunc(joinServerRoute(base, "status"), s.handleWhatsAppBridgeStatus) + mux.HandleFunc(joinServerRoute(base, "logout"), s.handleWhatsAppBridgeLogout) s.server = &http.Server{Addr: s.addr, Handler: mux} go func() { <-ctx.Done() @@ -866,9 +865,15 @@ func (s *Server) handleWebUIConfig(w http.ResponseWriter, r *http.Request) { return } if s.onConfigAfter != nil { - s.onConfigAfter() + if err := s.onConfigAfter(); err != nil { + http.Error(w, "config saved but reload failed: "+err.Error(), http.StatusInternalServerError) + return + } } else { - _ = requestSelfReloadSignal() + if err := requestSelfReloadSignal(); err != nil { + http.Error(w, "config saved but reload signal failed: "+err.Error(), http.StatusInternalServerError) + return + } } _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "reloaded": true}) default: @@ -1490,9 +1495,13 @@ func (s *Server) saveProviderConfig(cfg *cfgpkg.Config, name string, pc cfgpkg.P return err } if s.onConfigAfter != nil { - s.onConfigAfter() + if err := s.onConfigAfter(); err != nil { + return err + } } else { - _ = requestSelfReloadSignal() + if err := requestSelfReloadSignal(); err != nil { + return err + } } return nil } @@ -5890,7 +5899,10 @@ func (s *Server) handleWebUIExecApprovals(w http.ResponseWriter, r *http.Request return } if s.onConfigAfter != nil { - s.onConfigAfter() + if err := s.onConfigAfter(); err != nil { + http.Error(w, "config saved but reload failed: "+err.Error(), http.StatusInternalServerError) + return + } } _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "reloaded": true}) return diff --git a/pkg/api/server_test.go b/pkg/api/server_test.go index c589f51..9c96cb0 100644 --- a/pkg/api/server_test.go +++ b/pkg/api/server_test.go @@ -340,6 +340,80 @@ func TestHandleWebUIConfigRequiresConfirmForCustomProviderSecretChange(t *testin } } +func TestHandleWebUIConfigRunsReloadHookSynchronously(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfgPath := filepath.Join(tmp, "config.json") + cfg := cfgpkg.DefaultConfig() + cfg.Logging.Enabled = false + if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("save config: %v", err) + } + + body, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + + srv := NewServer("127.0.0.1", 0, "", nil) + srv.SetConfigPath(cfgPath) + called := false + srv.SetConfigAfterHook(func() error { + called = true + return nil + }) + + req := httptest.NewRequest(http.MethodPost, "/webui/api/config", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + srv.handleWebUIConfig(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + if !called { + t.Fatalf("expected reload hook to run") + } +} + +func TestHandleWebUIConfigReturnsReloadHookError(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfgPath := filepath.Join(tmp, "config.json") + cfg := cfgpkg.DefaultConfig() + cfg.Logging.Enabled = false + if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("save config: %v", err) + } + + body, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + + srv := NewServer("127.0.0.1", 0, "", nil) + srv.SetConfigPath(cfgPath) + srv.SetConfigAfterHook(func() error { + return fmt.Errorf("reload boom") + }) + + req := httptest.NewRequest(http.MethodPost, "/webui/api/config", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + srv.handleWebUIConfig(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "reload failed") { + t.Fatalf("expected reload failure in body, got: %s", rec.Body.String()) + } +} + func TestHandleNodeConnectRegistersAndHeartbeatsNode(t *testing.T) { t.Parallel() diff --git a/webui/package.json b/webui/package.json index 610338c..9db77ad 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,7 +1,7 @@ { "name": "clawgo-webui", "private": true, - "version": "0.2.0", + "version": "0.2.1", "type": "module", "scripts": { "dev": "tsx server.ts", diff --git a/webui/src/components/config/useConfigSaveAction.ts b/webui/src/components/config/useConfigSaveAction.ts index 28c76c0..17b135d 100644 --- a/webui/src/components/config/useConfigSaveAction.ts +++ b/webui/src/components/config/useConfigSaveAction.ts @@ -9,9 +9,11 @@ type UI = { type UseConfigSaveActionArgs = { cfg: any; cfgRaw: string; + loadConfig: (force?: boolean, tokenOverride?: string) => Promise; q: string; setBaseline: React.Dispatch>; setConfigEditing: (editing: boolean) => void; + setToken: (token: string) => void; setShowDiff: React.Dispatch>; showRaw: boolean; t: (key: string, options?: any) => string; @@ -21,9 +23,11 @@ type UseConfigSaveActionArgs = { export function useConfigSaveAction({ cfg, cfgRaw, + loadConfig, q, setBaseline, setConfigEditing, + setToken, setShowDiff, showRaw, t, @@ -68,8 +72,14 @@ export function useConfigSaveAction({ throw new Error(result.data?.error || result.text || 'save failed'); } + const hasGatewayToken = typeof payload?.gateway?.token === 'string'; + const nextToken = hasGatewayToken ? payload.gateway.token.trim() : ''; + const reloaded = await loadConfig(true, nextToken || undefined); + if (hasGatewayToken) { + setToken(nextToken); + } await ui.notify({ title: t('saved'), message: t('configSaved') }); - setBaseline(cloneJSON(payload)); + setBaseline(cloneJSON(reloaded ?? payload)); setConfigEditing(false); setShowDiff(false); } catch (error) { diff --git a/webui/src/context/AppContext.tsx b/webui/src/context/AppContext.tsx index a0df8a9..e50fcac 100644 --- a/webui/src/context/AppContext.tsx +++ b/webui/src/context/AppContext.tsx @@ -87,7 +87,7 @@ interface AppContextType { refreshTaskQueue: () => Promise; refreshEKGSummary: () => Promise; refreshVersion: () => Promise; - loadConfig: (force?: boolean) => Promise; + loadConfig: (force?: boolean, tokenOverride?: string) => Promise; gatewayVersion: string; webuiVersion: string; compiledChannels: string[]; @@ -145,15 +145,20 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children const q = token ? `?token=${encodeURIComponent(token)}` : ''; - const loadConfig = useCallback(async (force = false) => { + const loadConfig = useCallback(async (force = false, tokenOverride?: string) => { + let loadedConfig: any = null; try { - const hotQ = q ? `${q}&include_hot_reload_fields=1` : '?include_hot_reload_fields=1'; + const authQ = tokenOverride + ? `?token=${encodeURIComponent(tokenOverride)}` + : q; + const hotQ = authQ ? `${authQ}&include_hot_reload_fields=1` : '?include_hot_reload_fields=1'; const r = await fetch(`/webui/api/config${hotQ}`); if (!r.ok) throw new Error('Failed to load config'); const txt = await r.text(); try { const parsed = JSON.parse(txt); if (parsed && parsed.config) { + loadedConfig = parsed.config; if (!configEditing || force) { setCfg(parsed.config); setCfgRaw(JSON.stringify(parsed.config, null, 2)); @@ -161,15 +166,23 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children setHotReloadFields(Array.isArray(parsed.hot_reload_fields) ? parsed.hot_reload_fields : []); setHotReloadFieldDetails(Array.isArray(parsed.hot_reload_field_details) ? parsed.hot_reload_field_details : []); } else { + loadedConfig = parsed || {}; if (!configEditing || force) { setCfg(parsed || {}); setCfgRaw(txt); } } } catch { + loadedConfig = null; if (!configEditing || force) { setCfgRaw(txt); - try { setCfg(JSON.parse(txt)); } catch { setCfg({}); } + try { + loadedConfig = JSON.parse(txt); + setCfg(loadedConfig); + } catch { + loadedConfig = {}; + setCfg({}); + } } } setIsGatewayOnline(true); @@ -177,6 +190,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children setIsGatewayOnline(false); console.error(e); } + return loadedConfig; }, [q, configEditing]); const refreshNodes = useCallback(async () => { diff --git a/webui/src/pages/Config.tsx b/webui/src/pages/Config.tsx index 171eefb..59cd328 100644 --- a/webui/src/pages/Config.tsx +++ b/webui/src/pages/Config.tsx @@ -15,7 +15,7 @@ import { cloneJSON } from '../utils/object'; const Config: React.FC = () => { const { t } = useTranslation(); const ui = useUI(); - const { cfg, setCfg, cfgRaw, setCfgRaw, loadConfig, hotReloadFieldDetails, q, setConfigEditing } = useAppContext(); + const { cfg, setCfg, cfgRaw, setCfgRaw, loadConfig, hotReloadFieldDetails, q, setConfigEditing, setToken } = useAppContext(); const [showRaw, setShowRaw] = useState(false); const [basicMode, setBasicMode] = useState(true); const [hotOnly, setHotOnly] = useState(false); @@ -43,9 +43,11 @@ const Config: React.FC = () => { const { saveConfig } = useConfigSaveAction({ cfg, cfgRaw, + loadConfig, q, setBaseline, setConfigEditing, + setToken, setShowDiff, showRaw, t, @@ -87,7 +89,10 @@ const Config: React.FC = () => { basicMode={basicMode} hotOnly={hotOnly} onHotOnlyChange={setHotOnly} - onReload={async () => { await loadConfig(true); setTimeout(() => setBaseline(cloneJSON(cfg)), 0); }} + onReload={async () => { + const reloaded = await loadConfig(true); + setBaseline(cloneJSON(reloaded ?? cfg)); + }} onSearchChange={setSearch} onShowDiff={() => setShowDiff(true)} onToggleBasicMode={() => setBasicMode((value) => !value)} diff --git a/webui/src/pages/Providers.tsx b/webui/src/pages/Providers.tsx index 8aefe90..6a032e0 100644 --- a/webui/src/pages/Providers.tsx +++ b/webui/src/pages/Providers.tsx @@ -16,7 +16,7 @@ import { cloneJSON } from '../utils/object'; const Providers: React.FC = () => { const { t } = useTranslation(); const ui = useUI(); - const { cfg, setCfg, cfgRaw, loadConfig, q, setConfigEditing, providerRuntimeItems } = useAppContext(); + const { cfg, setCfg, cfgRaw, loadConfig, q, setConfigEditing, providerRuntimeItems, setToken } = useAppContext(); const [newProxyName, setNewProxyName] = useState(''); const [runtimeAutoRefresh, setRuntimeAutoRefresh] = useState(true); const [runtimeRefreshSec, setRuntimeRefreshSec] = useState(10); @@ -134,9 +134,11 @@ const Providers: React.FC = () => { const { saveConfig } = useConfigSaveAction({ cfg, cfgRaw, + loadConfig, q, setBaseline, setConfigEditing, + setToken, setShowDiff, showRaw: false, t, @@ -152,7 +154,10 @@ const Providers: React.FC = () => { titleClassName="ui-text-primary" actions={
- { await loadConfig(true); setTimeout(() => setBaseline(cloneJSON(cfg)), 0); }} label={t('reload')}> + { + const reloaded = await loadConfig(true); + setBaseline(cloneJSON(reloaded ?? cfg)); + }} label={t('reload')}>