Stream live server logs over SSE

This commit is contained in:
npc0-hue
2026-08-03 22:28:54 +08:00
parent 5d4fca14f9
commit 7eac1926dd
48 changed files with 1526 additions and 263 deletions
+187
View File
@@ -0,0 +1,187 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/dto"
"browser.local/platform/service"
)
const (
defaultLogEventHistoryLimit = 100
maxLogEventHistoryLimit = 500
logEventHeartbeatInterval = 15 * time.Second
)
// serverLogEvents godoc
// @Summary Stream server log events
// @Description Streams safe server log entries over Server-Sent Events. Durable cursor query remains available for history and reconnect repair.
// @Tags logs
// @Produce text/event-stream
// @Param id path string true "Server instance ID"
// @Param historyLimit query int false "Recent entries per stream to replay before live events"
// @Success 200 {string} string "event-stream"
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 405 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/logs/events [get]
func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
instance, streams, subscription, err := h.openLogEventSubscription(r)
if err != nil {
writeServiceError(w, err)
return
}
defer subscription.Close()
flusher, ok := w.(http.Flusher)
if !ok {
writeServiceError(w, fmt.Errorf("streaming response unsupported"))
return
}
header := w.Header()
header.Set("Content-Type", "text/event-stream")
header.Set("Cache-Control", "no-cache, no-transform")
header.Set("Connection", "keep-alive")
header.Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
historyLimit := parseLogEventHistoryLimit(r.URL.Query().Get("historyLimit"))
for _, stream := range streams {
if err := writeSSEJSON(w, "stream", "", dto.LogStreamFromDomain(stream)); err != nil {
return
}
if historyLimit > 0 {
if err := h.writeLogEventHistory(w, stream, historyLimit); err != nil {
_ = writeSSEJSON(w, "error", "", map[string]string{"message": err.Error()})
flusher.Flush()
return
}
}
}
if err := writeSSEJSON(w, "ready", "", dto.LogStreamEventsReadyResponse{ServerInstanceID: instance.ID, StreamCount: len(streams), ServerTime: time.Now().UTC()}); err != nil {
return
}
flusher.Flush()
heartbeat := time.NewTicker(logEventHeartbeatInterval)
defer heartbeat.Stop()
for {
select {
case <-r.Context().Done():
return
case event, ok := <-subscription.Events:
if !ok {
return
}
if err := writeSSEJSON(w, "log", logEventID(event), dto.LogStreamEventFromDomain(event)); err != nil {
return
}
flusher.Flush()
case <-heartbeat.C:
if _, err := fmt.Fprintf(w, ": heartbeat %s\n\n", time.Now().UTC().Format(time.RFC3339)); err != nil {
return
}
flusher.Flush()
}
}
}
func (h *coreHandlers) openLogEventSubscription(r *http.Request) (domain.ServerInstance, []domain.LogStream, service.LogEventSubscription, error) {
var instance domain.ServerInstance
var streams []domain.LogStream
var subscription service.LogEventSubscription
var err error
if h.enforceAuthorization {
sessionID := bearerToken(r)
instance, err = h.core.GetServerInstanceForSession(sessionID, r.PathValue("id"))
if err != nil {
return domain.ServerInstance{}, nil, subscription, err
}
streams, err = h.core.ListLogStreamsForSession(sessionID, domain.LogStreamFilter{ServerInstanceID: instance.ID})
if err != nil {
return domain.ServerInstance{}, nil, subscription, err
}
subscription, err = h.core.SubscribeLogEventsForSession(sessionID, instance.ID)
} else {
instance, err = h.core.GetServerInstance(r.PathValue("id"))
if err != nil {
return domain.ServerInstance{}, nil, subscription, err
}
streams, err = h.core.ListLogStreams(domain.LogStreamFilter{ServerInstanceID: instance.ID})
if err != nil {
return domain.ServerInstance{}, nil, subscription, err
}
subscription, err = h.core.SubscribeLogEvents(instance.ID)
}
return instance, streams, subscription, err
}
func (h *coreHandlers) writeLogEventHistory(w http.ResponseWriter, stream domain.LogStream, limit int) error {
afterSeq := uint64(0)
if stream.LatestSeq > uint64(limit) {
afterSeq = stream.LatestSeq - uint64(limit)
}
cursor, err := h.core.QueryLogStream(domain.LogStreamCursorQuery{LogStreamID: stream.ID, AfterSeq: afterSeq, Limit: limit})
if err != nil {
return err
}
for _, entry := range cursor.Entries {
event := domain.LogStreamEvent{ServerInstanceID: stream.ServerInstanceID, Stream: stream, Entry: entry, LatestSeq: cursor.LatestSeq}
if err := writeSSEJSON(w, "log", logEventID(event), dto.LogStreamEventFromDomain(event)); err != nil {
return err
}
}
return nil
}
func parseLogEventHistoryLimit(value string) int {
if strings.TrimSpace(value) == "" {
return defaultLogEventHistoryLimit
}
limit, err := strconv.Atoi(value)
if err != nil || limit < 0 {
return defaultLogEventHistoryLimit
}
if limit > maxLogEventHistoryLimit {
return maxLogEventHistoryLimit
}
return limit
}
func writeSSEJSON(w http.ResponseWriter, eventName string, id string, value any) error {
payload, err := json.Marshal(value)
if err != nil {
return err
}
if id != "" {
if _, err := fmt.Fprintf(w, "id: %s\n", sanitizeSSEField(id)); err != nil {
return err
}
}
if _, err := fmt.Fprintf(w, "event: %s\n", sanitizeSSEField(eventName)); err != nil {
return err
}
_, err = fmt.Fprintf(w, "data: %s\n\n", payload)
return err
}
func sanitizeSSEField(value string) string {
value = strings.ReplaceAll(value, "\r", "")
value = strings.ReplaceAll(value, "\n", "")
return value
}
func logEventID(event domain.LogStreamEvent) string {
return fmt.Sprintf("%s:%d", sanitizeSSEField(event.Stream.ID), event.Entry.Seq)
}
+43
View File
@@ -1,7 +1,10 @@
package api
import (
"bufio"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -35,6 +38,32 @@ func TestLogIngestAPIWorkflow(t *testing.T) {
}
}
func TestLogEventsSSEReplaysHistory(t *testing.T) {
router := newTestRouter()
hello := createLogIngestAPIFixtures(t, router)
batch := validLogBatchRequest(t, hello.SessionToken, 1, 2)
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", batch), http.StatusOK)
server := httptest.NewServer(router)
defer server.Close()
client := server.Client()
client.Timeout = 2 * time.Second
response, err := client.Get(server.URL + "/api/v1/server-instances/server-1/logs/events?historyLimit=2")
if err != nil {
t.Fatalf("open log event stream: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK || !strings.HasPrefix(response.Header.Get("Content-Type"), "text/event-stream") {
t.Fatalf("unexpected event stream response: status=%d content-type=%q", response.StatusCode, response.Header.Get("Content-Type"))
}
body := readSSEUntil(t, response, "event: ready")
for _, fragment := range []string{"event: stream", "event: log", `"streamId":"log-1"`, `"seq":1`, `"seq":2`} {
if !strings.Contains(body, fragment) {
t.Fatalf("expected SSE body to contain %q, got:\n%s", fragment, body)
}
}
}
func TestLogIngestAPIDuplicateAndErrors(t *testing.T) {
router := newTestRouter()
hello := createLogIngestAPIFixtures(t, router)
@@ -77,6 +106,20 @@ func createLogIngestAPIFixtures(t *testing.T, router http.Handler) dto.RunContro
return hello
}
func readSSEUntil(t *testing.T, response *http.Response, marker string) string {
t.Helper()
reader := bufio.NewReader(response.Body)
var body strings.Builder
for !strings.Contains(body.String(), marker) {
line, err := reader.ReadString('\n')
if err != nil {
t.Fatalf("read event stream: %v\n%s", err, body.String())
}
body.WriteString(line)
}
return body.String()
}
func validLogBatchRequest(t *testing.T, sessionToken string, firstSeq uint64, lastSeq uint64) dto.LogBatchIngestRequest {
t.Helper()
entries := make([]dto.LogEntryBody, 0, lastSeq-firstSeq+1)
+1
View File
@@ -108,6 +108,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/install", h.serverDependenciesInstall)
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies", h.serverDependencies)
mux.HandleFunc("/api/v1/server-instances/{id}/logs/live", h.serverLiveLogs)
mux.HandleFunc("/api/v1/server-instances/{id}/logs/events", h.serverLogEvents)
mux.HandleFunc("/api/v1/server-instances/{id}/logs/backfill", h.serverLogsBackfill)
mux.HandleFunc("/api/v1/server-instances/{id}/config/diff", h.serverInstanceConfigDiff)
mux.HandleFunc("/api/v1/server-instances/{id}/config/approve", h.serverInstanceConfigApprove)
+22 -4
View File
@@ -114,7 +114,18 @@ func TestCoreAPICreateListDetailWorkflows(t *testing.T) {
}
getJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams/log-1")
streams := getJSON[dto.LogStreamListResponse](t, router, "/api/v1/log-streams?serverInstanceId=server-1&streamKey=stdout")
assertListCount(t, streams.Count, 1)
if streams.Count < 1 {
t.Fatalf("expected stdout streams, got %+v", streams)
}
foundExplicitStream := false
for _, stream := range streams.Items {
if stream.ID == "log-1" {
foundExplicitStream = true
}
}
if !foundExplicitStream {
t.Fatalf("expected explicitly created stream in list, got %+v", streams)
}
auditResponse := postJSON[dto.AuditEventResponse](t, router, "/api/v1/audit-events", dto.AuditEventCreateRequest{
ID: "audit-1",
@@ -361,7 +372,13 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
t.Fatalf("unexpected log backfill job: %+v", backfill)
}
liveLogs := getJSONWithAuth[dto.LogStreamListResponse](t, router, "/api/v1/server-instances/"+serverID+"/logs/live", adminSession)
if liveLogs.Count != 1 || liveLogs.Items[0].StreamKey != "stdout" {
foundFileLog := false
for _, stream := range liveLogs.Items {
if stream.Source == domain.LogStreamSourceFile && stream.StreamKey == "latest-log" {
foundFileLog = true
}
}
if !foundFileLog {
t.Fatalf("unexpected live logs: %+v", liveLogs)
}
@@ -1503,7 +1520,7 @@ func TestRuntimeBindingAPIIsAuthorizedValidatedAndRedacted(t *testing.T) {
assertErrorResponse(t, undeclared, http.StatusBadRequest, errorCodeValidation)
created := postOKJSONWithAuth[dto.ServerLifecycleResponse](t, router, "/api/v1/server-instances/workflows/create", dto.ServerLifecycleCreateRequest{ID: "runtime-create-complete", PluginID: registration.Manifest.ID, RunEndpointID: "run-local", Name: "Runtime Create Complete", IdempotencyKey: "runtime-create-complete", ProfileKey: "local", Bindings: map[string]string{"server-root": "runtime.server-root", "rcon.password": "secret://runtime-create-complete/rcon"}}, adminSession)
if created.Job.TargetKey != "local" {
if created.Job.TargetKey != "actions/install.json" {
t.Fatalf("create workflow did not dispatch selected profile: %+v", created.Job)
}
incompleteCreate := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/workflows/create", dto.ServerLifecycleCreateRequest{ID: "runtime-create-incomplete", PluginID: registration.Manifest.ID, RunEndpointID: "run-local", Name: "Runtime Create Incomplete", IdempotencyKey: "runtime-create-incomplete", ProfileKey: "local", Bindings: map[string]string{"server-root": "runtime.server-root"}}, adminSession)
@@ -1858,6 +1875,7 @@ func createRuntimeAPIFixtures(t *testing.T, router http.Handler, adminSession st
}
pluginRequest.RuntimeProfiles.DependencyProbes = []dto.RuntimeDependencyProbeBody{{Key: "java-runtime", Kind: "command.version", TargetKey: "java", Platforms: []string{"linux"}}}
pluginRequest.RuntimeProfiles.InstallPlans = []dto.RuntimeInstallPlanBody{{Key: "java-install", Title: "Install Java", Platforms: []string{"linux"}, Steps: []dto.RuntimeInstallStepBody{{Type: "package", TargetKey: "java", PackageManager: "apt", PackageName: "openjdk-21-jre"}}}}
pluginRequest.RuntimeProfiles.LogSources = []dto.RuntimeLogSourceBody{{Key: "latest", Kind: "file.tail", TargetKey: "logs/latest", StreamKey: "latest-log", CursorKind: "offset", RetentionDays: 30}}
pluginRequest.RuntimeProfiles.ClientManagers = []dto.RuntimeClientManagerProfileBody{{Key: "scum-client-manager", DisplayName: "SCUM Client Manager", Version: "1.0.0", Repository: dto.RuntimeRepositoryBody{URL: "https://github.com/F88888/scum_client.git", RevisionPolicy: "branch", Branch: "main"}, SupportedTargets: []dto.RuntimeTargetBody{{OS: "windows", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}, Build: dto.RuntimeBuildBody{System: "go", EntryRef: "main.go"}, OutputArtifacts: []string{"scum_client.exe"}, Deployment: dto.RuntimeClientManagerDeploymentBody{Mode: "run-supervised", ExecutableRef: "scum_client.exe", RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: dto.RuntimeClientManagerLifecycleBody{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: dto.RuntimeClientManagerHealthBody{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health"}}, Compatibility: dto.RuntimeClientManagerCompatibilityBody{MinimumVersion: "1.0.0"}, UpdatePolicy: dto.RuntimeClientManagerUpdatePolicyBody{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}}
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", pluginRequest)
@@ -1886,7 +1904,7 @@ func createRuntimeAPIFixtures(t *testing.T, router http.Handler, adminSession st
Name: "Runtime API Server",
State: domain.ServerInstanceStateReady,
}, adminSession)
putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+server.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, adminSession)
putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+server.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{"logs/latest": "runtime.logs.latest"}}, adminSession)
postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{
ID: "log-runtime-api",
ServerInstanceID: server.ID,
+4 -2
View File
@@ -14,7 +14,7 @@ All routes use JSON request and response bodies. Collection routes support `GET`
| Plugin marketplace | `GET /api/v1/plugin-marketplace/plugins` | `GET /api/v1/plugin-marketplace/plugins/{id}`, `POST /api/v1/plugin-marketplace/plugins/{id}/state` | `MarketplacePluginResponse`, `MarketplacePluginListResponse`, `MarketplacePluginStateRequest` |
| Plugin bridge | `POST /api/v1/plugin-bridge/authorize`, `POST /api/v1/plugin-bridge/execute` | n/a | `PluginBridgeAuthorizeRequest`, `PluginBridgeAuthorizeResponse`, `PluginBridgeExecuteRequest`, `PluginBridgeExecuteResponse` |
| Server instances | `GET /api/v1/server-instances`, `POST /api/v1/server-instances` | `GET /api/v1/server-instances/{id}`, `PUT /api/v1/server-instances/{id}`, `DELETE /api/v1/server-instances/{id}` | `ServerInstanceCreateRequest`, `ServerInstanceUpdateRequest`, `ServerInstanceResponse`, `ServerInstanceListResponse` |
| Server runtime distribution | n/a | `GET /api/v1/server-instances/{id}/runtime/actions`, `POST /api/v1/server-instances/{id}/run/generate`, `POST /api/v1/server-instances/{id}/run/download`, `POST /api/v1/server-instances/{id}/run/key/reset`, `POST /api/v1/server-instances/{id}/run/update`, `GET /api/v1/server-instances/{id}/run/update`, `POST /api/v1/server-instances/{id}/client-managers/generate`, `POST /api/v1/server-instances/{id}/client-managers/download`, `POST /api/v1/server-instances/{id}/client-managers/key/reset`, `GET /api/v1/server-instances/{id}/dependencies`, `POST /api/v1/server-instances/{id}/dependencies/check`, `POST /api/v1/server-instances/{id}/dependencies/install`, `GET /api/v1/server-instances/{id}/logs/live`, `POST /api/v1/server-instances/{id}/logs/backfill` | `ServerRuntimeActionsResponse`, `RunDistributionGenerateRequest`, `RunDistributionResponse`, `RunUpdateRequest`, `RunUpdateJobResponse`/`RunUpdateJobListResponse`, `ClientManagerBuildRequest`, `ClientManagerDistributionResponse`, `ClientManagerDownloadRequest`, `ComponentKeyResetRequest`, `ComponentKeyResponse`, `DependencyCatalogResponse`, `DependencyJobRequest`, `LogBackfillRequest` |
| Server runtime distribution | n/a | `GET /api/v1/server-instances/{id}/runtime/actions`, `POST /api/v1/server-instances/{id}/run/generate`, `POST /api/v1/server-instances/{id}/run/download`, `POST /api/v1/server-instances/{id}/run/key/reset`, `POST /api/v1/server-instances/{id}/run/update`, `GET /api/v1/server-instances/{id}/run/update`, `POST /api/v1/server-instances/{id}/client-managers/generate`, `POST /api/v1/server-instances/{id}/client-managers/download`, `POST /api/v1/server-instances/{id}/client-managers/key/reset`, `GET /api/v1/server-instances/{id}/dependencies`, `POST /api/v1/server-instances/{id}/dependencies/check`, `POST /api/v1/server-instances/{id}/dependencies/install`, `GET /api/v1/server-instances/{id}/logs/live`, `GET /api/v1/server-instances/{id}/logs/events`, `POST /api/v1/server-instances/{id}/logs/backfill` | `ServerRuntimeActionsResponse`, `RunDistributionGenerateRequest`, `RunDistributionResponse`, `RunUpdateRequest`, `RunUpdateJobResponse`/`RunUpdateJobListResponse`, `ClientManagerBuildRequest`, `ClientManagerDistributionResponse`, `ClientManagerDownloadRequest`, `ComponentKeyResetRequest`, `ComponentKeyResponse`, `DependencyCatalogResponse`, `DependencyJobRequest`, `LogBackfillRequest`, `LogStreamEventResponse` |
| Metrics | `GET /api/v1/metrics/platform`, `GET /api/v1/metrics/server-instances` | n/a | `PlatformResourceUsageResponse`, `ServerMetricsResponse`, `ServerMetricsListResponse` |
| Server config | n/a | `GET /api/v1/server-instances/{id}/config`, `POST /api/v1/server-instances/{id}/config/diff`, `POST /api/v1/server-instances/{id}/config/approve` | `ServerConfigResponse`, `ServerConfigDiffPreviewRequest`, `ServerConfigDiffPreviewResponse`, `ServerConfigWriteApprovalRequest`, `ServerConfigWriteDispatchResponse` |
| File operations | `POST /api/v1/file-operations/dispatch` | n/a | `FileOperationDispatchRequest`, `FileOperationDispatchResponse` |
@@ -146,7 +146,7 @@ Lifecycle workflow responses include accepted status, action, bounded server ins
## Implemented Runtime Distribution And Client Manager Actions
- `GET /api/v1/server-instances/{id}/runtime-binding`: returns the visible server's selected profile and redacted logical binding readiness. Values are represented only by configured/secret-backed flags.
- `PUT /api/v1/server-instances/{id}/runtime-binding`: lets the server owner or a platform administrator select a declared profile and patch safe logical refs. Undeclared keys, unsafe paths/sockets/credentials, and changes to an existing active binding are rejected.
- `PUT /api/v1/server-instances/{id}/runtime-binding`: lets the server owner or a platform administrator select a declared profile and patch safe logical refs for non-deleted servers. Undeclared keys, unsafe paths/sockets/credentials, and plaintext secrets are rejected.
- `GET /api/v1/server-instances/{id}/runtime/actions`: returns the current user-visible runtime action matrix for the server, including run endpoint status, action availability, and safe unavailable reasons.
- `POST /api/v1/server-instances/{id}/run/generate`: accepts `RunDistributionGenerateRequest`, queues a platform-owned Docker build, creates or reuses the server's current encrypted run key, writes that key into the secret-bearing generated package config, publishes an artifact, and returns `RunDistributionResponse` with checksum, key generation, artifact ID, build job ID, and redacted secret ref only.
- `POST /api/v1/server-instances/{id}/run/download`: opens the latest available run package through `ArtifactDownloadReferenceResponse` after server-scoped authorization.
@@ -160,6 +160,7 @@ Lifecycle workflow responses include accepted status, action, bounded server ins
- `POST /api/v1/server-instances/{id}/dependencies/check`: accepts `DependencyJobRequest` and queues a `dependencies.check` run job for a declared logical probe key.
- `POST /api/v1/server-instances/{id}/dependencies/install`: accepts `DependencyJobRequest` with an install plan key and the exact catalog `planDigest`; stale/missing digests are denied before job creation.
- `GET /api/v1/server-instances/{id}/logs/live`: returns safe live log stream metadata for the selected server using `LogStreamListResponse`.
- `GET /api/v1/server-instances/{id}/logs/events`: streams selected server log metadata and entries as `text/event-stream`; the optional `historyLimit` query replays recent stored entries before live push events.
- `POST /api/v1/server-instances/{id}/logs/backfill`: accepts `LogBackfillRequest`, queues a `logs.backfill` job with source key, checkpoint ref, limit, and idempotency metadata, and keeps log bodies out of job results.
Runtime distribution and client-manager APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings where required, and platform-builder readiness. Run-side lifecycle commands separately require run endpoint capability support. Responses and audit summaries expose artifact IDs, job IDs, checksums, key generations, fingerprints, status, and redacted `secret://runtime-keys/.../current` refs only. They do not expose raw run keys, client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct sockets, run endpoint private addresses, build workspace paths, or large inline logs.
@@ -196,6 +197,7 @@ Job ack/progress/result/cancel/reconcile calls remain lightweight and independen
- `POST /api/v1/run/logs/batches`: accept `LogBatchIngestRequest`, validate run session and stream metadata, store contiguous entries, update `LogStream.LatestSeq`, and return `LogBatchIngestResponse` with the acknowledged range.
- `POST /api/v1/log-streams/query`: accept `LogStreamCursorRequest` and return `LogStreamCursorResponse` with bounded ordered entries after a cursor.
- `GET /api/v1/server-instances/{id}/logs/events`: authorize the browser session for the server, replay bounded recent entries, and push newly ingested log entries over SSE without polling log stream queries.
Log ingest actions carry durable log metadata and bounded entries only: run endpoint ID, session token, stream identity, source, sequence range, compression metadata, checksum, entries, and cursor limits. They do not carry artifact chunks, host paths, raw credentials, direct sockets, or unbounded inline data.
Log ingest is durable and independently retried. Artifact/file transfer backlog must not prevent log acknowledgement, duplicate acknowledgement, cursor state updates, or spool cleanup.