multica/server/internal/handler/agent.go
Naiyuan Qing 759dd741bd refactor(server): extract inbox creation to bus listeners, add agent visibility filtering
- Move all CreateInboxItem calls from handlers to centralized inbox_listeners.go
- Enrich issue:updated payload with change context (assignee_changed, status_changed, prev values)
- Enrich comment:created payload with issue context (assignee info)
- Bus listeners handle: issue assign, unassign, reassign, status change, comment notification
- ListAgents filters private agents: only visible to owner_id or workspace admin
- Zero CreateInboxItem calls remain in handler package

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:24:45 +08:00

441 lines
14 KiB
Go

package handler
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
type AgentResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
RuntimeID string `json:"runtime_id"`
Name string `json:"name"`
Description string `json:"description"`
AvatarURL *string `json:"avatar_url"`
RuntimeMode string `json:"runtime_mode"`
RuntimeConfig any `json:"runtime_config"`
Visibility string `json:"visibility"`
Status string `json:"status"`
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
OwnerID *string `json:"owner_id"`
Skills string `json:"skills"`
Tools any `json:"tools"`
Triggers any `json:"triggers"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func agentToResponse(a db.Agent) AgentResponse {
var rc any
if a.RuntimeConfig != nil {
json.Unmarshal(a.RuntimeConfig, &rc)
}
if rc == nil {
rc = map[string]any{}
}
var tools any
if a.Tools != nil {
json.Unmarshal(a.Tools, &tools)
}
if tools == nil {
tools = []any{}
}
var triggers any
if a.Triggers != nil {
json.Unmarshal(a.Triggers, &triggers)
}
if triggers == nil {
triggers = []any{}
}
return AgentResponse{
ID: uuidToString(a.ID),
WorkspaceID: uuidToString(a.WorkspaceID),
RuntimeID: uuidToString(a.RuntimeID),
Name: a.Name,
Description: a.Description,
AvatarURL: textToPtr(a.AvatarUrl),
RuntimeMode: a.RuntimeMode,
RuntimeConfig: rc,
Visibility: a.Visibility,
Status: a.Status,
MaxConcurrentTasks: a.MaxConcurrentTasks,
OwnerID: uuidToPtr(a.OwnerID),
Skills: a.Skills,
Tools: tools,
Triggers: triggers,
CreatedAt: timestampToString(a.CreatedAt),
UpdatedAt: timestampToString(a.UpdatedAt),
}
}
type AgentTaskResponse struct {
ID string `json:"id"`
AgentID string `json:"agent_id"`
RuntimeID string `json:"runtime_id"`
IssueID string `json:"issue_id"`
Status string `json:"status"`
Priority int32 `json:"priority"`
DispatchedAt *string `json:"dispatched_at"`
StartedAt *string `json:"started_at"`
CompletedAt *string `json:"completed_at"`
Result any `json:"result"`
Error *string `json:"error"`
Context any `json:"context,omitempty"`
CreatedAt string `json:"created_at"`
}
func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse {
var result any
if t.Result != nil {
json.Unmarshal(t.Result, &result)
}
var ctx any
if t.Context != nil {
json.Unmarshal(t.Context, &ctx)
}
return AgentTaskResponse{
ID: uuidToString(t.ID),
AgentID: uuidToString(t.AgentID),
RuntimeID: uuidToString(t.RuntimeID),
IssueID: uuidToString(t.IssueID),
Status: t.Status,
Priority: t.Priority,
DispatchedAt: timestampToPtr(t.DispatchedAt),
StartedAt: timestampToPtr(t.StartedAt),
CompletedAt: timestampToPtr(t.CompletedAt),
Result: result,
Error: textToPtr(t.Error),
Context: ctx,
CreatedAt: timestampToString(t.CreatedAt),
}
}
func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
workspaceID := resolveWorkspaceID(r)
member, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found")
if !ok {
return
}
agents, err := h.Queries.ListAgents(r.Context(), parseUUID(workspaceID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list agents")
return
}
userID := requestUserID(r)
isAdmin := roleAllowed(member.Role, "owner", "admin")
// Filter private agents: only visible to owner_id or workspace admin
var visible []AgentResponse
for _, a := range agents {
if a.Visibility == "private" && !isAdmin && uuidToString(a.OwnerID) != userID {
continue
}
visible = append(visible, agentToResponse(a))
}
if visible == nil {
visible = []AgentResponse{}
}
writeJSON(w, http.StatusOK, visible)
}
func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
agent, ok := h.loadAgentForUser(w, r, id)
if !ok {
return
}
writeJSON(w, http.StatusOK, agentToResponse(agent))
}
type CreateAgentRequest struct {
Name string `json:"name"`
Description string `json:"description"`
AvatarURL *string `json:"avatar_url"`
RuntimeID string `json:"runtime_id"`
RuntimeConfig any `json:"runtime_config"`
Visibility string `json:"visibility"`
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
Skills string `json:"skills"`
Tools any `json:"tools"`
Triggers any `json:"triggers"`
}
func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
workspaceID := resolveWorkspaceID(r)
if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin"); !ok {
return
}
var req CreateAgentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
ownerID, ok := requireUserID(w, r)
if !ok {
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if req.RuntimeID == "" {
writeError(w, http.StatusBadRequest, "runtime_id is required")
return
}
if req.Visibility == "" {
req.Visibility = "workspace"
}
if req.MaxConcurrentTasks == 0 {
req.MaxConcurrentTasks = 1
}
runtime, err := h.Queries.GetAgentRuntimeForWorkspace(r.Context(), db.GetAgentRuntimeForWorkspaceParams{
ID: parseUUID(req.RuntimeID),
WorkspaceID: parseUUID(workspaceID),
})
if err != nil {
writeError(w, http.StatusBadRequest, "invalid runtime_id")
return
}
rc, _ := json.Marshal(req.RuntimeConfig)
if req.RuntimeConfig == nil {
rc = []byte("{}")
}
tools, _ := json.Marshal(req.Tools)
if req.Tools == nil {
tools = []byte("[]")
}
triggers, _ := json.Marshal(req.Triggers)
if req.Triggers == nil {
triggers = []byte("[]")
}
agent, err := h.Queries.CreateAgent(r.Context(), db.CreateAgentParams{
WorkspaceID: parseUUID(workspaceID),
Name: req.Name,
Description: req.Description,
AvatarUrl: ptrToText(req.AvatarURL),
RuntimeMode: runtime.RuntimeMode,
RuntimeConfig: rc,
RuntimeID: runtime.ID,
Visibility: req.Visibility,
MaxConcurrentTasks: req.MaxConcurrentTasks,
OwnerID: parseUUID(ownerID),
Skills: req.Skills,
Tools: tools,
Triggers: triggers,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create agent: "+err.Error())
return
}
if runtime.Status == "online" {
h.TaskService.ReconcileAgentStatus(r.Context(), agent.ID)
agent, _ = h.Queries.GetAgent(r.Context(), agent.ID)
}
// Best-effort: create an initialization issue assigned to the new agent.
h.createAgentInitIssue(r.Context(), agent, parseUUID(ownerID))
resp := agentToResponse(agent)
h.publish(protocol.EventAgentCreated, workspaceID, "member", ownerID, map[string]any{"agent": resp})
writeJSON(w, http.StatusCreated, resp)
}
// createAgentInitIssue creates an initialization issue assigned to a newly created agent.
// It incorporates workspace context so the agent can set up its environment.
// Failures are silently ignored — the agent creation itself has already succeeded.
func (h *Handler) createAgentInitIssue(ctx context.Context, agent db.Agent, creatorID pgtype.UUID) {
ws, err := h.Queries.GetWorkspace(ctx, agent.WorkspaceID)
if err != nil {
return
}
var desc string
if ws.Context.Valid && ws.Context.String != "" {
desc = fmt.Sprintf("Initialize the development environment for agent **%s**.\n\n## Workspace Context\n\n%s\n\n## Instructions\n\n- Set up the local development environment based on the workspace context above\n- Clone and configure any referenced repositories\n- Verify access to the codebase and tools\n- Report back on what was set up and any issues encountered", agent.Name, ws.Context.String)
} else {
desc = fmt.Sprintf("Initialize the development environment for agent **%s**.\n\n## Instructions\n\n- Explore the local working directory and understand the project structure\n- Verify access to the codebase and tools\n- Report back on what was found and any issues encountered", agent.Name)
}
issue, err := h.Queries.CreateIssue(ctx, db.CreateIssueParams{
WorkspaceID: agent.WorkspaceID,
Title: "Initialize environment for " + agent.Name,
Description: strToText(desc),
Status: "todo",
Priority: "medium",
AssigneeType: pgtype.Text{String: "agent", Valid: true},
AssigneeID: agent.ID,
CreatorType: "member",
CreatorID: creatorID,
AcceptanceCriteria: []byte("[]"),
ContextRefs: []byte("[]"),
Position: 0,
})
if err != nil {
return
}
h.publish(protocol.EventIssueCreated, uuidToString(agent.WorkspaceID), "system", "", map[string]any{"issue": issueToResponse(issue)})
// Enqueue the task directly — we know the agent is assigned and status is "todo".
if _, err := h.TaskService.EnqueueTaskForIssue(ctx, issue); err != nil {
log.Printf("createAgentInitIssue: enqueue task failed for issue %s: %v", issue.Title, err)
}
}
type UpdateAgentRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`
AvatarURL *string `json:"avatar_url"`
RuntimeID *string `json:"runtime_id"`
RuntimeConfig any `json:"runtime_config"`
Visibility *string `json:"visibility"`
Status *string `json:"status"`
MaxConcurrentTasks *int32 `json:"max_concurrent_tasks"`
Skills *string `json:"skills"`
Tools any `json:"tools"`
Triggers any `json:"triggers"`
}
func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
agent, ok := h.loadAgentForUser(w, r, id)
if !ok {
return
}
if _, ok := h.requireWorkspaceRole(w, r, uuidToString(agent.WorkspaceID), "agent not found", "owner", "admin"); !ok {
return
}
var req UpdateAgentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
params := db.UpdateAgentParams{
ID: parseUUID(id),
}
if req.Name != nil {
params.Name = pgtype.Text{String: *req.Name, Valid: true}
}
if req.Description != nil {
params.Description = pgtype.Text{String: *req.Description, Valid: true}
}
if req.AvatarURL != nil {
params.AvatarUrl = pgtype.Text{String: *req.AvatarURL, Valid: true}
}
if req.RuntimeConfig != nil {
rc, _ := json.Marshal(req.RuntimeConfig)
params.RuntimeConfig = rc
}
if req.RuntimeID != nil {
runtime, err := h.Queries.GetAgentRuntimeForWorkspace(r.Context(), db.GetAgentRuntimeForWorkspaceParams{
ID: parseUUID(*req.RuntimeID),
WorkspaceID: agent.WorkspaceID,
})
if err != nil {
writeError(w, http.StatusBadRequest, "invalid runtime_id")
return
}
params.RuntimeID = runtime.ID
params.RuntimeMode = pgtype.Text{String: runtime.RuntimeMode, Valid: true}
}
if req.Visibility != nil {
params.Visibility = pgtype.Text{String: *req.Visibility, Valid: true}
}
if req.Status != nil {
params.Status = pgtype.Text{String: *req.Status, Valid: true}
}
if req.MaxConcurrentTasks != nil {
params.MaxConcurrentTasks = pgtype.Int4{Int32: *req.MaxConcurrentTasks, Valid: true}
}
if req.Skills != nil {
params.Skills = pgtype.Text{String: *req.Skills, Valid: true}
}
if req.Tools != nil {
tools, _ := json.Marshal(req.Tools)
params.Tools = tools
}
if req.Triggers != nil {
triggers, _ := json.Marshal(req.Triggers)
params.Triggers = triggers
}
agent, err := h.Queries.UpdateAgent(r.Context(), params)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update agent: "+err.Error())
return
}
resp := agentToResponse(agent)
userID := requestUserID(r)
h.publish(protocol.EventAgentStatus, uuidToString(agent.WorkspaceID), "member", userID, map[string]any{"agent": resp})
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) DeleteAgent(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
agent, ok := h.loadAgentForUser(w, r, id)
if !ok {
return
}
wsID := uuidToString(agent.WorkspaceID)
// Require owner or admin role
if _, ok := h.requireWorkspaceRole(w, r, wsID, "agent not found", "owner", "admin"); !ok {
return
}
err := h.Queries.DeleteAgent(r.Context(), parseUUID(id))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete agent")
return
}
userID := requestUserID(r)
h.publish(protocol.EventAgentDeleted, wsID, "member", userID, map[string]any{"agent_id": id, "workspace_id": wsID})
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) ListAgentTasks(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, ok := h.loadAgentForUser(w, r, id); !ok {
return
}
tasks, err := h.Queries.ListAgentTasks(r.Context(), parseUUID(id))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list agent tasks")
return
}
resp := make([]AgentTaskResponse, len(tasks))
for i, t := range tasks {
resp[i] = taskToResponse(t)
}
writeJSON(w, http.StatusOK, resp)
}