package main import ( "context" "database/sql" "flag" "fmt" "log" "net/http" "os" "os/signal" "strings" "syscall" "time" ) type Config struct { Port int DBPath string RepoRoot string WorktreesRoot string } type App struct { cfg Config db *sql.DB sse *sseHub } func main() { var ( port = flag.Int("port", 8486, "HTTP port") dbPath = flag.String("db", "agent_runs.db", "SQLite database path") repoRoot = flag.String("repo-root", "", "Git repo root (defaults to $PWD)") wtRoot = flag.String("worktrees-root", "/tmp", "Parent dir for worktrees") ) flag.Parse() root := *repoRoot if root == "" { root, _ = os.Getwd() } db, err := openDB(*dbPath) if err != nil { log.Fatalf("openDB: %v", err) } defer db.Close() app := &App{ cfg: Config{ Port: *port, DBPath: *dbPath, RepoRoot: root, WorktreesRoot: *wtRoot, }, db: db, sse: newSSEHub(), } mux := http.NewServeMux() mux.HandleFunc("/api/health", app.handleHealth) mux.HandleFunc("/api/runs", func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodPost: app.handleCreateRun(w, r) case http.MethodGet: app.handleListRuns(w, r) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } }) mux.HandleFunc("/api/runs/", func(w http.ResponseWriter, r *http.Request) { app.routeRun(w, r) }) srv := &http.Server{ Addr: fmt.Sprintf(":%d", *port), Handler: mux, ReadHeaderTimeout: 5 * time.Second, } // Graceful shutdown go func() { sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) <-sigs ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = srv.Shutdown(ctx) }() log.Printf("agent_runner_api listening :%d db=%s repo=%s", *port, *dbPath, root) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("listen: %v", err) } } // routeRun parses /api/runs/:id[/...] subroutes. func (a *App) routeRun(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/api/runs/") parts := strings.Split(path, "/") if len(parts) == 0 || parts[0] == "" { http.NotFound(w, r) return } runID := parts[0] if len(parts) == 1 { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } a.handleGetRun(w, r, runID) return } switch parts[1] { case "sse": a.handleRunSSE(w, r, runID) case "merge": if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } a.handleMergeRun(w, r, runID) case "abort": if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } a.handleAbortRun(w, r, runID) case "evidence": // /api/runs/:id/evidence (POST) — attach // /api/runs/:id/evidence/:eid/validate (POST) — validate if len(parts) == 2 { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } a.handleAttachEvidence(w, r, runID) return } if len(parts) == 4 && parts[3] == "validate" { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } a.handleValidateEvidence(w, r, runID, parts[2]) return } http.NotFound(w, r) default: http.NotFound(w, r) } }