Add Run control event stream

This commit is contained in:
npc0-hue
2026-08-26 23:06:06 +08:00
parent 6369a8099a
commit 55a5d6de80
10 changed files with 274 additions and 3 deletions
+106
View File
@@ -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.