tenseleyflow/shithub / 7da9698

Browse files

web/actions: expose cancellation controls

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
7da9698f913b38ff25177547d5aa97fdf24311e1
Parents
4fc3462
Tree
6051c07

11 changed files

StatusFile+-
A internal/web/handlers/api/actions_cancel.go 100 0
M internal/web/handlers/api/api.go 2 0
M internal/web/handlers/api/runners.go 25 50
M internal/web/handlers/api/runners_test.go 70 0
M internal/web/handlers/repo/actions.go 17 2
A internal/web/handlers/repo/actions_cancel.go 129 0
M internal/web/handlers/repo/actions_test.go 114 0
M internal/web/handlers/repo/repo.go 3 3
M internal/web/handlers/repo/repo_test.go 1 1
M internal/web/static/css/shithub.css 15 0
M internal/web/templates/repo/action_run.html 18 0
internal/web/handlers/api/actions_cancel.goadded
@@ -0,0 +1,100 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package api
4
+
5
+import (
6
+	"errors"
7
+	"net/http"
8
+	"strconv"
9
+
10
+	"github.com/go-chi/chi/v5"
11
+	"github.com/jackc/pgx/v5"
12
+
13
+	actionslifecycle "github.com/tenseleyFlow/shithub/internal/actions/lifecycle"
14
+	actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
15
+	"github.com/tenseleyFlow/shithub/internal/auth/pat"
16
+	"github.com/tenseleyFlow/shithub/internal/auth/policy"
17
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
18
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
19
+)
20
+
21
+// mountActionsLifecycle registers user/PAT-authenticated Actions lifecycle
22
+// controls. Runner-owned job endpoints stay in runners.go and use job JWTs.
23
+func (h *Handlers) mountActionsLifecycle(r chi.Router) {
24
+	r.Group(func(r chi.Router) {
25
+		r.Use(middleware.RequireScope(pat.ScopeRepoWrite))
26
+		r.Post("/api/v1/jobs/{id}/cancel", h.workflowJobCancel)
27
+	})
28
+}
29
+
30
+func (h *Handlers) workflowJobCancel(w http.ResponseWriter, r *http.Request) {
31
+	auth := middleware.PATAuthFromContext(r.Context())
32
+	if auth.UserID == 0 {
33
+		writeAPIError(w, http.StatusUnauthorized, "unauthenticated")
34
+		return
35
+	}
36
+	jobID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
37
+	if err != nil || jobID <= 0 {
38
+		writeAPIError(w, http.StatusNotFound, "job not found")
39
+		return
40
+	}
41
+	job, run, repo, ok := h.resolveCancellableJob(w, r, auth.UserID, jobID)
42
+	if !ok {
43
+		return
44
+	}
45
+	result, err := actionslifecycle.CancelJob(r.Context(), actionslifecycle.Deps{
46
+		Pool:   h.d.Pool,
47
+		Logger: h.d.Logger,
48
+	}, job.ID, actionslifecycle.CancelReasonUser)
49
+	if err != nil {
50
+		h.d.Logger.WarnContext(r.Context(), "api actions cancel job", "job_id", job.ID, "error", err)
51
+		writeAPIError(w, http.StatusInternalServerError, "cancel failed")
52
+		return
53
+	}
54
+	writeJSON(w, http.StatusAccepted, map[string]any{
55
+		"job_id":         job.ID,
56
+		"run_id":         run.ID,
57
+		"repo_id":        repo.ID,
58
+		"changed_jobs":   len(result.ChangedJobs),
59
+		"run_completed":  result.RunCompleted,
60
+		"run_conclusion": string(result.RunConclusion),
61
+	})
62
+}
63
+
64
+func (h *Handlers) resolveCancellableJob(
65
+	w http.ResponseWriter,
66
+	r *http.Request,
67
+	userID int64,
68
+	jobID int64,
69
+) (actionsdb.WorkflowJob, actionsdb.WorkflowRun, reposdb.Repo, bool) {
70
+	q := actionsdb.New()
71
+	job, err := q.GetWorkflowJobByID(r.Context(), h.d.Pool, jobID)
72
+	if err != nil {
73
+		writeAPIError(w, http.StatusNotFound, "job not found")
74
+		return actionsdb.WorkflowJob{}, actionsdb.WorkflowRun{}, reposdb.Repo{}, false
75
+	}
76
+	run, err := q.GetWorkflowRunByID(r.Context(), h.d.Pool, job.RunID)
77
+	if err != nil {
78
+		if errors.Is(err, pgx.ErrNoRows) {
79
+			writeAPIError(w, http.StatusNotFound, "job not found")
80
+		} else {
81
+			writeAPIError(w, http.StatusInternalServerError, "run lookup failed")
82
+		}
83
+		return actionsdb.WorkflowJob{}, actionsdb.WorkflowRun{}, reposdb.Repo{}, false
84
+	}
85
+	repo, err := reposdb.New().GetRepoByID(r.Context(), h.d.Pool, run.RepoID)
86
+	if err != nil {
87
+		if errors.Is(err, pgx.ErrNoRows) {
88
+			writeAPIError(w, http.StatusNotFound, "job not found")
89
+		} else {
90
+			writeAPIError(w, http.StatusInternalServerError, "repo lookup failed")
91
+		}
92
+		return actionsdb.WorkflowJob{}, actionsdb.WorkflowRun{}, reposdb.Repo{}, false
93
+	}
94
+	actor := policy.UserActor(userID, "", false, false)
95
+	if !policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, policy.ActionRepoWrite, policy.NewRepoRefFromRepo(repo)).Allow {
96
+		writeAPIError(w, http.StatusNotFound, "job not found")
97
+		return actionsdb.WorkflowJob{}, actionsdb.WorkflowRun{}, reposdb.Repo{}, false
98
+	}
99
+	return job, run, repo, true
100
+}
internal/web/handlers/api/api.gomodified
@@ -95,6 +95,8 @@ func (h *Handlers) Mount(r chi.Router) {
9595
 		// inside the helper since reads need repo:read but writes need
9696
 		// repo:write.
9797
 		h.mountChecks(r)
98
+		// S41g Actions lifecycle controls.
99
+		h.mountActionsLifecycle(r)
98100
 		// S26 stars: PUT/DELETE need user:write, GET needs user:read.
99101
 		h.mountStars(r)
100102
 	})
