Skip to content

Commit ccec712

Browse files
committed
feat: add ability to delete individual runs
Add CLI command 'gob runs delete <run_id>' and TUI keybinding 'd' in the Runs panel to delete stopped runs and their log files. Job statistics (run count, success rate, durations) are recalculated after deletion.
1 parent 1e6eac3 commit ccec712

File tree

11 files changed

+501
-1
lines changed

11 files changed

+501
-1
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- **Delete individual runs**: Remove stopped runs and their log files without deleting the entire job
13+
- CLI: `gob runs delete <run_id>` - delete a specific run by ID
14+
- TUI: Press `d` in the Runs panel to delete the selected run
15+
- Only stopped runs can be deleted; running runs must be stopped first
16+
- Deletes associated stdout/stderr log files from disk
17+
- Job statistics (run count, success rate, durations) are recalculated after deletion
18+
1219
- **TUI stderr panel expansion**: The stderr panel now expands to 80% of the right side when focused, making it easier to read error output
1320

1421
- **TUI progress bar**: Shows a progress bar in the Runs panel when a job is running

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ The TUI has an info bar and five panels:
261261
| `w` | Toggle line wrap |
262262
| `s/S` | Stop / kill job |
263263
| `r` | Restart job |
264-
| `d` | Delete stopped job |
264+
| `d` | Delete stopped job/run |
265265
| `n` | New job |
266266
| `1/2/3/4/5` | Switch to panel |
267267
| `?` | Show all shortcuts |
@@ -313,6 +313,7 @@ Run `gob <command> --help` for detailed usage, examples, and flags.
313313
| `await-all` | Wait for all jobs to complete (`--timeout`) |
314314
| `list` | List jobs (`--all` for all directories) |
315315
| `runs <id>` | Show run history for a job |
316+
| `runs delete <run_id>` | Delete a stopped run and its logs |
316317
| `stats <id>` | Show statistics for a job |
317318
| `stdout <id>` | View stdout (`--follow` for real-time) |
318319
| `stderr <id>` | View stderr (`--follow` for real-time) |

