detached aa5a961

feat(execstream): carry a job and its steps, logs and result on the frame stream

commit
aa5a961e600d37aa253759cc2e3fc0b901055600
author
brice · · 2026-10-12 18:20 UTC
parent
e0de5de
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +871 -48
M internal/execstream/exec.proto +79 -2
@@ -21,6 +21,10 @@ message Frame {
2121 Output output = 3; // executor -> host
2222 Exit exit = 4; // executor -> host, last frame
2323 Cancel cancel = 5; // host -> executor
24+ StepStarted step_started = 6; // executor -> host
25+ StepCompleted step_completed = 7; // executor -> host
26+ Log log = 8; // executor -> host
27+ JobResult job_result = 9; // executor -> host, before Exit
2428 }
2529 }
2630
@@ -30,11 +34,32 @@ message Hello {
3034 string hostname = 3;
3135 }
3236
33-// Run asks the executor to run a script. CI-1 stub executor: the script is run
34-// with `sh -c`; an empty script means the built-in diagnostics script.
37+// Run asks the executor to run a script or a job. Without job it is the stub
38+// executor of sandbox-check: the script is run with `sh -c`; an empty script
39+// means the built-in diagnostics script. With job the executor runs the job
40+// like a GitHub Actions runner and reports it with StepStarted, Log,
41+// StepCompleted and JobResult frames, then Exit.
3542 message Run {
3643 string script = 1;
3744 map<string, string> env = 2;
45+ JobSpec job = 3;
46+}
47+
48+// JobSpec is the job to run: the fields of the job that ermite handed to the
49+// host process, minus the tokens (the sandbox never sees them).
50+message JobSpec {
51+ string workflow_path = 1;
52+ string workflow_yaml = 2;
53+ string job_key = 3;
54+ string matrix = 4; // JSON object
55+ string contexts = 5; // JSON object
56+ string repo = 6; // repository name
57+ int32 timeout_minutes = 7;
58+ string runner_name = 8;
59+ string runner_version = 9;
60+ // runner_environment is the runner.environment value ("self-hosted").
61+ string runner_environment = 10;
62+ bool debug = 11; // ACTIONS_STEP_DEBUG
3863 }
3964
4065 enum StreamKind {
@@ -61,4 +86,56 @@ message Cancel {
6186 // reason is for the job log and diagnostics ("cancelled by the user",
6287 // "job timeout", ...); it never changes the behaviour.
6388 string reason = 1;
89+ // kind tells the job executor how the job ends: cancelled, timed out, or
90+ // cancelled because the runner shuts down. The stub executor ignores it.
91+ CancelKind kind = 2;
92+}
93+
94+enum CancelKind {
95+ CANCEL_KIND_UNSPECIFIED = 0; // same as USER
96+ CANCEL_KIND_USER = 1;
97+ CANCEL_KIND_TIMEOUT = 2;
98+ CANCEL_KIND_SHUTDOWN = 3;
99+}
100+
101+// Steps are numbered like GitHub's log: "Set up job" is 1, the steps of the
102+// workflow follow, "Complete job" is last.
103+message StepStarted {
104+ int32 number = 1;
105+ string name = 2;
106+ int64 unix_nano = 3;
107+}
108+
109+message StepCompleted {
110+ int32 number = 1;
111+ string name = 2;
112+ StepConclusion conclusion = 3;
113+ int64 unix_nano = 4;
114+}
115+
116+enum StepConclusion {
117+ STEP_CONCLUSION_UNSPECIFIED = 0;
118+ STEP_CONCLUSION_SUCCESS = 1;
119+ STEP_CONCLUSION_FAILURE = 2;
120+ STEP_CONCLUSION_CANCELLED = 3;
121+ STEP_CONCLUSION_SKIPPED = 4;
122+ STEP_CONCLUSION_TIMED_OUT = 5; // jobs only
123+}
124+
125+// Log is one line of the job log, without terminator, valid UTF-8. A line of
126+// more than 64 KiB is cut by the executor into pieces at character boundaries;
127+// every piece but the first has continued set (the AppendLog rule).
128+message Log {
129+ int32 step = 1;
130+ int64 unix_nano = 2;
131+ string text = 3;
132+ bool continued = 4;
133+}
134+
135+// JobResult is the outcome of the job; Exit follows.
136+message JobResult {
137+ StepConclusion conclusion = 1;
138+ map<string, string> outputs = 2;
139+ string error = 3;
140+ bool runner_shutdown = 4;
64141 }
@@ -21,6 +21,10 @@ message Frame {
21 Output output = 3; // executor -> host21 Output output = 3; // executor -> host
22 Exit exit = 4; // executor -> host, last frame22 Exit exit = 4; // executor -> host, last frame
23 Cancel cancel = 5; // host -> executor23 Cancel cancel = 5; // host -> executor
24+ StepStarted step_started = 6; // executor -> host
25+ StepCompleted step_completed = 7; // executor -> host
26+ Log log = 8; // executor -> host
27+ JobResult job_result = 9; // executor -> host, before Exit
24 }28 }
25 }29 }
26 30
@@ -30,11 +34,32 @@ message Hello {
30 string hostname = 3;34 string hostname = 3;
31 }35 }
32 36
33-// Run asks the executor to run a script. CI-1 stub executor: the script is run37+// Run asks the executor to run a script or a job. Without job it is the stub
34-// with `sh -c`; an empty script means the built-in diagnostics script.38+// executor of sandbox-check: the script is run with `sh -c`; an empty script
39+// means the built-in diagnostics script. With job the executor runs the job
40+// like a GitHub Actions runner and reports it with StepStarted, Log,
41+// StepCompleted and JobResult frames, then Exit.
35 message Run {42 message Run {
36 string script = 1;43 string script = 1;
37 map<string, string> env = 2;44 map<string, string> env = 2;
45+ JobSpec job = 3;
46+}
47+
48+// JobSpec is the job to run: the fields of the job that ermite handed to the
49+// host process, minus the tokens (the sandbox never sees them).
50+message JobSpec {
51+ string workflow_path = 1;
52+ string workflow_yaml = 2;
53+ string job_key = 3;
54+ string matrix = 4; // JSON object
55+ string contexts = 5; // JSON object
56+ string repo = 6; // repository name
57+ int32 timeout_minutes = 7;
58+ string runner_name = 8;
59+ string runner_version = 9;
60+ // runner_environment is the runner.environment value ("self-hosted").
61+ string runner_environment = 10;
62+ bool debug = 11; // ACTIONS_STEP_DEBUG
38 }63 }
39 64
40 enum StreamKind {65 enum StreamKind {
@@ -61,4 +86,56 @@ message Cancel {
61 // reason is for the job log and diagnostics ("cancelled by the user",86 // reason is for the job log and diagnostics ("cancelled by the user",
62 // "job timeout", ...); it never changes the behaviour.87 // "job timeout", ...); it never changes the behaviour.
63 string reason = 1;88 string reason = 1;
89+ // kind tells the job executor how the job ends: cancelled, timed out, or
90+ // cancelled because the runner shuts down. The stub executor ignores it.
91+ CancelKind kind = 2;
92+}
93+
94+enum CancelKind {
95+ CANCEL_KIND_UNSPECIFIED = 0; // same as USER
96+ CANCEL_KIND_USER = 1;
97+ CANCEL_KIND_TIMEOUT = 2;
98+ CANCEL_KIND_SHUTDOWN = 3;
99+}
100+
101+// Steps are numbered like GitHub's log: "Set up job" is 1, the steps of the
102+// workflow follow, "Complete job" is last.
103+message StepStarted {
104+ int32 number = 1;
105+ string name = 2;
106+ int64 unix_nano = 3;
107+}
108+
109+message StepCompleted {
110+ int32 number = 1;
111+ string name = 2;
112+ StepConclusion conclusion = 3;
113+ int64 unix_nano = 4;
114+}
115+
116+enum StepConclusion {
117+ STEP_CONCLUSION_UNSPECIFIED = 0;
118+ STEP_CONCLUSION_SUCCESS = 1;
119+ STEP_CONCLUSION_FAILURE = 2;
120+ STEP_CONCLUSION_CANCELLED = 3;
121+ STEP_CONCLUSION_SKIPPED = 4;
122+ STEP_CONCLUSION_TIMED_OUT = 5; // jobs only
123+}
124+
125+// Log is one line of the job log, without terminator, valid UTF-8. A line of
126+// more than 64 KiB is cut by the executor into pieces at character boundaries;
127+// every piece but the first has continued set (the AppendLog rule).
128+message Log {
129+ int32 step = 1;
130+ int64 unix_nano = 2;
131+ string text = 3;
132+ bool continued = 4;
133+}
134+
135+// JobResult is the outcome of the job; Exit follows.
136+message JobResult {
137+ StepConclusion conclusion = 1;
138+ map<string, string> outputs = 2;
139+ string error = 3;
140+ bool runner_shutdown = 4;
64 }141 }
M internal/execstream/execpb/exec.pb.go +717 -44 generated file: collapsed
M internal/execstream/frame.go +39 -2
@@ -100,6 +100,24 @@ func (c *Conn) Recv() (*execpb.Frame, error) {
100100 // a Cancel; the caller bounds that wait by stopping the sandbox, which closes
101101 // the stream and makes Drive return an error.
102102 func Drive(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, onOutput func(*execpb.Output)) (*execpb.Hello, *execpb.Exit, error) {
103+ return DriveJob(rw, run, cancel, JobHandler{Output: onOutput})
104+}
105+
106+// JobHandler receives the frames of a session. Nil functions ignore their
107+// frames. The functions are called from the goroutine of DriveJob, in the
108+// order of the stream.
109+type JobHandler struct {
110+ Hello func(*execpb.Hello)
111+ Output func(*execpb.Output)
112+ StepStarted func(*execpb.StepStarted)
113+ StepCompleted func(*execpb.StepCompleted)
114+ Log func(*execpb.Log)
115+ JobResult func(*execpb.JobResult)
116+}
117+
118+// DriveJob is Drive for a session that carries a job: besides Output frames it
119+// delivers the step, log and result frames to h.
120+func DriveJob(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, h JobHandler) (*execpb.Hello, *execpb.Exit, error) {
103121 c := NewConn(rw)
104122 first, err := c.Recv()
105123 if err != nil {
@@ -109,6 +127,9 @@ func Drive(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, onOu
109127 if hello == nil {
110128 return nil, nil, fmt.Errorf("execstream: first frame is not hello")
111129 }
130+ if h.Hello != nil {
131+ h.Hello(hello)
132+ }
112133 if err := c.Send(&execpb.Frame{Body: &execpb.Frame_Run{Run: run}}); err != nil {
113134 return hello, nil, fmt.Errorf("execstream: sending run: %w", err)
114135 }
@@ -137,8 +158,24 @@ func Drive(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, onOu
137158 }
138159 switch b := f.Body.(type) {
139160 case *execpb.Frame_Output:
140- if onOutput != nil {
141- onOutput(b.Output)
161+ if h.Output != nil {
162+ h.Output(b.Output)
163+ }
164+ case *execpb.Frame_StepStarted:
165+ if h.StepStarted != nil {
166+ h.StepStarted(b.StepStarted)
167+ }
168+ case *execpb.Frame_StepCompleted:
169+ if h.StepCompleted != nil {
170+ h.StepCompleted(b.StepCompleted)
171+ }
172+ case *execpb.Frame_Log:
173+ if h.Log != nil {
174+ h.Log(b.Log)
175+ }
176+ case *execpb.Frame_JobResult:
177+ if h.JobResult != nil {
178+ h.JobResult(b.JobResult)
142179 }
143180 case *execpb.Frame_Exit:
144181 return hello, b.Exit, nil
@@ -100,6 +100,24 @@ func (c *Conn) Recv() (*execpb.Frame, error) {
100 // a Cancel; the caller bounds that wait by stopping the sandbox, which closes100 // a Cancel; the caller bounds that wait by stopping the sandbox, which closes
101 // the stream and makes Drive return an error.101 // the stream and makes Drive return an error.
102 func Drive(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, onOutput func(*execpb.Output)) (*execpb.Hello, *execpb.Exit, error) {102 func Drive(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, onOutput func(*execpb.Output)) (*execpb.Hello, *execpb.Exit, error) {
103+ return DriveJob(rw, run, cancel, JobHandler{Output: onOutput})
104+}
105+
106+// JobHandler receives the frames of a session. Nil functions ignore their
107+// frames. The functions are called from the goroutine of DriveJob, in the
108+// order of the stream.
109+type JobHandler struct {
110+ Hello func(*execpb.Hello)
111+ Output func(*execpb.Output)
112+ StepStarted func(*execpb.StepStarted)
113+ StepCompleted func(*execpb.StepCompleted)
114+ Log func(*execpb.Log)
115+ JobResult func(*execpb.JobResult)
116+}
117+
118+// DriveJob is Drive for a session that carries a job: besides Output frames it
119+// delivers the step, log and result frames to h.
120+func DriveJob(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, h JobHandler) (*execpb.Hello, *execpb.Exit, error) {
103 c := NewConn(rw)121 c := NewConn(rw)
104 first, err := c.Recv()122 first, err := c.Recv()
105 if err != nil {123 if err != nil {
@@ -109,6 +127,9 @@ func Drive(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, onOu
109 if hello == nil {127 if hello == nil {
110 return nil, nil, fmt.Errorf("execstream: first frame is not hello")128 return nil, nil, fmt.Errorf("execstream: first frame is not hello")
111 }129 }
130+ if h.Hello != nil {
131+ h.Hello(hello)
132+ }
112 if err := c.Send(&execpb.Frame{Body: &execpb.Frame_Run{Run: run}}); err != nil {133 if err := c.Send(&execpb.Frame{Body: &execpb.Frame_Run{Run: run}}); err != nil {
113 return hello, nil, fmt.Errorf("execstream: sending run: %w", err)134 return hello, nil, fmt.Errorf("execstream: sending run: %w", err)
114 }135 }
@@ -137,8 +158,24 @@ func Drive(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, onOu
137 }158 }
138 switch b := f.Body.(type) {159 switch b := f.Body.(type) {
139 case *execpb.Frame_Output:160 case *execpb.Frame_Output:
140- if onOutput != nil {161+ if h.Output != nil {
141- onOutput(b.Output)162+ h.Output(b.Output)
163+ }
164+ case *execpb.Frame_StepStarted:
165+ if h.StepStarted != nil {
166+ h.StepStarted(b.StepStarted)
167+ }
168+ case *execpb.Frame_StepCompleted:
169+ if h.StepCompleted != nil {
170+ h.StepCompleted(b.StepCompleted)
171+ }
172+ case *execpb.Frame_Log:
173+ if h.Log != nil {
174+ h.Log(b.Log)
175+ }
176+ case *execpb.Frame_JobResult:
177+ if h.JobResult != nil {
178+ h.JobResult(b.JobResult)
142 }179 }
143 case *execpb.Frame_Exit:180 case *execpb.Frame_Exit:
144 return hello, b.Exit, nil181 return hello, b.Exit, nil
M internal/execstream/frame_test.go +36 -0
@@ -8,6 +8,7 @@ import (
88 "errors"
99 "io"
1010 "net"
11+ "strings"
1112 "testing"
1213 "testing/iotest"
1314 "time"
@@ -136,3 +137,38 @@ func TestDriveHelloMissing(t *testing.T) {
136137 t.Fatal("want error when the first frame is not hello")
137138 }
138139 }
140+
141+func TestDriveJobDeliversJobFrames(t *testing.T) {
142+ host, guest := net.Pipe()
143+ go func() {
144+ g := NewConn(guest)
145+ _ = g.Send(&execpb.Frame{Body: &execpb.Frame_Hello{Hello: &execpb.Hello{Version: "v"}}})
146+ r, _ := g.Recv()
147+ if r.GetRun().GetJob().GetJobKey() != "build" {
148+ t.Errorf("run: %v", r)
149+ }
150+ for _, b := range []*execpb.Frame{
151+ {Body: &execpb.Frame_StepStarted{StepStarted: &execpb.StepStarted{Number: 1, Name: "Set up job"}}},
152+ {Body: &execpb.Frame_Log{Log: &execpb.Log{Step: 1, Text: "hello", UnixNano: 5}}},
153+ {Body: &execpb.Frame_StepCompleted{StepCompleted: &execpb.StepCompleted{Number: 1, Conclusion: execpb.StepConclusion_STEP_CONCLUSION_SUCCESS}}},
154+ {Body: &execpb.Frame_JobResult{JobResult: &execpb.JobResult{Conclusion: execpb.StepConclusion_STEP_CONCLUSION_SUCCESS, Outputs: map[string]string{"a": "b"}}}},
155+ {Body: &execpb.Frame_Exit{Exit: &execpb.Exit{}}},
156+ } {
157+ _ = g.Send(b)
158+ }
159+ }()
160+ var got []string
161+ _, exit, err := DriveJob(host, &execpb.Run{Job: &execpb.JobSpec{JobKey: "build"}}, nil, JobHandler{
162+ StepStarted: func(s *execpb.StepStarted) { got = append(got, "start:"+s.Name) },
163+ Log: func(l *execpb.Log) { got = append(got, "log:"+l.Text) },
164+ StepCompleted: func(s *execpb.StepCompleted) { got = append(got, "done:"+s.Conclusion.String()) },
165+ JobResult: func(r *execpb.JobResult) { got = append(got, "result:"+r.Outputs["a"]) },
166+ })
167+ if err != nil || exit == nil {
168+ t.Fatal(err)
169+ }
170+ want := "start:Set up job log:hello done:STEP_CONCLUSION_SUCCESS result:b"
171+ if strings.Join(got, " ") != want {
172+ t.Fatalf("%v", got)
173+ }
174+}
@@ -8,6 +8,7 @@ import (
8 "errors"8 "errors"
9 "io"9 "io"
10 "net"10 "net"
11+ "strings"
11 "testing"12 "testing"
12 "testing/iotest"13 "testing/iotest"
13 "time"14 "time"
@@ -136,3 +137,38 @@ func TestDriveHelloMissing(t *testing.T) {
136 t.Fatal("want error when the first frame is not hello")137 t.Fatal("want error when the first frame is not hello")
137 }138 }
138 }139 }
140+
141+func TestDriveJobDeliversJobFrames(t *testing.T) {
142+ host, guest := net.Pipe()
143+ go func() {
144+ g := NewConn(guest)
145+ _ = g.Send(&execpb.Frame{Body: &execpb.Frame_Hello{Hello: &execpb.Hello{Version: "v"}}})
146+ r, _ := g.Recv()
147+ if r.GetRun().GetJob().GetJobKey() != "build" {
148+ t.Errorf("run: %v", r)
149+ }
150+ for _, b := range []*execpb.Frame{
151+ {Body: &execpb.Frame_StepStarted{StepStarted: &execpb.StepStarted{Number: 1, Name: "Set up job"}}},
152+ {Body: &execpb.Frame_Log{Log: &execpb.Log{Step: 1, Text: "hello", UnixNano: 5}}},
153+ {Body: &execpb.Frame_StepCompleted{StepCompleted: &execpb.StepCompleted{Number: 1, Conclusion: execpb.StepConclusion_STEP_CONCLUSION_SUCCESS}}},
154+ {Body: &execpb.Frame_JobResult{JobResult: &execpb.JobResult{Conclusion: execpb.StepConclusion_STEP_CONCLUSION_SUCCESS, Outputs: map[string]string{"a": "b"}}}},
155+ {Body: &execpb.Frame_Exit{Exit: &execpb.Exit{}}},
156+ } {
157+ _ = g.Send(b)
158+ }
159+ }()
160+ var got []string
161+ _, exit, err := DriveJob(host, &execpb.Run{Job: &execpb.JobSpec{JobKey: "build"}}, nil, JobHandler{
162+ StepStarted: func(s *execpb.StepStarted) { got = append(got, "start:"+s.Name) },
163+ Log: func(l *execpb.Log) { got = append(got, "log:"+l.Text) },
164+ StepCompleted: func(s *execpb.StepCompleted) { got = append(got, "done:"+s.Conclusion.String()) },
165+ JobResult: func(r *execpb.JobResult) { got = append(got, "result:"+r.Outputs["a"]) },
166+ })
167+ if err != nil || exit == nil {
168+ t.Fatal(err)
169+ }
170+ want := "start:Set up job log:hello done:STEP_CONCLUSION_SUCCESS result:b"
171+ if strings.Join(got, " ") != want {
172+ t.Fatalf("%v", got)
173+ }
174+}

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 4

Tags 11

↑↓ moveenter openesc close