package api import ( "encoding/json" "errors" "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" ) // errStoryNotFound is returned when a story ID does not resolve. REST maps // it to 404 (via writeOpError, mirroring errTaskNotFound in taskops.go); MCP // surfaces it as a tool error. var errStoryNotFound = errors.New("story not found") 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) } // acceptStory is the shared service-layer accept-gate for a story: both // handleAcceptStory (REST) and the accept_story chatbot MCP tool call this, // mirroring taskops.go's shared task-operation pattern (see acceptTask) so // the two surfaces cannot drift. func (s *Server) acceptStory(id string, actor event.Actor) error { st, err := s.store.GetStory(id) if err != nil { return errStoryNotFound } if st.Status != "REVIEW_READY" { return conflictf("story cannot be accepted from status %s", st.Status) } from := st.Status st.Status = "DONE" if err := s.store.UpdateStory(st); err != nil { return err } 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: actor, Payload: payload, }); err != nil { s.logger.Error("failed to record human_accepted event", "storyID", id, "error", err) } return nil } // handleAcceptStory is the human accept-gate for a story: it mirrors // handleAcceptTask's pattern exactly (validate current state, transition, // emit an event), delegating to the shared acceptStory service-layer helper // so the chatbot MCP accept_story tool cannot drift from this REST behavior. func (s *Server) handleAcceptStory(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if err := s.acceptStory(id, event.ActorUser); err != nil { writeOpError(w, err) return } writeJSON(w, http.StatusOK, map[string]string{"message": "story accepted", "story_id": id}) }