cmd/runs.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ Example output:
3434
abc-4 1 hour ago 2m15s ✓ (0)
3535
abc-3 2 hours ago 2m45s ✗ (1)
3636
37+
Subcommands:
38+
runs delete <run_id> Delete a stopped run and its log files
39+
3740
Exit codes:
3841
0: Success
3942
1: Error (job not found)`,
@@ -106,6 +109,46 @@ Exit codes:
106109
},
107110
}
108111

112+
var runsDeleteCmd = &cobra.Command{
113+
Use: "delete <run_id>",
114+
Short: "Delete a stopped run and its log files",
115+
Long: `Delete a stopped run and its associated log files.
116+
117+
The run must be stopped (not currently running). To delete a running run,
118+
first stop the job with 'gob stop <job_id>'.
119+
120+
Examples:
121+
gob runs delete abc-1
122+
gob runs delete myserver-5
123+
124+
Exit codes:
125+
0: Success
126+
1: Error (run not found, run still running)`,
127+
Args: cobra.ExactArgs(1),
128+
RunE: func(cmd *cobra.Command, args []string) error {
129+
runID := args[0]
130+
131+
// Connect to daemon
132+
client, err := daemon.NewClient()
133+
if err != nil {
134+
return fmt.Errorf("failed to create client: %w", err)
135+
}
136+
defer client.Close()
137+
138+
if err := client.Connect(); err != nil {
139+
return fmt.Errorf("failed to connect to daemon: %w", err)
140+
}
141+
142+
// Delete the run
143+
if err := client.RemoveRun(runID); err != nil {
144+
return err
145+
}
146+
147+
fmt.Printf("Deleted run %s\n", runID)
148+
return nil
149+
},
150+
}
151+
109152
// formatRelativeTime formats a time as a human-readable relative string
110153
func formatRelativeTime(t time.Time) string {
111154
d := time.Since(t)
@@ -136,4 +179,5 @@ func formatRelativeTime(t time.Time) string {
136179
func init() {
137180
RootCmd.AddCommand(runsCmd)
138181
runsCmd.Flags().BoolVar(&runsJSON, "json", false, "Output in JSON format")
182+
runsCmd.AddCommand(runsDeleteCmd)
139183
}

internal/daemon/client.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,23 @@ func (c *Client) Remove(jobID string) (int, error) {
385385
return int(pid), nil
386386
}
387387

388+
// RemoveRun removes a stopped run and its log files
389+
func (c *Client) RemoveRun(runID string) error {
390+
req := NewRequest(RequestTypeRemoveRun)
391+
req.Payload["run_id"] = runID
392+
393+
resp, err := c.SendRequest(req)
394+
if err != nil {
395+
return err
396+
}
397+
398+
if !resp.Success {
399+
return fmt.Errorf("%s", resp.Error)
400+
}
401+
402+
return nil
403+
}
404+
388405
// StopAll stops all running jobs
389406
func (c *Client) StopAll() (stopped int, err error) {
390407
req := NewRequest(RequestTypeStopAll)

internal/daemon/daemon.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,8 @@ func (d *Daemon) handleRequest(req *Request) *Response {
356356
return d.handleStats(req)
357357
case RequestTypePorts:
358358
return d.handlePorts(req)
359+
case RequestTypeRemoveRun:
360+
return d.handleRemoveRun(req)
359361
default:
360362
return NewErrorResponse(fmt.Errorf("unknown request type: %s", req.Type))
361363
}
@@ -619,6 +621,22 @@ func (d *Daemon) handleRemove(req *Request) *Response {
619621
return resp
620622
}
621623

624+
// handleRemoveRun handles a remove_run request
625+
func (d *Daemon) handleRemoveRun(req *Request) *Response {
626+
runID, ok := req.Payload["run_id"].(string)
627+
if !ok {
628+
return NewErrorResponse(fmt.Errorf("missing run_id"))
629+
}
630+
631+
if err := d.jobManager.RemoveRun(runID); err != nil {
632+
return NewErrorResponse(err)
633+
}
634+
635+
resp := NewSuccessResponse()
636+
resp.Data["run_id"] = runID
637+
return resp
638+
}
639+
622640
// handleStopAll handles a stop_all request
623641
func (d *Daemon) handleStopAll(req *Request) *Response {
624642
stopped := d.jobManager.StopAll()

internal/daemon/daemon_test.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,3 +464,109 @@ func TestDaemon_handlePorts_AllJobs(t *testing.T) {
464464
t.Error("expected ports in response")
465465
}
466466
}
467+
468+
func TestDaemon_handleRemoveRun(t *testing.T) {
469+
tmpDir := t.TempDir()
470+
executor := NewFakeProcessExecutor()
471+
jm := NewJobManagerWithExecutor(tmpDir, nil, executor, nil)
472+
473+
// Add a job (which creates a run)
474+
job, _, _ := jm.AddJob([]string{"echo"}, "/workdir", "", nil)
475+
476+
// Get the run ID
477+
runs, _ := jm.ListRunsForJob(job.ID)
478+
runID := runs[0].ID
479+
480+
// Stop the process first
481+
executor.LastHandle().Stop()
482+
time.Sleep(10 * time.Millisecond)
483+
484+
d := &Daemon{jobManager: jm}
485+
req := &Request{
486+
Type: RequestTypeRemoveRun,
487+
Payload: map[string]interface{}{
488+
"run_id": runID,
489+
},
490+
}
491+
492+
resp := d.handleRequest(req)
493+
494+
if !resp.Success {
495+
t.Errorf("expected success, got error: %s", resp.Error)
496+
}
497+
498+
if resp.Data["run_id"] != runID {
499+
t.Errorf("expected run_id %s in response, got %v", runID, resp.Data["run_id"])
500+
}
501+
502+
// Verify run is actually removed
503+
runs, _ = jm.ListRunsForJob(job.ID)
504+
if len(runs) != 0 {
505+
t.Errorf("expected 0 runs after removal, got %d", len(runs))
506+
}
507+
}
508+
509+
func TestDaemon_handleRemoveRun_MissingRunID(t *testing.T) {
510+
tmpDir := t.TempDir()
511+
executor := NewFakeProcessExecutor()
512+
jm := NewJobManagerWithExecutor(tmpDir, nil, executor, nil)
513+
514+
d := &Daemon{jobManager: jm}
515+
req := &Request{
516+
Type: RequestTypeRemoveRun,
517+
Payload: map[string]interface{}{},
518+
}
519+
520+
resp := d.handleRequest(req)
521+
522+
if resp.Success {
523+
t.Error("expected error for missing run_id")
524+
}
525+
}
526+
527+
func TestDaemon_handleRemoveRun_NotFound(t *testing.T) {
528+
tmpDir := t.TempDir()
529+
executor := NewFakeProcessExecutor()
530+
jm := NewJobManagerWithExecutor(tmpDir, nil, executor, nil)
531+
532+
d := &Daemon{jobManager: jm}
533+
req := &Request{
534+
Type: RequestTypeRemoveRun,
535+
Payload: map[string]interface{}{
536+
"run_id": "nonexistent-1",
537+
},
538+
}
539+
540+
resp := d.handleRequest(req)
541+
542+
if resp.Success {
543+
t.Error("expected error for nonexistent run")
544+
}
545+
}
546+
547+
func TestDaemon_handleRemoveRun_Running(t *testing.T) {
548+
tmpDir := t.TempDir()
549+
executor := NewFakeProcessExecutor()
550+
jm := NewJobManagerWithExecutor(tmpDir, nil, executor, nil)
551+
552+
// Add a job (which creates a running run)
553+
job, _, _ := jm.AddJob([]string{"echo"}, "/workdir", "", nil)
554+
555+
// Get the run ID while it's still running
556+
runs, _ := jm.ListRunsForJob(job.ID)
557+
runID := runs[0].ID
558+
559+
d := &Daemon{jobManager: jm}
560+
req := &Request{
561+
Type: RequestTypeRemoveRun,
562+
Payload: map[string]interface{}{
563+
"run_id": runID,
564+
},
565+
}
566+
567+
resp := d.handleRequest(req)
568+
569+
if resp.Success {
570+
t.Error("expected error for running run")
571+
}
572+
}

internal/daemon/db.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,12 @@ func (s *Store) UpdateRun(run *Run) error {
177177
return err
178178
}
179179

180+
// DeleteRun removes a run from the database
181+
func (s *Store) DeleteRun(runID string) error {
182+
_, err := s.db.Exec("DELETE FROM runs WHERE id = ?", runID)
183+
return err
184+
}
185+
180186
// LoadJobs loads all jobs from the database
181187
func (s *Store) LoadJobs() ([]*Job, error) {
182188
rows, err := s.db.Query(`

