389 lines
11 KiB
Go
389 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
type Run struct {
|
|
ID string `json:"id"`
|
|
WorkflowID *string `json:"workflow_id,omitempty"`
|
|
IssueID *string `json:"issue_id,omitempty"`
|
|
CardID *string `json:"card_id,omitempty"`
|
|
KanbanApp *string `json:"kanban_app,omitempty"`
|
|
Mode string `json:"mode"`
|
|
Branch string `json:"branch"`
|
|
WorktreePath string `json:"worktree_path"`
|
|
Status string `json:"status"`
|
|
StartedAt int64 `json:"started_at"`
|
|
FinishedAt *int64 `json:"finished_at,omitempty"`
|
|
AgentPID *int `json:"agent_pid,omitempty"`
|
|
AgentLogPath *string `json:"agent_log_path,omitempty"`
|
|
Error *string `json:"error,omitempty"`
|
|
}
|
|
|
|
type createRunRequest struct {
|
|
WorkflowID string `json:"workflow_id"`
|
|
IssueID string `json:"issue_id"`
|
|
CardID string `json:"card_id"`
|
|
KanbanApp string `json:"kanban_app"`
|
|
Mode string `json:"mode"`
|
|
Prompt string `json:"prompt"`
|
|
}
|
|
|
|
type createRunResponse struct {
|
|
RunID string `json:"run_id"`
|
|
Branch string `json:"branch"`
|
|
WorktreePath string `json:"worktree_path"`
|
|
SSEURL string `json:"sse_url"`
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, body interface{}) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(body)
|
|
}
|
|
|
|
func writeErr(w http.ResponseWriter, status int, msg string) {
|
|
writeJSON(w, status, map[string]string{"error": msg})
|
|
}
|
|
|
|
func (a *App) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"status": "ok",
|
|
"port": a.cfg.Port,
|
|
"db": a.cfg.DBPath,
|
|
})
|
|
}
|
|
|
|
// POST /api/runs
|
|
func (a *App) handleCreateRun(w http.ResponseWriter, r *http.Request) {
|
|
var req createRunRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid json: "+err.Error())
|
|
return
|
|
}
|
|
if req.Mode == "" {
|
|
req.Mode = "agent"
|
|
}
|
|
|
|
runID := "run_" + uuid.New().String()[:12]
|
|
slug := req.IssueID
|
|
if slug == "" {
|
|
slug = req.CardID
|
|
}
|
|
if slug == "" {
|
|
slug = runID
|
|
}
|
|
branch := fmt.Sprintf("auto/%s", slug)
|
|
worktreePath := filepath.Join(a.cfg.WorktreesRoot, "wt-"+slug+"-"+runID[4:])
|
|
now := time.Now().Unix()
|
|
|
|
// Insert pending row
|
|
_, err := a.db.Exec(`INSERT INTO runs
|
|
(id, workflow_id, issue_id, card_id, kanban_app, mode, branch, worktree_path, status, started_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?)`,
|
|
runID,
|
|
nullStrFromS(req.WorkflowID),
|
|
nullStrFromS(req.IssueID),
|
|
nullStrFromS(req.CardID),
|
|
nullStrFromS(req.KanbanApp),
|
|
req.Mode, branch, worktreePath, now)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "insert run: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// Spawn async (worktree create blocks briefly but is short)
|
|
prompt := req.Prompt
|
|
if prompt == "" {
|
|
prompt = fmt.Sprintf("Resolve %s in branch %s", slug, branch)
|
|
}
|
|
logPath := filepath.Join(worktreePath, "agent.log")
|
|
|
|
res := Spawn(SpawnConfig{
|
|
RepoRoot: a.cfg.RepoRoot,
|
|
Branch: branch,
|
|
WorktreePath: worktreePath,
|
|
Prompt: prompt,
|
|
LogPath: logPath,
|
|
})
|
|
|
|
if res.Error != "" {
|
|
_, _ = a.db.Exec(`UPDATE runs SET status = 'failed', error = ?, finished_at = ?
|
|
WHERE id = ?`, res.Error, time.Now().Unix(), runID)
|
|
writeErr(w, http.StatusInternalServerError, "spawn: "+res.Error)
|
|
return
|
|
}
|
|
|
|
// Update row with PID + log + worktree entry
|
|
_, _ = a.db.Exec(`UPDATE runs SET agent_pid = ?, agent_log_path = ?, status = 'running'
|
|
WHERE id = ?`, res.PID, res.LogPath, runID)
|
|
wtID := "wt_" + uuid.New().String()[:12]
|
|
_, _ = a.db.Exec(`INSERT INTO worktrees (id, run_id, path, branch, created_at)
|
|
VALUES (?, ?, ?, ?, ?)`, wtID, runID, worktreePath, branch, now)
|
|
|
|
a.sse.Publish(runID, sseEvent{Event: "status", Data: `{"status":"running","pid":` + strconv.Itoa(res.PID) + `}`})
|
|
|
|
writeJSON(w, http.StatusCreated, createRunResponse{
|
|
RunID: runID,
|
|
Branch: branch,
|
|
WorktreePath: worktreePath,
|
|
SSEURL: fmt.Sprintf("/api/runs/%s/sse", runID),
|
|
})
|
|
}
|
|
|
|
// GET /api/runs?status=&app=&since=
|
|
func (a *App) handleListRuns(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
where := []string{}
|
|
args := []interface{}{}
|
|
if s := q.Get("status"); s != "" {
|
|
where = append(where, "status = ?")
|
|
args = append(args, s)
|
|
}
|
|
if app := q.Get("app"); app != "" {
|
|
where = append(where, "kanban_app = ?")
|
|
args = append(args, app)
|
|
}
|
|
if since := q.Get("since"); since != "" {
|
|
if ts, err := strconv.ParseInt(since, 10, 64); err == nil {
|
|
where = append(where, "started_at >= ?")
|
|
args = append(args, ts)
|
|
}
|
|
}
|
|
sqlStr := `SELECT id, workflow_id, issue_id, card_id, kanban_app, mode, branch, worktree_path,
|
|
status, started_at, finished_at, agent_pid, agent_log_path, error
|
|
FROM runs`
|
|
if len(where) > 0 {
|
|
sqlStr += " WHERE " + strings.Join(where, " AND ")
|
|
}
|
|
sqlStr += " ORDER BY started_at DESC LIMIT 200"
|
|
rows, err := a.db.Query(sqlStr, args...)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "query: "+err.Error())
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
out := []Run{}
|
|
for rows.Next() {
|
|
run, err := scanRun(rows)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "scan: "+err.Error())
|
|
return
|
|
}
|
|
out = append(out, run)
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// GET /api/runs/:id
|
|
func (a *App) handleGetRun(w http.ResponseWriter, r *http.Request, id string) {
|
|
row := a.db.QueryRow(`SELECT id, workflow_id, issue_id, card_id, kanban_app, mode, branch, worktree_path,
|
|
status, started_at, finished_at, agent_pid, agent_log_path, error
|
|
FROM runs WHERE id = ?`, id)
|
|
run, err := scanRun(row)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
writeErr(w, http.StatusNotFound, "run not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "scan: "+err.Error())
|
|
return
|
|
}
|
|
// Include dod items
|
|
items, _ := listDodItems(a.db, id)
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"run": run,
|
|
"dod_items": items,
|
|
})
|
|
}
|
|
|
|
// POST /api/runs/:id/evidence
|
|
type evidenceRequest struct {
|
|
ItemID string `json:"item_id"`
|
|
ItemKey string `json:"item_key"`
|
|
Kind string `json:"kind"`
|
|
PayloadPath *string `json:"payload_path,omitempty"`
|
|
PayloadURL *string `json:"payload_url,omitempty"`
|
|
PayloadText *string `json:"payload_text,omitempty"`
|
|
}
|
|
|
|
func (a *App) handleAttachEvidence(w http.ResponseWriter, r *http.Request, runID string) {
|
|
var req evidenceRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid json: "+err.Error())
|
|
return
|
|
}
|
|
itemID := req.ItemID
|
|
if itemID == "" && req.ItemKey != "" {
|
|
// auto-create item if key provided
|
|
it, err := createDodItem(a.db, runID, req.ItemKey, "manual", "", true)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "auto-create item: "+err.Error())
|
|
return
|
|
}
|
|
itemID = it.ID
|
|
}
|
|
if itemID == "" {
|
|
writeErr(w, http.StatusBadRequest, "item_id or item_key required")
|
|
return
|
|
}
|
|
if req.Kind == "" {
|
|
req.Kind = "text"
|
|
}
|
|
ev, err := attachEvidence(a.db, itemID, req.Kind, req.PayloadPath, req.PayloadURL, req.PayloadText)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "attach: "+err.Error())
|
|
return
|
|
}
|
|
a.sse.Publish(runID, sseEvent{Event: "evidence", Data: `{"item_id":"` + itemID + `","evidence_id":"` + ev.ID + `"}`})
|
|
writeJSON(w, http.StatusCreated, ev)
|
|
}
|
|
|
|
// POST /api/runs/:id/evidence/:eid/validate
|
|
type validateRequest struct {
|
|
ValidatedBy string `json:"validated_by"`
|
|
}
|
|
|
|
func (a *App) handleValidateEvidence(w http.ResponseWriter, r *http.Request, runID, evID string) {
|
|
var req validateRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid json: "+err.Error())
|
|
return
|
|
}
|
|
if req.ValidatedBy == "" {
|
|
req.ValidatedBy = "human"
|
|
}
|
|
if err := validateEvidence(a.db, evID, req.ValidatedBy); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
writeErr(w, http.StatusNotFound, "evidence not found")
|
|
return
|
|
}
|
|
writeErr(w, http.StatusInternalServerError, "validate: "+err.Error())
|
|
return
|
|
}
|
|
a.sse.Publish(runID, sseEvent{Event: "validated", Data: `{"evidence_id":"` + evID + `","validated_by":"` + req.ValidatedBy + `"}`})
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "validated"})
|
|
}
|
|
|
|
// POST /api/runs/:id/merge
|
|
func (a *App) handleMergeRun(w http.ResponseWriter, r *http.Request, runID string) {
|
|
open, err := dodGateOpen(a.db, runID)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "gate check: "+err.Error())
|
|
return
|
|
}
|
|
if !open {
|
|
writeErr(w, http.StatusPreconditionFailed, "dod gate closed — required items not validated")
|
|
return
|
|
}
|
|
row := a.db.QueryRow(`SELECT branch FROM runs WHERE id = ?`, runID)
|
|
var branch string
|
|
if err := row.Scan(&branch); err != nil {
|
|
writeErr(w, http.StatusNotFound, "run not found")
|
|
return
|
|
}
|
|
out, err := exec.Command("git", "-C", a.cfg.RepoRoot, "merge", "--no-ff", branch).CombinedOutput()
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "merge: "+err.Error()+": "+string(out))
|
|
return
|
|
}
|
|
_, _ = a.db.Exec(`UPDATE runs SET status = 'merged', finished_at = ? WHERE id = ?`,
|
|
time.Now().Unix(), runID)
|
|
a.sse.Publish(runID, sseEvent{Event: "merged", Data: `{"branch":"` + branch + `"}`})
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "merged", "branch": branch})
|
|
}
|
|
|
|
// POST /api/runs/:id/abort
|
|
func (a *App) handleAbortRun(w http.ResponseWriter, r *http.Request, runID string) {
|
|
row := a.db.QueryRow(`SELECT agent_pid, worktree_path, branch FROM runs WHERE id = ?`, runID)
|
|
var pidNS sql.NullInt64
|
|
var wt, branch string
|
|
if err := row.Scan(&pidNS, &wt, &branch); err != nil {
|
|
writeErr(w, http.StatusNotFound, "run not found")
|
|
return
|
|
}
|
|
pid := 0
|
|
if pidNS.Valid {
|
|
pid = int(pidNS.Int64)
|
|
}
|
|
if err := Cleanup(a.cfg.RepoRoot, pid, wt, branch); err != nil {
|
|
// non-fatal; record but continue
|
|
fmt.Println("cleanup warning:", err)
|
|
}
|
|
now := time.Now().Unix()
|
|
_, _ = a.db.Exec(`UPDATE runs SET status = 'aborted', finished_at = ? WHERE id = ?`, now, runID)
|
|
_, _ = a.db.Exec(`UPDATE worktrees SET removed_at = ? WHERE run_id = ? AND removed_at IS NULL`, now, runID)
|
|
a.sse.Publish(runID, sseEvent{Event: "aborted", Data: `{"run_id":"` + runID + `"}`})
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "aborted"})
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
type rowScanner interface {
|
|
Scan(dest ...interface{}) error
|
|
}
|
|
|
|
func scanRun(s rowScanner) (Run, error) {
|
|
var r Run
|
|
var workflowID, issueID, cardID, kanbanApp, logPath, errStr sql.NullString
|
|
var finishedAt sql.NullInt64
|
|
var agentPID sql.NullInt64
|
|
err := s.Scan(&r.ID, &workflowID, &issueID, &cardID, &kanbanApp, &r.Mode, &r.Branch, &r.WorktreePath,
|
|
&r.Status, &r.StartedAt, &finishedAt, &agentPID, &logPath, &errStr)
|
|
if err != nil {
|
|
return r, err
|
|
}
|
|
if workflowID.Valid {
|
|
s := workflowID.String
|
|
r.WorkflowID = &s
|
|
}
|
|
if issueID.Valid {
|
|
s := issueID.String
|
|
r.IssueID = &s
|
|
}
|
|
if cardID.Valid {
|
|
s := cardID.String
|
|
r.CardID = &s
|
|
}
|
|
if kanbanApp.Valid {
|
|
s := kanbanApp.String
|
|
r.KanbanApp = &s
|
|
}
|
|
if finishedAt.Valid {
|
|
v := finishedAt.Int64
|
|
r.FinishedAt = &v
|
|
}
|
|
if agentPID.Valid {
|
|
v := int(agentPID.Int64)
|
|
r.AgentPID = &v
|
|
}
|
|
if logPath.Valid {
|
|
s := logPath.String
|
|
r.AgentLogPath = &s
|
|
}
|
|
if errStr.Valid {
|
|
s := errStr.String
|
|
r.Error = &s
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
func nullStrFromS(s string) interface{} {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return s
|
|
}
|