mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-13 13:18:56 +02:00
* fix: polish quick-create UX (kind labeling, dark toast, placeholder)
Three small fixes shaken out from using the agent-create flow:
- AgentTaskResponse now carries a `kind` discriminator
("comment" | "autopilot" | "chat" | "quick_create" | "direct"), computed
from the existing FK shape with no extra DB access. The Activity row
uses it to label quick-create tasks as "Creating issue" instead of
falling through to the generic "Untracked" — once the agent finishes
and the new issue is linked, the row transitions to the normal
identifier+title display.
- Sonner Toaster reads `resolvedTheme` instead of `theme`, so toasts
follow the actual dark/light state. Forwarding "system" let sonner
pick its own answer from `prefers-color-scheme`, which in the Electron
renderer can disagree with next-themes' `html.dark` class — the toast
rendered light on a dark UI.
- Agent-create placeholder rephrased to a more conversational example
with a project reference: "let Bohan fix the inbox loading slowness
in the Web project". Drops the priority hint (priority isn't widely
used) and matches how people actually instruct the agent.
* fix(quick-create): link new issue back to task on completion
Addresses the review on PR #1831: completed quick-create tasks were
left with issue_id=NULL forever, so the activity row stayed on
"Creating issue" instead of transitioning to the normal MUL-XXX +
title rendering once the agent finished.
- Server: notifyQuickCreateCompleted now writes the resolved issue id
back to agent_task_queue.issue_id via a new LinkTaskToIssue query
(guarded by `issue_id IS NULL` so it only ever fills the unset
quick-create case). Best-effort: a write failure logs but doesn't
block the inbox notification.
- Frontend: defensive wording fallback — kind=quick_create rows in
terminal status (completed/failed/cancelled) now render as
"Quick create" instead of the active "Creating issue" label,
covering rows whose link write failed or whose agent never
produced an issue at all.
900 lines
33 KiB
Go
900 lines
33 KiB
Go
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)
|
|
}
|