summaryrefslogtreecommitdiff
path: root/internal/api
diff options
context:
space:
mode:
Diffstat (limited to 'internal/api')
-rw-r--r--internal/api/changestats.go15
-rw-r--r--internal/api/deployment.go4
-rw-r--r--internal/api/drops.go165
-rw-r--r--internal/api/drops_test.go159
-rw-r--r--internal/api/elaborate.go205
-rw-r--r--internal/api/elaborate_test.go11
-rw-r--r--internal/api/executions.go42
-rw-r--r--internal/api/projects.go71
-rw-r--r--internal/api/push.go120
-rw-r--r--internal/api/push_test.go159
-rw-r--r--internal/api/server.go131
-rw-r--r--internal/api/server_test.go464
-rw-r--r--internal/api/stories.go378
-rw-r--r--internal/api/stories_test.go351
-rw-r--r--internal/api/task_view.go47
-rw-r--r--internal/api/webhook.go74
-rw-r--r--internal/api/webhook_test.go114
17 files changed, 2390 insertions, 120 deletions
diff --git a/internal/api/changestats.go b/internal/api/changestats.go
deleted file mode 100644
index 4f18f7f..0000000
--- a/internal/api/changestats.go
+++ /dev/null
@@ -1,15 +0,0 @@
-package api
-
-import "github.com/thepeterstone/claudomator/internal/task"
-
-// parseChangestatFromOutput delegates to task.ParseChangestatFromOutput.
-// Kept as a package-local wrapper for use within the api package.
-func parseChangestatFromOutput(output string) *task.Changestats {
- return task.ParseChangestatFromOutput(output)
-}
-
-// parseChangestatFromFile delegates to task.ParseChangestatFromFile.
-// Kept as a package-local wrapper for use within the api package.
-func parseChangestatFromFile(path string) *task.Changestats {
- return task.ParseChangestatFromFile(path)
-}
diff --git a/internal/api/deployment.go b/internal/api/deployment.go
index d927545..8972fe2 100644
--- a/internal/api/deployment.go
+++ b/internal/api/deployment.go
@@ -23,7 +23,7 @@ func (s *Server) handleGetDeploymentStatus(w http.ResponseWriter, r *http.Reques
if err != nil {
if err == sql.ErrNoRows {
// No execution yet — return status with no fix commits.
- status := deployment.Check(nil, tk.Agent.ProjectDir)
+ status := deployment.Check(nil, tk.RepositoryURL)
writeJSON(w, http.StatusOK, status)
return
}
@@ -31,6 +31,6 @@ func (s *Server) handleGetDeploymentStatus(w http.ResponseWriter, r *http.Reques
return
}
- status := deployment.Check(exec.Commits, tk.Agent.ProjectDir)
+ status := deployment.Check(exec.Commits, tk.RepositoryURL)
writeJSON(w, http.StatusOK, status)
}
diff --git a/internal/api/drops.go b/internal/api/drops.go
new file mode 100644
index 0000000..a5000f1
--- /dev/null
+++ b/internal/api/drops.go
@@ -0,0 +1,165 @@
+package api
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+// handleListDrops returns a JSON array of files in the drops directory.
+func (s *Server) handleListDrops(w http.ResponseWriter, r *http.Request) {
+ if s.dropsDir == "" {
+ writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "drops directory not configured"})
+ return
+ }
+
+ entries, err := os.ReadDir(s.dropsDir)
+ if err != nil {
+ if os.IsNotExist(err) {
+ writeJSON(w, http.StatusOK, []map[string]interface{}{})
+ return
+ }
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list drops"})
+ return
+ }
+
+ type fileEntry struct {
+ Name string `json:"name"`
+ Size int64 `json:"size"`
+ Modified time.Time `json:"modified"`
+ }
+ files := []fileEntry{}
+ for _, e := range entries {
+ if e.IsDir() {
+ continue
+ }
+ info, err := e.Info()
+ if err != nil {
+ continue
+ }
+ files = append(files, fileEntry{
+ Name: e.Name(),
+ Size: info.Size(),
+ Modified: info.ModTime().UTC(),
+ })
+ }
+ writeJSON(w, http.StatusOK, files)
+}
+
+// handleGetDrop serves a file from the drops directory as an attachment.
+func (s *Server) handleGetDrop(w http.ResponseWriter, r *http.Request) {
+ if s.dropsDir == "" {
+ writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "drops directory not configured"})
+ return
+ }
+
+ filename := r.PathValue("filename")
+ if strings.Contains(filename, "/") || strings.Contains(filename, "..") {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid filename"})
+ return
+ }
+
+ path := filepath.Join(s.dropsDir, filepath.Clean(filename))
+ // Extra safety: ensure the resolved path is still inside dropsDir.
+ if !strings.HasPrefix(path, s.dropsDir) {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid filename"})
+ return
+ }
+
+ f, err := os.Open(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "file not found"})
+ return
+ }
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to open file"})
+ return
+ }
+ defer f.Close()
+
+ w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
+ w.Header().Set("Content-Type", "application/octet-stream")
+ io.Copy(w, f) //nolint:errcheck
+}
+
+// handlePostDrop accepts a file upload (multipart/form-data or raw body with ?filename=).
+func (s *Server) handlePostDrop(w http.ResponseWriter, r *http.Request) {
+ if s.dropsDir == "" {
+ writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "drops directory not configured"})
+ return
+ }
+
+ if err := os.MkdirAll(s.dropsDir, 0700); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create drops directory"})
+ return
+ }
+
+ ct := r.Header.Get("Content-Type")
+ if strings.Contains(ct, "multipart/form-data") {
+ s.handleMultipartDrop(w, r)
+ return
+ }
+
+ // Raw body with ?filename= query param.
+ filename := r.URL.Query().Get("filename")
+ if filename == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "filename query param required for raw upload"})
+ return
+ }
+ if strings.Contains(filename, "/") || strings.Contains(filename, "..") {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid filename"})
+ return
+ }
+ path := filepath.Join(s.dropsDir, filename)
+ data, err := io.ReadAll(r.Body)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to read body"})
+ return
+ }
+ if err := os.WriteFile(path, data, 0600); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save file"})
+ return
+ }
+ writeJSON(w, http.StatusCreated, map[string]interface{}{"name": filename, "size": len(data)})
+}
+
+func (s *Server) handleMultipartDrop(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseMultipartForm(32 << 20); err != nil { // 32 MB limit
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to parse multipart form: " + err.Error()})
+ return
+ }
+
+ file, header, err := r.FormFile("file")
+ if err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing 'file' field: " + err.Error()})
+ return
+ }
+ defer file.Close()
+
+ filename := filepath.Base(header.Filename)
+ if filename == "" || filename == "." {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid filename"})
+ return
+ }
+
+ path := filepath.Join(s.dropsDir, filename)
+ dst, err := os.Create(path)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create file"})
+ return
+ }
+ defer dst.Close()
+
+ n, err := io.Copy(dst, file)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to write file"})
+ return
+ }
+
+ writeJSON(w, http.StatusCreated, map[string]interface{}{"name": filename, "size": n})
+}
+
diff --git a/internal/api/drops_test.go b/internal/api/drops_test.go
new file mode 100644
index 0000000..ab67489
--- /dev/null
+++ b/internal/api/drops_test.go
@@ -0,0 +1,159 @@
+package api
+
+import (
+ "bytes"
+ "encoding/json"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func testServerWithDrops(t *testing.T) (*Server, string) {
+ t.Helper()
+ srv, _ := testServer(t)
+ dropsDir := t.TempDir()
+ srv.SetDropsDir(dropsDir)
+ return srv, dropsDir
+}
+
+func TestHandleListDrops_Empty(t *testing.T) {
+ srv, _ := testServerWithDrops(t)
+
+ req := httptest.NewRequest("GET", "/api/drops", nil)
+ rec := httptest.NewRecorder()
+ srv.mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d", rec.Code)
+ }
+
+ var files []map[string]interface{}
+ if err := json.NewDecoder(rec.Body).Decode(&files); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if len(files) != 0 {
+ t.Errorf("want empty list, got %d entries", len(files))
+ }
+}
+
+func TestHandleListDrops_WithFile(t *testing.T) {
+ srv, dropsDir := testServerWithDrops(t)
+
+ // Create a file in the drops dir.
+ if err := os.WriteFile(filepath.Join(dropsDir, "hello.txt"), []byte("world"), 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/drops", nil)
+ rec := httptest.NewRecorder()
+ srv.mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d: %s", rec.Code, rec.Body.String())
+ }
+
+ var files []map[string]interface{}
+ if err := json.NewDecoder(rec.Body).Decode(&files); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if len(files) != 1 {
+ t.Fatalf("want 1 file, got %d", len(files))
+ }
+ if files[0]["name"] != "hello.txt" {
+ t.Errorf("name: want %q, got %v", "hello.txt", files[0]["name"])
+ }
+}
+
+func TestHandlePostDrop_Multipart(t *testing.T) {
+ srv, dropsDir := testServerWithDrops(t)
+
+ var buf bytes.Buffer
+ w := multipart.NewWriter(&buf)
+ fw, err := w.CreateFormFile("file", "test.txt")
+ if err != nil {
+ t.Fatal(err)
+ }
+ fw.Write([]byte("hello world")) //nolint:errcheck
+ w.Close()
+
+ req := httptest.NewRequest("POST", "/api/drops", &buf)
+ req.Header.Set("Content-Type", w.FormDataContentType())
+ rec := httptest.NewRecorder()
+ srv.mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusCreated {
+ t.Fatalf("want 201, got %d: %s", rec.Code, rec.Body.String())
+ }
+
+ var resp map[string]interface{}
+ if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if resp["name"] != "test.txt" {
+ t.Errorf("name: want %q, got %v", "test.txt", resp["name"])
+ }
+
+ // Verify file was created on disk.
+ content, err := os.ReadFile(filepath.Join(dropsDir, "test.txt"))
+ if err != nil {
+ t.Fatalf("reading uploaded file: %v", err)
+ }
+ if string(content) != "hello world" {
+ t.Errorf("content: want %q, got %q", "hello world", content)
+ }
+}
+
+func TestHandleGetDrop_Download(t *testing.T) {
+ srv, dropsDir := testServerWithDrops(t)
+
+ if err := os.WriteFile(filepath.Join(dropsDir, "download.txt"), []byte("download me"), 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/drops/download.txt", nil)
+ rec := httptest.NewRecorder()
+ srv.mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d", rec.Code)
+ }
+
+ cd := rec.Header().Get("Content-Disposition")
+ if !strings.Contains(cd, "attachment") {
+ t.Errorf("want Content-Disposition: attachment, got %q", cd)
+ }
+ if rec.Body.String() != "download me" {
+ t.Errorf("body: want %q, got %q", "download me", rec.Body.String())
+ }
+}
+
+func TestHandleGetDrop_PathTraversal(t *testing.T) {
+ srv, _ := testServerWithDrops(t)
+
+ // Attempt path traversal — should be rejected.
+ req := httptest.NewRequest("GET", "/api/drops/..%2Fetc%2Fpasswd", nil)
+ rec := httptest.NewRecorder()
+ srv.mux.ServeHTTP(rec, req)
+
+ // The Go net/http router will handle %2F-encoded slashes as literal characters,
+ // so the filename becomes "../etc/passwd". Our handler should reject it.
+ if rec.Code == http.StatusOK {
+ t.Error("expected non-200 for path traversal attempt")
+ }
+}
+
+func TestHandleGetDrop_NotFound(t *testing.T) {
+ srv, _ := testServerWithDrops(t)
+
+ req := httptest.NewRequest("GET", "/api/drops/notexist.txt", nil)
+ rec := httptest.NewRecorder()
+ srv.mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("want 404, got %d", rec.Code)
+ }
+}
diff --git a/internal/api/elaborate.go b/internal/api/elaborate.go
index 30095c8..8676b36 100644
--- a/internal/api/elaborate.go
+++ b/internal/api/elaborate.go
@@ -172,7 +172,7 @@ func readProjectContext(workDir string) string {
return ""
}
var sb strings.Builder
- for _, filename := range []string{"CLAUDE.md", "SESSION_STATE.md"} {
+ for _, filename := range []string{"CLAUDE.md", ".agent/worklog.md"} {
path := filepath.Join(workDir, filename)
if data, err := os.ReadFile(path); err == nil {
if sb.Len() > 0 {
@@ -303,6 +303,197 @@ func (s *Server) elaborateWithGemini(ctx context.Context, workDir, fullPrompt st
return &result, nil
}
+// elaboratedStorySubtask is a leaf unit within a story task.
+type elaboratedStorySubtask struct {
+ Name string `json:"name"`
+ Instructions string `json:"instructions"`
+}
+
+// elaboratedStoryTask is one independently-deployable unit in a story plan.
+type elaboratedStoryTask struct {
+ Name string `json:"name"`
+ Instructions string `json:"instructions"`
+ AcceptanceCriteria string `json:"acceptance_criteria"`
+ Subtasks []elaboratedStorySubtask `json:"subtasks"`
+}
+
+// elaboratedStoryValidation describes how to verify the story was successful.
+type elaboratedStoryValidation struct {
+ Type string `json:"type"`
+ Steps []string `json:"steps"`
+ SuccessCriteria string `json:"success_criteria"`
+}
+
+// elaboratedStory is the full implementation plan produced by story elaboration.
+type elaboratedStory struct {
+ Name string `json:"name"`
+ BranchName string `json:"branch_name"`
+ Tasks []elaboratedStoryTask `json:"tasks"`
+ Validation elaboratedStoryValidation `json:"validation"`
+}
+
+func buildStoryElaboratePrompt() string {
+ return `You are a software architect. Given a goal, analyze the codebase at /workspace and produce a structured implementation plan as JSON.
+
+Output ONLY valid JSON matching this schema:
+{
+ "name": "story name",
+ "branch_name": "story/kebab-case-name",
+ "tasks": [
+ {
+ "name": "task name",
+ "instructions": "detailed instructions including file paths and what to change",
+ "acceptance_criteria": "specific, verifiable conditions a separate reviewer can check — e.g. 'run go test ./... and verify all pass; confirm GET /api/foo returns 200 with expected JSON shape'",
+ "subtasks": [
+ { "name": "subtask name", "instructions": "..." }
+ ]
+ }
+ ],
+ "validation": {
+ "type": "build|test|smoke",
+ "steps": ["step1", "step2"],
+ "success_criteria": "what success looks like"
+ }
+}
+
+Rules:
+- Tasks must be independently buildable (each can be deployed alone)
+- Subtasks within a task are order-dependent and run sequentially
+- Instructions must include specific file paths, function names, and exact changes
+- Instructions must end with: git add -A && git commit -m "..." && git push origin <branch>
+- Validation should match the scope: small change = build check; new feature = smoke test
+- acceptance_criteria must be concrete and verifiable by a separate agent — no vague assertions like "code looks good"`
+}
+
+func (s *Server) elaborateStoryWithClaude(ctx context.Context, workDir, goal string) (*elaboratedStory, error) {
+ cmd := exec.CommandContext(ctx, s.claudeBinaryPath(),
+ "-p", goal,
+ "--system-prompt", buildStoryElaboratePrompt(),
+ "--output-format", "json",
+ "--model", "haiku",
+ )
+ if workDir != "" {
+ cmd.Dir = workDir
+ }
+
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+
+ err := cmd.Run()
+
+ output := stdout.Bytes()
+ if len(output) == 0 {
+ if err != nil {
+ return nil, fmt.Errorf("claude failed: %w (stderr: %s)", err, stderr.String())
+ }
+ return nil, fmt.Errorf("claude returned no output")
+ }
+
+ var wrapper claudeJSONResult
+ if jerr := json.Unmarshal(output, &wrapper); jerr != nil {
+ return nil, fmt.Errorf("failed to parse claude JSON wrapper: %w (output: %s)", jerr, string(output))
+ }
+ if wrapper.IsError {
+ return nil, fmt.Errorf("claude error: %s", wrapper.Result)
+ }
+
+ var result elaboratedStory
+ if jerr := json.Unmarshal([]byte(extractJSON(wrapper.Result)), &result); jerr != nil {
+ return nil, fmt.Errorf("failed to parse elaborated story JSON: %w (result: %s)", jerr, wrapper.Result)
+ }
+ return &result, nil
+}
+
+func (s *Server) elaborateStoryWithGemini(ctx context.Context, workDir, goal string) (*elaboratedStory, error) {
+ combinedPrompt := fmt.Sprintf("%s\n\n%s", buildStoryElaboratePrompt(), goal)
+ cmd := exec.CommandContext(ctx, s.geminiBinaryPath(),
+ "-p", combinedPrompt,
+ "--output-format", "json",
+ "--model", "gemini-2.5-flash-lite",
+ )
+ if workDir != "" {
+ cmd.Dir = workDir
+ }
+
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+
+ if err := cmd.Run(); err != nil {
+ return nil, fmt.Errorf("gemini failed: %w (stderr: %s)", err, stderr.String())
+ }
+
+ var wrapper geminiJSONResult
+ if err := json.Unmarshal(stdout.Bytes(), &wrapper); err != nil {
+ return nil, fmt.Errorf("failed to parse gemini JSON wrapper: %w (output: %s)", err, stdout.String())
+ }
+
+ var result elaboratedStory
+ if err := json.Unmarshal([]byte(extractJSON(wrapper.Response)), &result); err != nil {
+ return nil, fmt.Errorf("failed to parse elaborated story JSON: %w (response: %s)", err, wrapper.Response)
+ }
+ return &result, nil
+}
+
+func (s *Server) handleElaborateStory(w http.ResponseWriter, r *http.Request) {
+ var input struct {
+ Goal string `json:"goal"`
+ ProjectID string `json:"project_id"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
+ return
+ }
+ if input.Goal == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "goal is required"})
+ return
+ }
+ if input.ProjectID == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "project_id is required"})
+ return
+ }
+
+ proj, err := s.store.GetProject(input.ProjectID)
+ if err != nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "project not found"})
+ return
+ }
+
+ // Update git refs without modifying the working tree.
+ if proj.LocalPath != "" {
+ gitCmd := exec.Command("git", "-C", proj.LocalPath, "fetch", "origin")
+ if err := gitCmd.Run(); err != nil {
+ s.logger.Warn("story elaborate: git fetch failed", "error", err, "path", proj.LocalPath)
+ }
+ }
+
+ ctx, cancel := context.WithTimeout(r.Context(), elaborateTimeout)
+ defer cancel()
+
+ result, err := s.elaborateStoryWithClaude(ctx, proj.LocalPath, input.Goal)
+ if err != nil {
+ s.logger.Warn("story elaborate: claude failed, falling back to gemini", "error", err)
+ result, err = s.elaborateStoryWithGemini(ctx, proj.LocalPath, input.Goal)
+ if err != nil {
+ s.logger.Error("story elaborate: fallback gemini also failed", "error", err)
+ writeJSON(w, http.StatusBadGateway, map[string]string{
+ "error": fmt.Sprintf("elaboration failed: %v", err),
+ })
+ return
+ }
+ }
+
+ if result.Name == "" {
+ writeJSON(w, http.StatusBadGateway, map[string]string{
+ "error": "elaboration failed: missing required fields in response",
+ })
+ return
+ }
+
+ writeJSON(w, http.StatusOK, result)
+}
+
func (s *Server) handleElaborateTask(w http.ResponseWriter, r *http.Request) {
if s.elaborateLimiter != nil && !s.elaborateLimiter.allow(realIP(r)) {
writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "rate limit exceeded"})
@@ -310,7 +501,9 @@ func (s *Server) handleElaborateTask(w http.ResponseWriter, r *http.Request) {
}
var input struct {
- Prompt string `json:"prompt"`
+ Prompt string `json:"prompt"`
+ ProjectID string `json:"project_id"`
+ // project_dir kept for backward compat; project_id takes precedence
ProjectDir string `json:"project_dir"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
@@ -323,11 +516,15 @@ func (s *Server) handleElaborateTask(w http.ResponseWriter, r *http.Request) {
}
workDir := s.workDir
- if input.ProjectDir != "" {
+ if input.ProjectID != "" {
+ if proj, err := s.store.GetProject(input.ProjectID); err == nil {
+ workDir = proj.LocalPath
+ }
+ } else if input.ProjectDir != "" {
workDir = input.ProjectDir
}
- if input.ProjectDir != "" {
+ if workDir != s.workDir {
go s.appendRawNarrative(workDir, input.Prompt)
}
diff --git a/internal/api/elaborate_test.go b/internal/api/elaborate_test.go
index 0b5c706..32cec3c 100644
--- a/internal/api/elaborate_test.go
+++ b/internal/api/elaborate_test.go
@@ -350,6 +350,8 @@ func TestElaborateTask_InvalidJSONFromClaude(t *testing.T) {
// Fake Claude returns something that is not valid JSON.
srv.elaborateCmdPath = createFakeClaude(t, "not valid json at all", 0)
+ // Ensure Gemini fallback also fails so we get the expected 502.
+ srv.geminiBinPath = "/nonexistent/gemini"
body := `{"prompt":"do something"}`
req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body))
@@ -388,14 +390,17 @@ func createFakeClaudeCapturingArgs(t *testing.T, output string, exitCode int, ar
func TestElaborateTask_WithProjectContext(t *testing.T) {
srv, _ := testServer(t)
- // Create a temporary workspace with CLAUDE.md and SESSION_STATE.md
+ // Create a temporary workspace with CLAUDE.md and .agent/worklog.md
workDir := t.TempDir()
claudeContent := "Claude context info"
sessionContent := "Session state info"
if err := os.WriteFile(filepath.Join(workDir, "CLAUDE.md"), []byte(claudeContent), 0600); err != nil {
t.Fatal(err)
}
- if err := os.WriteFile(filepath.Join(workDir, "SESSION_STATE.md"), []byte(sessionContent), 0600); err != nil {
+ if err := os.MkdirAll(filepath.Join(workDir, ".agent"), 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(workDir, ".agent", "worklog.md"), []byte(sessionContent), 0600); err != nil {
t.Fatal(err)
}
@@ -436,7 +441,7 @@ func TestElaborateTask_WithProjectContext(t *testing.T) {
t.Errorf("expected arguments to contain CLAUDE.md content, got %s", argsStr)
}
if !strings.Contains(argsStr, sessionContent) {
- t.Errorf("expected arguments to contain SESSION_STATE.md content, got %s", argsStr)
+ t.Errorf("expected arguments to contain .agent/worklog.md content, got %s", argsStr)
}
}
diff --git a/internal/api/executions.go b/internal/api/executions.go
index 114425e..4d8ba9c 100644
--- a/internal/api/executions.go
+++ b/internal/api/executions.go
@@ -86,6 +86,48 @@ func (s *Server) handleGetExecutionLog(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, content)
}
+// handleGetDashboardStats returns pre-aggregated error, throughput, and billing stats.
+// GET /api/stats?window=7d|24h
+func (s *Server) handleGetDashboardStats(w http.ResponseWriter, r *http.Request) {
+ window := 7 * 24 * time.Hour
+ if r.URL.Query().Get("window") == "24h" {
+ window = 24 * time.Hour
+ }
+ since := time.Now().Add(-window)
+
+ stats, err := s.store.QueryDashboardStats(since)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ writeJSON(w, http.StatusOK, stats)
+}
+
+// handleGetAgentStatus returns the current status of all agents and recent rate-limit events.
+// GET /api/agents/status?since=<RFC3339>
+func (s *Server) handleGetAgentStatus(w http.ResponseWriter, r *http.Request) {
+ since := time.Now().Add(-24 * time.Hour)
+ if v := r.URL.Query().Get("since"); v != "" {
+ if t, err := time.Parse(time.RFC3339, v); err == nil {
+ since = t
+ }
+ }
+
+ events, err := s.store.ListAgentEvents(since)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ if events == nil {
+ events = []storage.AgentEvent{}
+ }
+
+ writeJSON(w, http.StatusOK, map[string]interface{}{
+ "agents": s.pool.AgentStatuses(),
+ "events": events,
+ })
+}
+
// tailLogFile reads the last n lines from the file at path.
func tailLogFile(path string, n int) (string, error) {
data, err := os.ReadFile(path)
diff --git a/internal/api/projects.go b/internal/api/projects.go
new file mode 100644
index 0000000..d3dbbf9
--- /dev/null
+++ b/internal/api/projects.go
@@ -0,0 +1,71 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/google/uuid"
+ "github.com/thepeterstone/claudomator/internal/task"
+)
+
+func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) {
+ projects, err := s.store.ListProjects()
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ if projects == nil {
+ projects = []*task.Project{}
+ }
+ writeJSON(w, http.StatusOK, projects)
+}
+
+func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
+ var p task.Project
+ if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
+ return
+ }
+ if p.Name == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
+ return
+ }
+ if p.ID == "" {
+ p.ID = uuid.New().String()
+ }
+ if err := s.store.CreateProject(&p); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ writeJSON(w, http.StatusCreated, p)
+}
+
+func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ p, err := s.store.GetProject(id)
+ if err != nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "project not found"})
+ return
+ }
+ writeJSON(w, http.StatusOK, p)
+}
+
+func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ existing, err := s.store.GetProject(id)
+ if err != nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "project not found"})
+ return
+ }
+ 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
+ if err := s.store.UpdateProject(existing); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ writeJSON(w, http.StatusOK, existing)
+}
+
diff --git a/internal/api/push.go b/internal/api/push.go
new file mode 100644
index 0000000..dde5441
--- /dev/null
+++ b/internal/api/push.go
@@ -0,0 +1,120 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/google/uuid"
+ "github.com/thepeterstone/claudomator/internal/storage"
+ webui "github.com/thepeterstone/claudomator/web"
+)
+
+// pushSubscriptionStore is the minimal interface needed by push handlers.
+type pushSubscriptionStore interface {
+ SavePushSubscription(sub storage.PushSubscription) error
+ DeletePushSubscription(endpoint string) error
+ ListPushSubscriptions() ([]storage.PushSubscription, error)
+}
+
+// SetVAPIDConfig configures VAPID keys and email for web push notifications.
+func (s *Server) SetVAPIDConfig(pub, priv, email string) {
+ s.vapidPublicKey = pub
+ s.vapidPrivateKey = priv
+ s.vapidEmail = email
+}
+
+// SetPushStore configures the push subscription store.
+func (s *Server) SetPushStore(store pushSubscriptionStore) {
+ s.pushStore = store
+}
+
+// SetDropsDir configures the file drop directory.
+func (s *Server) SetDropsDir(dir string) {
+ s.dropsDir = dir
+}
+
+// handleGetVAPIDKey returns the VAPID public key for client-side push subscription.
+func (s *Server) handleGetVAPIDKey(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, http.StatusOK, map[string]string{"public_key": s.vapidPublicKey})
+}
+
+// handleServiceWorker serves sw.js with a Service-Worker-Allowed: / header so
+// the SW can control the full origin even though it is registered from /api/push/sw.js.
+func (s *Server) handleServiceWorker(w http.ResponseWriter, r *http.Request) {
+ data, err := webui.Files.ReadFile("sw.js")
+ if err != nil {
+ http.Error(w, "service worker not found", http.StatusNotFound)
+ return
+ }
+ w.Header().Set("Content-Type", "application/javascript")
+ w.Header().Set("Service-Worker-Allowed", "/")
+ w.WriteHeader(http.StatusOK)
+ w.Write(data)
+}
+
+// handlePushSubscribe saves a new push subscription.
+func (s *Server) handlePushSubscribe(w http.ResponseWriter, r *http.Request) {
+ var input struct {
+ Endpoint string `json:"endpoint"`
+ Keys struct {
+ P256DH string `json:"p256dh"`
+ Auth string `json:"auth"`
+ } `json:"keys"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
+ return
+ }
+ if input.Endpoint == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "endpoint is required"})
+ return
+ }
+ if input.Keys.P256DH == "" || input.Keys.Auth == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "keys.p256dh and keys.auth are required"})
+ return
+ }
+
+ sub := storage.PushSubscription{
+ ID: uuid.New().String(),
+ Endpoint: input.Endpoint,
+ P256DHKey: input.Keys.P256DH,
+ AuthKey: input.Keys.Auth,
+ }
+
+ store := s.pushStore
+ if store == nil {
+ store = s.store
+ }
+
+ if err := store.SavePushSubscription(sub); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ writeJSON(w, http.StatusCreated, map[string]string{"id": sub.ID})
+}
+
+// handlePushUnsubscribe deletes a push subscription.
+func (s *Server) handlePushUnsubscribe(w http.ResponseWriter, r *http.Request) {
+ var input struct {
+ Endpoint string `json:"endpoint"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
+ return
+ }
+ if input.Endpoint == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "endpoint is required"})
+ return
+ }
+
+ store := s.pushStore
+ if store == nil {
+ store = s.store
+ }
+
+ if err := store.DeletePushSubscription(input.Endpoint); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
diff --git a/internal/api/push_test.go b/internal/api/push_test.go
new file mode 100644
index 0000000..dfd5a3a
--- /dev/null
+++ b/internal/api/push_test.go
@@ -0,0 +1,159 @@
+package api
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "sync"
+ "testing"
+
+ "github.com/thepeterstone/claudomator/internal/storage"
+)
+
+// mockPushStore implements pushSubscriptionStore for testing.
+type mockPushStore struct {
+ mu sync.Mutex
+ subs []storage.PushSubscription
+}
+
+func (m *mockPushStore) SavePushSubscription(sub storage.PushSubscription) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ // Upsert by endpoint.
+ for i, s := range m.subs {
+ if s.Endpoint == sub.Endpoint {
+ m.subs[i] = sub
+ return nil
+ }
+ }
+ m.subs = append(m.subs, sub)
+ return nil
+}
+
+func (m *mockPushStore) DeletePushSubscription(endpoint string) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ filtered := m.subs[:0]
+ for _, s := range m.subs {
+ if s.Endpoint != endpoint {
+ filtered = append(filtered, s)
+ }
+ }
+ m.subs = filtered
+ return nil
+}
+
+func (m *mockPushStore) ListPushSubscriptions() ([]storage.PushSubscription, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ cp := make([]storage.PushSubscription, len(m.subs))
+ copy(cp, m.subs)
+ return cp, nil
+}
+
+func testServerWithPush(t *testing.T) (*Server, *mockPushStore) {
+ t.Helper()
+ srv, _ := testServer(t)
+ ps := &mockPushStore{}
+ srv.SetVAPIDConfig("testpub", "testpriv", "mailto:test@example.com")
+ srv.SetPushStore(ps)
+ return srv, ps
+}
+
+func TestHandleGetVAPIDKey(t *testing.T) {
+ srv, _ := testServerWithPush(t)
+
+ req := httptest.NewRequest("GET", "/api/push/vapid-key", nil)
+ rec := httptest.NewRecorder()
+ srv.mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d", rec.Code)
+ }
+
+ var resp map[string]string
+ if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if resp["public_key"] != "testpub" {
+ t.Errorf("want public_key %q, got %q", "testpub", resp["public_key"])
+ }
+}
+
+func TestHandlePushSubscribe_CreatesSub(t *testing.T) {
+ srv, ps := testServerWithPush(t)
+
+ body := `{"endpoint":"https://push.example.com/sub1","keys":{"p256dh":"key1","auth":"auth1"}}`
+ req := httptest.NewRequest("POST", "/api/push/subscribe", bytes.NewBufferString(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ srv.mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusCreated {
+ t.Fatalf("want 201, got %d: %s", rec.Code, rec.Body.String())
+ }
+
+ subs, _ := ps.ListPushSubscriptions()
+ if len(subs) != 1 {
+ t.Fatalf("want 1 subscription, got %d", len(subs))
+ }
+ if subs[0].Endpoint != "https://push.example.com/sub1" {
+ t.Errorf("endpoint: want %q, got %q", "https://push.example.com/sub1", subs[0].Endpoint)
+ }
+}
+
+func TestHandlePushSubscribe_MissingEndpoint(t *testing.T) {
+ srv, _ := testServerWithPush(t)
+
+ body := `{"keys":{"p256dh":"key1","auth":"auth1"}}`
+ req := httptest.NewRequest("POST", "/api/push/subscribe", bytes.NewBufferString(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ srv.mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("want 400, got %d", rec.Code)
+ }
+}
+
+func TestHandlePushUnsubscribe_DeletesSub(t *testing.T) {
+ srv, ps := testServerWithPush(t)
+
+ // Add a subscription.
+ ps.SavePushSubscription(storage.PushSubscription{ //nolint:errcheck
+ ID: "sub-1",
+ Endpoint: "https://push.example.com/todelete",
+ P256DHKey: "key",
+ AuthKey: "auth",
+ })
+
+ body := `{"endpoint":"https://push.example.com/todelete"}`
+ req := httptest.NewRequest("DELETE", "/api/push/subscribe", bytes.NewBufferString(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ srv.mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusNoContent {
+ t.Fatalf("want 204, got %d: %s", rec.Code, rec.Body.String())
+ }
+
+ subs, _ := ps.ListPushSubscriptions()
+ if len(subs) != 0 {
+ t.Errorf("want 0 subscriptions after delete, got %d", len(subs))
+ }
+}
+
+func TestHandlePushUnsubscribe_MissingEndpoint(t *testing.T) {
+ srv, _ := testServerWithPush(t)
+
+ body := `{}`
+ req := httptest.NewRequest("DELETE", "/api/push/subscribe", bytes.NewBufferString(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ srv.mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("want 400, got %d", rec.Code)
+ }
+}
diff --git a/internal/api/server.go b/internal/api/server.go
index 33048e4..28cfe4a 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -16,6 +16,7 @@ import (
"github.com/thepeterstone/claudomator/internal/notify"
"github.com/thepeterstone/claudomator/internal/storage"
"github.com/thepeterstone/claudomator/internal/task"
+ "github.com/thepeterstone/claudomator/internal/version"
webui "github.com/thepeterstone/claudomator/web"
"github.com/google/uuid"
)
@@ -31,6 +32,7 @@ type questionStore interface {
// Server provides the REST API and WebSocket endpoint for Claudomator.
type Server struct {
+ ctx context.Context // server lifecycle context; used for pool submissions
store *storage.DB
logStore logStore // injectable for tests; defaults to store
taskLogStore taskLogStore // injectable for tests; defaults to store
@@ -51,7 +53,12 @@ type Server struct {
elaborateLimiter *ipRateLimiter // per-IP rate limiter for elaborate/validate endpoints
webhookSecret string // HMAC-SHA256 secret for GitHub webhook validation
projects []config.Project // configured projects for webhook routing
- llm *llm.Client // optional local LLM client; when set, elaboration prefers it
+ vapidPublicKey string
+ vapidPrivateKey string
+ vapidEmail string
+ pushStore pushSubscriptionStore
+ dropsDir string
+ llm *llm.Client
}
// SetAPIToken configures a bearer token that must be supplied to access the API.
@@ -59,6 +66,12 @@ func (s *Server) SetAPIToken(token string) {
s.apiToken = token
}
+// SetContext replaces the server's lifecycle context used for pool submissions.
+// Call this before StartHub to tie task submissions to the server's shutdown signal.
+func (s *Server) SetContext(ctx context.Context) {
+ s.ctx = ctx
+}
+
// SetNotifier configures a notifier that is called on every task completion.
func (s *Server) SetNotifier(n notify.Notifier) {
s.notifier = n
@@ -75,6 +88,9 @@ func (s *Server) SetWorkspaceRoot(path string) {
s.workspaceRoot = path
}
+// Pool returns the executor pool, for graceful shutdown by the caller.
+func (s *Server) Pool() *executor.Pool { return s.pool }
+
// SetLLM wires a local OpenAI-compatible LLM client for use by elaboration
// (and future internal helpers). When non-nil, elaboration will prefer it
// over the Claude CLI; on failure it falls back to claude → gemini.
@@ -82,9 +98,11 @@ func (s *Server) SetLLM(c *llm.Client) {
s.llm = c
}
+
func NewServer(store *storage.DB, pool *executor.Pool, logger *slog.Logger, claudeBinPath, geminiBinPath string) *Server {
wd, _ := os.Getwd()
s := &Server{
+ ctx: context.Background(),
store: store,
logStore: store,
taskLogStore: store,
@@ -125,6 +143,8 @@ func (s *Server) routes() {
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", s.handleListRecentExecutions)
+ s.mux.HandleFunc("GET /api/stats", s.handleGetDashboardStats)
+ s.mux.HandleFunc("GET /api/agents/status", s.handleGetAgentStatus)
s.mux.HandleFunc("GET /api/executions/{id}", s.handleGetExecution)
s.mux.HandleFunc("GET /api/executions/{id}/log", s.handleGetExecutionLog)
s.mux.HandleFunc("GET /api/tasks/{id}/logs/stream", s.handleStreamTaskLogs)
@@ -135,29 +155,53 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /api/ws", s.handleWebSocket)
s.mux.HandleFunc("GET /api/workspaces", s.handleListWorkspaces)
s.mux.HandleFunc("GET /api/tasks/{id}/deployment-status", s.handleGetDeploymentStatus)
+ s.mux.HandleFunc("GET /api/projects", s.handleListProjects)
+ 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("POST /api/stories/elaborate", s.handleElaborateStory)
+ s.mux.HandleFunc("POST /api/stories/approve", s.handleApproveStory)
+ 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("GET /api/stories/{id}/tasks", s.handleListStoryTasks)
+ s.mux.HandleFunc("POST /api/stories/{id}/tasks", s.handleAddTaskToStory)
+ s.mux.HandleFunc("PUT /api/stories/{id}/status", s.handleUpdateStoryStatus)
+ s.mux.HandleFunc("POST /api/stories/{id}/ship", s.handleShipStory)
+ s.mux.HandleFunc("GET /api/stories/{id}/deployment-status", s.handleStoryDeploymentStatus)
s.mux.HandleFunc("GET /api/health", s.handleHealth)
+ s.mux.HandleFunc("GET /api/version", s.handleVersion)
s.mux.HandleFunc("POST /api/webhooks/github", s.handleGitHubWebhook)
+ s.mux.HandleFunc("GET /api/push/vapid-key", s.handleGetVAPIDKey)
+ s.mux.HandleFunc("GET /api/push/sw.js", s.handleServiceWorker)
+ s.mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
+ s.mux.HandleFunc("DELETE /api/push/subscribe", s.handlePushUnsubscribe)
+ s.mux.HandleFunc("GET /api/drops", s.handleListDrops)
+ s.mux.HandleFunc("GET /api/drops/{filename}", s.handleGetDrop)
+ s.mux.HandleFunc("POST /api/drops", s.handlePostDrop)
s.mux.Handle("GET /", http.FileServerFS(webui.Files))
}
-// forwardResults listens on the executor pool's result channel and broadcasts via WebSocket.
+// forwardResults listens on the executor pool's result and started channels and broadcasts via WebSocket.
func (s *Server) forwardResults() {
+ go func() {
+ for taskID := range s.pool.Started() {
+ event := map[string]interface{}{
+ "type": "task_started",
+ "task_id": taskID,
+ "timestamp": time.Now().UTC(),
+ }
+ data, _ := json.Marshal(event)
+ s.hub.Broadcast(data)
+ }
+ }()
for result := range s.pool.Results() {
s.processResult(result)
}
}
// processResult broadcasts a task completion event via WebSocket and calls the notifier if set.
-// It also parses git diff stats from the execution stdout log and persists them.
func (s *Server) processResult(result *executor.Result) {
- if result.Execution.StdoutPath != "" {
- if stats := parseChangestatFromFile(result.Execution.StdoutPath); stats != nil {
- if err := s.store.UpdateExecutionChangestats(result.Execution.ID, stats); err != nil {
- s.logger.Error("failed to store changestats", "execID", result.Execution.ID, "error", err)
- }
- }
- }
-
event := map[string]interface{}{
"type": "task_completed",
"task_id": result.TaskID,
@@ -318,7 +362,7 @@ func (s *Server) handleAnswerQuestion(w http.ResponseWriter, r *http.Request) {
ResumeAnswer: input.Answer,
SandboxDir: latest.SandboxDir,
}
- if err := s.pool.SubmitResume(context.Background(), tk, resumeExec); err != nil {
+ if err := s.pool.SubmitResume(s.ctx, tk, resumeExec); err != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
return
}
@@ -363,7 +407,7 @@ func (s *Server) handleResumeTimedOutTask(w http.ResponseWriter, r *http.Request
ResumeSessionID: latest.SessionID,
ResumeAnswer: resumeMsg,
}
- if err := s.pool.SubmitResume(context.Background(), tk, resumeExec); err != nil {
+ if err := s.pool.SubmitResume(s.ctx, tk, resumeExec); err != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
return
}
@@ -415,11 +459,17 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
})
}
+func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, http.StatusOK, map[string]string{"version": version.Version()})
+}
+
func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
var input struct {
Name string `json:"name"`
Description string `json:"description"`
ElaborationInput string `json:"elaboration_input"`
+ Project string `json:"project"`
+ RepositoryURL string `json:"repository_url"`
Agent task.AgentConfig `json:"agent"`
Claude task.AgentConfig `json:"claude"` // legacy alias
Timeout string `json:"timeout"`
@@ -443,6 +493,8 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
Name: input.Name,
Description: input.Description,
ElaborationInput: input.ElaborationInput,
+ Project: input.Project,
+ RepositoryURL: input.RepositoryURL,
Agent: input.Agent,
Priority: task.Priority(input.Priority),
Tags: input.Tags,
@@ -453,6 +505,7 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
UpdatedAt: now,
ParentTaskID: input.ParentTaskID,
}
+
if t.Agent.Type == "" {
t.Agent.Type = "claude"
}
@@ -523,7 +576,11 @@ func (s *Server) handleListTasks(w http.ResponseWriter, r *http.Request) {
if tasks == nil {
tasks = []*task.Task{}
}
- writeJSON(w, http.StatusOK, tasks)
+ views := make([]*taskView, len(tasks))
+ for i, tk := range tasks {
+ views[i] = s.enrichTask(tk)
+ }
+ writeJSON(w, http.StatusOK, views)
}
func (s *Server) handleGetTask(w http.ResponseWriter, r *http.Request) {
@@ -533,8 +590,43 @@ func (s *Server) handleGetTask(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
return
}
- writeJSON(w, http.StatusOK, t)
+ writeJSON(w, http.StatusOK, s.enrichTask(t))
+}
+// retryableDepStates are the states from which a dependency can be retried
+// when cascading a retry from a dependent task.
+var retryableDepStates = map[task.State]bool{
+ task.StateFailed: true,
+ task.StateTimedOut: true,
+ task.StateCancelled: true,
+ task.StateBudgetExceeded: true,
+}
+
+// cascadeRetryDeps resets any dependency (recursively) that is in a retryable
+// terminal state, and submits it to the pool. This ensures that retrying a
+// CANCELLED task that was blocked by a failed dep will also restart that dep.
+func (s *Server) cascadeRetryDeps(ctx context.Context, t *task.Task) {
+ for _, depID := range t.DependsOn {
+ dep, err := s.store.GetTask(depID)
+ if err != nil {
+ s.logger.Warn("cascadeRetryDeps: dep not found", "depID", depID)
+ continue
+ }
+ if !retryableDepStates[dep.State] {
+ continue
+ }
+ // Recursively cascade first (depth-first so root deps go first).
+ s.cascadeRetryDeps(ctx, dep)
+ reset, err := s.store.ResetTaskForRetry(depID)
+ if err != nil {
+ s.logger.Warn("cascadeRetryDeps: reset failed", "depID", depID, "error", err)
+ continue
+ }
+ if submitErr := s.pool.Submit(ctx, reset); submitErr != nil {
+ s.logger.Warn("cascadeRetryDeps: submit failed", "depID", depID, "error", submitErr)
+ }
+ }
}
+
func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agentParam := r.URL.Query().Get("agent") // Use a different name to avoid confusion
@@ -583,7 +675,11 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) {
}
// The task `t` now has the correct agent configuration.
- if err := s.pool.Submit(context.Background(), t); err != nil {
+ // 6. Cascade-retry any deps that are in a terminal failure state so the
+ // task isn't immediately re-cancelled by checkDepsReady.
+ s.cascadeRetryDeps(r.Context(), originalTask)
+
+ if err := s.pool.Submit(s.ctx, t); err != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": fmt.Sprintf("executor pool: %v", err)})
return
}
@@ -611,6 +707,9 @@ func (s *Server) handleAcceptTask(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
+ if t.StoryID != "" {
+ go s.pool.CheckStoryCompletion(r.Context(), t.StoryID)
+ }
writeJSON(w, http.StatusOK, map[string]string{"message": "task accepted", "task_id": id})
}
diff --git a/internal/api/server_test.go b/internal/api/server_test.go
index 2139e36..2530d55 100644
--- a/internal/api/server_test.go
+++ b/internal/api/server_test.go
@@ -16,6 +16,7 @@ import (
"context"
+ "github.com/google/uuid"
"github.com/thepeterstone/claudomator/internal/executor"
"github.com/thepeterstone/claudomator/internal/notify"
"github.com/thepeterstone/claudomator/internal/storage"
@@ -89,6 +90,9 @@ func testServerWithRunner(t *testing.T, runner executor.Runner) (*Server, *stora
t.Cleanup(func() { store.Close() })
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
+ if mr, ok := runner.(*mockRunner); ok {
+ mr.logDir = t.TempDir()
+ }
runners := map[string]executor.Runner{
"claude": runner,
"gemini": runner,
@@ -99,11 +103,39 @@ func testServerWithRunner(t *testing.T, runner executor.Runner) (*Server, *stora
}
type mockRunner struct {
- err error
- sleep time.Duration
+ err error
+ sleep time.Duration
+ logDir string
+ onRun func(*task.Task, *storage.Execution) error
+}
+
+func (m *mockRunner) ExecLogDir(execID string) string {
+ if m.logDir == "" {
+ return ""
+ }
+ return filepath.Join(m.logDir, execID)
}
-func (m *mockRunner) Run(ctx context.Context, _ *task.Task, _ *storage.Execution) error {
+func (m *mockRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution) error {
+ if e.ID == "" {
+ e.ID = uuid.New().String()
+ }
+ if m.logDir != "" {
+ dir := m.ExecLogDir(e.ID)
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return err
+ }
+ e.StdoutPath = filepath.Join(dir, "stdout.log")
+ e.StderrPath = filepath.Join(dir, "stderr.log")
+ e.ArtifactDir = dir
+ // Create an empty file at least
+ os.WriteFile(e.StdoutPath, []byte(""), 0644)
+ }
+ if m.onRun != nil {
+ if err := m.onRun(t, e); err != nil {
+ return err
+ }
+ }
if m.sleep > 0 {
select {
case <-time.After(m.sleep):
@@ -143,41 +175,26 @@ func testServerWithGeminiMockRunner(t *testing.T) (*Server, *storage.DB) {
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
- // Create the mock gemini binary script. Use single-quoted heredoc so
- // bash does not try to evaluate the literal backticks as command
- // substitution.
- mockBinDir := t.TempDir()
- mockGeminiPath := filepath.Join(mockBinDir, "mock-gemini-binary.sh")
- mockScriptContent := `#!/bin/bash
-cat <<'EOF'
-` + "```json" + `
-{"type":"content_block_start","content_block":{"text":"Hello, Gemini!","type":"text"}}
-{"type":"content_block_delta","content_block":{"text":" How are you?"}}
-{"type":"content_block_end"}
-{"type":"message_delta","message":{"role":"model"}}
-{"type":"message_end"}
-` + "```" + `
-EOF
-exit 0
-`
- if err := os.WriteFile(mockGeminiPath, []byte(mockScriptContent), 0755); err != nil {
- t.Fatalf("writing mock gemini script: %v", err)
- }
-
- // Configure GeminiRunner to use the mock script.
- geminiRunner := &executor.GeminiRunner{
- BinaryPath: mockGeminiPath,
- Logger: logger,
- LogDir: t.TempDir(), // Ensure log directory is temporary for test
- APIURL: "http://localhost:8080", // Placeholder, not used by this mock
+ mr := &mockRunner{
+ logDir: t.TempDir(),
+ onRun: func(t *task.Task, e *storage.Execution) error {
+ lines := []string{
+ `{"type":"content_block_start","content_block":{"text":"Hello, Gemini!","type":"text"}}`,
+ `{"type":"content_block_delta","content_block":{"text":" How are you?"}}`,
+ `{"type":"content_block_end"}`,
+ `{"type":"message_delta","message":{"role":"model"}}`,
+ `{"type":"message_end"}`,
+ }
+ return os.WriteFile(e.StdoutPath, []byte(strings.Join(lines, "\n")), 0644)
+ },
}
runners := map[string]executor.Runner{
- "claude": &mockRunner{}, // Keep mock for claude to not interfere
- "gemini": geminiRunner,
+ "claude": mr,
+ "gemini": mr,
}
pool := executor.NewPool(2, runners, store, logger)
- srv := NewServer(store, pool, logger, "claude", "gemini") // Pass original binary paths
+ srv := NewServer(store, pool, logger, "claude", "gemini")
return srv, store
}
@@ -200,6 +217,7 @@ func TestGeminiLogs_ParsedCorrectly(t *testing.T) {
tk := createTestTask(t, srv, `{
"name": "Gemini Log Test Task",
"description": "Test Gemini log parsing",
+ "repository_url": "https://github.com/user/repo",
"agent": {
"type": "gemini",
"instructions": "generate some output",
@@ -346,6 +364,7 @@ func TestCreateTask_Success(t *testing.T) {
payload := `{
"name": "API Task",
"description": "Created via API",
+ "repository_url": "https://github.com/user/repo",
"agent": {
"type": "claude",
"instructions": "do the thing",
@@ -399,6 +418,50 @@ func TestCreateTask_ValidationFailure(t *testing.T) {
}
}
+func TestProject_RoundTrip(t *testing.T) {
+ srv, _ := testServer(t)
+
+ payload := `{
+ "name": "Project Task",
+ "project": "test-project",
+ "repository_url": "https://github.com/user/repo",
+ "agent": {
+ "type": "claude",
+ "instructions": "do the thing",
+ "model": "sonnet"
+ }
+ }`
+ req := httptest.NewRequest("POST", "/api/tasks", bytes.NewBufferString(payload))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusCreated {
+ t.Fatalf("create: want 201, got %d; body: %s", w.Code, w.Body.String())
+ }
+
+ var created task.Task
+ json.NewDecoder(w.Body).Decode(&created)
+ if created.Project != "test-project" {
+ t.Errorf("create response: project want 'test-project', got %q", created.Project)
+ }
+
+ // GET the task and verify project is persisted
+ req2 := httptest.NewRequest("GET", "/api/tasks/"+created.ID, nil)
+ w2 := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w2, req2)
+
+ if w2.Code != http.StatusOK {
+ t.Fatalf("get: want 200, got %d; body: %s", w2.Code, w2.Body.String())
+ }
+
+ var fetched task.Task
+ json.NewDecoder(w2.Body).Decode(&fetched)
+ if fetched.Project != "test-project" {
+ t.Errorf("get response: project want 'test-project', got %q", fetched.Project)
+ }
+}
+
func TestListTasks_Empty(t *testing.T) {
srv, _ := testServer(t)
@@ -436,6 +499,7 @@ func TestListTasks_WithTasks(t *testing.T) {
for i := 0; i < 3; i++ {
tk := &task.Task{
ID: fmt.Sprintf("lt-%d", i), Name: fmt.Sprintf("T%d", i),
+ RepositoryURL: "https://github.com/user/repo",
Agent: task.AgentConfig{Type: "claude", Instructions: "x"}, Priority: task.PriorityNormal,
Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"},
Tags: []string{}, DependsOn: []string{}, State: task.StatePending,
@@ -473,6 +537,7 @@ func createTaskWithState(t *testing.T, store *storage.DB, id string, state task.
tk := &task.Task{
ID: id,
Name: "test-task-" + id,
+ RepositoryURL: "https://github.com/user/repo",
Agent: task.AgentConfig{Type: "claude", Instructions: "do something"},
Priority: task.PriorityNormal,
Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"},
@@ -851,6 +916,7 @@ func TestRunTask_ManualRunIgnoresRetryLimit(t *testing.T) {
tk := &task.Task{
ID: "retry-limit-manual",
Name: "Retry Limit Task",
+ RepositoryURL: "https://github.com/user/repo",
Agent: task.AgentConfig{Instructions: "do something"},
Priority: task.PriorityNormal,
Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"},
@@ -888,6 +954,7 @@ func TestRunTask_WithinRetryLimit_Returns202(t *testing.T) {
tk := &task.Task{
ID: "retry-within-1",
Name: "Retry Within Task",
+ RepositoryURL: "https://github.com/user/repo",
Agent: task.AgentConfig{Instructions: "do something"},
Priority: task.PriorityNormal,
Retry: task.RetryConfig{MaxAttempts: 3, Backoff: "linear"},
@@ -935,7 +1002,7 @@ func TestDeleteTask_Success(t *testing.T) {
srv, store := testServer(t)
// Create a task to delete.
- created := createTestTask(t, srv, `{"name":"Delete Me","agent":{"type":"claude","instructions":"x","model":"sonnet"}}`)
+ created := createTestTask(t, srv, `{"name":"Delete Me","repository_url":"https://github.com/user/repo","agent":{"type":"claude","instructions":"x","model":"sonnet"}}`)
req := httptest.NewRequest("DELETE", "/api/tasks/"+created.ID, nil)
w := httptest.NewRecorder()
@@ -970,6 +1037,7 @@ func TestDeleteTask_RunningTaskRejected(t *testing.T) {
tk := &task.Task{
ID: "running-task-del",
Name: "Running Task",
+ RepositoryURL: "https://github.com/user/repo",
Agent: task.AgentConfig{Instructions: "x", Model: "sonnet"},
Priority: task.PriorityNormal,
Tags: []string{},
@@ -1524,6 +1592,7 @@ func TestRunTask_AgentTimesOut_TaskSetToTimedOut(t *testing.T) {
tk := &task.Task{
ID: "async-timeout-1",
Name: "timeout-test",
+ RepositoryURL: "https://github.com/user/repo",
Agent: task.AgentConfig{Type: "claude", Instructions: "do something"},
Priority: task.PriorityNormal,
Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"},
@@ -1581,34 +1650,31 @@ func TestRunTask_AgentCancelled_TaskSetToCancelled(t *testing.T) {
}
}
-// TestGetTask_IncludesChangestats verifies that after processResult parses git diff stats
-// from the execution stdout log, they appear in the execution history response.
+// TestGetTask_IncludesChangestats verifies that changestats stored on an execution
+// are returned correctly by GET /api/tasks/{id}/executions.
func TestGetTask_IncludesChangestats(t *testing.T) {
srv, store := testServer(t)
tk := createTaskWithState(t, store, "cs-task-1", task.StateCompleted)
- // Write a stdout log with a git diff --stat summary line.
- dir := t.TempDir()
- stdoutPath := filepath.Join(dir, "stdout.log")
- logContent := "Agent output line 1\n3 files changed, 50 insertions(+), 10 deletions(-)\nAgent output line 2\n"
- if err := os.WriteFile(stdoutPath, []byte(logContent), 0600); err != nil {
- t.Fatal(err)
- }
-
exec := &storage.Execution{
- ID: "cs-exec-1",
- TaskID: tk.ID,
- StartTime: time.Now().UTC(),
- EndTime: time.Now().UTC().Add(time.Minute),
- Status: "COMPLETED",
- StdoutPath: stdoutPath,
+ ID: "cs-exec-1",
+ TaskID: tk.ID,
+ StartTime: time.Now().UTC(),
+ EndTime: time.Now().UTC().Add(time.Minute),
+ Status: "COMPLETED",
}
if err := store.CreateExecution(exec); err != nil {
t.Fatal(err)
}
- // processResult should parse changestats from the stdout log and store them.
+ // Pool stores changestats after execution; simulate by calling UpdateExecutionChangestats directly.
+ cs := &task.Changestats{FilesChanged: 3, LinesAdded: 50, LinesRemoved: 10}
+ if err := store.UpdateExecutionChangestats(exec.ID, cs); err != nil {
+ t.Fatal(err)
+ }
+
+ // processResult broadcasts but does NOT parse changestats (that's the pool's job).
result := &executor.Result{
TaskID: tk.ID,
Execution: exec,
@@ -1782,3 +1848,299 @@ func TestDeploymentStatus_NotFound(t *testing.T) {
t.Fatalf("want 404, got %d", w.Code)
}
}
+
+// TestListTasks_ReadyTask_IncludesDeploymentStatus verifies that GET /api/tasks
+// returns a deployment_status field for READY tasks containing deployed_commit,
+// fix_commits, and includes_fix.
+func TestListTasks_ReadyTask_IncludesDeploymentStatus(t *testing.T) {
+ srv, store := testServer(t)
+
+ tk := createTaskWithState(t, store, "enrich-list-ready-1", task.StateReady)
+ exec := &storage.Execution{
+ ID: "enrich-list-exec-1",
+ TaskID: tk.ID,
+ StartTime: time.Now(),
+ EndTime: time.Now(),
+ Status: "COMPLETED",
+ Commits: []task.GitCommit{{Hash: "aabbcc", Message: "fix: list test"}},
+ }
+ if err := store.CreateExecution(exec); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/tasks", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d; body: %s", w.Code, w.Body.String())
+ }
+
+ var tasks []map[string]interface{}
+ if err := json.NewDecoder(w.Body).Decode(&tasks); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+
+ var found map[string]interface{}
+ for _, tsk := range tasks {
+ if tsk["id"] == tk.ID {
+ found = tsk
+ break
+ }
+ }
+ if found == nil {
+ t.Fatalf("task %q not found in list response", tk.ID)
+ }
+
+ ds, ok := found["deployment_status"].(map[string]interface{})
+ if !ok {
+ t.Fatalf("READY task missing deployment_status field; got: %v", found["deployment_status"])
+ }
+ if _, ok := ds["deployed_commit"]; !ok {
+ t.Error("deployment_status missing deployed_commit")
+ }
+ if _, ok := ds["includes_fix"]; !ok {
+ t.Error("deployment_status missing includes_fix")
+ }
+}
+
+// TestGetTask_ReadyTask_IncludesDeploymentStatus verifies that GET /api/tasks/{id}
+// returns a deployment_status field for a READY task.
+func TestGetTask_ReadyTask_IncludesDeploymentStatus(t *testing.T) {
+ srv, store := testServer(t)
+
+ tk := createTaskWithState(t, store, "enrich-get-ready-1", task.StateReady)
+ exec := &storage.Execution{
+ ID: "enrich-get-exec-1",
+ TaskID: tk.ID,
+ StartTime: time.Now(),
+ EndTime: time.Now(),
+ Status: "COMPLETED",
+ Commits: []task.GitCommit{{Hash: "ddeeff", Message: "fix: get test"}},
+ }
+ if err := store.CreateExecution(exec); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/tasks/"+tk.ID, nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d", w.Code)
+ }
+
+ var resp map[string]interface{}
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+
+ ds, ok := resp["deployment_status"].(map[string]interface{})
+ if !ok {
+ t.Fatalf("READY task GET response missing deployment_status; got: %v", resp["deployment_status"])
+ }
+ if _, ok := ds["deployed_commit"]; !ok {
+ t.Error("deployment_status missing deployed_commit")
+ }
+ if _, ok := ds["includes_fix"]; !ok {
+ t.Error("deployment_status missing includes_fix")
+ }
+}
+
+// TestListTasks_NonReadyTask_OmitsDeploymentStatus verifies that non-READY tasks
+// (e.g. PENDING) do not include a deployment_status field.
+func TestListTasks_NonReadyTask_OmitsDeploymentStatus(t *testing.T) {
+ srv, store := testServer(t)
+
+ tk := createTaskWithState(t, store, "enrich-list-pending-1", task.StatePending)
+
+ req := httptest.NewRequest("GET", "/api/tasks", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d", w.Code)
+ }
+
+ var tasks []map[string]interface{}
+ if err := json.NewDecoder(w.Body).Decode(&tasks); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+
+ var found map[string]interface{}
+ for _, tsk := range tasks {
+ if tsk["id"] == tk.ID {
+ found = tsk
+ break
+ }
+ }
+ if found == nil {
+ t.Fatalf("task %q not found in list", tk.ID)
+ }
+
+ if _, ok := found["deployment_status"]; ok {
+ t.Error("PENDING task should not include deployment_status field")
+ }
+}
+
+func TestProjects_CRUD(t *testing.T) {
+ srv, _ := testServer(t)
+
+ // Create
+ body := `{"name":"testproj","local_path":"/workspace/testproj","type":"web"}`
+ req := httptest.NewRequest("POST", "/api/projects", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusCreated {
+ t.Fatalf("POST /api/projects: want 201, got %d; body: %s", w.Code, w.Body.String())
+ }
+ var created map[string]interface{}
+ json.NewDecoder(w.Body).Decode(&created)
+ id, _ := created["id"].(string)
+ if id == "" {
+ t.Fatal("created project has no id")
+ }
+
+ // Get
+ req = httptest.NewRequest("GET", "/api/projects/"+id, nil)
+ w = httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("GET /api/projects/%s: want 200, got %d", id, w.Code)
+ }
+
+ // List
+ req = httptest.NewRequest("GET", "/api/projects", nil)
+ w = httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("GET /api/projects: want 200, got %d", w.Code)
+ }
+ var list []interface{}
+ json.NewDecoder(w.Body).Decode(&list)
+ if len(list) == 0 {
+ t.Error("expected at least one project in list")
+ }
+}
+
+func TestHandleRunTask_CascadesRetryToFailedDeps(t *testing.T) {
+ // Use a blocking runner so tasks stay QUEUED long enough to assert state.
+ block := make(chan struct{})
+ t.Cleanup(func() { close(block) })
+ srv, store := testServerWithRunner(t, &mockRunner{onRun: func(*task.Task, *storage.Execution) error {
+ <-block
+ return nil
+ }})
+
+ now := time.Now().UTC()
+
+ // Task A: the dependency, in FAILED state.
+ taskA := &task.Task{
+ ID: "cascade-dep-a",
+ Name: "Dep A",
+ State: task.StateFailed,
+ DependsOn: []string{},
+ Priority: task.PriorityNormal,
+ Tags: []string{},
+ Agent: task.AgentConfig{Type: "claude", Instructions: "do A"},
+ Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "exponential"},
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := store.CreateTask(taskA); err != nil {
+ t.Fatalf("CreateTask A: %v", err)
+ }
+
+ // Task B: depends on A, in CANCELLED state (was cancelled because A failed).
+ taskB := &task.Task{
+ ID: "cascade-task-b",
+ Name: "Task B",
+ State: task.StateCancelled,
+ DependsOn: []string{taskA.ID},
+ Priority: task.PriorityNormal,
+ Tags: []string{},
+ Agent: task.AgentConfig{Type: "claude", Instructions: "do B"},
+ Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "exponential"},
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := store.CreateTask(taskB); err != nil {
+ t.Fatalf("CreateTask B: %v", err)
+ }
+
+ // Run task B — should cascade-retry dep A.
+ req := httptest.NewRequest("POST", "/api/tasks/cascade-task-b/run", nil)
+ w := httptest.NewRecorder()
+ srv.mux.ServeHTTP(w, req)
+
+ if w.Code != http.StatusAccepted {
+ t.Fatalf("expected 202, got %d: %s", w.Code, w.Body.String())
+ }
+
+ // Dep A should now be QUEUED.
+ a, err := store.GetTask(taskA.ID)
+ if err != nil {
+ t.Fatalf("GetTask A: %v", err)
+ }
+ if a.State != task.StateQueued {
+ t.Errorf("dep A: want QUEUED after cascade, got %s", a.State)
+ }
+
+ // Task B itself should be QUEUED.
+ b, err := store.GetTask(taskB.ID)
+ if err != nil {
+ t.Fatalf("GetTask B: %v", err)
+ }
+ if b.State != task.StateQueued {
+ t.Errorf("task B: want QUEUED, got %s", b.State)
+ }
+}
+
+func TestShipStory_ShippableStory_Returns202(t *testing.T) {
+ srv, store := testServer(t)
+
+ proj := &task.Project{
+ ID: "ship-proj-1", Name: "test", RemoteURL: "https://github.com/x/y",
+ Type: "web", DeployScript: "",
+ }
+ if err := store.CreateProject(proj); err != nil {
+ t.Fatalf("CreateProject: %v", err)
+ }
+
+ story := &task.Story{
+ ID: "ship-story-1", Name: "Ship Test", ProjectID: "ship-proj-1",
+ Status: task.StoryShippable, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(),
+ }
+ if err := store.CreateStory(story); err != nil {
+ t.Fatalf("CreateStory: %v", err)
+ }
+
+ req := httptest.NewRequest("POST", "/api/stories/ship-story-1/ship", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusAccepted {
+ t.Errorf("expected 202, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestShipStory_NonShippable_Returns409(t *testing.T) {
+ srv, store := testServer(t)
+
+ story := &task.Story{
+ ID: "nonship-1", Name: "Not Ready", ProjectID: "",
+ Status: task.StoryInProgress, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(),
+ }
+ if err := store.CreateStory(story); err != nil {
+ t.Fatalf("CreateStory: %v", err)
+ }
+
+ req := httptest.NewRequest("POST", "/api/stories/nonship-1/ship", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusConflict {
+ t.Errorf("expected 409, got %d", w.Code)
+ }
+}
diff --git a/internal/api/stories.go b/internal/api/stories.go
new file mode 100644
index 0000000..fa10ccd
--- /dev/null
+++ b/internal/api/stories.go
@@ -0,0 +1,378 @@
+package api
+
+import (
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "os/exec"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/thepeterstone/claudomator/internal/deployment"
+ "github.com/thepeterstone/claudomator/internal/task"
+)
+
+// createStoryBranch creates a new git branch in localPath from the latest main
+// and pushes it to remoteURL (the bare repo). Idempotent: treats "already exists" as success.
+func createStoryBranch(localPath, branchName, remoteURL string) error {
+ // Fetch latest from the bare repo so we have an up-to-date base.
+ if out, err := exec.Command("git", "-C", localPath, "fetch", remoteURL, "main").CombinedOutput(); err != nil {
+ return fmt.Errorf("git fetch: %w (output: %s)", err, string(out))
+ }
+ base := "FETCH_HEAD"
+ out, err := exec.Command("git", "-C", localPath, "checkout", "-b", branchName, base).CombinedOutput()
+ if err != nil {
+ if !strings.Contains(string(out), "already exists") {
+ return fmt.Errorf("git checkout -b: %w (output: %s)", err, string(out))
+ }
+ }
+ if out, err := exec.Command("git", "-C", localPath, "push", remoteURL, branchName).CombinedOutput(); err != nil {
+ return fmt.Errorf("git push: %w (output: %s)", err, string(out))
+ }
+ return nil
+}
+
+func (s *Server) handleListStories(w http.ResponseWriter, r *http.Request) {
+ stories, err := s.store.ListStories()
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ if stories == nil {
+ stories = []*task.Story{}
+ }
+ writeJSON(w, http.StatusOK, stories)
+}
+
+func (s *Server) handleCreateStory(w http.ResponseWriter, r *http.Request) {
+ var st task.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 st.Status == "" {
+ st.Status = task.StoryPending
+ }
+ 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)
+}
+
+func (s *Server) handleListStoryTasks(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
+ }
+ tasks, err := s.store.ListTasksByStory(id)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ if tasks == nil {
+ tasks = []*task.Task{}
+ }
+ writeJSON(w, http.StatusOK, tasks)
+}
+
+func (s *Server) handleAddTaskToStory(w http.ResponseWriter, r *http.Request) {
+ storyID := r.PathValue("id")
+ st, err := s.store.GetStory(storyID)
+ if err != nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "story not found"})
+ return
+ }
+ _ = st
+
+ var input struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Project string `json:"project"`
+ RepositoryURL string `json:"repository_url"`
+ Agent task.AgentConfig `json:"agent"`
+ Claude task.AgentConfig `json:"claude"`
+ Timeout string `json:"timeout"`
+ Priority string `json:"priority"`
+ Tags []string `json:"tags"`
+ ParentTaskID string `json:"parent_task_id"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
+ return
+ }
+ if input.Agent.Instructions == "" && input.Claude.Instructions != "" {
+ input.Agent = input.Claude
+ }
+
+ existing, err := s.store.ListTasksByStory(storyID)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+
+ now := time.Now().UTC()
+ t := &task.Task{
+ ID: uuid.New().String(),
+ Name: input.Name,
+ Description: input.Description,
+ Project: input.Project,
+ RepositoryURL: input.RepositoryURL,
+ Agent: input.Agent,
+ Priority: task.Priority(input.Priority),
+ Tags: input.Tags,
+ DependsOn: []string{},
+ Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "exponential"},
+ State: task.StatePending,
+ StoryID: storyID,
+ ParentTaskID: input.ParentTaskID,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+
+ if t.Agent.Type == "" {
+ t.Agent.Type = "claude"
+ }
+ if t.Priority == "" {
+ t.Priority = task.PriorityNormal
+ }
+ if t.Tags == nil {
+ t.Tags = []string{}
+ }
+ if input.Timeout != "" {
+ dur, err := time.ParseDuration(input.Timeout)
+ if err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid timeout: " + err.Error()})
+ return
+ }
+ t.Timeout.Duration = dur
+ }
+
+ // Auto-wire depends_on: new task depends on the last existing task (sorted ASC by created_at).
+ if len(existing) > 0 {
+ lastTask := existing[len(existing)-1]
+ t.DependsOn = []string{lastTask.ID}
+ }
+
+ if err := s.store.CreateTask(t); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ writeJSON(w, http.StatusCreated, t)
+}
+
+func (s *Server) handleUpdateStoryStatus(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
+ }
+
+ var input struct {
+ Status task.StoryState `json:"status"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
+ return
+ }
+ if !task.ValidStoryTransition(st.Status, input.Status) {
+ writeJSON(w, http.StatusConflict, map[string]string{
+ "error": "invalid story status transition from " + string(st.Status) + " to " + string(input.Status),
+ })
+ return
+ }
+ if err := s.store.UpdateStoryStatus(id, input.Status); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"message": "story status updated", "story_id": id, "status": string(input.Status)})
+}
+
+func (s *Server) handleApproveStory(w http.ResponseWriter, r *http.Request) {
+ var input struct {
+ Name string `json:"name"`
+ BranchName string `json:"branch_name"`
+ ProjectID string `json:"project_id"`
+ Tasks []elaboratedStoryTask `json:"tasks"`
+ Validation elaboratedStoryValidation `json:"validation"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
+ return
+ }
+ if input.Name == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
+ return
+ }
+
+ validationJSON, _ := json.Marshal(input.Validation)
+ now := time.Now().UTC()
+ story := &task.Story{
+ ID: uuid.New().String(),
+ Name: input.Name,
+ ProjectID: input.ProjectID,
+ BranchName: input.BranchName,
+ ValidationJSON: string(validationJSON),
+ Status: task.StoryPending,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := s.store.CreateStory(story); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+
+ var repoURL string
+ if input.ProjectID != "" {
+ if proj, err := s.store.GetProject(input.ProjectID); err == nil {
+ repoURL = proj.RemoteURL
+ }
+ }
+
+ taskIDs := make([]string, 0, len(input.Tasks))
+ var prevTaskID string
+ for _, tp := range input.Tasks {
+ t := &task.Task{
+ ID: uuid.New().String(),
+ Name: tp.Name,
+ Project: input.ProjectID,
+ RepositoryURL: repoURL,
+ StoryID: story.ID,
+ Agent: task.AgentConfig{Type: "claude", Instructions: tp.Instructions},
+ AcceptanceCriteria: tp.AcceptanceCriteria,
+ Priority: task.PriorityNormal,
+ Tags: []string{},
+ DependsOn: []string{},
+ Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "exponential"},
+ State: task.StatePending,
+ CreatedAt: time.Now().UTC(),
+ UpdatedAt: time.Now().UTC(),
+ }
+ if prevTaskID != "" {
+ t.DependsOn = []string{prevTaskID}
+ }
+ if err := s.store.CreateTask(t); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ taskIDs = append(taskIDs, t.ID)
+
+ var prevSubtaskID string
+ for _, sub := range tp.Subtasks {
+ st := &task.Task{
+ ID: uuid.New().String(),
+ Name: sub.Name,
+ Project: input.ProjectID,
+ RepositoryURL: repoURL,
+ StoryID: story.ID,
+ ParentTaskID: t.ID,
+ Agent: task.AgentConfig{Type: "claude", Instructions: sub.Instructions},
+ Priority: task.PriorityNormal,
+ Tags: []string{},
+ DependsOn: []string{},
+ Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "exponential"},
+ State: task.StatePending,
+ CreatedAt: time.Now().UTC(),
+ UpdatedAt: time.Now().UTC(),
+ }
+ if prevSubtaskID != "" {
+ st.DependsOn = []string{prevSubtaskID}
+ }
+ if err := s.store.CreateTask(st); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ prevSubtaskID = st.ID
+ }
+ prevTaskID = t.ID
+ }
+
+ // Create the story branch (non-fatal if it fails).
+ if input.BranchName != "" && input.ProjectID != "" {
+ if proj, err := s.store.GetProject(input.ProjectID); err == nil && proj.LocalPath != "" && proj.RemoteURL != "" {
+ if err := createStoryBranch(proj.LocalPath, input.BranchName, proj.RemoteURL); err != nil {
+ s.logger.Warn("story approve: failed to create branch", "error", err, "branch", input.BranchName)
+ }
+ }
+ }
+
+ writeJSON(w, http.StatusCreated, map[string]interface{}{
+ "story": story,
+ "task_ids": taskIDs,
+ })
+}
+
+// handleShipStory triggers the merge + deploy for a SHIPPABLE story.
+// POST /api/stories/{id}/ship
+func (s *Server) handleShipStory(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ if err := s.pool.ShipStory(r.Context(), id); err != nil {
+ writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
+ return
+ }
+ writeJSON(w, http.StatusAccepted, map[string]string{"message": "story shipping initiated", "story_id": id})
+}
+
+// handleStoryDeploymentStatus aggregates the deployment status across all tasks in a story.
+// GET /api/stories/{id}/deployment-status
+func (s *Server) handleStoryDeploymentStatus(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+
+ story, err := s.store.GetStory(id)
+ if err != nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "story not found"})
+ return
+ }
+
+ tasks, err := s.store.ListTasksByStory(id)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+
+ // Collect all commits from the latest execution of each task.
+ var allCommits []task.GitCommit
+ for _, t := range tasks {
+ exec, err := s.store.GetLatestExecution(t.ID)
+ if err != nil {
+ if err == sql.ErrNoRows {
+ continue
+ }
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ allCommits = append(allCommits, exec.Commits...)
+ }
+
+ // Determine project remote URL for the deployment check.
+ projectRemoteURL := ""
+ if story.ProjectID != "" {
+ if proj, err := s.store.GetProject(story.ProjectID); err == nil {
+ projectRemoteURL = proj.RemoteURL
+ }
+ }
+
+ status := deployment.Check(allCommits, projectRemoteURL)
+ writeJSON(w, http.StatusOK, status)
+}
diff --git a/internal/api/stories_test.go b/internal/api/stories_test.go
new file mode 100644
index 0000000..f43ad86
--- /dev/null
+++ b/internal/api/stories_test.go
@@ -0,0 +1,351 @@
+package api
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/thepeterstone/claudomator/internal/deployment"
+ "github.com/thepeterstone/claudomator/internal/task"
+)
+
+func TestCreateStory_API(t *testing.T) {
+ srv, _ := testServer(t)
+
+ body := `{"name":"My Story","project_id":"proj-1"}`
+ req := httptest.NewRequest("POST", "/api/stories", bytes.NewBufferString(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ srv.mux.ServeHTTP(w, req)
+
+ if w.Code != http.StatusCreated {
+ t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
+ }
+ var st task.Story
+ if err := json.NewDecoder(w.Body).Decode(&st); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if st.Name != "My Story" {
+ t.Errorf("Name: want 'My Story', got %q", st.Name)
+ }
+ if st.ID == "" {
+ t.Error("ID should be auto-generated")
+ }
+ if st.Status != task.StoryPending {
+ t.Errorf("Status: want PENDING, got %q", st.Status)
+ }
+}
+
+func TestGetStory_API(t *testing.T) {
+ srv, _ := testServer(t)
+
+ // Create a story first.
+ body := `{"name":"Get Me"}`
+ req := httptest.NewRequest("POST", "/api/stories", bytes.NewBufferString(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ srv.mux.ServeHTTP(w, req)
+ if w.Code != http.StatusCreated {
+ t.Fatalf("create story: expected 201, got %d", w.Code)
+ }
+ var created task.Story
+ json.NewDecoder(w.Body).Decode(&created)
+
+ // Fetch it.
+ req2 := httptest.NewRequest("GET", "/api/stories/"+created.ID, nil)
+ w2 := httptest.NewRecorder()
+ srv.mux.ServeHTTP(w2, req2)
+
+ if w2.Code != http.StatusOK {
+ t.Fatalf("get story: expected 200, got %d: %s", w2.Code, w2.Body.String())
+ }
+ var got task.Story
+ if err := json.NewDecoder(w2.Body).Decode(&got); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if got.ID != created.ID {
+ t.Errorf("ID: want %q, got %q", created.ID, got.ID)
+ }
+ if got.Name != "Get Me" {
+ t.Errorf("Name: want 'Get Me', got %q", got.Name)
+ }
+}
+
+func TestAddTaskToStory_AutoWiresDependsOn(t *testing.T) {
+ srv, _ := testServer(t)
+
+ // Create a story.
+ storyBody := `{"name":"Story For Tasks"}`
+ req := httptest.NewRequest("POST", "/api/stories", bytes.NewBufferString(storyBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ srv.mux.ServeHTTP(w, req)
+ if w.Code != http.StatusCreated {
+ t.Fatalf("create story: %d %s", w.Code, w.Body.String())
+ }
+ var story task.Story
+ json.NewDecoder(w.Body).Decode(&story)
+
+ addTask := func(name string) *task.Task {
+ body := `{"name":"` + name + `","agent":{"type":"claude","instructions":"do it"}}`
+ r := httptest.NewRequest("POST", "/api/stories/"+story.ID+"/tasks", bytes.NewBufferString(body))
+ r.Header.Set("Content-Type", "application/json")
+ wr := httptest.NewRecorder()
+ srv.mux.ServeHTTP(wr, r)
+ if wr.Code != http.StatusCreated {
+ t.Fatalf("add task %s: expected 201, got %d: %s", name, wr.Code, wr.Body.String())
+ }
+ var tk task.Task
+ json.NewDecoder(wr.Body).Decode(&tk)
+ return &tk
+ }
+
+ task1 := addTask("Task 1")
+ task2 := addTask("Task 2")
+ task3 := addTask("Task 3")
+
+ // task1 has no dependencies.
+ if len(task1.DependsOn) != 0 {
+ t.Errorf("task1.DependsOn: want [], got %v", task1.DependsOn)
+ }
+ // task2 depends on task1.
+ if len(task2.DependsOn) != 1 || task2.DependsOn[0] != task1.ID {
+ t.Errorf("task2.DependsOn: want [%s], got %v", task1.ID, task2.DependsOn)
+ }
+ // task3 depends on task2.
+ if len(task3.DependsOn) != 1 || task3.DependsOn[0] != task2.ID {
+ t.Errorf("task3.DependsOn: want [%s], got %v", task2.ID, task3.DependsOn)
+ }
+}
+
+func TestBuildStoryElaboratePrompt(t *testing.T) {
+ prompt := buildStoryElaboratePrompt()
+ checks := []struct {
+ label string
+ want string
+ }{
+ {"schema: name field", `"name"`},
+ {"schema: branch_name field", `"branch_name"`},
+ {"schema: tasks field", `"tasks"`},
+ {"schema: validation field", `"validation"`},
+ {"rule: git push", "git push origin"},
+ {"rule: sequential subtasks", "sequentially"},
+ {"rule: specific file paths", "file paths"},
+ }
+ for _, c := range checks {
+ if !strings.Contains(prompt, c.want) {
+ t.Errorf("%s: prompt should contain %q", c.label, c.want)
+ }
+ }
+}
+
+func TestHandleStoryApprove_WiresDepends(t *testing.T) {
+ srv, _ := testServer(t)
+
+ body := `{
+ "name": "My Story",
+ "branch_name": "story/my-story",
+ "tasks": [
+ {"name": "Task 1", "instructions": "do task 1", "subtasks": []},
+ {"name": "Task 2", "instructions": "do task 2", "subtasks": []},
+ {"name": "Task 3", "instructions": "do task 3", "subtasks": []}
+ ],
+ "validation": {"type": "build", "steps": ["go build ./..."], "success_criteria": "compiles"}
+ }`
+ req := httptest.NewRequest("POST", "/api/stories/approve", bytes.NewBufferString(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ srv.mux.ServeHTTP(w, req)
+
+ if w.Code != http.StatusCreated {
+ t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var resp struct {
+ Story task.Story `json:"story"`
+ TaskIDs []string `json:"task_ids"`
+ }
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if len(resp.TaskIDs) != 3 {
+ t.Fatalf("expected 3 task IDs, got %d", len(resp.TaskIDs))
+ }
+ if resp.Story.Name != "My Story" {
+ t.Errorf("story name: want 'My Story', got %q", resp.Story.Name)
+ }
+
+ // Verify depends_on chain via the store.
+ store := srv.store
+ task1, err := store.GetTask(resp.TaskIDs[0])
+ if err != nil {
+ t.Fatalf("GetTask[0]: %v", err)
+ }
+ task2, err := store.GetTask(resp.TaskIDs[1])
+ if err != nil {
+ t.Fatalf("GetTask[1]: %v", err)
+ }
+ task3, err := store.GetTask(resp.TaskIDs[2])
+ if err != nil {
+ t.Fatalf("GetTask[2]: %v", err)
+ }
+
+ if len(task1.DependsOn) != 0 {
+ t.Errorf("task1.DependsOn: want [], got %v", task1.DependsOn)
+ }
+ if len(task2.DependsOn) != 1 || task2.DependsOn[0] != task1.ID {
+ t.Errorf("task2.DependsOn: want [%s], got %v", task1.ID, task2.DependsOn)
+ }
+ if len(task3.DependsOn) != 1 || task3.DependsOn[0] != task2.ID {
+ t.Errorf("task3.DependsOn: want [%s], got %v", task2.ID, task3.DependsOn)
+ }
+}
+
+func TestHandleStoryApprove_SetsRepositoryURL(t *testing.T) {
+ srv, store := testServer(t)
+
+ proj := &task.Project{
+ ID: "proj-repo",
+ Name: "claudomator",
+ RemoteURL: "/site/git.terst.org/repos/claudomator.git",
+ // LocalPath intentionally empty: branch creation is a non-fatal side effect,
+ // omitting it keeps the test fast and free of real git operations.
+ }
+ if err := store.CreateProject(proj); err != nil {
+ t.Fatalf("CreateProject: %v", err)
+ }
+
+ body := `{
+ "name": "Repo URL Story",
+ "branch_name": "story/repo-url",
+ "project_id": "proj-repo",
+ "tasks": [
+ {"name": "Task A", "instructions": "do A", "subtasks": []},
+ {"name": "Task B", "instructions": "do B", "subtasks": [
+ {"name": "Sub B1", "instructions": "do B1"}
+ ]}
+ ],
+ "validation": {"type": "build", "steps": ["go build ./..."], "success_criteria": "ok"}
+ }`
+ req := httptest.NewRequest("POST", "/api/stories/approve", bytes.NewBufferString(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ srv.mux.ServeHTTP(w, req)
+
+ if w.Code != http.StatusCreated {
+ t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var resp struct {
+ TaskIDs []string `json:"task_ids"`
+ }
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+
+ for _, id := range resp.TaskIDs {
+ tk, err := store.GetTask(id)
+ if err != nil {
+ t.Fatalf("GetTask %s: %v", id, err)
+ }
+ if tk.RepositoryURL != proj.RemoteURL {
+ t.Errorf("task %s RepositoryURL: want %q, got %q", tk.ID, proj.RemoteURL, tk.RepositoryURL)
+ }
+ }
+}
+
+func TestApproveStory_AcceptanceCriteriaStored(t *testing.T) {
+ srv, store := testServer(t)
+
+ proj := &task.Project{
+ ID: "ac-proj", Name: "test", RemoteURL: "https://github.com/x/y",
+ Type: "web", DeployScript: "",
+ }
+ store.CreateProject(proj)
+
+ body := `{
+ "name": "AC Story",
+ "branch_name": "story/ac-test",
+ "project_id": "ac-proj",
+ "tasks": [
+ {
+ "name": "Add feature",
+ "instructions": "implement the thing",
+ "acceptance_criteria": "run go test ./... and verify all pass",
+ "subtasks": []
+ }
+ ],
+ "validation": {"type": "test", "steps": [], "success_criteria": "tests pass"}
+ }`
+ req := httptest.NewRequest("POST", "/api/stories/approve", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ srv.mux.ServeHTTP(w, req)
+
+ if w.Code != http.StatusCreated {
+ t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var resp struct {
+ TaskIDs []string `json:"task_ids"`
+ }
+ json.NewDecoder(w.Body).Decode(&resp)
+ if len(resp.TaskIDs) == 0 {
+ t.Fatal("expected task_ids in response")
+ }
+
+ tk, err := store.GetTask(resp.TaskIDs[0])
+ if err != nil {
+ t.Fatalf("GetTask: %v", err)
+ }
+ if tk.AcceptanceCriteria != "run go test ./... and verify all pass" {
+ t.Errorf("expected acceptance criteria stored on task, got %q", tk.AcceptanceCriteria)
+ }
+}
+
+func TestHandleStoryDeploymentStatus(t *testing.T) {
+ srv, store := testServer(t)
+
+ // Create a story.
+ now := time.Now().UTC()
+ story := &task.Story{
+ ID: "deploy-story-1",
+ Name: "Deploy Status Story",
+ Status: task.StoryInProgress,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := store.CreateStory(story); err != nil {
+ t.Fatalf("CreateStory: %v", err)
+ }
+
+ // Request deployment status — no tasks yet.
+ req := httptest.NewRequest("GET", "/api/stories/deploy-story-1/deployment-status", nil)
+ w := httptest.NewRecorder()
+ srv.mux.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var status deployment.Status
+ if err := json.NewDecoder(w.Body).Decode(&status); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ // No tasks → no commits → IncludesFix = false (nothing to check).
+ if status.IncludesFix {
+ t.Error("expected IncludesFix=false when no commits")
+ }
+
+ // 404 for unknown story.
+ req2 := httptest.NewRequest("GET", "/api/stories/nonexistent/deployment-status", nil)
+ w2 := httptest.NewRecorder()
+ srv.mux.ServeHTTP(w2, req2)
+ if w2.Code != http.StatusNotFound {
+ t.Errorf("expected 404 for unknown story, got %d", w2.Code)
+ }
+}
diff --git a/internal/api/task_view.go b/internal/api/task_view.go
new file mode 100644
index 0000000..6a4b58e
--- /dev/null
+++ b/internal/api/task_view.go
@@ -0,0 +1,47 @@
+package api
+
+import (
+ "database/sql"
+
+ "github.com/thepeterstone/claudomator/internal/deployment"
+ "github.com/thepeterstone/claudomator/internal/task"
+)
+
+// taskView wraps a task with computed fields that are derived from execution
+// history and deployment state. It is used as the JSON response type for task
+// list and get endpoints so that callers receive enriched data in one request.
+type taskView struct {
+ *task.Task
+ Changestats *task.Changestats `json:"changestats,omitempty"`
+ DeploymentStatus *deployment.Status `json:"deployment_status,omitempty"`
+ ErrorMsg string `json:"error_msg,omitempty"`
+}
+
+var failedStates = map[task.State]bool{
+ task.StateFailed: true,
+ task.StateBudgetExceeded: true,
+ task.StateTimedOut: true,
+}
+
+// enrichTask fetches the latest execution for the given task and attaches
+// changestats, deployment_status, and error_msg fields.
+func (s *Server) enrichTask(tk *task.Task) *taskView {
+ view := &taskView{Task: tk}
+
+ exec, err := s.store.GetLatestExecution(tk.ID)
+ if err != nil {
+ if err == sql.ErrNoRows && tk.State == task.StateReady {
+ view.DeploymentStatus = deployment.Check(nil, tk.RepositoryURL)
+ }
+ return view
+ }
+
+ if failedStates[tk.State] && exec.ErrorMsg != "" {
+ view.ErrorMsg = exec.ErrorMsg
+ }
+ if tk.State == task.StateReady {
+ view.Changestats = exec.Changestats
+ view.DeploymentStatus = deployment.Check(exec.Commits, tk.RepositoryURL)
+ }
+ return view
+}
diff --git a/internal/api/webhook.go b/internal/api/webhook.go
index 9437f7d..3af4cc8 100644
--- a/internal/api/webhook.go
+++ b/internal/api/webhook.go
@@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"io"
+ "log/slog"
"net/http"
"path/filepath"
"strings"
@@ -18,17 +19,26 @@ import (
"github.com/thepeterstone/claudomator/internal/task"
)
+// prRef is a minimal pull request entry used to extract branch names.
+type prRef struct {
+ Head struct {
+ Ref string `json:"ref"`
+ } `json:"head"`
+}
+
// checkRunPayload is the GitHub check_run webhook payload.
type checkRunPayload struct {
Action string `json:"action"`
CheckRun struct {
- Name string `json:"name"`
- Conclusion string `json:"conclusion"`
- HTMLURL string `json:"html_url"`
- HeadSHA string `json:"head_sha"`
- CheckSuite struct {
+ Name string `json:"name"`
+ Conclusion string `json:"conclusion"`
+ HTMLURL string `json:"html_url"`
+ DetailsURL string `json:"details_url"`
+ HeadSHA string `json:"head_sha"`
+ CheckSuite struct {
HeadBranch string `json:"head_branch"`
} `json:"check_suite"`
+ PullRequests []prRef `json:"pull_requests"`
} `json:"check_run"`
Repository struct {
Name string `json:"name"`
@@ -40,11 +50,12 @@ type checkRunPayload struct {
type workflowRunPayload struct {
Action string `json:"action"`
WorkflowRun struct {
- Name string `json:"name"`
- Conclusion string `json:"conclusion"`
- HTMLURL string `json:"html_url"`
- HeadSHA string `json:"head_sha"`
- HeadBranch string `json:"head_branch"`
+ Name string `json:"name"`
+ Conclusion string `json:"conclusion"`
+ HTMLURL string `json:"html_url"`
+ HeadSHA string `json:"head_sha"`
+ HeadBranch string `json:"head_branch"`
+ PullRequests []prRef `json:"pull_requests"`
} `json:"workflow_run"`
Repository struct {
Name string `json:"name"`
@@ -98,6 +109,7 @@ func (s *Server) handleGitHubWebhook(w http.ResponseWriter, r *http.Request) {
}
eventType := r.Header.Get("X-GitHub-Event")
+ slog.Info("github webhook received", "event", eventType, "bytes", len(body))
switch eventType {
case "check_run":
s.handleCheckRunEvent(w, body)
@@ -118,13 +130,22 @@ func (s *Server) handleCheckRunEvent(w http.ResponseWriter, body []byte) {
w.WriteHeader(http.StatusNoContent)
return
}
+ branch := p.CheckRun.CheckSuite.HeadBranch
+ if branch == "" && len(p.CheckRun.PullRequests) > 0 {
+ branch = p.CheckRun.PullRequests[0].Head.Ref
+ }
+ htmlURL := p.CheckRun.HTMLURL
+ if htmlURL == "" {
+ htmlURL = p.CheckRun.DetailsURL
+ }
+ slog.Info("check_run webhook", "repo", p.Repository.FullName, "conclusion", p.CheckRun.Conclusion, "branch", branch, "html_url", htmlURL)
s.createCIFailureTask(w,
p.Repository.Name,
p.Repository.FullName,
- p.CheckRun.CheckSuite.HeadBranch,
+ branch,
p.CheckRun.HeadSHA,
p.CheckRun.Name,
- p.CheckRun.HTMLURL,
+ htmlURL,
)
}
@@ -142,10 +163,15 @@ func (s *Server) handleWorkflowRunEvent(w http.ResponseWriter, body []byte) {
w.WriteHeader(http.StatusNoContent)
return
}
+ branch := p.WorkflowRun.HeadBranch
+ if branch == "" && len(p.WorkflowRun.PullRequests) > 0 {
+ branch = p.WorkflowRun.PullRequests[0].Head.Ref
+ }
+ slog.Info("workflow_run webhook", "repo", p.Repository.FullName, "conclusion", p.WorkflowRun.Conclusion, "branch", branch, "html_url", p.WorkflowRun.HTMLURL)
s.createCIFailureTask(w,
p.Repository.Name,
p.Repository.FullName,
- p.WorkflowRun.HeadBranch,
+ branch,
p.WorkflowRun.HeadSHA,
p.WorkflowRun.Name,
p.WorkflowRun.HTMLURL,
@@ -155,6 +181,10 @@ func (s *Server) handleWorkflowRunEvent(w http.ResponseWriter, body []byte) {
func (s *Server) createCIFailureTask(w http.ResponseWriter, repoName, fullName, branch, sha, checkName, htmlURL string) {
project := matchProject(s.projects, repoName)
+ if htmlURL == "" && fullName != "" && sha != "" {
+ htmlURL = fmt.Sprintf("https://github.com/%s/commit/%s", fullName, sha)
+ }
+
fallback := fmt.Sprintf(
"A CI failure has been detected and requires investigation.\n\n"+
"Repository: %s\n"+
@@ -188,20 +218,22 @@ func (s *Server) createCIFailureTask(w http.ResponseWriter, repoName, fullName,
Name: fmt.Sprintf("Fix CI failure: %s on %s", checkName, branch),
Agent: task.AgentConfig{
Type: "claude",
+ Model: "sonnet",
Instructions: instructions,
MaxBudgetUSD: 3.0,
AllowedTools: []string{"Read", "Edit", "Bash", "Glob", "Grep"},
},
- Priority: task.PriorityNormal,
- Tags: []string{"ci", "auto"},
- DependsOn: []string{},
- Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "exponential"},
- State: task.StatePending,
- CreatedAt: now,
- UpdatedAt: now,
+ Priority: task.PriorityNormal,
+ Tags: []string{"ci", "auto"},
+ DependsOn: []string{},
+ Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "exponential"},
+ State: task.StatePending,
+ CreatedAt: now,
+ UpdatedAt: now,
+ RepositoryURL: fmt.Sprintf("https://github.com/%s.git", fullName),
}
if project != nil {
- t.Agent.ProjectDir = project.Dir
+ t.Project = project.Name
}
if err := s.store.CreateTask(t); err != nil {
diff --git a/internal/api/webhook_test.go b/internal/api/webhook_test.go
index 8b0599a..967b62b 100644
--- a/internal/api/webhook_test.go
+++ b/internal/api/webhook_test.go
@@ -124,8 +124,8 @@ func TestGitHubWebhook_CheckRunFailure_CreatesTask(t *testing.T) {
if !strings.Contains(tk.Name, "main") {
t.Errorf("task name %q does not contain branch", tk.Name)
}
- if tk.Agent.ProjectDir != "/workspace/myrepo" {
- t.Errorf("task project dir = %q, want /workspace/myrepo", tk.Agent.ProjectDir)
+ if tk.RepositoryURL != "https://github.com/owner/myrepo.git" {
+ t.Errorf("task repository url = %q, want https://github.com/owner/myrepo.git", tk.RepositoryURL)
}
if !contains(tk.Tags, "ci") || !contains(tk.Tags, "auto") {
t.Errorf("task tags %v missing expected ci/auto tags", tk.Tags)
@@ -237,6 +237,104 @@ func TestGitHubWebhook_NoSecretConfigured_SkipsHMACCheck(t *testing.T) {
}
}
+func TestGitHubWebhook_CreatesTask_WithDefaultModel(t *testing.T) {
+ srv, store := testServer(t)
+ srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}}
+
+ w := webhookPost(t, srv, "check_run", checkRunFailurePayload, "")
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d", w.Code)
+ }
+ var resp map[string]string
+ json.NewDecoder(w.Body).Decode(&resp)
+ tk, err := store.GetTask(resp["task_id"])
+ if err != nil {
+ t.Fatalf("task not found: %v", err)
+ }
+ if tk.Agent.Model == "" {
+ t.Error("expected model to be set, got empty string")
+ }
+}
+
+const checkRunNullBranchPayload = `{
+ "action": "completed",
+ "check_run": {
+ "name": "CI Build",
+ "conclusion": "failure",
+ "html_url": "",
+ "details_url": "https://github.com/owner/myrepo/actions/runs/999/jobs/123",
+ "head_sha": "abc123def",
+ "check_suite": {
+ "head_branch": null
+ },
+ "pull_requests": [{"head": {"ref": "feature/my-branch"}}]
+ },
+ "repository": {
+ "name": "myrepo",
+ "full_name": "owner/myrepo"
+ }
+}`
+
+func TestGitHubWebhook_CheckRunNullBranch_UsesPRRefAndDetailsURL(t *testing.T) {
+ srv, store := testServer(t)
+ srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}}
+
+ w := webhookPost(t, srv, "check_run", checkRunNullBranchPayload, "")
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d; body: %s", w.Code, w.Body.String())
+ }
+ var resp map[string]string
+ json.NewDecoder(w.Body).Decode(&resp)
+ tk, err := store.GetTask(resp["task_id"])
+ if err != nil {
+ t.Fatalf("task not found: %v", err)
+ }
+ if !strings.Contains(tk.Name, "feature/my-branch") {
+ t.Errorf("task name %q should contain PR branch", tk.Name)
+ }
+ if !strings.Contains(tk.Agent.Instructions, "actions/runs/999") {
+ t.Errorf("instructions should contain details_url fallback, got: %s", tk.Agent.Instructions)
+ }
+}
+
+const workflowRunNullBranchPayload = `{
+ "action": "completed",
+ "workflow_run": {
+ "name": "CI Pipeline",
+ "conclusion": "failure",
+ "html_url": "",
+ "head_sha": "def456abc",
+ "head_branch": null,
+ "pull_requests": [{"head": {"ref": "fix/something"}}]
+ },
+ "repository": {
+ "name": "myrepo",
+ "full_name": "owner/myrepo"
+ }
+}`
+
+func TestGitHubWebhook_WorkflowRunNullBranch_UsesPRRef(t *testing.T) {
+ srv, store := testServer(t)
+ srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}}
+
+ w := webhookPost(t, srv, "workflow_run", workflowRunNullBranchPayload, "")
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d; body: %s", w.Code, w.Body.String())
+ }
+ var resp map[string]string
+ json.NewDecoder(w.Body).Decode(&resp)
+ tk, err := store.GetTask(resp["task_id"])
+ if err != nil {
+ t.Fatalf("task not found: %v", err)
+ }
+ if !strings.Contains(tk.Name, "fix/something") {
+ t.Errorf("task name %q should contain PR branch", tk.Name)
+ }
+}
+
func TestGitHubWebhook_UnknownEvent_Returns204(t *testing.T) {
srv, _ := testServer(t)
@@ -277,14 +375,14 @@ func TestGitHubWebhook_FallbackToSingleProject(t *testing.T) {
if err != nil {
t.Fatalf("task not found: %v", err)
}
- if tk.Agent.ProjectDir != "/workspace/someapp" {
- t.Errorf("expected fallback to /workspace/someapp, got %q", tk.Agent.ProjectDir)
+ if tk.RepositoryURL != "https://github.com/owner/myrepo.git" {
+ t.Errorf("expected fallback repository url, got %q", tk.RepositoryURL)
}
}
-func TestGitHubWebhook_NoProjectsConfigured_CreatesTaskWithoutProjectDir(t *testing.T) {
+func TestGitHubWebhook_NoProjectsConfigured_CreatesTaskWithGitHubURL(t *testing.T) {
srv, store := testServer(t)
- // No projects configured — task should still be created, just no project dir set.
+ // No projects configured — task should still be created with the GitHub remote URL.
w := webhookPost(t, srv, "check_run", checkRunFailurePayload, "")
@@ -297,8 +395,8 @@ func TestGitHubWebhook_NoProjectsConfigured_CreatesTaskWithoutProjectDir(t *test
if err != nil {
t.Fatalf("task not found: %v", err)
}
- if tk.Agent.ProjectDir != "" {
- t.Errorf("expected empty project dir, got %q", tk.Agent.ProjectDir)
+ if tk.RepositoryURL == "" {
+ t.Error("expected non-empty repository_url from GitHub webhook payload")
}
}