Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
58 changes: 58 additions & 0 deletions internal/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package server

import (
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
Expand Down Expand Up @@ -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}

Expand Down
35 changes: 35 additions & 0 deletions internal/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
43 changes: 43 additions & 0 deletions internal/store/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions internal/web/assets/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
52 changes: 51 additions & 1 deletion internal/web/assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 })
)
),
]),
]);
Expand All @@ -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))),
])
);
}
Expand All @@ -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],
Expand Down
Loading