package handler import ( "bytes" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "unicode/utf8" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgtype" "github.com/multica-ai/multica/server/internal/analytics" "github.com/multica-ai/multica/server/internal/logger" "github.com/multica-ai/multica/server/internal/service" db "github.com/multica-ai/multica/server/pkg/db/generated" "github.com/multica-ai/multica/server/pkg/protocol" ) // Mirrors AGENT_DESCRIPTION_MAX_LENGTH in packages/core/agents/constants.ts // and the agent_description_length CHECK constraint in migration 060. Counted // in unicode code points (utf8.RuneCountInString), matching Postgres // char_length and the front-end's String.prototype.length-with-counter UX. const maxAgentDescriptionLength = 255 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"` Instructions string `json:"instructions"` AvatarURL *string `json:"avatar_url"` RuntimeMode string `json:"runtime_mode"` RuntimeConfig any `json:"runtime_config"` CustomEnv map[string]string `json:"custom_env"` CustomArgs []string `json:"custom_args"` McpConfig json.RawMessage `json:"mcp_config"` CustomEnvRedacted bool `json:"custom_env_redacted"` McpConfigRedacted bool `json:"mcp_config_redacted"` Visibility string `json:"visibility"` Status string `json:"status"` MaxConcurrentTasks int32 `json:"max_concurrent_tasks"` Model string `json:"model"` OwnerID *string `json:"owner_id"` Skills []SkillResponse `json:"skills"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` ArchivedAt *string `json:"archived_at"` ArchivedBy *string `json:"archived_by"` } 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 customEnv map[string]string if a.CustomEnv != nil { if err := json.Unmarshal(a.CustomEnv, &customEnv); err != nil { slog.Warn("failed to unmarshal agent custom_env", "agent_id", uuidToString(a.ID), "error", err) } } if customEnv == nil { customEnv = map[string]string{} } var customArgs []string if a.CustomArgs != nil { if err := json.Unmarshal(a.CustomArgs, &customArgs); err != nil { slog.Warn("failed to unmarshal agent custom_args", "agent_id", uuidToString(a.ID), "error", err) } } if customArgs == nil { customArgs = []string{} } var mcpConfig json.RawMessage if a.McpConfig != nil { mcpConfig = json.RawMessage(a.McpConfig) } return AgentResponse{ ID: uuidToString(a.ID), WorkspaceID: uuidToString(a.WorkspaceID), RuntimeID: uuidToString(a.RuntimeID), Name: a.Name, Description: a.Description, Instructions: a.Instructions, AvatarURL: textToPtr(a.AvatarUrl), RuntimeMode: a.RuntimeMode, RuntimeConfig: rc, CustomEnv: customEnv, CustomArgs: customArgs, McpConfig: mcpConfig, Visibility: a.Visibility, Status: a.Status, MaxConcurrentTasks: a.MaxConcurrentTasks, Model: a.Model.String, OwnerID: uuidToPtr(a.OwnerID), Skills: []SkillResponse{}, CreatedAt: timestampToString(a.CreatedAt), UpdatedAt: timestampToString(a.UpdatedAt), ArchivedAt: timestampToPtr(a.ArchivedAt), ArchivedBy: uuidToPtr(a.ArchivedBy), } } // RepoData holds repository information included in claim responses so the // daemon can set up worktrees for each workspace repo. type RepoData struct { URL string `json:"url"` Description string `json:"description"` } type AgentTaskResponse struct { ID string `json:"id"` AgentID string `json:"agent_id"` RuntimeID string `json:"runtime_id"` IssueID string `json:"issue_id"` WorkspaceID string `json:"workspace_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"` FailureReason string `json:"failure_reason,omitempty"` // see TaskService.MaybeRetryFailedTask Attempt int32 `json:"attempt"` MaxAttempts int32 `json:"max_attempts"` ParentTaskID *string `json:"parent_task_id,omitempty"` Agent *TaskAgentData `json:"agent,omitempty"` Repos []RepoData `json:"repos,omitempty"` CreatedAt string `json:"created_at"` PriorSessionID string `json:"prior_session_id,omitempty"` // session ID from a previous task on same issue PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on same issue TriggerCommentID *string `json:"trigger_comment_id,omitempty"` // comment that triggered this task TriggerCommentContent string `json:"trigger_comment_content,omitempty"` // content of the triggering comment TriggerSummary *string `json:"trigger_summary,omitempty"` // canonical short description snapshot — comment text / autopilot title — taken at task creation; survives source edits/deletes TriggerAuthorType string `json:"trigger_author_type,omitempty"` // "agent" or "member" — author kind of the triggering comment TriggerAuthorName string `json:"trigger_author_name,omitempty"` // display name of the triggering comment author ChatSessionID string `json:"chat_session_id,omitempty"` // non-empty for chat tasks ChatMessage string `json:"chat_message,omitempty"` // user message for chat tasks AutopilotRunID string `json:"autopilot_run_id,omitempty"` // non-empty for autopilot-spawned tasks AutopilotID string `json:"autopilot_id,omitempty"` // autopilot that spawned this task AutopilotTitle string `json:"autopilot_title,omitempty"` // autopilot title used as task context AutopilotDescription string `json:"autopilot_description,omitempty"` // autopilot description used as task prompt AutopilotSource string `json:"autopilot_source,omitempty"` // manual, schedule, webhook, or api AutopilotTriggerPayload json.RawMessage `json:"autopilot_trigger_payload,omitempty"` // optional trigger payload for webhook/api runs QuickCreatePrompt string `json:"quick_create_prompt,omitempty"` // user's natural-language input for quick-create tasks Kind string `json:"kind"` // discriminator: "comment" | "autopilot" | "chat" | "quick_create" | "direct" — used by the activity row to label tasks that have no linked issue } // TaskAgentData holds agent info included in claim responses so the daemon // can set up the execution environment (branch naming, skill files, instructions). type TaskAgentData struct { ID string `json:"id"` Name string `json:"name"` Instructions string `json:"instructions"` Skills []service.AgentSkillData `json:"skills,omitempty"` CustomEnv map[string]string `json:"custom_env,omitempty"` CustomArgs []string `json:"custom_args,omitempty"` McpConfig json.RawMessage `json:"mcp_config,omitempty"` Model string `json:"model,omitempty"` } func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse { var result any if t.Result != nil { json.Unmarshal(t.Result, &result) } failureReason := "" if t.FailureReason.Valid { failureReason = t.FailureReason.String } 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), FailureReason: failureReason, Attempt: t.Attempt, MaxAttempts: t.MaxAttempts, ParentTaskID: uuidToPtr(t.ParentTaskID), CreatedAt: timestampToString(t.CreatedAt), TriggerCommentID: uuidToPtr(t.TriggerCommentID), TriggerSummary: textToPtr(t.TriggerSummary), // Surface task source so the UI can distinguish issue-linked tasks // from chat-spawned or autopilot-spawned ones; all three may arrive // with issue_id = "" once a task has no linked issue. ChatSessionID: uuidToString(t.ChatSessionID), AutopilotRunID: uuidToString(t.AutopilotRunID), Kind: computeTaskKind(t), } } // computeTaskKind picks the source-discriminator string the activity UI uses // to choose how to render a task row. Computed from the existing FK shape so // no extra DB lookup is needed: chat / autopilot / comment-on-issue (any // triggered task with both an issue_id and trigger_comment_id) / quick_create // (no linked source — the agent is creating the issue itself) / direct // (assignee-driven task on an existing issue). func computeTaskKind(t db.AgentTaskQueue) string { if uuidToString(t.ChatSessionID) != "" { return "chat" } if uuidToString(t.AutopilotRunID) != "" { return "autopilot" } if uuidToString(t.IssueID) == "" { return "quick_create" } if uuidToString(t.TriggerCommentID) != "" { return "comment" } return "direct" } func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) { workspaceID := h.resolveWorkspaceID(r) member, ok := h.workspaceMember(w, r, workspaceID) if !ok { return } userID := requestUserID(r) var agents []db.Agent var err error if r.URL.Query().Get("include_archived") == "true" { agents, err = h.Queries.ListAllAgents(r.Context(), parseUUID(workspaceID)) } else { agents, err = h.Queries.ListAgents(r.Context(), parseUUID(workspaceID)) } if err != nil { writeError(w, http.StatusInternalServerError, "failed to list agents") return } // Batch-load skills for all agents to avoid N+1. skillRows, err := h.Queries.ListAgentSkillsByWorkspace(r.Context(), parseUUID(workspaceID)) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load agent skills") return } skillMap := map[string][]SkillResponse{} for _, row := range skillRows { agentID := uuidToString(row.AgentID) skillMap[agentID] = append(skillMap[agentID], SkillResponse{ ID: uuidToString(row.ID), Name: row.Name, Description: row.Description, }) } // All agents (including private) are visible to workspace members. visible := make([]AgentResponse, 0, len(agents)) for _, a := range agents { resp := agentToResponse(a) if skills, ok := skillMap[resp.ID]; ok { resp.Skills = skills } // Redact sensitive fields for users who are not the agent owner or workspace owner/admin. if !canViewAgentEnv(a, userID, member.Role) { redactEnv(&resp) redactMcpConfig(&resp) } visible = append(visible, resp) } 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 } resp := agentToResponse(agent) skills, err := h.Queries.ListAgentSkills(r.Context(), agent.ID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load agent skills") return } if len(skills) > 0 { resp.Skills = make([]SkillResponse, len(skills)) for i, s := range skills { resp.Skills[i] = skillToResponse(s) } } // Redact sensitive fields for users who are not the agent owner or workspace owner/admin. userID := requestUserID(r) if member, ok := ctxMember(r.Context()); ok { if !canViewAgentEnv(agent, userID, member.Role) { redactEnv(&resp) redactMcpConfig(&resp) } } writeJSON(w, http.StatusOK, resp) } type CreateAgentRequest struct { Name string `json:"name"` Description string `json:"description"` Instructions string `json:"instructions"` AvatarURL *string `json:"avatar_url"` RuntimeID string `json:"runtime_id"` RuntimeConfig any `json:"runtime_config"` CustomEnv map[string]string `json:"custom_env"` CustomArgs []string `json:"custom_args"` McpConfig json.RawMessage `json:"mcp_config"` Visibility string `json:"visibility"` MaxConcurrentTasks int32 `json:"max_concurrent_tasks"` Model string `json:"model"` // Template records which template slug was used to seed this agent // (e.g. "coding" / "planning" / "writing" / "assistant"). Empty when // the caller didn't come from a template picker — the `agent_created` // event still fires with `template=""`, which is the correct signal // for "manually authored agent". Template string `json:"template"` } func decodeJSONBodyWithRawFields(body io.Reader, dst any) (map[string]json.RawMessage, error) { payload, err := io.ReadAll(body) if err != nil { return nil, err } if err := json.Unmarshal(payload, dst); err != nil { return nil, err } var raw map[string]json.RawMessage if err := json.Unmarshal(payload, &raw); err != nil { return nil, err } if raw == nil { raw = map[string]json.RawMessage{} } return raw, nil } func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) { workspaceID := h.resolveWorkspaceID(r) var req CreateAgentRequest rawFields, err := decodeJSONBodyWithRawFields(r.Body, &req) if 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 utf8.RuneCountInString(req.Description) > maxAgentDescriptionLength { writeError(w, http.StatusBadRequest, fmt.Sprintf("description must be %d characters or fewer", maxAgentDescriptionLength)) return } if req.RuntimeID == "" { writeError(w, http.StatusBadRequest, "runtime_id is required") return } if req.Visibility == "" { req.Visibility = "private" } if req.MaxConcurrentTasks == 0 { req.MaxConcurrentTasks = 6 } runtimeUUID, ok := parseUUIDOrBadRequest(w, req.RuntimeID, "runtime_id") if !ok { return } wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") if !ok { return } runtime, err := h.Queries.GetAgentRuntimeForWorkspace(r.Context(), db.GetAgentRuntimeForWorkspaceParams{ ID: runtimeUUID, WorkspaceID: wsUUID, }) if err != nil { writeError(w, http.StatusBadRequest, "invalid runtime_id") return } // Probe workspace agent count BEFORE the insert so the funnel has a // clean "first agent ever in this workspace" signal — Step 4 of // onboarding always lands in this branch. A non-fatal read: if the // list fails we fall through with isFirstAgent=false rather than // blocking creation, since the primary DB operation is the insert. isFirstAgent := false if existing, listErr := h.Queries.ListAgents(r.Context(), wsUUID); listErr == nil { isFirstAgent = len(existing) == 0 } rc, _ := json.Marshal(req.RuntimeConfig) if req.RuntimeConfig == nil { rc = []byte("{}") } ce, _ := json.Marshal(req.CustomEnv) if req.CustomEnv == nil { ce = []byte("{}") } ca, _ := json.Marshal(req.CustomArgs) if req.CustomArgs == nil { ca = []byte("[]") } var mc []byte if rawMcpConfig, ok := rawFields["mcp_config"]; ok && !bytes.Equal(bytes.TrimSpace(rawMcpConfig), []byte("null")) { mc = append([]byte(nil), rawMcpConfig...) } agent, err := h.Queries.CreateAgent(r.Context(), db.CreateAgentParams{ WorkspaceID: wsUUID, Name: req.Name, Description: req.Description, Instructions: req.Instructions, AvatarUrl: ptrToText(req.AvatarURL), RuntimeMode: runtime.RuntimeMode, RuntimeConfig: rc, RuntimeID: runtime.ID, Visibility: req.Visibility, MaxConcurrentTasks: req.MaxConcurrentTasks, OwnerID: parseUUID(ownerID), CustomEnv: ce, CustomArgs: ca, McpConfig: mc, Model: pgtype.Text{String: req.Model, Valid: req.Model != ""}, }) if err != nil { // Unique constraint on (workspace_id, name) — return a clear conflict error // so the UI can show the right message instead of a generic 500. var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == "23505" && pgErr.ConstraintName == "agent_workspace_name_unique" { writeError(w, http.StatusConflict, fmt.Sprintf("an agent named %q already exists in this workspace", req.Name)) return } slog.Warn("create agent failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...) writeError(w, http.StatusInternalServerError, "failed to create agent: "+err.Error()) return } slog.Info("agent created", append(logger.RequestAttrs(r), "agent_id", uuidToString(agent.ID), "name", agent.Name, "workspace_id", workspaceID)...) if runtime.Status == "online" { h.TaskService.ReconcileAgentStatus(r.Context(), agent.ID) agent, _ = h.Queries.GetAgent(r.Context(), agent.ID) } resp := agentToResponse(agent) actorType, actorID := h.resolveActor(r, ownerID, workspaceID) h.publish(protocol.EventAgentCreated, workspaceID, actorType, actorID, map[string]any{"agent": resp}) h.Analytics.Capture(analytics.AgentCreated( ownerID, workspaceID, uuidToString(agent.ID), runtime.Provider, req.Template, isFirstAgent, )) writeJSON(w, http.StatusCreated, resp) } type UpdateAgentRequest struct { Name *string `json:"name"` Description *string `json:"description"` Instructions *string `json:"instructions"` AvatarURL *string `json:"avatar_url"` RuntimeID *string `json:"runtime_id"` RuntimeConfig any `json:"runtime_config"` CustomEnv *map[string]string `json:"custom_env"` CustomArgs *[]string `json:"custom_args"` McpConfig *json.RawMessage `json:"mcp_config"` Visibility *string `json:"visibility"` Status *string `json:"status"` MaxConcurrentTasks *int32 `json:"max_concurrent_tasks"` Model *string `json:"model"` } // canViewAgentEnv checks whether the requesting user is allowed to see the // agent's custom environment variables. Only the agent owner or workspace // owner/admin may view them; for everyone else the field is redacted. func canViewAgentEnv(agent db.Agent, userID string, memberRole string) bool { if roleAllowed(memberRole, "owner", "admin") { return true } return uuidToString(agent.OwnerID) == userID } // redactEnv masks custom_env values in the response when the caller is not // authorised to view them. Keys are preserved so members can see which // variables are configured; values are replaced with "****". func redactEnv(resp *AgentResponse) { masked := make(map[string]string, len(resp.CustomEnv)) for k := range resp.CustomEnv { masked[k] = "****" } resp.CustomEnv = masked resp.CustomEnvRedacted = true } // redactMcpConfig removes the mcp_config value from the response when the caller is not // authorised to view it. The field is set to null; McpConfigRedacted is set to true so // callers know a config exists without seeing its contents (which may contain secrets). func redactMcpConfig(resp *AgentResponse) { if resp.McpConfig != nil { resp.McpConfig = nil resp.McpConfigRedacted = true } } // canManageAgent checks whether the current user can update or archive an agent. // Only the agent owner or workspace owner/admin can manage any agent, // regardless of whether it is public or private. func (h *Handler) canManageAgent(w http.ResponseWriter, r *http.Request, agent db.Agent) bool { wsID := uuidToString(agent.WorkspaceID) member, ok := h.requireWorkspaceRole(w, r, wsID, "agent not found", "owner", "admin", "member") if !ok { return false } isAdmin := roleAllowed(member.Role, "owner", "admin") isAgentOwner := uuidToString(agent.OwnerID) == requestUserID(r) if !isAdmin && !isAgentOwner { writeError(w, http.StatusForbidden, "only the agent owner can manage this agent") return false } return true } 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 !h.canManageAgent(w, r, agent) { return } var req UpdateAgentRequest rawFields, err := decodeJSONBodyWithRawFields(r.Body, &req) if err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } params := db.UpdateAgentParams{ ID: agent.ID, } if req.Name != nil { params.Name = pgtype.Text{String: *req.Name, Valid: true} } if req.Description != nil { if utf8.RuneCountInString(*req.Description) > maxAgentDescriptionLength { writeError(w, http.StatusBadRequest, fmt.Sprintf("description must be %d characters or fewer", maxAgentDescriptionLength)) return } params.Description = pgtype.Text{String: *req.Description, Valid: true} } if req.Instructions != nil { params.Instructions = pgtype.Text{String: *req.Instructions, 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.CustomEnv != nil { ce, _ := json.Marshal(*req.CustomEnv) params.CustomEnv = ce } if req.CustomArgs != nil { ca, _ := json.Marshal(*req.CustomArgs) params.CustomArgs = ca } rawMcpConfig, hasMcpConfig := rawFields["mcp_config"] shouldClearMcpConfig := hasMcpConfig && bytes.Equal(bytes.TrimSpace(rawMcpConfig), []byte("null")) if hasMcpConfig && !shouldClearMcpConfig { params.McpConfig = append([]byte(nil), rawMcpConfig...) } if req.RuntimeID != nil { runtimeUUID, ok := parseUUIDOrBadRequest(w, *req.RuntimeID, "runtime_id") if !ok { return } runtime, err := h.Queries.GetAgentRuntimeForWorkspace(r.Context(), db.GetAgentRuntimeForWorkspaceParams{ ID: runtimeUUID, 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.Model != nil { params.Model = pgtype.Text{String: *req.Model, Valid: true} } agent, err = h.Queries.UpdateAgent(r.Context(), params) if err != nil { slog.Warn("update agent failed", append(logger.RequestAttrs(r), "error", err, "agent_id", id)...) writeError(w, http.StatusInternalServerError, "failed to update agent: "+err.Error()) return } // mcp_config: null in the request means explicitly clear the field. // COALESCE in UpdateAgent cannot set a column to NULL, so we use a dedicated query. if shouldClearMcpConfig { agent, err = h.Queries.ClearAgentMcpConfig(r.Context(), agent.ID) if err != nil { slog.Warn("clear agent mcp_config failed", append(logger.RequestAttrs(r), "error", err, "agent_id", id)...) writeError(w, http.StatusInternalServerError, "failed to clear mcp_config: "+err.Error()) return } } resp := agentToResponse(agent) slog.Info("agent updated", append(logger.RequestAttrs(r), "agent_id", id, "workspace_id", uuidToString(agent.WorkspaceID))...) userID := requestUserID(r) actorType, actorID := h.resolveActor(r, userID, uuidToString(agent.WorkspaceID)) h.publish(protocol.EventAgentStatus, uuidToString(agent.WorkspaceID), actorType, actorID, map[string]any{"agent": resp}) writeJSON(w, http.StatusOK, resp) } func (h *Handler) ArchiveAgent(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") agent, ok := h.loadAgentForUser(w, r, id) if !ok { return } if !h.canManageAgent(w, r, agent) { return } if agent.ArchivedAt.Valid { writeError(w, http.StatusConflict, "agent is already archived") return } userID := requestUserID(r) archived, err := h.Queries.ArchiveAgent(r.Context(), db.ArchiveAgentParams{ ID: agent.ID, ArchivedBy: parseUUID(userID), }) if err != nil { slog.Warn("archive agent failed", append(logger.RequestAttrs(r), "error", err, "agent_id", id)...) writeError(w, http.StatusInternalServerError, "failed to archive agent") return } // Cancel all pending/active tasks for this agent. Discard the returned // rows here — the agent:archived event below already triggers a full // active-tasks invalidation on every connected client, so per-task // task:cancelled events would be redundant noise. if _, err := h.Queries.CancelAgentTasksByAgent(r.Context(), agent.ID); err != nil { slog.Warn("cancel agent tasks on archive failed", append(logger.RequestAttrs(r), "error", err, "agent_id", id)...) } wsID := uuidToString(archived.WorkspaceID) slog.Info("agent archived", append(logger.RequestAttrs(r), "agent_id", id, "workspace_id", wsID)...) resp := agentToResponse(archived) actorType, actorID := h.resolveActor(r, userID, wsID) h.publish(protocol.EventAgentArchived, wsID, actorType, actorID, map[string]any{"agent": resp}) writeJSON(w, http.StatusOK, resp) } func (h *Handler) RestoreAgent(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") agent, ok := h.loadAgentForUser(w, r, id) if !ok { return } if !h.canManageAgent(w, r, agent) { return } if !agent.ArchivedAt.Valid { writeError(w, http.StatusConflict, "agent is not archived") return } restored, err := h.Queries.RestoreAgent(r.Context(), agent.ID) if err != nil { slog.Warn("restore agent failed", append(logger.RequestAttrs(r), "error", err, "agent_id", id)...) writeError(w, http.StatusInternalServerError, "failed to restore agent") return } wsID := uuidToString(restored.WorkspaceID) slog.Info("agent restored", append(logger.RequestAttrs(r), "agent_id", id, "workspace_id", wsID)...) resp := agentToResponse(restored) userID := requestUserID(r) actorType, actorID := h.resolveActor(r, userID, wsID) h.publish(protocol.EventAgentRestored, wsID, actorType, actorID, map[string]any{"agent": resp}) writeJSON(w, http.StatusOK, resp) } // CancelAgentTasks bulk-cancels every active task (queued/dispatched/running) // belonging to an agent. Powers the agents-list "Cancel all tasks" row // action. Same permission gate as archive (canManageAgent — owner or // workspace admin/owner). Each cancelled row triggers a task:cancelled WS // event so connected clients clear their live cards immediately. // // Note: a `running` task on the daemon side won't actually halt for up to // ~5 seconds (daemon polls GetTaskStatus on that interval). The DB row is // marked cancelled instantly, but the child process keeps going briefly; // see daemon/daemon.go:919-942 for the polling loop. Surface this in the // confirm-dialog copy so users aren't surprised by trailing transcript // lines. type cancelAgentTasksResponse struct { Cancelled int `json:"cancelled"` } func (h *Handler) CancelAgentTasks(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") agent, ok := h.loadAgentForUser(w, r, id) if !ok { return } if !h.canManageAgent(w, r, agent) { return } cancelled, err := h.TaskService.CancelTasksForAgent(r.Context(), parseUUID(id)) if err != nil { slog.Warn("cancel agent tasks failed", append(logger.RequestAttrs(r), "error", err, "agent_id", id)...) writeError(w, http.StatusInternalServerError, "failed to cancel tasks") return } slog.Info("agent tasks cancelled", append(logger.RequestAttrs(r), "agent_id", id, "count", len(cancelled))...) writeJSON(w, http.StatusOK, cancelAgentTasksResponse{Cancelled: len(cancelled)}) } func (h *Handler) ListAgentTasks(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") agent, ok := h.loadAgentForUser(w, r, id) if !ok { return } tasks, err := h.Queries.ListAgentTasks(r.Context(), agent.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) } // AgentActivityBucket is one day-bucketed throughput sample for the // Agents-list ACTIVITY sparkline. bucket_at is midnight UTC of the day. type AgentActivityBucket struct { AgentID string `json:"agent_id"` BucketAt string `json:"bucket_at"` TaskCount int32 `json:"task_count"` FailedCount int32 `json:"failed_count"` } // AgentRunCount is the trailing-30-day total task run count per agent, // powering the Agents-list RUNS column. type AgentRunCount struct { AgentID string `json:"agent_id"` RunCount int32 `json:"run_count"` } // GetWorkspaceAgentRunCounts returns 30-day total run counts for every // agent in the workspace. Same single-fetch pattern as live-tasks / // activity to keep the Agents list cheap regardless of agent count. func (h *Handler) GetWorkspaceAgentRunCounts(w http.ResponseWriter, r *http.Request) { workspaceID := h.resolveWorkspaceID(r) if _, ok := h.workspaceMember(w, r, workspaceID); !ok { return } rows, err := h.Queries.GetWorkspaceAgentRunCounts(r.Context(), parseUUID(workspaceID)) if err != nil { writeError(w, http.StatusInternalServerError, "failed to get agent run counts") return } resp := make([]AgentRunCount, len(rows)) for i, row := range rows { resp[i] = AgentRunCount{ AgentID: uuidToString(row.AgentID), RunCount: row.RunCount, } } writeJSON(w, http.StatusOK, resp) } // GetWorkspaceAgentActivity30d returns per-agent daily task counts for the // last 30 days, anchored on completed_at. Single workspace-wide read backs // both the Agents list sparkline (uses the trailing 7 buckets) and the // agent detail "Last 30 days" panel (uses all 30) — one fetch is cheaper // than two. Front-end fills missing days with zero; the back-end omits // empty buckets to keep the response small. func (h *Handler) GetWorkspaceAgentActivity30d(w http.ResponseWriter, r *http.Request) { workspaceID := h.resolveWorkspaceID(r) if _, ok := h.workspaceMember(w, r, workspaceID); !ok { return } rows, err := h.Queries.GetWorkspaceAgentActivity30d(r.Context(), parseUUID(workspaceID)) if err != nil { writeError(w, http.StatusInternalServerError, "failed to get agent activity") return } resp := make([]AgentActivityBucket, len(rows)) for i, row := range rows { resp[i] = AgentActivityBucket{ AgentID: uuidToString(row.AgentID), BucketAt: timestampToString(row.Bucket), TaskCount: row.TaskCount, FailedCount: row.FailedCount, } } writeJSON(w, http.StatusOK, resp) } // ListWorkspaceAgentTaskSnapshot returns the task data the front-end needs to // derive each agent's presence: every active task (queued/dispatched/running) // plus each agent's most recent OUTCOME task (completed/failed only). Cancelled // tasks are excluded from the outcome half by design — cancel is a procedural // signal ("attempt aborted"), not an outcome, so it must not mask a prior // failure. The front-end picks "active wins, else latest outcome"; a failed // outcome stays sticky until the user starts a new task or one succeeds. // Per-agent filtering happens in the front-end against this workspace-wide // snapshot. func (h *Handler) ListWorkspaceAgentTaskSnapshot(w http.ResponseWriter, r *http.Request) { workspaceID := h.resolveWorkspaceID(r) if _, ok := h.workspaceMember(w, r, workspaceID); !ok { return } tasks, err := h.Queries.ListWorkspaceAgentTaskSnapshot(r.Context(), parseUUID(workspaceID)) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list agent task snapshot") return } resp := make([]AgentTaskResponse, len(tasks)) for i, t := range tasks { resp[i] = taskToResponse(t) } writeJSON(w, http.StatusOK, resp) }