Files
agent_runner_api/handlers.go
T

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
}