diff --git a/README.md b/README.md index d469d666..4cd85fa1 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ For contribution workflow and code style, see [CONTRIBUTING](CONTRIBUTING.md) if - **API** โ€” See `apps/api/README.md` for setup, env vars, and running the server. - **UI** โ€” See `apps/web/README.md` for front-end setup and scripts. +- **Agent workflows** โ€” See `proposals/agent-workflows.md` for the proposed autonomous agent architecture and rollout plan. --- diff --git a/apps/api/internal/handler/agent.go b/apps/api/internal/handler/agent.go new file mode 100644 index 00000000..d815a247 --- /dev/null +++ b/apps/api/internal/handler/agent.go @@ -0,0 +1,345 @@ +package handler + +import ( + "errors" + "net/http" + + "github.com/Devlaner/devlane/api/internal/middleware" + "github.com/Devlaner/devlane/api/internal/model" + "github.com/Devlaner/devlane/api/internal/service" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type AgentHandler struct { + Agent *service.AgentService +} + +type agentToolPermissionBody struct { + Tool string `json:"tool"` + Scope string `json:"scope"` + Config map[string]interface{} `json:"config"` +} + +type agentCreateBody struct { + ProjectID *string `json:"project_id"` + Name string `json:"name" binding:"required"` + Description string `json:"description"` + Avatar string `json:"avatar"` + Instructions string `json:"instructions"` + Model string `json:"model"` + Enabled *bool `json:"enabled"` + AutonomyLevel string `json:"autonomy_level"` + ToolPermissions []agentToolPermissionBody `json:"tool_permissions"` +} + +type agentUpdateBody struct { + Name *string `json:"name"` + Description *string `json:"description"` + Avatar *string `json:"avatar"` + Instructions *string `json:"instructions"` + Model *string `json:"model"` + Enabled *bool `json:"enabled"` + AutonomyLevel *string `json:"autonomy_level"` + ToolPermissions *[]agentToolPermissionBody `json:"tool_permissions"` +} + +func parseUUIDParam(c *gin.Context, param, label string) (uuid.UUID, bool) { + id, err := uuid.Parse(c.Param(param)) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid " + label}) + return uuid.Nil, false + } + return id, true +} + +func agentPermissionsFromBody(body []agentToolPermissionBody) []service.AgentToolPermissionParams { + out := make([]service.AgentToolPermissionParams, 0, len(body)) + for _, p := range body { + cfg := model.JSONMap{} + if p.Config != nil { + cfg = model.JSONMap(p.Config) + } + out = append(out, service.AgentToolPermissionParams{ + Tool: p.Tool, + Scope: p.Scope, + Config: cfg, + }) + } + return out +} + +func writeAgentError(c *gin.Context, err error, fallback string) { + switch { + case errors.Is(err, service.ErrProjectForbidden), + errors.Is(err, service.ErrProjectNotFound), + errors.Is(err, service.ErrIssueNotFound), + errors.Is(err, service.ErrAgentNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": "Not found"}) + case errors.Is(err, service.ErrAgentForbidden): + c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"}) + case errors.Is(err, service.ErrAgentNameRequired), + errors.Is(err, service.ErrAgentInvalidAutonomyLevel), + errors.Is(err, service.ErrAgentInvalidTool), + errors.Is(err, service.ErrAgentUnavailable): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": fallback}) + } +} + +func (h *AgentHandler) List(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + var projectID *uuid.UUID + if c.Param("projectId") != "" { + id, ok := parseUUIDParam(c, "projectId", "project ID") + if !ok { + return + } + projectID = &id + } + list, err := h.Agent.ListAgents(c.Request.Context(), c.Param("slug"), projectID, user.ID) + if err != nil { + writeAgentError(c, err, "Failed to list agents") + return + } + c.JSON(http.StatusOK, list) +} + +func (h *AgentHandler) Create(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + var body agentCreateBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()}) + return + } + var projectID *uuid.UUID + if c.Param("projectId") != "" { + id, ok := parseUUIDParam(c, "projectId", "project ID") + if !ok { + return + } + projectID = &id + } else if body.ProjectID != nil && *body.ProjectID != "" { + id, err := uuid.Parse(*body.ProjectID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project_id"}) + return + } + projectID = &id + } + agent, err := h.Agent.CreateAgent(c.Request.Context(), c.Param("slug"), user.ID, service.AgentCreateParams{ + ProjectID: projectID, + Name: body.Name, + Description: body.Description, + Avatar: body.Avatar, + Instructions: body.Instructions, + Model: body.Model, + Enabled: body.Enabled, + AutonomyLevel: body.AutonomyLevel, + ToolPermissions: agentPermissionsFromBody(body.ToolPermissions), + }) + if err != nil { + writeAgentError(c, err, "Failed to create agent") + return + } + c.JSON(http.StatusCreated, agent) +} + +func (h *AgentHandler) Get(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + agentID, ok := parseUUIDParam(c, "agentId", "agent ID") + if !ok { + return + } + agent, err := h.Agent.GetAgent(c.Request.Context(), c.Param("slug"), agentID, user.ID) + if err != nil { + writeAgentError(c, err, "Failed to get agent") + return + } + c.JSON(http.StatusOK, agent) +} + +func (h *AgentHandler) Update(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + agentID, ok := parseUUIDParam(c, "agentId", "agent ID") + if !ok { + return + } + var body agentUpdateBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()}) + return + } + params := service.AgentUpdateParams{ + Name: body.Name, + Description: body.Description, + Avatar: body.Avatar, + Instructions: body.Instructions, + Model: body.Model, + Enabled: body.Enabled, + AutonomyLevel: body.AutonomyLevel, + } + if body.ToolPermissions != nil { + params.ReplaceToolPerms = true + params.ToolPermissions = agentPermissionsFromBody(*body.ToolPermissions) + } + agent, err := h.Agent.UpdateAgent(c.Request.Context(), c.Param("slug"), agentID, user.ID, params) + if err != nil { + writeAgentError(c, err, "Failed to update agent") + return + } + c.JSON(http.StatusOK, agent) +} + +func (h *AgentHandler) Delete(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + agentID, ok := parseUUIDParam(c, "agentId", "agent ID") + if !ok { + return + } + if err := h.Agent.DeleteAgent(c.Request.Context(), c.Param("slug"), agentID, user.ID); err != nil { + writeAgentError(c, err, "Failed to delete agent") + return + } + c.Status(http.StatusNoContent) +} + +func (h *AgentHandler) ListIssueAssignments(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + projectID, ok := parseUUIDParam(c, "projectId", "project ID") + if !ok { + return + } + issueID, ok := parseUUIDParam(c, "pk", "issue ID") + if !ok { + return + } + list, err := h.Agent.ListIssueAssignments(c.Request.Context(), c.Param("slug"), projectID, issueID, user.ID) + if err != nil { + writeAgentError(c, err, "Failed to list agent assignments") + return + } + c.JSON(http.StatusOK, list) +} + +func (h *AgentHandler) AssignIssue(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + projectID, ok := parseUUIDParam(c, "projectId", "project ID") + if !ok { + return + } + issueID, ok := parseUUIDParam(c, "pk", "issue ID") + if !ok { + return + } + var body struct { + AgentID string `json:"agent_id" binding:"required"` + Reason string `json:"reason"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()}) + return + } + agentID, err := uuid.Parse(body.AgentID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid agent_id"}) + return + } + assignment, err := h.Agent.AssignIssue(c.Request.Context(), c.Param("slug"), projectID, issueID, agentID, user.ID, body.Reason) + if err != nil { + writeAgentError(c, err, "Failed to assign agent") + return + } + c.JSON(http.StatusCreated, assignment) +} + +func (h *AgentHandler) ListIssueRuns(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + projectID, ok := parseUUIDParam(c, "projectId", "project ID") + if !ok { + return + } + issueID, ok := parseUUIDParam(c, "pk", "issue ID") + if !ok { + return + } + list, err := h.Agent.ListIssueRuns(c.Request.Context(), c.Param("slug"), projectID, issueID, user.ID) + if err != nil { + writeAgentError(c, err, "Failed to list agent runs") + return + } + c.JSON(http.StatusOK, list) +} + +func (h *AgentHandler) CreateIssueRun(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + projectID, ok := parseUUIDParam(c, "projectId", "project ID") + if !ok { + return + } + issueID, ok := parseUUIDParam(c, "pk", "issue ID") + if !ok { + return + } + var body struct { + AgentID string `json:"agent_id" binding:"required"` + Trigger string `json:"trigger"` + Input map[string]interface{} `json:"input"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()}) + return + } + agentID, err := uuid.Parse(body.AgentID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid agent_id"}) + return + } + input := model.JSONMap{} + if body.Input != nil { + input = model.JSONMap(body.Input) + } + run, err := h.Agent.CreateIssueRun(c.Request.Context(), c.Param("slug"), projectID, issueID, agentID, user.ID, body.Trigger, input) + if err != nil { + writeAgentError(c, err, "Failed to create agent run") + return + } + c.JSON(http.StatusCreated, run) +} diff --git a/apps/api/internal/handler/agent_test.go b/apps/api/internal/handler/agent_test.go new file mode 100644 index 00000000..e49af930 --- /dev/null +++ b/apps/api/internal/handler/agent_test.go @@ -0,0 +1,180 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func agentBase(slug string) string { + return "/api/workspaces/" + slug + "/agents/" +} + +func projectAgentBase(slug, projectID string) string { + return "/api/workspaces/" + slug + "/projects/" + projectID + "/agents/" +} + +func issueAgentBase(slug, projectID, issueID string) string { + return "/api/workspaces/" + slug + "/projects/" + projectID + "/issues/" + issueID + "/" +} + +func createTestAgent(t *testing.T, ts *testutil.TestServer, w testutil.SeededWorld, name string) string { + t.Helper() + rr := ts.POST(agentBase(w.Workspace.Slug), map[string]any{ + "name": name, + "description": "Keeps issue work tidy", + "instructions": "Summarize the issue and propose next steps.", + "autonomy_level": "comment", + "tool_permissions": []map[string]any{ + {"tool": "issue.read", "scope": "workspace"}, + {"tool": "issue.comment", "scope": "workspace"}, + }, + }, w.Session) + require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String()) + id, _ := testutil.MustJSONMap(t, rr)["id"].(string) + require.NotEmpty(t, id) + return id +} + +func TestAgent_RequiresAuth(t *testing.T) { + ts := testutil.NewTestServer(t) + rr := ts.GET(agentBase("x"), "") + require.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestAgent_CRUD(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + base := agentBase(w.Workspace.Slug) + + agentID := createTestAgent(t, ts, w, "Bug Triage") + + rrList := ts.GET(base, w.Session) + require.Equal(t, http.StatusOK, rrList.Code, "body=%s", rrList.Body.String()) + agents := testutil.DecodeJSON[[]map[string]any](t, rrList) + require.Len(t, agents, 1) + assert.Equal(t, "Bug Triage", agents[0]["name"]) + require.Len(t, agents[0]["tool_permissions"], 2) + + rrGet := ts.GET(base+agentID+"/", w.Session) + require.Equal(t, http.StatusOK, rrGet.Code, "body=%s", rrGet.Body.String()) + assert.Equal(t, "comment", testutil.MustJSONMap(t, rrGet)["autonomy_level"]) + + enabled := false + rrPatch := ts.PATCH(base+agentID+"/", map[string]any{ + "name": "Bug Triage v2", + "enabled": enabled, + "autonomy_level": "suggest", + "tool_permissions": []map[string]any{{"tool": "issue.read", "scope": "workspace"}}, + }, w.Session) + require.Equal(t, http.StatusOK, rrPatch.Code, "body=%s", rrPatch.Body.String()) + updated := testutil.MustJSONMap(t, rrPatch) + assert.Equal(t, "Bug Triage v2", updated["name"]) + assert.Equal(t, false, updated["enabled"]) + assert.Equal(t, "suggest", updated["autonomy_level"]) + require.Len(t, updated["tool_permissions"], 1) + + rrDelete := ts.DELETE(base+agentID+"/", w.Session) + require.Equal(t, http.StatusNoContent, rrDelete.Code, "body=%s", rrDelete.Body.String()) + + rrMissing := ts.GET(base+agentID+"/", w.Session) + require.Equal(t, http.StatusNotFound, rrMissing.Code) +} + +func TestAgent_ProjectRosterIncludesWorkspaceAgents(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + createTestAgent(t, ts, w, "Workspace Agent") + + rrProject := ts.POST(projectAgentBase(w.Workspace.Slug, w.Project.ID.String()), map[string]any{ + "name": "Project Agent", + "autonomy_level": "suggest", + }, w.Session) + require.Equal(t, http.StatusCreated, rrProject.Code, "body=%s", rrProject.Body.String()) + assert.Equal(t, w.Project.ID.String(), testutil.MustJSONMap(t, rrProject)["project_id"]) + + rrList := ts.GET(projectAgentBase(w.Workspace.Slug, w.Project.ID.String()), w.Session) + require.Equal(t, http.StatusOK, rrList.Code, "body=%s", rrList.Body.String()) + agents := testutil.DecodeJSON[[]map[string]any](t, rrList) + require.Len(t, agents, 2) + assert.Equal(t, "Workspace Agent", agents[0]["name"]) + assert.Equal(t, "Project Agent", agents[1]["name"]) +} + +func TestAgent_CreateRequiresWorkspaceAdmin(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + member := testutil.CreateUser(t, ts.DB) + testutil.AddWorkspaceMember(t, ts.DB, w.Workspace.ID, member.ID, testutil.RoleMember) + memberSession := testutil.LoginAs(t, ts.DB, member) + + rr := ts.POST(agentBase(w.Workspace.Slug), map[string]any{"name": "Docs Writer"}, memberSession) + require.Equal(t, http.StatusForbidden, rr.Code, "body=%s", rr.Body.String()) +} + +func TestAgent_IssueAssignmentAndRun(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + agentID := createTestAgent(t, ts, w, "Spec Breaker") + issue := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + base := issueAgentBase(w.Workspace.Slug, w.Project.ID.String(), issue.ID.String()) + + rrAssign := ts.POST(base+"agent-assignments/", map[string]any{ + "agent_id": agentID, + "reason": "Break this into child tasks", + }, w.Session) + require.Equal(t, http.StatusCreated, rrAssign.Code, "body=%s", rrAssign.Body.String()) + assignment := testutil.MustJSONMap(t, rrAssign) + assert.Equal(t, agentID, assignment["agent_id"]) + assert.Equal(t, "active", assignment["status"]) + + rrAssignments := ts.GET(base+"agent-assignments/", w.Session) + require.Equal(t, http.StatusOK, rrAssignments.Code, "body=%s", rrAssignments.Body.String()) + require.Len(t, testutil.DecodeJSON[[]map[string]any](t, rrAssignments), 1) + + rrRun := ts.POST(base+"agent-runs/", map[string]any{ + "agent_id": agentID, + "trigger": "manual", + "input": map[string]any{ + "task": "draft_subtasks", + }, + }, w.Session) + require.Equal(t, http.StatusCreated, rrRun.Code, "body=%s", rrRun.Body.String()) + run := testutil.MustJSONMap(t, rrRun) + assert.Equal(t, agentID, run["agent_id"]) + assert.Equal(t, "queued", run["status"]) + assert.Equal(t, "manual", run["trigger"]) + + rrRuns := ts.GET(base+"agent-runs/", w.Session) + require.Equal(t, http.StatusOK, rrRuns.Code, "body=%s", rrRuns.Body.String()) + require.Len(t, testutil.DecodeJSON[[]map[string]any](t, rrRuns), 1) + + rrActivities := ts.GET(base+"activities/", w.Session) + require.Equal(t, http.StatusOK, rrActivities.Code, "body=%s", rrActivities.Body.String()) + activities := testutil.DecodeJSON[[]map[string]any](t, rrActivities) + var verbs []string + for _, activity := range activities { + if verb, ok := activity["verb"].(string); ok { + verbs = append(verbs, verb) + } + } + assert.Contains(t, verbs, "agent_assigned") + assert.Contains(t, verbs, "agent_run_queued") +} + +func TestAgent_DisabledAgentCannotBeAssigned(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + agentID := createTestAgent(t, ts, w, "Disabled Agent") + rrPatch := ts.PATCH(agentBase(w.Workspace.Slug)+agentID+"/", map[string]any{"enabled": false}, w.Session) + require.Equal(t, http.StatusOK, rrPatch.Code, "body=%s", rrPatch.Body.String()) + issue := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + base := issueAgentBase(w.Workspace.Slug, w.Project.ID.String(), issue.ID.String()) + + rrAssign := ts.POST(base+"agent-assignments/", map[string]any{"agent_id": agentID}, w.Session) + require.Equal(t, http.StatusBadRequest, rrAssign.Code, "body=%s", rrAssign.Body.String()) + assert.Contains(t, rrAssign.Body.String(), "agent is not available") +} diff --git a/apps/api/internal/model/agent.go b/apps/api/internal/model/agent.go new file mode 100644 index 00000000..66a2e5e6 --- /dev/null +++ b/apps/api/internal/model/agent.go @@ -0,0 +1,155 @@ +package model + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +const ( + AgentAutonomySuggest = "suggest" + AgentAutonomyComment = "comment" + AgentAutonomyModifyIssue = "modify_issue" + AgentAutonomyGithubDraft = "github_draft" + AgentAutonomyGithubReviewed = "github_reviewed" + + AgentAssignmentActive = "active" + AgentAssignmentCancelled = "cancelled" + AgentAssignmentCompleted = "completed" + + AgentRunQueued = "queued" + AgentRunRunning = "running" + AgentRunNeedsReview = "needs_review" + AgentRunCompleted = "completed" + AgentRunFailed = "failed" + AgentRunCancelled = "cancelled" +) + +type Agent struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"` + ProjectID *uuid.UUID `gorm:"type:uuid" json:"project_id,omitempty"` + Name string `gorm:"type:varchar(255);not null" json:"name"` + Description string `gorm:"type:text" json:"description"` + Avatar string `gorm:"type:text" json:"avatar"` + Instructions string `gorm:"type:text" json:"instructions"` + Model string `gorm:"type:varchar(100)" json:"model"` + Enabled bool `gorm:"default:true" json:"enabled"` + AutonomyLevel string `gorm:"type:varchar(50);default:suggest" json:"autonomy_level"` + ToolPermissions []AgentToolPermission `gorm:"foreignKey:AgentID" json:"tool_permissions,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"` + UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"` +} + +func (Agent) TableName() string { return "agents" } + +func (a *Agent) BeforeCreate(tx *gorm.DB) error { + if a.ID == uuid.Nil { + a.ID = uuid.New() + } + if a.AutonomyLevel == "" { + a.AutonomyLevel = AgentAutonomySuggest + } + return nil +} + +type AgentToolPermission struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + AgentID uuid.UUID `gorm:"type:uuid;not null" json:"agent_id"` + Tool string `gorm:"type:varchar(100);not null" json:"tool"` + Scope string `gorm:"type:varchar(100);default:workspace" json:"scope"` + Config JSONMap `gorm:"type:jsonb;serializer:json;not null;default:'{}'" json:"config,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +func (AgentToolPermission) TableName() string { return "agent_tool_permissions" } + +func (p *AgentToolPermission) BeforeCreate(tx *gorm.DB) error { + if p.ID == uuid.Nil { + p.ID = uuid.New() + } + if p.Scope == "" { + p.Scope = "workspace" + } + if p.Config == nil { + p.Config = JSONMap{} + } + return nil +} + +type AgentIssueAssignment struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + IssueID uuid.UUID `gorm:"type:uuid;not null" json:"issue_id"` + AgentID uuid.UUID `gorm:"type:uuid;not null" json:"agent_id"` + ProjectID uuid.UUID `gorm:"type:uuid;not null" json:"project_id"` + WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"` + AssignedByID *uuid.UUID `gorm:"type:uuid" json:"assigned_by_id,omitempty"` + Reason string `gorm:"type:text" json:"reason"` + Status string `gorm:"type:varchar(50);default:active" json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +func (AgentIssueAssignment) TableName() string { return "agent_issue_assignments" } + +func (a *AgentIssueAssignment) BeforeCreate(tx *gorm.DB) error { + if a.ID == uuid.Nil { + a.ID = uuid.New() + } + if a.Status == "" { + a.Status = AgentAssignmentActive + } + return nil +} + +type AgentRun struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + AgentID uuid.UUID `gorm:"type:uuid;not null" json:"agent_id"` + IssueID *uuid.UUID `gorm:"type:uuid" json:"issue_id,omitempty"` + ProjectID uuid.UUID `gorm:"type:uuid;not null" json:"project_id"` + WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"` + Trigger string `gorm:"type:varchar(100);default:manual" json:"trigger"` + Status string `gorm:"type:varchar(50);default:queued" json:"status"` + Input JSONMap `gorm:"type:jsonb;serializer:json;not null;default:'{}'" json:"input,omitempty"` + Output JSONMap `gorm:"type:jsonb;serializer:json;not null;default:'{}'" json:"output,omitempty"` + Error string `gorm:"type:text" json:"error"` + QueuedAt time.Time `gorm:"type:timestamptz" json:"queued_at"` + StartedAt *time.Time `gorm:"type:timestamptz" json:"started_at,omitempty"` + CompletedAt *time.Time `gorm:"type:timestamptz" json:"completed_at,omitempty"` + CancelledAt *time.Time `gorm:"type:timestamptz" json:"cancelled_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"` +} + +func (AgentRun) TableName() string { return "agent_runs" } + +func (r *AgentRun) BeforeCreate(tx *gorm.DB) error { + if r.ID == uuid.Nil { + r.ID = uuid.New() + } + if r.Trigger == "" { + r.Trigger = "manual" + } + if r.Status == "" { + r.Status = AgentRunQueued + } + if r.Input == nil { + r.Input = JSONMap{} + } + if r.Output == nil { + r.Output = JSONMap{} + } + if r.QueuedAt.IsZero() { + r.QueuedAt = time.Now() + } + return nil +} diff --git a/apps/api/internal/router/router.go b/apps/api/internal/router/router.go index 81e55e4a..8676c3ca 100644 --- a/apps/api/internal/router/router.go +++ b/apps/api/internal/router/router.go @@ -68,6 +68,7 @@ func New(cfg Config) *gin.Engine { stateStore := store.NewStateStore(cfg.DB) labelStore := store.NewLabelStore(cfg.DB) issueStore := store.NewIssueStore(cfg.DB) + agentStore := store.NewAgentStore(cfg.DB) cycleStore := store.NewCycleStore(cfg.DB) moduleStore := store.NewModuleStore(cfg.DB) issueViewStore := store.NewIssueViewStore(cfg.DB) @@ -142,6 +143,8 @@ func New(cfg Config) *gin.Engine { issueActivityStore := store.NewIssueActivityStore(cfg.DB) issueSvc := service.NewIssueService(issueStore, projectStore, workspaceStore) issueSvc.SetActivityStore(issueActivityStore) + agentSvc := service.NewAgentService(agentStore, projectStore, workspaceStore, issueStore) + agentSvc.SetActivityStore(issueActivityStore) attachmentSvc := service.NewAttachmentService(issueStore, projectStore, workspaceStore, cfg.Minio) attachmentSvc.SetActivityStore(issueActivityStore) cycleSvc := service.NewCycleService(cycleStore, projectStore, workspaceStore) @@ -238,6 +241,7 @@ func New(cfg Config) *gin.Engine { estimateHandler := &handler.EstimateHandler{Estimate: estimateSvc} issueHandler := &handler.IssueHandler{Issue: issueSvc} issueLinkHandler := &handler.IssueLinkHandler{Issue: issueSvc} + agentHandler := &handler.AgentHandler{Agent: agentSvc} attachmentHandler := &handler.AttachmentHandler{Attachment: attachmentSvc} epicHandler := &handler.EpicHandler{Issue: issueSvc} cycleHandler := &handler.CycleHandler{Cycle: cycleSvc} @@ -301,6 +305,11 @@ func New(cfg Config) *gin.Engine { api.GET("/workspaces/:slug/draft-issues/", issueHandler.ListWorkspaceDrafts) api.GET("/workspaces/:slug/archived-issues/", issueHandler.ListWorkspaceArchived) api.GET("/workspaces/:slug/search/", searchHandler.Search) + api.GET("/workspaces/:slug/agents/", agentHandler.List) + api.POST("/workspaces/:slug/agents/", agentHandler.Create) + api.GET("/workspaces/:slug/agents/:agentId/", agentHandler.Get) + api.PATCH("/workspaces/:slug/agents/:agentId/", agentHandler.Update) + api.DELETE("/workspaces/:slug/agents/:agentId/", agentHandler.Delete) api.GET("/workspaces/:slug/projects/", projectHandler.List) api.POST("/workspaces/:slug/projects/", projectHandler.Create) @@ -319,6 +328,8 @@ func New(cfg Config) *gin.Engine { api.GET("/workspaces/:slug/projects/:projectId/invitations/:pk/", projectHandler.GetInvite) api.DELETE("/workspaces/:slug/projects/:projectId/invitations/:pk/", projectHandler.DeleteInvite) api.POST("/workspaces/:slug/projects/:projectId/invitations/:pk/join/", projectHandler.JoinByInvite) + api.GET("/workspaces/:slug/projects/:projectId/agents/", agentHandler.List) + api.POST("/workspaces/:slug/projects/:projectId/agents/", agentHandler.Create) api.GET("/workspaces/:slug/projects/:projectId/states/", stateHandler.List) api.POST("/workspaces/:slug/projects/:projectId/states/", stateHandler.Create) @@ -366,6 +377,10 @@ func New(cfg Config) *gin.Engine { api.DELETE("/workspaces/:slug/projects/:projectId/issues/:pk/archive/", issueHandler.Restore) api.POST("/workspaces/:slug/projects/:projectId/issues/:pk/convert/", issueHandler.Convert) api.POST("/workspaces/:slug/projects/:projectId/issues/:pk/move/", issueHandler.Move) + api.GET("/workspaces/:slug/projects/:projectId/issues/:pk/agent-assignments/", agentHandler.ListIssueAssignments) + api.POST("/workspaces/:slug/projects/:projectId/issues/:pk/agent-assignments/", agentHandler.AssignIssue) + api.GET("/workspaces/:slug/projects/:projectId/issues/:pk/agent-runs/", agentHandler.ListIssueRuns) + api.POST("/workspaces/:slug/projects/:projectId/issues/:pk/agent-runs/", agentHandler.CreateIssueRun) api.GET("/workspaces/:slug/projects/:projectId/archived-issues/", issueHandler.ListArchived) api.POST("/workspaces/:slug/projects/:projectId/issues-bulk/update/", issueHandler.BulkUpdate) api.POST("/workspaces/:slug/projects/:projectId/issues-bulk/archive/", issueHandler.BulkArchive) diff --git a/apps/api/internal/service/agent.go b/apps/api/internal/service/agent.go new file mode 100644 index 00000000..47cbc500 --- /dev/null +++ b/apps/api/internal/service/agent.go @@ -0,0 +1,433 @@ +package service + +import ( + "context" + "errors" + "strings" + + "github.com/Devlaner/devlane/api/internal/model" + "github.com/Devlaner/devlane/api/internal/store" + "github.com/google/uuid" +) + +var ( + ErrAgentNotFound = errors.New("agent not found") + ErrAgentForbidden = errors.New("agent forbidden") + ErrAgentNameRequired = errors.New("agent name is required") + ErrAgentInvalidAutonomyLevel = errors.New("invalid agent autonomy level") + ErrAgentInvalidTool = errors.New("invalid agent tool permission") + ErrAgentUnavailable = errors.New("agent is not available for this issue") +) + +type AgentToolPermissionParams struct { + Tool string + Scope string + Config model.JSONMap +} + +type AgentCreateParams struct { + ProjectID *uuid.UUID + Name string + Description string + Avatar string + Instructions string + Model string + Enabled *bool + AutonomyLevel string + ToolPermissions []AgentToolPermissionParams +} + +type AgentUpdateParams struct { + Name *string + Description *string + Avatar *string + Instructions *string + Model *string + Enabled *bool + AutonomyLevel *string + ToolPermissions []AgentToolPermissionParams + ReplaceToolPerms bool +} + +type AgentService struct { + as *store.AgentStore + ps *store.ProjectStore + ws *store.WorkspaceStore + is *store.IssueStore + activity *store.IssueActivityStore +} + +func NewAgentService(as *store.AgentStore, ps *store.ProjectStore, ws *store.WorkspaceStore, is *store.IssueStore) *AgentService { + return &AgentService{as: as, ps: ps, ws: ws, is: is} +} + +func (s *AgentService) SetActivityStore(a *store.IssueActivityStore) { s.activity = a } + +func (s *AgentService) ensureWorkspaceAccess(ctx context.Context, workspaceSlug string, userID uuid.UUID) (*model.Workspace, error) { + wrk, err := s.ws.GetBySlug(ctx, workspaceSlug) + if err != nil { + return nil, ErrProjectForbidden + } + ok, _ := s.ws.IsMember(ctx, wrk.ID, userID) + if !ok { + return nil, ErrProjectForbidden + } + return wrk, nil +} + +func (s *AgentService) ensureWorkspaceAdmin(ctx context.Context, wrk *model.Workspace, userID uuid.UUID) error { + m, err := s.ws.GetMember(ctx, wrk.ID, userID) + if err != nil || m == nil || m.Role < model.RoleAdmin { + return ErrAgentForbidden + } + return nil +} + +func (s *AgentService) ensureProjectScope(ctx context.Context, workspaceID uuid.UUID, projectID *uuid.UUID) error { + if projectID == nil { + return nil + } + inWorkspace, _ := s.ps.IsInWorkspace(ctx, *projectID, workspaceID) + if !inWorkspace { + return ErrProjectNotFound + } + return nil +} + +func (s *AgentService) ensureProjectAccess(ctx context.Context, workspaceSlug string, projectID, userID uuid.UUID) (*model.Workspace, error) { + wrk, err := s.ensureWorkspaceAccess(ctx, workspaceSlug, userID) + if err != nil { + return nil, err + } + inWorkspace, _ := s.ps.IsInWorkspace(ctx, projectID, wrk.ID) + if !inWorkspace { + return nil, ErrProjectNotFound + } + return wrk, nil +} + +func (s *AgentService) normalizePermissions(params []AgentToolPermissionParams) ([]model.AgentToolPermission, error) { + out := make([]model.AgentToolPermission, 0, len(params)) + seen := map[string]bool{} + for _, p := range params { + tool := strings.TrimSpace(p.Tool) + scope := strings.TrimSpace(p.Scope) + if scope == "" { + scope = "workspace" + } + if !validAgentTools[tool] { + return nil, ErrAgentInvalidTool + } + key := tool + "\x00" + scope + if seen[key] { + continue + } + seen[key] = true + cfg := p.Config + if cfg == nil { + cfg = model.JSONMap{} + } + out = append(out, model.AgentToolPermission{ + Tool: tool, + Scope: scope, + Config: cfg, + }) + } + return out, nil +} + +var validAgentAutonomyLevels = map[string]bool{ + model.AgentAutonomySuggest: true, + model.AgentAutonomyComment: true, + model.AgentAutonomyModifyIssue: true, + model.AgentAutonomyGithubDraft: true, + model.AgentAutonomyGithubReviewed: true, +} + +var validAgentTools = map[string]bool{ + "issue.read": true, + "issue.comment": true, + "issue.update": true, + "issue.create_child": true, + "project.read": true, + "github.read": true, + "github.comment": true, + "github.draft_pr": true, +} + +func normalizeAutonomyLevel(level string) (string, error) { + level = strings.TrimSpace(level) + if level == "" { + level = model.AgentAutonomySuggest + } + if !validAgentAutonomyLevels[level] { + return "", ErrAgentInvalidAutonomyLevel + } + return level, nil +} + +func (s *AgentService) ListAgents(ctx context.Context, workspaceSlug string, projectID *uuid.UUID, userID uuid.UUID) ([]model.Agent, error) { + wrk, err := s.ensureWorkspaceAccess(ctx, workspaceSlug, userID) + if err != nil { + return nil, err + } + if err := s.ensureProjectScope(ctx, wrk.ID, projectID); err != nil { + return nil, err + } + return s.as.ListAgents(ctx, wrk.ID, projectID) +} + +func (s *AgentService) CreateAgent(ctx context.Context, workspaceSlug string, userID uuid.UUID, params AgentCreateParams) (*model.Agent, error) { + wrk, err := s.ensureWorkspaceAccess(ctx, workspaceSlug, userID) + if err != nil { + return nil, err + } + if err := s.ensureWorkspaceAdmin(ctx, wrk, userID); err != nil { + return nil, err + } + if err := s.ensureProjectScope(ctx, wrk.ID, params.ProjectID); err != nil { + return nil, err + } + name := strings.TrimSpace(params.Name) + if name == "" { + return nil, ErrAgentNameRequired + } + level, err := normalizeAutonomyLevel(params.AutonomyLevel) + if err != nil { + return nil, err + } + permissions, err := s.normalizePermissions(params.ToolPermissions) + if err != nil { + return nil, err + } + enabled := true + if params.Enabled != nil { + enabled = *params.Enabled + } + actor := userID + a := &model.Agent{ + WorkspaceID: wrk.ID, + ProjectID: params.ProjectID, + Name: name, + Description: params.Description, + Avatar: params.Avatar, + Instructions: params.Instructions, + Model: params.Model, + Enabled: enabled, + AutonomyLevel: level, + CreatedByID: &actor, + UpdatedByID: &actor, + } + if err := s.as.CreateAgent(ctx, a, permissions); err != nil { + return nil, err + } + return s.as.GetAgentByID(ctx, a.ID) +} + +func (s *AgentService) GetAgent(ctx context.Context, workspaceSlug string, agentID, userID uuid.UUID) (*model.Agent, error) { + wrk, err := s.ensureWorkspaceAccess(ctx, workspaceSlug, userID) + if err != nil { + return nil, err + } + a, err := s.as.GetAgentByID(ctx, agentID) + if err != nil || a.WorkspaceID != wrk.ID { + return nil, ErrAgentNotFound + } + return a, nil +} + +func (s *AgentService) UpdateAgent(ctx context.Context, workspaceSlug string, agentID, userID uuid.UUID, params AgentUpdateParams) (*model.Agent, error) { + wrk, err := s.ensureWorkspaceAccess(ctx, workspaceSlug, userID) + if err != nil { + return nil, err + } + if err := s.ensureWorkspaceAdmin(ctx, wrk, userID); err != nil { + return nil, err + } + a, err := s.as.GetAgentByID(ctx, agentID) + if err != nil || a.WorkspaceID != wrk.ID { + return nil, ErrAgentNotFound + } + if params.Name != nil { + name := strings.TrimSpace(*params.Name) + if name == "" { + return nil, ErrAgentNameRequired + } + a.Name = name + } + if params.Description != nil { + a.Description = *params.Description + } + if params.Avatar != nil { + a.Avatar = *params.Avatar + } + if params.Instructions != nil { + a.Instructions = *params.Instructions + } + if params.Model != nil { + a.Model = *params.Model + } + if params.Enabled != nil { + a.Enabled = *params.Enabled + } + if params.AutonomyLevel != nil { + level, err := normalizeAutonomyLevel(*params.AutonomyLevel) + if err != nil { + return nil, err + } + a.AutonomyLevel = level + } + var permissions []model.AgentToolPermission + if params.ReplaceToolPerms { + permissions, err = s.normalizePermissions(params.ToolPermissions) + if err != nil { + return nil, err + } + } + actor := userID + a.UpdatedByID = &actor + if err := s.as.UpdateAgent(ctx, a, permissions, params.ReplaceToolPerms); err != nil { + return nil, err + } + return s.as.GetAgentByID(ctx, a.ID) +} + +func (s *AgentService) DeleteAgent(ctx context.Context, workspaceSlug string, agentID, userID uuid.UUID) error { + wrk, err := s.ensureWorkspaceAccess(ctx, workspaceSlug, userID) + if err != nil { + return err + } + if err := s.ensureWorkspaceAdmin(ctx, wrk, userID); err != nil { + return err + } + a, err := s.as.GetAgentByID(ctx, agentID) + if err != nil || a.WorkspaceID != wrk.ID { + return ErrAgentNotFound + } + return s.as.DeleteAgent(ctx, agentID) +} + +func (s *AgentService) resolveIssue(ctx context.Context, workspaceSlug string, projectID, issueID, userID uuid.UUID) (*model.Workspace, *model.Issue, error) { + wrk, err := s.ensureProjectAccess(ctx, workspaceSlug, projectID, userID) + if err != nil { + return nil, nil, err + } + issue, err := s.is.GetByID(ctx, issueID) + if err != nil || issue.ProjectID != projectID || issue.WorkspaceID != wrk.ID { + return nil, nil, ErrIssueNotFound + } + return wrk, issue, nil +} + +func (s *AgentService) resolveAvailableAgent(ctx context.Context, workspaceID, projectID, agentID uuid.UUID) (*model.Agent, error) { + a, err := s.as.GetAgentByID(ctx, agentID) + if err != nil || a.WorkspaceID != workspaceID { + return nil, ErrAgentNotFound + } + if !a.Enabled { + return nil, ErrAgentUnavailable + } + if a.ProjectID != nil && *a.ProjectID != projectID { + return nil, ErrAgentUnavailable + } + return a, nil +} + +func (s *AgentService) AssignIssue(ctx context.Context, workspaceSlug string, projectID, issueID, agentID, userID uuid.UUID, reason string) (*model.AgentIssueAssignment, error) { + _, issue, err := s.resolveIssue(ctx, workspaceSlug, projectID, issueID, userID) + if err != nil { + return nil, err + } + a, err := s.resolveAvailableAgent(ctx, issue.WorkspaceID, issue.ProjectID, agentID) + if err != nil { + return nil, err + } + actor := userID + assignment := &model.AgentIssueAssignment{ + IssueID: issue.ID, + AgentID: a.ID, + ProjectID: issue.ProjectID, + WorkspaceID: issue.WorkspaceID, + AssignedByID: &actor, + Reason: reason, + Status: model.AgentAssignmentActive, + } + if err := s.as.CreateOrUpdateAssignment(ctx, assignment); err != nil { + return nil, err + } + s.recordIssueAgentActivity(ctx, issue, userID, "agent_assigned", a.ID, "Assigned to agent "+a.Name) + return assignment, nil +} + +func (s *AgentService) ListIssueAssignments(ctx context.Context, workspaceSlug string, projectID, issueID, userID uuid.UUID) ([]model.AgentIssueAssignment, error) { + _, issue, err := s.resolveIssue(ctx, workspaceSlug, projectID, issueID, userID) + if err != nil { + return nil, err + } + return s.as.ListAssignmentsByIssue(ctx, issue.ID) +} + +func (s *AgentService) CreateIssueRun(ctx context.Context, workspaceSlug string, projectID, issueID, agentID, userID uuid.UUID, trigger string, input model.JSONMap) (*model.AgentRun, error) { + _, issue, err := s.resolveIssue(ctx, workspaceSlug, projectID, issueID, userID) + if err != nil { + return nil, err + } + a, err := s.resolveAvailableAgent(ctx, issue.WorkspaceID, issue.ProjectID, agentID) + if err != nil { + return nil, err + } + if trigger = strings.TrimSpace(trigger); trigger == "" { + trigger = "manual" + } + if input == nil { + input = model.JSONMap{} + } + actor := userID + iid := issue.ID + run := &model.AgentRun{ + AgentID: a.ID, + IssueID: &iid, + ProjectID: issue.ProjectID, + WorkspaceID: issue.WorkspaceID, + Trigger: trigger, + Status: model.AgentRunQueued, + Input: input, + Output: model.JSONMap{}, + CreatedByID: &actor, + } + if err := s.as.CreateRun(ctx, run); err != nil { + return nil, err + } + s.recordIssueAgentActivity(ctx, issue, userID, "agent_run_queued", a.ID, "Queued agent run for "+a.Name) + return run, nil +} + +func (s *AgentService) ListIssueRuns(ctx context.Context, workspaceSlug string, projectID, issueID, userID uuid.UUID) ([]model.AgentRun, error) { + _, issue, err := s.resolveIssue(ctx, workspaceSlug, projectID, issueID, userID) + if err != nil { + return nil, err + } + return s.as.ListRunsByIssue(ctx, issue.ID) +} + +func (s *AgentService) recordIssueAgentActivity(ctx context.Context, issue *model.Issue, userID uuid.UUID, verb string, agentID uuid.UUID, comment string) { + if s.activity == nil || issue == nil { + return + } + field := "agent_id" + newVal := agentID.String() + actor := userID + row := &model.IssueActivity{ + IssueID: &issue.ID, + ProjectID: issue.ProjectID, + WorkspaceID: issue.WorkspaceID, + Verb: verb, + Field: &field, + NewValue: &newVal, + Comment: &comment, + CreatedByID: &actor, + UpdatedByID: &actor, + ActorID: &actor, + } + _ = s.activity.Create(ctx, row) +} diff --git a/apps/api/internal/store/agent.go b/apps/api/internal/store/agent.go new file mode 100644 index 00000000..49b22393 --- /dev/null +++ b/apps/api/internal/store/agent.go @@ -0,0 +1,127 @@ +package store + +import ( + "context" + + "github.com/Devlaner/devlane/api/internal/model" + "github.com/google/uuid" + "gorm.io/gorm" +) + +type AgentStore struct{ db *gorm.DB } + +func NewAgentStore(db *gorm.DB) *AgentStore { return &AgentStore{db: db} } + +func (s *AgentStore) CreateAgent(ctx context.Context, a *model.Agent, permissions []model.AgentToolPermission) error { + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Omit("ToolPermissions").Create(a).Error; err != nil { + return err + } + for i := range permissions { + permissions[i].AgentID = a.ID + } + if len(permissions) > 0 { + if err := tx.Create(&permissions).Error; err != nil { + return err + } + } + a.ToolPermissions = permissions + return nil + }) +} + +func (s *AgentStore) ListAgents(ctx context.Context, workspaceID uuid.UUID, projectID *uuid.UUID) ([]model.Agent, error) { + var list []model.Agent + q := s.db.WithContext(ctx). + Preload("ToolPermissions", "deleted_at IS NULL"). + Where("workspace_id = ? AND deleted_at IS NULL", workspaceID) + if projectID != nil { + q = q.Where("(project_id IS NULL OR project_id = ?)", *projectID) + } + err := q.Order("project_id NULLS FIRST, name ASC, created_at ASC").Find(&list).Error + return list, err +} + +func (s *AgentStore) GetAgentByID(ctx context.Context, id uuid.UUID) (*model.Agent, error) { + var a model.Agent + err := s.db.WithContext(ctx). + Preload("ToolPermissions", "deleted_at IS NULL"). + Where("id = ? AND deleted_at IS NULL", id). + First(&a).Error + if err != nil { + return nil, err + } + return &a, nil +} + +func (s *AgentStore) UpdateAgent(ctx context.Context, a *model.Agent, permissions []model.AgentToolPermission, replacePermissions bool) error { + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Omit("ToolPermissions").Save(a).Error; err != nil { + return err + } + if !replacePermissions { + return nil + } + if err := tx.Where("agent_id = ?", a.ID).Delete(&model.AgentToolPermission{}).Error; err != nil { + return err + } + for i := range permissions { + permissions[i].AgentID = a.ID + } + if len(permissions) > 0 { + if err := tx.Create(&permissions).Error; err != nil { + return err + } + } + a.ToolPermissions = permissions + return nil + }) +} + +func (s *AgentStore) DeleteAgent(ctx context.Context, id uuid.UUID) error { + return s.db.WithContext(ctx).Where("id = ?", id).Delete(&model.Agent{}).Error +} + +func (s *AgentStore) CreateOrUpdateAssignment(ctx context.Context, assignment *model.AgentIssueAssignment) error { + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var existing model.AgentIssueAssignment + err := tx.Where("issue_id = ? AND agent_id = ? AND deleted_at IS NULL", assignment.IssueID, assignment.AgentID). + First(&existing).Error + if err == nil { + existing.Reason = assignment.Reason + existing.Status = assignment.Status + existing.AssignedByID = assignment.AssignedByID + if err := tx.Save(&existing).Error; err != nil { + return err + } + *assignment = existing + return nil + } + if err != gorm.ErrRecordNotFound { + return err + } + return tx.Create(assignment).Error + }) +} + +func (s *AgentStore) ListAssignmentsByIssue(ctx context.Context, issueID uuid.UUID) ([]model.AgentIssueAssignment, error) { + var list []model.AgentIssueAssignment + err := s.db.WithContext(ctx). + Where("issue_id = ? AND deleted_at IS NULL", issueID). + Order("created_at ASC"). + Find(&list).Error + return list, err +} + +func (s *AgentStore) CreateRun(ctx context.Context, run *model.AgentRun) error { + return s.db.WithContext(ctx).Create(run).Error +} + +func (s *AgentStore) ListRunsByIssue(ctx context.Context, issueID uuid.UUID) ([]model.AgentRun, error) { + var list []model.AgentRun + err := s.db.WithContext(ctx). + Where("issue_id = ? AND deleted_at IS NULL", issueID). + Order("queued_at DESC, created_at DESC"). + Find(&list).Error + return list, err +} diff --git a/apps/api/migrations/000007_agents.down.sql b/apps/api/migrations/000007_agents.down.sql new file mode 100644 index 00000000..56038de5 --- /dev/null +++ b/apps/api/migrations/000007_agents.down.sql @@ -0,0 +1,21 @@ +DROP INDEX IF EXISTS idx_agent_runs_workspace_status; +DROP INDEX IF EXISTS idx_agent_runs_issue; +DROP INDEX IF EXISTS idx_agent_runs_agent; +DROP TABLE IF EXISTS agent_runs; + +DROP INDEX IF EXISTS idx_agent_issue_assignments_issue_agent_active; +DROP INDEX IF EXISTS idx_agent_issue_assignments_workspace; +DROP INDEX IF EXISTS idx_agent_issue_assignments_agent; +DROP INDEX IF EXISTS idx_agent_issue_assignments_issue; +DROP TABLE IF EXISTS agent_issue_assignments; + +DROP INDEX IF EXISTS idx_agent_tool_permissions_agent_tool_scope_active; +DROP INDEX IF EXISTS idx_agent_tool_permissions_agent; +DROP TABLE IF EXISTS agent_tool_permissions; + +DROP INDEX IF EXISTS idx_agents_project_name_active; +DROP INDEX IF EXISTS idx_agents_workspace_name_active; +DROP INDEX IF EXISTS idx_agents_deleted_at; +DROP INDEX IF EXISTS idx_agents_project; +DROP INDEX IF EXISTS idx_agents_workspace; +DROP TABLE IF EXISTS agents; diff --git a/apps/api/migrations/000007_agents.up.sql b/apps/api/migrations/000007_agents.up.sql new file mode 100644 index 00000000..c709a419 --- /dev/null +++ b/apps/api/migrations/000007_agents.up.sql @@ -0,0 +1,85 @@ +CREATE TABLE agents ( + id UUID PRIMARY KEY, + workspace_id UUID NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, + project_id UUID REFERENCES projects (id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + description TEXT NOT NULL DEFAULT '', + avatar TEXT NOT NULL DEFAULT '', + instructions TEXT NOT NULL DEFAULT '', + model VARCHAR(100) NOT NULL DEFAULT '', + enabled BOOLEAN NOT NULL DEFAULT TRUE, + autonomy_level VARCHAR(50) NOT NULL DEFAULT 'suggest', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + created_by_id UUID REFERENCES users (id) ON DELETE SET NULL, + updated_by_id UUID REFERENCES users (id) ON DELETE SET NULL +); +CREATE INDEX idx_agents_workspace ON agents (workspace_id); +CREATE INDEX idx_agents_project ON agents (project_id); +CREATE INDEX idx_agents_deleted_at ON agents (deleted_at); +CREATE UNIQUE INDEX idx_agents_workspace_name_active + ON agents (workspace_id, lower(name)) + WHERE project_id IS NULL AND deleted_at IS NULL; +CREATE UNIQUE INDEX idx_agents_project_name_active + ON agents (project_id, lower(name)) + WHERE project_id IS NOT NULL AND deleted_at IS NULL; + +CREATE TABLE agent_tool_permissions ( + id UUID PRIMARY KEY, + agent_id UUID NOT NULL REFERENCES agents (id) ON DELETE CASCADE, + tool VARCHAR(100) NOT NULL, + scope VARCHAR(100) NOT NULL DEFAULT 'workspace', + config JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); +CREATE INDEX idx_agent_tool_permissions_agent ON agent_tool_permissions (agent_id); +CREATE UNIQUE INDEX idx_agent_tool_permissions_agent_tool_scope_active + ON agent_tool_permissions (agent_id, tool, scope) + WHERE deleted_at IS NULL; + +CREATE TABLE agent_issue_assignments ( + id UUID PRIMARY KEY, + issue_id UUID NOT NULL REFERENCES issues (id) ON DELETE CASCADE, + agent_id UUID NOT NULL REFERENCES agents (id) ON DELETE CASCADE, + project_id UUID NOT NULL REFERENCES projects (id) ON DELETE CASCADE, + workspace_id UUID NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, + assigned_by_id UUID REFERENCES users (id) ON DELETE SET NULL, + reason TEXT NOT NULL DEFAULT '', + status VARCHAR(50) NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); +CREATE INDEX idx_agent_issue_assignments_issue ON agent_issue_assignments (issue_id); +CREATE INDEX idx_agent_issue_assignments_agent ON agent_issue_assignments (agent_id); +CREATE INDEX idx_agent_issue_assignments_workspace ON agent_issue_assignments (workspace_id); +CREATE UNIQUE INDEX idx_agent_issue_assignments_issue_agent_active + ON agent_issue_assignments (issue_id, agent_id) + WHERE deleted_at IS NULL; + +CREATE TABLE agent_runs ( + id UUID PRIMARY KEY, + agent_id UUID NOT NULL REFERENCES agents (id) ON DELETE CASCADE, + issue_id UUID REFERENCES issues (id) ON DELETE CASCADE, + project_id UUID NOT NULL REFERENCES projects (id) ON DELETE CASCADE, + workspace_id UUID NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, + trigger VARCHAR(100) NOT NULL DEFAULT 'manual', + status VARCHAR(50) NOT NULL DEFAULT 'queued', + input JSONB NOT NULL DEFAULT '{}', + output JSONB NOT NULL DEFAULT '{}', + error TEXT NOT NULL DEFAULT '', + queued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + cancelled_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + created_by_id UUID REFERENCES users (id) ON DELETE SET NULL +); +CREATE INDEX idx_agent_runs_agent ON agent_runs (agent_id); +CREATE INDEX idx_agent_runs_issue ON agent_runs (issue_id); +CREATE INDEX idx_agent_runs_workspace_status ON agent_runs (workspace_id, status); diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index 78f4c401..414063ef 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -919,3 +919,93 @@ export interface RecordRecentVisitRequest { entity_identifier?: string | null; project_id?: string | null; } + +export type AgentAutonomyLevel = + | 'suggest' + | 'comment' + | 'modify_issue' + | 'github_draft' + | 'github_reviewed'; + +export type AgentAssignmentStatus = 'active' | 'cancelled' | 'completed'; +export type AgentRunStatus = + | 'queued' + | 'running' + | 'needs_review' + | 'completed' + | 'failed' + | 'cancelled'; + +export interface AgentToolPermissionApiResponse { + id: string; + agent_id: string; + tool: string; + scope: string; + config?: Record; + created_at: string; + updated_at: string; +} + +export interface AgentApiResponse { + id: string; + workspace_id: string; + project_id?: string | null; + name: string; + description: string; + avatar: string; + instructions: string; + model: string; + enabled: boolean; + autonomy_level: AgentAutonomyLevel; + tool_permissions: AgentToolPermissionApiResponse[]; + created_at: string; + updated_at: string; +} + +export interface AgentIssueAssignmentApiResponse { + id: string; + issue_id: string; + agent_id: string; + project_id: string; + workspace_id: string; + assigned_by_id?: string | null; + reason: string; + status: AgentAssignmentStatus; + created_at: string; + updated_at: string; +} + +export interface AgentRunApiResponse { + id: string; + agent_id: string; + issue_id?: string | null; + project_id: string; + workspace_id: string; + trigger: string; + status: AgentRunStatus; + input?: Record; + output?: Record; + error: string; + queued_at: string; + started_at?: string | null; + completed_at?: string | null; + cancelled_at?: string | null; + created_at: string; + updated_at: string; +} + +export interface AgentUpsertRequest { + project_id?: string | null; + name: string; + description?: string; + avatar?: string; + instructions?: string; + model?: string; + enabled?: boolean; + autonomy_level?: AgentAutonomyLevel; + tool_permissions?: Array<{ + tool: string; + scope: string; + config: Record; + }>; +} diff --git a/apps/web/src/components/agents/AgentSettingsPanel.tsx b/apps/web/src/components/agents/AgentSettingsPanel.tsx new file mode 100644 index 00000000..0d970e96 --- /dev/null +++ b/apps/web/src/components/agents/AgentSettingsPanel.tsx @@ -0,0 +1,457 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Pencil, Plus, Trash2 } from 'lucide-react'; +import type { + AgentApiResponse, + AgentAutonomyLevel, + AgentUpsertRequest, + ProjectApiResponse, +} from '../../api/types'; +import { getApiErrorMessage } from '../../api/client'; +import { agentService } from '../../services/agentService'; +import { Button, Modal, Tooltip } from '../ui'; +import { AgentMark } from './agentUi'; +import { AUTONOMY_OPTIONS, TOOL_OPTIONS, autonomyLabel, toolLabel } from './agentOptions'; + +interface AgentSettingsPanelProps { + workspaceSlug: string; + projects: ProjectApiResponse[]; +} + +interface AgentFormState { + name: string; + description: string; + instructions: string; + model: string; + projectId: string; + autonomyLevel: AgentAutonomyLevel; + tools: string[]; + enabled: boolean; +} + +const EMPTY_FORM: AgentFormState = { + name: '', + description: '', + instructions: '', + model: 'gpt-5', + projectId: '', + autonomyLevel: 'suggest', + tools: ['issue.read'], + enabled: true, +}; + +function formFromAgent(agent: AgentApiResponse): AgentFormState { + return { + name: agent.name, + description: agent.description ?? '', + instructions: agent.instructions ?? '', + model: agent.model || 'gpt-5', + projectId: agent.project_id ?? '', + autonomyLevel: agent.autonomy_level, + tools: agent.tool_permissions?.map((permission) => permission.tool) ?? [], + enabled: agent.enabled, + }; +} + +export function AgentSettingsPanel({ workspaceSlug, projects }: AgentSettingsPanelProps) { + const [agents, setAgents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [editingAgent, setEditingAgent] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + const [form, setForm] = useState(EMPTY_FORM); + const [saving, setSaving] = useState(false); + const [deletingId, setDeletingId] = useState(null); + const [togglingId, setTogglingId] = useState(null); + + const loadAgents = useCallback(async () => { + setLoading(true); + setError(null); + try { + setAgents(await agentService.list(workspaceSlug)); + } catch (err) { + setError(getApiErrorMessage(err)); + } finally { + setLoading(false); + } + }, [workspaceSlug]); + + useEffect(() => { + void loadAgents(); + }, [loadAgents]); + + const enabledCount = useMemo(() => agents.filter((agent) => agent.enabled).length, [agents]); + const toolCount = useMemo( + () => new Set(agents.flatMap((agent) => agent.tool_permissions?.map((p) => p.tool) ?? [])).size, + [agents], + ); + + const openCreate = () => { + setEditingAgent(null); + setForm({ ...EMPTY_FORM, tools: [...EMPTY_FORM.tools] }); + setError(null); + setModalOpen(true); + }; + + const openEdit = (agent: AgentApiResponse) => { + setEditingAgent(agent); + setForm(formFromAgent(agent)); + setError(null); + setModalOpen(true); + }; + + const saveAgent = async () => { + if (!form.name.trim()) return; + setSaving(true); + setError(null); + const payload: AgentUpsertRequest = { + name: form.name.trim(), + description: form.description.trim(), + instructions: form.instructions.trim(), + model: form.model, + enabled: form.enabled, + autonomy_level: form.autonomyLevel, + tool_permissions: form.tools.map((tool) => ({ tool, scope: 'workspace', config: {} })), + }; + if (!editingAgent) payload.project_id = form.projectId || null; + + try { + const saved = editingAgent + ? await agentService.update(workspaceSlug, editingAgent.id, payload) + : await agentService.create(workspaceSlug, payload); + setAgents((current) => { + const exists = current.some((agent) => agent.id === saved.id); + return exists + ? current.map((agent) => (agent.id === saved.id ? saved : agent)) + : [...current, saved].sort((a, b) => a.name.localeCompare(b.name)); + }); + setModalOpen(false); + } catch (err) { + setError(getApiErrorMessage(err)); + } finally { + setSaving(false); + } + }; + + const toggleAgent = async (agent: AgentApiResponse) => { + setTogglingId(agent.id); + setError(null); + try { + const updated = await agentService.update(workspaceSlug, agent.id, { + name: agent.name, + enabled: !agent.enabled, + }); + setAgents((current) => current.map((item) => (item.id === agent.id ? updated : item))); + } catch (err) { + setError(getApiErrorMessage(err)); + } finally { + setTogglingId(null); + } + }; + + const deleteAgent = async (agent: AgentApiResponse) => { + if (!window.confirm(`Delete ${agent.name}? Existing run history will be retained.`)) return; + setDeletingId(agent.id); + setError(null); + try { + await agentService.delete(workspaceSlug, agent.id); + setAgents((current) => current.filter((item) => item.id !== agent.id)); + } catch (err) { + setError(getApiErrorMessage(err)); + } finally { + setDeletingId(null); + } + }; + + return ( +
+
+
+

Agents

+

+ Configure autonomous teammates, their boundaries, and the tools they can use. +

+
+ +
+ +
+ {[ + ['Total agents', agents.length], + ['Enabled', enabledCount], + ['Tools in use', toolCount], + ].map(([label, value], index) => ( +
0 ? 'border-l border-(--border-subtle)' : ''}`} + > +

{value}

+

{label}

+
+ ))} +
+ + {error && ( +
+ {error} +
+ )} + + {loading ? ( +
Loading agents...
+ ) : agents.length === 0 ? ( +
+ +

No agents yet

+

+ Create one, give it a job, then assign it from any work item. +

+ +
+ ) : ( +
+ {agents.map((agent) => { + const project = projects.find((item) => item.id === agent.project_id); + return ( +
+
+ +
+
+

{agent.name}

+ + {autonomyLabel(agent.autonomy_level)} + + + {project ? project.name : 'All projects'} + +
+

+ {agent.description || 'No description provided.'} +

+
+ {(agent.tool_permissions ?? []).length ? ( + agent.tool_permissions.map((permission) => ( + + {toolLabel(permission.tool)} + + )) + ) : ( + No tools granted + )} +
+
+
+ + + + + + + +
+
+
+ ); + })} +
+ )} + + !saving && setModalOpen(false)} + title={editingAgent ? 'Edit agent' : 'Create agent'} + className="max-h-[calc(100dvh-2rem)] max-w-2xl overflow-y-auto" + footer={ + <> + + + + } + > +
+
+ + +
+ + + +