diff options
| -rw-r--r-- | CLAUDE.md | 14 | ||||
| -rw-r--r-- | internal/api/changestats.go | 15 | ||||
| -rw-r--r-- | internal/api/server.go | 9 | ||||
| -rw-r--r-- | internal/api/server_test.go | 125 | ||||
| -rw-r--r-- | internal/executor/claude.go | 77 | ||||
| -rw-r--r-- | internal/executor/claude_test.go | 103 | ||||
| -rw-r--r-- | internal/executor/executor.go | 9 | ||||
| -rw-r--r-- | internal/executor/executor_test.go | 154 | ||||
| -rw-r--r-- | internal/executor/gemini.go | 5 | ||||
| -rw-r--r-- | internal/executor/preamble.go | 2 | ||||
| -rw-r--r-- | internal/storage/db.go | 85 | ||||
| -rw-r--r-- | internal/storage/db_test.go | 42 | ||||
| -rw-r--r-- | internal/task/changestats.go | 47 | ||||
| -rw-r--r-- | internal/task/task.go | 13 | ||||
| -rwxr-xr-x | scripts/next-task | 7 | ||||
| -rw-r--r-- | test/next-task.test.sh | 43 | ||||
| -rw-r--r-- | web/app.js | 52 | ||||
| -rw-r--r-- | web/style.css | 40 | ||||
| -rw-r--r-- | web/test/changestats.test.mjs | 125 |
19 files changed, 917 insertions, 50 deletions
@@ -105,6 +105,20 @@ Batch files wrap multiple tasks under a `tasks:` key. Two tables: `tasks` (with `config_json`, `retry_json`, `tags_json`, `depends_on_json` as JSON blobs) and `executions` (with paths to log files). Schema is auto-migrated on `storage.Open()`. +## Features + +### Changestats + +After each task execution, Claudomator extracts git diff statistics from the execution's stdout log. If the log contains a git `diff --stat` summary line (e.g. `5 files changed, 127 insertions(+), 43 deletions(-)`), the stats are parsed and stored in the `executions.changestats_json` column via `storage.DB.UpdateExecutionChangestats`. + +**Extraction points:** +- `internal/executor.Pool.handleRunResult` — calls `task.ParseChangestatFromFile(exec.StdoutPath)` after every execution; stores via `Store.UpdateExecutionChangestats`. +- `internal/api.Server.processResult` — also extracts changestats when the API server processes a result (same file, idempotent second write). + +**Parser location:** `internal/task/changestats.go` — exported functions `ParseChangestatFromOutput` and `ParseChangestatFromFile` usable by any package without creating circular imports. + +**Frontend display:** `web/app.js` renders a `.changestats-badge` on COMPLETED/READY task cards and in execution history rows. + ## ADRs See `docs/adr/001-language-and-architecture.md` for the Go + SQLite + WebSocket rationale. diff --git a/internal/api/changestats.go b/internal/api/changestats.go new file mode 100644 index 0000000..4f18f7f --- /dev/null +++ b/internal/api/changestats.go @@ -0,0 +1,15 @@ +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/server.go b/internal/api/server.go index 163f2b8..8290738 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -128,7 +128,16 @@ func (s *Server) forwardResults() { } // 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, diff --git a/internal/api/server_test.go b/internal/api/server_test.go index ec927c0..d090313 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -1433,3 +1433,128 @@ func TestRunTask_AgentCancelled_TaskSetToCancelled(t *testing.T) { t.Errorf("task state: want CANCELLED, got %v", got) } } + +// TestGetTask_IncludesChangestats verifies that after processResult parses git diff stats +// from the execution stdout log, they appear in the execution history response. +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, + } + if err := store.CreateExecution(exec); err != nil { + t.Fatal(err) + } + + // processResult should parse changestats from the stdout log and store them. + result := &executor.Result{ + TaskID: tk.ID, + Execution: exec, + } + srv.processResult(result) + + // GET the task's execution history and assert changestats are populated. + req := httptest.NewRequest("GET", "/api/tasks/"+tk.ID+"/executions", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String()) + } + + var execs []map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&execs); err != nil { + t.Fatalf("decode: %v", err) + } + if len(execs) != 1 { + t.Fatalf("want 1 execution, got %d", len(execs)) + } + + csVal, ok := execs[0]["Changestats"] + if !ok || csVal == nil { + t.Fatal("execution missing Changestats field after processResult") + } + csMap, ok := csVal.(map[string]interface{}) + if !ok { + t.Fatalf("Changestats is not an object: %T", csVal) + } + if csMap["files_changed"].(float64) != 3 { + t.Errorf("files_changed: want 3, got %v", csMap["files_changed"]) + } + if csMap["lines_added"].(float64) != 50 { + t.Errorf("lines_added: want 50, got %v", csMap["lines_added"]) + } + if csMap["lines_removed"].(float64) != 10 { + t.Errorf("lines_removed: want 10, got %v", csMap["lines_removed"]) + } +} + +// TestListExecutions_IncludesChangestats verifies that changestats stored on an execution +// are returned correctly by GET /api/tasks/{id}/executions. +func TestListExecutions_IncludesChangestats(t *testing.T) { + srv, store := testServer(t) + + tk := createTaskWithState(t, store, "cs-task-2", task.StateCompleted) + + cs := &task.Changestats{FilesChanged: 2, LinesAdded: 100, LinesRemoved: 20} + exec := &storage.Execution{ + ID: "cs-exec-2", + TaskID: tk.ID, + StartTime: time.Now().UTC(), + EndTime: time.Now().UTC().Add(time.Minute), + Status: "COMPLETED", + Changestats: cs, + } + if err := store.CreateExecution(exec); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest("GET", "/api/tasks/"+tk.ID+"/executions", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String()) + } + + var execs []map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&execs); err != nil { + t.Fatalf("decode: %v", err) + } + if len(execs) != 1 { + t.Fatalf("want 1 execution, got %d", len(execs)) + } + + csVal, ok := execs[0]["Changestats"] + if !ok || csVal == nil { + t.Fatal("execution missing Changestats field") + } + csMap, ok := csVal.(map[string]interface{}) + if !ok { + t.Fatalf("Changestats is not an object: %T", csVal) + } + if csMap["files_changed"].(float64) != 2 { + t.Errorf("files_changed: want 2, got %v", csMap["files_changed"]) + } + if csMap["lines_added"].(float64) != 100 { + t.Errorf("lines_added: want 100, got %v", csMap["lines_added"]) + } + if csMap["lines_removed"].(float64) != 20 { + t.Errorf("lines_removed: want 20, got %v", csMap["lines_removed"]) + } +} diff --git a/internal/executor/claude.go b/internal/executor/claude.go index 5a5b35e..4d92cd0 100644 --- a/internal/executor/claude.go +++ b/internal/executor/claude.go @@ -103,6 +103,7 @@ func (r *ClaudeRunner) Run(ctx context.Context, t *task.Task, e *storage.Executi // finds its session files under the same project slug. If no sandbox was // preserved (e.g. task had no project_dir), fall back to project_dir. var sandboxDir string + var startHEAD string effectiveWorkingDir := projectDir if e.ResumeSessionID != "" { if e.SandboxDir != "" { @@ -134,6 +135,12 @@ func (r *ClaudeRunner) Run(ctx context.Context, t *task.Task, e *storage.Executi r.Logger.Info("sandbox created", "sandbox", sandboxDir, "project_dir", projectDir) } + if effectiveWorkingDir != "" { + // Capture the initial HEAD so we can identify new commits later. + headOut, _ := exec.Command("git", gitSafe("-C", effectiveWorkingDir, "rev-parse", "HEAD")...).Output() + startHEAD = strings.TrimSpace(string(headOut)) + } + questionFile := filepath.Join(logDir, "question.json") args := r.buildArgs(t, e, questionFile) @@ -147,7 +154,7 @@ func (r *ClaudeRunner) Run(ctx context.Context, t *task.Task, e *storage.Executi ) } attempt++ - return r.execOnce(ctx, args, effectiveWorkingDir, e) + return r.execOnce(ctx, args, effectiveWorkingDir, projectDir, e) }) if err != nil { if sandboxDir != "" { @@ -183,7 +190,7 @@ func (r *ClaudeRunner) Run(ctx context.Context, t *task.Task, e *storage.Executi // Merge sandbox back to project_dir and clean up. if sandboxDir != "" { - if mergeErr := teardownSandbox(projectDir, sandboxDir, r.Logger); mergeErr != nil { + if mergeErr := teardownSandbox(projectDir, sandboxDir, startHEAD, r.Logger, e); mergeErr != nil { return fmt.Errorf("sandbox teardown: %w (sandbox preserved at %s)", mergeErr, sandboxDir) } } @@ -277,20 +284,57 @@ func setupSandbox(projectDir string) (string, error) { // The working copy (projectDir) is NOT updated automatically — it is the // developer's workspace and is pulled manually. This avoids permission errors // from mixed-owner .git/objects directories. -func teardownSandbox(projectDir, sandboxDir string, logger *slog.Logger) error { - // Fail if agent left uncommitted changes. +func teardownSandbox(projectDir, sandboxDir, startHEAD string, logger *slog.Logger, execRecord *storage.Execution) error { + // Automatically commit uncommitted changes. out, err := exec.Command("git", "-C", sandboxDir, "status", "--porcelain").Output() if err != nil { return fmt.Errorf("git status: %w", err) } if len(strings.TrimSpace(string(out))) > 0 { - return fmt.Errorf("uncommitted changes in sandbox (agent must commit all work):\n%s", out) + logger.Info("autocommitting uncommitted changes", "sandbox", sandboxDir) + cmds := [][]string{ + gitSafe("-C", sandboxDir, "add", "-A"), + gitSafe("-C", sandboxDir, "commit", "-m", "chore: autocommit uncommitted changes"), + } + for _, args := range cmds { + if out, err := exec.Command("git", args...).CombinedOutput(); err != nil { + return fmt.Errorf("autocommit failed (%v): %w\n%s", args, err, out) + } + } + } + + // Capture commits before pushing/deleting. + // Use startHEAD..HEAD to find all commits made during this execution. + logRange := "origin/HEAD..HEAD" + if startHEAD != "" && startHEAD != "HEAD" { + logRange = startHEAD + "..HEAD" + } + + logCmd := exec.Command("git", gitSafe("-C", sandboxDir, "log", logRange, "--pretty=format:%H|%s")...) + logOut, logErr := logCmd.CombinedOutput() + if logErr == nil { + lines := strings.Split(strings.TrimSpace(string(logOut)), "\n") + logger.Debug("captured commits", "count", len(lines), "range", logRange) + for _, line := range lines { + if line == "" { + continue + } + parts := strings.SplitN(line, "|", 2) + if len(parts) == 2 { + execRecord.Commits = append(execRecord.Commits, task.GitCommit{ + Hash: parts[0], + Message: parts[1], + }) + } + } + } else { + logger.Warn("failed to capture commits", "err", logErr, "range", logRange, "output", string(logOut)) } // Check whether there are any new commits to push. - ahead, err := exec.Command("git", "-C", sandboxDir, "rev-list", "--count", "origin/HEAD..HEAD").Output() + ahead, err := exec.Command("git", gitSafe("-C", sandboxDir, "rev-list", "--count", logRange)...).Output() if err != nil { - logger.Warn("could not determine commits ahead of origin; proceeding", "err", err) + logger.Warn("could not determine commits ahead of origin; proceeding", "err", err, "range", logRange) } if strings.TrimSpace(string(ahead)) == "0" { os.RemoveAll(sandboxDir) @@ -305,6 +349,22 @@ func teardownSandbox(projectDir, sandboxDir string, logger *slog.Logger) error { if out2, err2 := exec.Command("git", "-C", sandboxDir, "pull", "--rebase", "origin", "master").CombinedOutput(); err2 != nil { return fmt.Errorf("git rebase before retry push: %w\n%s", err2, out2) } + // Re-capture commits after rebase (hashes might have changed) + execRecord.Commits = nil + logOut, logErr = exec.Command("git", "-C", sandboxDir, "log", logRange, "--pretty=format:%H|%s").Output() + if logErr == nil { + lines := strings.Split(strings.TrimSpace(string(logOut)), "\n") + for _, line := range lines { + parts := strings.SplitN(line, "|", 2) + if len(parts) == 2 { + execRecord.Commits = append(execRecord.Commits, task.GitCommit{ + Hash: parts[0], + Message: parts[1], + }) + } + } + } + if out3, err3 := exec.Command("git", "-C", sandboxDir, "push", "origin", "HEAD").CombinedOutput(); err3 != nil { return fmt.Errorf("git push to origin (after rebase): %w\n%s", err3, out3) } @@ -319,11 +379,12 @@ func teardownSandbox(projectDir, sandboxDir string, logger *slog.Logger) error { } // execOnce runs the claude subprocess once, streaming output to e's log paths. -func (r *ClaudeRunner) execOnce(ctx context.Context, args []string, workingDir string, e *storage.Execution) error { +func (r *ClaudeRunner) execOnce(ctx context.Context, args []string, workingDir, projectDir string, e *storage.Execution) error { cmd := exec.CommandContext(ctx, r.binaryPath(), args...) cmd.Env = append(os.Environ(), "CLAUDOMATOR_API_URL="+r.APIURL, "CLAUDOMATOR_TASK_ID="+e.TaskID, + "CLAUDOMATOR_PROJECT_DIR="+projectDir, "CLAUDOMATOR_QUESTION_FILE="+filepath.Join(e.ArtifactDir, "question.json"), "CLAUDOMATOR_SUMMARY_FILE="+filepath.Join(e.ArtifactDir, "summary.txt"), ) diff --git a/internal/executor/claude_test.go b/internal/executor/claude_test.go index 9bb873f..02d1b2e 100644 --- a/internal/executor/claude_test.go +++ b/internal/executor/claude_test.go @@ -173,8 +173,11 @@ func TestClaudeRunner_BuildArgs_PreamblePrepended(t *testing.T) { if len(args) < 2 || args[0] != "-p" { t.Fatalf("expected -p as first arg, got: %v", args) } - if !strings.HasPrefix(args[1], planningPreamble) { - t.Errorf("instructions should start with planning preamble") + if !strings.HasPrefix(args[1], "## Runtime Environment") { + t.Errorf("instructions should start with planning preamble, got prefix: %q", args[1][:min(len(args[1]), 20)]) + } + if !strings.Contains(args[1], "$CLAUDOMATOR_PROJECT_DIR") { + t.Errorf("preamble should mention $CLAUDOMATOR_PROJECT_DIR") } if !strings.HasSuffix(args[1], "fix the bug") { t.Errorf("instructions should end with original instructions") @@ -329,7 +332,7 @@ func TestExecOnce_NoGoroutineLeak_OnNaturalExit(t *testing.T) { runtime.Gosched() baseline := runtime.NumGoroutine() - if err := r.execOnce(context.Background(), []string{}, "", e); err != nil { + if err := r.execOnce(context.Background(), []string{}, "", "", e); err != nil { t.Fatalf("execOnce failed: %v", err) } @@ -350,16 +353,24 @@ func TestExecOnce_NoGoroutineLeak_OnNaturalExit(t *testing.T) { func initGitRepo(t *testing.T, dir string) { t.Helper() cmds := [][]string{ - {"git", "-C", dir, "init"}, - {"git", "-C", dir, "config", "user.email", "test@test"}, - {"git", "-C", dir, "config", "user.name", "test"}, - {"git", "-C", dir, "commit", "--allow-empty", "-m", "init"}, + {"git", "-c", "safe.directory=*", "-C", dir, "init", "-b", "main"}, + {"git", "-c", "safe.directory=*", "-C", dir, "config", "user.email", "test@test"}, + {"git", "-c", "safe.directory=*", "-C", dir, "config", "user.name", "test"}, } for _, args := range cmds { if out, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil { t.Fatalf("%v: %v\n%s", args, err, out) } } + if err := os.WriteFile(filepath.Join(dir, "init.txt"), []byte("init"), 0644); err != nil { + t.Fatal(err) + } + if out, err := exec.Command("git", "-c", "safe.directory=*", "-C", dir, "add", ".").CombinedOutput(); err != nil { + t.Fatalf("git add: %v\n%s", err, out) + } + if out, err := exec.Command("git", "-c", "safe.directory=*", "-C", dir, "commit", "-m", "init").CombinedOutput(); err != nil { + t.Fatalf("git commit: %v\n%s", err, out) + } } func TestSandboxCloneSource_PrefersLocalRemote(t *testing.T) { @@ -409,6 +420,13 @@ func TestSetupSandbox_ClonesGitRepo(t *testing.T) { } t.Cleanup(func() { os.RemoveAll(sandbox) }) + // Force sandbox to master if it cloned as main + exec.Command("git", gitSafe("-C", sandbox, "checkout", "master")...).Run() + + // Debug sandbox + logOut, _ := exec.Command("git", "-C", sandbox, "log", "-1").CombinedOutput() + fmt.Printf("DEBUG: sandbox log: %s\n", string(logOut)) + // Verify sandbox is a git repo with at least one commit. out, err := exec.Command("git", "-C", sandbox, "log", "--oneline").Output() if err != nil { @@ -434,31 +452,63 @@ func TestSetupSandbox_InitialisesNonGitDir(t *testing.T) { } } -func TestTeardownSandbox_UncommittedChanges_ReturnsError(t *testing.T) { - src := t.TempDir() - initGitRepo(t, src) - sandbox, err := setupSandbox(src) +func TestTeardownSandbox_AutocommitsChanges(t *testing.T) { + // Create a bare repo as origin so push succeeds. + bare := t.TempDir() + if out, err := exec.Command("git", "init", "--bare", bare).CombinedOutput(); err != nil { + t.Fatalf("git init bare: %v\n%s", err, out) + } + + // Create a sandbox directly. + sandbox := t.TempDir() + initGitRepo(t, sandbox) + if out, err := exec.Command("git", "-c", "safe.directory=*", "-C", sandbox, "remote", "add", "origin", bare).CombinedOutput(); err != nil { + t.Fatalf("git remote add: %v\n%s", err, out) + } + // Initial push to establish origin/main + if out, err := exec.Command("git", "-c", "safe.directory=*", "-C", sandbox, "push", "origin", "main").CombinedOutput(); err != nil { + t.Fatalf("git push initial: %v\n%s", err, out) + } + + // Capture startHEAD + headOut, err := exec.Command("git", "-c", "safe.directory=*", "-C", sandbox, "rev-parse", "HEAD").Output() if err != nil { - t.Fatalf("setupSandbox: %v", err) + t.Fatalf("rev-parse HEAD: %v", err) } - t.Cleanup(func() { os.RemoveAll(sandbox) }) + startHEAD := strings.TrimSpace(string(headOut)) // Leave an uncommitted file in the sandbox. - if err := os.WriteFile(filepath.Join(sandbox, "dirty.txt"), []byte("oops"), 0644); err != nil { + if err := os.WriteFile(filepath.Join(sandbox, "dirty.txt"), []byte("autocommit me"), 0644); err != nil { t.Fatal(err) } - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - err = teardownSandbox(src, sandbox, logger) - if err == nil { - t.Fatal("expected error for uncommitted changes, got nil") + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})) + execRecord := &storage.Execution{} + + err = teardownSandbox("", sandbox, startHEAD, logger, execRecord) + if err != nil { + t.Fatalf("expected autocommit to succeed, got error: %v", err) + } + + // Sandbox should be removed after successful autocommit and push. + if _, statErr := os.Stat(sandbox); !os.IsNotExist(statErr) { + t.Error("sandbox should have been removed after successful autocommit and push") } - if !strings.Contains(err.Error(), "uncommitted changes") { - t.Errorf("expected 'uncommitted changes' in error, got: %v", err) + + // Verify the commit exists in the bare repo. + out, err := exec.Command("git", "-C", bare, "log", "-1", "--pretty=%B").Output() + if err != nil { + t.Fatalf("git log in bare repo: %v", err) } - // Sandbox should be preserved (not removed) on error. - if _, statErr := os.Stat(sandbox); os.IsNotExist(statErr) { - t.Error("sandbox was removed despite error; should be preserved for debugging") + if !strings.Contains(string(out), "chore: autocommit uncommitted changes") { + t.Errorf("expected autocommit message in log, got: %q", string(out)) + } + + // Verify the commit was captured in execRecord. + if len(execRecord.Commits) == 0 { + t.Error("expected at least one commit in execRecord") + } else if !strings.Contains(execRecord.Commits[0].Message, "chore: autocommit uncommitted changes") { + t.Errorf("unexpected commit message: %q", execRecord.Commits[0].Message) } } @@ -471,8 +521,13 @@ func TestTeardownSandbox_CleanSandboxWithNoNewCommits_RemovesSandbox(t *testing. } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + execRecord := &storage.Execution{} + + headOut, _ := exec.Command("git", "-C", sandbox, "rev-parse", "HEAD").Output() + startHEAD := strings.TrimSpace(string(headOut)) + // Sandbox has no new commits beyond origin; teardown should succeed and remove it. - if err := teardownSandbox(src, sandbox, logger); err != nil { + if err := teardownSandbox(src, sandbox, startHEAD, logger, execRecord); err != nil { t.Fatalf("teardownSandbox: %v", err) } if _, statErr := os.Stat(sandbox); !os.IsNotExist(statErr) { diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 7674fe6..f85f1ff 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -29,6 +29,7 @@ type Store interface { UpdateTaskSummary(taskID, summary string) error AppendTaskInteraction(taskID string, interaction task.Interaction) error UpdateTaskAgent(id string, agent task.AgentConfig) error + UpdateExecutionChangestats(execID string, stats *task.Changestats) error } // LogPather is an optional interface runners can implement to provide the log @@ -352,6 +353,14 @@ func (p *Pool) handleRunResult(ctx context.Context, t *task.Task, exec *storage. p.logger.Error("failed to update task summary", "taskID", t.ID, "error", summaryErr) } } + if exec.StdoutPath != "" { + if cs := task.ParseChangestatFromFile(exec.StdoutPath); cs != nil { + exec.Changestats = cs + if csErr := p.store.UpdateExecutionChangestats(exec.ID, cs); csErr != nil { + p.logger.Error("failed to store changestats", "execID", exec.ID, "error", csErr) + } + } + } if updateErr := p.store.UpdateExecution(exec); updateErr != nil { p.logger.Error("failed to update execution", "error", updateErr) } diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go index a6c4ad8..610ed3b 100644 --- a/internal/executor/executor_test.go +++ b/internal/executor/executor_test.go @@ -921,9 +921,13 @@ type minimalMockStore struct { executions map[string]*storage.Execution stateUpdates []struct{ id string; state task.State } questionUpdates []string - subtasksFunc func(parentID string) ([]*task.Task, error) - updateExecErr error - updateStateErr error + changestatCalls []struct { + execID string + stats *task.Changestats + } + subtasksFunc func(parentID string) ([]*task.Task, error) + updateExecErr error + updateStateErr error } func newMinimalMockStore() *minimalMockStore { @@ -977,6 +981,15 @@ func (m *minimalMockStore) AppendTaskInteraction(taskID string, _ task.Interacti return nil } func (m *minimalMockStore) UpdateTaskAgent(id string, agent task.AgentConfig) error { return nil } +func (m *minimalMockStore) UpdateExecutionChangestats(execID string, stats *task.Changestats) error { + m.mu.Lock() + m.changestatCalls = append(m.changestatCalls, struct { + execID string + stats *task.Changestats + }{execID, stats}) + m.mu.Unlock() + return nil +} func (m *minimalMockStore) lastStateUpdate() (string, task.State, bool) { m.mu.Lock() @@ -1203,3 +1216,138 @@ func TestPool_SpecificAgent_PersistsToDB(t *testing.T) { t.Errorf("expected agent type gemini in DB, got %q", reloaded.Agent.Type) } } + +// TestExecute_ExtractAndStoreChangestats verifies that when the execution stdout +// contains a git diff --stat summary line, the changestats are parsed and stored. +func TestExecute_ExtractAndStoreChangestats(t *testing.T) { + store := testStore(t) + logDir := t.TempDir() + + runner := &logPatherMockRunner{logDir: logDir} + runner.onRun = func(tk *task.Task, e *storage.Execution) error { + if err := os.MkdirAll(filepath.Dir(e.StdoutPath), 0755); err != nil { + return err + } + content := "some output\n5 files changed, 127 insertions(+), 43 deletions(-)\n" + return os.WriteFile(e.StdoutPath, []byte(content), 0644) + } + + runners := map[string]Runner{"claude": runner} + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + pool := NewPool(2, runners, store, logger) + + tk := makeTask("cs-extract-1") + store.CreateTask(tk) + + if err := pool.Submit(context.Background(), tk); err != nil { + t.Fatalf("submit: %v", err) + } + result := <-pool.Results() + if result.Err != nil { + t.Fatalf("unexpected error: %v", result.Err) + } + + execs, err := store.ListExecutions(tk.ID) + if err != nil { + t.Fatalf("list executions: %v", err) + } + if len(execs) == 0 { + t.Fatal("no executions found") + } + cs := execs[0].Changestats + if cs == nil { + t.Fatal("expected changestats to be populated, got nil") + } + if cs.FilesChanged != 5 { + t.Errorf("FilesChanged: want 5, got %d", cs.FilesChanged) + } + if cs.LinesAdded != 127 { + t.Errorf("LinesAdded: want 127, got %d", cs.LinesAdded) + } + if cs.LinesRemoved != 43 { + t.Errorf("LinesRemoved: want 43, got %d", cs.LinesRemoved) + } +} + +// TestExecute_NoChangestats verifies that when execution output contains no git +// diff stat line, changestats are not stored (remain nil). +func TestExecute_NoChangestats(t *testing.T) { + store := testStore(t) + logDir := t.TempDir() + + runner := &logPatherMockRunner{logDir: logDir} + runner.onRun = func(tk *task.Task, e *storage.Execution) error { + if err := os.MkdirAll(filepath.Dir(e.StdoutPath), 0755); err != nil { + return err + } + return os.WriteFile(e.StdoutPath, []byte("no git output here\n"), 0644) + } + + runners := map[string]Runner{"claude": runner} + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + pool := NewPool(2, runners, store, logger) + + tk := makeTask("cs-none-1") + store.CreateTask(tk) + + if err := pool.Submit(context.Background(), tk); err != nil { + t.Fatalf("submit: %v", err) + } + result := <-pool.Results() + if result.Err != nil { + t.Fatalf("unexpected error: %v", result.Err) + } + + execs, err := store.ListExecutions(tk.ID) + if err != nil { + t.Fatalf("list executions: %v", err) + } + if len(execs) == 0 { + t.Fatal("no executions found") + } + if execs[0].Changestats != nil { + t.Errorf("expected changestats to be nil for output with no git stats, got %+v", execs[0].Changestats) + } +} + +// TestExecute_MalformedChangestats verifies that malformed git-stat-like output +// does not produce changestats (parser returns nil, nothing is stored). +func TestExecute_MalformedChangestats(t *testing.T) { + store := testStore(t) + logDir := t.TempDir() + + runner := &logPatherMockRunner{logDir: logDir} + runner.onRun = func(tk *task.Task, e *storage.Execution) error { + if err := os.MkdirAll(filepath.Dir(e.StdoutPath), 0755); err != nil { + return err + } + // Looks like a git stat line but doesn't match the regex. + return os.WriteFile(e.StdoutPath, []byte("lots of cheese changed, many insertions\n"), 0644) + } + + runners := map[string]Runner{"claude": runner} + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + pool := NewPool(2, runners, store, logger) + + tk := makeTask("cs-malformed-1") + store.CreateTask(tk) + + if err := pool.Submit(context.Background(), tk); err != nil { + t.Fatalf("submit: %v", err) + } + result := <-pool.Results() + if result.Err != nil { + t.Fatalf("unexpected error: %v", result.Err) + } + + execs, err := store.ListExecutions(tk.ID) + if err != nil { + t.Fatalf("list executions: %v", err) + } + if len(execs) == 0 { + t.Fatal("no executions found") + } + if execs[0].Changestats != nil { + t.Errorf("expected nil changestats for malformed output, got %+v", execs[0].Changestats) + } +} diff --git a/internal/executor/gemini.go b/internal/executor/gemini.go index 2db3218..67ea7dd 100644 --- a/internal/executor/gemini.go +++ b/internal/executor/gemini.go @@ -68,7 +68,7 @@ func (r *GeminiRunner) Run(ctx context.Context, t *task.Task, e *storage.Executi // Gemini CLI doesn't necessarily have the same rate limiting behavior as Claude, // but we'll use a similar execution pattern. - err := r.execOnce(ctx, args, t.Agent.ProjectDir, e) + err := r.execOnce(ctx, args, t.Agent.ProjectDir, t.Agent.ProjectDir, e) if err != nil { return err } @@ -82,11 +82,12 @@ func (r *GeminiRunner) Run(ctx context.Context, t *task.Task, e *storage.Executi return nil } -func (r *GeminiRunner) execOnce(ctx context.Context, args []string, workingDir string, e *storage.Execution) error { +func (r *GeminiRunner) execOnce(ctx context.Context, args []string, workingDir, projectDir string, e *storage.Execution) error { cmd := exec.CommandContext(ctx, r.binaryPath(), args...) cmd.Env = append(os.Environ(), "CLAUDOMATOR_API_URL="+r.APIURL, "CLAUDOMATOR_TASK_ID="+e.TaskID, + "CLAUDOMATOR_PROJECT_DIR="+projectDir, "CLAUDOMATOR_QUESTION_FILE="+filepath.Join(e.ArtifactDir, "question.json"), ) cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} diff --git a/internal/executor/preamble.go b/internal/executor/preamble.go index 8ae79ad..f5dba2b 100644 --- a/internal/executor/preamble.go +++ b/internal/executor/preamble.go @@ -27,7 +27,7 @@ Before doing any implementation work: 2. If YES — break it down: - Create 3–7 discrete subtasks by POSTing to $CLAUDOMATOR_API_URL/api/tasks - - Each subtask POST body should be JSON with: name, agent.instructions, agent.working_dir (copy from current task), agent.model, agent.allowed_tools, and agent.skip_planning set to true + - Each subtask POST body should be JSON with: name, agent.instructions, agent.project_dir (copy from $CLAUDOMATOR_PROJECT_DIR), agent.model, agent.allowed_tools, and agent.skip_planning set to true - Set parent_task_id to $CLAUDOMATOR_TASK_ID in each POST body - After creating all subtasks, output a brief summary and STOP. Do not implement anything. - You can also specify agent.type (either "claude" or "gemini") to choose the agent for subtasks. diff --git a/internal/storage/db.go b/internal/storage/db.go index 043009c..69bcf68 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -83,6 +83,8 @@ func (s *DB) migrate() error { `ALTER TABLE executions ADD COLUMN sandbox_dir TEXT`, `ALTER TABLE tasks ADD COLUMN summary TEXT`, `ALTER TABLE tasks ADD COLUMN interactions_json TEXT NOT NULL DEFAULT '[]'`, + `ALTER TABLE executions ADD COLUMN changestats_json TEXT`, + `ALTER TABLE executions ADD COLUMN commits_json TEXT NOT NULL DEFAULT '[]'`, } for _, m := range migrations { if _, err := s.db.Exec(m); err != nil { @@ -366,6 +368,9 @@ type Execution struct { SessionID string // claude --session-id; persisted for resume SandboxDir string // preserved sandbox path when task is BLOCKED; resume must run here + Changestats *task.Changestats // stored as JSON; nil if not yet recorded + Commits []task.GitCommit // stored as JSON; empty if no commits + // In-memory only: set when creating a resume execution, not stored in DB. ResumeSessionID string ResumeAnswer string @@ -375,24 +380,41 @@ type Execution struct { // CreateExecution inserts an execution record. func (s *DB) CreateExecution(e *Execution) error { + var changestatsJSON *string + if e.Changestats != nil { + b, err := json.Marshal(e.Changestats) + if err != nil { + return fmt.Errorf("marshaling changestats: %w", err) + } + s := string(b) + changestatsJSON = &s + } + commitsJSON := "[]" + if len(e.Commits) > 0 { + b, err := json.Marshal(e.Commits) + if err != nil { + return fmt.Errorf("marshaling commits: %w", err) + } + commitsJSON = string(b) + } _, err := s.db.Exec(` - INSERT INTO executions (id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + INSERT INTO executions (id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, e.ID, e.TaskID, e.StartTime.UTC(), e.EndTime.UTC(), e.ExitCode, e.Status, - e.StdoutPath, e.StderrPath, e.ArtifactDir, e.CostUSD, e.ErrorMsg, e.SessionID, e.SandboxDir, + e.StdoutPath, e.StderrPath, e.ArtifactDir, e.CostUSD, e.ErrorMsg, e.SessionID, e.SandboxDir, changestatsJSON, commitsJSON, ) return err } // GetExecution retrieves an execution by ID. func (s *DB) GetExecution(id string) (*Execution, error) { - row := s.db.QueryRow(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir FROM executions WHERE id = ?`, id) + row := s.db.QueryRow(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json FROM executions WHERE id = ?`, id) return scanExecution(row) } // ListExecutions returns executions for a task. func (s *DB) ListExecutions(taskID string) ([]*Execution, error) { - rows, err := s.db.Query(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir FROM executions WHERE task_id = ? ORDER BY start_time DESC`, taskID) + rows, err := s.db.Query(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json FROM executions WHERE task_id = ? ORDER BY start_time DESC`, taskID) if err != nil { return nil, err } @@ -411,7 +433,7 @@ func (s *DB) ListExecutions(taskID string) ([]*Execution, error) { // GetLatestExecution returns the most recent execution for a task. func (s *DB) GetLatestExecution(taskID string) (*Execution, error) { - row := s.db.QueryRow(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir FROM executions WHERE task_id = ? ORDER BY start_time DESC LIMIT 1`, taskID) + row := s.db.QueryRow(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json FROM executions WHERE task_id = ? ORDER BY start_time DESC LIMIT 1`, taskID) return scanExecution(row) } @@ -576,12 +598,31 @@ func (s *DB) AppendTaskInteraction(taskID string, interaction task.Interaction) // UpdateExecution updates a completed execution. func (s *DB) UpdateExecution(e *Execution) error { + var changestatsJSON *string + if e.Changestats != nil { + b, err := json.Marshal(e.Changestats) + if err != nil { + return fmt.Errorf("marshaling changestats: %w", err) + } + s := string(b) + changestatsJSON = &s + } + commitsJSON := "[]" + if len(e.Commits) > 0 { + b, err := json.Marshal(e.Commits) + if err != nil { + return fmt.Errorf("marshaling commits: %w", err) + } + commitsJSON = string(b) + } _, err := s.db.Exec(` UPDATE executions SET end_time = ?, exit_code = ?, status = ?, cost_usd = ?, error_msg = ?, - stdout_path = ?, stderr_path = ?, artifact_dir = ?, session_id = ?, sandbox_dir = ? + stdout_path = ?, stderr_path = ?, artifact_dir = ?, session_id = ?, sandbox_dir = ?, + changestats_json = ?, commits_json = ? WHERE id = ?`, e.EndTime.UTC(), e.ExitCode, e.Status, e.CostUSD, e.ErrorMsg, - e.StdoutPath, e.StderrPath, e.ArtifactDir, e.SessionID, e.SandboxDir, e.ID, + e.StdoutPath, e.StderrPath, e.ArtifactDir, e.SessionID, e.SandboxDir, + changestatsJSON, commitsJSON, e.ID, ) return err } @@ -647,16 +688,42 @@ func scanExecution(row scanner) (*Execution, error) { var e Execution var sessionID sql.NullString var sandboxDir sql.NullString + var changestatsJSON sql.NullString + var commitsJSON sql.NullString err := row.Scan(&e.ID, &e.TaskID, &e.StartTime, &e.EndTime, &e.ExitCode, &e.Status, - &e.StdoutPath, &e.StderrPath, &e.ArtifactDir, &e.CostUSD, &e.ErrorMsg, &sessionID, &sandboxDir) + &e.StdoutPath, &e.StderrPath, &e.ArtifactDir, &e.CostUSD, &e.ErrorMsg, &sessionID, &sandboxDir, &changestatsJSON, &commitsJSON) if err != nil { return nil, err } e.SessionID = sessionID.String e.SandboxDir = sandboxDir.String + if changestatsJSON.Valid && changestatsJSON.String != "" { + var cs task.Changestats + if err := json.Unmarshal([]byte(changestatsJSON.String), &cs); err != nil { + return nil, fmt.Errorf("unmarshaling changestats: %w", err) + } + e.Changestats = &cs + } + if commitsJSON.Valid && commitsJSON.String != "" { + if err := json.Unmarshal([]byte(commitsJSON.String), &e.Commits); err != nil { + return nil, fmt.Errorf("unmarshaling commits: %w", err) + } + } else { + e.Commits = []task.GitCommit{} + } return &e, nil } +// UpdateExecutionChangestats stores git change metrics for a completed execution. +func (s *DB) UpdateExecutionChangestats(execID string, stats *task.Changestats) error { + b, err := json.Marshal(stats) + if err != nil { + return fmt.Errorf("marshaling changestats: %w", err) + } + _, err = s.db.Exec(`UPDATE executions SET changestats_json = ? WHERE id = ?`, string(b), execID) + return err +} + func scanExecutionRows(rows *sql.Rows) (*Execution, error) { return scanExecution(rows) } diff --git a/internal/storage/db_test.go b/internal/storage/db_test.go index 31be246..a16311d 100644 --- a/internal/storage/db_test.go +++ b/internal/storage/db_test.go @@ -893,3 +893,45 @@ func TestAppendTaskInteraction_NotFound(t *testing.T) { } } +func TestExecution_StoreAndRetrieveChangestats(t *testing.T) { + db := testDB(t) + now := time.Now().UTC().Truncate(time.Second) + db.CreateTask(makeTestTask("cs-task", now)) + + exec := &Execution{ + ID: "cs-exec", + TaskID: "cs-task", + StartTime: now, + Status: "COMPLETED", + } + if err := db.CreateExecution(exec); err != nil { + t.Fatalf("CreateExecution: %v", err) + } + + stats := &task.Changestats{ + FilesChanged: 5, + LinesAdded: 127, + LinesRemoved: 43, + } + if err := db.UpdateExecutionChangestats("cs-exec", stats); err != nil { + t.Fatalf("UpdateExecutionChangestats: %v", err) + } + + got, err := db.GetExecution("cs-exec") + if err != nil { + t.Fatalf("GetExecution: %v", err) + } + if got.Changestats == nil { + t.Fatal("Changestats: want non-nil, got nil") + } + if got.Changestats.FilesChanged != 5 { + t.Errorf("FilesChanged: want 5, got %d", got.Changestats.FilesChanged) + } + if got.Changestats.LinesAdded != 127 { + t.Errorf("LinesAdded: want 127, got %d", got.Changestats.LinesAdded) + } + if got.Changestats.LinesRemoved != 43 { + t.Errorf("LinesRemoved: want 43, got %d", got.Changestats.LinesRemoved) + } +} + diff --git a/internal/task/changestats.go b/internal/task/changestats.go new file mode 100644 index 0000000..95be8ec --- /dev/null +++ b/internal/task/changestats.go @@ -0,0 +1,47 @@ +package task + +import ( + "bufio" + "os" + "regexp" + "strconv" + "strings" +) + +// gitDiffStatRe matches git diff --stat summary lines, e.g.: +// +// "3 files changed, 50 insertions(+), 10 deletions(-)" +// "1 file changed, 5 insertions(+)" +// "1 file changed, 3 deletions(-)" +var gitDiffStatRe = regexp.MustCompile(`(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?`) + +// ParseChangestatFromOutput scans text for git diff --stat summary lines and +// returns the first match as a Changestats value. Returns nil if no match found. +func ParseChangestatFromOutput(output string) *Changestats { + scanner := bufio.NewScanner(strings.NewReader(output)) + for scanner.Scan() { + line := scanner.Text() + if m := gitDiffStatRe.FindStringSubmatch(line); m != nil { + cs := &Changestats{} + cs.FilesChanged, _ = strconv.Atoi(m[1]) + if m[2] != "" { + cs.LinesAdded, _ = strconv.Atoi(m[2]) + } + if m[3] != "" { + cs.LinesRemoved, _ = strconv.Atoi(m[3]) + } + return cs + } + } + return nil +} + +// ParseChangestatFromFile reads a log file and extracts the first git diff stat +// summary it finds. Returns nil if the file cannot be read or contains no match. +func ParseChangestatFromFile(path string) *Changestats { + data, err := os.ReadFile(path) + if err != nil { + return nil + } + return ParseChangestatFromOutput(string(data)) +} diff --git a/internal/task/task.go b/internal/task/task.go index 69da5f3..6a9d1db 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -48,6 +48,19 @@ type RetryConfig struct { Backoff string `yaml:"backoff" json:"backoff"` // "linear", "exponential" } +// GitCommit represents a single git commit created during a task execution. +type GitCommit struct { + Hash string `json:"hash"` + Message string `json:"message"` +} + +// Changestats records file/line change metrics from an agent execution. +type Changestats struct { + FilesChanged int `json:"files_changed"` + LinesAdded int `json:"lines_added"` + LinesRemoved int `json:"lines_removed"` +} + // Interaction records a single question/answer exchange between an agent and the user. type Interaction struct { QuestionText string `json:"question_text"` diff --git a/scripts/next-task b/scripts/next-task index e74ca26..9df09f0 100755 --- a/scripts/next-task +++ b/scripts/next-task @@ -11,7 +11,7 @@ # Usage: next_id=$(scripts/next-task) # Example: scripts/start-next-task -DB_PATH="/site/doot.terst.org/data/claudomator.db" +DB_PATH="${DB_PATH:-/site/doot.terst.org/data/claudomator.db}" # 1. Fetch the most recently updated COMPLETED or READY task target=$(sqlite3 "$DB_PATH" "SELECT id, state, parent_task_id FROM tasks WHERE state IN ('COMPLETED', 'READY') ORDER BY updated_at DESC LIMIT 1;") @@ -32,7 +32,7 @@ fi if [ -z "$next_task" ]; then # 4. No child/sibling found: fall back to highest-priority oldest PENDING task - next_task=$(sqlite3 "$DB_PATH" "SELECT id FROM tasks WHERE state = 'PENDING' AND id != '$id' + FALLBACK_SQL="SELECT id FROM tasks WHERE state IN ('PENDING', 'QUEUED') AND id != '$id' ORDER BY CASE priority WHEN 'critical' THEN 4 @@ -42,7 +42,8 @@ if [ -z "$next_task" ]; then ELSE 0 END DESC, created_at ASC - LIMIT 1;") + LIMIT 1;" + next_task=$(sqlite3 "$DB_PATH" "$FALLBACK_SQL") fi echo "$next_task" diff --git a/test/next-task.test.sh b/test/next-task.test.sh new file mode 100644 index 0000000..3304efa --- /dev/null +++ b/test/next-task.test.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# test/next-task.test.sh + +set -euo pipefail + +# Create a temporary database +TEST_DB=$(mktemp) +sqlite3 "$TEST_DB" <<EOF +CREATE TABLE tasks ( + id TEXT PRIMARY KEY, + state TEXT NOT NULL, + parent_task_id TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + priority TEXT DEFAULT 'normal' +); +EOF + +# Insert a COMPLETED task that will not have a PENDING child or sibling, +# to ensure the fallback logic is triggered. +sqlite3 "$TEST_DB" "INSERT INTO tasks (id, state, created_at) VALUES ('completed-no-children', 'COMPLETED', '2023-01-01 12:00:00');" + +# Inject a QUEUED task (should be picked by fallback) +sqlite3 "$TEST_DB" "INSERT INTO tasks (id, state, priority, created_at) VALUES ('queued-task-id', 'QUEUED', 'high', '2023-01-01 10:00:00');" + +# Inject a PENDING task (lower priority, should not be picked first by fallback) +sqlite3 "$TEST_DB" "INSERT INTO tasks (id, state, priority, created_at) VALUES ('pending-task-id', 'PENDING', 'normal', '2023-01-01 11:00:00');" + +# Run the next-task script with the temporary database path +export DB_PATH="$TEST_DB" # Override DB_PATH for the test +SCRIPT_DIR="$(dirname "$(dirname "$0")")/scripts" +NEXT_TASK_ID=$("$SCRIPT_DIR/next-task") + +# Assert that the QUEUED task is returned +if [[ "$NEXT_TASK_ID" == "queued-task-id" ]]; then + echo "Test passed: QUEUED task was selected by fallback." +else + echo "Test failed: Expected 'queued-task-id', got '$NEXT_TASK_ID'" + exit 1 +fi + +# Clean up +rm "$TEST_DB" @@ -74,6 +74,24 @@ function formatDate(iso) { }); } +// Returns formatted string for changestats, e.g. "5 files, +127 -43". +// Returns empty string for null/undefined input. +export function formatChangestats(stats) { + if (stats == null) return ''; + return `${stats.files_changed} files, +${stats.lines_added} -${stats.lines_removed}`; +} + +// Returns a <span class="changestats-badge"> element for the given stats, +// or null if stats is null/undefined. +// Accepts an optional doc parameter for testability (defaults to document). +export function renderChangestatsBadge(stats, doc = (typeof document !== 'undefined' ? document : null)) { + if (stats == null || doc == null) return null; + const span = doc.createElement('span'); + span.className = 'changestats-badge'; + span.textContent = formatChangestats(stats); + return span; +} + function createTaskCard(task) { const card = document.createElement('div'); card.className = 'task-card'; @@ -118,6 +136,13 @@ function createTaskCard(task) { card.appendChild(desc); } + // Changestats badge for COMPLETED/READY tasks + const CHANGESTATS_STATES = new Set(['COMPLETED', 'READY']); + if (CHANGESTATS_STATES.has(task.state) && task.changestats != null) { + const csBadge = renderChangestatsBadge(task.changestats); + if (csBadge) card.appendChild(csBadge); + } + // Footer: action buttons based on state // Interrupted states (CANCELLED, FAILED, BUDGET_EXCEEDED) show both Resume and Restart. // TIMED_OUT shows Resume only. Others show a single action. @@ -1690,6 +1715,33 @@ function renderTaskPanel(task, executions) { exitEl.textContent = `exit: ${exec.ExitCode ?? '—'}`; row.appendChild(exitEl); + if (exec.Changestats != null) { + const csBadge = renderChangestatsBadge(exec.Changestats); + if (csBadge) row.appendChild(csBadge); + } + + if (exec.Commits && exec.Commits.length > 0) { + const commitList = document.createElement('div'); + commitList.className = 'execution-commits'; + for (const commit of exec.Commits) { + const item = document.createElement('div'); + item.className = 'commit-item'; + + const hash = document.createElement('span'); + hash.className = 'commit-hash'; + hash.textContent = commit.hash.slice(0, 7); + item.appendChild(hash); + + const msg = document.createElement('span'); + msg.className = 'commit-msg'; + msg.textContent = commit.message; + item.appendChild(msg); + + commitList.appendChild(item); + } + row.appendChild(commitList); + } + const logsBtn = document.createElement('button'); logsBtn.className = 'btn-view-logs'; logsBtn.textContent = 'View Logs'; diff --git a/web/style.css b/web/style.css index 31f929e..e7d1de4 100644 --- a/web/style.css +++ b/web/style.css @@ -793,6 +793,39 @@ dialog label select:focus { flex-wrap: wrap; } +.execution-commits { + width: 100%; + margin-top: 0.25rem; + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.8rem; + color: var(--text-muted); + border-top: 1px solid var(--border-light); + padding-top: 0.5rem; +} + +.commit-item { + display: flex; + gap: 0.5rem; + align-items: baseline; +} + +.commit-hash { + font-family: var(--font-mono); + color: var(--text); + background: var(--bg-hover); + padding: 0.125rem 0.25rem; + border-radius: 0.25rem; + font-size: 0.75rem; +} + +.commit-msg { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .execution-id { font-family: monospace; font-size: 0.72rem; @@ -823,6 +856,13 @@ dialog label select:focus { white-space: nowrap; } +.changestats-badge { + font-family: monospace; + font-size: 0.72rem; + color: var(--text-muted); + white-space: nowrap; +} + .btn-view-logs { font-size: 0.72rem; font-weight: 600; diff --git a/web/test/changestats.test.mjs b/web/test/changestats.test.mjs new file mode 100644 index 0000000..5363812 --- /dev/null +++ b/web/test/changestats.test.mjs @@ -0,0 +1,125 @@ +// changestats.test.mjs — Unit tests for changestats display functions. +// +// Run with: node --test web/test/changestats.test.mjs + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { formatChangestats, renderChangestatsBadge } from '../app.js'; + +// ── Mock DOM ─────────────────────────────────────────────────────────────────── + +function makeDoc() { + return { + createElement(tag) { + const el = { + tag, + className: '', + textContent: '', + children: [], + appendChild(child) { this.children.push(child); return child; }, + }; + return el; + }, + }; +} + +// ── formatChangestats ────────────────────────────────────────────────────────── + +describe('formatChangestats', () => { + it('formats valid stats as "N files, +A -R"', () => { + const result = formatChangestats({ files_changed: 5, lines_added: 127, lines_removed: 43 }); + assert.equal(result, '5 files, +127 -43'); + }); + + it('returns empty string for null', () => { + const result = formatChangestats(null); + assert.equal(result, ''); + }); + + it('returns empty string for undefined', () => { + const result = formatChangestats(undefined); + assert.equal(result, ''); + }); + + it('formats zero values correctly', () => { + const result = formatChangestats({ files_changed: 0, lines_added: 0, lines_removed: 0 }); + assert.equal(result, '0 files, +0 -0'); + }); + + it('formats single file correctly', () => { + const result = formatChangestats({ files_changed: 1, lines_added: 10, lines_removed: 2 }); + assert.equal(result, '1 files, +10 -2'); + }); +}); + +// ── renderChangestatsBadge ───────────────────────────────────────────────────── + +describe('renderChangestatsBadge', () => { + it('returns element with class changestats-badge for valid stats', () => { + const doc = makeDoc(); + const el = renderChangestatsBadge({ files_changed: 5, lines_added: 127, lines_removed: 43 }, doc); + assert.ok(el, 'element should not be null'); + assert.equal(el.className, 'changestats-badge'); + }); + + it('returns element with correct text content', () => { + const doc = makeDoc(); + const el = renderChangestatsBadge({ files_changed: 5, lines_added: 127, lines_removed: 43 }, doc); + assert.equal(el.textContent, '5 files, +127 -43'); + }); + + it('returns null for null stats', () => { + const doc = makeDoc(); + const el = renderChangestatsBadge(null, doc); + assert.equal(el, null); + }); + + it('returns null for undefined stats', () => { + const doc = makeDoc(); + const el = renderChangestatsBadge(undefined, doc); + assert.equal(el, null); + }); +}); + +// ── State-based visibility ──────────────────────────────────────────────────── +// +// Changestats badge should appear on COMPLETED (and READY) tasks that have +// changestats data, and must not appear on QUEUED tasks. + +const CHANGESTATS_STATES = new Set(['COMPLETED', 'READY']); + +function shouldShowChangestats(task) { + return CHANGESTATS_STATES.has(task.state) && task.changestats != null; +} + +describe('changestats badge visibility by task state', () => { + it('COMPLETED task with changestats shows badge', () => { + const task = { state: 'COMPLETED', changestats: { files_changed: 3, lines_added: 50, lines_removed: 10 } }; + assert.equal(shouldShowChangestats(task), true); + }); + + it('READY task with changestats shows badge', () => { + const task = { state: 'READY', changestats: { files_changed: 1, lines_added: 5, lines_removed: 2 } }; + assert.equal(shouldShowChangestats(task), true); + }); + + it('QUEUED task hides changestats', () => { + const task = { state: 'QUEUED', changestats: { files_changed: 3, lines_added: 50, lines_removed: 10 } }; + assert.equal(shouldShowChangestats(task), false); + }); + + it('COMPLETED task without changestats hides badge', () => { + const task = { state: 'COMPLETED', changestats: null }; + assert.equal(shouldShowChangestats(task), false); + }); + + it('RUNNING task hides changestats', () => { + const task = { state: 'RUNNING', changestats: null }; + assert.equal(shouldShowChangestats(task), false); + }); + + it('PENDING task hides changestats', () => { + const task = { state: 'PENDING', changestats: null }; + assert.equal(shouldShowChangestats(task), false); + }); +}); |
