package handlers import ( "database/sql" "strconv" "director/api/pubsub" "github.com/gin-gonic/gin" ) type Handler struct { db *sql.DB ps *pubsub.PubSub } func New(db *sql.DB, ps *pubsub.PubSub) *Handler { return &Handler{db: db, ps: ps} } func (h *Handler) broadcast(c *gin.Context, event string, data any) { h.ps.Publish(c.Request.Context(), event, data) } // --- Plans --- func (h *Handler) ListPlans(c *gin.Context) { rows, err := h.db.Query(` SELECT id, title, description, team, priority, status, created_at, updated_at, approved_at, started_at, completed_at FROM plans ORDER BY CASE priority WHEN 'critical' THEN 0 WHEN '1' THEN 1 WHEN '2' THEN 2 WHEN '3' THEN 3 WHEN '4' THEN 4 WHEN '5' THEN 5 WHEN 'backlog' THEN 6 END, created_at DESC `) if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } defer rows.Close() plans := []map[string]any{} for rows.Next() { var id int var title, desc, team, priority, status string var createdAt, updatedAt string var approvedAt, startedAt, completedAt sql.NullString rows.Scan(&id, &title, &desc, &team, &priority, &status, &createdAt, &updatedAt, &approvedAt, &startedAt, &completedAt) p := map[string]any{ "id": id, "title": title, "description": desc, "team": team, "priority": priority, "status": status, "created_at": createdAt, "updated_at": updatedAt, } if approvedAt.Valid { p["approved_at"] = approvedAt.String } if startedAt.Valid { p["started_at"] = startedAt.String } if completedAt.Valid { p["completed_at"] = completedAt.String } plans = append(plans, p) } c.JSON(200, plans) } func (h *Handler) CreatePlan(c *gin.Context) { var input struct { Title string `json:"title" binding:"required"` Description string `json:"description" binding:"required"` Team string `json:"team" binding:"required"` Priority string `json:"priority"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } if input.Priority == "" { input.Priority = "3" } var id int err := h.db.QueryRow(` INSERT INTO plans (title, description, team, priority) VALUES ($1, $2, $3, $4) RETURNING id `, input.Title, input.Description, input.Team, input.Priority).Scan(&id) if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } plan := map[string]any{ "id": id, "title": input.Title, "description": input.Description, "team": input.Team, "priority": input.Priority, "status": "draft", } h.broadcast(c, "plan:created", plan) c.JSON(201, plan) } func (h *Handler) GetPlan(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) var title, desc, team, priority, status, createdAt, updatedAt string var approvedAt, startedAt, completedAt sql.NullString err := h.db.QueryRow(` SELECT title, description, team, priority, status, created_at, updated_at, approved_at, started_at, completed_at FROM plans WHERE id = $1 `, id).Scan(&title, &desc, &team, &priority, &status, &createdAt, &updatedAt, &approvedAt, &startedAt, &completedAt) if err == sql.ErrNoRows { c.JSON(404, gin.H{"error": "plan not found"}) return } if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } out := map[string]any{ "id": id, "title": title, "description": desc, "team": team, "priority": priority, "status": status, "created_at": createdAt, "updated_at": updatedAt, } if approvedAt.Valid { out["approved_at"] = approvedAt.String } if startedAt.Valid { out["started_at"] = startedAt.String } if completedAt.Valid { out["completed_at"] = completedAt.String } c.JSON(200, out) } func (h *Handler) UpdatePlan(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) var input map[string]any if err := c.ShouldBindJSON(&input); err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } // Build dynamic update allowed := map[string]bool{"title": true, "description": true, "priority": true, "status": true} sets := "" args := []any{} i := 1 for k, v := range input { if !allowed[k] { continue } if sets != "" { sets += ", " } sets += k + " = $" + strconv.Itoa(i) args = append(args, v) i++ } if sets == "" { c.JSON(400, gin.H{"error": "no valid fields"}) return } args = append(args, id) _, err := h.db.Exec("UPDATE plans SET "+sets+" WHERE id = $"+strconv.Itoa(i), args...) if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } input["id"] = id h.broadcast(c, "plan:updated", input) c.JSON(200, input) } // --- Tasks --- func (h *Handler) ListTasks(c *gin.Context) { planID, _ := strconv.Atoi(c.Param("id")) rows, err := h.db.Query(` SELECT id, title, description, task_type, status, assigned_agent, progress_note, sort_order, created_at, updated_at, started_at, completed_at FROM tasks WHERE plan_id = $1 ORDER BY sort_order, id `, planID) if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } defer rows.Close() tasks := []map[string]any{} for rows.Next() { var id, sortOrder int var title, taskType, status, createdAt, updatedAt string var desc, agent, note *string var startedAt, completedAt sql.NullString rows.Scan(&id, &title, &desc, &taskType, &status, &agent, ¬e, &sortOrder, &createdAt, &updatedAt, &startedAt, &completedAt) t := map[string]any{ "id": id, "plan_id": planID, "title": title, "task_type": taskType, "status": status, "sort_order": sortOrder, "created_at": createdAt, "updated_at": updatedAt, } if desc != nil { t["description"] = *desc } if agent != nil { t["assigned_agent"] = *agent } if note != nil { t["progress_note"] = *note } if startedAt.Valid { t["started_at"] = startedAt.String } if completedAt.Valid { t["completed_at"] = completedAt.String } tasks = append(tasks, t) } c.JSON(200, tasks) } func (h *Handler) CreateTask(c *gin.Context) { planID, _ := strconv.Atoi(c.Param("id")) var input struct { Title string `json:"title" binding:"required"` Description string `json:"description"` TaskType string `json:"task_type"` AssignedAgent string `json:"assigned_agent"` SortOrder int `json:"sort_order"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } if input.TaskType == "" { input.TaskType = "core" } var id int err := h.db.QueryRow(` INSERT INTO tasks (plan_id, title, description, task_type, assigned_agent, sort_order) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id `, planID, input.Title, input.Description, input.TaskType, input.AssignedAgent, input.SortOrder).Scan(&id) if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } task := map[string]any{ "id": id, "plan_id": planID, "title": input.Title, "task_type": input.TaskType, "status": "queued", } h.broadcast(c, "task:created", task) c.JSON(201, task) } func (h *Handler) UpdateTask(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) var input map[string]any if err := c.ShouldBindJSON(&input); err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } allowed := map[string]bool{"status": true, "assigned_agent": true, "progress_note": true, "blockers": true} sets := "" args := []any{} i := 1 for k, v := range input { if !allowed[k] { continue } if sets != "" { sets += ", " } sets += k + " = $" + strconv.Itoa(i) args = append(args, v) i++ } if sets == "" { c.JSON(400, gin.H{"error": "no valid fields"}) return } args = append(args, id) _, err := h.db.Exec("UPDATE tasks SET "+sets+" WHERE id = $"+strconv.Itoa(i), args...) if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } input["id"] = id h.broadcast(c, "task:updated", input) c.JSON(200, input) } // --- Agents --- func (h *Handler) ListAgents(c *gin.Context) { rows, err := h.db.Query(` SELECT id, name, team, role, model_provider, model_id, gpu_default, status, current_task_id, last_seen_at, specialization FROM agents ORDER BY team, role `) if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } defer rows.Close() agents := []map[string]any{} for rows.Next() { var id, name, team, role, provider, model, status string var gpu, lastSeen, specialization *string var taskID *int rows.Scan(&id, &name, &team, &role, &provider, &model, &gpu, &status, &taskID, &lastSeen, &specialization) a := map[string]any{ "id": id, "name": name, "team": team, "role": role, "model_provider": provider, "model_id": model, "status": status, } if gpu != nil { a["gpu_default"] = *gpu } if taskID != nil { a["current_task_id"] = *taskID } if lastSeen != nil { a["last_seen_at"] = *lastSeen } if specialization != nil { a["specialization"] = *specialization } agents = append(agents, a) } c.JSON(200, agents) } func (h *Handler) UpdateAgent(c *gin.Context) { id := c.Param("id") var input map[string]any if err := c.ShouldBindJSON(&input); err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } allowed := map[string]bool{"status": true, "current_task_id": true} sets := "" args := []any{} i := 1 for k, v := range input { if !allowed[k] { continue } if sets != "" { sets += ", " } sets += k + " = $" + strconv.Itoa(i) args = append(args, v) i++ } if sets == "" { c.JSON(400, gin.H{"error": "no valid fields"}) return } args = append(args, id) _, err := h.db.Exec("UPDATE agents SET "+sets+", last_seen_at = now() WHERE id = $"+strconv.Itoa(i), args...) if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } input["id"] = id h.broadcast(c, "agent:updated", input) c.JSON(200, input) } // --- Director Queue --- func (h *Handler) ListQueue(c *gin.Context) { status := c.DefaultQuery("status", "ready_for_approval") rows, err := h.db.Query(` SELECT dq.id, dq.plan_id, dq.task_id, dq.status, dq.verification_notes, dq.director_notes, dq.submitted_at, dq.reviewed_at, COALESCE(p.title, ''), COALESCE(t.title, '') FROM director_queue dq LEFT JOIN plans p ON dq.plan_id = p.id LEFT JOIN tasks t ON dq.task_id = t.id WHERE dq.status = $1 ORDER BY dq.submitted_at `, status) if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } defer rows.Close() items := []map[string]any{} for rows.Next() { var id int var planID, taskID *int var qStatus, planTitle, taskTitle string var verNotes, dirNotes *string var submittedAt string var reviewedAt *string rows.Scan(&id, &planID, &taskID, &qStatus, &verNotes, &dirNotes, &submittedAt, &reviewedAt, &planTitle, &taskTitle) item := map[string]any{ "id": id, "status": qStatus, "submitted_at": submittedAt, "plan_title": planTitle, "task_title": taskTitle, } if planID != nil { item["plan_id"] = *planID } if taskID != nil { item["task_id"] = *taskID } if verNotes != nil { item["verification_notes"] = *verNotes } if dirNotes != nil { item["director_notes"] = *dirNotes } if reviewedAt != nil { item["reviewed_at"] = *reviewedAt } items = append(items, item) } c.JSON(200, items) } func (h *Handler) UpdateQueueItem(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) var input struct { Status string `json:"status" binding:"required"` DirectorNotes string `json:"director_notes"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } _, err := h.db.Exec(` UPDATE director_queue SET status = $1, director_notes = $2, reviewed_at = now() WHERE id = $3 `, input.Status, input.DirectorNotes, id) if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } result := map[string]any{"id": id, "status": input.Status} h.broadcast(c, "queue:updated", result) c.JSON(200, result) } // --- Pending Questions --- func (h *Handler) CreateQuestion(c *gin.Context) { var input struct { PlanID *int `json:"plan_id"` TaskID *int `json:"task_id"` FromRole string `json:"from_role" binding:"required"` FromID string `json:"from_id" binding:"required"` Question string `json:"question" binding:"required"` Context string `json:"context"` Priority string `json:"priority"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } if input.Priority == "" { input.Priority = "3" } var id int err := h.db.QueryRow(` INSERT INTO pending_questions (plan_id, task_id, from_role, from_id, question, context, priority) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id `, input.PlanID, input.TaskID, input.FromRole, input.FromID, input.Question, input.Context, input.Priority).Scan(&id) if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } q := map[string]any{ "id": id, "from_role": input.FromRole, "from_id": input.FromID, "question": input.Question, "priority": input.Priority, "status": "awaiting_director", } if input.PlanID != nil { q["plan_id"] = *input.PlanID } if input.TaskID != nil { q["task_id"] = *input.TaskID } if input.Context != "" { q["context"] = input.Context } h.broadcast(c, "question:created", q) c.JSON(201, q) } func (h *Handler) ListQuestions(c *gin.Context) { statusFilter := c.DefaultQuery("status", "awaiting_director") rows, err := h.db.Query(` SELECT pq.id, pq.plan_id, pq.task_id, pq.from_role, pq.from_id, pq.question, pq.context, pq.priority, pq.status, pq.answer, pq.created_at, pq.answered_at, COALESCE(p.title,''), COALESCE(t.title,'') FROM pending_questions pq LEFT JOIN plans p ON pq.plan_id = p.id LEFT JOIN tasks t ON pq.task_id = t.id WHERE pq.status = $1 ORDER BY CASE pq.priority WHEN 'critical' THEN 0 WHEN '1' THEN 1 WHEN '2' THEN 2 WHEN '3' THEN 3 WHEN '4' THEN 4 WHEN '5' THEN 5 END, pq.created_at `, statusFilter) if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } defer rows.Close() questions := []map[string]any{} for rows.Next() { var id int var planID, taskID *int var fromRole, fromID, question, priority, status, createdAt string var ctx, answer, answeredAt *string var planTitle, taskTitle string rows.Scan(&id, &planID, &taskID, &fromRole, &fromID, &question, &ctx, &priority, &status, &answer, &createdAt, &answeredAt, &planTitle, &taskTitle) q := map[string]any{ "id": id, "from_role": fromRole, "from_id": fromID, "question": question, "priority": priority, "status": status, "created_at": createdAt, "plan_title": planTitle, "task_title": taskTitle, } if planID != nil { q["plan_id"] = *planID } if taskID != nil { q["task_id"] = *taskID } if ctx != nil { q["context"] = *ctx } if answer != nil { q["answer"] = *answer } if answeredAt != nil { q["answered_at"] = *answeredAt } questions = append(questions, q) } c.JSON(200, questions) } func (h *Handler) AnswerQuestion(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) var input struct { Answer string `json:"answer" binding:"required"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } _, err := h.db.Exec(` UPDATE pending_questions SET answer = $1, status = 'answered', answered_at = now() WHERE id = $2 `, input.Answer, id) if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } result := map[string]any{"id": id, "status": "answered"} h.broadcast(c, "question:answered", result) c.JSON(200, result) } // --- Messages --- func (h *Handler) ListMessages(c *gin.Context) { planID, _ := strconv.Atoi(c.Param("id")) rows, err := h.db.Query(` SELECT id, task_id, reply_to_id, from_role, from_id, message, message_type, created_at FROM messages WHERE plan_id = $1 ORDER BY created_at `, planID) if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } defer rows.Close() messages := []map[string]any{} for rows.Next() { var id int var taskID, replyToID *int var fromRole, fromID, message, msgType, createdAt string rows.Scan(&id, &taskID, &replyToID, &fromRole, &fromID, &message, &msgType, &createdAt) m := map[string]any{ "id": id, "plan_id": planID, "from_role": fromRole, "from_id": fromID, "message": message, "message_type": msgType, "created_at": createdAt, } if taskID != nil { m["task_id"] = *taskID } if replyToID != nil { m["reply_to_id"] = *replyToID } messages = append(messages, m) } c.JSON(200, messages) } func (h *Handler) CreateMessage(c *gin.Context) { planID, _ := strconv.Atoi(c.Param("id")) var input struct { TaskID *int `json:"task_id"` ReplyToID *int `json:"reply_to_id"` FromRole string `json:"from_role" binding:"required"` FromID string `json:"from_id" binding:"required"` Message string `json:"message" binding:"required"` MessageType string `json:"message_type"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } if input.MessageType == "" { input.MessageType = "comment" } var id int err := h.db.QueryRow(` INSERT INTO messages (plan_id, task_id, reply_to_id, from_role, from_id, message, message_type) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id `, planID, input.TaskID, input.ReplyToID, input.FromRole, input.FromID, input.Message, input.MessageType).Scan(&id) if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } msg := map[string]any{ "id": id, "plan_id": planID, "from_role": input.FromRole, "from_id": input.FromID, "message": input.Message, "message_type": input.MessageType, } if input.TaskID != nil { msg["task_id"] = *input.TaskID } if input.ReplyToID != nil { msg["reply_to_id"] = *input.ReplyToID } h.broadcast(c, "message:created", msg) c.JSON(201, msg) } func (h *Handler) UpdateMessage(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) var input struct { Message string `json:"message" binding:"required"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } // Only allow editing director-authored messages — never mutate agent or lead history var fromRole string err := h.db.QueryRow(`SELECT from_role FROM messages WHERE id = $1`, id).Scan(&fromRole) if err == sql.ErrNoRows { c.JSON(404, gin.H{"error": "message not found"}) return } if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } if fromRole != "director" { c.JSON(403, gin.H{"error": "only director-authored messages can be edited"}) return } _, err = h.db.Exec(`UPDATE messages SET message = $1 WHERE id = $2`, input.Message, id) if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } result := map[string]any{"id": id, "message": input.Message} h.broadcast(c, "message:updated", result) c.JSON(200, result) } func intParam(c *gin.Context, name string) int { v, _ := strconv.Atoi(c.Param(name)) return v } func sendError(c *gin.Context, code int, err error) { c.JSON(code, gin.H{"error": err.Error()}) }