summaryrefslogtreecommitdiff
path: root/internal/api
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-04 21:25:34 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-04 21:25:34 +0000
commit6511d6e0ff139495413c7848a9b4aabb9d9ee4e2 (patch)
tree95bd6a0efc0ace206a5716da62a5956491cb46e7 /internal/api
parent3962597950421e422b6e1ce57764550f5600ded6 (diff)
Add READY state for human-in-the-loop verification
Top-level tasks now land in READY after successful execution instead of going directly to COMPLETED. Subtasks (with parent_task_id) skip the gate and remain COMPLETED. Users accept or reject via new API endpoints: POST /api/tasks/{id}/accept → READY → COMPLETED POST /api/tasks/{id}/reject → READY → PENDING (with rejection_comment) - task: add StateReady, RejectionComment field, update ValidTransition - storage: migrate rejection_comment column, add RejectTask method - executor: route top-level vs subtask to READY vs COMPLETED - api: /accept and /reject handlers with 409 on invalid state Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/api')
-rw-r--r--internal/api/server.go49
-rw-r--r--internal/api/server_test.go69
2 files changed, 118 insertions, 0 deletions
diff --git a/internal/api/server.go b/internal/api/server.go
index 8415b28..608cdd4 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -60,6 +60,8 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /api/tasks", s.handleListTasks)
s.mux.HandleFunc("GET /api/tasks/{id}", s.handleGetTask)
s.mux.HandleFunc("POST /api/tasks/{id}/run", s.handleRunTask)
+ s.mux.HandleFunc("POST /api/tasks/{id}/accept", s.handleAcceptTask)
+ s.mux.HandleFunc("POST /api/tasks/{id}/reject", s.handleRejectTask)
s.mux.HandleFunc("GET /api/tasks/{id}/subtasks", s.handleListSubtasks)
s.mux.HandleFunc("GET /api/tasks/{id}/executions", s.handleListExecutions)
s.mux.HandleFunc("GET /api/executions/{id}", s.handleGetExecution)
@@ -210,6 +212,53 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) {
})
}
+func (s *Server) handleAcceptTask(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ t, err := s.store.GetTask(id)
+ if err != nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
+ return
+ }
+ if !task.ValidTransition(t.State, task.StateCompleted) {
+ writeJSON(w, http.StatusConflict, map[string]string{
+ "error": fmt.Sprintf("task cannot be accepted from state %s", t.State),
+ })
+ return
+ }
+ if err := s.store.UpdateTaskState(id, task.StateCompleted); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"message": "task accepted", "task_id": id})
+}
+
+func (s *Server) handleRejectTask(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ t, err := s.store.GetTask(id)
+ if err != nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
+ return
+ }
+ if !task.ValidTransition(t.State, task.StatePending) {
+ writeJSON(w, http.StatusConflict, map[string]string{
+ "error": fmt.Sprintf("task cannot be rejected from state %s", t.State),
+ })
+ return
+ }
+ var input struct {
+ Comment string `json:"comment"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
+ return
+ }
+ if err := s.store.RejectTask(id, input.Comment); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"message": "task rejected", "task_id": id})
+}
+
func (s *Server) handleListSubtasks(w http.ResponseWriter, r *http.Request) {
parentID := r.PathValue("id")
tasks, err := s.store.ListSubtasks(parentID)
diff --git a/internal/api/server_test.go b/internal/api/server_test.go
index 68f3657..5094961 100644
--- a/internal/api/server_test.go
+++ b/internal/api/server_test.go
@@ -252,6 +252,75 @@ func TestRunTask_CompletedTask_Returns409(t *testing.T) {
}
}
+func TestAcceptTask_ReadyTask_Returns200(t *testing.T) {
+ srv, store := testServer(t)
+ createTaskWithState(t, store, "accept-ready", task.StateReady)
+
+ req := httptest.NewRequest("POST", "/api/tasks/accept-ready/accept", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status: want 200, got %d; body: %s", w.Code, w.Body.String())
+ }
+
+ got, _ := store.GetTask("accept-ready")
+ if got.State != task.StateCompleted {
+ t.Errorf("task state: want COMPLETED, got %v", got.State)
+ }
+}
+
+func TestAcceptTask_NonReadyTask_Returns409(t *testing.T) {
+ srv, store := testServer(t)
+ createTaskWithState(t, store, "accept-pending", task.StatePending)
+
+ req := httptest.NewRequest("POST", "/api/tasks/accept-pending/accept", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusConflict {
+ t.Errorf("status: want 409, got %d; body: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestRejectTask_ReadyTask_Returns200(t *testing.T) {
+ srv, store := testServer(t)
+ createTaskWithState(t, store, "reject-ready", task.StateReady)
+
+ body := bytes.NewBufferString(`{"comment": "needs more detail"}`)
+ req := httptest.NewRequest("POST", "/api/tasks/reject-ready/reject", body)
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status: want 200, got %d; body: %s", w.Code, w.Body.String())
+ }
+
+ got, _ := store.GetTask("reject-ready")
+ if got.State != task.StatePending {
+ t.Errorf("task state: want PENDING, got %v", got.State)
+ }
+ if got.RejectionComment != "needs more detail" {
+ t.Errorf("rejection_comment: want 'needs more detail', got %q", got.RejectionComment)
+ }
+}
+
+func TestRejectTask_NonReadyTask_Returns409(t *testing.T) {
+ srv, store := testServer(t)
+ createTaskWithState(t, store, "reject-pending", task.StatePending)
+
+ body := bytes.NewBufferString(`{"comment": "comment"}`)
+ req := httptest.NewRequest("POST", "/api/tasks/reject-pending/reject", body)
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusConflict {
+ t.Errorf("status: want 409, got %d; body: %s", w.Code, w.Body.String())
+ }
+}
+
func TestCORS_Headers(t *testing.T) {
srv, _ := testServer(t)