diff options
| author | Claudomator Agent <agent@claudomator> | 2026-03-14 16:02:28 +0000 |
|---|---|---|
| committer | Claudomator Agent <agent@claudomator> | 2026-03-14 16:02:28 +0000 |
| commit | 59bc518eee4026fa072c163149389b05428b5398 (patch) | |
| tree | fbcde553161e5ac4784d70426a57434e003b1287 | |
| parent | 4029fdd82bdd657ed862c89f20eb03ff2594cde9 (diff) | |
feat: add Changestats struct and storage support
- Add task.Changestats{FilesChanged, LinesAdded, LinesRemoved}
- Add changestats_json column to executions via additive migration
- Add Changestats field to storage.Execution struct
- Add UpdateExecutionChangestats(execID, *task.Changestats) method
- Update all SELECT/INSERT/scan paths for executions
- Test: TestExecution_StoreAndRetrieveChangestats (was red, now green)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | internal/storage/db.go | 44 | ||||
| -rw-r--r-- | internal/storage/db_test.go | 42 | ||||
| -rw-r--r-- | internal/task/task.go | 7 |
3 files changed, 86 insertions, 7 deletions
diff --git a/internal/storage/db.go b/internal/storage/db.go index 043009c..2b7e33f 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -83,6 +83,7 @@ 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`, } for _, m := range migrations { if _, err := s.db.Exec(m); err != nil { @@ -366,6 +367,8 @@ 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 + // In-memory only: set when creating a resume execution, not stored in DB. ResumeSessionID string ResumeAnswer string @@ -375,24 +378,33 @@ 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 + } _, 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) + 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, ) 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 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 FROM executions WHERE task_id = ? ORDER BY start_time DESC`, taskID) if err != nil { return nil, err } @@ -411,7 +423,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 FROM executions WHERE task_id = ? ORDER BY start_time DESC LIMIT 1`, taskID) return scanExecution(row) } @@ -647,16 +659,34 @@ func scanExecution(row scanner) (*Execution, error) { var e Execution var sessionID sql.NullString var sandboxDir sql.NullString + var changestatsJSON 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) 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 + } 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/task.go b/internal/task/task.go index 69da5f3..b85b07c 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -48,6 +48,13 @@ type RetryConfig struct { Backoff string `yaml:"backoff" json:"backoff"` // "linear", "exponential" } +// 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"` |
