Files
kandev/apps/backend/internal/office/dashboard/handler.go
T
2026-05-16 20:07:57 +01:00

750 lines
24 KiB
Go

package dashboard
import (
"context"
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/office/configloader"
"github.com/kandev/kandev/internal/office/models"
"github.com/kandev/kandev/internal/office/repository/sqlite"
"github.com/kandev/kandev/internal/office/shared"
"go.uber.org/zap"
"golang.org/x/sync/errgroup"
)
// labelFetcher is the subset of the label repository used by the dashboard handler.
type labelFetcher interface {
ListLabelsForTask(ctx context.Context, taskID string) ([]*sqlite.Label, error)
ListLabelsForTasks(ctx context.Context, taskIDs []string) (map[string][]*sqlite.Label, error)
}
// Handler provides HTTP handlers for dashboard, inbox, activity, run,
// task search, git, and meta routes.
type Handler struct {
svc *DashboardService
labels labelFetcher
gitMgr *configloader.GitManager
runDetail RunDetailRepo
agentSummary AgentSummaryRepository
logger *logger.Logger
}
// NewHandler creates a new dashboard Handler.
// gitMgr may be nil if git operations are not configured.
// labelRepo may be nil; when nil, tasks are returned with empty label slices.
// labelRepo doubles as the RunDetailRepo and AgentSummaryRepository
// when it satisfies those interfaces (the production *sqlite.Repository
// does); a fake repo that does not implement them causes the
// corresponding endpoints to respond 503.
func NewHandler(svc *DashboardService, labelRepo labelFetcher, gitMgr *configloader.GitManager, log *logger.Logger) *Handler {
h := &Handler{
svc: svc,
labels: labelRepo,
gitMgr: gitMgr,
logger: log.WithFields(zap.String("component", "office-dashboard-handler")),
}
if r, ok := labelRepo.(RunDetailRepo); ok {
h.runDetail = r
}
if r, ok := labelRepo.(AgentSummaryRepository); ok {
h.agentSummary = r
}
return h
}
// RegisterRoutes registers all dashboard-related routes on the given router group.
func RegisterRoutes(api *gin.RouterGroup, svc *DashboardService, labelRepo labelFetcher, gitMgr *configloader.GitManager, log *logger.Logger) {
h := NewHandler(svc, labelRepo, gitMgr, log)
api.GET("/meta", h.getMeta)
api.GET("/workspaces/:wsId/dashboard", h.getDashboard)
api.GET("/workspaces/:wsId/live-runs", h.getLiveRuns)
api.GET("/workspaces/:wsId/agent-summaries", h.getAgentSummaries)
api.GET("/agents/:id/summary", h.getAgentSummary)
api.GET("/agents/:id/runs", h.listAgentRuns)
api.GET("/agents/:id/runs/:runId", h.getAgentRunDetail)
api.GET("/workspaces/:wsId/inbox", h.getInbox)
api.POST("/inbox/dismiss", h.dismissInboxItem)
api.GET("/workspaces/:wsId/activity", h.listActivity)
api.GET("/workspaces/:wsId/runs", h.listRuns)
api.GET("/workspaces/:wsId/tasks/search", h.searchTasks)
api.GET("/workspaces/:wsId/tasks", h.listTasks)
api.GET("/workspaces/:wsId/settings", h.getWorkspaceSettings)
api.PATCH("/workspaces/:wsId/settings", h.updateWorkspaceSettings)
api.GET("/tasks/:id", h.getTask)
api.PATCH("/tasks/:id", h.updateTask)
api.GET("/tasks/:id/comments", h.listComments)
api.POST("/tasks/:id/comments", h.createComment)
api.POST("/tasks/:id/blockers", h.addTaskBlocker)
api.DELETE("/tasks/:id/blockers/:blockerId", h.removeTaskBlocker)
api.GET("/tasks/:id/reviewers", h.listTaskReviewers)
api.POST("/tasks/:id/reviewers", h.addTaskReviewer)
api.DELETE("/tasks/:id/reviewers/:agentId", h.removeTaskReviewer)
api.GET("/tasks/:id/approvers", h.listTaskApprovers)
api.POST("/tasks/:id/approvers", h.addTaskApprover)
api.DELETE("/tasks/:id/approvers/:agentId", h.removeTaskApprover)
api.POST("/tasks/:id/approve", h.approveTask)
api.POST("/tasks/:id/request-changes", h.requestTaskChanges)
api.GET("/tasks/:id/decisions", h.listTaskDecisions)
api.POST("/workspaces/:wsId/git/clone", h.gitClone)
api.POST("/workspaces/:wsId/git/pull", h.gitPull)
api.POST("/workspaces/:wsId/git/push", h.gitPush)
api.GET("/workspaces/:wsId/git/status", h.gitStatus)
// Provider routing endpoints (office-provider-routing).
api.GET("/workspaces/:wsId/routing", h.getWorkspaceRouting)
api.PUT("/workspaces/:wsId/routing", h.updateWorkspaceRouting)
api.POST("/workspaces/:wsId/routing/retry", h.retryWorkspaceProvider)
api.GET("/workspaces/:wsId/routing/health", h.listWorkspaceRoutingHealth)
api.GET("/workspaces/:wsId/routing/preview", h.getWorkspaceRoutingPreview)
api.GET("/runs/:id/attempts", h.listRunAttempts)
api.GET("/agents/:id/route", h.getAgentRoute)
}
// -- Dashboard --
func (h *Handler) getDashboard(c *gin.Context) {
wsID := c.Param("wsId")
ctx := c.Request.Context()
// Fetch dashboard aggregate and per-agent summaries in parallel
// (Stream A + C of office optimization). Each call internally batches
// its sub-queries; running them concurrently halves the wall time.
var (
data *models.DashboardData
summaries []AgentSummary
)
g, gctx := errgroup.WithContext(ctx)
g.Go(func() error {
d, err := h.svc.GetDashboardData(gctx, wsID)
if err != nil {
return err
}
data = d
return nil
})
g.Go(func() error {
s, err := h.svc.GetAgentSummaries(gctx, wsID)
if err != nil {
return err
}
summaries = s
return nil
})
if err := g.Wait(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if summaries == nil {
summaries = []AgentSummary{}
}
runActivity := make([]RunActivityDay, len(data.RunActivity))
for i, d := range data.RunActivity {
runActivity[i] = RunActivityDay{
Date: d.Date,
Succeeded: d.Succeeded,
Failed: d.Failed,
Other: d.Other,
}
}
recentTasks := make([]RecentTaskDTO, len(data.RecentTasks))
for i, t := range data.RecentTasks {
recentTasks[i] = RecentTaskDTO{
ID: t.ID,
Identifier: t.Identifier,
Title: t.Title,
Status: dbStateToOfficeStatus(t.Status),
AssigneeAgentProfileID: t.AssigneeAgentProfileID,
UpdatedAt: t.UpdatedAt,
}
}
c.JSON(http.StatusOK, DashboardResponse{
AgentCount: data.AgentCount,
RunningCount: data.RunningCount,
PausedCount: data.PausedCount,
ErrorCount: data.ErrorCount,
MonthSpendSubcents: data.MonthSpendSubcents,
PendingApprovals: data.PendingApprovals,
RecentActivity: data.RecentActivity,
TaskCount: data.TaskCount,
SkillCount: data.SkillCount,
RoutineCount: data.RoutineCount,
RunActivity: runActivity,
TaskBreakdown: TaskBreakdown{
Open: data.TaskBreakdown.Open,
InProgress: data.TaskBreakdown.InProgress,
Blocked: data.TaskBreakdown.Blocked,
Done: data.TaskBreakdown.Done,
},
RecentTasks: recentTasks,
AgentSummaries: summaries,
})
}
// -- Live runs --
func (h *Handler) getLiveRuns(c *gin.Context) {
limit := 4
if v := c.Query("limit"); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
limit = parsed
}
}
runs, err := h.svc.GetLiveRuns(c.Request.Context(), c.Param("wsId"), limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
dtos := make([]LiveRunDTO, len(runs))
for i, r := range runs {
dtos[i] = LiveRunDTO(r)
}
c.JSON(http.StatusOK, LiveRunsResponse{Runs: dtos})
}
// -- Agent summaries --
// getAgentSummaries returns one card-shaped summary per workspace agent.
// Empty workspaces respond with `{"agents": []}` rather than null.
func (h *Handler) getAgentSummaries(c *gin.Context) {
summaries, err := h.svc.GetAgentSummaries(c.Request.Context(), c.Param("wsId"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if summaries == nil {
summaries = []AgentSummary{}
}
c.JSON(http.StatusOK, AgentSummariesResponse{Agents: summaries})
}
// -- Agent dashboard summary --
// getAgentSummary serves the agent dashboard's single-shot data
// payload at GET /agents/:id/summary?days=14. The window is clamped
// in [1, 90]; values outside the range are silently capped.
func (h *Handler) getAgentSummary(c *gin.Context) {
if h.agentSummary == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "agent summary not configured"})
return
}
agentID := c.Param("id")
if agentID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "agent id required"})
return
}
days := 14
if v := c.Query("days"); v != "" {
if parsed, err := strconv.Atoi(v); err == nil {
days = parsed
}
}
summary, err := GetAgentSummary(c.Request.Context(), h.agentSummary, agentID, days)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, summary)
}
// -- Agent runs (paginated) + run detail --
// listAgentRuns serves GET /agents/:id/runs?cursor=&limit=. Default
// limit is 25, capped at 100. Cursor is the requested_at of the
// last row in the previous page (RFC3339); empty cursor returns
// page 1.
func (h *Handler) listAgentRuns(c *gin.Context) {
if h.runDetail == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "run detail not configured"})
return
}
agentID := c.Param("id")
if agentID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "agent id required"})
return
}
limit := 25
if v := c.Query("limit"); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
limit = parsed
}
}
cursor := c.Query("cursor")
cursorID := c.Query("cursor_id")
resp, err := ListAgentRunsPaged(c.Request.Context(), h.runDetail, agentID, cursor, cursorID, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, resp)
}
// getAgentRunDetail serves GET /agents/:id/runs/:runId. Returns the
// per-run aggregate (header + cost rollup + session + invocation +
// tasks_touched + events). 404 when the run id is unknown or when
// the run belongs to a different agent.
func (h *Handler) getAgentRunDetail(c *gin.Context) {
if h.runDetail == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "run detail not configured"})
return
}
agentID := c.Param("id")
runID := c.Param("runId")
if agentID == "" || runID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "agent id and run id required"})
return
}
resp, err := GetRunDetail(c.Request.Context(), h.runDetail, agentID, runID)
if err != nil {
switch {
case errors.Is(err, ErrRunNotFound), errors.Is(err, ErrRunAgentMismatch):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusOK, resp)
}
// Inbox, Activity, and Runs list handlers live in handler_inbox.go.
// -- Task search --
func (h *Handler) searchTasks(c *gin.Context) {
wsID := c.Param("wsId")
query := c.Query("q")
limit := 50
if v := c.Query("limit"); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
limit = parsed
}
}
// Empty query means "no text filter" — return the workspace's tasks
// up to the limit. This matches what pickers (parent, blockers, …)
// expect when populating their candidate lists, and avoids forcing
// every caller to know two endpoints (search + list).
var results []*sqlite.TaskSearchResult
var err error
if query == "" {
results, err = h.svc.ListTasks(c.Request.Context(), wsID, includeSystemFromQuery(c))
if len(results) > limit {
results = results[:limit]
}
} else {
results, err = h.svc.SearchTasks(c.Request.Context(), wsID, query, limit)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
tasks := make([]*TaskSearchResultDTO, len(results))
for i, r := range results {
tasks[i] = &TaskSearchResultDTO{
ID: r.ID,
WorkspaceID: r.WorkspaceID,
Identifier: r.Identifier,
Title: r.Title,
Description: r.Description,
Status: dbStateToOfficeStatus(r.Status),
Priority: normaliseTaskPriority(r.Priority),
ParentID: r.ParentID,
ProjectID: r.ProjectID,
AssigneeAgentProfileID: r.AssigneeAgentProfileID,
Labels: r.Labels,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
}
c.JSON(http.StatusOK, TaskSearchResponse{Tasks: tasks})
}
// -- Tasks --
func (h *Handler) listTasks(c *gin.Context) {
ctx := c.Request.Context()
wsID := c.Param("wsId")
// Stream E (office optimization): when any filter / sort / pagination
// query param is present, route through the keyset-paginated path.
// Without query params, fall back to the legacy unbounded listing so
// existing callers (kanban board, full task tree) keep working.
if hasTaskFilterParams(c) {
h.listTasksFiltered(c, wsID)
return
}
tasks, err := h.svc.ListTasks(ctx, wsID, includeSystemFromQuery(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
dtos := h.attachLabelsToTasks(ctx, tasks)
c.JSON(http.StatusOK, TaskListResponse{Tasks: dtos})
}
// hasTaskFilterParams returns true when the request carries any of the
// Stream E task list query params (filter / sort / pagination).
func hasTaskFilterParams(c *gin.Context) bool {
q := c.Request.URL.Query()
for _, k := range []string{"status", "priority", "assignee", "project", "sort", "limit", "cursor"} {
if q.Get(k) != "" {
return true
}
}
return false
}
// includeSystemFromQuery reads the `include_system` query param and
// returns true only for an explicit "true"/"1". Default false hides
// kandev-managed system tasks (coordination, future routines) from
// the Office Tasks UI.
func includeSystemFromQuery(c *gin.Context) bool {
v := c.Query("include_system")
return v == "true" || v == "1"
}
// listTasksFiltered handles the paginated/filtered task list. Returns the
// same JSON shape as the legacy handler, plus next_cursor / next_id when
// more pages are available.
func (h *Handler) listTasksFiltered(c *gin.Context, wsID string) {
ctx := c.Request.Context()
q := c.Request.URL.Query()
opts := sqlite.ListTasksOptions{
Status: q["status"],
Priority: q["priority"],
AssigneeID: q.Get("assignee"),
ProjectID: q.Get("project"),
CursorValue: q.Get("cursor"),
CursorID: q.Get("cursor_id"),
SortDesc: true,
IncludeSystem: includeSystemFromQuery(c),
}
if sortRaw := q.Get("sort"); sortRaw != "" {
opts.SortField = sqlite.TaskListSortField(sortRaw)
}
if order := q.Get("order"); order == "asc" {
opts.SortDesc = false
}
if lim := q.Get("limit"); lim != "" {
if n, err := strconv.Atoi(lim); err == nil {
opts.Limit = n
}
}
page, err := h.svc.ListTasksFiltered(ctx, wsID, opts)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
dtos := h.attachLabelsToTasks(ctx, page.Tasks)
c.JSON(http.StatusOK, gin.H{
"tasks": dtos,
"next_cursor": page.NextCursor,
"next_id": page.NextID,
})
}
// attachLabelsToTasks runs a single batched ListLabelsForTasks call for
// the supplied rows and projects them into TaskDTOs.
func (h *Handler) attachLabelsToTasks(ctx context.Context, tasks []*sqlite.TaskRow) []*TaskDTO {
labelMap := map[string][]*sqlite.Label{}
if h.labels != nil && len(tasks) > 0 {
taskIDs := make([]string, len(tasks))
for i, t := range tasks {
taskIDs[i] = t.ID
}
labelMap, _ = h.labels.ListLabelsForTasks(ctx, taskIDs)
}
dtos := make([]*TaskDTO, len(tasks))
for i, t := range tasks {
dtos[i] = taskRowToDTO(t, labelMap[t.ID])
}
return dtos
}
func (h *Handler) getTask(c *gin.Context) {
ctx := c.Request.Context()
task, err := h.svc.GetTask(ctx, c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if task == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
var lbls []*sqlite.Label
if h.labels != nil {
lbls, _ = h.labels.ListLabelsForTask(ctx, task.ID)
}
dto := taskRowToDTO(task, lbls)
if parts, perr := h.svc.ListAllTaskParticipants(ctx, task.ID); perr == nil {
dto.Reviewers, dto.Approvers = splitParticipantsByRole(parts)
}
// Populate the main task's BlockedBy so the picker UI and tests can
// observe blocker mutations. Children's BlockedBy is set further down.
if blockerMap, berr := h.svc.ListBlockersForChildren(ctx, []string{task.ID}); berr == nil {
if blockers, ok := blockerMap[task.ID]; ok && len(blockers) > 0 {
dto.BlockedBy = blockers
}
}
h.attachChildren(ctx, task.ID, dto)
h.attachDecisions(ctx, c, task.ID, dto)
statusChanges, _ := h.svc.ListStatusChanges(ctx, task.WorkspaceID, task.ID)
c.JSON(http.StatusOK, TaskResponse{Task: dto, Timeline: buildStatusTimeline(statusChanges)})
}
func (h *Handler) attachChildren(ctx context.Context, taskID string, dto *TaskDTO) {
children, err := h.svc.ListChildTasks(ctx, taskID)
if err != nil || len(children) == 0 {
return
}
childIDs := make([]string, len(children))
for i, child := range children {
childIDs[i] = child.ID
}
blockerMap, _ := h.svc.ListBlockersForChildren(ctx, childIDs)
dto.Children = make([]*TaskDTO, len(children))
for i, child := range children {
childDTO := taskRowToDTO(child, nil)
if blockers, ok := blockerMap[child.ID]; ok && len(blockers) > 0 {
childDTO.BlockedBy = blockers
}
dto.Children[i] = childDTO
}
}
func (h *Handler) attachDecisions(ctx context.Context, c *gin.Context, taskID string, dto *TaskDTO) {
decisions, err := h.svc.ListTaskDecisions(ctx, taskID)
if err != nil {
return
}
dto.Decisions = make([]*DecisionDTO, len(decisions))
for i := range decisions {
dto.Decisions[i] = h.decisionToDTO(c, &decisions[i])
}
}
func buildStatusTimeline(changes []TimelineEvent) []TimelineEventDTO {
timeline := make([]TimelineEventDTO, len(changes))
for i, ev := range changes {
timeline[i] = TimelineEventDTO{
Type: "status_change",
From: ev.From,
To: ev.To,
At: ev.At,
}
}
return timeline
}
func taskRowToDTO(r *sqlite.TaskRow, lbls []*sqlite.Label) *TaskDTO {
labels := make([]LabelDTO, len(lbls))
for i, l := range lbls {
labels[i] = LabelDTO{Name: l.Name, Color: l.Color}
}
return &TaskDTO{
ID: r.ID,
WorkspaceID: r.WorkspaceID,
Identifier: r.Identifier,
Title: r.Title,
Description: r.Description,
Status: dbStateToOfficeStatus(r.Status),
Priority: normaliseTaskPriority(r.Priority),
ParentID: r.ParentID,
ProjectID: r.ProjectID,
AssigneeAgentProfileID: r.AssigneeAgentProfileID,
Labels: labels,
Reviewers: []string{},
Approvers: []string{},
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
IsSystem: r.IsSystem,
}
}
// normaliseTaskPriority guards against legacy/empty values seeping through.
// All persisted rows after migration carry one of the four enum strings, but
// callers that bypass the writer (older test fixtures, raw SQL inserts) may
// still return empty strings; default those to "medium" for safety.
func normaliseTaskPriority(p string) string {
switch p {
case "critical", "high", "medium", "low":
return p
default:
return "medium"
}
}
// UpdateTaskRequest is the request body for PATCH /tasks/:id.
type UpdateTaskRequest struct {
Status string `json:"status"`
Comment string `json:"comment"`
// AssigneeAgentProfileID is a pointer so callers can distinguish "field
// omitted" (nil → no-op) from "explicitly clear assignee" (empty
// string → clear). The picker UI sends "" when the user picks
// "No assignee".
AssigneeAgentProfileID *string `json:"assignee_agent_profile_id"`
// Priority is one of "critical" | "high" | "medium" | "low" when set.
Priority *string `json:"priority,omitempty"`
// ProjectID empty string clears the project; otherwise must be a project
// in the same workspace.
ProjectID *string `json:"project_id,omitempty"`
// ParentID empty string clears the parent; otherwise must be a different
// task than the one being updated.
ParentID *string `json:"parent_id,omitempty"`
// Reopen=true is shorthand for status→todo on a closed task; the
// reactivity pipeline emits task_reopened (or task_reopened_via_comment
// if a comment is also attached).
Reopen bool `json:"reopen,omitempty"`
// Resume=true means the user wants the agent to pick the task back up
// with explicit follow-up intent. A non-empty Comment is REQUIRED so
// the agent has context. Emits task_reopened_via_comment.
Resume bool `json:"resume,omitempty"`
}
// hasAnyField returns true when at least one mutation field is set, so the
// handler can short-circuit empty bodies with a 400.
func (req *UpdateTaskRequest) hasAnyField() bool {
if req.Status != "" || req.Comment != "" {
return true
}
if req.AssigneeAgentProfileID != nil ||
req.Priority != nil || req.ProjectID != nil || req.ParentID != nil {
return true
}
return req.Reopen || req.Resume
}
func (h *Handler) updateTask(c *gin.Context) {
var req UpdateTaskRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !req.hasAnyField() {
c.JSON(http.StatusBadRequest, gin.H{"error": "no mutation fields supplied"})
return
}
if req.Resume && req.Comment == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "resume requires a comment"})
return
}
if req.Status == "" && (req.Reopen || req.Resume) {
req.Status = statusTODOLowercase
}
taskID := c.Param("id")
actorAgentID := agentIDFromCtx(c)
if !h.applyTaskMutations(c, taskID, actorAgentID, &req) {
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// applyTaskMutations runs each mutation in the canonical order and reports
// whether all writes succeeded. When a mutation fails the handler responds
// with the appropriate status code and applyTaskMutations returns false.
//
// Order: assignee → priority → project → parent → status.
// Status is last because it triggers the reactivity pipeline.
func (h *Handler) applyTaskMutations(c *gin.Context, taskID, actorAgentID string, req *UpdateTaskRequest) bool {
ctx := c.Request.Context()
if req.AssigneeAgentProfileID != nil {
if err := h.svc.SetTaskAssigneeAsAgent(ctx, actorAgentID, taskID, *req.AssigneeAgentProfileID); err != nil {
respondMutationError(c, err)
return false
}
}
if req.Priority != nil {
if err := h.svc.UpdateTaskPriority(ctx, taskID, *req.Priority); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return false
}
}
if req.ProjectID != nil {
if err := h.svc.UpdateTaskProjectID(ctx, taskID, *req.ProjectID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return false
}
}
if req.ParentID != nil {
if err := h.svc.UpdateTaskParentID(ctx, taskID, *req.ParentID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return false
}
}
if req.Status != "" {
err := h.svc.UpdateTaskStatus(ctx, TaskStatusUpdateRequest{
TaskID: taskID,
NewStatus: req.Status,
Comment: req.Comment,
ActorAgentID: actorAgentID,
ReopenIntent: req.Reopen || req.Resume,
ResumeIntent: req.Resume,
})
if err != nil {
h.respondStatusUpdateError(c, err)
return false
}
}
return true
}
// respondStatusUpdateError translates UpdateTaskStatus errors into HTTP
// responses. ApprovalsPendingError → 409 with a body listing pending
// approvers (resolved to {agent_profile_id, name}) and the redirected
// status. Everything else → 400.
func (h *Handler) respondStatusUpdateError(c *gin.Context, err error) {
var pending *ApprovalsPendingError
if errors.As(err, &pending) {
c.JSON(http.StatusConflict, gin.H{
"error": err.Error(),
"pending_approvers": h.svc.resolvePendingApprovers(c.Request.Context(), pending.Pending),
"status": statusInReviewLowercase,
})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
// respondMutationError translates a mutation error into the matching HTTP
// response. Forbidden bubbles up as 403; everything else as 500.
func respondMutationError(c *gin.Context, err error) {
if errors.Is(err, shared.ErrForbidden) {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
// agentIDFromCtx returns the agent instance ID from an authenticated request,
// or "" if the caller is not an authenticated agent.
func agentIDFromCtx(c *gin.Context) string {
val, ok := c.Get("agent_caller")
if !ok {
return ""
}
agent, ok := val.(*models.AgentInstance)
if !ok || agent == nil {
return ""
}
return agent.ID
}
// Comment handlers live in handler_comments.go.