Initial commit — Director app (API + UI)
This commit is contained in:
20
api/db/db.go
Normal file
20
api/db/db.go
Normal 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
BIN
api/director-api
Executable file
Binary file not shown.
17
api/director-api.service
Normal file
17
api/director-api.service
Normal 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
42
api/go.mod
Normal 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
90
api/go.sum
Normal 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
697
api/handlers/handlers.go
Normal 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, ¬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()})
|
||||
}
|
||||
83
api/handlers/system.go
Normal file
83
api/handlers/system.go
Normal 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
119
api/main.go
Normal 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
63
api/pubsub/pubsub.go
Normal 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
71
api/ws/hub.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user