package api import ( "encoding/json" "net/http" "strconv" "github.com/google/uuid" "github.com/thepeterstone/claudomator/internal/event" "github.com/thepeterstone/claudomator/internal/storage" "github.com/thepeterstone/claudomator/internal/story" ) func (s *Server) handleListStories(w http.ResponseWriter, r *http.Request) { filter := storage.StoryFilter{ Status: r.URL.Query().Get("status"), EpicID: r.URL.Query().Get("epic_id"), } stories, err := s.store.ListStories(filter) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if stories == nil { stories = []*story.Story{} } writeJSON(w, http.StatusOK, stories) } func (s *Server) handleCreateStory(w http.ResponseWriter, r *http.Request) { var st story.Story if err := json.NewDecoder(r.Body).Decode(&st); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()}) return } if st.Name == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) return } if st.ID == "" { st.ID = uuid.New().String() } if err := s.store.CreateStory(&st); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusCreated, st) } func (s *Server) handleGetStory(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") st, err := s.store.GetStory(id) if err != nil { writeJSON(w, http.StatusNotFound, map[string]string{"error": "story not found"}) return } writeJSON(w, http.StatusOK, st) } // handleUpdateStory replaces all editable fields on a story (everything // except id/created_at). No status-machine validation is performed in this // phase — the status string is written as given, same trust level as // handleUpdateProject. func (s *Server) handleUpdateStory(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") existing, err := s.store.GetStory(id) if err != nil { writeJSON(w, http.StatusNotFound, map[string]string{"error": "story not found"}) return } createdAt := existing.CreatedAt if err := json.NewDecoder(r.Body).Decode(existing); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()}) return } existing.ID = id // ensure ID cannot be changed via body existing.CreatedAt = createdAt if err := s.store.UpdateStory(existing); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, existing) } // taskTreeNode is one task in a story's task-tree/graph response. type taskTreeNode struct { ID string `json:"id"` Name string `json:"name"` State string `json:"state"` AgentType string `json:"agent_type"` Role string `json:"role,omitempty"` ParentTaskID string `json:"parent_task_id,omitempty"` DependsOn []string `json:"depends_on"` } // taskTreeResponse is the payload for GET /api/stories/{id}/task-tree. type taskTreeResponse struct { StoryID string `json:"story_id"` RootTaskID string `json:"root_task_id"` Nodes []taskTreeNode `json:"nodes"` } // handleStoryTaskTree walks the task graph realizing a story, starting at // its root_task_id and following both parent_task_id children (via // ListSubtasks) and depends_on edges in either direction (via // ListDependents, so a sibling task that merely depends on something already // in the tree is still discovered even if it isn't a literal // parent_task_id child). Returns one flat node list; clients reconstruct // the graph from each node's parent_task_id/depends_on. If the story has no // root_task_id set, returns an empty node list, not an error. func (s *Server) handleStoryTaskTree(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") st, err := s.store.GetStory(id) if err != nil { writeJSON(w, http.StatusNotFound, map[string]string{"error": "story not found"}) return } resp := taskTreeResponse{StoryID: id, RootTaskID: st.RootTaskID, Nodes: []taskTreeNode{}} if st.RootTaskID == "" { writeJSON(w, http.StatusOK, resp) return } visited := map[string]bool{} queue := []string{st.RootTaskID} for len(queue) > 0 { tid := queue[0] queue = queue[1:] if visited[tid] { continue } visited[tid] = true t, err := s.store.GetTask(tid) if err != nil { // A referenced task ID no longer resolves (e.g. deleted); skip it // rather than failing the whole tree. continue } dependsOn := t.DependsOn if dependsOn == nil { dependsOn = []string{} } resp.Nodes = append(resp.Nodes, taskTreeNode{ ID: t.ID, Name: t.Name, State: string(t.State), AgentType: t.Agent.Type, Role: t.Agent.Role, ParentTaskID: t.ParentTaskID, DependsOn: dependsOn, }) if children, err := s.store.ListSubtasks(tid); err == nil { for _, c := range children { if !visited[c.ID] { queue = append(queue, c.ID) } } } if dependents, err := s.store.ListDependents(tid); err == nil { for _, d := range dependents { if !visited[d.ID] { queue = append(queue, d.ID) } } } } writeJSON(w, http.StatusOK, resp) } // handleListStoryEvents returns a story's observability event stream in seq // order. Mirrors handleListTaskEvents exactly (same ?since_seq=N incremental // polling contract), but resolves existence via GetStory rather than // GetTask: the internal/scheduler.StoryOrchestrator (Phase 7b) attaches // KindEvalVerdict/KindArbitrationDecided/KindHumanAccepted events to the // story's ID rather than to any single task's ID (see that package's doc // comments for why) — events.task_id has no enforced FK, so this is exactly // the tolerance 7a's event.Kind doc comments anticipated ("a later phase's // orchestration logic writes them against a story or epic ID"). func (s *Server) handleListStoryEvents(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if _, err := s.store.GetStory(id); err != nil { writeJSON(w, http.StatusNotFound, map[string]string{"error": "story not found"}) return } var sinceSeq int64 if v := r.URL.Query().Get("since_seq"); v != "" { n, err := strconv.ParseInt(v, 10, 64) if err != nil || n < 0 { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid since_seq"}) return } sinceSeq = n } events, err := s.store.ListEvents(id, sinceSeq) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if events == nil { events = []*event.Event{} } writeJSON(w, http.StatusOK, events) } // handleAcceptStory is the human accept-gate for a story: it mirrors // handleAcceptTask's pattern exactly (validate current state, transition, // emit an event) but operates on story.Story, which (unlike task.Task) has // no enforced state machine (story.UpdateStory is unvalidated pass-through — // see internal/story's doc comments) — so the "only valid from REVIEW_READY" // check is done here, not in the storage layer. func (s *Server) handleAcceptStory(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") st, err := s.store.GetStory(id) if err != nil { writeJSON(w, http.StatusNotFound, map[string]string{"error": "story not found"}) return } if st.Status != "REVIEW_READY" { writeJSON(w, http.StatusConflict, map[string]string{"error": "story cannot be accepted from status " + st.Status}) return } from := st.Status st.Status = "DONE" if err := s.store.UpdateStory(st); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } payload, _ := json.Marshal(struct { StoryID string `json:"story_id"` From string `json:"from"` To string `json:"to"` }{StoryID: id, From: from, To: "DONE"}) if err := s.store.CreateEvent(&event.Event{ TaskID: id, Kind: event.KindHumanAccepted, Actor: event.ActorUser, Payload: payload, }); err != nil { s.logger.Error("failed to record human_accepted event", "storyID", id, "error", err) } writeJSON(w, http.StatusOK, map[string]string{"message": "story accepted", "story_id": id}) }