Add persistent Run control stream

This commit is contained in:
npc0-hue
2026-08-26 23:05:40 +08:00
parent 3b4e857ef7
commit ac14f80306
8 changed files with 245 additions and 9 deletions
+79
View File
@@ -1,6 +1,7 @@
package api
import (
"bufio"
"bytes"
"context"
"crypto/hmac"
@@ -148,6 +149,84 @@ func (c PlatformClient) Heartbeat(ctx context.Context, request protocol.RunHeart
return postPlatformJSON[protocol.RunHeartbeatRequest, protocol.RunHeartbeatResponse](ctx, c, "/api/v1/run/control/heartbeat", request)
}
func (c PlatformClient) StreamControlEvents(ctx context.Context, request protocol.RunControlStreamRequest, handle func(protocol.RunControlEvent) error) error {
if handle == nil {
return fmt.Errorf("control event handler is required")
}
startedAt := time.Now()
path := "/api/v1/run/control/events"
log.Printf("RUN platform stream status=starting method=POST base=%s path=%s", diagnosticLogValue(c.baseURL), path)
var body bytes.Buffer
if err := json.NewEncoder(&body).Encode(request); err != nil {
return fmt.Errorf("encode control stream request: %w", err)
}
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, &body)
if err != nil {
return fmt.Errorf("build control stream request: %w", err)
}
httpRequest.Header.Set("Content-Type", "application/json")
httpRequest.Header.Set("Accept", "text/event-stream")
signatureSummary, err := signRunRequest(httpRequest, body.Bytes())
if err != nil {
return err
}
log.Printf("RUN platform stream status=signed method=POST base=%s path=%s endpoint=%s timestamp=%s nonce=%s bodyHash=%s signature=%s", diagnosticLogValue(c.baseURL), path, diagnosticLogValue(signatureSummary.RunEndpointID), signatureSummary.Timestamp, shortDiagnosticValue(signatureSummary.Nonce), shortDiagnosticValue(signatureSummary.BodyHash), shortDiagnosticValue(signatureSummary.Signature))
httpResponse, err := c.httpClient.Do(httpRequest)
if err != nil {
log.Printf("RUN platform stream status=send_error method=POST base=%s path=%s durationMs=%d error=%s", diagnosticLogValue(c.baseURL), path, time.Since(startedAt).Milliseconds(), err)
return fmt.Errorf("send control stream request: %w", err)
}
defer httpResponse.Body.Close()
log.Printf("RUN platform stream status=response method=POST base=%s path=%s httpStatus=%d durationMs=%d", diagnosticLogValue(c.baseURL), path, httpResponse.StatusCode, time.Since(startedAt).Milliseconds())
if httpResponse.StatusCode < http.StatusOK || httpResponse.StatusCode >= http.StatusMultipleChoices {
var failure struct {
Code string `json:"code"`
Details []string `json:"details"`
}
_ = json.NewDecoder(io.LimitReader(httpResponse.Body, 64<<10)).Decode(&failure)
return PlatformRequestError{Status: httpResponse.StatusCode, Path: path, Code: failure.Code, Details: failure.Details}
}
reader := bufio.NewReader(httpResponse.Body)
var eventName string
var dataLines []string
for {
line, err := reader.ReadString('\n')
if err != nil && len(line) == 0 {
if errors.Is(err, io.EOF) || ctx.Err() != nil {
return ctx.Err()
}
return fmt.Errorf("read control stream: %w", err)
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
if len(dataLines) > 0 {
var event protocol.RunControlEvent
if err := json.Unmarshal([]byte(strings.Join(dataLines, "\n")), &event); err != nil {
return fmt.Errorf("decode control event: %w", err)
}
if event.Type == "" {
event.Type = eventName
}
if err := handle(event); err != nil {
return err
}
}
eventName = ""
dataLines = nil
} else if strings.HasPrefix(line, "event:") {
eventName = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
} else if strings.HasPrefix(line, "data:") {
dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:")))
}
if err != nil {
if errors.Is(err, io.EOF) || ctx.Err() != nil {
return ctx.Err()
}
return fmt.Errorf("read control stream: %w", err)
}
}
}
func (c PlatformClient) ReportLifecycle(ctx context.Context, request protocol.RunLifecycleReportRequest) (protocol.RunLifecycleReportResponse, error) {
return postPlatformJSON[protocol.RunLifecycleReportRequest, protocol.RunLifecycleReportResponse](ctx, c, "/api/v1/run/lifecycle/report", request)
}
+39
View File
@@ -136,6 +136,45 @@ func TestPlatformClientSignsRunChannelRequestsWithUniqueNonce(t *testing.T) {
}
}
func TestPlatformClientStreamsSignedControlEvents(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/run/control/events" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read stream body: %v", err)
}
verifyRunRequestSignature(t, r, body, "run-local", "session-token")
var request protocol.RunControlStreamRequest
if err := json.Unmarshal(body, &request); err != nil {
t.Fatalf("decode stream request: %v", err)
}
if request.LastEventSeq != 7 {
t.Fatalf("unexpected stream cursor: %+v", request)
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("event: job.changed\nid: 8\ndata: {\"runEndpointId\":\"run-local\",\"sequence\":8,\"type\":\"job.changed\",\"serverTime\":\"2026-07-03T12:00:00Z\"}\n\n"))
}))
defer server.Close()
client, err := NewPlatformClient(server.URL)
if err != nil {
t.Fatalf("new client: %v", err)
}
events := []protocol.RunControlEvent{}
err = client.StreamControlEvents(context.Background(), protocol.RunControlStreamRequest{RunEndpointID: "run-local", SessionToken: "session-token", LastEventSeq: 7}, func(event protocol.RunControlEvent) error {
events = append(events, event)
return nil
})
if err != nil {
t.Fatalf("stream events: %v", err)
}
if len(events) != 1 || events[0].Type != protocol.RunControlEventTypeJobChanged || events[0].Sequence != 8 {
t.Fatalf("unexpected control events: %+v", events)
}
}
func verifyRunRequestSignature(t *testing.T, request *http.Request, body []byte, endpoint string, token string) {
t.Helper()
if request.Header.Get("X-Run-Endpoint") != endpoint {