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

20
api/db/db.go Normal file
View File

@@ -0,0 +1,20 @@
package db
import (
"database/sql"
_ "github.com/lib/pq"
)
func Connect(dsn string) (*sql.DB, error) {
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
return nil, err
}
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(5)
return db, nil
}

BIN
api/director-api Executable file

Binary file not shown.

17
api/director-api.service Normal file
View File

@@ -0,0 +1,17 @@
[Unit]
Description=Director API
After=network.target postgresql.service redis-server.service
Requires=postgresql.service redis-server.service
[Service]
Type=simple
User=joe
Group=joe
WorkingDirectory=/data/director/app/api
ExecStart=/data/director/app/api/director-api
Environment=GIN_MODE=release
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target

42
api/go.mod Normal file
View File

@@ -0,0 +1,42 @@
module director/api
go 1.25.0
require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/gin-gonic/gin v1.12.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lib/pq v1.12.3 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/redis/go-redis/v9 v9.18.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)

90
api/go.sum Normal file
View File

@@ -0,0 +1,90 @@
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

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]
}

119
api/main.go Normal file
View File

@@ -0,0 +1,119 @@
package main
import (
"context"
"log"
"os"
"director/api/db"
"director/api/handlers"
"director/api/pubsub"
"director/api/ws"
"github.com/gin-gonic/gin"
)
func main() {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
dsn = "postgres://director:director_local_2026@127.0.0.1:5432/director?sslmode=disable"
}
redisAddr := os.Getenv("REDIS_URL")
if redisAddr == "" {
redisAddr = "127.0.0.1:6379"
}
database, err := db.Connect(dsn)
if err != nil {
log.Fatalf("database connection failed: %v", err)
}
defer database.Close()
hub := ws.NewHub()
go hub.Run()
// Redis pub/sub — bridges external events (OpenClaw agents) to WebSocket clients
ps := pubsub.New(redisAddr, hub)
if err := ps.Ping(context.Background()); err != nil {
log.Fatalf("redis connection failed: %v", err)
}
defer ps.Close()
go ps.Subscribe(context.Background())
r := gin.Default()
// CORS for local dev
r.Use(func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
})
api := r.Group("/api")
{
h := handlers.New(database, ps)
// Plans
api.GET("/plans", h.ListPlans)
api.POST("/plans", h.CreatePlan)
api.GET("/plans/:id", h.GetPlan)
api.PATCH("/plans/:id", h.UpdatePlan)
// Tasks
api.GET("/plans/:id/tasks", h.ListTasks)
api.POST("/plans/:id/tasks", h.CreateTask)
api.PATCH("/tasks/:id", h.UpdateTask)
// Agents
api.GET("/agents", h.ListAgents)
api.PATCH("/agents/:id", h.UpdateAgent)
// Director Queue
api.GET("/queue", h.ListQueue)
api.PATCH("/queue/:id", h.UpdateQueueItem)
// Pending Questions
api.GET("/questions", h.ListQuestions)
api.POST("/questions", h.CreateQuestion)
api.PATCH("/questions/:id", h.AnswerQuestion)
// Messages
api.GET("/plans/:id/messages", h.ListMessages)
api.POST("/plans/:id/messages", h.CreateMessage)
api.PATCH("/messages/:id", h.UpdateMessage)
// System / Settings
api.GET("/system/claude-mode", h.GetClaudeMode)
api.POST("/system/claude-mode", h.SetClaudeMode)
}
// WebSocket
r.GET("/ws", func(c *gin.Context) {
ws.HandleWebSocket(hub, c.Writer, c.Request)
})
// Health
r.GET("/health", func(c *gin.Context) {
if err := ps.Ping(c.Request.Context()); err != nil {
c.JSON(500, gin.H{"status": "error", "redis": err.Error()})
return
}
c.JSON(200, gin.H{"status": "ok"})
})
port := os.Getenv("PORT")
if port == "" {
port = "8090"
}
log.Printf("Director API starting on :%s", port)
if err := r.Run(":" + port); err != nil {
log.Fatalf("server failed: %v", err)
}
}

63
api/pubsub/pubsub.go Normal file
View File

@@ -0,0 +1,63 @@
package pubsub
import (
"context"
"encoding/json"
"log"
"director/api/ws"
"github.com/redis/go-redis/v9"
)
const Channel = "director:events"
type Event struct {
Type string `json:"event"`
Data any `json:"data"`
}
type PubSub struct {
rdb *redis.Client
hub *ws.Hub
}
func New(redisAddr string, hub *ws.Hub) *PubSub {
rdb := redis.NewClient(&redis.Options{
Addr: redisAddr,
})
return &PubSub{rdb: rdb, hub: hub}
}
// Publish sends an event to Redis pub/sub.
// Called by the API handlers and can also be called by external agents via redis-cli.
func (ps *PubSub) Publish(ctx context.Context, event string, data any) error {
msg, err := json.Marshal(Event{Type: event, Data: data})
if err != nil {
return err
}
return ps.rdb.Publish(ctx, Channel, msg).Err()
}
// Subscribe listens for events on Redis pub/sub and forwards them to WebSocket clients.
// This bridges external publishers (OpenClaw agents, cron jobs) to the React UI.
func (ps *PubSub) Subscribe(ctx context.Context) {
sub := ps.rdb.Subscribe(ctx, Channel)
ch := sub.Channel()
log.Printf("Redis pub/sub subscribed to %s", Channel)
for msg := range ch {
ps.hub.Broadcast([]byte(msg.Payload))
}
}
// Ping tests the Redis connection.
func (ps *PubSub) Ping(ctx context.Context) error {
return ps.rdb.Ping(ctx).Err()
}
// Close shuts down the Redis client.
func (ps *PubSub) Close() error {
return ps.rdb.Close()
}

71
api/ws/hub.go Normal file
View File

@@ -0,0 +1,71 @@
package ws
import (
"log"
"net/http"
"sync"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
type Hub struct {
mu sync.RWMutex
clients map[*websocket.Conn]bool
}
func NewHub() *Hub {
return &Hub{
clients: make(map[*websocket.Conn]bool),
}
}
func (h *Hub) Run() {
// Hub is passive — broadcasts are push-based via Broadcast()
select {}
}
func (h *Hub) Register(conn *websocket.Conn) {
h.mu.Lock()
h.clients[conn] = true
h.mu.Unlock()
}
func (h *Hub) Unregister(conn *websocket.Conn) {
h.mu.Lock()
delete(h.clients, conn)
h.mu.Unlock()
conn.Close()
}
func (h *Hub) Broadcast(msg []byte) {
h.mu.RLock()
defer h.mu.RUnlock()
for conn := range h.clients {
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
log.Printf("ws write error: %v", err)
conn.Close()
delete(h.clients, conn)
}
}
}
func HandleWebSocket(hub *Hub, w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("ws upgrade error: %v", err)
return
}
hub.Register(conn)
defer hub.Unregister(conn)
for {
_, _, err := conn.ReadMessage()
if err != nil {
break
}
}
}