Move game log processing into SCUM companion
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"browser.local/platform/dto"
|
||||
)
|
||||
|
||||
// gameClientBridgeCompanionLogEvents authorizes a component session and
|
||||
// forwards the current opaque log channel. Platform does not parse, redact,
|
||||
// filter, or derive records from the log body.
|
||||
func (h *coreHandlers) gameClientBridgeCompanionLogEvents(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.GameClientBridgeLogStreamRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
instance, err := h.core.AuthorizeGameClientBridgeLogStream(request.ToDomain())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
forward := r.Clone(withComponentLogServer(r, instance.ID).Context())
|
||||
forward.Method = http.MethodGet
|
||||
h.serverLogEvents(w, forward)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -12,6 +13,8 @@ import (
|
||||
"browser.local/platform/service"
|
||||
)
|
||||
|
||||
type componentLogServerContextKey struct{}
|
||||
|
||||
const (
|
||||
logEventHeartbeatInterval = 15 * time.Second
|
||||
liveLogSourceClockSkew = 90 * time.Second
|
||||
@@ -24,17 +27,13 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
liveBoundary := time.Now().UTC().Add(-liveLogSourceClockSkew)
|
||||
instance, streams, liveEligible, subscription, err := h.openLogEventSubscription(r)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
defer subscription.Close()
|
||||
h.streamCurrentLogEvents(w, r, instance, streams, liveEligible, subscription)
|
||||
}
|
||||
|
||||
func (h *coreHandlers) streamCurrentLogEvents(w http.ResponseWriter, r *http.Request, instance domain.ServerInstance, streams []domain.LogStream, liveEligible bool, subscription service.LogEventSubscription) {
|
||||
liveBoundary := time.Now().UTC().Add(-liveLogSourceClockSkew)
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
writeServiceError(w, fmt.Errorf("streaming response unsupported"))
|
||||
@@ -50,7 +49,11 @@ func (h *coreHandlers) streamCurrentLogEvents(w http.ResponseWriter, r *http.Req
|
||||
|
||||
active := supervisedLogSession{}
|
||||
if liveEligible {
|
||||
active = activeSupervisedLogSession(streams)
|
||||
if isComponentLogRequest(r) {
|
||||
active = activeComponentLogSession(streams)
|
||||
} else {
|
||||
active = activeSupervisedLogSession(streams)
|
||||
}
|
||||
}
|
||||
emittedThrough, err := h.writeCurrentLogSession(w, instance.ID, active)
|
||||
if err != nil {
|
||||
@@ -74,6 +77,9 @@ func (h *coreHandlers) streamCurrentLogEvents(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
if subscriptionEvent.Kind == service.LogEventSubscriptionEventProcessState {
|
||||
if isComponentLogRequest(r) {
|
||||
continue
|
||||
}
|
||||
if subscriptionEvent.ServerInstanceID != instance.ID {
|
||||
continue
|
||||
}
|
||||
@@ -90,7 +96,7 @@ func (h *coreHandlers) streamCurrentLogEvents(w http.ResponseWriter, r *http.Req
|
||||
flusher.Flush()
|
||||
continue
|
||||
}
|
||||
streams, liveEligible, err = h.loadLiveLogSnapshot(instance.ID)
|
||||
streams, liveEligible, err = h.loadLiveLogSnapshot(instance.ID, isComponentLogRequest(r))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -114,8 +120,11 @@ func (h *coreHandlers) streamCurrentLogEvents(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
event := subscriptionEvent.LogEvent
|
||||
candidate := activeSupervisedLogSession([]domain.LogStream{event.Stream})
|
||||
if active.allStreams {
|
||||
candidate = supervisedLogSession{}
|
||||
}
|
||||
if candidate.sessionID != "" && newerLogSession(candidate, active) {
|
||||
streams, liveEligible, err = h.loadLiveLogSnapshot(instance.ID)
|
||||
streams, liveEligible, err = h.loadLiveLogSnapshot(instance.ID, isComponentLogRequest(r))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -133,9 +142,15 @@ func (h *coreHandlers) streamCurrentLogEvents(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
}
|
||||
if !active.contains(event.Stream) {
|
||||
continue
|
||||
if !active.allStreams || event.Stream.ServerInstanceID != instance.ID {
|
||||
continue
|
||||
}
|
||||
active.streams = append(active.streams, event.Stream)
|
||||
if err := writeSSEJSON(w, "stream", "", dto.LogStreamFromDomain(event.Stream)); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if !subscriptionEvent.Live && !sourceLogEntryIsLive(event.Entry, liveBoundary) {
|
||||
if !sourceLogEntryIsLive(event.Entry, liveBoundary) {
|
||||
if event.Entry.Seq > emittedThrough[event.Stream.ID] {
|
||||
emittedThrough[event.Stream.ID] = event.Entry.Seq
|
||||
}
|
||||
@@ -147,10 +162,7 @@ func (h *coreHandlers) streamCurrentLogEvents(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
}
|
||||
// Live relay traffic is already a current best-effort observation.
|
||||
// Do not sequence-gate it: a restarted Run intentionally starts with
|
||||
// fresh in-memory sequence state and must still reach this subscriber.
|
||||
if !subscriptionEvent.Live && event.Entry.Seq <= emittedThrough[event.Stream.ID] {
|
||||
if event.Entry.Seq <= emittedThrough[event.Stream.ID] {
|
||||
continue
|
||||
}
|
||||
if err := writeSSEJSON(w, "log", logEventID(event), dto.LogStreamEventFromDomain(event)); err != nil {
|
||||
@@ -172,9 +184,14 @@ func sourceLogEntryIsLive(entry domain.LogEntry, liveBoundary time.Time) bool {
|
||||
}
|
||||
|
||||
type supervisedLogSession struct {
|
||||
sessionID string
|
||||
startedAt time.Time
|
||||
streams []domain.LogStream
|
||||
sessionID string
|
||||
startedAt time.Time
|
||||
streams []domain.LogStream
|
||||
allStreams bool
|
||||
}
|
||||
|
||||
func activeComponentLogSession(streams []domain.LogStream) supervisedLogSession {
|
||||
return supervisedLogSession{sessionID: "component", streams: append([]domain.LogStream(nil), streams...), allStreams: true}
|
||||
}
|
||||
|
||||
func activeSupervisedLogSession(streams []domain.LogStream) supervisedLogSession {
|
||||
@@ -217,6 +234,9 @@ func sameSupervisedLogSession(left supervisedLogSession, right supervisedLogSess
|
||||
}
|
||||
|
||||
func (session supervisedLogSession) contains(stream domain.LogStream) bool {
|
||||
if session.allStreams {
|
||||
return session.hasStream(stream.ID)
|
||||
}
|
||||
return session.sessionID != "" && stream.Source == domain.LogStreamSourceProcess && stream.LogSessionID == session.sessionID && stream.SessionStartedAt.Equal(session.startedAt)
|
||||
}
|
||||
|
||||
@@ -235,9 +255,7 @@ func (h *coreHandlers) writeCurrentLogSession(w http.ResponseWriter, serverInsta
|
||||
return nil, err
|
||||
}
|
||||
for _, stream := range active.streams {
|
||||
// Stream metadata identifies the current channel only. The platform
|
||||
// never replays its old body when a live subscriber connects.
|
||||
emittedThrough[stream.ID] = 0
|
||||
emittedThrough[stream.ID] = stream.LatestSeq
|
||||
if err := writeSSEJSON(w, "stream", "", dto.LogStreamFromDomain(stream)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -251,7 +269,12 @@ func (h *coreHandlers) openLogEventSubscription(r *http.Request) (domain.ServerI
|
||||
var liveEligible bool
|
||||
var subscription service.LogEventSubscription
|
||||
var err error
|
||||
if h.enforceAuthorization {
|
||||
if serverInstanceID, ok := r.Context().Value(componentLogServerContextKey{}).(string); ok && strings.TrimSpace(serverInstanceID) != "" {
|
||||
instance, err = h.core.GetServerInstance(serverInstanceID)
|
||||
if err == nil {
|
||||
subscription, err = h.core.SubscribeLogEvents(instance.ID)
|
||||
}
|
||||
} else if h.enforceAuthorization {
|
||||
sessionID := bearerToken(r)
|
||||
instance, err = h.core.GetServerInstanceForSession(sessionID, r.PathValue("id"))
|
||||
if err != nil {
|
||||
@@ -272,7 +295,7 @@ func (h *coreHandlers) openLogEventSubscription(r *http.Request) (domain.ServerI
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
streams, liveEligible, err = h.loadLiveLogSnapshot(instance.ID)
|
||||
streams, liveEligible, err = h.loadLiveLogSnapshot(instance.ID, isComponentLogRequest(r))
|
||||
}
|
||||
if err != nil && subscription.Close != nil {
|
||||
subscription.Close()
|
||||
@@ -280,7 +303,16 @@ func (h *coreHandlers) openLogEventSubscription(r *http.Request) (domain.ServerI
|
||||
return instance, streams, liveEligible, subscription, err
|
||||
}
|
||||
|
||||
func (h *coreHandlers) loadLiveLogSnapshot(serverInstanceID string) ([]domain.LogStream, bool, error) {
|
||||
func withComponentLogServer(r *http.Request, serverInstanceID string) *http.Request {
|
||||
return r.WithContext(context.WithValue(r.Context(), componentLogServerContextKey{}, serverInstanceID))
|
||||
}
|
||||
|
||||
func isComponentLogRequest(r *http.Request) bool {
|
||||
_, ok := r.Context().Value(componentLogServerContextKey{}).(string)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (h *coreHandlers) loadLiveLogSnapshot(serverInstanceID string, includeDeclaredStreams bool) ([]domain.LogStream, bool, error) {
|
||||
instance, err := h.core.GetServerInstance(serverInstanceID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
@@ -305,6 +337,16 @@ func (h *coreHandlers) loadLiveLogSnapshot(serverInstanceID string) ([]domain.Lo
|
||||
}
|
||||
current := make([]domain.LogStream, 0, len(streams))
|
||||
for _, stream := range streams {
|
||||
if includeDeclaredStreams {
|
||||
if stream.LogSessionID != "" && stream.LogSessionID != logSessionID {
|
||||
continue
|
||||
}
|
||||
if stream.Source != domain.LogStreamSourceProcess && stream.Source != domain.LogStreamSourceFile && stream.Source != domain.LogStreamSourceManagementProgram {
|
||||
continue
|
||||
}
|
||||
current = append(current, stream)
|
||||
continue
|
||||
}
|
||||
if stream.Source == domain.LogStreamSourceProcess && stream.LogSessionID == logSessionID {
|
||||
current = append(current, stream)
|
||||
}
|
||||
|
||||
@@ -130,7 +130,6 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/run/jobs/cancel", h.requireRunSignature(h.runJobCancelPoll))
|
||||
mux.HandleFunc("/api/v1/run/jobs/reconcile", h.requireRunSignature(h.runJobReconcile))
|
||||
mux.HandleFunc("/api/v1/run/logs/batches", h.requireRunSignature(h.runLogBatchIngest))
|
||||
mux.HandleFunc("/api/v1/run/logs/relay", h.requireRunSignature(h.runLiveLogRelay))
|
||||
mux.HandleFunc("/api/v1/run/logs/progress", h.requireRunSignature(h.runLogStreamProgress))
|
||||
mux.HandleFunc("/api/v1/run/artifacts/open", h.requireRunSignature(h.runArtifactOpen))
|
||||
mux.HandleFunc("/api/v1/run/artifacts/chunks", h.requireRunSignature(h.runArtifactChunkUpload))
|
||||
|
||||
@@ -154,7 +154,7 @@ Lifecycle workflow responses include accepted status, action, bounded server ins
|
||||
- `GET /api/v1/server-instances/{id}/dependencies`: returns the target-matched plugin/profile dependency catalog, current safe probe status/evidence, typed plan summaries, and deterministic immutable plan digests.
|
||||
- `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.
|
||||
Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is registered for the server detail terminal drawer and emits current live Run log relay events only. It does not replay retained platform log entries. SCUM companion streaming (`POST /api/v1/game-client-bridge/companion/logs/events`) uses the component session and receives the same current live stream so the plugin can own game-log storage, analysis, and console fan-out.
|
||||
Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is registered for the server detail terminal drawer and emits platform-accepted live log SSE events only. The component stream (`POST /api/v1/game-client-bridge/companion/logs/events`) forwards the current declared streams to the bound plugin companion. Neither route replays retained log entries or interprets their body; the raw log list/backfill routes (`logs/live` and `logs/backfill`) remain unavailable as product APIs, and internal log ingest and cursor query remain available for run/platform maintenance flows.
|
||||
|
||||
Runtime distribution and client-manager APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings only for actions that truly depend on external logical bindings, and platform-builder readiness. Run-side lifecycle commands separately require run endpoint capability support and use plugin-declared lifecycle actions without making manual runtime-profile binding a user prerequisite. Responses and 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.
|
||||
|
||||
@@ -194,17 +194,17 @@ Job ack/progress/result/cancel/reconcile calls remain lightweight and independen
|
||||
|
||||
Run file input chunks are used only for browser-staged file uploads that produce `artifact://` job inputs. The route never returns storage backend paths, browser bearer credentials, machine paths, direct sockets, or unrestricted artifact bodies. This channel is lower priority than control, job lifecycle calls, and log ingest.
|
||||
|
||||
## Implemented Live Log Relay Actions
|
||||
## Implemented Log Ingest Actions
|
||||
|
||||
- `POST /api/v1/run/logs/relay`: accept `LogBatchIngestRequest`, validate run session and stream metadata, update current stream/session metadata, and immediately fan out entries to live subscribers without storing log bodies.
|
||||
- `GET /api/v1/server-instances/{id}/logs/events`: stream the current supervised process session to the server terminal drawer without replaying old retained log bodies.
|
||||
- `POST /api/v1/game-client-bridge/companion/logs/events`: stream the current supervised process session to a component-authenticated game companion so the plugin can store/analyze game logs.
|
||||
- `POST /api/v1/run/logs/batches`: compatibility/internal durable ingest for older workers; current Run output should use `/run/logs/relay` instead of a local spool/cache/resend loop.
|
||||
- `POST /api/v1/run/logs/batches`: accept `LogBatchIngestRequest`, validate run session and stream metadata, store contiguous entries verbatim, 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.
|
||||
- `POST /api/v1/game-client-bridge/companion/logs/events`: authorize the component session and forward the current declared log channel as SSE. The payload is opaque; parsing, redaction, and user/business projections belong to the plugin companion.
|
||||
Server-scoped SSE log streaming remains available for the terminal drawer. `POST /api/v1/log-streams/query` remains the bounded cursor contract for internal maintenance/debug reads.
|
||||
|
||||
Live log relay actions carry current 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. Run must not block lifecycle/control/job progress on whether Platform or a plugin subscriber received live logs.
|
||||
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.
|
||||
|
||||
Platform storage is configured by `PLATFORM_STORAGE_BACKEND` for platform metadata and compatibility durable-ingest bodies only. Current live relay does not persist log bodies in Platform. SCUM durable console logs and semantic events are stored by the SCUM plugin companion in plugin-owned SQL tables; raw trajectory samples keep world coordinates and do not perform projection or coordinate conversion.
|
||||
Platform storage is configured by `PLATFORM_STORAGE_BACKEND`. The default `file` backend writes metadata snapshots to `PLATFORM_METADATA_PATH` and log bodies to segmented files in `PLATFORM_LOG_DIR`; `memory` remains available for tests and ephemeral local runs. Log bodies are retained and relayed verbatim; Platform does not parse, redact, filter, or derive plugin records from them. Relational stores such as MySQL/Postgres are reserved for metadata, stream cursors, indexes, retention state, and operational records. High-volume log bodies for hundreds or thousands of servers should use a log-optimized backend behind `LogBodyStore`, such as ClickHouse, Loki, OpenSearch/Elasticsearch, or object-storage segments.
|
||||
|
||||
## Implemented Run Artifact Actions
|
||||
|
||||
|
||||
Reference in New Issue
Block a user