internal/daemon/job.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1004,6 +1004,112 @@ func (jm *JobManager) RemoveJob(jobID string) error {
10041004
return nil
10051005
}
10061006

1007+
// RemoveRun removes a stopped run and its log files
1008+
func (jm *JobManager) RemoveRun(runID string) error {
1009+
jm.mu.Lock()
1010+
defer jm.mu.Unlock()
1011+
1012+
run, ok := jm.runs[runID]
1013+
if !ok {
1014+
return fmt.Errorf("run not found: %s", runID)
1015+
}
1016+
1017+
// Check if run is currently running
1018+
if run.Status == "running" {
1019+
return fmt.Errorf("cannot remove running run: %s (stop the job first)", runID)
1020+
}
1021+
1022+
// Get the job for stats update
1023+
job, jobExists := jm.jobs[run.JobID]
1024+
1025+
// Capture run info for event before deletion
1026+
runResp := runToResponse(run)
1027+
1028+
// Update job statistics if job exists
1029+
if jobExists && run.StoppedAt != nil {
1030+
durationMs := run.StoppedAt.Sub(run.StartedAt).Milliseconds()
1031+
1032+
// Decrement counts
1033+
job.RunCount--
1034+
if run.ExitCode != nil && *run.ExitCode == 0 {
1035+
job.SuccessCount--
1036+
job.SuccessTotalDurationMs -= durationMs
1037+
} else if run.ExitCode != nil {
1038+
job.FailureCount--
1039+
job.FailureTotalDurationMs -= durationMs
1040+
}
1041+
// Killed processes (ExitCode == nil) only affect RunCount
1042+
1043+
// Recalculate min/max duration from remaining runs
1044+
jm.recalculateMinMaxDuration(job)
1045+
}
1046+
1047+
// Delete log files
1048+
os.Remove(run.StdoutPath)
1049+
os.Remove(run.StderrPath)
1050+
1051+
// Remove from in-memory map
1052+
delete(jm.runs, runID)
1053+
1054+
// Delete from database and update job stats
1055+
if jm.store != nil {
1056+
if err := jm.store.DeleteRun(runID); err != nil {
1057+
Logger.Warn("failed to delete run from database", "id", runID, "error", err)
1058+
}
1059+
if jobExists {
1060+
if err := jm.store.UpdateJob(job); err != nil {
1061+
Logger.Warn("failed to update job stats", "id", job.ID, "error", err)
1062+
}
1063+
}
1064+
}
1065+
1066+
// Emit removed event with updated stats
1067+
var jobResp JobResponse
1068+
var stats *StatsResponse
1069+
if jobExists {
1070+
jobResp = jm.jobToResponse(job)
1071+
s := jobToStats(job)
1072+
stats = &s
1073+
}
1074+
jm.emitEvent(Event{
1075+
Type: EventTypeRunRemoved,
1076+
JobID: run.JobID,
1077+
Job: jobResp,
1078+
Run: &runResp,
1079+
Stats: stats,
1080+
JobCount: len(jm.jobs),
1081+
RunningJobCount: jm.countRunningJobsLocked(),
1082+
})
1083+
1084+
return nil
1085+
}
1086+
1087+
// recalculateMinMaxDuration recalculates min/max duration from all stopped runs for a job
1088+
func (jm *JobManager) recalculateMinMaxDuration(job *Job) {
1089+
job.MinDurationMs = 0
1090+
job.MaxDurationMs = 0
1091+
1092+
first := true
1093+
for _, run := range jm.runs {
1094+
if run.JobID != job.ID || run.StoppedAt == nil {
1095+
continue
1096+
}
1097+
durationMs := run.StoppedAt.Sub(run.StartedAt).Milliseconds()
1098+
if first {
1099+
job.MinDurationMs = durationMs
1100+
job.MaxDurationMs = durationMs
1101+
first = false
1102+
} else {
1103+
if durationMs < job.MinDurationMs {
1104+
job.MinDurationMs = durationMs
1105+
}
1106+
if durationMs > job.MaxDurationMs {
1107+
job.MaxDurationMs = durationMs
1108+
}
1109+
}
1110+
}
1111+
}
1112+
10071113
// StopAll stops all running jobs and their process trees
10081114
func (jm *JobManager) StopAll() (stopped int) {
10091115
jm.mu.Lock()

0 commit comments

Comments
 (0)