Initial commit — Director app (API + UI)

This commit is contained in:
2026-04-07 15:18:16 -05:00
commit 5f29db67f3
44 changed files with 6727 additions and 0 deletions

697
api/handlers/handlers.go Normal file
View File

@@ -0,0 +1,697 @@
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, &note, &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()})
}

83
api/handlers/system.go Normal file
View File

@@ -0,0 +1,83 @@
package handlers
import (
"os/exec"
"github.com/gin-gonic/gin"
)
const claudeModeScript = "/data/director/scripts/claude-mode.sh"
// GetClaudeMode returns the current Claude provider mode (api or proxy)
// by parsing the openclaw.json directly via the toggle script.
func (h *Handler) GetClaudeMode(c *gin.Context) {
out, err := exec.Command(claudeModeScript, "status").CombinedOutput()
if err != nil {
c.JSON(500, gin.H{"error": err.Error(), "output": string(out)})
return
}
mode := "unknown"
for _, line := range splitLines(string(out)) {
if hasPrefix(line, "Current Claude mode: ") {
mode = trim(line[len("Current Claude mode: "):])
break
}
}
c.JSON(200, gin.H{
"mode": mode,
"output": string(out),
})
}
// SetClaudeMode flips between api and proxy modes.
func (h *Handler) SetClaudeMode(c *gin.Context) {
var input struct {
Mode string `json:"mode" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
if input.Mode != "api" && input.Mode != "proxy" {
c.JSON(400, gin.H{"error": "mode must be 'api' or 'proxy'"})
return
}
out, err := exec.Command(claudeModeScript, input.Mode).CombinedOutput()
if err != nil {
c.JSON(500, gin.H{"error": err.Error(), "output": string(out)})
return
}
h.broadcast(c, "system:claude-mode", gin.H{"mode": input.Mode})
c.JSON(200, gin.H{"mode": input.Mode, "output": string(out)})
}
// tiny string helpers — avoid importing strings just for these
func splitLines(s string) []string {
var out []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
out = append(out, s[start:i])
start = i + 1
}
}
if start < len(s) {
out = append(out, s[start:])
}
return out
}
func hasPrefix(s, p string) bool {
return len(s) >= len(p) && s[:len(p)] == p
}
func trim(s string) string {
start, end := 0, len(s)
for start < end && (s[start] == ' ' || s[start] == '\t' || s[start] == '\r') {
start++
}
for end > start && (s[end-1] == ' ' || s[end-1] == '\t' || s[end-1] == '\r') {
end--
}
return s[start:end]
}