diff options
| author | Claude Sonnet 4.6 <noreply@anthropic.com> | 2026-03-14 18:11:44 +0000 |
|---|---|---|
| committer | Claude Sonnet 4.6 <noreply@anthropic.com> | 2026-03-14 18:11:44 +0000 |
| commit | 43440200facf9f7c51ba4f4638e69e7d651dd50d (patch) | |
| tree | 5984798cb6ba0038e17c0e175ab35054276b38cf /internal/executor | |
| parent | 5ad88bfc08584585b56ac8eee9cde95ed20b7b43 (diff) | |
feat(Phase4): add file changes for changestats executor wiring
Files changed: CLAUDE.md, internal/api/changestats.go,
internal/executor/executor.go, internal/executor/executor_test.go,
internal/task/changestats.go (new)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/executor')
| -rw-r--r-- | internal/executor/executor.go | 8 | ||||
| -rw-r--r-- | internal/executor/executor_test.go | 154 |
2 files changed, 159 insertions, 3 deletions
diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 7674fe6..fd37c33 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,13 @@ 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 { + 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) + } +} |
