Initial commit: omnichannel-mcp — MCP-сервер управления платформой Omnichannel
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
package configserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.totmin.ru/en2zmax/forge-toolkit/configreload"
|
||||
)
|
||||
|
||||
// tenantFileName — имя файла конфига внутри каталога OMNI_CONFIG_DIR
|
||||
// (например, при постраничной/per-call передаче конфига).
|
||||
const tenantFileName = "omnichannel-mcp.json"
|
||||
|
||||
// Manager — одиночный потокобезопасный диспетчер процесса (ARCHITECTURE.md §2).
|
||||
// Кэширует per-путь загрузчики конфига (live-reload по контент-хэшу), сессии
|
||||
// (отдельно rw/ro) и семафоры на мутации. Конкурентный доступ агентов безопасен.
|
||||
type Manager struct {
|
||||
staticPath string
|
||||
|
||||
mu sync.Mutex
|
||||
loaders map[string]*configreload.Loader[*Config]
|
||||
sessions map[string]*Session
|
||||
sems map[string]chan struct{}
|
||||
}
|
||||
|
||||
// NewManager создаёт Manager. staticPath — путь из `-config` (основной режим);
|
||||
// пусто — путь ищется в OMNI_CONFIG_DIR.
|
||||
func NewManager(staticPath string) *Manager {
|
||||
return &Manager{
|
||||
staticPath: staticPath,
|
||||
loaders: map[string]*configreload.Loader[*Config]{},
|
||||
sessions: map[string]*Session{},
|
||||
sems: map[string]chan struct{}{},
|
||||
}
|
||||
}
|
||||
|
||||
// ResolvePath выбирает путь конфига по приоритету: явный путь вызова →
|
||||
// каталог из окружения (OMNI_CONFIG_DIR, затем legacy FORGE_TENANT_CONFIG) →
|
||||
// статический `-config`. Если переменная задаёт каталог, в нём берётся
|
||||
// tenantFileName.
|
||||
func (m *Manager) ResolvePath(tenantPath string) string {
|
||||
if tenantPath != "" {
|
||||
return tenantPath
|
||||
}
|
||||
for _, env := range []string{"OMNI_CONFIG_DIR", "FORGE_TENANT_CONFIG"} {
|
||||
if dir := os.Getenv(env); dir != "" {
|
||||
return filepath.Join(dir, tenantFileName)
|
||||
}
|
||||
}
|
||||
return m.staticPath
|
||||
}
|
||||
|
||||
// Config загружает конфиг (live-reload) и возвращает его вместе с путём.
|
||||
// Отсутствие файла — fail-closed (ErrNoConfig); битый файл с last-good —
|
||||
// работаем со старым значением и логируем.
|
||||
func (m *Manager) Config(tenantPath string) (*Config, string, error) {
|
||||
path := m.ResolvePath(tenantPath)
|
||||
if path == "" {
|
||||
return nil, "", ErrNoConfig
|
||||
}
|
||||
|
||||
loader := m.loaderFor(path)
|
||||
cfg, err := loader.Get()
|
||||
if err != nil {
|
||||
if errors.Is(err, configreload.ErrNotFound) {
|
||||
return nil, path, fmt.Errorf("%w (файл %s)", ErrNoConfig, path)
|
||||
}
|
||||
if cfg == nil {
|
||||
return nil, path, err
|
||||
}
|
||||
// last-good: продолжаем на прежнем конфиге, но сигналим в лог.
|
||||
slog.Warn("конфиг изменён к невалидному, работаем на последнем рабочем", "path", path, "error", err)
|
||||
}
|
||||
return cfg, path, nil
|
||||
}
|
||||
|
||||
// Open собирает фасад API для вызова: резолвит конфиг и сервер, проверяет
|
||||
// read_only, поднимает сессию и (для мутаций) берёт семафор конкурентности.
|
||||
// Close обязателен.
|
||||
func (m *Manager) Open(ctx context.Context, tenantPath, alias string, write bool) (*API, error) {
|
||||
cfg, path, err := m.Config(tenantPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
srv, err := cfg.Server(alias)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if write && cfg.ReadOnly {
|
||||
return nil, ErrReadOnly
|
||||
}
|
||||
|
||||
// Read-инструменты используют read-only креды, если они заданы.
|
||||
sess, err := m.session(path, srv, !write)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
api := &API{
|
||||
CfgPath: path,
|
||||
Srv: *srv,
|
||||
Config: cfg,
|
||||
Sess: sess,
|
||||
Write: write,
|
||||
}
|
||||
if write {
|
||||
sem := m.semaphore(path, srv.Alias, srv.MaxConcurrentMutations)
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
api.sem, api.held = sem, true
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
return api, nil
|
||||
}
|
||||
|
||||
// loaderFor возвращает (и кэширует) live-reload загрузчик по пути.
|
||||
func (m *Manager) loaderFor(path string) *configreload.Loader[*Config] {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if l, ok := m.loaders[path]; ok {
|
||||
return l
|
||||
}
|
||||
l := configreload.New(path, func([]byte) (*Config, error) { return LoadFile(path) })
|
||||
m.loaders[path] = l
|
||||
return l
|
||||
}
|
||||
|
||||
// session возвращает (и кэширует) сессию по (путь, алиас, rw/ro).
|
||||
func (m *Manager) session(path string, srv *Server, readonly bool) (*Session, error) {
|
||||
key := path + "|" + srv.Alias + "|" + strconv.FormatBool(readonly)
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if s, ok := m.sessions[key]; ok {
|
||||
return s, nil
|
||||
}
|
||||
s, err := NewSession(srv, readonly)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.sessions[key] = s
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// semaphore возвращает (и кэширует) семафор конкурентных мутаций на сервер.
|
||||
func (m *Manager) semaphore(path, alias string, n int) chan struct{} {
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
key := path + "|" + alias
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
ch, ok := m.sems[key]
|
||||
if !ok || cap(ch) != n {
|
||||
ch = make(chan struct{}, n)
|
||||
m.sems[key] = ch
|
||||
}
|
||||
return ch
|
||||
}
|
||||
|
||||
// Close закрывает все сессии (graceful shutdown).
|
||||
func (m *Manager) Close() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.sessions = nil
|
||||
m.sems = nil
|
||||
}
|
||||
|
||||
// API — фасад одного вызова: конфиг сервера + авторизованная сессия + лимиты.
|
||||
// Методы API (см. api_*.go) соответствуют эндпоинтам config_server v1.1.0.
|
||||
type API struct {
|
||||
CfgPath string
|
||||
Srv Server
|
||||
Config *Config
|
||||
Sess *Session
|
||||
Write bool
|
||||
|
||||
sem chan struct{}
|
||||
held bool
|
||||
}
|
||||
|
||||
// Close освобождает семафор мутаций (если он был взят). Идемпотентен.
|
||||
func (a *API) Close() {
|
||||
if a.held {
|
||||
<-a.sem
|
||||
a.held = false
|
||||
}
|
||||
}
|
||||
|
||||
// Alias возвращает алиас сервера (для сообщений).
|
||||
func (a *API) Alias() string { return a.Srv.Alias }
|
||||
|
||||
// withTimeout накладывает таймаут сервера на вызов.
|
||||
func (a *API) withTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(ctx, time.Duration(a.Srv.TimeoutSec)*time.Second)
|
||||
}
|
||||
|
||||
// withUploadTimeout — увеличенный таймаут для загрузки фронта.
|
||||
func (a *API) withUploadTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(ctx, time.Duration(a.Srv.UploadTimeoutSec)*time.Second)
|
||||
}
|
||||
Reference in New Issue
Block a user