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 }