From 110da5f3cc1738d5de53befde445d654a8cee514 Mon Sep 17 00:00:00 2001 From: "coding-agent-loop[bot]" Date: Sun, 6 Sep 2026 07:04:40 -0400 Subject: [PATCH] Add delete-run and stop-run controls to the web console DeleteRun on the store removes a run's events, sessions rows, and the run row itself in one transaction; the new POST /runs/:id/delete handler cancels an in-flight run first (best-effort, async) then deletes the DB rows and the on-disk transcript. The runs list and run detail pages gain Stop/Delete buttons, with a native confirm() dialog gating the delete. Closes #23 Co-Authored-By: Claude Sonnet 5 --- internal/server/server.go | 32 +++++++++++++++++++ internal/server/server_test.go | 58 ++++++++++++++++++++++++++++++++++ internal/store/store.go | 35 ++++++++++++++++++++ internal/store/store_test.go | 43 +++++++++++++++++++++++++ internal/web/assets/app.css | 5 +++ internal/web/assets/app.js | 52 +++++++++++++++++++++++++++++- 6 files changed, 224 insertions(+), 1 deletion(-) diff --git a/internal/server/server.go b/internal/server/server.go index e9feab2..9189855 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -107,6 +107,7 @@ func (s *Server) routes() { s.app.Post("/pause", s.pause) s.app.Post("/resume", s.resume) s.app.Post("/runs/:id/cancel", s.cancelRun) + s.app.Post("/runs/:id/delete", s.deleteRun) s.app.Get("/config", s.getConfig) s.app.Get("/models", s.getModels) s.app.Post("/poll", s.pollNow) @@ -420,6 +421,37 @@ func (s *Server) cancelRun(c fiber.Ctx) error { return c.JSON(fiber.Map{"cancelled": true, "run": id}) } +// deleteRun permanently removes a run and every trace of it: its DB rows and +// its on-disk transcript. If the run is still in flight, it is cancelled +// first — cancellation is asynchronous, so the delete proceeds regardless of +// whether the run has actually reached a terminal state yet. +func (s *Server) deleteRun(c fiber.Ctx) error { + id := fiber.Params(c, "id", "") + run, err := s.store.GetRun(c.Context(), id) + if errors.Is(err, store.ErrNotFound) { + return s.fail(c, http.StatusNotFound, errors.New("no such run")) + } + if err != nil { + return s.fail(c, http.StatusInternalServerError, err) + } + + if !store.IsTerminal(run.Status) && s.ctrl != nil { + s.ctrl.Cancel(id) + } + + if err := s.store.DeleteRun(c.Context(), id); err != nil { + return s.fail(c, http.StatusInternalServerError, err) + } + if run.LogPath != "" { + if err := os.Remove(run.LogPath); err != nil && !os.IsNotExist(err) { + s.log.Warn("failed to remove run transcript", "run", id, "path", run.LogPath, "error", err) + } + } + + s.log.Info("run deleted by operator", "run", id) + return c.JSON(fiber.Map{"deleted": true, "run": id}) +} + // getConfig returns the daemon's configuration for the web console, with the // Discord webhook URL and web console password blanked: they are secrets, // everything else here is not. diff --git a/internal/server/server_test.go b/internal/server/server_test.go index fb14c60..c6b7442 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -2,6 +2,7 @@ package server import ( "encoding/json" + "errors" "io" "net/http" "net/http/httptest" @@ -251,6 +252,63 @@ func TestCancelRunWithNoStoreRowStillSucceeds(t *testing.T) { } } +func TestDeleteRun(t *testing.T) { + s, st, ctrl := testServer(t) + ctx := t.Context() + + logPath := filepath.Join(t.TempDir(), "run-1.jsonl") + if err := os.WriteFile(logPath, []byte(`{"type":"result"}`+"\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := st.CreateRun(ctx, store.Run{ + ID: "run-1", Repo: "acme/widgets", Issue: 42, Attempt: 1, + Status: store.StatusPROpen, StartedAt: time.Now(), LogPath: logPath, + }); err != nil { + t.Fatal(err) + } + + code, body := do(t, s, http.MethodPost, "/runs/run-1/delete", nil) + if code != http.StatusOK || body["deleted"] != true { + t.Fatalf("delete = %d %v", code, body) + } + if len(ctrl.cancelled) != 0 { + t.Fatalf("a terminal run should not be cancelled before deletion: %v", ctrl.cancelled) + } + if _, err := st.GetRun(ctx, "run-1"); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("run should be gone from the store, got %v", err) + } + if _, err := os.Stat(logPath); !os.IsNotExist(err) { + t.Fatalf("transcript should be removed, stat err = %v", err) + } + + if code, _ := do(t, s, http.MethodPost, "/runs/nope/delete", nil); code != http.StatusNotFound { + t.Fatalf("deleting an unknown run should 404, got %d", code) + } +} + +func TestDeleteRunStopsInFlightRunFirst(t *testing.T) { + s, st, ctrl := testServer(t) + ctx := t.Context() + + if err := st.CreateRun(ctx, store.Run{ + ID: "run-1", Repo: "acme/widgets", Issue: 42, Attempt: 1, + Status: store.StatusWorking, StartedAt: time.Now(), + }); err != nil { + t.Fatal(err) + } + + code, body := do(t, s, http.MethodPost, "/runs/run-1/delete", nil) + if code != http.StatusOK || body["deleted"] != true { + t.Fatalf("delete = %d %v", code, body) + } + if len(ctrl.cancelled) != 1 || ctrl.cancelled[0] != "run-1" { + t.Fatalf("in-flight run should be cancelled before deletion: %v", ctrl.cancelled) + } + if _, err := st.GetRun(ctx, "run-1"); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("run should be gone from the store, got %v", err) + } +} + // fiberTimeout gives handlers room on a loaded machine; the default is 1s. var fiberTimeout = fiber.TestConfig{Timeout: 10 * time.Second, FailOnTimeout: true} diff --git a/internal/store/store.go b/internal/store/store.go index a3b37d6..955ff3e 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -595,6 +595,41 @@ func (s *Store) FailRun(ctx context.Context, runID, status, msg string) error { return nil } +// DeleteRun permanently removes a run and every trace of it in the database: +// its events, its sessions rows, and the runs row itself. There is no undo. +// There are no declared foreign keys between runs, events, and sessions, so +// the cleanup is done manually inside one transaction rather than relying on +// cascade. +func (s *Store) DeleteRun(ctx context.Context, runID string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("delete run %s: %w", runID, err) + } + defer tx.Rollback() + + if _, err := tx.ExecContext(ctx, `DELETE FROM events WHERE run_id = ?`, runID); err != nil { + return fmt.Errorf("delete run %s events: %w", runID, err) + } + if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE run_id = ?`, runID); err != nil { + return fmt.Errorf("delete run %s sessions: %w", runID, err) + } + res, err := tx.ExecContext(ctx, `DELETE FROM runs WHERE id = ?`, runID) + if err != nil { + return fmt.Errorf("delete run %s: %w", runID, err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("delete run %s: %w", runID, err) + } + if n == 0 { + return ErrNotFound + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("delete run %s: %w", runID, err) + } + return nil +} + var errNoRows = errors.New("not found") // ErrNotFound is returned when a lookup finds nothing. diff --git a/internal/store/store_test.go b/internal/store/store_test.go index c39b5c8..eaab90d 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -284,6 +284,49 @@ func TestGetRunNotFound(t *testing.T) { } } +func TestDeleteRun(t *testing.T) { + ctx := context.Background() + st := testStore(t) + + if err := st.CreateRun(ctx, Run{ID: "r1", Repo: "o/r", Issue: 1, Status: StatusPROpen, StartedAt: time.Now()}); err != nil { + t.Fatal(err) + } + if err := st.AppendEvent(ctx, "r1", "claimed", "attempt 1"); err != nil { + t.Fatal(err) + } + if err := st.RecordSession(ctx, Session{SessionID: "sess-1", RunID: "r1", Repo: "o/r", Issue: 1}); err != nil { + t.Fatal(err) + } + + if err := st.DeleteRun(ctx, "r1"); err != nil { + t.Fatal(err) + } + + if _, err := st.GetRun(ctx, "r1"); err != ErrNotFound { + t.Fatalf("run should be gone, got %v", err) + } + events, err := st.ListEvents(ctx, "r1") + if err != nil { + t.Fatal(err) + } + if len(events) != 0 { + t.Fatalf("events should be gone, got %+v", events) + } + sessions, err := st.ListSessions(ctx, "o/r", 1, 0) + if err != nil { + t.Fatal(err) + } + if len(sessions) != 0 { + t.Fatalf("sessions should be gone, got %+v", sessions) + } +} + +func TestDeleteRunNotFound(t *testing.T) { + if err := testStore(t).DeleteRun(context.Background(), "nope"); err != ErrNotFound { + t.Fatalf("want ErrNotFound, got %v", err) + } +} + func TestMigrationsAreIdempotent(t *testing.T) { path := filepath.Join(t.TempDir(), "state.db") for i := range 3 { diff --git a/internal/web/assets/app.css b/internal/web/assets/app.css index cf1b6e4..5494fd0 100644 --- a/internal/web/assets/app.css +++ b/internal/web/assets/app.css @@ -182,6 +182,11 @@ input:focus-visible { padding: 6px 8px; } +.row-actions { + display: flex; + gap: 6px; +} + .select { border: 1px solid var(--border); background: var(--surface); diff --git a/internal/web/assets/app.js b/internal/web/assets/app.js index 2e74fbf..d9c81ce 100644 --- a/internal/web/assets/app.js +++ b/internal/web/assets/app.js @@ -576,6 +576,48 @@ function inFlightTable(runs) { // --- Runs list ------------------------------------------------------------- +const IN_FLIGHT_STATUSES = ["claimed", "working", "verifying", "pushed"]; + +// stopAndDeleteRun cancels a run first if it's still in flight (cancellation +// is async, so this doesn't wait on it reaching a terminal status) then +// deletes it and everything about it from the database and disk. +async function stopAndDeleteRun(run) { + if (!window.confirm("Delete this run permanently? This cannot be undone.")) return false; + if (IN_FLIGHT_STATUSES.includes(run.Status)) { + await api.post(`/runs/${encodeURIComponent(run.ID)}/cancel`); + } + await api.post(`/runs/${encodeURIComponent(run.ID)}/delete`); + return true; +} + +function runActionButtons(run, onDeleted) { + const actions = el("div", { class: "row-actions" }); + if (IN_FLIGHT_STATUSES.includes(run.Status)) { + const stopBtn = el("button", { class: "btn", type: "button", text: "Stop" }); + stopBtn.addEventListener("click", async () => { + stopBtn.disabled = true; + try { + await api.post(`/runs/${encodeURIComponent(run.ID)}/cancel`); + await refreshStatus(); + } finally { + stopBtn.disabled = false; + } + }); + actions.appendChild(stopBtn); + } + const deleteBtn = el("button", { class: "btn btn-danger", type: "button", text: "Delete" }); + deleteBtn.addEventListener("click", async () => { + deleteBtn.disabled = true; + try { + if (await stopAndDeleteRun(run)) await onDeleted(); + } finally { + deleteBtn.disabled = false; + } + }); + actions.appendChild(deleteBtn); + return actions; +} + let runsFilters = { repo: "", limit: 50, status: "", kind: "" }; async function renderRuns(silent) { @@ -636,7 +678,9 @@ async function renderRuns(silent) { el( "tr", {}, - ["Repo", "Status", "Kind", "Model", "Attempt", "Cost", "Tokens", "Verify", "Duration", "PR"].map((h) => el("th", { text: h })) + ["Repo", "Status", "Kind", "Model", "Attempt", "Cost", "Tokens", "Verify", "Duration", "PR", "Actions"].map((h) => + el("th", { text: h }) + ) ), ]), ]); @@ -661,6 +705,7 @@ function runsTableBody(runs) { td("Verify", r.VerifyStatus || "—"), td("Duration", fmtDuration(r.StartedAt, r.EndedAt)), td("PR", prCell), + td("Actions", runActionButtons(r, () => renderRuns(false))), ]) ); } @@ -684,6 +729,11 @@ async function renderRunDetail(id, silent) { container.appendChild( el("h1", {}, [document.createTextNode(`${run.Repo}#${run.Issue} `), statusBadge(run.Status)]) ); + container.appendChild( + runActionButtons(run, async () => { + window.location.hash = "#/runs"; + }) + ); const fields = [ ["Run ID", run.ID],