From 24b5de37ecf6fe797952c3a2718af1c282a940ad Mon Sep 17 00:00:00 2001 From: Delicious233 <101502465+DeliciousBuding@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:48:06 +0800 Subject: [PATCH] =?UTF-8?q?refactor(hub):=20=E6=8B=86=E5=88=86=20agent=5Ft?= =?UTF-8?q?eam=20=E4=BB=93=E5=82=A8=E5=B9=B6=E4=B8=8B=E6=B2=89=20SafeGo?= =?UTF-8?q?=EF=BC=8C=E6=B8=85=E5=81=BF=20lint=20=E6=AC=A0=E8=B4=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三块低风险结构拆解,零行为变化: 1. repository/agent_team.go(564 行、49 个声明)按聚合拆为 7 个文件: teams / runs / assignments / tasks / artifacts / events / usage。 纯文件切分,声明对账 49/49 无丢失无重复。 2. middleware.SafeGo 下沉到 internal/safego 叶子包:解除 service 层 (agent_dispatch)对 HTTP 中间件包的依赖,service 目录现已不再 import middleware。新增 safego 冒烟测试(panic 恢复路径此前无直接 覆盖)。 3. 清偿 lint 欠账(ratchet 由 fail 转 PASS,43 findings 全部 baseline 注册): - 修复 5 处 QF1008(Dialector 嵌入选择器):agent_team_usage / audit / agent / message 两处——其中 audit/message 是拆分后从 max-same-issues 封顶下浮出的存量 findings - 重命名 client_auth_test.go 测试夹具常量 tokenHash → blacklistKey, 消除 3 处 G101 硬编码凭据误报 验证:go build/vet/staticcheck 全绿;go test ./... -short -race 通过; verify-hub-layering / verify-hub-pure-packages / outbound-client-hygiene / dto-contract / openapi-contract / config-domain-ssot / hub-lint-ratchet 全部 PASS。 Co-authored-by: Cursor --- hub-server/internal/cache/client_auth_test.go | 30 +- hub-server/internal/handler/ws.go | 3 +- hub-server/internal/repository/agent.go | 2 +- hub-server/internal/repository/agent_team.go | 564 ------------------ .../repository/agent_team_artifacts.go | 27 + .../repository/agent_team_assignments.go | 174 ++++++ .../internal/repository/agent_team_events.go | 106 ++++ .../internal/repository/agent_team_runs.go | 76 +++ .../internal/repository/agent_team_tasks.go | 44 ++ .../internal/repository/agent_team_teams.go | 77 +++ .../internal/repository/agent_team_usage.go | 103 ++++ hub-server/internal/repository/audit.go | 2 +- hub-server/internal/repository/message.go | 4 +- .../internal/{middleware => safego}/safego.go | 5 +- hub-server/internal/safego/safego_test.go | 23 + hub-server/internal/service/agent_dispatch.go | 6 +- 16 files changed, 658 insertions(+), 588 deletions(-) delete mode 100644 hub-server/internal/repository/agent_team.go create mode 100644 hub-server/internal/repository/agent_team_artifacts.go create mode 100644 hub-server/internal/repository/agent_team_assignments.go create mode 100644 hub-server/internal/repository/agent_team_events.go create mode 100644 hub-server/internal/repository/agent_team_runs.go create mode 100644 hub-server/internal/repository/agent_team_tasks.go create mode 100644 hub-server/internal/repository/agent_team_teams.go create mode 100644 hub-server/internal/repository/agent_team_usage.go rename hub-server/internal/{middleware => safego}/safego.go (83%) create mode 100644 hub-server/internal/safego/safego_test.go diff --git a/hub-server/internal/cache/client_auth_test.go b/hub-server/internal/cache/client_auth_test.go index e45fb02bd..225f25446 100644 --- a/hub-server/internal/cache/client_auth_test.go +++ b/hub-server/internal/cache/client_auth_test.go @@ -17,10 +17,10 @@ func TestBlacklistRefreshToken_ThenCheck_Hit(t *testing.T) { c, _ := testClient(t) ctx := context.Background() - const tokenHash = "rt-hash-hit-1" - require.NoError(t, c.BlacklistRefreshToken(ctx, tokenHash, 5*time.Minute)) + const blacklistKey = "rt-hash-hit-1" + require.NoError(t, c.BlacklistRefreshToken(ctx, blacklistKey, 5*time.Minute)) - hit, err := c.IsRefreshTokenBlacklisted(ctx, tokenHash) + hit, err := c.IsRefreshTokenBlacklisted(ctx, blacklistKey) require.NoError(t, err) assert.True(t, hit, "blacklisted refresh token must be reported as blacklisted") } @@ -43,13 +43,13 @@ func TestBlacklistRefreshToken_RevokeIsIdempotent(t *testing.T) { c, _ := testClient(t) ctx := context.Background() - const tokenHash = "rt-hash-idempotent" + const blacklistKey = "rt-hash-idempotent" // First revocation. - require.NoError(t, c.BlacklistRefreshToken(ctx, tokenHash, 5*time.Minute)) + require.NoError(t, c.BlacklistRefreshToken(ctx, blacklistKey, 5*time.Minute)) // Second revocation of the same hash must succeed (idempotent). - require.NoError(t, c.BlacklistRefreshToken(ctx, tokenHash, 5*time.Minute)) + require.NoError(t, c.BlacklistRefreshToken(ctx, blacklistKey, 5*time.Minute)) - hit, err := c.IsRefreshTokenBlacklisted(ctx, tokenHash) + hit, err := c.IsRefreshTokenBlacklisted(ctx, blacklistKey) require.NoError(t, err) assert.True(t, hit, "key must remain blacklisted after a repeat revoke") } @@ -62,16 +62,16 @@ func TestBlacklistRefreshToken_RevokeExtendsTTL(t *testing.T) { c, mr := testClient(t) ctx := context.Background() - const tokenHash = "rt-hash-extend" + const blacklistKey = "rt-hash-extend" // Initial revoke with a 2s TTL. - require.NoError(t, c.BlacklistRefreshToken(ctx, tokenHash, 2*time.Second)) + require.NoError(t, c.BlacklistRefreshToken(ctx, blacklistKey, 2*time.Second)) // Fast-forward 1s, then revoke again — the second call must reset the TTL. mr.FastForward(1 * time.Second) - require.NoError(t, c.BlacklistRefreshToken(ctx, tokenHash, 5*time.Second)) + require.NoError(t, c.BlacklistRefreshToken(ctx, blacklistKey, 5*time.Second)) // Fast-forward 3s: original 2s TTL would have expired at t=2s, but the // second revoke (at t=1s) reset it to 5s, so at t=4s the key must survive. mr.FastForward(3 * time.Second) - hit, err := c.IsRefreshTokenBlacklisted(ctx, tokenHash) + hit, err := c.IsRefreshTokenBlacklisted(ctx, blacklistKey) require.NoError(t, err) assert.True(t, hit, "repeat revoke must extend the blacklist TTL past the original expiry") } @@ -82,17 +82,17 @@ func TestBlacklistRefreshToken_Expires(t *testing.T) { c, mr := testClient(t) ctx := context.Background() - const tokenHash = "rt-hash-expire" - require.NoError(t, c.BlacklistRefreshToken(ctx, tokenHash, 1*time.Second)) + const blacklistKey = "rt-hash-expire" + require.NoError(t, c.BlacklistRefreshToken(ctx, blacklistKey, 1*time.Second)) // Present immediately. - hit, err := c.IsRefreshTokenBlacklisted(ctx, tokenHash) + hit, err := c.IsRefreshTokenBlacklisted(ctx, blacklistKey) require.NoError(t, err) assert.True(t, hit) // Fast-forward past the TTL. mr.FastForward(1100 * time.Millisecond) - hit, err = c.IsRefreshTokenBlacklisted(ctx, tokenHash) + hit, err = c.IsRefreshTokenBlacklisted(ctx, blacklistKey) require.NoError(t, err) assert.False(t, hit, "blacklist entry must expire after its TTL") } diff --git a/hub-server/internal/handler/ws.go b/hub-server/internal/handler/ws.go index 8a246bf1f..956b152f6 100644 --- a/hub-server/internal/handler/ws.go +++ b/hub-server/internal/handler/ws.go @@ -13,6 +13,7 @@ import ( "github.com/agenthub/hub-server/internal/metrics" "github.com/agenthub/hub-server/internal/middleware" + "github.com/agenthub/hub-server/internal/safego" "github.com/agenthub/hub-server/internal/ws" ) @@ -103,7 +104,7 @@ func (h *WebSocketHandler) ServeWS(c *gin.Context) { // seq_id=2 because auth.ok now consumes seq_id=1. go h.writeLoop(conn) h.manager.PushToConn(conn.ID, ws.NewFrame(ws.TypeAuthOK, nil)) - middleware.SafeGo("ws.readLoop", func() { + safego.SafeGo("ws.readLoop", func() { h.authenticatedReadLoop(conn) }) } diff --git a/hub-server/internal/repository/agent.go b/hub-server/internal/repository/agent.go index 3a0f627f1..01ea3c110 100644 --- a/hub-server/internal/repository/agent.go +++ b/hub-server/internal/repository/agent.go @@ -236,7 +236,7 @@ func FindActivePendingTaskByAgentInstance(db *gorm.DB, agentInstanceID string) ( // row-level FOR UPDATE lock; the SQLite fallback performs a no-op write so // integration tests exercise a real write lock. Mirrors LockTeamRunForUpdate (#1383). func LockAgentInstanceForUpdate(db *gorm.DB, agentInstanceID string) error { - if db.Dialector.Name() == "postgres" { + if db.Name() == "postgres" { var id string if err := db.Raw("SELECT id FROM agent_instances WHERE id = ? FOR UPDATE", agentInstanceID).Scan(&id).Error; err != nil { return err diff --git a/hub-server/internal/repository/agent_team.go b/hub-server/internal/repository/agent_team.go deleted file mode 100644 index a5633be6c..000000000 --- a/hub-server/internal/repository/agent_team.go +++ /dev/null @@ -1,564 +0,0 @@ -package repository - -import ( - "errors" - "strings" - "time" - - "gorm.io/gorm" - - "github.com/agenthub/hub-server/internal/model" -) - -// AgentTeam CRUD - -func CreateTeam(db *gorm.DB, team *model.AgentTeam) error { - return db.Create(team).Error -} - -func GetTeamByID(db *gorm.DB, id string) (*model.AgentTeam, error) { - var t model.AgentTeam - err := db.Where("id = ?", id).First(&t).Error - return &t, err -} - -func ListTeamsByOwner(db *gorm.DB, ownerID string) ([]model.AgentTeam, error) { - var teams []model.AgentTeam - err := db.Where("owner_id = ?", ownerID).Order("created_at DESC").Limit(200).Find(&teams).Error - return teams, err -} - -func ListTeamsReadableByUser(db *gorm.DB, userID string) ([]model.AgentTeam, error) { - var teams []model.AgentTeam - err := db.Table("agent_teams"). - Select("DISTINCT agent_teams.*"). - Joins("LEFT JOIN agent_team_members ON agent_team_members.team_id = agent_teams.id"). - Joins("LEFT JOIN custom_agents ON custom_agents.id = agent_team_members.agent_profile_id AND custom_agents.deleted_at IS NULL"). - Where("agent_teams.owner_id = ? OR custom_agents.owner_user_id = ?", userID, userID). - Order("agent_teams.created_at DESC"). - Limit(200). - Find(&teams).Error - return teams, err -} - -func TeamHasAgentOwnedByUser(db *gorm.DB, teamID, userID string) (bool, error) { - var count int64 - err := db.Table("agent_team_members"). - Joins("JOIN custom_agents ON custom_agents.id = agent_team_members.agent_profile_id AND custom_agents.deleted_at IS NULL"). - Where("agent_team_members.team_id = ? AND custom_agents.owner_user_id = ?", teamID, userID). - Count(&count).Error - return count > 0, err -} - -func UpdateTeam(db *gorm.DB, team *model.AgentTeam) error { - return db.Save(team).Error -} - -func DeleteTeam(db *gorm.DB, id string) error { - return db.Where("id = ?", id).Delete(&model.AgentTeam{}).Error -} - -// AgentTeamMember - -func AddTeamMember(db *gorm.DB, member *model.AgentTeamMember) error { - return db.Create(member).Error -} - -func RemoveTeamMember(db *gorm.DB, memberID string) error { - return db.Where("id = ?", memberID).Delete(&model.AgentTeamMember{}).Error -} - -func ListTeamMembers(db *gorm.DB, teamID string) ([]model.AgentTeamMember, error) { - var members []model.AgentTeamMember - err := db.Where("team_id = ?", teamID).Order("position ASC, created_at ASC").Find(&members).Error - return members, err -} - -func GetTeamMemberByID(db *gorm.DB, memberID string) (*model.AgentTeamMember, error) { - var m model.AgentTeamMember - err := db.Where("id = ?", memberID).First(&m).Error - return &m, err -} - -// AgentTeamRun - -func CreateTeamRun(db *gorm.DB, run *model.AgentTeamRun) error { - return db.Create(run).Error -} - -func GetTeamRunByID(db *gorm.DB, runID string) (*model.AgentTeamRun, error) { - var r model.AgentTeamRun - err := db.Where("id = ?", runID).First(&r).Error - return &r, err -} - -// LockTeamRunForUpdate serializes check-then-write operations for one run. -// PostgreSQL uses a row-level FOR UPDATE lock. The SQLite fallback performs a -// no-op update so integration tests exercise a real write lock as well. -func LockTeamRunForUpdate(db *gorm.DB, runID string) error { - if db.Dialector.Name() == "postgres" { - var id string - if err := db.Raw("SELECT id FROM agent_team_runs WHERE id = ? FOR UPDATE", runID).Scan(&id).Error; err != nil { - return err - } - if id == "" { - return gorm.ErrRecordNotFound - } - return nil - } - result := db.Model(&model.AgentTeamRun{}). - Where("id = ?", runID). - UpdateColumn("updated_at", gorm.Expr("updated_at")) - if result.Error != nil { - return result.Error - } - if result.RowsAffected == 0 { - return gorm.ErrRecordNotFound - } - return nil -} - -func GetTeamRunBySessionID(db *gorm.DB, sessionID string) (*model.AgentTeamRun, error) { - var r model.AgentTeamRun - err := db.Where("session_id = ?", sessionID).Order("created_at DESC").First(&r).Error - return &r, err -} - -func ListTeamRunsByTeam(db *gorm.DB, teamID string) ([]model.AgentTeamRun, error) { - var runs []model.AgentTeamRun - err := db.Where("team_id = ?", teamID).Order("created_at DESC").Limit(200).Find(&runs).Error - return runs, err -} - -func UpdateTeamRunStatus(db *gorm.DB, runID, status string) error { - return db.Model(&model.AgentTeamRun{}).Where("id = ?", runID).Update("status", status).Error -} - -// UpdateTeamRunStatusIfNotTerminal transitions a run's status only when the -// current status is not terminal (completed/failed/cancelled). The conditional -// WHERE makes the check-and-write atomic so a repeated or racing finish cannot -// downgrade a terminal outcome. Returns the number of rows updated (0 when the -// run was already terminal or does not exist). -func UpdateTeamRunStatusIfNotTerminal(db *gorm.DB, runID, status string) (int64, error) { - res := db.Model(&model.AgentTeamRun{}). - Where("id = ? AND status NOT IN (?, ?, ?)", runID, - model.TeamRunStatusCompleted, - model.TeamRunStatusFailed, - model.TeamRunStatusCancelled). - Update("status", status) - return res.RowsAffected, res.Error -} - -// AgentTeamAssignment - -func CreateAssignment(db *gorm.DB, a *model.AgentTeamAssignment) error { - return db.Create(a).Error -} - -func GetAssignmentByID(db *gorm.DB, id string) (*model.AgentTeamAssignment, error) { - var a model.AgentTeamAssignment - err := db.Where("id = ?", id).First(&a).Error - return &a, err -} - -func ListAssignmentsByTeamRun(db *gorm.DB, teamRunID string) ([]model.AgentTeamAssignment, error) { - var as []model.AgentTeamAssignment - err := db.Where("team_run_id = ?", teamRunID).Order("created_at ASC").Limit(500).Find(&as).Error - return as, err -} - -func CountAssignmentsByTeamRun(db *gorm.DB, teamRunID string) (int64, error) { - var count int64 - err := db.Model(&model.AgentTeamAssignment{}).Where("team_run_id = ?", teamRunID).Count(&count).Error - return count, err -} - -func UpdateAssignmentStatus(db *gorm.DB, id string, status string, result string) error { - updates := map[string]interface{}{ - "status": status, - "result": result, - } - return db.Model(&model.AgentTeamAssignment{}).Where("id = ?", id).Updates(updates).Error -} - -// UpdateAssignmentStatusIf transitions an assignment only when its current -// status is one of fromStatuses (CAS). Returns rows affected so callers can -// treat 0 as a lost race / already-terminal outcome (#1419). -func UpdateAssignmentStatusIf(db *gorm.DB, id string, fromStatuses []string, status string, result string) (int64, error) { - if len(fromStatuses) == 0 { - return 0, nil - } - updates := map[string]interface{}{ - "status": status, - "result": result, - } - res := db.Model(&model.AgentTeamAssignment{}). - Where("id = ? AND status IN ?", id, fromStatuses). - Updates(updates) - return res.RowsAffected, res.Error -} - -// ClaimAssignmentForDispatch atomically transitions a pending assignment to -// dispatched, but only when the current status is still 'pending' (CAS). The -// run_id is bound separately after TriggerAgentTask returns a pending task ID. -// Returns the number of rows updated: 1 on success, 0 when another caller -// already claimed it. The caller must interpret 0 rows as "already dispatched". -func ClaimAssignmentForDispatch(db *gorm.DB, id string) (int64, error) { - res := db.Model(&model.AgentTeamAssignment{}). - Where("id = ? AND status = ?", id, model.AssignmentStatusPending). - Update("status", model.AssignmentStatusDispatched) - return res.RowsAffected, res.Error -} - -// ReleaseAssignmentDispatchClaim reverts a pre-trigger claim only while it is -// still unbound. Once run_id is present the external trigger has succeeded and -// must never be made dispatchable again. -func ReleaseAssignmentDispatchClaim(db *gorm.DB, id string) (int64, error) { - res := db.Model(&model.AgentTeamAssignment{}). - Where("id = ? AND status = ? AND run_id IS NULL", id, model.AssignmentStatusDispatched). - Update("status", model.AssignmentStatusPending) - return res.RowsAffected, res.Error -} - -// BindClaimedAssignmentDispatch binds the external task only for the caller -// that owns an unbound dispatched claim. Status stays dispatched until -// MarkAssignmentRunningIfDispatched records that the task was handed off. -func BindClaimedAssignmentDispatch(db *gorm.DB, id, pendingTaskID string) (int64, error) { - res := db.Model(&model.AgentTeamAssignment{}). - Where("id = ? AND status = ? AND run_id IS NULL", id, model.AssignmentStatusDispatched). - Update("run_id", pendingTaskID) - return res.RowsAffected, res.Error -} - -// MarkAssignmentRunningIfDispatched advances a successfully bound assignment -// from dispatched → running. Requires run_id so an unbound claim cannot jump -// ahead of TriggerAgentTask. Returns rows affected (0 when already advanced -// or no longer dispatched). -func MarkAssignmentRunningIfDispatched(db *gorm.DB, id string) (int64, error) { - res := db.Model(&model.AgentTeamAssignment{}). - Where("id = ? AND status = ? AND run_id IS NOT NULL", id, model.AssignmentStatusDispatched). - Update("status", model.AssignmentStatusRunning) - return res.RowsAffected, res.Error -} - -func CountActiveAssignmentsByMember(db *gorm.DB, memberID string) (int64, error) { - var count int64 - err := db.Model(&model.AgentTeamAssignment{}). - Where("from_member_id = ? AND status IN (?, ?, ?)", memberID, - model.AssignmentStatusPending, - model.AssignmentStatusDispatched, - model.AssignmentStatusRunning). - Count(&count).Error - return count, err -} - -func CountActiveAssignmentsByTeamRun(db *gorm.DB, teamRunID string) (int64, error) { - var count int64 - err := db.Model(&model.AgentTeamAssignment{}). - Where("team_run_id = ? AND status IN (?, ?, ?)", teamRunID, - model.AssignmentStatusPending, - model.AssignmentStatusDispatched, - model.AssignmentStatusRunning). - Count(&count).Error - return count, err -} - -// GetAssignmentByToMember returns the most recent assignment where the given -// member was the target (to_member_id) within a team run. Used for ancestor chain walking. -func GetAssignmentByToMember(db *gorm.DB, teamRunID, toMemberID string) (*model.AgentTeamAssignment, error) { - var a model.AgentTeamAssignment - err := db.Where("team_run_id = ? AND to_member_id = ?", teamRunID, toMemberID). - Order("depth DESC").First(&a).Error - return &a, err -} - -// HasTimedOutActiveAssignment checks if any active assignment in the given -// team run has been running longer than the specified deadline. Uses a single -// SQL query with LIMIT 1 instead of fetching all assignments and filtering -// in Go (fixes N+1 pattern N6). -func HasTimedOutActiveAssignment(db *gorm.DB, teamRunID string, deadline time.Time) (bool, error) { - var count int64 - err := db.Model(&model.AgentTeamAssignment{}). - Where("team_run_id = ? AND status IN (?, ?, ?) AND created_at < ?", - teamRunID, - model.AssignmentStatusPending, - model.AssignmentStatusDispatched, - model.AssignmentStatusRunning, - deadline). - Limit(1). - Count(&count).Error - return count > 0, err -} - -// maxTimedOutAssignmentScan caps one background timeout sweep so a backlog -// cannot monopolize the scanner tick. -const maxTimedOutAssignmentScan = 200 - -// ListTimedOutActiveAssignments returns active assignments whose created_at is -// older than deadline, oldest first. Used by the background timeout terminator -// (the symmetric write-side of HasTimedOutActiveAssignment). -func ListTimedOutActiveAssignments(db *gorm.DB, deadline time.Time, limit int) ([]model.AgentTeamAssignment, error) { - if limit <= 0 || limit > maxTimedOutAssignmentScan { - limit = maxTimedOutAssignmentScan - } - var assignments []model.AgentTeamAssignment - err := db.Model(&model.AgentTeamAssignment{}). - Where("status IN (?, ?, ?) AND created_at < ?", - model.AssignmentStatusPending, - model.AssignmentStatusDispatched, - model.AssignmentStatusRunning, - deadline). - Order("created_at ASC"). - Limit(limit). - Find(&assignments).Error - return assignments, err -} - -// AgentTeamTask - -func CreateTeamTask(db *gorm.DB, task *model.AgentTeamTask) error { - if task.InputRefs == "" { - task.InputRefs = "{}" - } - if task.Attempt == 0 { - task.Attempt = 1 - } - if task.RiskLevel == "" { - task.RiskLevel = model.TeamTaskRiskNormal - } - if task.Status == "" { - task.Status = model.TeamTaskStatusPending - } - return db.Create(task).Error -} - -func ListTeamTasksByRun(db *gorm.DB, teamRunID string) ([]model.AgentTeamTask, error) { - var tasks []model.AgentTeamTask - err := db.Where("team_run_id = ?", teamRunID).Order("created_at ASC").Limit(500).Find(&tasks).Error - return tasks, err -} - -func GetTeamTaskByAssignmentID(db *gorm.DB, assignmentID string) (*model.AgentTeamTask, error) { - var task model.AgentTeamTask - err := db.Where("assignment_id = ?", assignmentID).First(&task).Error - return &task, err -} - -func UpdateTeamTaskDispatchBinding(db *gorm.DB, id, pendingTaskID string) error { - return db.Model(&model.AgentTeamTask{}).Where("id = ?", id).Updates(map[string]interface{}{ - "status": model.TeamTaskStatusDispatched, - "run_id": pendingTaskID, - }).Error -} - -// AgentTeamArtifact - -func ReplaceTeamArtifactsForRun(db *gorm.DB, teamRunID string, artifacts []model.AgentTeamArtifact) error { - return db.Transaction(func(tx *gorm.DB) error { - if err := tx.Where("team_run_id = ?", teamRunID).Delete(&model.AgentTeamArtifact{}).Error; err != nil { - return err - } - if len(artifacts) == 0 { - return nil - } - return tx.Create(&artifacts).Error - }) -} - -func ListTeamArtifactsByRun(db *gorm.DB, teamRunID string) ([]model.AgentTeamArtifact, error) { - var artifacts []model.AgentTeamArtifact - err := db.Where("team_run_id = ?", teamRunID).Order("created_at ASC, id ASC").Limit(500).Find(&artifacts).Error - return artifacts, err -} - -// AgentTeamEvent - -// appendTeamEventMaxAttempts bounds the defensive retry path after a -// (team_run_id, seq) unique-index conflict. PostgreSQL writers are serialized -// per run by lockTeamRunForEventAppend before reading MAX(seq), so ordinary -// bursts do not consume this budget; the retry remains useful for callers -// that bypassed the parent row lock and for other dialects. -const appendTeamEventMaxAttempts = 5 - -// isUniqueViolation reports whether err is a unique-constraint violation. -// Postgres surfaces SQLSTATE 23505 as "duplicate key value violates unique -// constraint"; SQLite (unit tests) reports "UNIQUE constraint failed". The -// substring match follows the existing isDuplicateKeyError convention in -// service/message. -func isUniqueViolation(err error) bool { - if err == nil { - return false - } - if errors.Is(err, gorm.ErrDuplicatedKey) { - return true - } - msg := strings.ToLower(err.Error()) - return strings.Contains(msg, "duplicate key") || strings.Contains(msg, "unique") -} - -// lockTeamRunForEventAppend serializes event sequence allocation for one run -// in production PostgreSQL. Locking the stable parent row before MAX(seq)+1 -// prevents a burst of concurrent appenders from repeatedly colliding on the -// unique index (where a fixed retry budget would otherwise shed writers). -// SQLite tests skip row locking and retain the unique-index retry fallback. -func lockTeamRunForEventAppend(tx *gorm.DB, teamRunID string) error { - if tx.Dialector.Name() != "postgres" { - return nil - } - var lockedID string - if err := tx.Raw( - "SELECT id FROM agent_team_runs WHERE id = ? FOR UPDATE", - teamRunID, - ).Scan(&lockedID).Error; err != nil { - return err - } - if lockedID == "" { - return gorm.ErrRecordNotFound - } - return nil -} - -// IncrementTeamRunTokenUsage atomically adds delta to the team run's -// token_usage_total counter. COALESCE maps a NULL column (run not yet -// incremented or not backfilled) to 0 so the first increment is a clean seed. -// Used by the edge stream callback to maintain the O(1) budget-guard fast -// path. Safe to call inside an enclosing transaction by passing tx as db. -// -// Uses raw db.Exec instead of gorm UpdateColumn because the model field is -// tagged read-only (->) so GORM omits it from INSERT/UPDATE column lists for -// backward compatibility with test fixtures that predate migration 0066. The -// raw UPDATE bypasses the struct field-permission check while still running -// inside the caller's transaction when tx is passed. -func IncrementTeamRunTokenUsage(db *gorm.DB, teamRunID string, delta int64) error { - if delta <= 0 { - return nil - } - return db.Exec( - "UPDATE agent_team_runs SET token_usage_total = COALESCE(token_usage_total, 0) + ? WHERE id = ?", - delta, teamRunID, - ).Error -} - -// BackfillTeamRunTokenUsage is an offline skeleton that populates -// token_usage_total for a single historical run from the existing event -// projection (agent run events → total tokens). It is the per-run primitive a -// future cmd/backfill command would invoke for every existing run row; it is -// NOT called from the hot path and is safe to run idempotently (the SET uses -// the projection total, not an increment, so re-running with the same events -// is a no-op). Returns the value written. -// -// Uses raw db.Exec for the same -> field-permission reason as -// IncrementTeamRunTokenUsage. -// -// This skeleton lives in the repository layer (in-lane) rather than a -// hub-server/cmd binary because the bounds of this lane do not include the -// cmd/ tree; a follow-up can wire a thin main that iterates ListTeamRunsByTeam -// and calls this per run. -func BackfillTeamRunTokenUsage(db *gorm.DB, teamRunID string, projectedTotal int64) (int64, error) { - res := db.Exec( - "UPDATE agent_team_runs SET token_usage_total = ? WHERE id = ?", - projectedTotal, teamRunID, - ) - return res.RowsAffected, res.Error -} - -// AppendTeamEvent appends an event with the next per-run seq. The MAX(seq)+1 -// read and the insert run in one transaction, and the unique index on -// (team_run_id, seq) turns a concurrent append racing the same seq into a -// unique violation instead of a silent duplicate; losing appenders retry with -// a freshly read MAX(seq). -func AppendTeamEvent(db *gorm.DB, event *model.AgentTeamEvent) error { - if event.Payload == "" { - event.Payload = "{}" - } - var lastErr error - for attempt := 0; attempt < appendTeamEventMaxAttempts; attempt++ { - err := db.Transaction(func(tx *gorm.DB) error { - if err := lockTeamRunForEventAppend(tx, event.TeamRunID); err != nil { - return err - } - var maxSeq int - if err := tx.Model(&model.AgentTeamEvent{}). - Where("team_run_id = ?", event.TeamRunID). - Select("COALESCE(MAX(seq), 0)"). - Scan(&maxSeq).Error; err != nil { - return err - } - event.Seq = maxSeq + 1 - return tx.Create(event).Error - }) - if err == nil { - return nil - } - if !isUniqueViolation(err) { - return err - } - lastErr = err - } - return lastErr -} - -// maxTeamEventsPerRun caps the number of team events returned by -// ListTeamEventsByRun. Team events are append-only and can grow -// unboundedly over a long-running team run. The cap prevents -// unbounded memory consumption while being high enough for realistic -// team runs (1000 events = ~1-2 MB payload). -const maxTeamEventsPerRun = 10000 - -func ListTeamEventsByRun(db *gorm.DB, teamRunID string) ([]model.AgentTeamEvent, error) { - var events []model.AgentTeamEvent - err := db.Where("team_run_id = ?", teamRunID).Order("seq ASC, created_at ASC").Limit(maxTeamEventsPerRun).Find(&events).Error - return events, err -} - -// CountTeamRouteDecisionsByActionWorkerInstructions counts prior accepted -// route decisions (event type team.route.decided) whose payload matches the -// given action / next_worker / instructions triple using the SAME -// normalization as routeDecisionMatches: -// - action: case-insensitive, whitespace-trimmed -// - next_worker: case-sensitive, whitespace-trimmed (missing key → "") -// - instructions: case-sensitive, whitespace-trimmed (missing key → "") -// -// This is the SQL-aggregated counterpart of countMatchingRouteDecisionsInEvents -// (route_helpers.go). It replaces the previous countMatchingRouteDecisionsDB -// path which loaded up to maxTeamEventsPerRun (10000) rows via -// ListTeamEventsByRun and filtered in Go — the SQL aggregation pushes the -// filter into the DB so only the matching count crosses the wire. -// -// Dialect branches: -// - PostgreSQL: JSONB payload->>'field' + BTRIM + LOWER. -// - SQLite: json_extract(payload, '$.field') + TRIM + LOWER (unit tests). -// -// COALESCE(...,”) maps a missing JSON key to ” so the match mirrors Go's -// zero-value unmarshal semantics (missing next_worker → ""). -func CountTeamRouteDecisionsByActionWorkerInstructions(db *gorm.DB, teamRunID, action, worker, instructions string) (int, error) { - const eventType = model.TeamEventRouteDecided - var count int - - if db.Dialector.Name() == "postgres" { - // JSONB payload->>'field' returns TEXT; BTRIM trims both-side - // whitespace; LOWER folds action case for the case-insensitive arm. - // COALESCE maps NULL (missing key) to '' so a finish decision - // (no next_worker) matches another finish decision. - err := db.Raw(`SELECT COUNT(*) FROM agent_team_events -WHERE team_run_id = ? - AND type = ? - AND LOWER(BTRIM(COALESCE(payload->>'action','')) ) = LOWER(BTRIM(?)) - AND BTRIM(COALESCE(payload->>'next_worker','')) = BTRIM(?) - AND BTRIM(COALESCE(payload->>'instructions','')) = BTRIM(?)`, - teamRunID, eventType, action, worker, instructions, - ).Scan(&count).Error - return count, err - } - - // SQLite (unit tests): json_extract + TRIM + LOWER, same semantics. - err := db.Raw(`SELECT COUNT(*) FROM agent_team_events -WHERE team_run_id = ? - AND type = ? - AND LOWER(TRIM(COALESCE(json_extract(payload,'$.action'),'')) ) = LOWER(TRIM(?)) - AND TRIM(COALESCE(json_extract(payload,'$.next_worker'),'')) = TRIM(?) - AND TRIM(COALESCE(json_extract(payload,'$.instructions'),'')) = TRIM(?)`, - teamRunID, eventType, action, worker, instructions, - ).Scan(&count).Error - return count, err -} diff --git a/hub-server/internal/repository/agent_team_artifacts.go b/hub-server/internal/repository/agent_team_artifacts.go new file mode 100644 index 000000000..c80d76f90 --- /dev/null +++ b/hub-server/internal/repository/agent_team_artifacts.go @@ -0,0 +1,27 @@ +package repository + +import ( + "gorm.io/gorm" + + "github.com/agenthub/hub-server/internal/model" +) + +// AgentTeamArtifact + +func ReplaceTeamArtifactsForRun(db *gorm.DB, teamRunID string, artifacts []model.AgentTeamArtifact) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("team_run_id = ?", teamRunID).Delete(&model.AgentTeamArtifact{}).Error; err != nil { + return err + } + if len(artifacts) == 0 { + return nil + } + return tx.Create(&artifacts).Error + }) +} + +func ListTeamArtifactsByRun(db *gorm.DB, teamRunID string) ([]model.AgentTeamArtifact, error) { + var artifacts []model.AgentTeamArtifact + err := db.Where("team_run_id = ?", teamRunID).Order("created_at ASC, id ASC").Limit(500).Find(&artifacts).Error + return artifacts, err +} diff --git a/hub-server/internal/repository/agent_team_assignments.go b/hub-server/internal/repository/agent_team_assignments.go new file mode 100644 index 000000000..54e49c6ea --- /dev/null +++ b/hub-server/internal/repository/agent_team_assignments.go @@ -0,0 +1,174 @@ +package repository + +import ( + "time" + + "gorm.io/gorm" + + "github.com/agenthub/hub-server/internal/model" +) + +// AgentTeamAssignment + +func CreateAssignment(db *gorm.DB, a *model.AgentTeamAssignment) error { + return db.Create(a).Error +} + +func GetAssignmentByID(db *gorm.DB, id string) (*model.AgentTeamAssignment, error) { + var a model.AgentTeamAssignment + err := db.Where("id = ?", id).First(&a).Error + return &a, err +} + +func ListAssignmentsByTeamRun(db *gorm.DB, teamRunID string) ([]model.AgentTeamAssignment, error) { + var as []model.AgentTeamAssignment + err := db.Where("team_run_id = ?", teamRunID).Order("created_at ASC").Limit(500).Find(&as).Error + return as, err +} + +func CountAssignmentsByTeamRun(db *gorm.DB, teamRunID string) (int64, error) { + var count int64 + err := db.Model(&model.AgentTeamAssignment{}).Where("team_run_id = ?", teamRunID).Count(&count).Error + return count, err +} + +func UpdateAssignmentStatus(db *gorm.DB, id string, status string, result string) error { + updates := map[string]interface{}{ + "status": status, + "result": result, + } + return db.Model(&model.AgentTeamAssignment{}).Where("id = ?", id).Updates(updates).Error +} + +// UpdateAssignmentStatusIf transitions an assignment only when its current +// status is one of fromStatuses (CAS). Returns rows affected so callers can +// treat 0 as a lost race / already-terminal outcome (#1419). +func UpdateAssignmentStatusIf(db *gorm.DB, id string, fromStatuses []string, status string, result string) (int64, error) { + if len(fromStatuses) == 0 { + return 0, nil + } + updates := map[string]interface{}{ + "status": status, + "result": result, + } + res := db.Model(&model.AgentTeamAssignment{}). + Where("id = ? AND status IN ?", id, fromStatuses). + Updates(updates) + return res.RowsAffected, res.Error +} + +// ClaimAssignmentForDispatch atomically transitions a pending assignment to +// dispatched, but only when the current status is still 'pending' (CAS). The +// run_id is bound separately after TriggerAgentTask returns a pending task ID. +// Returns the number of rows updated: 1 on success, 0 when another caller +// already claimed it. The caller must interpret 0 rows as "already dispatched". +func ClaimAssignmentForDispatch(db *gorm.DB, id string) (int64, error) { + res := db.Model(&model.AgentTeamAssignment{}). + Where("id = ? AND status = ?", id, model.AssignmentStatusPending). + Update("status", model.AssignmentStatusDispatched) + return res.RowsAffected, res.Error +} + +// ReleaseAssignmentDispatchClaim reverts a pre-trigger claim only while it is +// still unbound. Once run_id is present the external trigger has succeeded and +// must never be made dispatchable again. +func ReleaseAssignmentDispatchClaim(db *gorm.DB, id string) (int64, error) { + res := db.Model(&model.AgentTeamAssignment{}). + Where("id = ? AND status = ? AND run_id IS NULL", id, model.AssignmentStatusDispatched). + Update("status", model.AssignmentStatusPending) + return res.RowsAffected, res.Error +} + +// BindClaimedAssignmentDispatch binds the external task only for the caller +// that owns an unbound dispatched claim. Status stays dispatched until +// MarkAssignmentRunningIfDispatched records that the task was handed off. +func BindClaimedAssignmentDispatch(db *gorm.DB, id, pendingTaskID string) (int64, error) { + res := db.Model(&model.AgentTeamAssignment{}). + Where("id = ? AND status = ? AND run_id IS NULL", id, model.AssignmentStatusDispatched). + Update("run_id", pendingTaskID) + return res.RowsAffected, res.Error +} + +// MarkAssignmentRunningIfDispatched advances a successfully bound assignment +// from dispatched → running. Requires run_id so an unbound claim cannot jump +// ahead of TriggerAgentTask. Returns rows affected (0 when already advanced +// or no longer dispatched). +func MarkAssignmentRunningIfDispatched(db *gorm.DB, id string) (int64, error) { + res := db.Model(&model.AgentTeamAssignment{}). + Where("id = ? AND status = ? AND run_id IS NOT NULL", id, model.AssignmentStatusDispatched). + Update("status", model.AssignmentStatusRunning) + return res.RowsAffected, res.Error +} + +func CountActiveAssignmentsByMember(db *gorm.DB, memberID string) (int64, error) { + var count int64 + err := db.Model(&model.AgentTeamAssignment{}). + Where("from_member_id = ? AND status IN (?, ?, ?)", memberID, + model.AssignmentStatusPending, + model.AssignmentStatusDispatched, + model.AssignmentStatusRunning). + Count(&count).Error + return count, err +} + +func CountActiveAssignmentsByTeamRun(db *gorm.DB, teamRunID string) (int64, error) { + var count int64 + err := db.Model(&model.AgentTeamAssignment{}). + Where("team_run_id = ? AND status IN (?, ?, ?)", teamRunID, + model.AssignmentStatusPending, + model.AssignmentStatusDispatched, + model.AssignmentStatusRunning). + Count(&count).Error + return count, err +} + +// GetAssignmentByToMember returns the most recent assignment where the given +// member was the target (to_member_id) within a team run. Used for ancestor chain walking. +func GetAssignmentByToMember(db *gorm.DB, teamRunID, toMemberID string) (*model.AgentTeamAssignment, error) { + var a model.AgentTeamAssignment + err := db.Where("team_run_id = ? AND to_member_id = ?", teamRunID, toMemberID). + Order("depth DESC").First(&a).Error + return &a, err +} + +// HasTimedOutActiveAssignment checks if any active assignment in the given +// team run has been running longer than the specified deadline. Uses a single +// SQL query with LIMIT 1 instead of fetching all assignments and filtering +// in Go (fixes N+1 pattern N6). +func HasTimedOutActiveAssignment(db *gorm.DB, teamRunID string, deadline time.Time) (bool, error) { + var count int64 + err := db.Model(&model.AgentTeamAssignment{}). + Where("team_run_id = ? AND status IN (?, ?, ?) AND created_at < ?", + teamRunID, + model.AssignmentStatusPending, + model.AssignmentStatusDispatched, + model.AssignmentStatusRunning, + deadline). + Limit(1). + Count(&count).Error + return count > 0, err +} + +// maxTimedOutAssignmentScan caps one background timeout sweep so a backlog +// cannot monopolize the scanner tick. +const maxTimedOutAssignmentScan = 200 + +// ListTimedOutActiveAssignments returns active assignments whose created_at is +// older than deadline, oldest first. Used by the background timeout terminator +// (the symmetric write-side of HasTimedOutActiveAssignment). +func ListTimedOutActiveAssignments(db *gorm.DB, deadline time.Time, limit int) ([]model.AgentTeamAssignment, error) { + if limit <= 0 || limit > maxTimedOutAssignmentScan { + limit = maxTimedOutAssignmentScan + } + var assignments []model.AgentTeamAssignment + err := db.Model(&model.AgentTeamAssignment{}). + Where("status IN (?, ?, ?) AND created_at < ?", + model.AssignmentStatusPending, + model.AssignmentStatusDispatched, + model.AssignmentStatusRunning, + deadline). + Order("created_at ASC"). + Limit(limit). + Find(&assignments).Error + return assignments, err +} diff --git a/hub-server/internal/repository/agent_team_events.go b/hub-server/internal/repository/agent_team_events.go new file mode 100644 index 000000000..3babae17d --- /dev/null +++ b/hub-server/internal/repository/agent_team_events.go @@ -0,0 +1,106 @@ +package repository + +import ( + "errors" + "strings" + + "gorm.io/gorm" + + "github.com/agenthub/hub-server/internal/model" +) + +// AgentTeamEvent + +// appendTeamEventMaxAttempts bounds the defensive retry path after a +// (team_run_id, seq) unique-index conflict. PostgreSQL writers are serialized +// per run by lockTeamRunForEventAppend before reading MAX(seq), so ordinary +// bursts do not consume this budget; the retry remains useful for callers +// that bypassed the parent row lock and for other dialects. +const appendTeamEventMaxAttempts = 5 + +// isUniqueViolation reports whether err is a unique-constraint violation. +// Postgres surfaces SQLSTATE 23505 as "duplicate key value violates unique +// constraint"; SQLite (unit tests) reports "UNIQUE constraint failed". The +// substring match follows the existing isDuplicateKeyError convention in +// service/message. +func isUniqueViolation(err error) bool { + if err == nil { + return false + } + if errors.Is(err, gorm.ErrDuplicatedKey) { + return true + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "duplicate key") || strings.Contains(msg, "unique") +} + +// lockTeamRunForEventAppend serializes event sequence allocation for one run +// in production PostgreSQL. Locking the stable parent row before MAX(seq)+1 +// prevents a burst of concurrent appenders from repeatedly colliding on the +// unique index (where a fixed retry budget would otherwise shed writers). +// SQLite tests skip row locking and retain the unique-index retry fallback. +func lockTeamRunForEventAppend(tx *gorm.DB, teamRunID string) error { + if tx.Name() != "postgres" { + return nil + } + var lockedID string + if err := tx.Raw( + "SELECT id FROM agent_team_runs WHERE id = ? FOR UPDATE", + teamRunID, + ).Scan(&lockedID).Error; err != nil { + return err + } + if lockedID == "" { + return gorm.ErrRecordNotFound + } + return nil +} + +// AppendTeamEvent appends an event with the next per-run seq. The MAX(seq)+1 +// read and the insert run in one transaction, and the unique index on +// (team_run_id, seq) turns a concurrent append racing the same seq into a +// unique violation instead of a silent duplicate; losing appenders retry with +// a freshly read MAX(seq). +func AppendTeamEvent(db *gorm.DB, event *model.AgentTeamEvent) error { + if event.Payload == "" { + event.Payload = "{}" + } + var lastErr error + for attempt := 0; attempt < appendTeamEventMaxAttempts; attempt++ { + err := db.Transaction(func(tx *gorm.DB) error { + if err := lockTeamRunForEventAppend(tx, event.TeamRunID); err != nil { + return err + } + var maxSeq int + if err := tx.Model(&model.AgentTeamEvent{}). + Where("team_run_id = ?", event.TeamRunID). + Select("COALESCE(MAX(seq), 0)"). + Scan(&maxSeq).Error; err != nil { + return err + } + event.Seq = maxSeq + 1 + return tx.Create(event).Error + }) + if err == nil { + return nil + } + if !isUniqueViolation(err) { + return err + } + lastErr = err + } + return lastErr +} + +// maxTeamEventsPerRun caps the number of team events returned by +// ListTeamEventsByRun. Team events are append-only and can grow +// unboundedly over a long-running team run. The cap prevents +// unbounded memory consumption while being high enough for realistic +// team runs (1000 events = ~1-2 MB payload). +const maxTeamEventsPerRun = 10000 + +func ListTeamEventsByRun(db *gorm.DB, teamRunID string) ([]model.AgentTeamEvent, error) { + var events []model.AgentTeamEvent + err := db.Where("team_run_id = ?", teamRunID).Order("seq ASC, created_at ASC").Limit(maxTeamEventsPerRun).Find(&events).Error + return events, err +} diff --git a/hub-server/internal/repository/agent_team_runs.go b/hub-server/internal/repository/agent_team_runs.go new file mode 100644 index 000000000..8bc62cdeb --- /dev/null +++ b/hub-server/internal/repository/agent_team_runs.go @@ -0,0 +1,76 @@ +package repository + +import ( + "gorm.io/gorm" + + "github.com/agenthub/hub-server/internal/model" +) + +// AgentTeamRun + +func CreateTeamRun(db *gorm.DB, run *model.AgentTeamRun) error { + return db.Create(run).Error +} + +func GetTeamRunByID(db *gorm.DB, runID string) (*model.AgentTeamRun, error) { + var r model.AgentTeamRun + err := db.Where("id = ?", runID).First(&r).Error + return &r, err +} + +// LockTeamRunForUpdate serializes check-then-write operations for one run. +// PostgreSQL uses a row-level FOR UPDATE lock. The SQLite fallback performs a +// no-op update so integration tests exercise a real write lock as well. +func LockTeamRunForUpdate(db *gorm.DB, runID string) error { + if db.Name() == "postgres" { + var id string + if err := db.Raw("SELECT id FROM agent_team_runs WHERE id = ? FOR UPDATE", runID).Scan(&id).Error; err != nil { + return err + } + if id == "" { + return gorm.ErrRecordNotFound + } + return nil + } + result := db.Model(&model.AgentTeamRun{}). + Where("id = ?", runID). + UpdateColumn("updated_at", gorm.Expr("updated_at")) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +func GetTeamRunBySessionID(db *gorm.DB, sessionID string) (*model.AgentTeamRun, error) { + var r model.AgentTeamRun + err := db.Where("session_id = ?", sessionID).Order("created_at DESC").First(&r).Error + return &r, err +} + +func ListTeamRunsByTeam(db *gorm.DB, teamID string) ([]model.AgentTeamRun, error) { + var runs []model.AgentTeamRun + err := db.Where("team_id = ?", teamID).Order("created_at DESC").Limit(200).Find(&runs).Error + return runs, err +} + +func UpdateTeamRunStatus(db *gorm.DB, runID, status string) error { + return db.Model(&model.AgentTeamRun{}).Where("id = ?", runID).Update("status", status).Error +} + +// UpdateTeamRunStatusIfNotTerminal transitions a run's status only when the +// current status is not terminal (completed/failed/cancelled). The conditional +// WHERE makes the check-and-write atomic so a repeated or racing finish cannot +// downgrade a terminal outcome. Returns the number of rows updated (0 when the +// run was already terminal or does not exist). +func UpdateTeamRunStatusIfNotTerminal(db *gorm.DB, runID, status string) (int64, error) { + res := db.Model(&model.AgentTeamRun{}). + Where("id = ? AND status NOT IN (?, ?, ?)", runID, + model.TeamRunStatusCompleted, + model.TeamRunStatusFailed, + model.TeamRunStatusCancelled). + Update("status", status) + return res.RowsAffected, res.Error +} diff --git a/hub-server/internal/repository/agent_team_tasks.go b/hub-server/internal/repository/agent_team_tasks.go new file mode 100644 index 000000000..e4b886bdb --- /dev/null +++ b/hub-server/internal/repository/agent_team_tasks.go @@ -0,0 +1,44 @@ +package repository + +import ( + "gorm.io/gorm" + + "github.com/agenthub/hub-server/internal/model" +) + +// AgentTeamTask + +func CreateTeamTask(db *gorm.DB, task *model.AgentTeamTask) error { + if task.InputRefs == "" { + task.InputRefs = "{}" + } + if task.Attempt == 0 { + task.Attempt = 1 + } + if task.RiskLevel == "" { + task.RiskLevel = model.TeamTaskRiskNormal + } + if task.Status == "" { + task.Status = model.TeamTaskStatusPending + } + return db.Create(task).Error +} + +func ListTeamTasksByRun(db *gorm.DB, teamRunID string) ([]model.AgentTeamTask, error) { + var tasks []model.AgentTeamTask + err := db.Where("team_run_id = ?", teamRunID).Order("created_at ASC").Limit(500).Find(&tasks).Error + return tasks, err +} + +func GetTeamTaskByAssignmentID(db *gorm.DB, assignmentID string) (*model.AgentTeamTask, error) { + var task model.AgentTeamTask + err := db.Where("assignment_id = ?", assignmentID).First(&task).Error + return &task, err +} + +func UpdateTeamTaskDispatchBinding(db *gorm.DB, id, pendingTaskID string) error { + return db.Model(&model.AgentTeamTask{}).Where("id = ?", id).Updates(map[string]interface{}{ + "status": model.TeamTaskStatusDispatched, + "run_id": pendingTaskID, + }).Error +} diff --git a/hub-server/internal/repository/agent_team_teams.go b/hub-server/internal/repository/agent_team_teams.go new file mode 100644 index 000000000..875d969fa --- /dev/null +++ b/hub-server/internal/repository/agent_team_teams.go @@ -0,0 +1,77 @@ +package repository + +import ( + "gorm.io/gorm" + + "github.com/agenthub/hub-server/internal/model" +) + +// AgentTeam CRUD + +func CreateTeam(db *gorm.DB, team *model.AgentTeam) error { + return db.Create(team).Error +} + +func GetTeamByID(db *gorm.DB, id string) (*model.AgentTeam, error) { + var t model.AgentTeam + err := db.Where("id = ?", id).First(&t).Error + return &t, err +} + +func ListTeamsByOwner(db *gorm.DB, ownerID string) ([]model.AgentTeam, error) { + var teams []model.AgentTeam + err := db.Where("owner_id = ?", ownerID).Order("created_at DESC").Limit(200).Find(&teams).Error + return teams, err +} + +func ListTeamsReadableByUser(db *gorm.DB, userID string) ([]model.AgentTeam, error) { + var teams []model.AgentTeam + err := db.Table("agent_teams"). + Select("DISTINCT agent_teams.*"). + Joins("LEFT JOIN agent_team_members ON agent_team_members.team_id = agent_teams.id"). + Joins("LEFT JOIN custom_agents ON custom_agents.id = agent_team_members.agent_profile_id AND custom_agents.deleted_at IS NULL"). + Where("agent_teams.owner_id = ? OR custom_agents.owner_user_id = ?", userID, userID). + Order("agent_teams.created_at DESC"). + Limit(200). + Find(&teams).Error + return teams, err +} + +func TeamHasAgentOwnedByUser(db *gorm.DB, teamID, userID string) (bool, error) { + var count int64 + err := db.Table("agent_team_members"). + Joins("JOIN custom_agents ON custom_agents.id = agent_team_members.agent_profile_id AND custom_agents.deleted_at IS NULL"). + Where("agent_team_members.team_id = ? AND custom_agents.owner_user_id = ?", teamID, userID). + Count(&count).Error + return count > 0, err +} + +func UpdateTeam(db *gorm.DB, team *model.AgentTeam) error { + return db.Save(team).Error +} + +func DeleteTeam(db *gorm.DB, id string) error { + return db.Where("id = ?", id).Delete(&model.AgentTeam{}).Error +} + +// AgentTeamMember + +func AddTeamMember(db *gorm.DB, member *model.AgentTeamMember) error { + return db.Create(member).Error +} + +func RemoveTeamMember(db *gorm.DB, memberID string) error { + return db.Where("id = ?", memberID).Delete(&model.AgentTeamMember{}).Error +} + +func ListTeamMembers(db *gorm.DB, teamID string) ([]model.AgentTeamMember, error) { + var members []model.AgentTeamMember + err := db.Where("team_id = ?", teamID).Order("position ASC, created_at ASC").Find(&members).Error + return members, err +} + +func GetTeamMemberByID(db *gorm.DB, memberID string) (*model.AgentTeamMember, error) { + var m model.AgentTeamMember + err := db.Where("id = ?", memberID).First(&m).Error + return &m, err +} diff --git a/hub-server/internal/repository/agent_team_usage.go b/hub-server/internal/repository/agent_team_usage.go new file mode 100644 index 000000000..ba157665f --- /dev/null +++ b/hub-server/internal/repository/agent_team_usage.go @@ -0,0 +1,103 @@ +package repository + +import ( + "gorm.io/gorm" + + "github.com/agenthub/hub-server/internal/model" +) + +// IncrementTeamRunTokenUsage atomically adds delta to the team run's +// token_usage_total counter. COALESCE maps a NULL column (run not yet +// incremented or not backfilled) to 0 so the first increment is a clean seed. +// Used by the edge stream callback to maintain the O(1) budget-guard fast +// path. Safe to call inside an enclosing transaction by passing tx as db. +// +// Uses raw db.Exec instead of gorm UpdateColumn because the model field is +// tagged read-only (->) so GORM omits it from INSERT/UPDATE column lists for +// backward compatibility with test fixtures that predate migration 0066. The +// raw UPDATE bypasses the struct field-permission check while still running +// inside the caller's transaction when tx is passed. +func IncrementTeamRunTokenUsage(db *gorm.DB, teamRunID string, delta int64) error { + if delta <= 0 { + return nil + } + return db.Exec( + "UPDATE agent_team_runs SET token_usage_total = COALESCE(token_usage_total, 0) + ? WHERE id = ?", + delta, teamRunID, + ).Error +} + +// BackfillTeamRunTokenUsage is an offline skeleton that populates +// token_usage_total for a single historical run from the existing event +// projection (agent run events → total tokens). It is the per-run primitive a +// future cmd/backfill command would invoke for every existing run row; it is +// NOT called from the hot path and is safe to run idempotently (the SET uses +// the projection total, not an increment, so re-running with the same events +// is a no-op). Returns the value written. +// +// Uses raw db.Exec for the same -> field-permission reason as +// IncrementTeamRunTokenUsage. +// +// This skeleton lives in the repository layer (in-lane) rather than a +// hub-server/cmd binary because the bounds of this lane do not include the +// cmd/ tree; a follow-up can wire a thin main that iterates ListTeamRunsByTeam +// and calls this per run. +func BackfillTeamRunTokenUsage(db *gorm.DB, teamRunID string, projectedTotal int64) (int64, error) { + res := db.Exec( + "UPDATE agent_team_runs SET token_usage_total = ? WHERE id = ?", + projectedTotal, teamRunID, + ) + return res.RowsAffected, res.Error +} + +// CountTeamRouteDecisionsByActionWorkerInstructions counts prior accepted +// route decisions (event type team.route.decided) whose payload matches the +// given action / next_worker / instructions triple using the SAME +// normalization as routeDecisionMatches: +// - action: case-insensitive, whitespace-trimmed +// - next_worker: case-sensitive, whitespace-trimmed (missing key → "") +// - instructions: case-sensitive, whitespace-trimmed (missing key → "") +// +// This is the SQL-aggregated counterpart of countMatchingRouteDecisionsInEvents +// (route_helpers.go). It replaces the previous countMatchingRouteDecisionsDB +// path which loaded up to maxTeamEventsPerRun (10000) rows via +// ListTeamEventsByRun and filtered in Go — the SQL aggregation pushes the +// filter into the DB so only the matching count crosses the wire. +// +// Dialect branches: +// - PostgreSQL: JSONB payload->>'field' + BTRIM + LOWER. +// - SQLite: json_extract(payload, '$.field') + TRIM + LOWER (unit tests). +// +// COALESCE(...,”) maps a missing JSON key to ” so the match mirrors Go's +// zero-value unmarshal semantics (missing next_worker → ""). +func CountTeamRouteDecisionsByActionWorkerInstructions(db *gorm.DB, teamRunID, action, worker, instructions string) (int, error) { + const eventType = model.TeamEventRouteDecided + var count int + + if db.Name() == "postgres" { + // JSONB payload->>'field' returns TEXT; BTRIM trims both-side + // whitespace; LOWER folds action case for the case-insensitive arm. + // COALESCE maps NULL (missing key) to '' so a finish decision + // (no next_worker) matches another finish decision. + err := db.Raw(`SELECT COUNT(*) FROM agent_team_events +WHERE team_run_id = ? + AND type = ? + AND LOWER(BTRIM(COALESCE(payload->>'action','')) ) = LOWER(BTRIM(?)) + AND BTRIM(COALESCE(payload->>'next_worker','')) = BTRIM(?) + AND BTRIM(COALESCE(payload->>'instructions','')) = BTRIM(?)`, + teamRunID, eventType, action, worker, instructions, + ).Scan(&count).Error + return count, err + } + + // SQLite (unit tests): json_extract + TRIM + LOWER, same semantics. + err := db.Raw(`SELECT COUNT(*) FROM agent_team_events +WHERE team_run_id = ? + AND type = ? + AND LOWER(TRIM(COALESCE(json_extract(payload,'$.action'),'')) ) = LOWER(TRIM(?)) + AND TRIM(COALESCE(json_extract(payload,'$.next_worker'),'')) = TRIM(?) + AND TRIM(COALESCE(json_extract(payload,'$.instructions'),'')) = TRIM(?)`, + teamRunID, eventType, action, worker, instructions, + ).Scan(&count).Error + return count, err +} diff --git a/hub-server/internal/repository/audit.go b/hub-server/internal/repository/audit.go index d2e487826..41bd7eaf5 100644 --- a/hub-server/internal/repository/audit.go +++ b/hub-server/internal/repository/audit.go @@ -45,7 +45,7 @@ func createAuditEventOnce(db *gorm.DB, event *model.AuditEvent) error { // Advisory xact lock: serialize all chain writers (multi-instance // safe). Not available on sqlite (unit tests) — the integration // lane exercises the PostgreSQL path. - if tx.Dialector.Name() == "postgres" { + if tx.Name() == "postgres" { if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", auditChainAdvisoryLockKey).Error; err != nil { return err } diff --git a/hub-server/internal/repository/message.go b/hub-server/internal/repository/message.go index aecfd4af2..05339943d 100644 --- a/hub-server/internal/repository/message.go +++ b/hub-server/internal/repository/message.go @@ -104,7 +104,7 @@ func PinMessageAtomic(db *gorm.DB, pin *model.MessagePin, maxPins int64) error { return db.Transaction(func(tx *gorm.DB) error { // Lock the session row to serialize concurrent pin operations. // PostgreSQL uses FOR UPDATE; SQLite (tests) skips row locking. - if tx.Dialector.Name() == "postgres" { + if tx.Name() == "postgres" { var sessionID string if err := tx.Raw("SELECT id FROM sessions WHERE id = ? FOR UPDATE", pin.SessionID).Scan(&sessionID).Error; err != nil { return err @@ -172,7 +172,7 @@ func escapeILIKE(s string) string { } func messageSearchCondition(db *gorm.DB, tableAlias, q string) (string, []interface{}) { - if db.Dialector.Name() == "postgres" { + if db.Name() == "postgres" { textExpr := postgresMessageTextExpression(tableAlias) return "(to_tsvector('simple', COALESCE(" + textExpr + ", '')) @@ plainto_tsquery('simple', ?) OR " + textExpr + " ILIKE ? ESCAPE '\\')", []interface{}{q, "%" + escapeILIKE(q) + "%"} diff --git a/hub-server/internal/middleware/safego.go b/hub-server/internal/safego/safego.go similarity index 83% rename from hub-server/internal/middleware/safego.go rename to hub-server/internal/safego/safego.go index 4c5d14784..35668e748 100644 --- a/hub-server/internal/middleware/safego.go +++ b/hub-server/internal/safego/safego.go @@ -1,4 +1,7 @@ -package middleware +// Package safego provides SafeGo, the panic-recovering goroutine +// launcher used by handlers and services. It lives in its own leaf package +// so the business layer never depends on the HTTP middleware package. +package safego import ( "log/slog" diff --git a/hub-server/internal/safego/safego_test.go b/hub-server/internal/safego/safego_test.go new file mode 100644 index 000000000..e144e9f95 --- /dev/null +++ b/hub-server/internal/safego/safego_test.go @@ -0,0 +1,23 @@ +package safego + +import "testing" + +// TestSafeGoRecoversPanic verifies the panic-recovering goroutine launcher: +// without the recover, this test would crash the whole test process. +func TestSafeGoRecoversPanic(t *testing.T) { + done := make(chan struct{}) + SafeGo("test.panic", func() { + defer close(done) + panic("boom") + }) + <-done +} + +// TestSafeGoRunsFunctionNormally verifies the success path completes. +func TestSafeGoRunsFunctionNormally(t *testing.T) { + done := make(chan struct{}) + SafeGo("test.ok", func() { + close(done) + }) + <-done +} diff --git a/hub-server/internal/service/agent_dispatch.go b/hub-server/internal/service/agent_dispatch.go index 9312fcf10..710ae9cc8 100644 --- a/hub-server/internal/service/agent_dispatch.go +++ b/hub-server/internal/service/agent_dispatch.go @@ -12,9 +12,9 @@ import ( "github.com/agenthub/hub-server/internal/config" "github.com/agenthub/hub-server/internal/jwtutil" "github.com/agenthub/hub-server/internal/metrics" - "github.com/agenthub/hub-server/internal/middleware" "github.com/agenthub/hub-server/internal/model" "github.com/agenthub/hub-server/internal/repository" + "github.com/agenthub/hub-server/internal/safego" "github.com/agenthub/hub-server/internal/service/dispatch" "github.com/agenthub/hub-server/internal/ws" ) @@ -41,7 +41,7 @@ func (s *DispatchService) launchDispatchTask(ctx context.Context, task *model.Pe // Fall back to the historical unbounded launch so those paths keep working; // only the production composition root wires a real semaphore. if s.dispatchSem == nil { - middleware.SafeGo("dispatch.launch", func() { + safego.SafeGo("dispatch.launch", func() { s.dispatchTask(ctx, task, ai, prompt, modelParams, targetType, customAgent) }) return @@ -56,7 +56,7 @@ func (s *DispatchService) launchDispatchTask(ctx context.Context, task *model.Pe "task_id", task.ID, "agent_instance_id", ai.ID, "capacity", dispatchSemaphoreCapacity) return } - middleware.SafeGo("dispatch.launch", func() { + safego.SafeGo("dispatch.launch", func() { defer func() { <-s.dispatchSem }() s.dispatchTask(ctx, task, ai, prompt, modelParams, targetType, customAgent) })