diff options
| author | Claude Sonnet 5 <noreply@anthropic.com> | 2026-07-03 23:46:26 +0000 |
|---|---|---|
| committer | Claude Sonnet 5 <noreply@anthropic.com> | 2026-07-03 23:46:26 +0000 |
| commit | 392c7c1ada310b2f928dca89b75ba628478f7694 (patch) | |
| tree | c2b32420ce5df0da79d6d4b014fe2c784f2bd4be | |
| parent | 997cd8b56bc086a02b9c7c006dd62b07b9fcd2f3 (diff) | |
feat(story): add Epic/Story data model + REST CRUD (Phase 7a)
Pure data model + CRUD -- no orchestration behavior yet (that's Phase 7b).
Revives ADR-007's stories concept (adapted; ADR-007 itself was deleted as
"more machinery than the usage pattern needed") while staying lean: reuses
the existing events/projects/task-tree machinery rather than building a
parallel hierarchy.
- internal/story: Epic/Story types. Epic is a loosely-scoped initiative
(OPEN/CLOSED, no execution semantics). Story is a shippable slice of work
realized by a task tree rooted at root_task_id; epic_id/project_id/
root_task_id are loose references (no FK enforcement, matching the
codebase's existing tolerance for unmatched reference strings like
tasks.project).
- storage: new epics/stories tables (additive migrations) + CRUD methods.
Story status is intentionally unvalidated pass-through in this phase --
no task.ValidTransition-style state machine, since there's no orchestrator
yet to make transitions meaningful.
- event: 9 new Kind constants for the ceremony this layer will eventually
drive (epic_proposed, discovery_proposed, framing_decided, groomed,
prioritized, eval_verdict, arbitration_decided, retro_captured,
human_accepted) -- defined but nothing emits them yet.
- api: GET/POST /api/epics, GET/PUT /api/epics/{id}, GET /api/epics/{id}/stories,
GET/POST /api/stories, GET/PUT /api/stories/{id}, and GET
/api/stories/{id}/task-tree -- BFS walk from root_task_id following both
parent_task_id children and depends_on-edge dependents (visited-set
guarded), returning a flat node list for a later UI phase to render as a
graph. Unauthenticated, matching the existing projects/tasks endpoints'
posture.
go build/vet/test -race -count=1 all pass, full suite.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1moSNCJRcP6kykA4tyUSs
| -rw-r--r-- | CLAUDE.md | 63 | ||||
| -rw-r--r-- | internal/api/epics.go | 90 | ||||
| -rw-r--r-- | internal/api/epics_test.go | 202 | ||||
| -rw-r--r-- | internal/api/server.go | 10 | ||||
| -rw-r--r-- | internal/api/stories.go | 170 | ||||
| -rw-r--r-- | internal/api/stories_test.go | 278 | ||||
| -rw-r--r-- | internal/event/event.go | 15 | ||||
| -rw-r--r-- | internal/storage/db.go | 30 | ||||
| -rw-r--r-- | internal/storage/epic.go | 88 | ||||
| -rw-r--r-- | internal/storage/epic_test.go | 117 | ||||
| -rw-r--r-- | internal/storage/story.go | 160 | ||||
| -rw-r--r-- | internal/storage/story_test.go | 211 | ||||
| -rw-r--r-- | internal/story/story.go | 73 |
13 files changed, 1506 insertions, 1 deletions
@@ -79,6 +79,7 @@ Config defaults to `~/.claudomator/config.toml`. Data is stored in `~/.claudomat | `internal/sandbox` | `Sandbox` interface (`HostSandbox`/`DockerSandbox`) + pre-tool-use guardrail hooks | | `internal/role` | `RoleConfig`/`Tier`/`Rung` — per-role system prompt + provider/model escalation ladder | | `internal/scheduler` | `Scheduler` — polls role-typed FAILED tasks and retries/escalates them per their role's ladder | +| `internal/story` | `Epic`/`Story` — planning layer above the flat task tree; data model only (Phase 7a), no orchestration | | `web` | Embedded static UI (`embed.go`) | ### Key Data Flows @@ -196,6 +197,12 @@ events: id, task_id, seq, ts, kind, actor, payload_json role_configs: id, role, version, status, config_json, created_at, activated_at, retired_at, proposed_by (UNIQUE(role, version)) + +epics: id, name, description, status, discovery_source, created_at, updated_at + +stories: id, epic_id, project_id, name, branch_name, status, discovery_source, + spec, acceptance_criteria_json, validation_json, deploy_config, + priority, root_task_id, created_at, updated_at ``` The `events` table is the per-task observability stream (`internal/event`); see @@ -212,7 +219,12 @@ enforces "at most one active row per role" transactionally (retire-then-activate in one transaction), the same way `UpdateTaskState` enforces the task state machine in a transaction rather than in schema — see `internal/storage/roleconfig.go`. -JSON blobs: `config_json` (AgentConfig on `tasks`; RoleConfig on `role_configs`), `retry_json`, `tags_json`, `depends_on_json`, `interactions_json`, `changestats_json`, `commits_json`. +JSON blobs: `config_json` (AgentConfig on `tasks`; RoleConfig on `role_configs`), `retry_json`, `tags_json`, `depends_on_json`, `interactions_json`, `changestats_json`, `commits_json`, `acceptance_criteria_json`. + +`epics`/`stories` (`internal/story.Epic`/`internal/story.Story`) are the +planning layer that sits above the flat task tree — see "Planning layer: +epics & stories" below. This phase (7a) is pure data model + CRUD: no +orchestration reads or writes these tables yet. --- @@ -312,6 +324,45 @@ Two prerequisites for fan-out patterns (e.g. a Builder task with several role-ty - **Auto-cascade-fail** (`internal/executor.Pool.cascadeFail`): the moment a task lands in a terminal failure state (`FAILED`/`TIMED_OUT`/`CANCELLED`/`BUDGET_EXCEEDED` — checked in `handleRunResult` plus the two direct-cancel paths in `execute()` for budget-gate and dependency-failure), the pool looks up `Store.ListDependents(taskID)` (a full-table scan over `depends_on_json` — no reverse index, same tradeoff already accepted elsewhere for JSON-blob columns) and cancels every direct dependent still waiting (`PENDING`/`QUEUED`), recording a `CANCELLED` execution whose `error_msg` references the upstream task and writing the ordinary `KindStateChange` event via `UpdateTaskState` (no new event kind). It recurses into each cancelled dependent's own dependents (visited-set guarded against a pathological dependency cycle — task creation does not itself reject cycles), so a multi-level chain cascades all the way down. `execute()`'s dependency-wait requeue loop re-checks the task's fresh DB state before dispatching so a task cascade-cancelled out from under it is never actually run. - **Role-typed subtask spawning** (`agentchannel.SubtaskSpec.Role`, threaded through `storeChannel.SpawnSubtask` in `internal/executor/channel.go`): when a spawning agent sets `role` (available as an optional `spawn_subtask` tool parameter on both transports — `internal/agentloop/tools.go` for the native tool-use loop, `internal/executor/agentmcp.go` for the MCP transport), the child task gets `Agent.Role` set and `Agent.Type`/`Agent.Model` left empty, so Phase 5's role-based dispatch resolves them from the role's escalation ladder on the child's own first dispatch. Omitting `role` (every pre-Phase-6 caller) preserves the exact prior behavior (`Agent.Type: "claude"`, `Agent.Model` from the `model` argument). +### Planning layer: epics & stories (data model only) + +`internal/story` defines `Epic` and `Story` — a planning layer above the flat +task tree (`epics`/`stories` tables, see Storage Schema above). This is +Phase 7a: **pure data model + CRUD**. Nothing creates, transitions, or reads +these rows automatically yet — no watcher spawns Builder/Evaluator/ +Arbitration tasks from a story, no deploy-gating, no epic-proposal agent +tooling. A later phase builds that orchestration on top of what's stored +here. + +- **Epic** (`epics` table): a loosely-scoped initiative that decomposes into + Stories. `status` is `OPEN`/`CLOSED`, unvalidated pass-through (`UpdateEpic` + writes whatever it's given, same trust level as `UpdateProject`). +- **Story** (`stories` table): a shippable slice of work realized by a tree + of `internal/task` Tasks rooted at `root_task_id`. `epic_id`/`project_id`/ + `root_task_id` are loose references (plain strings, no FK enforcement) — + same tolerance the codebase already has for `tasks.project`. `status` is + one of `DISCOVERY|FRAMING|BACKLOG|PRIORITIZED|IN_PROGRESS|SHIPPABLE| + DEPLOYED|VALIDATING|REVIEW_READY|NEEDS_FIX|DONE|CANCELLED`, also + unvalidated pass-through in this phase (no `task.ValidTransition`-style + enforcement — there's no orchestrator yet to make transitions meaningful). +- **`GET /api/stories/{id}/task-tree`** walks the task graph realizing a + story: starting at `root_task_id`, it follows both `parent_task_id` + children (`ListSubtasks`) and `depends_on_json` edges in either direction + (`ListDependents`), so a task that merely depends on something already in + the tree is discovered even if it isn't a literal `parent_task_id` child. + Returns a flat node list (`{story_id, root_task_id, nodes: [{id, name, + state, agent_type, role, parent_task_id, depends_on}]}`); clients + reconstruct the graph from each node's `parent_task_id`/`depends_on`. An + unset `root_task_id` returns an empty node list, not an error. +- New `event.Kind` constants for the ceremony this layer will eventually + drive (`epic_proposed`, `discovery_proposed`, `framing_decided`, + `groomed`, `prioritized`, `eval_verdict`, `arbitration_decided`, + `retro_captured`, `human_accepted`) are defined in `internal/event` but + nothing emits them yet. +- REST endpoints are not gated by `api_token`, consistent with + `/api/projects`/`/api/tasks` (only chatbot MCP, agent MCP, and WebSocket + require it). + --- @@ -411,6 +462,16 @@ In `executor.go`, `withFailureHistory` creates a copy of the task struct (`copy | POST | `/api/roles/{role}/versions` | Create a new draft `role_configs` version | | GET | `/api/roles/{role}/versions` | List all versions for a role | | POST | `/api/roles/{role}/activate?version=N` | Activate a version (atomically retires the prior active one) | +| GET | `/api/epics` | List epics; `?status=OPEN` | +| POST | `/api/epics` | Create epic | +| GET | `/api/epics/{id}` | Get epic | +| PUT | `/api/epics/{id}` | Update epic (name/description/status; unvalidated pass-through) | +| GET | `/api/epics/{id}/stories` | List stories whose `epic_id` matches `{id}` | +| GET | `/api/stories` | List stories; `?status=X&epic_id=Y` | +| POST | `/api/stories` | Create story | +| GET | `/api/stories/{id}` | Get story | +| PUT | `/api/stories/{id}` | Update story (all fields but id/created_at; unvalidated status pass-through) | +| GET | `/api/stories/{id}/task-tree` | Walk the task graph realizing a story (parent_task_id + depends_on edges) | --- diff --git a/internal/api/epics.go b/internal/api/epics.go new file mode 100644 index 0000000..e22a377 --- /dev/null +++ b/internal/api/epics.go @@ -0,0 +1,90 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/google/uuid" + "github.com/thepeterstone/claudomator/internal/storage" + "github.com/thepeterstone/claudomator/internal/story" +) + +func (s *Server) handleListEpics(w http.ResponseWriter, r *http.Request) { + status := r.URL.Query().Get("status") + epics, err := s.store.ListEpics(status) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if epics == nil { + epics = []*story.Epic{} + } + writeJSON(w, http.StatusOK, epics) +} + +func (s *Server) handleCreateEpic(w http.ResponseWriter, r *http.Request) { + var e story.Epic + if err := json.NewDecoder(r.Body).Decode(&e); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()}) + return + } + if e.Name == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) + return + } + if e.ID == "" { + e.ID = uuid.New().String() + } + if err := s.store.CreateEpic(&e); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusCreated, e) +} + +func (s *Server) handleGetEpic(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + e, err := s.store.GetEpic(id) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "epic not found"}) + return + } + writeJSON(w, http.StatusOK, e) +} + +func (s *Server) handleUpdateEpic(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + existing, err := s.store.GetEpic(id) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "epic 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.UpdateEpic(existing); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, existing) +} + +// handleListEpicStories lists stories whose epic_id matches the path {id}. +// Mirrors handleListSubtasks: no existence check on the epic itself, just +// returns an empty list if there are no matching stories. +func (s *Server) handleListEpicStories(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + stories, err := s.store.ListStories(storage.StoryFilter{EpicID: id}) + 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) +} diff --git a/internal/api/epics_test.go b/internal/api/epics_test.go new file mode 100644 index 0000000..bae7468 --- /dev/null +++ b/internal/api/epics_test.go @@ -0,0 +1,202 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/thepeterstone/claudomator/internal/story" +) + +// TestServer_CreateEpic_AndGetEpic mirrors the projects endpoint test +// pattern: POST creates, GET retrieves what was created. +func TestServer_CreateEpic_AndGetEpic(t *testing.T) { + srv, _ := testServer(t) + + body, _ := json.Marshal(map[string]any{ + "name": "Bigger Auth Rework", + "description": "Overhaul the auth flow.", + "discovery_source": "human", + }) + req := httptest.NewRequest("POST", "/api/epics", bytes.NewReader(body)) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("create: expected 201, got %d: %s", w.Code, w.Body.String()) + } + var created story.Epic + if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil { + t.Fatalf("decode create response: %v", err) + } + if created.ID == "" { + t.Fatal("expected server-assigned ID") + } + if created.Status != "OPEN" { + t.Errorf("expected default status OPEN, got %q", created.Status) + } + + req = httptest.NewRequest("GET", "/api/epics/"+created.ID, nil) + w = httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("get: expected 200, got %d: %s", w.Code, w.Body.String()) + } + var got story.Epic + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode get response: %v", err) + } + if got.Name != "Bigger Auth Rework" { + t.Errorf("Name: want %q, got %q", "Bigger Auth Rework", got.Name) + } + if got.DiscoverySource != "human" { + t.Errorf("DiscoverySource: want human, got %q", got.DiscoverySource) + } +} + +func TestServer_CreateEpic_MissingName_Returns400(t *testing.T) { + srv, _ := testServer(t) + req := httptest.NewRequest("POST", "/api/epics", bytes.NewReader([]byte(`{}`))) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestServer_GetEpic_NotFound(t *testing.T) { + srv, _ := testServer(t) + req := httptest.NewRequest("GET", "/api/epics/does-not-exist", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +// TestServer_ListEpics_FiltersByStatus confirms ?status=OPEN narrows results. +func TestServer_ListEpics_FiltersByStatus(t *testing.T) { + srv, store := testServer(t) + + if err := store.CreateEpic(&story.Epic{ID: "e1", Name: "one", Status: "OPEN"}); err != nil { + t.Fatal(err) + } + if err := store.CreateEpic(&story.Epic{ID: "e2", Name: "two", Status: "CLOSED"}); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest("GET", "/api/epics", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var all []story.Epic + if err := json.Unmarshal(w.Body.Bytes(), &all); err != nil { + t.Fatalf("decode: %v", err) + } + if len(all) != 2 { + t.Fatalf("want 2 epics, got %d", len(all)) + } + + req = httptest.NewRequest("GET", "/api/epics?status=OPEN", nil) + w = httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var open []story.Epic + if err := json.Unmarshal(w.Body.Bytes(), &open); err != nil { + t.Fatalf("decode: %v", err) + } + if len(open) != 1 || open[0].ID != "e1" { + t.Fatalf("want exactly [e1], got %+v", open) + } +} + +func TestServer_UpdateEpic(t *testing.T) { + srv, store := testServer(t) + if err := store.CreateEpic(&story.Epic{ID: "e1", Name: "original", Status: "OPEN"}); err != nil { + t.Fatal(err) + } + + body, _ := json.Marshal(map[string]any{ + "name": "updated", + "status": "CLOSED", + }) + req := httptest.NewRequest("PUT", "/api/epics/e1", bytes.NewReader(body)) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var got story.Epic + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Name != "updated" || got.Status != "CLOSED" { + t.Errorf("expected updated fields, got %+v", got) + } + if got.ID != "e1" { + t.Errorf("ID must not change via body: got %q", got.ID) + } +} + +func TestServer_UpdateEpic_NotFound(t *testing.T) { + srv, _ := testServer(t) + req := httptest.NewRequest("PUT", "/api/epics/does-not-exist", bytes.NewReader([]byte(`{"name":"x"}`))) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +// TestServer_ListEpicStories confirms GET /api/epics/{id}/stories returns +// only stories whose epic_id matches the path {id}. +func TestServer_ListEpicStories(t *testing.T) { + srv, store := testServer(t) + if err := store.CreateEpic(&story.Epic{ID: "epic-a", Name: "a"}); err != nil { + t.Fatal(err) + } + if err := store.CreateStory(&story.Story{ID: "s1", EpicID: "epic-a", Name: "one", Status: "DISCOVERY"}); err != nil { + t.Fatal(err) + } + if err := store.CreateStory(&story.Story{ID: "s2", EpicID: "epic-b", Name: "two", Status: "DISCOVERY"}); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest("GET", "/api/epics/epic-a/stories", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var got []story.Story + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got) != 1 || got[0].ID != "s1" { + t.Fatalf("want exactly [s1], got %+v", got) + } +} + +// TestServer_ListEpicStories_UnknownEpic_ReturnsEmptyList mirrors +// handleListSubtasks: no existence check on the parent, just an empty list. +func TestServer_ListEpicStories_UnknownEpic_ReturnsEmptyList(t *testing.T) { + srv, _ := testServer(t) + req := httptest.NewRequest("GET", "/api/epics/does-not-exist/stories", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var got []story.Story + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got) != 0 { + t.Fatalf("want empty list, got %+v", got) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 0e5b185..9f0eb7c 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -172,6 +172,16 @@ func (s *Server) routes() { s.mux.HandleFunc("POST /api/projects", s.handleCreateProject) s.mux.HandleFunc("GET /api/projects/{id}", s.handleGetProject) s.mux.HandleFunc("PUT /api/projects/{id}", s.handleUpdateProject) + s.mux.HandleFunc("GET /api/epics", s.handleListEpics) + s.mux.HandleFunc("POST /api/epics", s.handleCreateEpic) + s.mux.HandleFunc("GET /api/epics/{id}", s.handleGetEpic) + s.mux.HandleFunc("PUT /api/epics/{id}", s.handleUpdateEpic) + s.mux.HandleFunc("GET /api/epics/{id}/stories", s.handleListEpicStories) + s.mux.HandleFunc("GET /api/stories", s.handleListStories) + s.mux.HandleFunc("POST /api/stories", s.handleCreateStory) + s.mux.HandleFunc("GET /api/stories/{id}", s.handleGetStory) + s.mux.HandleFunc("PUT /api/stories/{id}", s.handleUpdateStory) + s.mux.HandleFunc("GET /api/stories/{id}/task-tree", s.handleStoryTaskTree) s.mux.HandleFunc("POST /api/roles/{role}/versions", s.handleCreateRoleVersion) s.mux.HandleFunc("GET /api/roles/{role}/versions", s.handleListRoleVersions) s.mux.HandleFunc("POST /api/roles/{role}/activate", s.handleActivateRoleVersion) diff --git a/internal/api/stories.go b/internal/api/stories.go new file mode 100644 index 0000000..337da21 --- /dev/null +++ b/internal/api/stories.go @@ -0,0 +1,170 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/google/uuid" + "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) +} diff --git a/internal/api/stories_test.go b/internal/api/stories_test.go new file mode 100644 index 0000000..b346af5 --- /dev/null +++ b/internal/api/stories_test.go @@ -0,0 +1,278 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/thepeterstone/claudomator/internal/story" + "github.com/thepeterstone/claudomator/internal/task" +) + +func TestServer_CreateStory_AndGetStory(t *testing.T) { + srv, _ := testServer(t) + + body, _ := json.Marshal(map[string]any{ + "name": "Add login page", + "status": "DISCOVERY", + "epic_id": "epic-1", + "acceptance_criteria": []string{"User can log in"}, + }) + req := httptest.NewRequest("POST", "/api/stories", bytes.NewReader(body)) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("create: expected 201, got %d: %s", w.Code, w.Body.String()) + } + var created story.Story + if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil { + t.Fatalf("decode create response: %v", err) + } + if created.ID == "" { + t.Fatal("expected server-assigned ID") + } + + req = httptest.NewRequest("GET", "/api/stories/"+created.ID, nil) + w = httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("get: expected 200, got %d: %s", w.Code, w.Body.String()) + } + var got story.Story + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode get response: %v", err) + } + if got.Name != "Add login page" { + t.Errorf("Name: want %q, got %q", "Add login page", got.Name) + } + if got.EpicID != "epic-1" { + t.Errorf("EpicID: want epic-1, got %q", got.EpicID) + } + if len(got.AcceptanceCriteria) != 1 || got.AcceptanceCriteria[0] != "User can log in" { + t.Errorf("AcceptanceCriteria: want [User can log in], got %+v", got.AcceptanceCriteria) + } +} + +func TestServer_CreateStory_MissingName_Returns400(t *testing.T) { + srv, _ := testServer(t) + req := httptest.NewRequest("POST", "/api/stories", bytes.NewReader([]byte(`{}`))) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestServer_GetStory_NotFound(t *testing.T) { + srv, _ := testServer(t) + req := httptest.NewRequest("GET", "/api/stories/does-not-exist", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestServer_ListStories_FiltersByStatusAndEpic(t *testing.T) { + srv, store := testServer(t) + for _, st := range []*story.Story{ + {ID: "s1", EpicID: "epic-a", Name: "one", Status: "DISCOVERY"}, + {ID: "s2", EpicID: "epic-a", Name: "two", Status: "IN_PROGRESS"}, + {ID: "s3", EpicID: "epic-b", Name: "three", Status: "DISCOVERY"}, + } { + if err := store.CreateStory(st); err != nil { + t.Fatal(err) + } + } + + req := httptest.NewRequest("GET", "/api/stories?epic_id=epic-a&status=DISCOVERY", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var got []story.Story + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got) != 1 || got[0].ID != "s1" { + t.Fatalf("want exactly [s1], got %+v", got) + } +} + +func TestServer_UpdateStory_UnvalidatedStatusPassthrough(t *testing.T) { + srv, store := testServer(t) + if err := store.CreateStory(&story.Story{ID: "s1", Name: "original", Status: "DISCOVERY"}); err != nil { + t.Fatal(err) + } + + // A nonsensical status jump must still be accepted — this phase performs + // no status-machine validation on stories. + body, _ := json.Marshal(map[string]any{ + "name": "updated", + "status": "DONE", + "root_task_id": "task-x", + }) + req := httptest.NewRequest("PUT", "/api/stories/s1", bytes.NewReader(body)) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var got story.Story + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Status != "DONE" { + t.Errorf("Status: want DONE (unvalidated passthrough), got %q", got.Status) + } + if got.RootTaskID != "task-x" { + t.Errorf("RootTaskID: want task-x, got %q", got.RootTaskID) + } + if got.ID != "s1" { + t.Errorf("ID must not change via body: got %q", got.ID) + } +} + +func TestServer_UpdateStory_NotFound(t *testing.T) { + srv, _ := testServer(t) + req := httptest.NewRequest("PUT", "/api/stories/does-not-exist", bytes.NewReader([]byte(`{"name":"x"}`))) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +func makeTreeTestTask(id, parentID string, dependsOn []string) *task.Task { + now := time.Now().UTC() + if dependsOn == nil { + dependsOn = []string{} + } + return &task.Task{ + ID: id, + Name: "Task " + id, + Agent: task.AgentConfig{Type: "claude", Instructions: "x"}, + Priority: task.PriorityNormal, + Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"}, + Tags: []string{}, + DependsOn: dependsOn, + ParentTaskID: parentID, + State: task.StatePending, + CreatedAt: now, + UpdatedAt: now, + } +} + +// TestServer_StoryTaskTree_WalksParentAndDependsOnEdges constructs a small +// task tree by hand: a root task, a parent_task_id child of the root, and a +// separate task (not a parent_task_id child of anything) whose +// depends_on_json points at the root. Confirms the task-tree endpoint +// discovers all three via the combination of ListSubtasks (parent_task_id) +// and ListDependents (depends_on_json) traversal. +func TestServer_StoryTaskTree_WalksParentAndDependsOnEdges(t *testing.T) { + srv, store := testServer(t) + + root := makeTreeTestTask("tt-root", "", nil) + child := makeTreeTestTask("tt-child", "tt-root", nil) + sibling := makeTreeTestTask("tt-sibling", "", []string{"tt-root"}) + for _, tk := range []*task.Task{root, child, sibling} { + if err := store.CreateTask(tk); err != nil { + t.Fatalf("CreateTask(%s): %v", tk.ID, err) + } + } + + if err := store.CreateStory(&story.Story{ID: "story-1", Name: "story", Status: "IN_PROGRESS", RootTaskID: "tt-root"}); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest("GET", "/api/stories/story-1/task-tree", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var got taskTreeResponse + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.StoryID != "story-1" { + t.Errorf("StoryID: want story-1, got %q", got.StoryID) + } + if got.RootTaskID != "tt-root" { + t.Errorf("RootTaskID: want tt-root, got %q", got.RootTaskID) + } + if len(got.Nodes) != 3 { + t.Fatalf("want 3 nodes, got %d: %+v", len(got.Nodes), got.Nodes) + } + + byID := map[string]taskTreeNode{} + for _, n := range got.Nodes { + byID[n.ID] = n + } + if _, ok := byID["tt-root"]; !ok { + t.Error("missing tt-root node") + } + childNode, ok := byID["tt-child"] + if !ok { + t.Fatal("missing tt-child node") + } + if childNode.ParentTaskID != "tt-root" { + t.Errorf("tt-child.ParentTaskID: want tt-root, got %q", childNode.ParentTaskID) + } + siblingNode, ok := byID["tt-sibling"] + if !ok { + t.Fatal("missing tt-sibling node") + } + if len(siblingNode.DependsOn) != 1 || siblingNode.DependsOn[0] != "tt-root" { + t.Errorf("tt-sibling.DependsOn: want [tt-root], got %+v", siblingNode.DependsOn) + } + for _, n := range got.Nodes { + if n.State == "" { + t.Errorf("node %s: expected non-empty state", n.ID) + } + if n.AgentType != "claude" { + t.Errorf("node %s: AgentType = %q, want claude", n.ID, n.AgentType) + } + } +} + +// TestServer_StoryTaskTree_EmptyWhenNoRootTask confirms an unset +// root_task_id returns an empty node list, not an error. +func TestServer_StoryTaskTree_EmptyWhenNoRootTask(t *testing.T) { + srv, store := testServer(t) + if err := store.CreateStory(&story.Story{ID: "story-empty", Name: "story", Status: "DISCOVERY"}); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest("GET", "/api/stories/story-empty/task-tree", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var got taskTreeResponse + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.RootTaskID != "" { + t.Errorf("RootTaskID: want empty, got %q", got.RootTaskID) + } + if len(got.Nodes) != 0 { + t.Fatalf("want empty node list, got %+v", got.Nodes) + } +} + +func TestServer_StoryTaskTree_UnknownStory_Returns404(t *testing.T) { + srv, _ := testServer(t) + req := httptest.NewRequest("GET", "/api/stories/does-not-exist/task-tree", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } +} diff --git a/internal/event/event.go b/internal/event/event.go index 202df63..f31092a 100644 --- a/internal/event/event.go +++ b/internal/event/event.go @@ -29,6 +29,21 @@ const ( // includes "final": true — the task stays FAILED for human attention). // Payload shape: from_rung, to_rung, from_provider, to_provider, final. KindEscalated Kind = "escalated" + + // The following Kinds are part of the Phase 7 planning-layer/story-model + // ceremony (epics/stories, internal/story). This phase (7a) only adds the + // constants — nothing emits them yet; a later phase's orchestration logic + // writes them against a story or epic ID (events.task_id has no enforced + // FK, so it tolerates carrying a non-task ID). + KindEpicProposed Kind = "epic_proposed" + KindDiscoveryProposed Kind = "discovery_proposed" + KindFramingDecided Kind = "framing_decided" + KindGroomed Kind = "groomed" + KindPrioritized Kind = "prioritized" + KindEvalVerdict Kind = "eval_verdict" + KindArbitrationDecided Kind = "arbitration_decided" + KindRetroCaptured Kind = "retro_captured" + KindHumanAccepted Kind = "human_accepted" ) // Actor identifies the originator of an event. diff --git a/internal/storage/db.go b/internal/storage/db.go index d571c2e..8b563a6 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -154,6 +154,36 @@ func (s *DB) migrate() error { UNIQUE(role, version) )`, `CREATE INDEX IF NOT EXISTS idx_role_configs_role_status ON role_configs(role, status)`, + `CREATE TABLE IF NOT EXISTS epics ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL DEFAULT 'OPEN', + discovery_source TEXT, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS stories ( + id TEXT PRIMARY KEY, + epic_id TEXT, + project_id TEXT, + name TEXT NOT NULL, + branch_name TEXT, + status TEXT NOT NULL, + discovery_source TEXT, + spec TEXT, + acceptance_criteria_json TEXT NOT NULL DEFAULT '[]', + validation_json TEXT, + deploy_config TEXT, + priority TEXT, + root_task_id TEXT, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_epics_status ON epics(status)`, + `CREATE INDEX IF NOT EXISTS idx_stories_status ON stories(status)`, + `CREATE INDEX IF NOT EXISTS idx_stories_epic_id ON stories(epic_id)`, + `CREATE INDEX IF NOT EXISTS idx_stories_root_task_id ON stories(root_task_id)`, } for _, m := range migrations { if _, err := s.db.Exec(m); err != nil { diff --git a/internal/storage/epic.go b/internal/storage/epic.go new file mode 100644 index 0000000..9f3863c --- /dev/null +++ b/internal/storage/epic.go @@ -0,0 +1,88 @@ +package storage + +import ( + "database/sql" + "time" + + "github.com/thepeterstone/claudomator/internal/story" +) + +// CreateEpic inserts a new epic. Defaults Status to "OPEN" if unset. +func (s *DB) CreateEpic(e *story.Epic) error { + if e.Status == "" { + e.Status = "OPEN" + } + now := time.Now().UTC() + e.CreatedAt = now + e.UpdatedAt = now + _, err := s.db.Exec( + `INSERT INTO epics (id, name, description, status, discovery_source, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, + e.ID, e.Name, e.Description, e.Status, e.DiscoverySource, now, now, + ) + return err +} + +// GetEpic retrieves an epic by ID. +func (s *DB) GetEpic(id string) (*story.Epic, error) { + row := s.db.QueryRow(`SELECT id, name, description, status, discovery_source, created_at, updated_at FROM epics WHERE id = ?`, id) + return scanEpic(row) +} + +// ListEpics returns epics, optionally filtered by status. Pass an empty +// string to list all epics. +func (s *DB) ListEpics(status string) ([]*story.Epic, error) { + query := `SELECT id, name, description, status, discovery_source, created_at, updated_at FROM epics WHERE 1=1` + var args []interface{} + if status != "" { + query += " AND status = ?" + args = append(args, status) + } + query += " ORDER BY created_at DESC" + + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var epics []*story.Epic + for rows.Next() { + e, err := scanEpicRows(rows) + if err != nil { + return nil, err + } + epics = append(epics, e) + } + return epics, rows.Err() +} + +// UpdateEpic updates an existing epic's name/description/status/discovery_source. +// Mirrors UpdateProject's shape: no existence check, no status-machine validation. +func (s *DB) UpdateEpic(e *story.Epic) error { + now := time.Now().UTC() + _, err := s.db.Exec( + `UPDATE epics SET name = ?, description = ?, status = ?, discovery_source = ?, updated_at = ? WHERE id = ?`, + e.Name, e.Description, e.Status, e.DiscoverySource, now, e.ID, + ) + if err != nil { + return err + } + e.UpdatedAt = now + return nil +} + +func scanEpic(row scanner) (*story.Epic, error) { + var e story.Epic + var description sql.NullString + var discoverySource sql.NullString + if err := row.Scan(&e.ID, &e.Name, &description, &e.Status, &discoverySource, &e.CreatedAt, &e.UpdatedAt); err != nil { + return nil, err + } + e.Description = description.String + e.DiscoverySource = discoverySource.String + return &e, nil +} + +func scanEpicRows(rows *sql.Rows) (*story.Epic, error) { + return scanEpic(rows) +} diff --git a/internal/storage/epic_test.go b/internal/storage/epic_test.go new file mode 100644 index 0000000..dfd5be1 --- /dev/null +++ b/internal/storage/epic_test.go @@ -0,0 +1,117 @@ +package storage + +import ( + "testing" + + "github.com/thepeterstone/claudomator/internal/story" +) + +func TestCreateEpic_AndGetEpic(t *testing.T) { + db := testDB(t) + + e := &story.Epic{ + ID: "epic-1", + Name: "Bigger Auth Rework", + Description: "Overhaul the auth flow.", + DiscoverySource: "human", + } + if err := db.CreateEpic(e); err != nil { + t.Fatalf("CreateEpic: %v", err) + } + // CreateEpic defaults Status to OPEN when unset. + if e.Status != "OPEN" { + t.Errorf("Status after create: want OPEN, got %q", e.Status) + } + + got, err := db.GetEpic("epic-1") + if err != nil { + t.Fatalf("GetEpic: %v", err) + } + if got.Name != "Bigger Auth Rework" { + t.Errorf("Name: want %q, got %q", "Bigger Auth Rework", got.Name) + } + if got.Description != "Overhaul the auth flow." { + t.Errorf("Description: want %q, got %q", "Overhaul the auth flow.", got.Description) + } + if got.Status != "OPEN" { + t.Errorf("Status: want OPEN, got %q", got.Status) + } + if got.DiscoverySource != "human" { + t.Errorf("DiscoverySource: want human, got %q", got.DiscoverySource) + } + if got.CreatedAt.IsZero() || got.UpdatedAt.IsZero() { + t.Errorf("expected CreatedAt/UpdatedAt to be set, got %+v", got) + } +} + +func TestGetEpic_NotFound(t *testing.T) { + db := testDB(t) + if _, err := db.GetEpic("nope"); err == nil { + t.Fatal("expected error for missing epic, got nil") + } +} + +func TestListEpics(t *testing.T) { + db := testDB(t) + + for _, e := range []*story.Epic{ + {ID: "e1", Name: "alpha", Status: "OPEN"}, + {ID: "e2", Name: "beta", Status: "CLOSED"}, + {ID: "e3", Name: "gamma", Status: "OPEN"}, + } { + if err := db.CreateEpic(e); err != nil { + t.Fatalf("CreateEpic(%s): %v", e.ID, err) + } + } + + all, err := db.ListEpics("") + if err != nil { + t.Fatalf("ListEpics: %v", err) + } + if len(all) != 3 { + t.Errorf("want 3 epics, got %d", len(all)) + } + + open, err := db.ListEpics("OPEN") + if err != nil { + t.Fatalf("ListEpics(OPEN): %v", err) + } + if len(open) != 2 { + t.Errorf("want 2 OPEN epics, got %d", len(open)) + } + for _, e := range open { + if e.Status != "OPEN" { + t.Errorf("expected only OPEN epics, got %q", e.Status) + } + } +} + +func TestUpdateEpic(t *testing.T) { + db := testDB(t) + + e := &story.Epic{ID: "e1", Name: "original", Status: "OPEN"} + if err := db.CreateEpic(e); err != nil { + t.Fatalf("CreateEpic: %v", err) + } + e.Name = "updated" + e.Description = "now with a description" + e.Status = "CLOSED" + if err := db.UpdateEpic(e); err != nil { + t.Fatalf("UpdateEpic: %v", err) + } + got, err := db.GetEpic("e1") + if err != nil { + t.Fatalf("GetEpic: %v", err) + } + if got.Name != "updated" { + t.Errorf("Name after update: want updated, got %q", got.Name) + } + if got.Description != "now with a description" { + t.Errorf("Description after update: want %q, got %q", "now with a description", got.Description) + } + // UpdateEpic performs no status-machine validation — CLOSED is accepted + // as a plain pass-through write, matching UpdateProject's trust level. + if got.Status != "CLOSED" { + t.Errorf("Status after update: want CLOSED, got %q", got.Status) + } +} diff --git a/internal/storage/story.go b/internal/storage/story.go new file mode 100644 index 0000000..7570dfc --- /dev/null +++ b/internal/storage/story.go @@ -0,0 +1,160 @@ +package storage + +import ( + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/thepeterstone/claudomator/internal/story" +) + +// StoryFilter specifies criteria for listing stories. Zero-value fields are +// not applied as filters. +type StoryFilter struct { + Status string + EpicID string +} + +// CreateStory inserts a new story. +func (s *DB) CreateStory(st *story.Story) error { + now := time.Now().UTC() + st.CreatedAt = now + st.UpdatedAt = now + + ac := st.AcceptanceCriteria + if ac == nil { + ac = []string{} + } + acJSON, err := json.Marshal(ac) + if err != nil { + return fmt.Errorf("marshaling acceptance_criteria: %w", err) + } + + _, err = s.db.Exec(` + INSERT INTO stories (id, epic_id, project_id, name, branch_name, status, discovery_source, spec, acceptance_criteria_json, validation_json, deploy_config, priority, root_task_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + st.ID, st.EpicID, st.ProjectID, st.Name, st.BranchName, st.Status, st.DiscoverySource, st.Spec, + string(acJSON), rawJSONOrNull(st.Validation), rawJSONOrNull(st.DeployConfig), st.Priority, st.RootTaskID, now, now, + ) + return err +} + +// GetStory retrieves a story by ID. +func (s *DB) GetStory(id string) (*story.Story, error) { + row := s.db.QueryRow(storySelectColumns()+` FROM stories WHERE id = ?`, id) + return scanStory(row) +} + +// ListStories returns stories matching the given filter. +func (s *DB) ListStories(filter StoryFilter) ([]*story.Story, error) { + query := storySelectColumns() + ` FROM stories WHERE 1=1` + var args []interface{} + if filter.Status != "" { + query += " AND status = ?" + args = append(args, filter.Status) + } + if filter.EpicID != "" { + query += " AND epic_id = ?" + args = append(args, filter.EpicID) + } + query += " ORDER BY created_at DESC" + + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var stories []*story.Story + for rows.Next() { + st, err := scanStoryRows(rows) + if err != nil { + return nil, err + } + stories = append(stories, st) + } + return stories, rows.Err() +} + +// UpdateStory replaces all editable fields (everything except id/created_at). +// This phase enforces no state-machine validation on Status transitions — +// it writes whatever status string it is given, the same trust level as +// UpdateProject/UpdateTask's field replacement (not UpdateTaskState). +func (s *DB) UpdateStory(st *story.Story) error { + now := time.Now().UTC() + + ac := st.AcceptanceCriteria + if ac == nil { + ac = []string{} + } + acJSON, err := json.Marshal(ac) + if err != nil { + return fmt.Errorf("marshaling acceptance_criteria: %w", err) + } + + _, err = s.db.Exec(` + UPDATE stories SET epic_id = ?, project_id = ?, name = ?, branch_name = ?, status = ?, discovery_source = ?, + spec = ?, acceptance_criteria_json = ?, validation_json = ?, deploy_config = ?, priority = ?, root_task_id = ?, updated_at = ? + WHERE id = ?`, + st.EpicID, st.ProjectID, st.Name, st.BranchName, st.Status, st.DiscoverySource, + st.Spec, string(acJSON), rawJSONOrNull(st.Validation), rawJSONOrNull(st.DeployConfig), st.Priority, st.RootTaskID, now, + st.ID, + ) + if err != nil { + return err + } + st.UpdatedAt = now + return nil +} + +func storySelectColumns() string { + return `SELECT id, epic_id, project_id, name, branch_name, status, discovery_source, spec, acceptance_criteria_json, validation_json, deploy_config, priority, root_task_id, created_at, updated_at` +} + +func scanStory(row scanner) (*story.Story, error) { + var st story.Story + var epicID, projectID, branchName, discoverySource, spec, validationJSON, deployConfig, priority, rootTaskID sql.NullString + var acJSON string + if err := row.Scan( + &st.ID, &epicID, &projectID, &st.Name, &branchName, &st.Status, &discoverySource, &spec, + &acJSON, &validationJSON, &deployConfig, &priority, &rootTaskID, &st.CreatedAt, &st.UpdatedAt, + ); err != nil { + return nil, err + } + st.EpicID = epicID.String + st.ProjectID = projectID.String + st.BranchName = branchName.String + st.DiscoverySource = discoverySource.String + st.Spec = spec.String + st.Priority = priority.String + st.RootTaskID = rootTaskID.String + + if acJSON == "" { + acJSON = "[]" + } + if err := json.Unmarshal([]byte(acJSON), &st.AcceptanceCriteria); err != nil { + return nil, fmt.Errorf("unmarshaling acceptance_criteria: %w", err) + } + if validationJSON.Valid && validationJSON.String != "" { + st.Validation = json.RawMessage(validationJSON.String) + } + if deployConfig.Valid && deployConfig.String != "" { + st.DeployConfig = json.RawMessage(deployConfig.String) + } + return &st, nil +} + +func scanStoryRows(rows *sql.Rows) (*story.Story, error) { + return scanStory(rows) +} + +// rawJSONOrNull returns nil (SQL NULL) for an empty json.RawMessage, or the +// raw JSON text otherwise. Used for the nullable validation_json/deploy_config +// columns, which carry freeform JSON blobs, not a fixed schema. +func rawJSONOrNull(raw json.RawMessage) interface{} { + if len(raw) == 0 { + return nil + } + return string(raw) +} diff --git a/internal/storage/story_test.go b/internal/storage/story_test.go new file mode 100644 index 0000000..9f2c723 --- /dev/null +++ b/internal/storage/story_test.go @@ -0,0 +1,211 @@ +package storage + +import ( + "encoding/json" + "testing" + + "github.com/thepeterstone/claudomator/internal/story" +) + +func TestCreateStory_AndGetStory(t *testing.T) { + db := testDB(t) + + st := &story.Story{ + ID: "story-1", + EpicID: "epic-1", + ProjectID: "proj-1", + Name: "Add login page", + BranchName: "story/add-login-page", + Status: "DISCOVERY", + DiscoverySource: "agent", + Spec: "Product: users need to log in. Arch: reuse existing session middleware.", + AcceptanceCriteria: []string{"User can log in with email/password", "Invalid creds show an error"}, + Validation: json.RawMessage(`{"type":"curl","steps":["GET /login returns 200"],"success_criteria":"all steps pass"}`), + Priority: "high", + RootTaskID: "task-1", + } + if err := db.CreateStory(st); err != nil { + t.Fatalf("CreateStory: %v", err) + } + + got, err := db.GetStory("story-1") + if err != nil { + t.Fatalf("GetStory: %v", err) + } + if got.Name != "Add login page" { + t.Errorf("Name: want %q, got %q", "Add login page", got.Name) + } + if got.EpicID != "epic-1" { + t.Errorf("EpicID: want epic-1, got %q", got.EpicID) + } + if got.ProjectID != "proj-1" { + t.Errorf("ProjectID: want proj-1, got %q", got.ProjectID) + } + if got.BranchName != "story/add-login-page" { + t.Errorf("BranchName: want story/add-login-page, got %q", got.BranchName) + } + if got.Status != "DISCOVERY" { + t.Errorf("Status: want DISCOVERY, got %q", got.Status) + } + if len(got.AcceptanceCriteria) != 2 { + t.Errorf("AcceptanceCriteria: want 2 items, got %d: %+v", len(got.AcceptanceCriteria), got.AcceptanceCriteria) + } + if string(got.Validation) == "" { + t.Errorf("expected Validation to round-trip, got empty") + } + if got.RootTaskID != "task-1" { + t.Errorf("RootTaskID: want task-1, got %q", got.RootTaskID) + } + if got.CreatedAt.IsZero() || got.UpdatedAt.IsZero() { + t.Errorf("expected CreatedAt/UpdatedAt to be set, got %+v", got) + } +} + +// TestCreateStory_LooseRefsToleratesUnmatchedIDs confirms epic_id, +// project_id, and root_task_id are plain nullable-loose-ref strings with no +// FK enforcement — a story referencing IDs that don't exist anywhere else +// must still be created successfully, matching how tasks.project already +// tolerates an unmatched string. +func TestCreateStory_LooseRefsToleratesUnmatchedIDs(t *testing.T) { + db := testDB(t) + + st := &story.Story{ + ID: "story-loose", + EpicID: "epic-does-not-exist", + ProjectID: "project-does-not-exist", + Name: "Loose ref story", + Status: "DISCOVERY", + RootTaskID: "task-does-not-exist", + } + if err := db.CreateStory(st); err != nil { + t.Fatalf("CreateStory with unmatched loose refs should succeed, got: %v", err) + } + got, err := db.GetStory("story-loose") + if err != nil { + t.Fatalf("GetStory: %v", err) + } + if got.EpicID != "epic-does-not-exist" || got.ProjectID != "project-does-not-exist" || got.RootTaskID != "task-does-not-exist" { + t.Errorf("expected loose refs stored verbatim, got %+v", got) + } +} + +func TestCreateStory_DefaultsAcceptanceCriteriaToEmptyList(t *testing.T) { + db := testDB(t) + + st := &story.Story{ID: "story-nil-ac", Name: "No AC set", Status: "DISCOVERY"} + if err := db.CreateStory(st); err != nil { + t.Fatalf("CreateStory: %v", err) + } + got, err := db.GetStory("story-nil-ac") + if err != nil { + t.Fatalf("GetStory: %v", err) + } + if got.AcceptanceCriteria == nil { + t.Fatal("expected AcceptanceCriteria to be an empty slice, got nil") + } + if len(got.AcceptanceCriteria) != 0 { + t.Errorf("expected empty AcceptanceCriteria, got %+v", got.AcceptanceCriteria) + } +} + +func TestGetStory_NotFound(t *testing.T) { + db := testDB(t) + if _, err := db.GetStory("nope"); err == nil { + t.Fatal("expected error for missing story, got nil") + } +} + +func TestListStories_FilterByStatusAndEpic(t *testing.T) { + db := testDB(t) + + stories := []*story.Story{ + {ID: "s1", EpicID: "epic-a", Name: "one", Status: "DISCOVERY"}, + {ID: "s2", EpicID: "epic-a", Name: "two", Status: "IN_PROGRESS"}, + {ID: "s3", EpicID: "epic-b", Name: "three", Status: "DISCOVERY"}, + } + for _, st := range stories { + if err := db.CreateStory(st); err != nil { + t.Fatalf("CreateStory(%s): %v", st.ID, err) + } + } + + all, err := db.ListStories(StoryFilter{}) + if err != nil { + t.Fatalf("ListStories: %v", err) + } + if len(all) != 3 { + t.Errorf("want 3 stories, got %d", len(all)) + } + + byEpic, err := db.ListStories(StoryFilter{EpicID: "epic-a"}) + if err != nil { + t.Fatalf("ListStories(epic-a): %v", err) + } + if len(byEpic) != 2 { + t.Errorf("want 2 stories for epic-a, got %d", len(byEpic)) + } + + byStatus, err := db.ListStories(StoryFilter{Status: "DISCOVERY"}) + if err != nil { + t.Fatalf("ListStories(DISCOVERY): %v", err) + } + if len(byStatus) != 2 { + t.Errorf("want 2 DISCOVERY stories, got %d", len(byStatus)) + } + + byBoth, err := db.ListStories(StoryFilter{EpicID: "epic-a", Status: "DISCOVERY"}) + if err != nil { + t.Fatalf("ListStories(epic-a, DISCOVERY): %v", err) + } + if len(byBoth) != 1 || byBoth[0].ID != "s1" { + t.Errorf("want exactly [s1], got %+v", byBoth) + } +} + +// TestUpdateStory_UnvalidatedStatusPassthrough confirms UpdateStory writes +// whatever status string it is given with no state-machine enforcement +// (unlike task.ValidTransition/UpdateTaskState) — this phase explicitly +// defers that validation to a later orchestration phase. +func TestUpdateStory_UnvalidatedStatusPassthrough(t *testing.T) { + db := testDB(t) + + st := &story.Story{ID: "s1", Name: "original", Status: "DISCOVERY"} + if err := db.CreateStory(st); err != nil { + t.Fatalf("CreateStory: %v", err) + } + origCreatedAt := st.CreatedAt + + // A nonsensical jump (DISCOVERY -> DONE, skipping every intermediate + // status) must still be accepted verbatim. + st.Name = "updated" + st.Status = "DONE" + st.EpicID = "epic-x" + st.RootTaskID = "task-x" + st.AcceptanceCriteria = []string{"criterion one"} + if err := db.UpdateStory(st); err != nil { + t.Fatalf("UpdateStory: %v", err) + } + + got, err := db.GetStory("s1") + if err != nil { + t.Fatalf("GetStory: %v", err) + } + if got.Status != "DONE" { + t.Errorf("Status after update: want DONE (unvalidated passthrough), got %q", got.Status) + } + if got.Name != "updated" { + t.Errorf("Name after update: want updated, got %q", got.Name) + } + if got.EpicID != "epic-x" { + t.Errorf("EpicID after update: want epic-x, got %q", got.EpicID) + } + if got.RootTaskID != "task-x" { + t.Errorf("RootTaskID after update: want task-x, got %q", got.RootTaskID) + } + if len(got.AcceptanceCriteria) != 1 || got.AcceptanceCriteria[0] != "criterion one" { + t.Errorf("AcceptanceCriteria after update: want [criterion one], got %+v", got.AcceptanceCriteria) + } + if !got.CreatedAt.Equal(origCreatedAt) { + t.Errorf("CreatedAt must not change on update: want %v, got %v", origCreatedAt, got.CreatedAt) + } +} diff --git a/internal/story/story.go b/internal/story/story.go new file mode 100644 index 0000000..dc1764f --- /dev/null +++ b/internal/story/story.go @@ -0,0 +1,73 @@ +// Package story defines the Epic and Story data types that sit above +// internal/task's flat task tree in the planning hierarchy. This phase +// (7a) is pure data model + CRUD: no orchestration reads or writes these +// types yet. A later phase builds fan-out/orchestration logic (creating +// Builder/Evaluator/Arbitration tasks, gating deploys, etc.) on top of what +// is stored here. +package story + +import ( + "encoding/json" + "time" +) + +// Epic is a large, loosely-scoped initiative that decomposes into one or +// more Stories. Epics are not execution units — they carry no root_task_id +// and are never dispatched. +type Epic struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + // Status is "OPEN" or "CLOSED". This phase does not validate transitions + // — storage writes whatever status string it is given, the same trust + // level as task.Project/UpdateProject. + Status string `json:"status"` + // DiscoverySource is "human" or "agent" — how the epic came to exist. + DiscoverySource string `json:"discovery_source,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Story is a shippable slice of work realized by a tree of internal/task +// Tasks rooted at RootTaskID. EpicID and ProjectID are loose references +// (plain strings, no FK enforcement) to epics.id / projects.id, matching +// the codebase's existing tolerance for unmatched reference strings (e.g. +// tasks.project). +// +// Status is one of DISCOVERY|FRAMING|BACKLOG|PRIORITIZED|IN_PROGRESS| +// SHIPPABLE|DEPLOYED|VALIDATING|REVIEW_READY|NEEDS_FIX|DONE|CANCELLED, but +// this phase enforces no state machine on it (unlike task.ValidTransition) +// — a later phase adds that once there is an orchestrator to make +// transitions meaningful. UpdateStory writes whatever status string it is +// given. +type Story struct { + ID string `json:"id"` + EpicID string `json:"epic_id,omitempty"` + ProjectID string `json:"project_id,omitempty"` + Name string `json:"name"` + BranchName string `json:"branch_name,omitempty"` + Status string `json:"status"` + DiscoverySource string `json:"discovery_source,omitempty"` + // Spec is the Planner's framing-pass output: product + arch context. + // Freeform text, not JSON. + Spec string `json:"spec,omitempty"` + // AcceptanceCriteria is stored as acceptance_criteria_json; defaults to + // an empty list, never null in API responses. + AcceptanceCriteria []string `json:"acceptance_criteria"` + // Validation is the freeform {type, steps, success_criteria} blob a + // later phase's validation agent will consume (curl|tests|playwright| + // gradle). Stored verbatim as validation_json; nil/omitted if unset. + Validation json.RawMessage `json:"validation,omitempty"` + // DeployConfig is optional, freeform JSON — only populated if a story + // should deploy-gate. Stored verbatim as deploy_config; nil/omitted if + // unset. + DeployConfig json.RawMessage `json:"deploy_config,omitempty"` + Priority string `json:"priority,omitempty"` + // RootTaskID is the top-level task realizing this story (usually a + // Planner-role task). Empty/unset means the story has no execution tree + // yet — GET .../task-tree returns an empty tree, not an error, in that + // case. + RootTaskID string `json:"root_task_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} |