internal/web/handlers/api/runners.gomodified
@@ -21,13 +21,12 @@ import (
2121
 	"github.com/jackc/pgx/v5/pgtype"
2222
 
2323
 	"github.com/tenseleyFlow/shithub/internal/actions/finalize"
24
+	actionslifecycle "github.com/tenseleyFlow/shithub/internal/actions/lifecycle"
2425
 	"github.com/tenseleyFlow/shithub/internal/actions/logstream"
2526
 	"github.com/tenseleyFlow/shithub/internal/actions/runnerlabels"
2627
 	"github.com/tenseleyFlow/shithub/internal/actions/runnertoken"
2728
 	actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
2829
 	"github.com/tenseleyFlow/shithub/internal/auth/runnerjwt"
29
-	"github.com/tenseleyFlow/shithub/internal/checks"
30
-	checksdb "github.com/tenseleyFlow/shithub/internal/checks/sqlc"
3130
 	"github.com/tenseleyFlow/shithub/internal/infra/metrics"
3231
 	"github.com/tenseleyFlow/shithub/internal/ratelimit"
3332
 	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
@@ -700,6 +699,24 @@ func (h *Handlers) applyJobStatus(
700699
 	if err != nil {
701700
 		return actionsdb.WorkflowJob{}, false, "", err
702701
 	}
702
+	notifyWorker := false
703
+	if updated.Status == actionsdb.WorkflowJobStatusCancelled {
704
+		steps, err := q.CancelOpenWorkflowStepsForJob(ctx, tx, updated.ID)
705
+		if err != nil {
706
+			return actionsdb.WorkflowJob{}, false, "", err
707
+		}
708
+		for _, step := range steps {
709
+			if err := logstream.NotifyDone(ctx, tx, step.ID); err != nil {
710
+				return actionsdb.WorkflowJob{}, false, "", err
711
+			}
712
+			if h.d.ObjectStore != nil {
713
+				if _, err := worker.Enqueue(ctx, tx, finalize.KindWorkflowFinalizeStep, finalize.Payload{StepID: step.ID}, worker.EnqueueOptions{}); err != nil {
714
+					return actionsdb.WorkflowJob{}, false, "", err
715
+				}
716
+				notifyWorker = true
717
+			}
718
+		}
719
+	}
703720
 	jobs, err := q.ListJobsForRun(ctx, tx, updated.RunID)
704721
 	if err != nil {
705722
 		return actionsdb.WorkflowJob{}, false, "", err
@@ -719,6 +736,11 @@ func (h *Handlers) applyJobStatus(
719736
 		return actionsdb.WorkflowJob{}, false, "", err
720737
 	}
721738
 	committed = true
739
+	if notifyWorker {
740
+		if err := worker.Notify(ctx, h.d.Pool); err != nil && h.d.Logger != nil {
741
+			h.d.Logger.WarnContext(ctx, "runner cancelled-step finalizer notify failed", "job_id", updated.ID, "error", err)
742
+		}
743
+	}
722744
 	return updated, complete, runConclusion, nil
723745
 }
724746
 
@@ -754,47 +776,7 @@ func deriveWorkflowRunConclusion(jobs []actionsdb.ListJobsForRunRow) (actionsdb.
754776
 }
755777
 
756778
 func (h *Handlers) updateCheckRunForJob(ctx context.Context, job actionsdb.WorkflowJob) error {
757
-	run, err := actionsdb.New().GetWorkflowRunByID(ctx, h.d.Pool, job.RunID)
758
-	if err != nil {
759
-		return err
760
-	}
761
-	name := job.JobName
762
-	if name == "" {
763
-		name = job.JobKey
764
-	}
765
-	checkRun, err := checksdb.New().GetCheckRunByExternalID(ctx, h.d.Pool, checksdb.GetCheckRunByExternalIDParams{
766
-		RepoID:     run.RepoID,
767
-		HeadSha:    run.HeadSha,
768
-		Name:       name,
769
-		ExternalID: pgtype.Text{String: fmt.Sprintf("workflow_run:%d:job:%s", job.RunID, job.JobKey), Valid: true},
770
-	})
771
-	if err != nil {
772
-		return err
773
-	}
774
-	params := checks.UpdateParams{
775
-		RunID:        checkRun.ID,
776
-		HasStatus:    true,
777
-		HasStartedAt: true,
778
-		StartedAt:    timeFromPg(job.StartedAt),
779
-	}
780
-	switch job.Status {
781
-	case actionsdb.WorkflowJobStatusRunning:
782
-		params.Status = "in_progress"
783
-	case actionsdb.WorkflowJobStatusCompleted, actionsdb.WorkflowJobStatusCancelled:
784
-		params.Status = "completed"
785
-		params.HasConclusion = true
786
-		if job.Conclusion.Valid {
787
-			params.Conclusion = string(job.Conclusion.CheckConclusion)
788
-		} else if job.Status == actionsdb.WorkflowJobStatusCancelled {
789
-			params.Conclusion = "cancelled"
790
-		}
791
-		params.HasCompletedAt = true
792
-		params.CompletedAt = timeFromPg(job.CompletedAt)
793
-	default:
794
-		return nil
795
-	}
796
-	_, err = checks.Update(ctx, checks.Deps{Pool: h.d.Pool, Logger: h.d.Logger}, params)
797
-	return err
779
+	return actionslifecycle.SyncCheckRunForJob(ctx, actionslifecycle.Deps{Pool: h.d.Pool, Logger: h.d.Logger}, job)
798780
 }
799781
 
800782
 type runnerArtifactUploadRequest struct {
@@ -1310,10 +1292,3 @@ func nullableConclusion(c actionsdb.NullCheckConclusion) any {
13101292
 	}
13111293
 	return string(c.CheckConclusion)
13121294
 }
1313
-
1314
-func timeFromPg(t pgtype.Timestamptz) time.Time {
1315
-	if !t.Valid {
1316
-		return time.Time{}
1317
-	}
1318
-	return t.Time
1319
-}
internal/web/handlers/api/runners_test.gomodified
@@ -26,6 +26,7 @@ import (
2626
 	actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
2727
 	"github.com/tenseleyFlow/shithub/internal/actions/trigger"
2828
 	"github.com/tenseleyFlow/shithub/internal/actions/workflow"
29
+	"github.com/tenseleyFlow/shithub/internal/auth/pat"
2930
 	"github.com/tenseleyFlow/shithub/internal/auth/runnerjwt"
3031
 	"github.com/tenseleyFlow/shithub/internal/auth/secretbox"
3132
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
@@ -446,6 +447,57 @@ func TestRunnerStepStatusEnqueuesFinalizeWorker(t *testing.T) {
446447
 	}
447448
 }
448449
 
450
+func TestWorkflowJobCancelAPIRequestsCancellation(t *testing.T) {
451
+	ctx := context.Background()
452
+	pool := dbtest.NewTestDB(t)
453
+	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
454
+	repoID, userID := setupRunnerAPIRepo(t, pool)
455
+	runID := enqueueRunnerAPIRun(t, pool, logger, repoID, userID)
456
+	jobs, err := actionsdb.New().ListJobsForRun(ctx, pool, runID)
457
+	if err != nil {
458
+		t.Fatalf("ListJobsForRun: %v", err)
459
+	}
460
+	if len(jobs) != 1 {
461
+		t.Fatalf("jobs: %+v", jobs)
462
+	}
463
+	rawPAT := mintRunnerAPIPAT(t, pool, userID, string(pat.ScopeRepoWrite))
464
+	router := newRunnerAPIRouter(t, pool, logger, runnerAPISigner(t, time.Now()))
465
+
466
+	req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/jobs/%d/cancel", jobs[0].ID), nil)
467
+	req.Header.Set("Authorization", "Bearer "+rawPAT)
468
+	rr := httptest.NewRecorder()
469
+	router.ServeHTTP(rr, req)
470
+	if rr.Code != http.StatusAccepted {
471
+		t.Fatalf("cancel status: got %d, want 202; body=%s", rr.Code, rr.Body.String())
472
+	}
473
+	var body struct {
474
+		ChangedJobs  int  `json:"changed_jobs"`
475
+		RunCompleted bool `json:"run_completed"`
476
+	}
477
+	if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
478
+		t.Fatalf("decode response: %v", err)
479
+	}
480
+	if body.ChangedJobs != 1 || !body.RunCompleted {
481
+		t.Fatalf("response: %+v", body)
482
+	}
483
+	job, err := actionsdb.New().GetWorkflowJobByID(ctx, pool, jobs[0].ID)
484
+	if err != nil {
485
+		t.Fatalf("GetWorkflowJobByID: %v", err)
486
+	}
487
+	if job.Status != actionsdb.WorkflowJobStatusCancelled || !job.CancelRequested ||
488
+		!job.Conclusion.Valid || job.Conclusion.CheckConclusion != actionsdb.CheckConclusionCancelled {
489
+		t.Fatalf("job: %+v", job)
490
+	}
491
+	run, err := actionsdb.New().GetWorkflowRunByID(ctx, pool, runID)
492
+	if err != nil {
493
+		t.Fatalf("GetWorkflowRunByID: %v", err)
494
+	}
495
+	if run.Status != actionsdb.WorkflowRunStatusCompleted ||
496
+		!run.Conclusion.Valid || run.Conclusion.CheckConclusion != actionsdb.CheckConclusionCancelled {
497
+		t.Fatalf("run: %+v", run)
498
+	}
499
+}
500
+
449501
 func postRunnerLogChunk(t *testing.T, router http.Handler, jobID int64, token string, seq int32, chunk []byte) string {
450502
 	t.Helper()
451503
 	body := fmt.Sprintf(`{"seq":%d,"chunk":%q}`, seq, base64.StdEncoding.EncodeToString(chunk))
@@ -612,6 +664,24 @@ func registerRunnerForTest(t *testing.T, pool *pgxpool.Pool, labels []string, ca
612664
 	return token, runner.ID
613665
 }
614666
 
667
+func mintRunnerAPIPAT(t *testing.T, pool *pgxpool.Pool, userID int64, scopes ...string) string {
668
+	t.Helper()
669
+	raw, hash, prefix, err := pat.Mint()
670
+	if err != nil {
671
+		t.Fatalf("pat.Mint: %v", err)
672
+	}
673
+	if _, err := usersdb.New().InsertUserToken(context.Background(), pool, usersdb.InsertUserTokenParams{
674
+		UserID:      userID,
675
+		Name:        "api test",
676
+		TokenHash:   hash,
677
+		TokenPrefix: prefix,
678
+		Scopes:      scopes,
679
+	}); err != nil {
680
+		t.Fatalf("InsertUserToken: %v", err)
681
+	}
682
+	return raw
683
+}
684
+
615685
 func containsString(items []string, want string) bool {
616686
 	for _, item := range items {
617687
 		if item == want {
internal/web/handlers/repo/actions.gomodified
@@ -107,6 +107,8 @@ type actionsRunDetailView struct {
107107
 	Duration       string
108108
 	IsTerminal     bool
109109
 	StatusHref     string
110
+	CancelHref     string
111
+	CanCancel      bool
110112
 	ActionsHref    string
111113
 	CodeHref       string
112114
 	ArtifactCount  int
@@ -130,8 +132,14 @@ type actionsJobDetailView struct {
130132
 	StateIcon  string
131133
 	Duration   string
132134
 	Anchor     string
133
-	Depth      int
134
-	Steps      []actionsStepDetailView
135
+	CancelHref string
136
+	CanCancel  bool
137
+	// CancelRequested is true after a running job has been asked to stop but
138
+	// before its runner has reported the terminal cancelled state.
139
+	CancelRequested bool
140
+	IsCancellable   bool
141
+	Depth           int
142
+	Steps           []actionsStepDetailView
135143
 }
136144
 
137145
 type actionsStepDetailView struct {
@@ -609,6 +617,7 @@ func (h *Handlers) repoActionRun(w http.ResponseWriter, r *http.Request) {
609617
 		}
610618
 		return
611619
 	}
620
+	h.applyActionsCancelControls(r, row, &view)
612621
 
613622
 	data := h.repoHeaderData(r, row, owner.Username, "actions")
614623
 	data["Title"] = view.Title + " #" + strconv.FormatInt(view.RunIndex, 10) + " · " + row.Name
@@ -755,6 +764,7 @@ func (h *Handlers) loadActionsRunDetail(ctx context.Context, repoID int64, owner
755764
 		Duration:       workflowRunDuration(run.Status, run.StartedAt, run.CompletedAt, run.CreatedAt, updatedAt, now),
756765
 		IsTerminal:     workflowRunTerminal(run.Status),
757766
 		StatusHref:     runPath + "/status",
767
+		CancelHref:     runPath + "/cancel",
758768
 		ActionsHref:    basePath,
759769
 		CodeHref:       "/" + owner + "/" + repoName + "/tree/" + codeTarget(run.HeadRef, run.HeadSha),
760770
 		ArtifactCount:  len(artifacts),
@@ -804,6 +814,11 @@ func actionsJobDetailViewFromRow(row actionsdb.ListJobsForRunRow, owner, repoNam
804814
 		StateIcon:  stateIcon,
805815
 		Duration:   actionItemDuration(string(row.Status), string(actionsdb.WorkflowJobStatusQueued), row.StartedAt, row.CompletedAt, row.CreatedAt, row.UpdatedAt, now),
806816
 		Anchor:     "job-" + strconv.FormatInt(int64(row.JobIndex), 10),
817
+		CancelHref: "/" + owner + "/" + repoName + "/actions/runs/" + strconv.FormatInt(runIndex, 10) +
818
+			"/jobs/" + strconv.FormatInt(int64(row.JobIndex), 10) + "/cancel",
819
+		CancelRequested: row.CancelRequested,
820
+		IsCancellable: row.Status == actionsdb.WorkflowJobStatusQueued ||
821
+			row.Status == actionsdb.WorkflowJobStatusRunning,
807822
 	}
808823
 }
809824
 
internal/web/handlers/repo/actions_cancel.goadded
@@ -0,0 +1,129 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package repo
4
+
5
+import (
6
+	"errors"
7
+	"net/http"
8
+	"strconv"
9
+
10
+	"github.com/jackc/pgx/v5"
11
+
12
+	actionslifecycle "github.com/tenseleyFlow/shithub/internal/actions/lifecycle"
13
+	actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
14
+	"github.com/tenseleyFlow/shithub/internal/auth/policy"
15
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
16
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
17
+)
18
+
19
+func (h *Handlers) repoActionRunCancel(w http.ResponseWriter, r *http.Request) {
20
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoWrite)
21
+	if !ok {
22
+		return
23
+	}
24
+	runIndex, ok := parsePositiveInt64Param(r, "runIndex")
25
+	if !ok {
26
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
27
+		return
28
+	}
29
+	run, err := actionsdb.New().GetWorkflowRunForRepoByIndex(r.Context(), h.d.Pool, actionsdb.GetWorkflowRunForRepoByIndexParams{
30
+		RepoID:   row.ID,
31
+		RunIndex: runIndex,
32
+	})
33
+	if err != nil {
34
+		if errors.Is(err, pgx.ErrNoRows) {
35
+			h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
36
+		} else {
37
+			h.d.Logger.WarnContext(r.Context(), "repo actions: lookup run for cancel", "repo_id", row.ID, "run_index", runIndex, "error", err)
38
+			h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
39
+		}
40
+		return
41
+	}
42
+	if _, err := actionslifecycle.CancelRun(r.Context(), actionslifecycle.Deps{
43
+		Pool:   h.d.Pool,
44
+		Logger: h.d.Logger,
45
+	}, run.ID, actionslifecycle.CancelReasonUser); err != nil {
46
+		h.d.Logger.WarnContext(r.Context(), "repo actions: cancel run", "run_id", run.ID, "error", err)
47
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
48
+		return
49
+	}
50
+	http.Redirect(w, r, repoActionRunHref(owner.Username, row.Name, runIndex), http.StatusSeeOther)
51
+}
52
+
53
+func (h *Handlers) repoActionJobCancel(w http.ResponseWriter, r *http.Request) {
54
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoWrite)
55
+	if !ok {
56
+		return
57
+	}
58
+	runIndex, ok := parsePositiveInt64Param(r, "runIndex")
59
+	if !ok {
60
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
61
+		return
62
+	}
63
+	jobIndex, ok := parseNonNegativeInt32Param(r, "jobIndex")
64
+	if !ok {
65
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
66
+		return
67
+	}
68
+	q := actionsdb.New()
69
+	run, err := q.GetWorkflowRunForRepoByIndex(r.Context(), h.d.Pool, actionsdb.GetWorkflowRunForRepoByIndexParams{
70
+		RepoID:   row.ID,
71
+		RunIndex: runIndex,
72
+	})
73
+	if err != nil {
74
+		if errors.Is(err, pgx.ErrNoRows) {
75
+			h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
76
+		} else {
77
+			h.d.Logger.WarnContext(r.Context(), "repo actions: lookup run for job cancel", "repo_id", row.ID, "run_index", runIndex, "error", err)
78
+			h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
79
+		}
80
+		return
81
+	}
82
+	jobs, err := q.ListJobsForRun(r.Context(), h.d.Pool, run.ID)
83
+	if err != nil {
84
+		h.d.Logger.WarnContext(r.Context(), "repo actions: list jobs for cancel", "run_id", run.ID, "error", err)
85
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
86
+		return
87
+	}
88
+	var jobID int64
89
+	for _, job := range jobs {
90
+		if job.JobIndex == jobIndex {
91
+			jobID = job.ID
92
+			break
93
+		}
94
+	}
95
+	if jobID == 0 {
96
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
97
+		return
98
+	}
99
+	if _, err := actionslifecycle.CancelJob(r.Context(), actionslifecycle.Deps{
100
+		Pool:   h.d.Pool,
101
+		Logger: h.d.Logger,
102
+	}, jobID, actionslifecycle.CancelReasonUser); err != nil {
103
+		h.d.Logger.WarnContext(r.Context(), "repo actions: cancel job", "job_id", jobID, "error", err)
104
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
105
+		return
106
+	}
107
+	http.Redirect(w, r, repoActionRunHref(owner.Username, row.Name, runIndex)+"#job-"+strconv.FormatInt(int64(jobIndex), 10), http.StatusSeeOther)
108
+}
109
+
110
+func (h *Handlers) applyActionsCancelControls(r *http.Request, row reposdb.Repo, view *actionsRunDetailView) {
111
+	if view == nil || view.IsTerminal {
112
+		return
113
+	}
114
+	viewer := middleware.CurrentUserFromContext(r.Context())
115
+	dec := policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, viewer.PolicyActor(), policy.ActionRepoWrite, policy.NewRepoRefFromRepo(row))
116
+	if !dec.Allow {
117
+		return
118
+	}
119
+	for i := range view.Jobs {
120
+		if view.Jobs[i].IsCancellable && !view.Jobs[i].CancelRequested {
121
+			view.Jobs[i].CanCancel = true
122
+			view.CanCancel = true
123
+		}
124
+	}
125
+}
126
+
127
+func repoActionRunHref(owner, repoName string, runIndex int64) string {
128
+	return "/" + owner + "/" + repoName + "/actions/runs/" + strconv.FormatInt(runIndex, 10)
129
+}
internal/web/handlers/repo/actions_test.gomodified
@@ -303,6 +303,118 @@ func TestRepoActionRunRendersWorkflowRunJobsAndSteps(t *testing.T) {
303303
 	}
304304
 }
305305
 
306
+func TestRepoActionRunRendersCancelControlsForWritersOnly(t *testing.T) {
307
+	t.Parallel()
308
+	f := newRepoFixture(t)
309
+	now := time.Date(2026, 5, 11, 12, 0, 0, 0, time.UTC)
310
+	runID := f.insertWorkflowRun(t, workflowRunFixture{
311
+		RunIndex:      12,
312
+		WorkflowFile:  ".shithub/workflows/ci.yml",
313
+		WorkflowName:  "CI",
314
+		HeadRef:       "trunk",
315
+		Event:         actionsdb.WorkflowRunEventPush,
316
+		Status:        actionsdb.WorkflowRunStatusRunning,
317
+		ActorUserID:   f.owner.ID,
318
+		CreatedOffset: -5 * time.Minute,
319
+		StartedOffset: -4 * time.Minute,
320
+	}, now)
321
+	f.insertWorkflowJob(t, workflowJobFixture{
322
+		RunID:    runID,
323
+		JobIndex: 0,
324
+		JobKey:   "build",
325
+		JobName:  "Build",
326
+		RunsOn:   "ubuntu-latest",
327
+		Status:   actionsdb.WorkflowJobStatusQueued,
328
+	})
329
+
330
+	resp := httptest.NewRecorder()
331
+	req := httptest.NewRequest(http.MethodGet, "/alice/public-repo/actions/runs/12", nil)
332
+	f.actionsMux(viewerFor(f.owner)).ServeHTTP(resp, req)
333
+	if resp.Code != http.StatusOK {
334
+		t.Fatalf("owner status=%d body=%s", resp.Code, resp.Body.String())
335
+	}
336
+	body := resp.Body.String()
337
+	for _, want := range []string{
338
+		"CANCEL_RUN=/alice/public-repo/actions/runs/12/cancel;",
339
+		"CANCEL_JOB=/alice/public-repo/actions/runs/12/jobs/0/cancel;",
340
+	} {
341
+		if !strings.Contains(body, want) {
342
+			t.Fatalf("owner body missing %q in %s", want, body)
343
+		}
344
+	}
345
+
346
+	resp = httptest.NewRecorder()
347
+	req = httptest.NewRequest(http.MethodGet, "/alice/public-repo/actions/runs/12", nil)
348
+	f.actionsMux(viewerFor(f.stranger)).ServeHTTP(resp, req)
349
+	if resp.Code != http.StatusOK {
350
+		t.Fatalf("stranger status=%d body=%s", resp.Code, resp.Body.String())
351
+	}
352
+	if strings.Contains(resp.Body.String(), "CANCEL_") {
353
+		t.Fatalf("cancel controls leaked to non-writer: %s", resp.Body.String())
354
+	}
355
+}
356
+
357
+func TestRepoActionRunCancelCancelsQueuedRun(t *testing.T) {
358
+	t.Parallel()
359
+	f := newRepoFixture(t)
360
+	now := time.Date(2026, 5, 11, 12, 0, 0, 0, time.UTC)
361
+	runID := f.insertWorkflowRun(t, workflowRunFixture{
362
+		RunIndex:      13,
363
+		WorkflowFile:  ".shithub/workflows/ci.yml",
364
+		WorkflowName:  "CI",
365
+		HeadRef:       "trunk",
366
+		Event:         actionsdb.WorkflowRunEventPush,
367
+		Status:        actionsdb.WorkflowRunStatusQueued,
368
+		ActorUserID:   f.owner.ID,
369
+		CreatedOffset: -5 * time.Minute,
370
+	}, now)
371
+	jobID := f.insertWorkflowJob(t, workflowJobFixture{
372
+		RunID:    runID,
373
+		JobIndex: 0,
374
+		JobKey:   "build",
375
+		JobName:  "Build",
376
+		RunsOn:   "ubuntu-latest",
377
+		Status:   actionsdb.WorkflowJobStatusQueued,
378
+	})
379
+	stepID := f.insertWorkflowStep(t, workflowStepFixture{
380
+		JobID:      jobID,
381
+		StepIndex:  0,
382
+		RunCommand: "go test ./...",
383
+	})
384
+
385
+	resp := httptest.NewRecorder()
386
+	req := httptest.NewRequest(http.MethodPost, "/alice/public-repo/actions/runs/13/cancel", nil)
387
+	f.actionsMux(viewerFor(f.owner)).ServeHTTP(resp, req)
388
+	if resp.Code != http.StatusSeeOther {
389
+		t.Fatalf("status=%d body=%s", resp.Code, resp.Body.String())
390
+	}
391
+	if loc := resp.Header().Get("Location"); loc != "/alice/public-repo/actions/runs/13" {
392
+		t.Fatalf("Location=%q", loc)
393
+	}
394
+	job, err := actionsdb.New().GetWorkflowJobByID(context.Background(), f.pool, jobID)
395
+	if err != nil {
396
+		t.Fatalf("GetWorkflowJobByID: %v", err)
397
+	}
398
+	if job.Status != actionsdb.WorkflowJobStatusCancelled || !job.CancelRequested {
399
+		t.Fatalf("job: %+v", job)
400
+	}
401
+	step, err := actionsdb.New().GetWorkflowStepByID(context.Background(), f.pool, stepID)
402
+	if err != nil {
403
+		t.Fatalf("GetWorkflowStepByID: %v", err)
404
+	}
405
+	if step.Status != actionsdb.WorkflowStepStatusCancelled {
406
+		t.Fatalf("step: %+v", step)
407
+	}
408
+	run, err := actionsdb.New().GetWorkflowRunByID(context.Background(), f.pool, runID)
409
+	if err != nil {
410
+		t.Fatalf("GetWorkflowRunByID: %v", err)
411
+	}
412
+	if run.Status != actionsdb.WorkflowRunStatusCompleted ||
413
+		!run.Conclusion.Valid || run.Conclusion.CheckConclusion != actionsdb.CheckConclusionCancelled {
414
+		t.Fatalf("run: %+v", run)
415
+	}
416
+}
417
+
306418
 func TestRepoActionRunStatusRendersPollingFragment(t *testing.T) {
307419
 	t.Parallel()
308420
 	f := newRepoFixture(t)
@@ -528,6 +640,8 @@ func (f *repoFixture) actionsMux(viewer middleware.CurrentUser) http.Handler {
528640
 	mux.Get("/{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}/steps/{stepIndex}", f.handlers.repoActionStepLog)
529641
 	mux.Get("/{owner}/{repo}/actions/runs/{runIndex}/status", f.handlers.repoActionRunStatus)
530642
 	mux.Get("/{owner}/{repo}/actions/runs/{runIndex}", f.handlers.repoActionRun)
643
+	mux.Post("/{owner}/{repo}/actions/runs/{runIndex}/cancel", f.handlers.repoActionRunCancel)
644
+	mux.Post("/{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}/cancel", f.handlers.repoActionJobCancel)
531645
 	mux.Post("/{owner}/{repo}/actions/workflows/{file}/dispatches", f.handlers.repoActionsDispatch)
532646
 	mux.Get("/{owner}/{repo}/actions", f.handlers.repoTabActions)
533647
 	return mux
internal/web/handlers/repo/repo.gomodified
@@ -125,10 +125,10 @@ func (h *Handlers) MountNew(r chi.Router) {
125125
 }
126126
 
127127
 // MountRepoActionsAPI registers POST/state-changing routes under
128
-// /{owner}/{repo}/actions/. Caller wraps with RequireUser. Currently
129
-// just the workflow_dispatch endpoint (S41b); S41f will add re-run +
130
-// cancel.
128
+// /{owner}/{repo}/actions/. Caller wraps with RequireUser.
131129
 func (h *Handlers) MountRepoActionsAPI(r chi.Router) {
130
+	r.Post("/{owner}/{repo}/actions/runs/{runIndex}/cancel", h.repoActionRunCancel)
131
+	r.Post("/{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}/cancel", h.repoActionJobCancel)
132132
 	r.Post("/{owner}/{repo}/actions/workflows/{file}/dispatches", h.repoActionsDispatch)
133133
 }
134134
 
internal/web/handlers/repo/repo_test.gomodified
@@ -150,7 +150,7 @@ func minimalTemplatesFS() fstest.MapFS {
150150
 		"repo/new.html":                {Data: []byte(`{{ define "page" }}OWNERS={{ range .Owners }}{{ .Token }}:{{ if eq .Token $.Form.Owner }}selected{{ end }}:{{ .Slug }};{{ end }}{{ end }}`)},
151151
 		"repo/actions.html":            {Data: []byte(`{{ define "page" }}COUNT={{ .RunCount }};FILTERED={{ .FilteredRunCount }};PAGE={{ .Pagination.ResultText }};{{ range .DispatchWorkflows }}DISPATCH={{ .Name }}:{{ .DispatchHref }}:{{ range .Inputs }}{{ .Name }}/{{ .Type }}/{{ .Required }}/{{ .Default }}/{{ range .Options }}{{ .Value }}|{{ end }},{{ end }};{{ end }}{{ range .Workflows }}WF={{ .Name }}:{{ .Count }}:{{ .Active }};{{ end }}{{ range .Runs }}RUN={{ .Title }}:#{{ .RunIndex }}:{{ .Event }}:{{ .HeadRef }}:{{ .ActorUsername }}:{{ .StateClass }};{{ end }}{{ end }}`)},
152152
 		"repo/_action_run_status.html": {Data: []byte(`{{ define "action-run-status" }}STATUS={{ .Run.StateClass }}:{{ .Run.IsTerminal }}:{{ .Run.StatusHref }};{{ end }}`)},
153
-		"repo/action_run.html":         {Data: []byte(`{{ define "page" }}RUN={{ .Run.Title }}:#{{ .Run.RunIndex }}:{{ .Run.Event }}:{{ .Run.ActorUsername }}:{{ .Run.StateClass }};SUMMARY={{ .Run.JobCount }}:{{ .Run.CompletedCount }}:{{ .Run.FailureCount }}:{{ .Run.ArtifactCount }};{{ range .Run.Jobs }}JOB={{ .Name }}:{{ .StateClass }}:{{ .NeedsText }}:{{ .RunsOn }};{{ range .Steps }}STEP={{ .Name }}:{{ .StateClass }}:{{ .LogHref }};{{ end }}{{ end }}{{ end }}`)},
153
+		"repo/action_run.html":         {Data: []byte(`{{ define "page" }}RUN={{ .Run.Title }}:#{{ .Run.RunIndex }}:{{ .Run.Event }}:{{ .Run.ActorUsername }}:{{ .Run.StateClass }};{{ if .Run.CanCancel }}CANCEL_RUN={{ .Run.CancelHref }};{{ end }}SUMMARY={{ .Run.JobCount }}:{{ .Run.CompletedCount }}:{{ .Run.FailureCount }}:{{ .Run.ArtifactCount }};{{ range .Run.Jobs }}JOB={{ .Name }}:{{ .StateClass }}:{{ .NeedsText }}:{{ .RunsOn }};{{ if .CanCancel }}CANCEL_JOB={{ .CancelHref }};{{ end }}{{ if .CancelRequested }}CANCEL_REQUESTED={{ .Name }};{{ end }}{{ range .Steps }}STEP={{ .Name }}:{{ .StateClass }}:{{ .LogHref }};{{ end }}{{ end }}{{ end }}`)},
154154
 		"repo/action_run_status.html":  {Data: []byte(`{{ define "page" }}{{ template "action-run-status" . }}{{ end }}`)},
155155
 		"repo/action_step_log.html":    {Data: []byte(`{{ define "page" }}STEPLOG={{ .Log.Job.Name }}:{{ .Log.Step.Name }}:{{ .Log.LogSource }}:{{ .Log.DownloadURL }}:{{ .Log.LogTruncated }};{{ with .Log.StreamHref }}STREAM={{ . }};{{ end }}{{ with .Log.LogError }}ERROR={{ . }};{{ end }}LOG={{ .Log.LogText }};{{ end }}`)},
156156
 		"repo/settings_secrets.html":   {Data: []byte(`{{ define "page" }}{{ with .Error }}ERROR={{ . }}{{ end }}{{ range .Secrets }}SECRET={{ .Name }};{{ end }}{{ range .Variables }}VAR={{ .Name }}:{{ .Value }};{{ end }}{{ end }}`)},
internal/web/static/css/shithub.cssmodified
@@ -5141,6 +5141,9 @@ button.shithub-repo-action {
51415141
   gap: 0.5rem;
51425142
   flex-shrink: 0;
51435143
 }
5144
+.shithub-actions-inline-form {
5145
+  margin: 0;
5146
+}
51445147
 .shithub-actions-run-status {
51455148
   pointer-events: none;
51465149
 }
@@ -5304,6 +5307,18 @@ button.shithub-repo-action {
53045307
   text-overflow: ellipsis;
53055308
   white-space: nowrap;
53065309
 }
5310
+.shithub-actions-job-actions {
5311
+  display: flex;
5312
+  justify-content: flex-end;
5313
+  padding: 0 1rem 0.75rem;
5314
+}
5315
+.shithub-actions-cancel-requested {
5316
+  display: inline-flex;
5317
+  align-items: center;
5318
+  gap: 0.35rem;
5319
+  color: var(--fg-muted);
5320
+  font-size: 0.82rem;
5321
+}
53075322
 .shithub-actions-step-list {
53085323
   margin: 0;
53095324
   padding: 0;
internal/web/templates/repo/action_run.htmlmodified
@@ -17,6 +17,12 @@
1717
     </div>
1818
     <div class="shithub-actions-run-head-actions">
1919
       {{ template "action-run-status" . }}
20
+      {{ if .Run.CanCancel }}
21
+        <form method="POST" action="{{ .Run.CancelHref }}" class="shithub-actions-inline-form">
22
+          <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
23
+          <button type="submit" class="shithub-button shithub-button-danger">{{ octicon "stop" }} Cancel workflow</button>
24
+        </form>
25
+      {{ end }}
2026
       <a class="shithub-button" href="{{ .Run.CodeHref }}">{{ octicon "code" }} Code</a>
2127
     </div>
2228
   </header>
@@ -103,6 +109,18 @@
103109
                 <small>{{ .StateText }} · {{ .Duration }}{{ if .RunsOn }} · runs-on {{ .RunsOn }}{{ end }}{{ if .NeedsText }} · needs {{ .NeedsText }}{{ end }}</small>
104110
               </span>
105111
             </summary>
112
+            {{ if or .CanCancel .CancelRequested }}
113
+              <div class="shithub-actions-job-actions">
114
+                {{ if .CanCancel }}
115
+                  <form method="POST" action="{{ .CancelHref }}" class="shithub-actions-inline-form">
116
+                    <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
117
+                    <button type="submit" class="shithub-button shithub-button-danger">{{ octicon "stop" }} Cancel job</button>
118
+                  </form>
119
+                {{ else if .CancelRequested }}
120
+                  <span class="shithub-actions-cancel-requested">{{ octicon "stopwatch" }} Cancel requested</span>
121
+                {{ end }}
122
+              </div>
123
+            {{ end }}
106124
             <ol class="shithub-actions-step-list">
107125
               {{ range .Steps }}
108126
                 <li>