Add Run control event stream
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/dto"
|
||||
@@ -109,6 +112,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}", h.serverInstanceDetail)
|
||||
mux.HandleFunc("/api/v1/run/control/hello", h.runControlHello)
|
||||
mux.HandleFunc("/api/v1/run/control/heartbeat", h.requireRunSignature(h.runControlHeartbeat))
|
||||
mux.HandleFunc("/api/v1/run/control/events", h.requireRunSignature(h.runControlEvents))
|
||||
mux.HandleFunc("/api/v1/run/lifecycle/report", h.requireRunSignature(h.runLifecycleReport))
|
||||
mux.HandleFunc("/api/v1/run/jobs/claim", h.requireRunSignature(h.runJobClaim))
|
||||
mux.HandleFunc("/api/v1/run/jobs/ack", h.requireRunSignature(h.runJobAck))
|
||||
@@ -1691,6 +1695,108 @@ func (h *coreHandlers) runControlHeartbeat(w http.ResponseWriter, r *http.Reques
|
||||
writeJSON(w, http.StatusOK, dto.RunControlHeartbeatFromDomain(result))
|
||||
}
|
||||
|
||||
// runControlEvents godoc
|
||||
// @Summary Stream lightweight Run control events
|
||||
// @Description Opens a signed Run-only event stream for wakeups such as queued job changes. Events never carry job payloads, logs, artifacts, host paths, or credentials.
|
||||
// @Tags run
|
||||
// @Accept json
|
||||
// @Produce text/event-stream
|
||||
// @Param body body dto.RunControlStreamRequest true "Run control stream request"
|
||||
// @Success 200 {object} dto.RunControlEventResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 405 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/run/control/events [post]
|
||||
func (h *coreHandlers) runControlEvents(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.RunControlStreamRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
subscription, err := h.core.SubscribeRunControlEvents(request.ToDomain())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
defer subscription.Cancel()
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
writeServiceError(w, validator.ValidationError{Violations: []string{"streaming response is unavailable"}})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
lastSeq := request.LastEventSeq
|
||||
ready := dto.RunControlEventResponse{RunEndpointID: request.RunEndpointID, Sequence: lastSeq, Type: domain.RunControlEventTypeReady, ServerTime: time.Now().UTC(), RetrySeconds: 5}
|
||||
if err := writeRunControlSSE(w, flusher, ready); err != nil {
|
||||
return
|
||||
}
|
||||
if subscription.Initial != nil {
|
||||
event := dto.RunControlEventFromDomain(*subscription.Initial)
|
||||
if event.Sequence > lastSeq {
|
||||
lastSeq = event.Sequence
|
||||
}
|
||||
if err := writeRunControlSSE(w, flusher, event); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
heartbeatTicker := time.NewTicker(15 * time.Second)
|
||||
defer heartbeatTicker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case event, ok := <-subscription.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response := dto.RunControlEventFromDomain(event)
|
||||
if response.Sequence > lastSeq {
|
||||
lastSeq = response.Sequence
|
||||
}
|
||||
if err := writeRunControlSSE(w, flusher, response); err != nil {
|
||||
return
|
||||
}
|
||||
case <-heartbeatTicker.C:
|
||||
heartbeat := dto.RunControlEventResponse{RunEndpointID: request.RunEndpointID, Sequence: lastSeq, Type: domain.RunControlEventTypeHeartbeat, ServerTime: time.Now().UTC(), RetrySeconds: 5}
|
||||
if err := writeRunControlSSE(w, flusher, heartbeat); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeRunControlSSE(w http.ResponseWriter, flusher http.Flusher, event dto.RunControlEventResponse) error {
|
||||
data, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if event.Type != "" {
|
||||
if _, err := fmt.Fprintf(w, "event: %s\n", event.Type); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "id: %d\n", event.Sequence); err != nil {
|
||||
return err
|
||||
}
|
||||
if event.RetrySeconds > 0 {
|
||||
if _, err := fmt.Fprintf(w, "retry: %d\n", event.RetrySeconds*1000); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil {
|
||||
return err
|
||||
}
|
||||
flusher.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
// runLifecycleReport godoc
|
||||
// @Summary Report autonomous run lifecycle result
|
||||
// @Description Lets a registered run endpoint report an observed lifecycle terminal result without a platform-assigned job lease.
|
||||
|
||||
Reference in New Issue
Block a user