Files
GoClaw/gateway/internal/db/db.go
bboxwtf f8e0ca7d5d feat(gateway): restore Phase C full agent lifecycle API
- Restored Phase C gateway code (handlers, main.go, docker client, db)
- Added routes: GET /api/agents/running, POST /api/agents (CRUD),
  POST /api/agents/{id}/deploy, POST /api/agents/{id}/stop,
  POST /api/agents/{id}/restart, POST /api/agents/{id}/scale
- Fixed StopAgent: always try to stop by canonical name goclaw-agent-{id}
  even when serviceName is empty in DB
- Fixed DeployAgent: handle 409 conflict by removing existing container
  and retrying once (idempotent deploy)
- Added swarm_manager.go: background SwarmManager for dead-letter recovery
- Added AGENT_NETWORK and AGENT_DB_URL config options
- Updated .gitignore to exclude gateway binaries
- All agents use standalone docker run (not Swarm) on bridge network

Verified on prod: deploy/stop/restart cycle works correctly,
/api/agents/running returns live running agents with containerStatus
2026-04-19 11:40:39 +00:00

857 lines
25 KiB
Go

// Package db provides MySQL/TiDB connectivity and agent config queries.
package db
import (
"database/sql"
"database/sql/driver"
"encoding/json"
"fmt"
"log"
"strings"
_ "github.com/go-sql-driver/mysql"
)
// AgentConfig holds the orchestrator/agent configuration loaded from DB.
type AgentConfig struct {
ID int
Name string
Model string
SystemPrompt string
AllowedTools []string
Temperature float64
MaxTokens int
IsOrchestrator bool
IsSystem bool
IsActive bool
// Container / Swarm fields (Phase A)
ServiceName string
ServicePort int
ContainerImage string
ContainerStatus string // "stopped" | "deploying" | "running" | "error"
}
// AgentRow is a minimal agent representation for listing.
type AgentRow struct {
ID int `json:"id"`
Name string `json:"name"`
Role string `json:"role"`
Model string `json:"model"`
Description string `json:"description"`
IsActive bool `json:"isActive"`
IsSystem bool `json:"isSystem"`
IsOrchestrator bool `json:"isOrchestrator"`
// Container / Swarm fields
ServiceName string `json:"serviceName"`
ServicePort int `json:"servicePort"`
ContainerImage string `json:"containerImage"`
ContainerStatus string `json:"containerStatus"`
}
// CreateAgentInput holds the fields required to create a new agent in DB.
type CreateAgentInput struct {
Name string `json:"name"`
Role string `json:"role"`
Model string `json:"model"`
Description string `json:"description"`
SystemPrompt string `json:"systemPrompt"`
Temperature float64 `json:"temperature"`
MaxTokens int `json:"maxTokens"`
AllowedTools []string `json:"allowedTools"`
IsSystem bool `json:"isSystem"`
IsOrchestrator bool `json:"isOrchestrator"`
ContainerImage string `json:"containerImage"`
}
type DB struct {
conn *sql.DB
}
func Connect(dsn string) (*DB, error) {
if dsn == "" {
return nil, fmt.Errorf("DATABASE_URL is empty")
}
// Convert mysql:// URL to DSN format if needed
dsn = normalizeDSN(dsn)
conn, err := sql.Open("mysql", dsn)
if err != nil {
return nil, fmt.Errorf("failed to open DB: %w", err)
}
if err := conn.Ping(); err != nil {
return nil, fmt.Errorf("failed to ping DB: %w", err)
}
log.Println("[DB] Connected to MySQL")
return &DB{conn: conn}, nil
}
func (d *DB) Close() {
if d.conn != nil {
_ = d.conn.Close()
}
}
// GetOrchestratorConfig loads the agent with isOrchestrator=1 from DB.
func (d *DB) GetOrchestratorConfig() (*AgentConfig, error) {
row := d.conn.QueryRow(`
SELECT id, name, model, systemPrompt, allowedTools, temperature, maxTokens, isOrchestrator, isSystem, isActive
FROM agents
WHERE isOrchestrator = 1
LIMIT 1
`)
return scanAgentConfig(row)
}
// GetAgentByID loads a specific agent by ID.
func (d *DB) GetAgentByID(id int) (*AgentConfig, error) {
row := d.conn.QueryRow(`
SELECT id, name, model, systemPrompt, allowedTools, temperature, maxTokens, isOrchestrator, isSystem, isActive
FROM agents
WHERE id = ?
LIMIT 1
`, id)
return scanAgentConfig(row)
}
// ListAgents returns all agents with container status fields.
func (d *DB) ListAgents() ([]AgentRow, error) {
rows, err := d.conn.Query(`
SELECT id, name, role, model,
COALESCE(description,''), isActive, isSystem, isOrchestrator,
COALESCE(serviceName,''), COALESCE(servicePort,0),
COALESCE(containerImage,''), COALESCE(containerStatus,'stopped')
FROM agents
ORDER BY isOrchestrator DESC, isSystem DESC, id ASC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var agents []AgentRow
for rows.Next() {
var a AgentRow
var isActive, isSystem, isOrch int
if err := rows.Scan(
&a.ID, &a.Name, &a.Role, &a.Model, &a.Description,
&isActive, &isSystem, &isOrch,
&a.ServiceName, &a.ServicePort, &a.ContainerImage, &a.ContainerStatus,
); err != nil {
continue
}
a.IsActive = isActive == 1
a.IsSystem = isSystem == 1
a.IsOrchestrator = isOrch == 1
agents = append(agents, a)
}
return agents, nil
}
// CreateAgent inserts a new agent into the DB and returns its ID.
func (d *DB) CreateAgent(in CreateAgentInput) (int, error) {
if d.conn == nil {
return 0, fmt.Errorf("DB not connected")
}
toolsJSON := "[]"
if len(in.AllowedTools) > 0 {
b, _ := json.Marshal(in.AllowedTools)
toolsJSON = string(b)
}
temp := in.Temperature
if temp == 0 {
temp = 0.7
}
maxTok := in.MaxTokens
if maxTok == 0 {
maxTok = 8192
}
img := in.ContainerImage
if img == "" {
img = "goclaw-agent-worker:latest"
}
res, err := d.conn.Exec(`
INSERT INTO agents
(name, role, model, description, systemPrompt, temperature, maxTokens,
allowedTools, isActive, isSystem, isOrchestrator,
containerImage, containerStatus, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, 'stopped', NOW(), NOW())
`,
in.Name, in.Role, in.Model, in.Description, in.SystemPrompt,
temp, maxTok, toolsJSON,
boolToInt(in.IsSystem), boolToInt(in.IsOrchestrator),
img,
)
if err != nil {
return 0, fmt.Errorf("insert agent: %w", err)
}
id, _ := res.LastInsertId()
return int(id), nil
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
// DeleteAgent removes an agent record by ID (only non-system agents).
func (d *DB) DeleteAgent(id int) error {
if d.conn == nil {
return fmt.Errorf("DB not connected")
}
res, err := d.conn.Exec(`DELETE FROM agents WHERE id = ? AND isSystem = 0`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("agent %d not found or is a system agent", id)
}
return nil
}
// AssignServicePort finds the lowest free port in range [start, start+maxAgents).
// It reads all currently used ports from DB.
func (d *DB) AssignServicePort(start, maxAgents int) (int, error) {
if d.conn == nil {
return start, nil // offline — just return start
}
rows, err := d.conn.Query(`SELECT COALESCE(servicePort,0) FROM agents WHERE servicePort > 0`)
if err != nil {
return start, nil
}
defer rows.Close()
used := map[int]bool{}
for rows.Next() {
var p int
if rows.Scan(&p) == nil && p > 0 {
used[p] = true
}
}
for port := start; port < start+maxAgents; port++ {
if !used[port] {
return port, nil
}
}
return 0, fmt.Errorf("no free port in range %d-%d", start, start+maxAgents)
}
// ─── LLM Provider ─────────────────────────────────────────────────────────────
// ProviderRow holds the active LLM provider config from DB.
type ProviderRow struct {
ID int
Name string
BaseURL string
APIKey string // decrypted (Node.js encrypts, Go just reads raw for now)
}
// GetActiveProvider returns the active LLM provider from the llmProviders table.
// Note: The API key is stored AES-256-GCM encrypted by the Node.js server.
// The Go gateway reads the raw encrypted bytes but cannot decrypt them (no shared key in Go).
// The proper flow: Node.js decrypts the key and passes it via /api/providers/reload.
// For now, GetActiveProvider returns the stored encrypted bytes as-is (not useful for direct use).
// Use UpdateCredentials on the LLM client instead.
func (d *DB) GetActiveProvider() (*ProviderRow, error) {
var p ProviderRow
var apiKeyEncrypted sql.NullString
row := d.conn.QueryRow(`
SELECT id, name, baseUrl, COALESCE(apiKeyEncrypted, '')
FROM llmProviders
WHERE isActive = 1
LIMIT 1
`)
err := row.Scan(&p.ID, &p.Name, &p.BaseURL, &apiKeyEncrypted)
if err != nil {
return nil, err
}
// We cannot decrypt the key in Go (different crypto impl from Node.js)
// Return empty key — the LLM client will use its env-configured key
p.APIKey = ""
return &p, nil
}
// ─── Chat Sessions & Events ───────────────────────────────────────────────────
// ChatSessionRow holds one persistent chat session.
type ChatSessionRow struct {
ID int `json:"id"`
SessionID string `json:"sessionId"`
AgentID int `json:"agentId"`
Status string `json:"status"` // running | done | error
UserMessage string `json:"userMessage"`
FinalResponse string `json:"finalResponse"`
Model string `json:"model"`
TotalTokens int `json:"totalTokens"`
ProcessingTimeMs int64 `json:"processingTimeMs"`
ErrorMessage string `json:"errorMessage"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// ChatEventRow holds one event inside a session.
type ChatEventRow struct {
ID int `json:"id"`
SessionID string `json:"sessionId"`
Seq int `json:"seq"`
EventType string `json:"eventType"` // thinking | tool_call | delta | done | error
Content string `json:"content"`
ToolName string `json:"toolName"`
ToolArgs string `json:"toolArgs"` // JSON string
ToolResult string `json:"toolResult"`
ToolSuccess bool `json:"toolSuccess"`
DurationMs int `json:"durationMs"`
Model string `json:"model"`
UsageJSON string `json:"usageJson"` // JSON string
ErrorMsg string `json:"errorMsg"`
CreatedAt string `json:"createdAt"`
}
// CreateSession inserts a new running session and returns its row.
func (d *DB) CreateSession(sessionID, userMessage string, agentID int) error {
if d.conn == nil {
return fmt.Errorf("DB not connected")
}
_, err := d.conn.Exec(`
INSERT INTO chatSessions (sessionId, agentId, status, userMessage)
VALUES (?, ?, 'running', ?)
`, sessionID, agentID, truncate(userMessage, 65535))
return err
}
// AppendEvent inserts a new event row for a session.
// seq is auto-calculated as MAX(seq)+1 for the session.
func (d *DB) AppendEvent(e ChatEventRow) error {
if d.conn == nil {
return nil
}
toolArgs := e.ToolArgs
if toolArgs == "" {
toolArgs = "null"
}
usageJSON := e.UsageJSON
if usageJSON == "" {
usageJSON = "null"
}
var toolSuccessVal interface{}
if e.EventType == "tool_call" {
if e.ToolSuccess {
toolSuccessVal = 1
} else {
toolSuccessVal = 0
}
}
_, err := d.conn.Exec(`
INSERT INTO chatEvents
(sessionId, seq, eventType, content, toolName, toolArgs,
toolResult, toolSuccess, durationMs, model, usageJson, errorMsg)
SELECT ?, COALESCE(MAX(seq),0)+1, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?
FROM chatEvents WHERE sessionId = ?
`,
e.SessionID, e.EventType,
nullStr(e.Content), nullStr(e.ToolName), rawJSON(toolArgs),
nullStr(e.ToolResult), toolSuccessVal, nullInt(e.DurationMs),
nullStr(e.Model), rawJSON(usageJSON), nullStr(e.ErrorMsg),
e.SessionID,
)
if err != nil {
log.Printf("[DB] AppendEvent error: %v", err)
}
return err
}
// MarkSessionDone updates a session to done/error status.
func (d *DB) MarkSessionDone(sessionID, status, finalResponse, model, errorMessage string, totalTokens int, processingTimeMs int64) {
if d.conn == nil {
return
}
_, err := d.conn.Exec(`
UPDATE chatSessions
SET status=?, finalResponse=?, model=?, totalTokens=?,
processingTimeMs=?, errorMessage=?
WHERE sessionId=?
`, status,
truncate(finalResponse, 65535),
model,
totalTokens,
processingTimeMs,
truncate(errorMessage, 65535),
sessionID,
)
if err != nil {
log.Printf("[DB] MarkSessionDone error: %v", err)
}
}
// GetSession returns a single session by its string ID.
func (d *DB) GetSession(sessionID string) (*ChatSessionRow, error) {
if d.conn == nil {
return nil, fmt.Errorf("DB not connected")
}
row := d.conn.QueryRow(`
SELECT id, sessionId, agentId, status,
COALESCE(userMessage,''),
COALESCE(finalResponse,''),
COALESCE(model,''),
COALESCE(totalTokens,0),
COALESCE(processingTimeMs,0),
COALESCE(errorMessage,''),
createdAt, updatedAt
FROM chatSessions WHERE sessionId=? LIMIT 1
`, sessionID)
var s ChatSessionRow
err := row.Scan(&s.ID, &s.SessionID, &s.AgentID, &s.Status,
&s.UserMessage, &s.FinalResponse, &s.Model,
&s.TotalTokens, &s.ProcessingTimeMs, &s.ErrorMessage,
&s.CreatedAt, &s.UpdatedAt)
if err != nil {
return nil, err
}
return &s, nil
}
// GetEvents returns all events for a session with seq > afterSeq (for incremental polling).
func (d *DB) GetEvents(sessionID string, afterSeq int) ([]ChatEventRow, error) {
if d.conn == nil {
return nil, fmt.Errorf("DB not connected")
}
rows, err := d.conn.Query(`
SELECT id, sessionId, seq, eventType,
COALESCE(content,''), COALESCE(toolName,''),
COALESCE(CAST(toolArgs AS CHAR),'null'),
COALESCE(toolResult,''),
COALESCE(toolSuccess,0),
COALESCE(durationMs,0),
COALESCE(model,''),
COALESCE(CAST(usageJson AS CHAR),'null'),
COALESCE(errorMsg,''),
createdAt
FROM chatEvents
WHERE sessionId=? AND seq > ?
ORDER BY seq ASC
`, sessionID, afterSeq)
if err != nil {
return nil, err
}
defer rows.Close()
var result []ChatEventRow
for rows.Next() {
var e ChatEventRow
var toolSuccess int
if err := rows.Scan(
&e.ID, &e.SessionID, &e.Seq, &e.EventType,
&e.Content, &e.ToolName, &e.ToolArgs,
&e.ToolResult, &toolSuccess, &e.DurationMs,
&e.Model, &e.UsageJSON, &e.ErrorMsg, &e.CreatedAt,
); err != nil {
continue
}
e.ToolSuccess = toolSuccess == 1
result = append(result, e)
}
return result, nil
}
// GetRecentSessions returns the N most recent sessions.
func (d *DB) GetRecentSessions(limit int) ([]ChatSessionRow, error) {
if d.conn == nil {
return nil, fmt.Errorf("DB not connected")
}
rows, err := d.conn.Query(`
SELECT id, sessionId, agentId, status,
COALESCE(userMessage,''),
COALESCE(finalResponse,''),
COALESCE(model,''),
COALESCE(totalTokens,0),
COALESCE(processingTimeMs,0),
COALESCE(errorMessage,''),
createdAt, updatedAt
FROM chatSessions ORDER BY id DESC LIMIT ?
`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var result []ChatSessionRow
for rows.Next() {
var s ChatSessionRow
if err := rows.Scan(&s.ID, &s.SessionID, &s.AgentID, &s.Status,
&s.UserMessage, &s.FinalResponse, &s.Model,
&s.TotalTokens, &s.ProcessingTimeMs, &s.ErrorMessage,
&s.CreatedAt, &s.UpdatedAt); err != nil {
continue
}
result = append(result, s)
}
return result, nil
}
// helper — nil for empty strings
func nullStr(s string) interface{} {
if s == "" {
return nil
}
return s
}
// helper — nil for zero int
func nullInt(n int) interface{} {
if n == 0 {
return nil
}
return n
}
// rawJSON wraps a JSON string so it's passed as-is to MySQL (not double-encoded)
type rawJSON string
func (r rawJSON) Value() (driver.Value, error) {
if r == "null" || r == "" {
return nil, nil
}
return string(r), nil
}
// ─── Metrics & History ────────────────────────────────────────────────────────
// MetricInput holds data for a single orchestrator request metric.
type MetricInput struct {
AgentID int
RequestID string
UserMessage string
AgentResponse string
InputTokens int
OutputTokens int
TotalTokens int
ProcessingTimeMs int64
Status string // "success" | "error" | "timeout"
ErrorMessage string
ToolsCalled []string
Model string
}
// SaveMetric inserts a row into the agentMetrics table.
// Non-fatal — logs on error but does not return one.
func (d *DB) SaveMetric(m MetricInput) {
if d.conn == nil {
return
}
toolsJSON, _ := json.Marshal(m.ToolsCalled)
_, err := d.conn.Exec(`
INSERT INTO agentMetrics
(agentId, requestId, userMessage, agentResponse,
inputTokens, outputTokens, totalTokens,
processingTimeMs, status, errorMessage, toolsCalled, model)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
m.AgentID,
m.RequestID,
truncate(m.UserMessage, 65535),
truncate(m.AgentResponse, 65535),
m.InputTokens, m.OutputTokens, m.TotalTokens,
m.ProcessingTimeMs,
m.Status,
m.ErrorMessage,
string(toolsJSON),
m.Model,
)
if err != nil {
log.Printf("[DB] SaveMetric error: %v", err)
}
}
// HistoryInput holds data for one conversation entry.
type HistoryInput struct {
AgentID int
UserMessage string
AgentResponse string
ConversationID string
Status string // "success" | "error" | "pending"
}
// SaveHistory inserts a row into the agentHistory table.
// Non-fatal — logs on error but does not return one.
func (d *DB) SaveHistory(h HistoryInput) {
if d.conn == nil {
return
}
status := h.Status
if status == "" {
status = "success"
}
convID := sql.NullString{String: h.ConversationID, Valid: h.ConversationID != ""}
resp := sql.NullString{String: h.AgentResponse, Valid: h.AgentResponse != ""}
_, err := d.conn.Exec(`
INSERT INTO agentHistory (agentId, userMessage, agentResponse, conversationId, status)
VALUES (?, ?, ?, ?, ?)
`,
h.AgentID,
truncate(h.UserMessage, 65535),
resp,
convID,
status,
)
if err != nil {
log.Printf("[DB] SaveHistory error: %v", err)
}
}
// truncate caps a string to maxLen bytes (not runes — fast path for DB limits).
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen]
}
// ─── Swarm Node Persistence ───────────────────────────────────────────────────
// SwarmNodeInput is the data shape that handlers pass to UpsertSwarmNodes.
// It matches the JSON shape from handler's NodeOut struct so we can reuse it.
type SwarmNodeInput struct {
ID string `json:"id"`
Hostname string `json:"hostname"`
Role string `json:"role"`
State string `json:"state"`
Availability string `json:"availability"`
IP string `json:"ip"`
CPUCores int `json:"cpuCores"`
MemTotalMB int64 `json:"memTotalMB"`
DockerVersion string `json:"dockerVersion"`
IsLeader bool `json:"isLeader"`
ManagerAddr string `json:"managerAddr"`
Labels map[string]string `json:"labels"`
}
// UpsertSwarmNodes inserts or updates swarm node records in the swarmNodes table.
// Called asynchronously from the SwarmNodes handler — never blocks the response.
func (d *DB) UpsertSwarmNodes(nodes interface{}) {
if d.conn == nil {
return
}
// We accept interface{} to avoid circular import; use json round-trip to parse.
b, err := json.Marshal(nodes)
if err != nil {
return
}
var list []SwarmNodeInput
if err := json.Unmarshal(b, &list); err != nil {
return
}
for _, n := range list {
labelsJSON, _ := json.Marshal(n.Labels)
isLeader := 0
if n.IsLeader {
isLeader = 1
}
isManager := 0
if n.Role == "manager" {
isManager = 1
}
state := n.State
if state != "ready" && state != "down" && state != "disconnected" {
state = "ready"
}
avail := n.Availability
if avail != "active" && avail != "pause" && avail != "drain" {
avail = "active"
}
_, err := d.conn.Exec(`
INSERT INTO swarmNodes
(nodeId, hostname, role, state, availability, advertiseAddr,
labels, engineVersion, cpuCores, memTotalMB, isManager, isLeader)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
hostname=VALUES(hostname), role=VALUES(role),
state=VALUES(state), availability=VALUES(availability),
advertiseAddr=VALUES(advertiseAddr),
labels=VALUES(labels), engineVersion=VALUES(engineVersion),
cpuCores=VALUES(cpuCores), memTotalMB=VALUES(memTotalMB),
isManager=VALUES(isManager), isLeader=VALUES(isLeader),
lastSeenAt=CURRENT_TIMESTAMP
`,
n.ID, n.Hostname, n.Role, state, avail, n.IP,
string(labelsJSON), n.DockerVersion,
n.CPUCores, n.MemTotalMB, isManager, isLeader,
)
if err != nil {
log.Printf("[DB] UpsertSwarmNodes error for node %s: %v", n.ID, err)
}
}
}
// UpsertSwarmTokens stores the current swarm join tokens.
func (d *DB) UpsertSwarmTokens(workerToken, managerToken, managerAddr string) {
if d.conn == nil {
return
}
_, err := d.conn.Exec(`
INSERT INTO swarmTokens (managerToken, workerToken, managerAddr)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE
managerToken=VALUES(managerToken),
workerToken=VALUES(workerToken),
managerAddr=VALUES(managerAddr)
`, managerToken, workerToken, managerAddr)
if err != nil {
log.Printf("[DB] UpsertSwarmTokens error: %v", err)
}
}
// GetSwarmTokens retrieves the stored join tokens.
func (d *DB) GetSwarmTokens() (worker, manager, addr string, err error) {
if d.conn == nil {
err = fmt.Errorf("DB not connected")
return
}
row := d.conn.QueryRow(`
SELECT COALESCE(workerToken,''), COALESCE(managerToken,''), COALESCE(managerAddr,'')
FROM swarmTokens ORDER BY id DESC LIMIT 1
`)
err = row.Scan(&worker, &manager, &addr)
return
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
func scanAgentConfig(row *sql.Row) (*AgentConfig, error) {
var cfg AgentConfig
var systemPrompt sql.NullString
var allowedToolsJSON sql.NullString
var temperature sql.NullFloat64
var maxTokens sql.NullInt64
var isOrch, isSystem, isActive int
err := row.Scan(
&cfg.ID, &cfg.Name, &cfg.Model,
&systemPrompt, &allowedToolsJSON,
&temperature, &maxTokens,
&isOrch, &isSystem, &isActive,
)
if err != nil {
return nil, err
}
cfg.SystemPrompt = systemPrompt.String
cfg.Temperature = temperature.Float64
if cfg.Temperature == 0 {
cfg.Temperature = 0.5
}
cfg.MaxTokens = int(maxTokens.Int64)
if cfg.MaxTokens == 0 {
cfg.MaxTokens = 8192
}
cfg.IsOrchestrator = isOrch == 1
cfg.IsSystem = isSystem == 1
cfg.IsActive = isActive == 1
if allowedToolsJSON.Valid && allowedToolsJSON.String != "" && allowedToolsJSON.String != "null" {
_ = json.Unmarshal([]byte(allowedToolsJSON.String), &cfg.AllowedTools)
}
return &cfg, nil
}
// normalizeDSN converts mysql://user:pass@host:port/db to user:pass@tcp(host:port)/db
func normalizeDSN(dsn string) string {
if !strings.HasPrefix(dsn, "mysql://") {
return dsn
}
// Strip scheme
dsn = strings.TrimPrefix(dsn, "mysql://")
// user:pass@host:port/db → user:pass@tcp(host:port)/db
atIdx := strings.LastIndex(dsn, "@")
if atIdx < 0 {
return dsn
}
userInfo := dsn[:atIdx]
hostDB := dsn[atIdx+1:]
// Split host:port/db
slashIdx := strings.Index(hostDB, "/")
var hostPort, dbName string
if slashIdx >= 0 {
hostPort = hostDB[:slashIdx]
dbName = hostDB[slashIdx:]
} else {
hostPort = hostDB
dbName = ""
}
// TiDB Cloud and other cloud MySQL require TLS — detect by host pattern
tlsParam := ""
if strings.Contains(hostPort, "tidbcloud") ||
strings.Contains(hostPort, "tidb.cloud") ||
strings.Contains(hostPort, "aws") ||
strings.Contains(hostPort, "gcp") ||
strings.Contains(hostPort, "azure") {
tlsParam = "&tls=true"
}
// Also detect if the original DSN had ?ssl or ?tls params
if strings.Contains(dbName, "ssl") || strings.Contains(dbName, "tls") {
tlsParam = "" // already handled in dbName
}
return fmt.Sprintf("%s@tcp(%s)%s?parseTime=true&charset=utf8mb4%s", userInfo, hostPort, dbName, tlsParam)
}
// ─── Agent Container Fields ───────────────────────────────────────────────────
// These methods support the agent-worker container architecture where each
// agent runs as an autonomous Docker Swarm service.
// UpdateContainerStatus updates the container lifecycle state of an agent.
func (d *DB) UpdateContainerStatus(agentID int, status, serviceName string, servicePort int) error {
if d.conn == nil {
return nil
}
_, err := d.conn.Exec(`
UPDATE agents
SET containerStatus = ?, serviceName = ?, servicePort = ?, updatedAt = NOW()
WHERE id = ?
`, status, serviceName, servicePort, agentID)
return err
}
// HistoryRow is a single entry from agentHistory for sliding window memory.
type HistoryRow struct {
ID int `json:"id"`
UserMessage string `json:"userMessage"`
AgentResponse string `json:"agentResponse"`
ConvID string `json:"conversationId"`
}
// GetAgentHistory returns the last N conversation turns for an agent, oldest first.
func (d *DB) GetAgentHistory(agentID, limit int) ([]HistoryRow, error) {
if d.conn == nil {
return nil, nil
}
rows, err := d.conn.Query(`
SELECT id, userMessage, COALESCE(agentResponse,''), COALESCE(conversationId,'')
FROM agentHistory
WHERE agentId = ?
ORDER BY id DESC
LIMIT ?
`, agentID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var result []HistoryRow
for rows.Next() {
var h HistoryRow
if err := rows.Scan(&h.ID, &h.UserMessage, &h.AgentResponse, &h.ConvID); err != nil {
continue
}
result = append(result, h)
}
// Reverse so oldest is first (for LLM context ordering)
for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 {
result[i], result[j] = result[j], result[i]
}
return result, nil
}