feat: track channel and web search restart requirements
This commit is contained in:
parent
afc600baed
commit
02d9a0d190
2 changed files with 303 additions and 0 deletions
|
|
@ -2,6 +2,7 @@ package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
@ -10,7 +11,9 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"reflect"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
@ -431,6 +434,10 @@ func computeConfigSignature(cfg *config.Config) string {
|
||||||
}
|
}
|
||||||
if cfg.Tools.Web.Enabled {
|
if cfg.Tools.Web.Enabled {
|
||||||
toolSignatures = append(toolSignatures, "web")
|
toolSignatures = append(toolSignatures, "web")
|
||||||
|
webConfig, err := json.Marshal(canonicalizeSignatureValue(reflect.ValueOf(cfg.Tools.Web)))
|
||||||
|
if err == nil {
|
||||||
|
parts = append(parts, "webcfg:"+string(webConfig))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if cfg.Tools.WebFetch.Enabled {
|
if cfg.Tools.WebFetch.Enabled {
|
||||||
toolSignatures = append(toolSignatures, "web_fetch")
|
toolSignatures = append(toolSignatures, "web_fetch")
|
||||||
|
|
@ -474,9 +481,175 @@ func computeConfigSignature(cfg *config.Config) string {
|
||||||
if len(toolSignatures) > 0 {
|
if len(toolSignatures) > 0 {
|
||||||
parts = append(parts, "tools:"+strings.Join(toolSignatures, ","))
|
parts = append(parts, "tools:"+strings.Join(toolSignatures, ","))
|
||||||
}
|
}
|
||||||
|
channelSignatures := computeChannelSignatures(cfg.Channels)
|
||||||
|
if len(channelSignatures) > 0 {
|
||||||
|
parts = append(parts, "channels:"+strings.Join(channelSignatures, ","))
|
||||||
|
}
|
||||||
return strings.Join(parts, ";")
|
return strings.Join(parts, ";")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func computeChannelSignatures(channels config.ChannelsConfig) []string {
|
||||||
|
if len(channels) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := make([]string, 0, len(channels))
|
||||||
|
for name := range channels {
|
||||||
|
keys = append(keys, name)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
signatures := make([]string, 0, len(keys))
|
||||||
|
for _, name := range keys {
|
||||||
|
channel := channels[name]
|
||||||
|
if channel == nil {
|
||||||
|
signatures = append(signatures, name+":<nil>")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
AllowFrom config.FlexibleStringSlice `json:"allow_from,omitempty"`
|
||||||
|
ReasoningChannelID string `json:"reasoning_channel_id,omitempty"`
|
||||||
|
GroupTrigger config.GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
|
Typing config.TypingConfig `json:"typing,omitempty"`
|
||||||
|
Placeholder config.PlaceholderConfig `json:"placeholder,omitempty"`
|
||||||
|
Settings json.RawMessage `json:"settings,omitempty"`
|
||||||
|
}{
|
||||||
|
Enabled: channel.Enabled,
|
||||||
|
Type: channel.Type,
|
||||||
|
AllowFrom: channel.AllowFrom,
|
||||||
|
ReasoningChannelID: channel.ReasoningChannelID,
|
||||||
|
GroupTrigger: channel.GroupTrigger,
|
||||||
|
Typing: channel.Typing,
|
||||||
|
Placeholder: channel.Placeholder,
|
||||||
|
Settings: normalizeChannelSettings(channel),
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
signatures = append(signatures, name+":<invalid>")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
signatures = append(signatures, name+":"+string(encoded))
|
||||||
|
}
|
||||||
|
|
||||||
|
return signatures
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeChannelSettings(channel *config.Channel) json.RawMessage {
|
||||||
|
if channel == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, err := channel.GetDecoded()
|
||||||
|
if err == nil && decoded != nil {
|
||||||
|
normalized, err := json.Marshal(canonicalizeSignatureValue(reflect.ValueOf(decoded)))
|
||||||
|
if err == nil {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeRawJSON(channel.Settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRawJSON(raw config.RawNode) json.RawMessage {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var value any
|
||||||
|
if err := json.Unmarshal(raw, &value); err != nil {
|
||||||
|
return bytes.TrimSpace(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return bytes.TrimSpace(raw)
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalizeSignatureValue(value reflect.Value) any {
|
||||||
|
if !value.IsValid() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if value.CanInterface() {
|
||||||
|
switch typed := value.Interface().(type) {
|
||||||
|
case config.SecureString:
|
||||||
|
return typed.String()
|
||||||
|
case *config.SecureString:
|
||||||
|
if typed == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return typed.String()
|
||||||
|
case config.SecureStrings:
|
||||||
|
return typed.Values()
|
||||||
|
case *config.SecureStrings:
|
||||||
|
if typed == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return typed.Values()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch value.Kind() {
|
||||||
|
case reflect.Interface, reflect.Pointer:
|
||||||
|
if value.IsNil() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return canonicalizeSignatureValue(value.Elem())
|
||||||
|
case reflect.Struct:
|
||||||
|
result := make(map[string]any)
|
||||||
|
valueType := value.Type()
|
||||||
|
for i := 0; i < value.NumField(); i++ {
|
||||||
|
field := valueType.Field(i)
|
||||||
|
if field.PkgPath != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tag := field.Tag.Get("json")
|
||||||
|
name := field.Name
|
||||||
|
if tag != "" {
|
||||||
|
if comma := strings.Index(tag, ","); comma >= 0 {
|
||||||
|
tag = tag[:comma]
|
||||||
|
}
|
||||||
|
if tag == "-" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if tag != "" {
|
||||||
|
name = tag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result[name] = canonicalizeSignatureValue(value.Field(i))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
length := value.Len()
|
||||||
|
result := make([]any, 0, length)
|
||||||
|
for i := 0; i < length; i++ {
|
||||||
|
result = append(result, canonicalizeSignatureValue(value.Index(i)))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
case reflect.Map:
|
||||||
|
if value.Type().Key().Kind() != reflect.String {
|
||||||
|
return value.Interface()
|
||||||
|
}
|
||||||
|
result := make(map[string]any, value.Len())
|
||||||
|
iter := value.MapRange()
|
||||||
|
for iter.Next() {
|
||||||
|
result[iter.Key().String()] = canonicalizeSignatureValue(iter.Value())
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
default:
|
||||||
|
if value.CanInterface() {
|
||||||
|
return value.Interface()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func gatewayRestartRequiredBySignature(bootSignature, currentSignature, gatewayStatus string) bool {
|
func gatewayRestartRequiredBySignature(bootSignature, currentSignature, gatewayStatus string) bool {
|
||||||
if gatewayStatus != "running" {
|
if gatewayStatus != "running" {
|
||||||
return false
|
return false
|
||||||
|
|
|
||||||
|
|
@ -1108,6 +1108,136 @@ func TestGatewayStatusRequiresRestartAfterToolChange(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGatewayStatusRequiresRestartAfterChannelChange(t *testing.T) {
|
||||||
|
resetGatewayTestState(t)
|
||||||
|
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
||||||
|
cfg.ModelList[0].SetAPIKey("test-key")
|
||||||
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
process, err := os.FindProcess(os.Getpid())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindProcess() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bootSignature := computeConfigSignature(cfg)
|
||||||
|
gateway.mu.Lock()
|
||||||
|
gateway.cmd = &exec.Cmd{Process: process}
|
||||||
|
gateway.bootDefaultModel = cfg.ModelList[0].ModelName
|
||||||
|
gateway.bootConfigSignature = bootSignature
|
||||||
|
setGatewayRuntimeStatusLocked("running")
|
||||||
|
gateway.mu.Unlock()
|
||||||
|
|
||||||
|
updatedCfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
telegram := updatedCfg.Channels.Get("telegram")
|
||||||
|
if telegram == nil {
|
||||||
|
t.Fatalf("expected default telegram channel config")
|
||||||
|
}
|
||||||
|
telegram.Enabled = !telegram.Enabled
|
||||||
|
if err := config.SaveConfig(configPath, updatedCfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
|
||||||
|
return mockGatewayHealthResponse(http.StatusOK, os.Getpid()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := body["gateway_status"]; got != "running" {
|
||||||
|
t.Fatalf("gateway_status = %#v, want %q", got, "running")
|
||||||
|
}
|
||||||
|
if got := body["gateway_restart_required"]; got != true {
|
||||||
|
t.Fatalf("gateway_restart_required = %#v, want true", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGatewayStatusRequiresRestartAfterWebSearchConfigChange(t *testing.T) {
|
||||||
|
resetGatewayTestState(t)
|
||||||
|
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
||||||
|
cfg.ModelList[0].SetAPIKey("test-key")
|
||||||
|
cfg.Tools.Web.Enabled = true
|
||||||
|
cfg.Tools.Web.Provider = "sogou"
|
||||||
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
process, err := os.FindProcess(os.Getpid())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindProcess() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bootSignature := computeConfigSignature(cfg)
|
||||||
|
gateway.mu.Lock()
|
||||||
|
gateway.cmd = &exec.Cmd{Process: process}
|
||||||
|
gateway.bootDefaultModel = cfg.ModelList[0].ModelName
|
||||||
|
gateway.bootConfigSignature = bootSignature
|
||||||
|
setGatewayRuntimeStatusLocked("running")
|
||||||
|
gateway.mu.Unlock()
|
||||||
|
|
||||||
|
updatedCfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
updatedCfg.Tools.Web.Provider = "duckduckgo"
|
||||||
|
if err := config.SaveConfig(configPath, updatedCfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
|
||||||
|
return mockGatewayHealthResponse(http.StatusOK, os.Getpid()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := body["gateway_status"]; got != "running" {
|
||||||
|
t.Fatalf("gateway_status = %#v, want %q", got, "running")
|
||||||
|
}
|
||||||
|
if got := body["gateway_restart_required"]; got != true {
|
||||||
|
t.Fatalf("gateway_restart_required = %#v, want true", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGatewayStatusNoRestartRequiredForNonSensitiveChanges(t *testing.T) {
|
func TestGatewayStatusNoRestartRequiredForNonSensitiveChanges(t *testing.T) {
|
||||||
resetGatewayTestState(t)
|
resetGatewayTestState(t)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue