Move game log processing into SCUM companion
This commit is contained in:
@@ -16,8 +16,6 @@ Until the primary first-party workflows are implemented end-to-end and the produ
|
||||
|
||||
Before 1.0, prioritize making the actual user-facing flows work fully. Remove or avoid audit/restriction scaffolding when it blocks, distracts from, or outpaces the core implementation. Reintroduce audit, compliance, or restrictive governance only after an explicit future product decision for a completed flow.
|
||||
|
||||
Until the primary flows are running end-to-end, do not block implementation behind restrictive policy scaffolding, capability allowlists, delivery acknowledgements, audit gates, or placeholder governance. The governing rule is: make the first-party feature work in the product first, then harden it later only when explicitly requested.
|
||||
|
||||
## Project Roots
|
||||
|
||||
- `platform/` contains backend platform code.
|
||||
@@ -35,7 +33,7 @@ Until the primary flows are running end-to-end, do not block implementation behi
|
||||
- If native MCP tool wrappers return `unsupported call`, do not keep retrying the same wrapper. Read the server-management plugin MCP config and call the local MCP JSON-RPC gateway (`initialize`, `tools/list`, `tools/call`) from a small Node script; never print or commit MCP headers, tokens, host credentials, or raw `.mcp.json` contents.
|
||||
- If local access to the MCP gateway fails with `EPERM` on `127.0.0.1`, request sandbox escalation for that MCP gateway call instead of falling back to raw SSH.
|
||||
- On Windows targets, assume `ssh_exec` may run under `cmd`; for PowerShell diagnostics, send `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand <utf16le-base64>` to avoid quoting/glob problems.
|
||||
- For SCUM current-service discovery, run bounded read-only scripts on `枣庄服务器` itself and return only redacted aggregates/fingerprints. Do not download or parse `SCUM.db` on the platform/plugin/browser side; product evidence must flow through Platform durable jobs and Run typed/redacted envelopes.
|
||||
- For SCUM current-service discovery, run bounded read-only scripts on `枣庄服务器` itself and return bounded typed results. Do not download or parse `SCUM.db` on the platform/plugin/browser side; product evidence must flow through Platform durable jobs and Run typed envelopes. Do not apply game-specific redaction to plugin-declared result fields or player records. Logs are opaque terminal data: Run and Platform must not inspect, parse, filter, redact, transform, or derive player/plugin records from stdout, stderr, or declared file-tail payloads. The SCUM plugin companion may consume its declared log streams, perform the one game-specific parse/redaction pass, and create or update its own users and business records; Platform and Run must never do that work.
|
||||
|
||||
The machine-side run executor lives in the independent repository `git@git.npc0.com:admin343/run.git`; do not re-add a `run/` source tree to this repository.
|
||||
|
||||
@@ -91,31 +89,31 @@ Do not define business structs inside functions. Do not define request/response
|
||||
|
||||
The external run executor must not expose host paths, raw credentials, or direct sockets to plugins or platform_web.
|
||||
|
||||
Platform, plugin, and run lifecycle ownership must stay separated. Run is the machine-side state machine for execution; Platform records desired lifecycle intent, registration/auth, generated package inputs, and persisted lifecycle projections from Run-reported facts, not observed process truth:
|
||||
Platform, plugin, and run lifecycle ownership must stay separated. Run is the lifecycle authority for machine execution; Platform records desired lifecycle intent, registration/auth, generated package inputs, and persisted projections from Run-reported facts, not observed process truth:
|
||||
|
||||
- Platform owns server instances, plugin manifest validation, platform-side distribution builds, generated Run package inputs, run registration binding, authorization, persisted lifecycle projections, and live transport relay between Run, plugins, and platform_web.
|
||||
- Plugins own game-specific lifecycle declarations: init/install/update/pre-start checks, dependency probes/install plans, start arguments, stop logic, status/readiness probes, executable paths, Steam app IDs, game-specific dependency commands, game-log storage, game-log analysis, and console-page log stream fan-out to the frontend.
|
||||
- Run owns generic machine lifecycle execution and the observed runtime/process state it supervises: local bootstrap from generated package plans, scoped file operations, bounded process execution/supervision, current process output push, artifacts, and channel transport. Run must behave as a state machine, not as a durable log database or log-delivery authority.
|
||||
- Platform owns server instances, plugin manifest validation, platform-side distribution builds, generated Run package inputs, run registration binding, authorization, and persisted lifecycle projections.
|
||||
- Plugins own game-specific lifecycle declarations: init/install/update/pre-start checks, dependency probes/install plans, start arguments, stop logic, status/readiness probes, executable paths, Steam app IDs, and game-specific dependency commands.
|
||||
- Run owns generic machine lifecycle execution and the observed runtime/process state it supervises: local bootstrap from generated package plans, scoped file operations, bounded process execution/supervision, declared capability enforcement, logs, artifacts, and channel transport.
|
||||
|
||||
Observed machine/runtime status must flow from run reports, heartbeats, supervised process facts, and job/control events. Platform must not treat stale persisted server state, such as `running`, as authoritative when evaluating the current machine process state.
|
||||
Observed machine/runtime status must flow from run reports, heartbeats, supervised process facts, and job/log channels. Platform must not treat stale persisted server state, such as `running`, as authoritative when evaluating the current machine process state. Run must preserve plugin-declared game records without per-game content redaction. Supervised stdout/stderr and declared file tails are opaque verbatim channels: Run and Platform only persist, relay, or display them; they never content-filter or use them to produce player/plugin data. A plugin companion may parse its own declared stream and publish typed plugin-owned records through the component boundary. This does not grant plugins or the browser a direct host-path, credential, or socket API outside the log channel.
|
||||
|
||||
Do not hardcode game-specific deployment behavior in run or platform services. Values such as `SCUMServer.exe`, Steam app `3792580`, `steamcmd +app_update`, SCUM install directories, `-port`, `-MaxPlayers`, or `-log` belong in the SCUM plugin's manifests, action specs, templates, or scripts.
|
||||
|
||||
When a game needs "install if missing, update if present, then start" behavior, implement it as plugin-owned lifecycle actions and package those declarations into the generated Run autonomous lifecycle plan. Run executes the plan through generic capabilities; neither Platform nor Run should special-case a game by name to perform those steps.
|
||||
|
||||
Do not add extra platform/frontend lifecycle states just to represent game-specific setup checks. A plugin-owned start action should verify its declared files and dependencies, create missing directories, install or update missing server bits, then start the service through the same plugin-declared lifecycle script. Run should only execute that declared script through generic supervision, hide the started process window where the operating system supports it, and push the current supervised process output through the declared stdout/stderr live log relay without caching or delivery acknowledgement.
|
||||
Do not add extra platform/frontend lifecycle states just to represent game-specific setup checks. A plugin-owned start action should verify its declared files and dependencies, create missing directories, install or update missing server bits, then start the service through the same plugin-declared lifecycle script. Run should only execute that declared script through generic supervision, hide the started process window where the operating system supports it, and return the supervised process output through the declared stdout/stderr log channels.
|
||||
|
||||
Realtime log collection must be plugin-declared and plugin-configured. Run only pushes current log/output events that it is observing now; it must not care whether the server side has accepted them, must not maintain durable local log caches, and must not block lifecycle/control/job progress on log delivery. Platform only forwards live log traffic and should not store, analyze, acknowledge, sequence-gate, or own game-log history. The SCUM plugin owns SCUM log storage, analysis, and forwarding live console streams to platform_web when the operator opens the console page.
|
||||
Realtime log collection must be plugin-declared and plugin-configured. For live terminal output, prefer the plugin-declared supervised process channels (`process.stdout` / `process.stderr`) for the process that run started, and treat game-specific file tails only as plugin-declared sources for history, fallback, or explicit backfill. Do not inspect or prioritize a game log file such as `SCUM.log` merely because it exists on disk; if a plugin needs a file tail, window/console capture, startup flag, hidden window behavior, or another live-log source, declare that behavior in the plugin manifest/action/config and keep run/platform generic.
|
||||
|
||||
Run-platform communication must remain channelized, but logs are best-effort live traffic rather than durable platform-owned delivery:
|
||||
Run-platform communication must remain channelized:
|
||||
|
||||
- Control is lightweight and high priority.
|
||||
- Jobs carry lifecycle and bounded operations.
|
||||
- Logs are current best-effort push/relay only: no Run durable spool, no Run resend backlog, no platform sequence acknowledgement dependency, and no platform-owned log persistence. If a game needs stored logs or analysis, implement it in that game plugin.
|
||||
- Logs use durable batch ingest with local spool and sequence acknowledgement.
|
||||
- Artifacts use chunked and resumable transfer with lower priority than logs/control.
|
||||
- Optional game client bridge is separate from run lifecycle and live log relay.
|
||||
- Optional game client bridge is separate from run lifecycle and log ingest.
|
||||
|
||||
Large file transfer must not block control heartbeat, job ack/result, or live log relay.
|
||||
Large file transfer must not block control heartbeat, job ack/result, or log upload.
|
||||
|
||||
## Distribution Build Ownership Rules
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -59,25 +59,6 @@ type GameClientBridgeQueryProjectionDeclaration struct {
|
||||
MergeExisting bool
|
||||
}
|
||||
|
||||
type GameClientBridgeLogProjectionStepDeclaration struct {
|
||||
Pattern string
|
||||
}
|
||||
|
||||
type GameClientBridgeLogProjectionTargetDeclaration struct {
|
||||
Collection string
|
||||
UpsertKeys []string
|
||||
CaptureMappings map[string]string
|
||||
HashMappings map[string]string
|
||||
FixedValues map[string]string
|
||||
ObservedAtField string
|
||||
}
|
||||
|
||||
type GameClientBridgeLogProjectionPresenceDeclaration struct {
|
||||
TimestampField string
|
||||
ActiveWindowSeconds int
|
||||
ActivityTarget *GameClientBridgeLogProjectionTargetDeclaration
|
||||
}
|
||||
|
||||
type GameClientBridgeLifecycleProjectionDeclaration struct {
|
||||
Key string
|
||||
Capabilities []string
|
||||
@@ -102,20 +83,9 @@ type GameClientBridgeBulkActivityTargetDeclaration struct {
|
||||
ObservedAtField string
|
||||
}
|
||||
|
||||
type GameClientBridgeLogProjectionDeclaration struct {
|
||||
Key string
|
||||
StreamKeys []string
|
||||
Steps []GameClientBridgeLogProjectionStepDeclaration
|
||||
CorrelationFields []string
|
||||
MaxInterveningLines int
|
||||
Target GameClientBridgeLogProjectionTargetDeclaration
|
||||
Presence *GameClientBridgeLogProjectionPresenceDeclaration
|
||||
}
|
||||
|
||||
type GameClientBridgeDataPackDeclaration struct {
|
||||
Key string
|
||||
DatabaseUserVersion int
|
||||
LogParserRefs []string
|
||||
ConfigMapRefs []string
|
||||
DataRefs []string
|
||||
}
|
||||
@@ -156,7 +126,6 @@ type GameClientBridgeManifest struct {
|
||||
Commands []GameClientBridgeCommandDeclaration
|
||||
Snapshots []GameClientBridgeSnapshotDeclaration
|
||||
QueryTemplates []GameClientBridgeQueryTemplateDeclaration
|
||||
LogProjections []GameClientBridgeLogProjectionDeclaration
|
||||
LifecycleProjections []GameClientBridgeLifecycleProjectionDeclaration
|
||||
DataPacks []GameClientBridgeDataPackDeclaration
|
||||
Retention GameClientBridgeRetention
|
||||
@@ -330,6 +299,9 @@ type GameClientBridgeSnapshotIngestRequest struct {
|
||||
Retention GameClientBridgeRetention
|
||||
}
|
||||
|
||||
// GameClientBridgeLogStreamRequest authorizes a component to receive the
|
||||
// opaque log channel for its bound server. The platform never interprets the
|
||||
// log body; parsing and business projections remain plugin-owned.
|
||||
type GameClientBridgeLogStreamRequest struct {
|
||||
SessionToken string
|
||||
}
|
||||
@@ -440,17 +412,12 @@ func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBrid
|
||||
for index := range value.QueryTemplates {
|
||||
value.QueryTemplates[index] = CopyGameClientBridgeQueryTemplateDeclaration(value.QueryTemplates[index])
|
||||
}
|
||||
value.LogProjections = append([]GameClientBridgeLogProjectionDeclaration(nil), value.LogProjections...)
|
||||
for index := range value.LogProjections {
|
||||
value.LogProjections[index] = CopyGameClientBridgeLogProjectionDeclaration(value.LogProjections[index])
|
||||
}
|
||||
value.LifecycleProjections = append([]GameClientBridgeLifecycleProjectionDeclaration(nil), value.LifecycleProjections...)
|
||||
for index := range value.LifecycleProjections {
|
||||
value.LifecycleProjections[index] = CopyGameClientBridgeLifecycleProjectionDeclaration(value.LifecycleProjections[index])
|
||||
}
|
||||
value.DataPacks = append([]GameClientBridgeDataPackDeclaration(nil), value.DataPacks...)
|
||||
for index := range value.DataPacks {
|
||||
value.DataPacks[index].LogParserRefs = CopyStringSlice(value.DataPacks[index].LogParserRefs)
|
||||
value.DataPacks[index].ConfigMapRefs = CopyStringSlice(value.DataPacks[index].ConfigMapRefs)
|
||||
value.DataPacks[index].DataRefs = CopyStringSlice(value.DataPacks[index].DataRefs)
|
||||
}
|
||||
@@ -507,30 +474,6 @@ func CopyGameClientBridgeBulkActivityTargetDeclaration(value GameClientBridgeBul
|
||||
return value
|
||||
}
|
||||
|
||||
func CopyGameClientBridgeLogProjectionDeclaration(value GameClientBridgeLogProjectionDeclaration) GameClientBridgeLogProjectionDeclaration {
|
||||
value.StreamKeys = CopyStringSlice(value.StreamKeys)
|
||||
value.Steps = append([]GameClientBridgeLogProjectionStepDeclaration(nil), value.Steps...)
|
||||
value.CorrelationFields = CopyStringSlice(value.CorrelationFields)
|
||||
value.Target = CopyGameClientBridgeLogProjectionTargetDeclaration(value.Target)
|
||||
if value.Presence != nil {
|
||||
presence := *value.Presence
|
||||
if presence.ActivityTarget != nil {
|
||||
activityTarget := CopyGameClientBridgeLogProjectionTargetDeclaration(*presence.ActivityTarget)
|
||||
presence.ActivityTarget = &activityTarget
|
||||
}
|
||||
value.Presence = &presence
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func CopyGameClientBridgeLogProjectionTargetDeclaration(value GameClientBridgeLogProjectionTargetDeclaration) GameClientBridgeLogProjectionTargetDeclaration {
|
||||
value.UpsertKeys = CopyStringSlice(value.UpsertKeys)
|
||||
value.CaptureMappings = CopyStringMap(value.CaptureMappings)
|
||||
value.HashMappings = CopyStringMap(value.HashMappings)
|
||||
value.FixedValues = CopyStringMap(value.FixedValues)
|
||||
return value
|
||||
}
|
||||
|
||||
func copyGameClientBridgePayloadValue(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
|
||||
@@ -12,9 +12,10 @@ import (
|
||||
"browser.local/platform/validator"
|
||||
)
|
||||
|
||||
const gameClientBridgeCapability = "game-client.bridge"
|
||||
const gameClientBridgeLogStreamCapability = "logs.stream"
|
||||
|
||||
const gameClientBridgeCapability = "game-client.bridge"
|
||||
|
||||
func (svc *CoreService) ClaimGameClientBridgeCommands(request domain.GameClientBridgeClaimRequest) ([]domain.GameClientBridgeCommand, error) {
|
||||
if err := validator.ValidateGameClientBridgeClaimRequest(request); err != nil {
|
||||
return nil, err
|
||||
@@ -128,6 +129,9 @@ func (svc *CoreService) UploadGameClientBridgeSnapshot(request domain.GameClient
|
||||
return domain.CopyGameClientBridgeSnapshot(snapshot), nil
|
||||
}
|
||||
|
||||
// AuthorizeGameClientBridgeLogStream authenticates a component session and
|
||||
// returns only its bound server identity. Log entries are forwarded verbatim;
|
||||
// this method intentionally performs no content inspection or transformation.
|
||||
func (svc *CoreService) AuthorizeGameClientBridgeLogStream(request domain.GameClientBridgeLogStreamRequest) (domain.ServerInstance, error) {
|
||||
if err := validator.ValidateGameClientBridgeLogStreamRequest(request); err != nil {
|
||||
return domain.ServerInstance{}, err
|
||||
|
||||
@@ -222,7 +222,6 @@ type Core interface {
|
||||
SubscribeLogEvents(string) (LogEventSubscription, error)
|
||||
SubscribeLogEventsForSession(string, string) (LogEventSubscription, error)
|
||||
IngestLogBatch(domain.LogBatchIngest) (domain.LogBatchIngestResult, error)
|
||||
RelayLiveLogBatch(domain.LogBatchIngest) (domain.LogBatchIngestResult, error)
|
||||
GetRunLogStreamProgress(domain.RunLogStreamProgress) (domain.RunLogStreamProgressResult, error)
|
||||
QueryLogStream(domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error)
|
||||
SeedPlatformAdmin(string, string) error
|
||||
@@ -247,8 +246,6 @@ type CoreService struct {
|
||||
bridgeSeq uint64
|
||||
logStore LogBodyStore
|
||||
logIngestMu [64]sync.Mutex
|
||||
logProjectionMu sync.Mutex
|
||||
logProjectionStates map[string]map[string]pluginLogSequenceState
|
||||
logEventMu sync.Mutex
|
||||
logEventSubscribers map[uint64]logEventSubscriber
|
||||
logEventSubscriberSeq uint64
|
||||
@@ -296,7 +293,6 @@ func newCoreServiceWithLogStore(store repo.Store, logStore LogBodyStore, now fun
|
||||
controlStreamWaiters: map[string][]chan domain.RunControlEvent{},
|
||||
jobWaiters: map[string][]chan struct{}{},
|
||||
logStore: logStore,
|
||||
logProjectionStates: map[string]map[string]pluginLogSequenceState{},
|
||||
logEventSubscribers: map[uint64]logEventSubscriber{},
|
||||
artifactStore: artifactStore,
|
||||
artifactTransfers: map[string]domain.ArtifactTransferSession{},
|
||||
@@ -2162,13 +2158,6 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques
|
||||
if request.ExpectedConfigVersion > 0 && request.ExpectedConfigVersion != instance.ConfigVersion {
|
||||
return domain.FileOperationDispatchResult{}, validationError("expectedConfigVersion must match server instance")
|
||||
}
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return domain.FileOperationDispatchResult{}, err
|
||||
}
|
||||
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "file.operation.denied"); err != nil {
|
||||
return domain.FileOperationDispatchResult{}, err
|
||||
}
|
||||
content := request.Content
|
||||
if request.Operation == domain.FileOperationWrite && content == "" && strings.HasPrefix(request.InputRef, "artifact://") {
|
||||
artifactID := strings.TrimPrefix(request.InputRef, "artifact://")
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
# SCUM Companion
|
||||
# SCUM Companion One-Shot Smoke
|
||||
|
||||
Run stdout/stderr records provide bounded semantic player events. See [UE4SS_CAPABILITY.md](UE4SS_CAPABILITY.md) for the runtime boundary; this Companion never infers events from arbitrary log lines.
|
||||
Run stdout/stderr and the plugin-declared SCUM log streams arrive as opaque
|
||||
records. See [UE4SS_CAPABILITY.md](UE4SS_CAPABILITY.md) for the runtime
|
||||
boundary; this companion alone parses the SCUM login format and publishes the
|
||||
typed player snapshot used by the plugin page. Platform and Run never inspect
|
||||
or redact those log bodies.
|
||||
|
||||
The production command is plugin-owned and runs as the SCUM Client Manager. It registers the deployed component, keeps heartbeats alive, dispatches declared companion commands, reads SCUM SQLite data, and stores trajectory samples directly into the shared platform MySQL database from the companion process.
|
||||
This plugin-owned fixture proves the Platform Client Manager and Game Client Bridge integration without adding SCUM behavior to Run. The command registers the deployed component, sends one heartbeat, claims at most one command, processes only `companion.diagnostics`, and uploads one typed `companion.health` snapshot.
|
||||
|
||||
Trajectory samples keep the raw SCUM world coordinates (`world_x`, `world_y`, `world_z`). The companion does not project or convert coordinates before storage; any map pixel calculation is display-only in the plugin page.
|
||||
Use it only with a dedicated non-production server instance whose bridge queue contains no shared or production work. The claim API cannot filter by command type, so this smoke command must never target a shared or production queue.
|
||||
|
||||
The production process reads two environment variables supplied by the supervisor:
|
||||
|
||||
- `SCUM_COMPONENT_PROOF`: component registration proof material from the protected package.
|
||||
- `SCUM_DB_FILE`: the local SCUM SQLite database file to sample.
|
||||
- `PLATFORM_MYSQL_DSN`: the shared platform MySQL connection string used by plugin storage. It is read from the process environment, not written into `config.yaml`.
|
||||
Before starting it, confirm that the isolated queue is otherwise empty and queue exactly one `companion.diagnostics` command through the Platform SCUM operations page. Use the bounded payload `includeWindowState=false` and `maxEntries=1`. Do not pass an operator session or API token to the companion process.
|
||||
|
||||
## Package
|
||||
|
||||
Build the production command from this directory:
|
||||
Build the one-shot command from this directory:
|
||||
|
||||
```bash
|
||||
go build -o scum_client.exe ./cmd/scum-companion
|
||||
go build -o scum-companion-smoke ./cmd/scum-companion-smoke
|
||||
```
|
||||
|
||||
Place the generated `config.yaml` beside the executable. The command intentionally has no `--config` flag and reads only that sidecar filename from its working directory. `config.yaml.example` documents the generated shape; deployed identity and generation values must come from the fenced Client Manager lifecycle input.
|
||||
@@ -26,22 +26,6 @@ The Platform base URL must be a trusted HTTPS origin. The client uses host syste
|
||||
|
||||
The supervisor supplies the component proof through the environment variable named by `proof.materialEnv`. Bind it from the protected component package at process start. Do not place the proof in `config.yaml`, command arguments, command-line environment assignments, shell history, documentation, or logs.
|
||||
|
||||
If `trajectory.enabled` is true but `SCUM_DB_FILE` or `PLATFORM_MYSQL_DSN` is missing, the companion stays registered and reports degraded health instead of silently exiting. This keeps diagnostics reachable while operators fix the machine environment.
|
||||
|
||||
## Smoke Fixture
|
||||
|
||||
`cmd/scum-companion-smoke` remains a non-production one-shot fixture. It proves the Platform Client Manager and Game Client Bridge integration without adding SCUM behavior to Run. The smoke command registers the deployed component, sends one heartbeat, claims at most one command, processes only `companion.diagnostics`, and uploads one typed `companion.health` snapshot.
|
||||
|
||||
Use it only with a dedicated non-production server instance whose bridge queue contains no shared or production work. The claim API cannot filter by command type, so this smoke command must never target a shared or production queue.
|
||||
|
||||
Before starting it, confirm that the isolated queue is otherwise empty and queue exactly one `companion.diagnostics` command through the Platform SCUM operations page. Use the bounded payload `includeWindowState=false` and `maxEntries=1`. Do not pass an operator session or API token to the companion process.
|
||||
|
||||
Build the smoke command from this directory:
|
||||
|
||||
```bash
|
||||
go build -o scum-companion-smoke ./cmd/scum-companion-smoke
|
||||
```
|
||||
|
||||
The process environment must also set `SCUM_COMPANION_SMOKE_SCOPE` to `isolated-non-production`. This value is a non-secret safety acknowledgement; configure it in the supervisor rather than placing component proof material on a command line.
|
||||
|
||||
Run the executable from the package working directory:
|
||||
|
||||
@@ -27,45 +27,20 @@ func main() {
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
trajectoryStatus := &companion.TrajectoryCollectionStatus{}
|
||||
collector, cleanup := buildTrajectoryCollector(config, trajectoryStatus)
|
||||
defer cleanup()
|
||||
consoleCollector, consoleCleanup := buildConsoleLogCollector(config, client)
|
||||
defer consoleCleanup()
|
||||
|
||||
registry := companion.NewHandlerRegistry(defaultHandlerAvailability(config), companion.RuntimeAdapter{
|
||||
BoundServerID: config.Component.ServerInstanceID,
|
||||
DiagnosticsState: map[string]string{
|
||||
"trajectory": trajectoryDiagnostic(config, collector),
|
||||
"coordinates": "raw-world",
|
||||
},
|
||||
})
|
||||
projection := companion.NewSCUMPlayerLogProjection(client, config.Component.ServerInstanceID)
|
||||
collector := companion.NewConsoleLogCollector(client, noopLogStore{}, config.Component.ServerInstanceID, os.Getenv(config.Proof.MaterialEnv))
|
||||
collector.OnSemanticEvents = projection.Handle
|
||||
runtime := companion.Runtime{
|
||||
Client: client,
|
||||
Dispatcher: companion.Dispatcher{
|
||||
Client: client,
|
||||
Registry: registry,
|
||||
PollLimit: 10,
|
||||
Backoff: 2 * time.Second,
|
||||
},
|
||||
Client: client,
|
||||
Dispatcher: companion.Dispatcher{Client: client, Registry: companion.NewHandlerRegistry(companion.HandlerAvailability{BoundServerID: config.Component.ServerInstanceID, Approved: true, Capabilities: map[string]bool{"companion.diagnostics": true}}, companion.RuntimeAdapter{BoundServerID: config.Component.ServerInstanceID}), PollLimit: 10, Backoff: 2 * time.Second},
|
||||
HeartbeatEvery: time.Duration(config.Timing.HeartbeatIntervalSeconds) * time.Second,
|
||||
PollEvery: time.Duration(config.Timing.CommandPollIntervalSeconds) * time.Second,
|
||||
Backoff: 2 * time.Second,
|
||||
Health: trajectoryStatus.HealthReport,
|
||||
}
|
||||
|
||||
errorsCh := make(chan error, 3)
|
||||
errorsCh := make(chan error, 2)
|
||||
go func() { errorsCh <- runtime.Run(ctx) }()
|
||||
if collector != nil {
|
||||
go func() { errorsCh <- collector.Run(ctx, trajectoryStatus) }()
|
||||
}
|
||||
if consoleCollector != nil {
|
||||
go func() { errorsCh <- consoleCollector.Run(ctx) }()
|
||||
}
|
||||
|
||||
err = <-errorsCh
|
||||
stop()
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
go func() { errorsCh <- collector.Run(ctx) }()
|
||||
if err := <-errorsCh; err != nil && !errors.Is(err, context.Canceled) {
|
||||
log.Printf("SCUM companion stopped: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -80,58 +55,12 @@ func loadConfig() (companion.Config, error) {
|
||||
return companion.LoadConfig(file)
|
||||
}
|
||||
|
||||
func buildConsoleLogCollector(config companion.Config, client *companion.Client) (*companion.ConsoleLogCollector, func()) {
|
||||
cleanup := func() {}
|
||||
store, err := companion.OpenSCUMSQLStoreFromEnv(companion.PlatformMySQLDSNEnvironment)
|
||||
if err != nil {
|
||||
log.Printf("SCUM companion console log store unavailable: %v", err)
|
||||
return nil, cleanup
|
||||
}
|
||||
cleanup = func() { _ = store.Close() }
|
||||
secret := os.Getenv(config.Proof.MaterialEnv)
|
||||
return companion.NewConsoleLogCollector(client, store, config.Component.ServerInstanceID, secret), cleanup
|
||||
}
|
||||
type noopLogStore struct{}
|
||||
|
||||
func buildTrajectoryCollector(config companion.Config, status *companion.TrajectoryCollectionStatus) (*companion.TrajectoryCollector, func()) {
|
||||
cleanup := func() {}
|
||||
if !config.Trajectory.Enabled {
|
||||
status.Record(companion.TrajectoryCollectionReport{Status: "healthy", Reason: "trajectory collection disabled"}, nil)
|
||||
return nil, cleanup
|
||||
}
|
||||
source, err := companion.OpenSCUMSQLiteSourceFromEnv(config.Trajectory.FileEnv)
|
||||
if err != nil {
|
||||
status.Record(companion.TrajectoryCollectionReport{Status: "degraded", Reason: "trajectory source unavailable"}, err)
|
||||
return nil, cleanup
|
||||
}
|
||||
store, err := companion.OpenSCUMSQLStoreFromEnv(companion.PlatformMySQLDSNEnvironment)
|
||||
if err != nil {
|
||||
_ = source.Close()
|
||||
status.Record(companion.TrajectoryCollectionReport{Status: "degraded", Reason: "trajectory store unavailable"}, err)
|
||||
return nil, cleanup
|
||||
}
|
||||
cleanup = func() {
|
||||
_ = source.Close()
|
||||
_ = store.Close()
|
||||
}
|
||||
return companion.NewTrajectoryCollector(config, source, store), cleanup
|
||||
func (noopLogStore) EnsureSchema(context.Context) error { return nil }
|
||||
func (noopLogStore) StoreConsoleRecords(context.Context, []companion.ConsoleRecord) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func defaultHandlerAvailability(config companion.Config) companion.HandlerAvailability {
|
||||
capabilities := map[string]bool{"companion.diagnostics": true}
|
||||
for _, capability := range config.Capabilities {
|
||||
if capability == "handler.vehicle.spawn" {
|
||||
capabilities["vehicle.spawn"] = true
|
||||
}
|
||||
}
|
||||
return companion.HandlerAvailability{BoundServerID: config.Component.ServerInstanceID, Approved: true, Capabilities: capabilities}
|
||||
}
|
||||
|
||||
func trajectoryDiagnostic(config companion.Config, collector *companion.TrajectoryCollector) string {
|
||||
if !config.Trajectory.Enabled {
|
||||
return "disabled"
|
||||
}
|
||||
if collector == nil {
|
||||
return "waiting"
|
||||
}
|
||||
return "enabled"
|
||||
func (noopLogStore) StoreSemanticEventBatch(_ context.Context, batch companion.SemanticEventBatch) (int, error) {
|
||||
return len(batch.Events), nil
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ type ConsoleLogCollector struct {
|
||||
ServerInstanceID string
|
||||
CorrelationSecret string
|
||||
Backoff time.Duration
|
||||
OnSemanticEvents func(context.Context, SemanticEventBatch) error
|
||||
}
|
||||
|
||||
func NewConsoleLogCollector(client ConsoleLogStreamClient, store ConsoleLogStore, serverInstanceID string, correlationSecret string) *ConsoleLogCollector {
|
||||
@@ -69,6 +70,11 @@ func (collector *ConsoleLogCollector) handleEvent(ctx context.Context) func(LogS
|
||||
if _, err := collector.Store.StoreSemanticEventBatch(ctx, batch); err != nil {
|
||||
return err
|
||||
}
|
||||
if collector.OnSemanticEvents != nil {
|
||||
if err := collector.OnSemanticEvents(ctx, batch); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -79,7 +85,10 @@ func consoleRecordFromLogEvent(serverInstanceID string, event LogStreamEvent) (C
|
||||
}
|
||||
stream := consoleStreamName(event.StreamKey)
|
||||
if stream == "" {
|
||||
return ConsoleRecord{}, false
|
||||
if !knownPluginLogStream(event.StreamKey) {
|
||||
return ConsoleRecord{}, false
|
||||
}
|
||||
stream = strings.ToLower(strings.TrimSpace(event.StreamKey))
|
||||
}
|
||||
occurredAt := event.Entry.Timestamp
|
||||
if occurredAt.IsZero() {
|
||||
@@ -88,6 +97,15 @@ func consoleRecordFromLogEvent(serverInstanceID string, event LogStreamEvent) (C
|
||||
return ConsoleRecord{ServerID: serverInstanceID, Stream: stream, Sequence: event.Entry.Seq, OccurredAt: occurredAt.UTC(), Text: event.Entry.Line}, true
|
||||
}
|
||||
|
||||
func knownPluginLogStream(streamKey string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(streamKey)) {
|
||||
case "scum.login", "scum.chat", "scum.server", "scum.kill", "scum.trade", "scum.admin", "scum.performance":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func consoleStreamName(streamKey string) string {
|
||||
key := strings.ToLower(strings.TrimSpace(streamKey))
|
||||
if strings.Contains(key, "stderr") || strings.HasSuffix(key, ".err") || strings.HasSuffix(key, "-err") {
|
||||
|
||||
@@ -49,7 +49,7 @@ func ParseConsoleRecords(serverID string, records []ConsoleRecord, correlationSe
|
||||
records = records[:100]
|
||||
}
|
||||
for _, record := range records {
|
||||
if record.ServerID != serverID || (record.Stream != "stdout" && record.Stream != "stderr") || record.Sequence == 0 || record.OccurredAt.IsZero() || len(record.Text) > 1024 {
|
||||
if record.ServerID != serverID || strings.TrimSpace(record.Stream) == "" || len(record.Stream) > 64 || record.Sequence == 0 || record.OccurredAt.IsZero() || len(record.Text) > 1024 {
|
||||
batch.Diagnostics = appendDiagnostic(batch.Diagnostics, EventDiagnostic{ServerID: serverID, Sequence: record.Sequence, Code: "invalid-console-record"})
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SCUMPlayerLogProjection is plugin-owned business logic. It consumes the
|
||||
// opaque log channel after the platform has forwarded it and publishes a typed
|
||||
// players snapshot; Run and Platform never inspect the source text.
|
||||
type SCUMPlayerLogProjection struct {
|
||||
Client *Client
|
||||
ServerInstanceID string
|
||||
KeepForSeconds int
|
||||
MaxRecords int
|
||||
Now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
players map[string]scumPlayerProjection
|
||||
sequence uint64
|
||||
}
|
||||
|
||||
type scumPlayerProjection struct {
|
||||
PlayerID string
|
||||
PlayerName string
|
||||
Status string
|
||||
LastSeenAt time.Time
|
||||
}
|
||||
|
||||
func NewSCUMPlayerLogProjection(client *Client, serverInstanceID string) *SCUMPlayerLogProjection {
|
||||
return &SCUMPlayerLogProjection{Client: client, ServerInstanceID: serverInstanceID, KeepForSeconds: 86400, MaxRecords: 1000, Now: time.Now, players: map[string]scumPlayerProjection{}}
|
||||
}
|
||||
|
||||
func (projection *SCUMPlayerLogProjection) Handle(ctx context.Context, batch SemanticEventBatch) error {
|
||||
if projection == nil || projection.Client == nil || strings.TrimSpace(projection.ServerInstanceID) == "" {
|
||||
return fmt.Errorf("SCUM player log projection is not configured")
|
||||
}
|
||||
if batch.ServerID != projection.ServerInstanceID {
|
||||
return fmt.Errorf("SCUM player log projection server scope mismatch")
|
||||
}
|
||||
now := time.Now
|
||||
if projection.Now != nil {
|
||||
now = projection.Now
|
||||
}
|
||||
projection.mu.Lock()
|
||||
for _, event := range batch.Events {
|
||||
playerID := strings.TrimSpace(event.PlayerID)
|
||||
if playerID == "" {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(event.DisplayName)
|
||||
if name == "" {
|
||||
name = playerID
|
||||
}
|
||||
player := projection.players[playerID]
|
||||
player.PlayerID = playerID
|
||||
player.PlayerName = name
|
||||
player.LastSeenAt = event.OccurredAt.UTC()
|
||||
if player.LastSeenAt.IsZero() {
|
||||
player.LastSeenAt = now().UTC()
|
||||
}
|
||||
switch event.Type {
|
||||
case "scum.login":
|
||||
player.Status = "online"
|
||||
case "scum.logout":
|
||||
player.Status = "offline"
|
||||
default:
|
||||
continue
|
||||
}
|
||||
projection.players[playerID] = player
|
||||
}
|
||||
if len(batch.Events) == 0 {
|
||||
projection.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
players := make([]scumPlayerProjection, 0, len(projection.players))
|
||||
for _, player := range projection.players {
|
||||
players = append(players, player)
|
||||
}
|
||||
projection.sequence++
|
||||
sequence := projection.sequence
|
||||
projection.mu.Unlock()
|
||||
sort.Slice(players, func(i, j int) bool { return players[i].PlayerID < players[j].PlayerID })
|
||||
payloadPlayers := make([]map[string]any, 0, len(players))
|
||||
for _, player := range players {
|
||||
payloadPlayers = append(payloadPlayers, map[string]any{
|
||||
"playerId": player.PlayerID,
|
||||
"playerName": player.PlayerName,
|
||||
"status": player.Status,
|
||||
"lastSeenAt": player.LastSeenAt.Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
observedAt := now().UTC()
|
||||
_, err := projection.Client.UploadSnapshot(ctx, Snapshot{
|
||||
Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: sequence,
|
||||
ObservedAt: observedAt, Payload: map[string]any{"observedAt": observedAt.Format(time.RFC3339Nano), "players": payloadPlayers},
|
||||
KeepForSeconds: projection.KeepForSeconds, MaxRecords: projection.MaxRecords,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
type noopConsoleLogStore struct{}
|
||||
|
||||
func (noopConsoleLogStore) EnsureSchema(context.Context) error { return nil }
|
||||
func (noopConsoleLogStore) StoreConsoleRecords(context.Context, []ConsoleRecord) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (noopConsoleLogStore) StoreSemanticEventBatch(_ context.Context, batch SemanticEventBatch) (int, error) {
|
||||
return len(batch.Events), nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSCUMPlayerLogProjectionCreatesTypedUserSnapshot(t *testing.T) {
|
||||
stamp := time.Date(2026, 9, 2, 2, 0, 0, 0, time.UTC)
|
||||
config := loadTestConfig(t)
|
||||
var snapshot Snapshot
|
||||
client := newTestClient(t, config, roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
if request.URL.Path != snapshotPath {
|
||||
t.Fatalf("unexpected projection request path: %s", request.URL.Path)
|
||||
}
|
||||
var body snapshotRequest
|
||||
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode snapshot request: %v", err)
|
||||
}
|
||||
snapshot = Snapshot{Type: body.Type, SchemaVersion: body.SchemaVersion, StreamKey: body.StreamKey, Sequence: body.Sequence, ObservedAt: body.ObservedAt, Payload: body.Payload, KeepForSeconds: body.KeepForSeconds, MaxRecords: body.MaxRecords}
|
||||
return &http.Response{StatusCode: http.StatusAccepted, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"snapshotId":"snapshot-1","profileKey":"scum-client-manager","type":"players","schemaVersion":"1","streamKey":"current","sequence":1}`))}, nil
|
||||
}), stamp)
|
||||
client.mu.Lock()
|
||||
client.sessionToken = "component-session"
|
||||
client.sessionExpiresAt = stamp.Add(time.Hour)
|
||||
client.mu.Unlock()
|
||||
|
||||
projection := NewSCUMPlayerLogProjection(client, "server-example")
|
||||
projection.Now = func() time.Time { return stamp }
|
||||
if err := projection.Handle(context.Background(), SemanticEventBatch{ServerID: "server-example", Events: []SemanticEvent{{ServerID: "server-example", Sequence: 7, Type: "scum.login", PlayerID: "76561198000000001", DisplayName: "Ada", OccurredAt: stamp}}}); err != nil {
|
||||
t.Fatalf("project login event: %v", err)
|
||||
}
|
||||
players, ok := snapshot.Payload["players"].([]any)
|
||||
if !ok || len(players) != 1 {
|
||||
t.Fatalf("expected one projected player, payload=%#v", snapshot.Payload)
|
||||
}
|
||||
player, ok := players[0].(map[string]any)
|
||||
if !ok || player["playerId"] != "76561198000000001" || player["playerName"] != "Ada" || player["status"] != "online" {
|
||||
t.Fatalf("unexpected projected player: %#v", players[0])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user