detached 6d7ddba

refactor(ci): reserve the removed storage fields and type the cancel reason

The storage schema marks the field numbers it dropped as reserved, and the
cancel reason of a job is an enum with an explicit mapping instead of an index
into a slice. The comment of the event stream names the live log feed.
commit
6d7ddbac1cb5ce5a4753e1988ddbaf40b49342be
author
brice · · 2026-10-12 19:22 UTC
parent
916e849
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +174 -58
M gen/ermite/storage/ci.pb.go +110 -49 generated file: collapsed
M internal/adapters/kvstore/ci.go +28 -6
@@ -235,7 +235,32 @@ func decodeStepState(s storagepb.CIStepState) domain.CIStepState {
235235 return domain.CIStepPending
236236 }
237237
238-var cancelReasons = []domain.CICancelReason{domain.CICancelNone, domain.CICancelUser, domain.CICancelFailFast, domain.CICancelTimeout}
238+var cancelReasons = []struct {
239+ d domain.CICancelReason
240+ p storagepb.CICancelReason
241+}{
242+ {domain.CICancelUser, storagepb.CICancelReason_CI_CANCEL_REASON_USER},
243+ {domain.CICancelFailFast, storagepb.CICancelReason_CI_CANCEL_REASON_FAIL_FAST},
244+ {domain.CICancelTimeout, storagepb.CICancelReason_CI_CANCEL_REASON_TIMEOUT},
245+}
246+
247+func encodeCancelReason(r domain.CICancelReason) storagepb.CICancelReason {
248+ for _, e := range cancelReasons {
249+ if e.d == r {
250+ return e.p
251+ }
252+ }
253+ return storagepb.CICancelReason_CI_CANCEL_REASON_UNSPECIFIED
254+}
255+
256+func decodeCancelReason(r storagepb.CICancelReason) domain.CICancelReason {
257+ for _, e := range cancelReasons {
258+ if e.p == r {
259+ return e.d
260+ }
261+ }
262+ return domain.CICancelNone
263+}
239264
240265 func encodeRun(r domain.CIRun) ([]byte, error) {
241266 if r.Repo.IsZero() {
@@ -287,7 +312,6 @@ func encodeJob(j domain.CIJob) ([]byte, error) {
287312 LogStart: s.LogStart, HasLines: s.HasLines,
288313 })
289314 }
290- reason := int32(slices.Index(cancelReasons, j.CancelReason))
291315 pb := &storagepb.CIJob{
292316 Id: j.ID, RunId: j.RunID, Attempt: j.Attempt, RunAttempt: j.RunAttempt, Repo: j.Repo.String(),
293317 Key: j.Key, Name: j.Name, Phase: phase, Conclusion: encodeConclusion(j.Conclusion), Error: j.Error,
@@ -298,7 +322,7 @@ func encodeJob(j domain.CIJob) ([]byte, error) {
298322 TimeoutMinutes: j.TimeoutMinutes, FailFast: j.FailFast, MaxParallel: int32(j.MaxParallel), //nolint:gosec // validated at planning
299323 Outputs: j.Outputs, ReadyAt: encodeTime(j.ReadyAt), AssignedAt: encodeTime(j.AssignedAt),
300324 AckedAt: encodeTime(j.AckedAt), AssignCancels: int32(j.AssignCancels), //nolint:gosec // at most 3
301- TokenValidUntil: encodeTime(j.TokenValidUntil), CancelReason: max(reason, 0), CancelMessage: j.CancelMessage,
325+ TokenValidUntil: encodeTime(j.TokenValidUntil), CancelReason: encodeCancelReason(j.CancelReason), CancelMessage: j.CancelMessage,
302326 CancelRequestedAt: encodeTime(j.CancelRequestedAt), Planned: j.Planned,
303327 }
304328 if j.HasToken() {
@@ -331,9 +355,7 @@ func decodeJob(item kv.Item) (domain.CIJob, error) {
331355 CancelMessage: pb.GetCancelMessage(), CancelRequestedAt: decodeTime(pb.GetCancelRequestedAt()),
332356 Version: item.Version,
333357 }
334- if r := int(pb.GetCancelReason()); r >= 0 && r < len(cancelReasons) {
335- j.CancelReason = cancelReasons[r]
336- }
358+ j.CancelReason = decodeCancelReason(pb.GetCancelReason())
337359 copy(j.TokenHash[:], pb.GetTokenHash())
338360 for _, s := range pb.GetSteps() {
339361 j.Steps = append(j.Steps, domain.CIStep{
@@ -235,7 +235,32 @@ func decodeStepState(s storagepb.CIStepState) domain.CIStepState {
235 return domain.CIStepPending235 return domain.CIStepPending
236 }236 }
237 237
238-var cancelReasons = []domain.CICancelReason{domain.CICancelNone, domain.CICancelUser, domain.CICancelFailFast, domain.CICancelTimeout}238+var cancelReasons = []struct {
239+ d domain.CICancelReason
240+ p storagepb.CICancelReason
241+}{
242+ {domain.CICancelUser, storagepb.CICancelReason_CI_CANCEL_REASON_USER},
243+ {domain.CICancelFailFast, storagepb.CICancelReason_CI_CANCEL_REASON_FAIL_FAST},
244+ {domain.CICancelTimeout, storagepb.CICancelReason_CI_CANCEL_REASON_TIMEOUT},
245+}
246+
247+func encodeCancelReason(r domain.CICancelReason) storagepb.CICancelReason {
248+ for _, e := range cancelReasons {
249+ if e.d == r {
250+ return e.p
251+ }
252+ }
253+ return storagepb.CICancelReason_CI_CANCEL_REASON_UNSPECIFIED
254+}
255+
256+func decodeCancelReason(r storagepb.CICancelReason) domain.CICancelReason {
257+ for _, e := range cancelReasons {
258+ if e.p == r {
259+ return e.d
260+ }
261+ }
262+ return domain.CICancelNone
263+}
239 264
240 func encodeRun(r domain.CIRun) ([]byte, error) {265 func encodeRun(r domain.CIRun) ([]byte, error) {
241 if r.Repo.IsZero() {266 if r.Repo.IsZero() {
@@ -287,7 +312,6 @@ func encodeJob(j domain.CIJob) ([]byte, error) {
287 LogStart: s.LogStart, HasLines: s.HasLines,312 LogStart: s.LogStart, HasLines: s.HasLines,
288 })313 })
289 }314 }
290- reason := int32(slices.Index(cancelReasons, j.CancelReason))
291 pb := &storagepb.CIJob{315 pb := &storagepb.CIJob{
292 Id: j.ID, RunId: j.RunID, Attempt: j.Attempt, RunAttempt: j.RunAttempt, Repo: j.Repo.String(),316 Id: j.ID, RunId: j.RunID, Attempt: j.Attempt, RunAttempt: j.RunAttempt, Repo: j.Repo.String(),
293 Key: j.Key, Name: j.Name, Phase: phase, Conclusion: encodeConclusion(j.Conclusion), Error: j.Error,317 Key: j.Key, Name: j.Name, Phase: phase, Conclusion: encodeConclusion(j.Conclusion), Error: j.Error,
@@ -298,7 +322,7 @@ func encodeJob(j domain.CIJob) ([]byte, error) {
298 TimeoutMinutes: j.TimeoutMinutes, FailFast: j.FailFast, MaxParallel: int32(j.MaxParallel), //nolint:gosec // validated at planning322 TimeoutMinutes: j.TimeoutMinutes, FailFast: j.FailFast, MaxParallel: int32(j.MaxParallel), //nolint:gosec // validated at planning
299 Outputs: j.Outputs, ReadyAt: encodeTime(j.ReadyAt), AssignedAt: encodeTime(j.AssignedAt),323 Outputs: j.Outputs, ReadyAt: encodeTime(j.ReadyAt), AssignedAt: encodeTime(j.AssignedAt),
300 AckedAt: encodeTime(j.AckedAt), AssignCancels: int32(j.AssignCancels), //nolint:gosec // at most 3324 AckedAt: encodeTime(j.AckedAt), AssignCancels: int32(j.AssignCancels), //nolint:gosec // at most 3
301- TokenValidUntil: encodeTime(j.TokenValidUntil), CancelReason: max(reason, 0), CancelMessage: j.CancelMessage,325+ TokenValidUntil: encodeTime(j.TokenValidUntil), CancelReason: encodeCancelReason(j.CancelReason), CancelMessage: j.CancelMessage,
302 CancelRequestedAt: encodeTime(j.CancelRequestedAt), Planned: j.Planned,326 CancelRequestedAt: encodeTime(j.CancelRequestedAt), Planned: j.Planned,
303 }327 }
304 if j.HasToken() {328 if j.HasToken() {
@@ -331,9 +355,7 @@ func decodeJob(item kv.Item) (domain.CIJob, error) {
331 CancelMessage: pb.GetCancelMessage(), CancelRequestedAt: decodeTime(pb.GetCancelRequestedAt()),355 CancelMessage: pb.GetCancelMessage(), CancelRequestedAt: decodeTime(pb.GetCancelRequestedAt()),
332 Version: item.Version,356 Version: item.Version,
333 }357 }
334- if r := int(pb.GetCancelReason()); r >= 0 && r < len(cancelReasons) {358+ j.CancelReason = decodeCancelReason(pb.GetCancelReason())
335- j.CancelReason = cancelReasons[r]
336- }
337 copy(j.TokenHash[:], pb.GetTokenHash())359 copy(j.TokenHash[:], pb.GetTokenHash())
338 for _, s := range pb.GetSteps() {360 for _, s := range pb.GetSteps() {
339 j.Steps = append(j.Steps, domain.CIStep{361 j.Steps = append(j.Steps, domain.CIStep{
M internal/adapters/kvstore/ci_test.go +17 -0
@@ -356,3 +356,20 @@ func TestCIStateSurvivesARestart(t *testing.T) {
356356 active, _ := ci2.ListActiveJobs(ctx)
357357 assert.Len(t, active, 1)
358358 }
359+
360+func TestCICancelReasonsRoundTrip(t *testing.T) {
361+ ctx := context.Background()
362+ _, ci, n := ciRepo(t)
363+ d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 1))
364+ require.NoError(t, err)
365+ for _, reason := range []domain.CICancelReason{domain.CICancelUser, domain.CICancelFailFast, domain.CICancelTimeout, domain.CICancelNone} {
366+ j, err := ci.GetJob(ctx, d.Run.ID, 1, d.Jobs[0].ID)
367+ require.NoError(t, err)
368+ j.CancelReason, j.CancelMessage, j.CancelRequestedAt = reason, "why", created
369+ _, err = ci.Apply(ctx, ports.CIWrite{Jobs: []domain.CIJob{j}})
370+ require.NoError(t, err)
371+ got, err := ci.GetJob(ctx, d.Run.ID, 1, d.Jobs[0].ID)
372+ require.NoError(t, err)
373+ assert.Equal(t, reason, got.CancelReason)
374+ }
375+}
@@ -356,3 +356,20 @@ func TestCIStateSurvivesARestart(t *testing.T) {
356 active, _ := ci2.ListActiveJobs(ctx)356 active, _ := ci2.ListActiveJobs(ctx)
357 assert.Len(t, active, 1)357 assert.Len(t, active, 1)
358 }358 }
359+
360+func TestCICancelReasonsRoundTrip(t *testing.T) {
361+ ctx := context.Background()
362+ _, ci, n := ciRepo(t)
363+ d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 1))
364+ require.NoError(t, err)
365+ for _, reason := range []domain.CICancelReason{domain.CICancelUser, domain.CICancelFailFast, domain.CICancelTimeout, domain.CICancelNone} {
366+ j, err := ci.GetJob(ctx, d.Run.ID, 1, d.Jobs[0].ID)
367+ require.NoError(t, err)
368+ j.CancelReason, j.CancelMessage, j.CancelRequestedAt = reason, "why", created
369+ _, err = ci.Apply(ctx, ports.CIWrite{Jobs: []domain.CIJob{j}})
370+ require.NoError(t, err)
371+ got, err := ci.GetJob(ctx, d.Run.ID, 1, d.Jobs[0].ID)
372+ require.NoError(t, err)
373+ assert.Equal(t, reason, got.CancelReason)
374+ }
375+}
M internal/adapters/web/events.go +2 -2
@@ -56,8 +56,8 @@ import (
5656 // ci-run data: <tr id="run-<id>">…</tr> (the list of runs) or
5757 // <div id="run-body">…</div> (one run)
5858 // ci-job data: <h2 id="job-head">…</h2> and <ol id="job-steps">…</ol>; the log
59-// itself is never sent in the stream, the page reads the lines after
60-// the last it holds from /raw?from=
59+// itself is never sent in this stream: the page follows it on its own
60+// feed, /<repo>/actions/runs/<id>/job/<job>/stream?from=<line>
6161 // gap data: gap; events were lost, or what the page shows is gone or
6262 // changed: the page fetches itself again (ermiteLive in ermite.js)
6363 //
@@ -56,8 +56,8 @@ import (
56 // ci-run data: <tr id="run-<id>">…</tr> (the list of runs) or56 // ci-run data: <tr id="run-<id>">…</tr> (the list of runs) or
57 // <div id="run-body">…</div> (one run)57 // <div id="run-body">…</div> (one run)
58 // ci-job data: <h2 id="job-head">…</h2> and <ol id="job-steps">…</ol>; the log58 // ci-job data: <h2 id="job-head">…</h2> and <ol id="job-steps">…</ol>; the log
59-// itself is never sent in the stream, the page reads the lines after59+// itself is never sent in this stream: the page follows it on its own
60-// the last it holds from /raw?from=60+// feed, /<repo>/actions/runs/<id>/job/<job>/stream?from=<line>
61 // gap data: gap; events were lost, or what the page shows is gone or61 // gap data: gap; events were lost, or what the page shows is gone or
62 // changed: the page fetches itself again (ermiteLive in ermite.js)62 // changed: the page fetches itself again (ermiteLive in ermite.js)
63 //63 //
M proto/ermite/storage/ci.proto +17 -1
@@ -63,8 +63,19 @@ enum CIIsolation {
6363 CI_ISOLATION_CONTAINER_PRIVILEGED = 3;
6464 }
6565
66+// CICancelReason says why a job is asked to stop.
67+enum CICancelReason {
68+ CI_CANCEL_REASON_UNSPECIFIED = 0;
69+ CI_CANCEL_REASON_USER = 1;
70+ CI_CANCEL_REASON_FAIL_FAST = 2;
71+ CI_CANCEL_REASON_TIMEOUT = 3;
72+}
73+
6674 // CIRun is stored under "run/<repo>/<id:020d>".
6775 message CIRun {
76+ // 18 and 19 were the workflow file and the github context, which moved to
77+ // CIRunSource.
78+ reserved 18, 19;
6879 string repo = 1;
6980 int64 id = 2;
7081 int64 number = 3;
@@ -101,6 +112,9 @@ message CIRunSource {
101112
102113 // CIStep is a step of a job.
103114 message CIStep {
115+ // 8 and 10 were the end of the step, which is derived from the next step's
116+ // start and the line count.
117+ reserved 8, 10;
104118 int32 number = 1;
105119 string name = 2;
106120 CIStepState state = 3;
@@ -116,6 +130,8 @@ message CIStep {
116130
117131 // CIJob is stored under "job/<run_id:020d>/<run_attempt:010d>/<job id>".
118132 message CIJob {
133+ // 36 was the line count of the log, which the log file holds.
134+ reserved 36;
119135 string id = 1;
120136 int64 run_id = 2;
121137 // Attempt in which the job executed (the one of its log).
@@ -152,7 +168,7 @@ message CIJob {
152168 google.protobuf.Timestamp acked_at = 30;
153169 int32 assign_cancels = 31;
154170 google.protobuf.Timestamp token_valid_until = 32;
155- int32 cancel_reason = 33;
171+ CICancelReason cancel_reason = 33;
156172 string cancel_message = 34;
157173 google.protobuf.Timestamp cancel_requested_at = 35;
158174 // Set when the job is planned: runs-on, name and contexts are evaluated.
@@ -63,8 +63,19 @@ enum CIIsolation {
63 CI_ISOLATION_CONTAINER_PRIVILEGED = 3;63 CI_ISOLATION_CONTAINER_PRIVILEGED = 3;
64 }64 }
65 65
66+// CICancelReason says why a job is asked to stop.
67+enum CICancelReason {
68+ CI_CANCEL_REASON_UNSPECIFIED = 0;
69+ CI_CANCEL_REASON_USER = 1;
70+ CI_CANCEL_REASON_FAIL_FAST = 2;
71+ CI_CANCEL_REASON_TIMEOUT = 3;
72+}
73+
66 // CIRun is stored under "run/<repo>/<id:020d>".74 // CIRun is stored under "run/<repo>/<id:020d>".
67 message CIRun {75 message CIRun {
76+ // 18 and 19 were the workflow file and the github context, which moved to
77+ // CIRunSource.
78+ reserved 18, 19;
68 string repo = 1;79 string repo = 1;
69 int64 id = 2;80 int64 id = 2;
70 int64 number = 3;81 int64 number = 3;
@@ -101,6 +112,9 @@ message CIRunSource {
101 112
102 // CIStep is a step of a job.113 // CIStep is a step of a job.
103 message CIStep {114 message CIStep {
115+ // 8 and 10 were the end of the step, which is derived from the next step's
116+ // start and the line count.
117+ reserved 8, 10;
104 int32 number = 1;118 int32 number = 1;
105 string name = 2;119 string name = 2;
106 CIStepState state = 3;120 CIStepState state = 3;
@@ -116,6 +130,8 @@ message CIStep {
116 130
117 // CIJob is stored under "job/<run_id:020d>/<run_attempt:010d>/<job id>".131 // CIJob is stored under "job/<run_id:020d>/<run_attempt:010d>/<job id>".
118 message CIJob {132 message CIJob {
133+ // 36 was the line count of the log, which the log file holds.
134+ reserved 36;
119 string id = 1;135 string id = 1;
120 int64 run_id = 2;136 int64 run_id = 2;
121 // Attempt in which the job executed (the one of its log).137 // Attempt in which the job executed (the one of its log).
@@ -152,7 +168,7 @@ message CIJob {
152 google.protobuf.Timestamp acked_at = 30;168 google.protobuf.Timestamp acked_at = 30;
153 int32 assign_cancels = 31;169 int32 assign_cancels = 31;
154 google.protobuf.Timestamp token_valid_until = 32;170 google.protobuf.Timestamp token_valid_until = 32;
155- int32 cancel_reason = 33;171+ CICancelReason cancel_reason = 33;
156 string cancel_message = 34;172 string cancel_message = 34;
157 google.protobuf.Timestamp cancel_requested_at = 35;173 google.protobuf.Timestamp cancel_requested_at = 35;
158 // Set when the job is planned: runs-on, name and contexts are evaluated.174 // Set when the job is planned: runs-on, name and contexts are evaluated.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close