Files
kandev/apps/backend/internal/task/handlers/task_http_handlers.go
T
João Salavisa fbb04aed92 fix(backend): scope task state, move and delete to the calling user (#2503)
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 21:23:03 +01:00

2169 lines
81 KiB
Go

package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/common/constants"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/orchestrator"
storageworkspaces "github.com/kandev/kandev/internal/system/storage/workspaces"
"github.com/kandev/kandev/internal/task/dto"
"github.com/kandev/kandev/internal/task/models"
taskrepository "github.com/kandev/kandev/internal/task/repository"
taskrepo "github.com/kandev/kandev/internal/task/repository/sqlite"
"github.com/kandev/kandev/internal/task/service"
"github.com/kandev/kandev/internal/task/statussummary"
usermodels "github.com/kandev/kandev/internal/user/models"
"github.com/kandev/kandev/internal/worktree"
v1 "github.com/kandev/kandev/pkg/api/v1"
"go.uber.org/zap"
)
type httpWorkspaceSourcesRequest struct {
Sources []json.RawMessage `json:"sources"`
}
type workspaceSourceJSON struct {
Kind string `json:"kind"`
RepositoryID string `json:"repository_id"`
LocalPath string `json:"local_path"`
GitHubURL string `json:"github_url"`
RemoteURL string `json:"remote_url"`
Provider string `json:"provider"`
ProviderRepoID string `json:"provider_repo_id"`
ProviderOwner string `json:"provider_owner"`
ProviderName string `json:"provider_name"`
BaseBranch string `json:"base_branch"`
CheckoutBranch string `json:"checkout_branch"`
DisplayName string `json:"display_name"`
}
func (h *TaskHandlers) httpAttachWorkspaceSources(c *gin.Context) {
var body httpWorkspaceSourcesRequest
decoder := json.NewDecoder(c.Request.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&body); err != nil || len(body.Sources) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "sources is required"})
return
}
sources, err := parseHTTPWorkspaceSources(body.Sources)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
result, err := h.service.AttachWorkspaceSources(c.Request.Context(), service.AttachWorkspaceSourcesRequest{TaskID: c.Param("id"), Sources: sources})
if err != nil {
h.writeWorkspaceSourceError(c, err)
return
}
response := gin.H{"task_id": result.Task.ID, "repositories": result.Task.Repositories, "workspace_folders": result.Task.WorkspaceFolders, "workspace_path": result.WorkspacePath, "session_ids": result.SessionIDs}
c.JSON(http.StatusOK, response)
}
func parseHTTPWorkspaceSources(raw []json.RawMessage) ([]service.WorkspaceSourceInput, error) {
sources := make([]service.WorkspaceSourceInput, 0, len(raw))
for _, item := range raw {
var fields map[string]json.RawMessage
if err := json.Unmarshal(item, &fields); err != nil {
return nil, fmt.Errorf("source must be an object")
}
var kind string
if err := json.Unmarshal(fields["kind"], &kind); err != nil {
return nil, fmt.Errorf("source kind is required")
}
allowed := map[string]bool{"kind": true, "local_path": true}
switch kind {
case string(service.WorkspaceSourceRepository):
for _, key := range []string{"repository_id", "remote_url", "github_url", "provider", "provider_repo_id", "provider_owner", "provider_name", "base_branch", "checkout_branch"} {
allowed[key] = true
}
case string(service.WorkspaceSourceFolder):
allowed["display_name"] = true
default:
return nil, fmt.Errorf("unsupported workspace source kind %q", kind)
}
for key := range fields {
if !allowed[key] {
return nil, fmt.Errorf("field %q is not allowed for %s source", key, kind)
}
}
var source workspaceSourceJSON
if err := json.Unmarshal(item, &source); err != nil {
return nil, err
}
sources = append(sources, service.WorkspaceSourceInput{Kind: service.WorkspaceSourceKind(source.Kind), RepositoryID: source.RepositoryID, LocalPath: source.LocalPath, GitHubURL: source.GitHubURL, RemoteURL: source.RemoteURL, Provider: source.Provider, ProviderRepoID: source.ProviderRepoID, ProviderOwner: source.ProviderOwner, ProviderName: source.ProviderName, BaseBranch: source.BaseBranch, CheckoutBranch: source.CheckoutBranch, DisplayName: source.DisplayName})
}
return sources, nil
}
func workspaceSourceHTTPStatus(err error) int {
switch {
case errors.Is(err, service.ErrInvalidWorkspaceSource):
return http.StatusBadRequest
case errors.Is(err, taskrepo.ErrTaskNotFound), errors.Is(err, taskrepository.ErrRepositoryNotFound), errors.Is(err, service.ErrTaskRepositoryNotFound):
return http.StatusNotFound
case errors.Is(err, service.ErrWorkspaceSourceConflict), errors.Is(err, service.ErrWorkspaceSourceActive):
return http.StatusConflict
case errors.Is(err, service.ErrUnsupportedWorkspaceSource), errors.Is(err, service.ErrWorkspaceSourceMaterialize):
return http.StatusUnprocessableEntity
default:
return http.StatusInternalServerError
}
}
func (h *TaskHandlers) writeWorkspaceSourceError(c *gin.Context, err error) {
status := workspaceSourceHTTPStatus(err)
if status == http.StatusInternalServerError {
h.logger.Error("attach workspace sources failed", zap.Error(err))
c.JSON(status, gin.H{"error": "request failed"})
return
}
c.JSON(status, gin.H{"error": err.Error()})
}
func (h *TaskHandlers) httpListTasks(c *gin.Context) {
tasks, err := h.service.ListTasks(c.Request.Context(), c.Param("id"))
if err != nil {
handleNotFound(c, h.logger, err, "tasks not found")
return
}
taskDTOs, err := h.toTaskDTOsWithSessionInfo(c.Request.Context(), tasks)
if err != nil {
h.logger.Error("failed to enrich tasks with status summaries", zap.Error(err))
handleNotFound(c, h.logger, err, "tasks not found")
return
}
c.JSON(http.StatusOK, dto.ListTasksResponse{
Tasks: taskDTOs,
Total: len(tasks),
})
}
func (h *TaskHandlers) httpListTasksByWorkspace(c *gin.Context) {
page := 1
pageSize := 50
if p := c.Query("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
if ps := c.Query("page_size"); ps != "" {
if parsed, err := strconv.Atoi(ps); err == nil && parsed > 0 && parsed <= 100 {
pageSize = parsed
}
}
query := c.Query("query")
sort := usermodels.NormalizeTasksListSort(c.Query("sort"))
workflowID := c.Query("workflow_id")
repositoryID := c.Query("repository_id")
includeArchived := c.Query("include_archived") == queryValueTrue
onlyArchived := c.Query("only_archived") == queryValueTrue
includeEphemeral := c.Query("include_ephemeral") == queryValueTrue
onlyEphemeral := c.Query("only_ephemeral") == queryValueTrue
excludeConfig := c.Query("exclude_config") == queryValueTrue
tasks, total, err := h.service.ListTasksByWorkspaceWithArchiveMode(
c.Request.Context(), c.Param("id"), workflowID, repositoryID, query, page, pageSize, sort, includeArchived, includeEphemeral, onlyEphemeral, excludeConfig, onlyArchived,
)
if err != nil {
handleNotFound(c, h.logger, err, "tasks not found")
return
}
taskDTOs, err := h.toTaskDTOsWithSessionInfo(c.Request.Context(), tasks)
if err != nil {
h.logger.Error("failed to enrich tasks with session info", zap.Error(err))
handleNotFound(c, h.logger, err, "tasks not found")
return
}
c.JSON(http.StatusOK, dto.ListTasksResponse{
Tasks: taskDTOs,
Total: total,
})
}
// buildTaskDTOsWithSessionInfo converts tasks to DTOs enriched with primary
// session IDs, session counts, review status, and the bounded task status
// summary. Session and executor lookups remain batched; missing summary rows
// are repaired lazily from the same batch-loaded authoritative inputs.
func buildTaskDTOsWithSessionInfo(
ctx context.Context,
svc *service.Service,
log *logger.Logger,
activityProvider dto.ForegroundActivityProvider,
tasks []*models.Task,
) ([]dto.TaskDTO, error) {
if len(tasks) == 0 {
return []dto.TaskDTO{}, nil
}
taskIDs := make([]string, len(tasks))
for i, t := range tasks {
taskIDs[i] = t.ID
}
sessionsByTask, err := svc.BatchGetSessionsForTasks(ctx, taskIDs)
if err != nil {
return nil, err
}
primarySessionInfoMap, err := svc.GetPrimarySessionInfoForTasks(ctx, taskIDs)
if err != nil {
return nil, err
}
pendingActionsBySession, pendingErr := pendingActionsForInputCapableSessions(ctx, svc, sessionsByTask)
if pendingErr != nil {
log.Warn("failed to load pending actions for task list, using empty map", zap.Error(pendingErr))
pendingActionsBySession = map[string]models.TaskPendingAction{}
}
statusSummaries, summaryErr := svc.GetTaskStatusSummaries(ctx, taskIDs)
if summaryErr != nil {
log.Warn("failed to load task status summaries, using coarse task fields", zap.Error(summaryErr))
statusSummaries = map[string]*statussummary.TaskStatusSummary{}
}
if summaryErr == nil && pendingErr == nil {
statusSummaries, err = svc.HydrateMissingTaskStatusSummaries(
ctx, tasks, sessionsByTask, pendingActionsBySession, statusSummaries,
)
if err != nil {
log.Warn("failed to repair missing task status summaries", zap.Error(err))
}
}
// Stamp the authoritative per-task queued prompt count onto every summary.
// The projector keeps the summary field fresh between list loads; this
// fresh batch read is the initial-load backstop (e.g. after a restart the
// projector may not have observed every queue mutation yet). Never
// fabricate a summary here — a synthetic summary would make the frontend
// treat summary fields as authoritative and hide the coarse fallbacks.
queuedByTask, queuedErr := svc.CountPendingQueuedByTaskIDs(ctx, taskIDs)
if queuedErr != nil {
log.Warn("failed to load queued prompt counts for task list, omitting badges", zap.Error(queuedErr))
}
result := make([]dto.TaskDTO, 0, len(tasks))
for _, task := range tasks {
sessions := sessionsByTask[task.ID]
var primarySessionID *string
for _, s := range sessions {
if s.IsPrimary {
id := s.ID
primarySessionID = &id
break
}
}
var sessionCount *int
if n := len(sessions); n > 0 {
sessionCount = &n
}
si := extractSessionInfo(primarySessionInfoMap[task.ID])
taskDTO := dto.FromTaskWithSessionInfo(
task,
primarySessionID,
sessionCount,
si.reviewStatus,
si.executorID,
si.executorType,
si.executorName,
si.agentName,
si.workingDirectory,
si.sessionState,
pendingActionPtr(si.sessionID, pendingActionsBySession),
)
taskDTO.TaskPendingAction = taskPendingActionPtr(sessions, pendingActionsBySession)
dto.EnrichTaskForegroundActivity(&taskDTO, sessions, activityProvider)
taskDTO.StatusSummary = statusSummaries[task.ID]
if taskDTO.StatusSummary != nil {
switch {
case queuedErr != nil:
// Counter failed: honor the documented no-badge fallback in the
// response without persisting the cleared value.
taskDTO.StatusSummary.QueuedPromptCount = 0
case queuedByTask != nil:
// Fresh per-task count from the queue store; the projector keeps
// this field live between list loads, this batch read is the
// authoritative initial-load backstop. See the comment above the
// batch load.
taskDTO.StatusSummary.QueuedPromptCount = queuedByTask[task.ID]
}
}
result = append(result, taskDTO)
}
return result, nil
}
type sessionInfoFields struct {
sessionID *string
reviewStatus models.ReviewStatus
sessionState *string
executorID *string
executorType *string
executorName *string
agentName *string
workingDirectory *string
}
func extractSessionInfo(info *models.TaskSession) sessionInfoFields {
var si sessionInfoFields
if info == nil {
return si
}
if info.ID != "" {
val := info.ID
si.sessionID = &val
}
si.reviewStatus = info.ReviewStatus
if info.State != "" {
val := string(info.State)
si.sessionState = &val
}
if info.ExecutorID != "" {
val := info.ExecutorID
si.executorID = &val
}
if info.ExecutorSnapshot != nil {
if t, ok := info.ExecutorSnapshot["executor_type"].(string); ok && t != "" {
si.executorType = &t
}
if n, ok := info.ExecutorSnapshot["executor_name"].(string); ok && n != "" {
si.executorName = &n
}
}
if info.AgentProfileSnapshot != nil {
if name, ok := info.AgentProfileSnapshot["name"].(string); ok && name != "" {
si.agentName = &name
}
}
if info.RepositorySnapshot != nil {
if path, ok := info.RepositorySnapshot["path"].(string); ok && path != "" {
si.workingDirectory = &path
}
}
return si
}
func pendingActionsForInputCapableSessions(
ctx context.Context,
svc *service.Service,
sessionsByTask map[string][]*models.TaskSession,
) (map[string]models.TaskPendingAction, error) {
sessionIDs := make([]string, 0)
for _, sessions := range sessionsByTask {
for _, session := range sessions {
if isInputCapableSession(session) {
sessionIDs = append(sessionIDs, session.ID)
}
}
}
if len(sessionIDs) == 0 {
return map[string]models.TaskPendingAction{}, nil
}
return svc.GetPendingActionsForSessions(ctx, sessionIDs)
}
func isInputCapableSession(session *models.TaskSession) bool {
return session != nil && (session.State == models.TaskSessionStateRunning || session.State == models.TaskSessionStateWaitingForInput)
}
func taskPendingActionPtr(sessions []*models.TaskSession, actions map[string]models.TaskPendingAction) *string {
var clarification bool
for _, session := range sessions {
if !isInputCapableSession(session) {
continue
}
switch actions[session.ID] {
case models.TaskPendingActionPermission:
value := string(models.TaskPendingActionPermission)
return &value
case models.TaskPendingActionClarification:
clarification = true
}
}
if clarification {
value := string(models.TaskPendingActionClarification)
return &value
}
return nil
}
func pendingActionPtr(
sessionID *string,
pendingActionsBySession map[string]models.TaskPendingAction,
) *string {
if sessionID == nil {
return nil
}
action, ok := pendingActionsBySession[*sessionID]
if !ok {
return nil
}
value := string(action)
return &value
}
func (h *TaskHandlers) taskSessionDTO(ctx context.Context, session *models.TaskSession) dto.TaskSessionDTO {
result := dto.FromTaskSession(session)
dto.EnrichCancellationPending(&result, h.cancellationPending)
if !isInputCapableSession(session) {
return result
}
actions, err := h.service.GetPendingActionsForSessions(ctx, []string{session.ID})
if err != nil {
h.logger.Warn("get task session pending action failed",
zap.String("session_id", session.ID), zap.Error(err))
return result
}
result.PendingAction = pendingActionPtr(&session.ID, actions)
return result
}
func (h *TaskHandlers) toTaskDTOsWithSessionInfo(ctx context.Context, tasks []*models.Task) ([]dto.TaskDTO, error) {
return buildTaskDTOsWithSessionInfo(ctx, h.service, h.logger, h.foregroundActivity, tasks)
}
func (h *TaskHandlers) httpGetTask(c *gin.Context) {
task, err := h.service.GetTask(c.Request.Context(), c.Param("id"))
if err != nil {
handleNotFound(c, h.logger, err, "task not found")
return
}
dtos, err := buildTaskDTOsWithSessionInfo(c.Request.Context(), h.service, h.logger, h.foregroundActivity, []*models.Task{task})
if err != nil {
h.logger.Error("failed to build task DTO with session info", zap.Error(err))
c.JSON(http.StatusOK, dto.FromTask(task))
return
}
c.JSON(http.StatusOK, dtos[0])
}
func (h *TaskHandlers) httpListTaskSessions(c *gin.Context) {
ctx := c.Request.Context()
sessions, err := h.service.ListTaskSessions(ctx, c.Param("id"))
if err != nil {
handleNotFound(c, h.logger, err, "task sessions not found")
return
}
pendingActionsBySession, pendingErr := pendingActionsForInputCapableSessions(
ctx,
h.service,
map[string][]*models.TaskSession{c.Param("id"): sessions},
)
if pendingErr != nil {
h.logger.Warn("get task session pending actions failed", zap.Error(pendingErr))
pendingActionsBySession = map[string]models.TaskPendingAction{}
}
sessionDTOs := make([]dto.TaskSessionSummaryDTO, 0, len(sessions))
ids := make([]string, 0, len(sessions))
for _, session := range sessions {
summary := dto.FromTaskSessionSummary(session)
dto.EnrichForegroundActivitySummary(&summary, h.foregroundActivity)
dto.EnrichCancellationPendingSummary(&summary, h.cancellationPending)
summary.PendingAction = pendingActionPtr(&session.ID, pendingActionsBySession)
sessionDTOs = append(sessionDTOs, summary)
ids = append(ids, session.ID)
}
// Resolve the per-session tool_call counts so the frontend can render
// the "ran N commands" segment without fetching every session's full
// message list. Best-effort: a count failure leaves CommandCount at 0.
if counts, cErr := h.repo.CountToolCallMessagesBySession(ctx, ids); cErr == nil {
for i := range sessionDTOs {
sessionDTOs[i].CommandCount = counts[sessionDTOs[i].ID]
}
} else {
h.logger.Warn("count tool calls failed", zap.Error(cErr))
}
c.JSON(http.StatusOK, dto.ListTaskSessionSummariesResponse{
Sessions: sessionDTOs,
Total: len(sessionDTOs),
})
}
// httpEnsureTaskSession returns the task's existing primary/newest session if any,
// otherwise resolves the agent profile server-side and creates one (prepare or
// start, depending on the workflow step). Idempotent under concurrent calls.
func (h *TaskHandlers) httpEnsureTaskSession(c *gin.Context) {
taskID := c.Param("id")
if taskID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "task id is required"})
return
}
resp, err := h.orchestrator.EnsureSession(c.Request.Context(), taskID)
if err != nil {
handleNotFound(c, h.logger, err, "task not found")
return
}
c.JSON(http.StatusOK, resp)
}
func (h *TaskHandlers) httpGetTaskSession(c *gin.Context) {
session, err := h.service.GetTaskSession(c.Request.Context(), c.Param("id"))
if err != nil {
handleNotFound(c, h.logger, err, "task session not found")
return
}
sessionDTO := h.taskSessionDTO(c.Request.Context(), session)
dto.EnrichForegroundActivity(&sessionDTO, h.foregroundActivity)
c.JSON(http.StatusOK, dto.GetTaskSessionResponse{
Session: sessionDTO,
})
}
type dismissLastAgentErrorRequest struct {
Stamp string `json:"stamp"`
}
func (h *TaskHandlers) httpDismissLastAgentError(c *gin.Context) {
var req dismissLastAgentErrorRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON: " + err.Error()})
return
}
session, err := h.service.DismissLastAgentError(c.Request.Context(), c.Param("id"), req.Stamp)
if err != nil {
handleNotFound(c, h.logger, err, "task session not found")
return
}
sessionDTO := h.taskSessionDTO(c.Request.Context(), session)
dto.EnrichForegroundActivity(&sessionDTO, h.foregroundActivity)
c.JSON(http.StatusOK, dto.GetTaskSessionResponse{
Session: sessionDTO,
})
}
type markSessionReadRequest struct {
MessageID string `json:"message_id"`
}
// httpMarkSessionRead advances the session's Slack-style unread-divider read
// cursor to req.MessageID. A missing session is a 404; bad caller input
// (empty ids, an unknown message, or a message belonging to a different
// session — see service.ErrInvalidMarkSessionRead) is a 400; any other,
// unexpected failure (a repository/DB error) is logged and returned as a
// sanitized 500 rather than leaking the raw internal error to the caller.
func (h *TaskHandlers) httpMarkSessionRead(c *gin.Context) {
var req markSessionReadRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON: " + err.Error()})
return
}
sessionID := c.Param("id")
session, err := h.service.MarkSessionRead(c.Request.Context(), sessionID, req.MessageID)
if err != nil {
if errors.Is(err, models.ErrTaskSessionNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "task session not found"})
return
}
if errors.Is(err, service.ErrInvalidMarkSessionRead) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
h.logger.Error("failed to mark session read",
zap.String("session_id", sessionID),
zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to mark session read"})
return
}
c.JSON(http.StatusOK, dto.MarkSessionReadResponse{
SessionID: session.ID,
LastReadMessageID: session.LastReadMessageID,
})
}
func (h *TaskHandlers) httpListSessionTurns(c *gin.Context) {
sessionID := c.Param("id")
if sessionID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "session id is required"})
return
}
// Per-user scoping: the caller must own the session's workspace.
if err := h.service.AuthorizeSessionAccess(c.Request.Context(), sessionID); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
return
}
turns, err := h.repo.ListTurnsBySession(c.Request.Context(), sessionID)
if err != nil {
h.logger.Error("failed to list turns", zap.String("session_id", sessionID), zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list turns"})
return
}
// Convert to DTO
turnDTOs := make([]dto.TurnDTO, 0, len(turns))
for _, turn := range turns {
turnDTOs = append(turnDTOs, dto.FromTurn(turn))
}
c.JSON(http.StatusOK, dto.ListTurnsResponse{Turns: turnDTOs, Total: len(turnDTOs)})
}
func (h *TaskHandlers) httpApproveSession(c *gin.Context) {
result, err := h.service.ApproveSession(c.Request.Context(), c.Param("id"))
if err != nil {
// Route not-found through the shared mapper so a per-user scoping
// denial answers 404 like every other session route, rather than
// surfacing as a 500 that distinguishes "yours" from "someone else's".
handleNotFound(c, h.logger, err, "task session not found")
return
}
resp := dto.ApproveSessionResponse{
Success: true,
Session: h.taskSessionDTO(c.Request.Context(), result.Session),
}
if result.WorkflowStep != nil {
resp.WorkflowStep = dto.FromWorkflowStep(result.WorkflowStep)
}
c.JSON(http.StatusOK, resp)
}
func (h *TaskHandlers) httpGetWorkflowTaskCount(c *gin.Context) {
count, err := h.service.CountTasksByWorkflow(c.Request.Context(), c.Param("id"))
if err != nil {
h.logger.Error("failed to count tasks by workflow", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count tasks"})
return
}
c.JSON(http.StatusOK, dto.TaskCountResponse{TaskCount: count})
}
func (h *TaskHandlers) httpGetStepTaskCount(c *gin.Context) {
count, err := h.service.CountTasksByWorkflowStep(c.Request.Context(), c.Param("id"))
if err != nil {
h.logger.Error("failed to count tasks by step", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count tasks"})
return
}
c.JSON(http.StatusOK, dto.TaskCountResponse{TaskCount: count})
}
type httpBulkMoveTasksRequest struct {
SourceWorkflowID string `json:"source_workflow_id"`
SourceStepID string `json:"source_step_id,omitempty"`
TargetWorkflowID string `json:"target_workflow_id"`
TargetStepID string `json:"target_step_id"`
TaskIDs []string `json:"task_ids,omitempty"`
}
func (h *TaskHandlers) httpBulkMoveTasks(c *gin.Context) {
var body httpBulkMoveTasksRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
if body.TargetWorkflowID == "" || body.TargetStepID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "target_workflow_id and target_step_id are required"})
return
}
if len(body.TaskIDs) > 0 {
h.httpBulkMoveSelectedTasks(c, body)
return
}
if body.SourceWorkflowID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "source_workflow_id, target_workflow_id, and target_step_id are required"})
return
}
result, err := h.service.BulkMoveTasks(
c.Request.Context(),
body.SourceWorkflowID, body.SourceStepID,
body.TargetWorkflowID, body.TargetStepID,
)
if err != nil {
h.logger.Error("failed to bulk move tasks", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bulk move tasks"})
return
}
c.JSON(http.StatusOK, dto.BulkMoveTasksResponse{MovedCount: result.MovedCount})
}
func (h *TaskHandlers) httpBulkMoveSelectedTasks(c *gin.Context, body httpBulkMoveTasksRequest) {
result, err := h.service.BulkMoveSelectedTasks(
c.Request.Context(),
body.TaskIDs,
body.TargetWorkflowID,
body.TargetStepID,
)
if err != nil {
handleSelectedMoveError(c, h.logger, err)
return
}
c.JSON(http.StatusOK, dto.BulkMoveTasksResponse{MovedCount: result.MovedCount})
}
type httpTaskRepositoryInput struct {
RepositoryID string `json:"repository_id"`
BaseBranch string `json:"base_branch"`
CheckoutBranch string `json:"checkout_branch"`
PRNumber int `json:"pr_number,omitempty"`
LocalPath string `json:"local_path"`
Name string `json:"name"`
DefaultBranch string `json:"default_branch"`
GitHubURL string `json:"github_url"`
RemoteURL string `json:"remote_url"`
Provider string `json:"provider"`
ProviderRepoID string `json:"provider_repo_id"`
ProviderOwner string `json:"provider_owner"`
ProviderName string `json:"provider_name"`
// Fresh-branch flow (local executor only): when FreshBranch is true the
// handler discards uncommitted changes in the local clone and creates
// NewBranchName from BaseBranch after the task repository is persisted.
// ConfirmDiscard must be true if the working tree is dirty; otherwise
// the request is rejected with 409 + the dirty file list.
// ConsentedDirtyFiles is the dirty-file list the UI showed the user;
// the backend rejects with 409 if the live dirty set has any path
// that wasn't on this list, protecting against silent loss of files
// that became dirty between preflight and execution.
FreshBranch bool `json:"fresh_branch,omitempty"`
NewBranchName string `json:"new_branch_name,omitempty"`
ConfirmDiscard bool `json:"confirm_discard,omitempty"`
ConsentedDirtyFiles []string `json:"consented_dirty_files,omitempty"`
}
type httpCreateTaskRequest struct {
WorkspaceID string `json:"workspace_id"`
WorkflowID string `json:"workflow_id"`
WorkflowStepID string `json:"workflow_step_id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
AutoTitle bool `json:"auto_title,omitempty"`
Autopilot bool `json:"autopilot,omitempty"`
Priority string `json:"priority,omitempty"`
State *v1.TaskState `json:"state,omitempty"`
Repositories []httpTaskRepositoryInput `json:"repositories,omitempty"`
Position int `json:"position,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
StartAgent bool `json:"start_agent,omitempty"`
PrepareSession bool `json:"prepare_session,omitempty"`
AgentProfileID string `json:"agent_profile_id,omitempty"`
ExecutorID string `json:"executor_id,omitempty"`
ExecutorProfileID string `json:"executor_profile_id,omitempty"`
PlanMode bool `json:"plan_mode,omitempty"`
Attachments []v1.MessageAttachment `json:"attachments,omitempty"`
ParentID string `json:"parent_id,omitempty"`
WorkspacePath string `json:"workspace_path,omitempty"`
BlockedBy []string `json:"blocked_by,omitempty"`
ProjectID string `json:"project_id,omitempty"`
// ExternalID is a caller-supplied identity used for create-idempotency
// (docs/specs/tasks/external-id-idempotency/spec.md).
ExternalID string `json:"external_id,omitempty"`
// Office task-handoffs phase 5 — workspace policy. Optional; same
// shape as the MCP create_task_kandev fields.
WorkspaceMode string `json:"workspace_mode,omitempty"`
WorkspaceGroupID string `json:"workspace_group_id,omitempty"`
DefaultChildWorkspace string `json:"default_child_workspace,omitempty"`
DefaultChildOrdering string `json:"default_child_ordering,omitempty"`
}
type createTaskResponse struct {
dto.TaskDTO
TaskSessionID string `json:"session_id,omitempty"`
AgentExecutionID string `json:"agent_execution_id,omitempty"`
// Deduplicated and CreationComplete are required booleans (not
// presence-only markers) on every create-idempotency outcome, per
// docs/specs/tasks/external-id-idempotency/spec.md. Deduplicated is true
// for both Found outcomes. CreationComplete is false only for
// Found-unsettled — every other outcome (including CreatedIdentityLost)
// carries true, because the field means only "this task's required
// synchronous setup finished", not "an agent is running".
Deduplicated bool `json:"deduplicated"`
CreationComplete bool `json:"creation_complete"`
}
const (
maxCreateTaskAttachments = 10
maxAttachmentDataBytes = 10 * 1024 * 1024 // 10 MB base64 string length cap
)
var allowedAttachmentTypes = map[string]struct{}{
"image": {},
"audio": {},
"resource": {},
}
func validateAttachments(items []v1.MessageAttachment) error {
if len(items) > maxCreateTaskAttachments {
return fmt.Errorf("too many attachments (max %d)", maxCreateTaskAttachments)
}
var totalSize int64
var legacyTotalSize int
for i, a := range items {
typ := strings.TrimSpace(a.Type)
if _, ok := allowedAttachmentTypes[typ]; !ok {
return fmt.Errorf("attachment[%d] has unsupported type %q", i, typ)
}
if strings.TrimSpace(a.MimeType) == "" {
return fmt.Errorf("attachment[%d] mime_type is required", i)
}
if !a.HasValidDeliveryMode() {
return fmt.Errorf("attachment[%d] delivery_mode must be prompt or path", i)
}
if a.AttachmentID != "" {
if a.Data != "" {
return fmt.Errorf("attachment[%d] descriptors cannot include inline data", i)
}
if strings.TrimSpace(a.Name) == "" || strings.TrimSpace(a.MimeType) == "" {
return fmt.Errorf("attachment[%d] descriptor name and mime_type are required", i)
}
if err := service.ValidateAttachmentSize(a.SizeBytes); err != nil {
return fmt.Errorf("attachment[%d]: %w", i, err)
}
totalSize += a.SizeBytes
continue
}
if len(a.Data) == 0 {
return fmt.Errorf("attachment[%d] data is required", i)
}
if len(a.Data) > maxAttachmentDataBytes {
return fmt.Errorf("attachment[%d] data exceeds size limit", i)
}
totalSize += int64(len(a.Data))
legacyTotalSize += len(a.Data)
}
if legacyTotalSize > maxAttachmentDataBytes {
return fmt.Errorf("total legacy attachment size exceeds limit")
}
if totalSize > service.MaxAttachmentBytes {
return fmt.Errorf("total attachment size exceeds limit")
}
return nil
}
func (h *TaskHandlers) httpCreateTask(c *gin.Context) {
var body httpCreateTaskRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
if err := validateAttachments(body.Attachments); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if body.WorkspaceID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id is required"})
return
}
if (body.StartAgent || body.PrepareSession) && body.AgentProfileID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "agent_profile_id is required to start agent"})
return
}
repos, ok := convertCreateTaskRepositories(c, body.Repositories)
if !ok {
return
}
// Always persist profile IDs in task metadata so they can be used as the
// task's "default" agent profile. This is needed for deferred agent start
// (handleTaskMovedNoSession) and workflow steps that explicitly use the
// workflow/task default profile.
if body.AgentProfileID != "" {
if body.Metadata == nil {
body.Metadata = make(map[string]interface{})
}
body.Metadata[models.MetaKeyAgentProfileID] = body.AgentProfileID
if body.ExecutorProfileID != "" {
body.Metadata[models.MetaKeyExecutorProfileID] = body.ExecutorProfileID
}
}
title := strings.TrimSpace(body.Title)
description := strings.TrimSpace(body.Description)
// Office task-handoffs phase 5: resolve workspace policy from the
// request + parent task, merge into Metadata, and remember it so the
// post-create attach can record group membership / blocker chain.
wsPolicy, policyErr := h.resolveWorkspacePolicy(c.Request.Context(), body)
if policyErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": policyErr.Error()})
return
}
metadata := wsPolicy.MergeMetadataBlock(body.Metadata)
var deferredLaunch map[string]interface{}
if body.StartAgent || body.PrepareSession {
intent := "prepare"
if body.StartAgent {
intent = "start"
}
deferredLaunch = map[string]interface{}{
"intent": intent, "agent_profile_id": body.AgentProfileID, "executor_id": body.ExecutorID,
"executor_profile_id": body.ExecutorProfileID, "prompt": description,
"plan_mode": body.PlanMode, "attachments": body.Attachments,
}
}
result, err := h.service.CreateTask(c.Request.Context(), &service.CreateTaskRequest{
WorkspaceID: body.WorkspaceID,
WorkflowID: body.WorkflowID,
WorkflowStepID: body.WorkflowStepID,
Title: title,
Description: description,
AutoTitle: body.AutoTitle,
Autopilot: body.Autopilot,
Priority: body.Priority,
State: body.State,
Repositories: convertToServiceRepos(repos),
Position: body.Position,
Metadata: metadata,
DeferredLaunch: deferredLaunch,
PlanMode: body.PlanMode && !body.StartAgent,
ParentID: body.ParentID,
WorkspacePath: body.WorkspacePath,
BlockedBy: body.BlockedBy,
ProjectID: body.ProjectID,
ExternalID: body.ExternalID,
})
if err != nil {
handleNotFound(c, h.logger, err, "task not created")
return
}
// Both Found outcomes have no side effects: skip every post-create step
// below (attachment claim, workspace-policy attach, fresh-branch commit,
// session prepare/start, last-used recording, PR association) and return
// the existing task as-is. This is the data-loss guard's REST twin — the
// steps below assume a task this request just created, and running them
// against someone else's task would misapply attachments/policy meant for
// the retry payload to a task that never asked for them.
if result.Outcome != service.CreateTaskOutcomeCreated {
c.JSON(http.StatusOK, foundCreateTaskResponse(result))
return
}
task := result.Task
if err := h.service.ClaimMessageAttachments(c.Request.Context(), task.ID, "", body.Attachments); err != nil {
rollbackCtx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), 10*time.Second)
defer cancel()
if deleteErr := h.service.DeleteTask(rollbackCtx, task.ID); deleteErr != nil {
h.logger.Warn("failed to roll back task after attachment claim", zap.String("task_id", task.ID), zap.Error(deleteErr))
}
switch {
case errors.Is(err, service.ErrAttachmentTooLarge):
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrAttachmentForbidden), errors.Is(err, service.ErrAttachmentNotFound), errors.Is(err, service.ErrAttachmentClaimConflict):
c.JSON(http.StatusNotFound, gin.H{"error": "attachment not found"})
case errors.Is(err, service.ErrAttachmentInvalid), errors.Is(err, service.ErrAttachmentTotalTooLarge), errors.Is(err, service.ErrTooManyAttachments):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
h.logger.Error("failed to claim task attachments", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to claim task attachments"})
}
return
}
if h.handoffSvc != nil && wsPolicy.NeedsAttachment() {
if attachErr := h.handoffSvc.AttachWorkspacePolicy(c.Request.Context(), task.ID, body.ParentID, wsPolicy); attachErr != nil {
h.logger.Error("attach workspace policy; rolling back task creation",
zap.String("task_id", task.ID), zap.Error(attachErr))
if delErr := h.service.DeleteTask(c.Request.Context(), task.ID); delErr != nil {
h.logger.Error("rollback delete failed; task left in inconsistent state",
zap.String("task_id", task.ID), zap.Error(delErr))
}
c.JSON(http.StatusInternalServerError, gin.H{
"error": "failed to attach workspace policy: " + attachErr.Error(),
})
return
}
}
if !h.commitFreshBranch(c, task.ID, task.Title, body.WorkspaceID, body.Repositories, repos) {
return
}
taskDTO := dto.FromTask(task)
response := createTaskResponse{TaskDTO: taskDTO, Deduplicated: false, CreationComplete: true}
// Use the backend-resolved workflow step ID (from the created task) instead of the request's
resolvedStepID := taskDTO.WorkflowStepID
// Synchronous session preparation (create-sequence step 6, "required
// synchronous post-create work") runs before settlement, not after — see
// prepareTaskSession's doc comment for why. dispatch is nil unless a
// start_agent create genuinely prepared a session; it is only ever acted
// on below once settlement has succeeded.
dispatch := h.prepareTaskSession(c, &response, taskDTO.ID, body, resolvedStepID, task.QueuedForStepID == "")
// Settlement (create-sequence step 7): after all required synchronous
// work above — including session prepare — and before any asynchronous
// dispatch below.
settled, survivor, settleErr := h.service.SettleExternalID(c.Request.Context(), task.ID, task.ExternalID)
if settleErr != nil {
if !isNotFound(settleErr) {
h.logger.Error("failed to settle external_id", zap.String("task_id", task.ID), zap.Error(settleErr))
}
handleNotFound(c, h.logger, settleErr, "task not created")
return
}
if !settled {
// CreatedIdentityLost: another actor released the identity while this
// create was running. The task survives holding no external_id; per
// the spec, no asynchronous work (session start, PR association) is
// dispatched for it. Any session prepared above is not launched.
c.JSON(http.StatusOK, createTaskResponse{
TaskDTO: dto.FromTask(survivor),
Deduplicated: false,
CreationComplete: true,
})
return
}
h.dispatchTaskSession(taskDTO.ID, taskDTO.Description, body, dispatch)
h.recordTaskCreateLastUsed(c.Request.Context(), body, repos)
// Associate PR with task if any repository input contains a PR URL
h.associatePRFromRepoInputs(taskDTO.ID, response.TaskSessionID, body.Repositories)
c.JSON(http.StatusOK, response)
}
// foundCreateTaskResponse builds the response for either Found outcome: the
// existing task, deduplicated true, and creation_complete reflecting whether
// that task's own create had finished settling.
func foundCreateTaskResponse(result service.CreateTaskResult) createTaskResponse {
return createTaskResponse{
TaskDTO: dto.FromTask(result.Task),
Deduplicated: true,
CreationComplete: result.Outcome == service.CreateTaskOutcomeFoundSettled,
}
}
// lookupTaskResponse is the by-external-id GET route's body: the task DTO
// plus creation_complete. Unlike createTaskResponse, it carries no
// deduplicated field — that flag is meaningful only relative to a create
// this request performed, and a lookup never creates anything.
type lookupTaskResponse struct {
dto.TaskDTO
CreationComplete bool `json:"creation_complete"`
}
// httpGetTaskByExternalID is the REST lookup route
// (docs/specs/tasks/external-id-idempotency/spec.md, "REST — lookup"): a
// side-effect-free way to ask what holds an identity without risking a
// create. Returns the task whether settled or not, including archived tasks.
func (h *TaskHandlers) httpGetTaskByExternalID(c *gin.Context) {
task, err := h.service.GetTaskByExternalID(c.Request.Context(), c.Param("id"), c.Query("external_id"))
if err != nil {
handleNotFound(c, h.logger, err, "task not found")
return
}
c.JSON(http.StatusOK, lookupTaskResponse{
TaskDTO: dto.FromTask(task),
CreationComplete: task.ExternalIDSettledAt != nil,
})
}
// httpReleaseTaskExternalID is the REST release route
// (docs/specs/tasks/external-id-idempotency/spec.md, "REST — release"): an
// operator action for an identity a human has determined is abandoned. Frees
// the identity without deleting or otherwise modifying the task. MUST NOT be
// called automatically in response to creation_complete:false — see "The one
// unsafe thing a caller can do".
func (h *TaskHandlers) httpReleaseTaskExternalID(c *gin.Context) {
released, err := h.service.ReleaseTaskExternalID(c.Request.Context(), c.Param("id"), c.Query("external_id"))
if err != nil {
handleNotFound(c, h.logger, err, "task not found")
return
}
if !released {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
c.Status(http.StatusNoContent)
}
func (h *TaskHandlers) recordTaskCreateLastUsed(ctx context.Context, body httpCreateTaskRequest, repos []dto.TaskRepositoryInput) {
if h.taskCreateLastUsedRecorder == nil {
return
}
patch := buildTaskCreateLastUsedPatch(body, repos)
if err := h.taskCreateLastUsedRecorder.RecordTaskCreateLastUsed(ctx, patch); err != nil {
h.logger.Warn("failed to record task-create last-used settings", zap.Error(err))
}
}
func buildTaskCreateLastUsedPatch(body httpCreateTaskRequest, repos []dto.TaskRepositoryInput) usermodels.TaskCreateLastUsed {
patch := usermodels.TaskCreateLastUsed{
AgentProfileID: body.AgentProfileID,
ExecutorProfileID: body.ExecutorProfileID,
}
if body.WorkspaceID != "" && body.WorkflowID != "" {
patch.WorkflowIDsByWorkspace = map[string]string{body.WorkspaceID: body.WorkflowID}
}
for i, repo := range repos {
if repo.RepositoryID == "" {
continue
}
branch := taskCreateLastUsedBranch(body, i, repo)
patch.RepositoryID = repo.RepositoryID
patch.Branch = branch
break
}
return patch
}
func taskCreateLastUsedBranch(
body httpCreateTaskRequest,
index int,
repo dto.TaskRepositoryInput,
) string {
if index < len(body.Repositories) && body.Repositories[index].FreshBranch {
raw := body.Repositories[index]
return firstNonEmpty(raw.BaseBranch, raw.CheckoutBranch)
}
return firstNonEmpty(repo.CheckoutBranch, repo.BaseBranch)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
// commitFreshBranch wraps the post-CreateTask fresh-branch sequence: run the
// destructive checkout, compensate by deleting the task if it fails, then
// persist the rewritten BaseBranch onto the now-existing task. Returns false
// when an HTTP error response was already written.
func (h *TaskHandlers) commitFreshBranch(
c *gin.Context,
taskID, title, workspaceID string,
inputs []httpTaskRepositoryInput,
repos []dto.TaskRepositoryInput,
) bool {
hasFresh := false
for _, raw := range inputs {
if raw.FreshBranch {
hasFresh = true
break
}
}
if !hasFresh {
// No fresh-branch opt-in — repos were already persisted by CreateTask,
// so skip the destructive checkout and the DELETE+INSERT rewrite.
return true
}
task, err := h.service.GetTask(c.Request.Context(), taskID)
if err != nil {
h.logger.Error("failed to reload task repositories for fresh branch", zap.String("task_id", taskID), zap.Error(err))
h.rollbackFreshBranchTask(c.Request.Context(), taskID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve task repository"})
return false
}
if !h.applyFreshBranch(c, title, inputs, repos, task.Repositories) {
h.rollbackFreshBranchTask(c.Request.Context(), taskID)
return false
}
// Persist the rewritten BaseBranch (set by applyFreshBranch) onto the task.
// applyFreshBranch already mutated the git repo, so we can't roll back —
// but we must surface a 5xx so the caller knows the DB still references
// the user's original fork point. Otherwise every subsequent session
// resume would silently check out the old branch and abandon the new one.
if err := h.service.ReplaceTaskRepositories(c.Request.Context(), taskID, workspaceID, convertToServiceRepos(repos)); err != nil {
h.logger.Error("failed to persist fresh-branch base branch onto task",
zap.String("task_id", taskID), zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{
"error": "fresh branch was created but the task record could not be updated; please check the repository",
})
return false
}
return true
}
func (h *TaskHandlers) rollbackFreshBranchTask(ctx context.Context, taskID string) {
if err := h.service.DeleteTask(ctx, taskID); err != nil {
h.logger.Warn("failed to compensate by deleting task after fresh-branch failure",
zap.String("task_id", taskID), zap.Error(err))
}
}
// applyFreshBranch executes the fresh-branch flow for any local-executor
// repository inputs that opted in, resolving each repository from the ordered
// rows persisted on the new task. Mutates `repos[i].BaseBranch` to the
// newly-created branch on success so the persisted task uses it as the
// effective base branch on every session resume. Writes the appropriate
// HTTP error response and returns false on failure.
//
// When the caller doesn't supply NewBranchName, the backend generates a
// semantic name from the task title (matching the worktree executor's
// branch-naming) so the user only has to flip a switch.
func (h *TaskHandlers) applyFreshBranch(
c *gin.Context,
taskTitle string,
inputs []httpTaskRepositoryInput,
repos []dto.TaskRepositoryInput,
persisted []*models.TaskRepository,
) bool {
ctx := c.Request.Context()
for i, raw := range inputs {
if !raw.FreshBranch {
continue
}
if i >= len(persisted) || persisted[i] == nil || persisted[i].RepositoryID == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve task repository"})
return false
}
repositoryID := persisted[i].RepositoryID
baseBranch := raw.BaseBranch
if baseBranch == "" {
// User didn't pick one — fall back to the repo's checked-out branch.
baseBranch, _ = h.service.RepositoryCurrentBranch(ctx, repositoryID)
}
newBranch := resolveFreshBranchName(raw.NewBranchName, taskTitle)
err := h.service.PerformFreshBranch(ctx, service.FreshBranchRequest{
RepositoryID: repositoryID,
BaseBranch: baseBranch,
NewBranch: newBranch,
ConfirmDiscard: raw.ConfirmDiscard,
ConsentedDirtyFiles: raw.ConsentedDirtyFiles,
})
if err != nil {
h.respondFreshBranchError(c, err)
return false
}
repos[i].BaseBranch = newBranch
repos[i].CheckoutBranch = ""
}
return true
}
// resolveFreshBranchName returns the user-supplied branch name when present,
// otherwise generates one from the task title using the same semantic name +
// random-suffix scheme as the worktree executor. Returns an empty string only
// when both the raw name and the task title would produce nothing useful;
// PerformFreshBranch's sanitizeGitRef rejects that downstream.
func resolveFreshBranchName(rawNewBranch, taskTitle string) string {
if name := strings.TrimSpace(rawNewBranch); name != "" {
return name
}
return worktree.SemanticWorktreeName(taskTitle, worktree.SmallSuffix(3))
}
func (h *TaskHandlers) respondFreshBranchError(c *gin.Context, err error) {
var dirty *service.ErrDirtyWorkingTree
if errors.As(err, &dirty) {
c.JSON(http.StatusConflict, gin.H{
"error": "working tree has uncommitted changes",
"dirty_files": dirty.DirtyFiles,
})
return
}
if errors.Is(err, service.ErrInvalidGitRef) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if errors.Is(err, service.ErrFreshBranchCheckout) {
// git checkout failure (e.g. branch already exists, base branch unknown).
// Surface the underlying message — it tells the user what to fix.
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
return
}
if errors.Is(err, service.ErrPartialDiscard) {
// Tracked files already gone, untracked survived. The user needs to
// know they have lost work before retrying — never a generic 500.
h.logger.Error("partial discard during fresh-branch", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{
"error": "tracked changes were discarded but git clean failed; untracked files remain — inspect the repo before retrying",
"partial": true,
})
return
}
h.logger.Error("fresh branch checkout failed", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to prepare fresh branch"})
}
// convertCreateTaskRepositories converts httpTaskRepositoryInput slice to dto.TaskRepositoryInput slice.
// Returns (nil, false) and writes a 400 response if any entry is missing both repository_id and local_path.
func convertCreateTaskRepositories(c *gin.Context, inputs []httpTaskRepositoryInput) ([]dto.TaskRepositoryInput, bool) {
var repos []dto.TaskRepositoryInput
for _, r := range inputs {
if r.RepositoryID == "" && r.LocalPath == "" && r.RemoteURL == "" && r.GitHubURL == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "repository_id, local_path, or remote_url is required"})
return nil, false
}
repos = append(repos, dto.TaskRepositoryInput{
RepositoryID: r.RepositoryID,
BaseBranch: r.BaseBranch,
CheckoutBranch: r.CheckoutBranch,
PRNumber: r.PRNumber,
LocalPath: r.LocalPath,
Name: r.Name,
DefaultBranch: r.DefaultBranch,
GitHubURL: r.GitHubURL,
RemoteURL: r.RemoteURL,
Provider: r.Provider,
ProviderRepoID: r.ProviderRepoID,
ProviderOwner: r.ProviderOwner,
ProviderName: r.ProviderName,
})
}
return repos, true
}
// associatePRFromRepoInputs checks if any repository input contains a GitHub PR URL
// (e.g., github.com/owner/repo/pull/123) and fires the onTaskCreatedWithPR callback
// in a background goroutine to associate the PR with the newly created task.
func (h *TaskHandlers) associatePRFromRepoInputs(taskID, sessionID string, repos []httpTaskRepositoryInput) {
if h.onTaskCreatedWithPR == nil {
return
}
for _, r := range repos {
if r.GitHubURL != "" && strings.Contains(r.GitHubURL, "/pull/") {
prURL := r.GitHubURL
branch := r.CheckoutBranch
go h.onTaskCreatedWithPR(context.Background(), taskID, sessionID, prURL, branch)
return // only one PR per task
}
}
}
// startAgentDispatch carries what dispatchTaskSession needs to launch the
// deferred agent start (create-sequence step 8) after settlement has
// succeeded. A nil *startAgentDispatch means there is nothing to dispatch —
// prepare-only, start_agent not requested, or prepare itself failed.
type startAgentDispatch struct {
sessionID string
}
// prepareTaskSession runs the create sequence's synchronous session-setup
// step (part of step 6, "required synchronous post-create work") — it MUST
// run, and be allowed to fail, before settlement (step 7). Only the
// resulting agent launch is asynchronous dispatch (step 8), which must run
// after settlement (docs/specs/tasks/external-id-idempotency/spec.md,
// "Settlement call site (normative, per surface)": "the helper must expose
// preparation and dispatch separately so settlement can sit between them").
// A prior shape settled before calling this at all, which satisfied "no
// dispatch precedes settlement" too narrowly — a crash between settling and
// preparing would report creation_complete:true for a task whose session
// never got created, and a retry would never attempt session-prep again
// since Found outcomes skip all post-create work by design. Reordering fixes
// that: any failure or crash during prepare now leaves the row unsettled, so
// a retry correctly reports FoundUnsettled instead.
//
// A prepare failure (as opposed to a crash) is not itself fatal to task
// creation — matching the existing behavior for a request with no
// start_agent/prepare_session at all — so it is logged and the caller
// proceeds to settle normally; only a genuine crash before this call returns
// leaves the row unsettled.
func (h *TaskHandlers) prepareTaskSession(
c *gin.Context,
response *createTaskResponse,
taskID string,
body httpCreateTaskRequest,
resolvedStepID string,
canLaunch bool,
) *startAgentDispatch {
if !canLaunch || h.orchestrator == nil || body.AgentProfileID == "" {
return nil
}
if body.PrepareSession && !body.StartAgent {
// Prepare-only: no follow-up start is coming, so DeferredStart is
// intentionally omitted — a passthrough profile should be eagerly
// upgraded to a full launch here so the terminal has a PTY to attach to.
// (Contrast the start_agent branch below, which sets DeferredStart=true.)
resp, err := h.orchestrator.LaunchSession(c.Request.Context(), &orchestrator.LaunchSessionRequest{
TaskID: taskID,
Intent: orchestrator.IntentPrepare,
AgentProfileID: body.AgentProfileID,
ExecutorID: body.ExecutorID,
ExecutorProfileID: body.ExecutorProfileID,
WorkflowStepID: resolvedStepID,
LaunchWorkspace: true,
})
if err != nil {
h.logger.Error("failed to prepare session for task", zap.Error(err), zap.String("task_id", taskID))
} else {
response.TaskSessionID = resp.SessionID
}
return nil
}
if !body.StartAgent {
return nil
}
return h.prepareStartAgentSession(c.Request.Context(), response, taskID, body, resolvedStepID)
}
// prepareStartAgentSession runs the synchronous half of a start_agent create:
// creates the session entry so the caller can return a session ID
// immediately, without launching the workspace (the deferred async start
// below handles that, avoiding a 30-60s block on remote executors).
func (h *TaskHandlers) prepareStartAgentSession(
ctx context.Context,
response *createTaskResponse,
taskID string,
body httpCreateTaskRequest,
resolvedStepID string,
) *startAgentDispatch {
prepResp, err := h.orchestrator.LaunchSession(ctx, &orchestrator.LaunchSessionRequest{
TaskID: taskID,
Intent: orchestrator.IntentPrepare,
AgentProfileID: body.AgentProfileID,
ExecutorID: body.ExecutorID,
ExecutorProfileID: body.ExecutorProfileID,
WorkflowStepID: resolvedStepID,
// The async IntentStartCreated dispatch below carries the prompt. Mark
// this as a deferred start so a passthrough profile is not eagerly
// launched here with an empty prompt (which would pre-empt that
// prompt-bearing start).
DeferredStart: true,
})
if err != nil {
h.logger.Error("failed to prepare session for task", zap.Error(err), zap.String("task_id", taskID))
return nil
}
sessionID := prepResp.SessionID
response.TaskSessionID = sessionID
if updatedTask, updateErr := h.service.UpdateTaskState(ctx, taskID, v1.TaskStateScheduling); updateErr != nil {
h.logger.Warn("failed to mark task scheduling after preparing start session",
zap.Error(updateErr),
zap.String("task_id", taskID),
zap.String("session_id", sessionID))
} else {
response.State = updatedTask.State
}
return &startAgentDispatch{sessionID: sessionID}
}
// dispatchTaskSession launches the agent asynchronously (create-sequence
// step 8). Callers MUST only invoke this after settlement has succeeded —
// dispatch is nil whenever there was nothing prepared to dispatch.
func (h *TaskHandlers) dispatchTaskSession(taskID, description string, body httpCreateTaskRequest, dispatch *startAgentDispatch) {
if dispatch == nil {
return
}
sessionID := dispatch.sessionID
// Launch agent asynchronously so the HTTP request can return immediately.
// The frontend will receive WebSocket updates when the agent actually starts.
go func() {
startCtx, cancel := context.WithTimeout(context.Background(), constants.AgentLaunchTimeout)
defer cancel()
launchResp, err := h.orchestrator.LaunchSession(startCtx, &orchestrator.LaunchSessionRequest{
TaskID: taskID,
Intent: orchestrator.IntentStartCreated,
SessionID: sessionID,
AgentProfileID: body.AgentProfileID,
Prompt: description,
SkipMessageRecord: false,
PlanMode: body.PlanMode,
Attachments: body.Attachments,
})
if err != nil {
h.logger.Error("failed to start agent for task (async)", zap.Error(err), zap.String("task_id", taskID), zap.String("session_id", sessionID))
return
}
h.logger.Info("agent started for task (async)",
zap.String("task_id", taskID),
zap.String("session_id", launchResp.SessionID),
zap.String("execution_id", launchResp.AgentExecutionID))
}()
}
type httpUpdateTaskRequest struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
Priority *string `json:"priority,omitempty"`
State *v1.TaskState `json:"state,omitempty"`
Repositories []httpTaskRepositoryInput `json:"repositories,omitempty"`
Position *int `json:"position,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
// ParentID nests the task under another task. "" clears the parent.
ParentID *string `json:"parent_id,omitempty"`
}
type httpUpdateTaskPortForwardingRequest struct {
Enabled *bool `json:"enabled"`
}
func (h *TaskHandlers) httpUpdateTaskPortForwarding(c *gin.Context) {
var body httpUpdateTaskPortForwardingRequest
decoder := json.NewDecoder(c.Request.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&body); err != nil || body.Enabled == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "enabled must be a boolean"})
return
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
c.JSON(http.StatusBadRequest, gin.H{"error": "enabled must be a boolean"})
return
}
task, err := h.service.UpdateTaskMetadata(c.Request.Context(), c.Param("id"), map[string]interface{}{
models.MetaKeyPortForwardingEnabled: *body.Enabled,
})
if err != nil {
handleNotFound(c, h.logger, err, "task not updated")
return
}
c.JSON(http.StatusOK, dto.FromTask(task))
}
func (h *TaskHandlers) httpUpdateTask(c *gin.Context) {
var body httpUpdateTaskRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
// Convert repositories if provided
var repos []dto.TaskRepositoryInput
if body.Repositories != nil {
for _, r := range body.Repositories {
repos = append(repos, dto.TaskRepositoryInput{
RepositoryID: r.RepositoryID,
BaseBranch: r.BaseBranch,
CheckoutBranch: r.CheckoutBranch,
PRNumber: r.PRNumber,
LocalPath: r.LocalPath,
Name: r.Name,
DefaultBranch: r.DefaultBranch,
GitHubURL: r.GitHubURL,
RemoteURL: r.RemoteURL,
Provider: r.Provider,
ProviderRepoID: r.ProviderRepoID,
ProviderOwner: r.ProviderOwner,
ProviderName: r.ProviderName,
})
}
}
// Trim strings like the controller did
var title *string
if body.Title != nil {
trimmed := strings.TrimSpace(*body.Title)
title = &trimmed
}
var description *string
if body.Description != nil {
trimmed := strings.TrimSpace(*body.Description)
description = &trimmed
}
task, err := h.service.UpdateTask(c.Request.Context(), c.Param("id"), &service.UpdateTaskRequest{
Title: title,
Description: description,
Priority: body.Priority,
State: body.State,
Repositories: convertUpdateRepositories(body.Repositories != nil, repos),
Position: body.Position,
Metadata: body.Metadata,
ParentID: body.ParentID,
})
if err != nil {
handleNotFound(c, h.logger, err, "task not updated")
return
}
c.JSON(http.StatusOK, dto.FromTask(task))
}
func (h *TaskHandlers) httpDetachTask(c *gin.Context) {
task, err := h.service.DetachTask(c.Request.Context(), c.Param("id"))
if err != nil {
handleNotFound(c, h.logger, err, "task not found")
return
}
c.JSON(http.StatusOK, dto.FromTask(task))
}
type httpUpdateTaskRepositoryRequest struct {
BaseBranch string `json:"base_branch"`
}
// httpUpdateTaskRepository handles PATCH /tasks/:id/repositories/:repo_id.
// Today it only mutates base_branch; future per-row fields can be added on
// httpUpdateTaskRepositoryRequest. Mirrors the WS / MCP paths through the
// same service method so all three surfaces stay in sync.
func (h *TaskHandlers) httpUpdateTaskRepository(c *gin.Context) {
var body httpUpdateTaskRepositoryRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
taskRepo, err := h.service.UpdateRepositoryBaseBranch(c.Request.Context(), service.UpdateRepositoryBaseBranchRequest{
TaskID: c.Param("id"),
TaskRepositoryID: c.Param("repo_id"),
BaseBranch: body.BaseBranch,
})
if err != nil {
if errors.Is(err, service.ErrTaskRepositoryNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
// Distinguish caller-fixable validation failures (required field
// missing, unsafe ref name, …) from server-side faults (DB write
// errors propagated up from the service). Anything that matches a
// known validation message stays at 400; everything else escalates
// to 500 so client retries don't mask backend regressions.
if isValidationError(err) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Avoid echoing raw service errors on the 500 path — DB / IO
// failures can carry connection strings, table names, or stack
// traces. Log the detail server-side; return an opaque message.
h.logger.Error("update task repository failed",
zap.String("task_id", c.Param("id")),
zap.String("repo_id", c.Param("repo_id")),
zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update task repository"})
return
}
c.JSON(http.StatusOK, taskRepo)
}
type httpMoveTaskRequest struct {
WorkflowID string `json:"workflow_id"`
WorkflowStepID string `json:"workflow_step_id"`
Position int `json:"position"`
}
func (h *TaskHandlers) httpMoveTask(c *gin.Context) {
var body httpMoveTaskRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
if body.WorkflowID == "" || body.WorkflowStepID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "workflow_id and workflow_step_id are required"})
return
}
result, err := h.service.MoveTaskWithOptions(
c.Request.Context(), c.Param("id"),
body.WorkflowID, body.WorkflowStepID, body.Position,
service.MoveTaskOptions{AllowActivePrimarySession: true},
)
if err != nil {
handleSelectedMoveError(c, h.logger, err)
return
}
response := dto.MoveTaskResponse{
Task: dto.FromTask(result.Task),
}
if result.WorkflowStep != nil {
response.WorkflowStep = dto.FromWorkflowStep(result.WorkflowStep)
}
c.JSON(http.StatusOK, response)
}
func (h *TaskHandlers) httpDeleteTask(c *gin.Context) {
// WithoutCancel, not Background: a delete tears down worktrees, containers
// and subtree rows, so it must survive the client navigating away — but
// context.Background() also dropped the caller identity the auth middleware
// attached, and an identity-free context reads as an internal caller, which
// authorizes everything. WithoutCancel keeps the request's values and drops
// only its cancellation.
deleteCtx, cancel := context.WithTimeout(
context.WithoutCancel(c.Request.Context()), constants.TaskDeleteTimeout)
defer cancel()
taskID := c.Param("id")
cascade := cascadeQueryParam(c)
// Office task-handoffs phase 6: route through HandoffService.DeleteTaskTree
// when wired so descendant runs are cancelled, group memberships are
// released with reason=deleted, and the cleanup state machine fires.
if h.handoffSvc != nil {
if _, err := h.handoffSvc.DeleteTaskTree(deleteCtx, taskID, cascade); err != nil {
handleNotFound(c, h.logger, err, "task not deleted")
return
}
c.JSON(http.StatusOK, dto.SuccessResponse{Success: true})
return
}
if err := h.service.DeleteTask(deleteCtx, taskID); err != nil {
handleNotFound(c, h.logger, err, "task not deleted")
return
}
c.JSON(http.StatusOK, dto.SuccessResponse{Success: true})
}
func (h *TaskHandlers) httpArchiveTask(c *gin.Context) {
taskID := c.Param("id")
cascade := cascadeQueryParam(c)
// Office task-handoffs phase 6: when a HandoffService is wired,
// archive the whole subtree under a single cascade ID so
// descendants get tagged for scoped unarchive AND workspace-group
// memberships are released. When HandoffService is unconfigured
// (legacy / tests) fall back to the single-task path.
if h.handoffSvc != nil {
if _, err := h.handoffSvc.ArchiveTaskTree(c.Request.Context(), taskID, cascade); err != nil {
handleNotFound(c, h.logger, err, "task not archived")
return
}
c.JSON(http.StatusOK, dto.SuccessResponse{Success: true})
return
}
if err := h.service.ArchiveTask(c.Request.Context(), taskID); err != nil {
handleNotFound(c, h.logger, err, "task not archived")
return
}
c.JSON(http.StatusOK, dto.SuccessResponse{Success: true})
}
// cascadeQueryParam returns whether the archive/delete request asked to
// cascade into subtasks. Default is false — subtasks are preserved
// unless the client explicitly opts in via ?cascade=true.
func cascadeQueryParam(c *gin.Context) bool {
return strings.EqualFold(c.Query("cascade"), "true")
}
// httpTaskSubtaskCount returns the count of direct, non-archived,
// non-ephemeral subtasks for a task. Used by the frontend's archive /
// delete confirmation dialogs to decide whether to render the
// "Also archive/delete subtasks" checkbox.
func (h *TaskHandlers) httpTaskSubtaskCount(c *gin.Context) {
taskID := c.Param("id")
children, err := h.repo.ListChildren(c.Request.Context(), taskID)
if err != nil {
// Don't surface the raw repo error to the client — it can leak
// driver / SQL details. Log the full reason server-side, return
// a generic 500 to the caller.
h.logger.Error("failed to list direct subtasks",
zap.String("task_id", taskID), zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count subtasks"})
return
}
c.JSON(http.StatusOK, gin.H{"count": len(children)})
}
// httpUnarchiveTask routes through HandoffService.UnarchiveTaskTree so
// every task this cascade archived (and only those) is restored. The
// handler returns 503 when no HandoffService is wired since unarchive
// is meaningless without the cascade infrastructure.
func (h *TaskHandlers) httpUnarchiveTask(c *gin.Context) {
if h.handoffSvc == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "unarchive requires office task-handoffs to be configured",
})
return
}
taskID := c.Param("id")
outcome, err := h.handoffSvc.UnarchiveTaskTree(c.Request.Context(), taskID)
if err != nil {
handleNotFound(c, h.logger, err, "task not unarchived")
return
}
// Probe branch recoverability for every restored task: archive deleted
// the local branch + worktree, so report whether the branch still
// exists (locally or on origin) and restore checkout_branch so the
// next session picks the old work back up. Best-effort — an empty
// list just means nothing was recoverable. Detached from the request
// context: the tasks are already unarchived, so a client disconnect
// must not skip the checkout_branch restore.
recoveryCtx, cancelRecovery := context.WithTimeout(
context.WithoutCancel(c.Request.Context()),
h.detachedRecoveryTimeout(),
)
defer cancelRecovery()
recovery := make([]service.BranchRecovery, 0)
for _, id := range outcome.ArchivedTaskIDs {
recovery = append(recovery, h.service.RecoverTaskBranches(recoveryCtx, id)...)
}
workspaceRecovery := make([]storageworkspaces.WorkspaceRecovery, 0, len(outcome.ArchivedTaskIDs))
if h.workspaceRestorer != nil {
for _, id := range outcome.ArchivedTaskIDs {
workspaceRecovery = append(workspaceRecovery, h.workspaceRestorer.RestoreTask(recoveryCtx, id))
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"cascade_id": outcome.CascadeID,
"unarchived_ids": outcome.ArchivedTaskIDs,
"skipped_ids": outcome.SkippedTaskIDs,
"affected_group_ids": outcome.ReleasedGroupIDs,
"workspace_recovery": workspaceRecovery,
"recovery": recovery,
})
}
// httpStartQuickChatRequest is the request body for starting a quick chat session.
type httpStartQuickChatRequest struct {
Title string `json:"title,omitempty"`
RepositoryID string `json:"repository_id,omitempty"`
Repositories []httpQuickChatRepositoryInput `json:"repositories,omitempty"`
AgentProfileID string `json:"agent_profile_id,omitempty"`
ExecutorID string `json:"executor_id,omitempty"`
Prompt string `json:"prompt,omitempty"`
LocalPath string `json:"local_path,omitempty"`
RepositoryName string `json:"repository_name,omitempty"`
DefaultBranch string `json:"default_branch,omitempty"`
BaseBranch string `json:"base_branch,omitempty"`
LaunchImmediately bool `json:"launch_immediately,omitempty"`
}
type httpQuickChatRepositoryInput struct {
RepositoryID string `json:"repository_id"`
BaseBranch string `json:"base_branch"`
}
func (body *httpStartQuickChatRequest) validateRepositories() error {
hasLegacyRepository := body.RepositoryID != "" || body.LocalPath != "" ||
body.RepositoryName != "" || body.DefaultBranch != "" || body.BaseBranch != ""
if len(body.Repositories) > 0 && hasLegacyRepository {
return errors.New("repositories cannot be combined with legacy repository fields")
}
seen := make(map[string]struct{}, len(body.Repositories))
for _, repo := range body.Repositories {
if repo.RepositoryID == "" {
return errors.New("repository_id is required")
}
if repo.BaseBranch == "" {
return fmt.Errorf("base_branch is required for repository %q", repo.RepositoryID)
}
if _, exists := seen[repo.RepositoryID]; exists {
return fmt.Errorf("repository %q can only be selected once", repo.RepositoryID)
}
seen[repo.RepositoryID] = struct{}{}
}
return nil
}
// httpStartQuickChatResponse is returned when a quick chat session is created.
type httpStartQuickChatResponse struct {
TaskID string `json:"task_id"`
SessionID string `json:"session_id"`
}
// quickChatParams holds resolved parameters for creating a quick chat session.
type quickChatParams struct {
agentProfileID string
executorID string
title string
repos []service.TaskRepositoryInput
metadata map[string]interface{}
}
// buildQuickChatRepositories builds the repository input list from the request.
func (body *httpStartQuickChatRequest) buildRepositories() []service.TaskRepositoryInput {
if len(body.Repositories) > 0 {
repos := make([]service.TaskRepositoryInput, 0, len(body.Repositories))
for _, repo := range body.Repositories {
repos = append(repos, service.TaskRepositoryInput{
RepositoryID: repo.RepositoryID,
BaseBranch: repo.BaseBranch,
})
}
return repos
}
if body.RepositoryID == "" && body.LocalPath == "" {
return nil
}
return []service.TaskRepositoryInput{{
RepositoryID: body.RepositoryID,
LocalPath: body.LocalPath,
Name: body.RepositoryName,
DefaultBranch: body.DefaultBranch,
BaseBranch: body.BaseBranch,
}}
}
// resolveParams resolves agent/executor IDs and builds metadata for quick chat.
func (body *httpStartQuickChatRequest) resolveParams(workspace *models.Workspace) quickChatParams {
agentProfileID := body.AgentProfileID
if agentProfileID == "" && workspace.DefaultAgentProfileID != nil {
agentProfileID = *workspace.DefaultAgentProfileID
}
repos := body.buildRepositories()
executorID := body.ExecutorID
if len(repos) > 0 {
executorID = models.ExecutorIDWorktree
} else if executorID == "" && workspace.DefaultExecutorID != nil {
executorID = *workspace.DefaultExecutorID
}
metadata := make(map[string]interface{})
if agentProfileID != "" {
metadata[models.MetaKeyAgentProfileID] = agentProfileID
}
if executorID != "" {
metadata[models.MetaKeyExecutorID] = executorID
}
title := body.Title
if title == "" {
title = "Quick Chat"
}
return quickChatParams{
agentProfileID: agentProfileID,
executorID: executorID,
title: title,
repos: repos,
metadata: metadata,
}
}
// httpStartQuickChat creates an ephemeral task and prepares a session for quick chat.
func (h *TaskHandlers) httpStartQuickChat(c *gin.Context) {
workspaceID := c.Param("id")
var body httpStartQuickChatRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
if err := body.validateRepositories(); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx := c.Request.Context()
workspace, err := h.service.GetWorkspace(ctx, workspaceID)
if err != nil {
handleNotFound(c, h.logger, err, "workspace not found")
return
}
params := body.resolveParams(workspace)
if params.agentProfileID == "" {
h.logger.Error("no agent profile configured for quick chat", zap.String("workspace_id", workspaceID))
c.JSON(http.StatusBadRequest, gin.H{"error": "workspace has no default agent profile configured"})
return
}
result, err := h.service.CreateTask(ctx, &service.CreateTaskRequest{
WorkspaceID: workspaceID,
Title: params.title,
Description: body.Prompt,
Repositories: params.repos,
IsEphemeral: true,
Metadata: params.metadata,
})
if err != nil {
h.logger.Error("failed to create ephemeral task", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create quick chat"})
return
}
task := result.Task
// Eager-init: launch the agent process up-front so ACP `initialize` + `session/new`
// fire and the agent emits available_commands/modes/models. This populates the
// slash menu, mode dropdown, and model selector before the user sends any prompt.
resp, err := h.orchestrator.LaunchSession(ctx, &orchestrator.LaunchSessionRequest{
TaskID: task.ID,
Intent: orchestrator.IntentStart,
AgentProfileID: params.agentProfileID,
ExecutorID: params.executorID,
})
if err != nil {
// Rollback: delete the ephemeral task to prevent orphans. Use a fresh
// background context — the request context may already be cancelled
// (e.g. client aborted, deadline exceeded), and we still want cleanup
// to run. TaskDeleteTimeout matches the other DeleteTask call sites
// in this file so a future change to the constant covers this path too.
rollbackCtx, cancel := context.WithTimeout(context.Background(), constants.TaskDeleteTimeout)
defer cancel()
if deleteErr := h.service.DeleteTask(rollbackCtx, task.ID); deleteErr != nil {
h.logger.Error("failed to rollback quick chat task",
zap.String("task_id", task.ID),
zap.Error(deleteErr))
}
h.logger.Error("failed to start quick chat session", zap.Error(err), zap.String("task_id", task.ID))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start session"})
return
}
h.logger.Info("quick chat session created",
zap.String("task_id", task.ID),
zap.String("session_id", resp.SessionID),
zap.String("workspace_id", workspaceID))
c.JSON(http.StatusOK, httpStartQuickChatResponse{
TaskID: task.ID,
SessionID: resp.SessionID,
})
}
// httpQuickChatSession is one restorable quick-chat tab.
type httpQuickChatSession struct {
SessionID string `json:"session_id"`
TaskID string `json:"task_id"`
WorkspaceID string `json:"workspace_id"`
Kind string `json:"kind"`
Name string `json:"name,omitempty"`
AgentProfileID string `json:"agent_profile_id,omitempty"`
}
// httpListQuickChatSessionsResponse mirrors the quick-chat slice of the boot
// payload so a running client can resync its tab strip without a full reload.
type httpListQuickChatSessionsResponse struct {
Sessions []httpQuickChatSession `json:"sessions"`
TaskSessions []dto.TaskSessionDTO `json:"task_sessions"`
}
// httpListQuickChatSessions returns the workspace's restorable quick-chat tabs.
// Quick chats are created and closed from any device, so clients poll this on
// (re)connect to converge on the server's list instead of drifting apart.
func (h *TaskHandlers) httpListQuickChatSessions(c *gin.Context) {
workspaceID := c.Param("id")
items, err := h.service.ListQuickChatSessions(c.Request.Context(), workspaceID)
if err != nil {
handleNotFound(c, h.logger, err, "workspace not found")
return
}
response := httpListQuickChatSessionsResponse{
Sessions: make([]httpQuickChatSession, 0, len(items)),
TaskSessions: make([]dto.TaskSessionDTO, 0, len(items)),
}
for _, item := range items {
response.Sessions = append(response.Sessions, httpQuickChatSession{
SessionID: item.SessionID,
TaskID: item.TaskID,
WorkspaceID: item.WorkspaceID,
Kind: item.Kind,
Name: item.Name,
AgentProfileID: item.AgentProfileID,
})
sessionDTO := dto.FromTaskSession(item.Session)
dto.EnrichCancellationPending(&sessionDTO, h.cancellationPending)
response.TaskSessions = append(response.TaskSessions, sessionDTO)
}
c.JSON(http.StatusOK, response)
}
// httpStartConfigChatRequest is the request body for starting a config chat session.
type httpStartConfigChatRequest struct {
AgentProfileID string `json:"agent_profile_id,omitempty"`
ExecutorID string `json:"executor_id,omitempty"`
Prompt string `json:"prompt,omitempty"`
}
// httpStartConfigChat creates an ephemeral task with config_mode and prepares a session.
// The session will have config MCP tools (workflow, agent, MCP management) instead of
// the normal kanban/plan tools used for task-solving agents.
// resolveConfigChatDefaults resolves the agent profile ID, executor ID, and task
// metadata for a config chat session. Profile priority: request → workspace config → workspace default.
func resolveConfigChatDefaults(body httpStartConfigChatRequest, ws *models.Workspace) (agentProfileID, executorID string, metadata map[string]interface{}) {
agentProfileID = body.AgentProfileID
if agentProfileID == "" && ws.DefaultConfigAgentProfileID != nil {
agentProfileID = *ws.DefaultConfigAgentProfileID
}
if agentProfileID == "" && ws.DefaultAgentProfileID != nil {
agentProfileID = *ws.DefaultAgentProfileID
}
executorID = body.ExecutorID
if executorID == "" && ws.DefaultExecutorID != nil {
executorID = *ws.DefaultExecutorID
}
metadata = map[string]interface{}{
"config_mode": true,
models.MetaKeyAgentProfileID: agentProfileID,
}
if executorID != "" {
metadata[models.MetaKeyExecutorID] = executorID
}
return agentProfileID, executorID, metadata
}
func (h *TaskHandlers) httpStartConfigChat(c *gin.Context) {
workspaceID := c.Param("id")
var body httpStartConfigChatRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
ctx := c.Request.Context()
workspace, err := h.service.GetWorkspace(ctx, workspaceID)
if err != nil {
handleNotFound(c, h.logger, err, "workspace not found")
return
}
agentProfileID, executorID, metadata := resolveConfigChatDefaults(body, workspace)
if agentProfileID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "no agent profile configured — set a default agent profile in workspace settings"})
return
}
result, err := h.service.CreateTask(ctx, &service.CreateTaskRequest{
WorkspaceID: workspaceID,
Title: "Config Chat",
Description: body.Prompt,
IsEphemeral: true,
Metadata: metadata,
})
if err != nil {
h.logger.Error("failed to create config chat task", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config chat"})
return
}
task := result.Task
resp, err := h.orchestrator.LaunchSession(ctx, &orchestrator.LaunchSessionRequest{
TaskID: task.ID,
Intent: orchestrator.IntentPrepare,
AgentProfileID: agentProfileID,
ExecutorID: executorID,
// When a prompt is present, launchConfigChatAgent below follows with a
// prompt-bearing IntentStartCreated. Defer the start so a passthrough
// profile isn't eagerly launched here with an empty prompt. With no
// prompt there is no follow-up, so keep the eager upgrade that gives the
// terminal a PTY to attach to.
DeferredStart: body.Prompt != "",
})
if err != nil {
h.deleteTaskOnError(task.ID, "config chat", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to prepare session"})
return
}
sessionID := resp.SessionID
// If a prompt was provided, launch the agent asynchronously so it starts
// processing immediately. The frontend receives WS updates when it starts.
if body.Prompt != "" {
go h.launchConfigChatAgent(task.ID, sessionID, agentProfileID, body.Prompt)
}
h.logger.Info("config chat session created",
zap.String("task_id", task.ID),
zap.String("session_id", sessionID),
zap.String("workspace_id", workspaceID))
c.JSON(http.StatusOK, httpStartQuickChatResponse{
TaskID: task.ID,
SessionID: sessionID,
})
}
func (h *TaskHandlers) deleteTaskOnError(taskID, label string, err error) {
if deleteErr := h.service.DeleteTask(context.Background(), taskID); deleteErr != nil {
h.logger.Error("failed to rollback "+label+" task",
zap.String("task_id", taskID), zap.Error(deleteErr))
}
h.logger.Error("failed to prepare "+label+" session",
zap.Error(err), zap.String("task_id", taskID))
}
func (h *TaskHandlers) launchConfigChatAgent(
taskID, sessionID, agentProfileID, prompt string,
) {
startCtx, cancel := context.WithTimeout(
context.Background(), constants.AgentLaunchTimeout,
)
defer cancel()
launchResp, err := h.orchestrator.LaunchSession(startCtx, &orchestrator.LaunchSessionRequest{
TaskID: taskID,
Intent: orchestrator.IntentStartCreated,
SessionID: sessionID,
AgentProfileID: agentProfileID,
Prompt: prompt,
})
if err != nil {
h.logger.Error("failed to start config chat agent",
zap.Error(err), zap.String("task_id", taskID),
zap.String("session_id", sessionID))
return
}
h.logger.Info("config chat agent started",
zap.String("task_id", taskID),
zap.String("session_id", launchResp.SessionID),
zap.String("execution_id", launchResp.AgentExecutionID))
}