Restore durable log ingest and typed plugin projections
This commit is contained in:
@@ -6,48 +6,6 @@ import (
|
||||
"browser.local/platform/dto"
|
||||
)
|
||||
|
||||
// gameClientBridgeCompanionLogEvents godoc
|
||||
// @Summary Stream live Run logs to a game companion
|
||||
// @Description Authorizes a component session and relays only the current supervised process log stream to the companion. The platform does not persist log bodies on this route.
|
||||
// @Tags game-client-bridge
|
||||
// @Accept json
|
||||
// @Produce text/event-stream
|
||||
// @Param body body dto.GameClientBridgeLogStreamRequest true "Component log stream request"
|
||||
// @Success 200 {object} dto.LogStreamEventResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Failure 405 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/game-client-bridge/companion/logs/events [post]
|
||||
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
|
||||
}
|
||||
subscription, err := h.core.SubscribeLogEvents(instance.ID)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
defer subscription.Close()
|
||||
streams, liveEligible, err := h.loadLiveLogSnapshot(instance.ID)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
h.streamCurrentLogEvents(w, r, instance, streams, liveEligible, subscription)
|
||||
}
|
||||
|
||||
func (h *coreHandlers) gameClientBridgeCompanionClaim(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -41,13 +38,6 @@ func (core *gameClientBridgeCompanionCore) UploadGameClientBridgeSnapshot(domain
|
||||
return domain.CopyGameClientBridgeSnapshot(core.snapshot), nil
|
||||
}
|
||||
|
||||
func (core *gameClientBridgeCompanionCore) AuthorizeGameClientBridgeLogStream(request domain.GameClientBridgeLogStreamRequest) (domain.ServerInstance, error) {
|
||||
if strings.TrimSpace(request.SessionToken) != "component-token" {
|
||||
return domain.ServerInstance{}, service.ErrUnauthorized
|
||||
}
|
||||
return core.Core.GetServerInstance("server-1")
|
||||
}
|
||||
|
||||
func TestGameClientBridgeOperatorRoutes(t *testing.T) {
|
||||
store := repo.NewMemoryStore()
|
||||
coreService := service.NewCoreService(store)
|
||||
@@ -159,40 +149,3 @@ func TestGameClientBridgeCompanionRoutesAreComponentSessionMediated(t *testing.T
|
||||
diagnostic := performJSON(t, router, http.MethodPost, "/api/v1/game-client-bridge/companion/diagnostics", snapshotRequest)
|
||||
assertStatus(t, diagnostic, http.StatusAccepted)
|
||||
}
|
||||
|
||||
func TestGameClientBridgeCompanionLogEventsRelaysCurrentRunOutput(t *testing.T) {
|
||||
coreService := service.NewCoreService(repo.NewMemoryStore())
|
||||
if err := coreService.SeedLocalPlatformAdmin(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
core := &gameClientBridgeCompanionCore{Core: coreService}
|
||||
router := NewTestRouterWithCore(core)
|
||||
hello := createLogIngestAPIFixtures(t, router)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/game-client-bridge/companion/logs/events", strings.NewReader("{\"sessionToken\":\"component-token\"}")).WithContext(ctx)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
streamWriter, streamReader := newSSEPipeResponseWriter()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
router.ServeHTTP(streamWriter, request)
|
||||
_ = streamWriter.Close()
|
||||
close(done)
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
_ = streamReader.Close()
|
||||
<-done
|
||||
})
|
||||
if status := <-streamWriter.status; status != http.StatusOK {
|
||||
t.Fatalf("unexpected companion SSE status: %d", status)
|
||||
}
|
||||
reader := bufio.NewReader(streamReader)
|
||||
assertSSEEvent(t, reader, "session", "\"logSessionId\":\"session-current\"")
|
||||
assertSSEEvent(t, reader, "stream", "\"id\":\"log-1\"")
|
||||
assertSSEEvent(t, reader, "ready", "\"streamCount\":1")
|
||||
|
||||
live := validLogBatchRequest(t, hello.SessionToken, 5, 5)
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/relay", live), http.StatusOK)
|
||||
assertSSEEvent(t, reader, "log", "\"seq\":5")
|
||||
}
|
||||
|
||||
@@ -81,26 +81,6 @@ func TestLogIngestAPIWorkflow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveLogRelayAPIForwardsWithoutStoredOutput(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
hello := createLogIngestAPIFixtures(t, router)
|
||||
batch := validLogBatchRequest(t, hello.SessionToken, 1, 1)
|
||||
|
||||
relay := performJSON(t, router, http.MethodPost, "/api/v1/run/logs/relay", batch)
|
||||
assertStatus(t, relay, http.StatusOK)
|
||||
ack := decodeBody[dto.LogBatchIngestResponse](t, relay)
|
||||
if !ack.Accepted || ack.AcceptedFrom != 1 || ack.AcceptedTo != 1 {
|
||||
t.Fatalf("unexpected live relay ack: %+v", ack)
|
||||
}
|
||||
|
||||
query := performJSON(t, router, http.MethodPost, "/api/v1/log-streams/query", dto.LogStreamCursorRequest{LogStreamID: batch.LogStreamID, AfterSeq: 0, Limit: 10})
|
||||
assertStatus(t, query, http.StatusOK)
|
||||
body := decodeBody[dto.LogStreamCursorResponse](t, query)
|
||||
if len(body.Entries) != 0 || body.LatestSeq != 1 {
|
||||
t.Fatalf("live relay stored platform log output: %+v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogEventsSSEDoesNotReplayHistory(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
hello := createLogIngestAPIFixtures(t, router)
|
||||
@@ -173,41 +153,6 @@ func TestLogEventsSSELiveOnlyStartsAfterSnapshotTail(t *testing.T) {
|
||||
assertSSEEvent(t, reader, "log", `"seq":2`)
|
||||
}
|
||||
|
||||
func TestLogEventsSSERelaysLiveBatchesWithoutSequenceGate(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
hello := createLogIngestAPIFixtures(t, router)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/server-instances/server-1/logs/events", nil).WithContext(ctx)
|
||||
streamWriter, streamReader := newSSEPipeResponseWriter()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
router.ServeHTTP(streamWriter, request)
|
||||
_ = streamWriter.Close()
|
||||
close(done)
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
_ = streamReader.Close()
|
||||
<-done
|
||||
})
|
||||
if status := <-streamWriter.status; status != http.StatusOK {
|
||||
t.Fatalf("unexpected SSE status: %d", status)
|
||||
}
|
||||
reader := bufio.NewReader(streamReader)
|
||||
assertSSEEvent(t, reader, "session", `"logSessionId":"session-current"`)
|
||||
assertSSEEvent(t, reader, "stream", `"id":"log-1"`)
|
||||
assertSSEEvent(t, reader, "ready", `"streamCount":1`)
|
||||
|
||||
first := validLogBatchRequest(t, hello.SessionToken, 4, 4)
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/relay", first), http.StatusOK)
|
||||
assertSSEEvent(t, reader, "log", `"seq":4`)
|
||||
|
||||
second := validLogBatchRequest(t, hello.SessionToken, 1, 1)
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/relay", second), http.StatusOK)
|
||||
assertSSEEvent(t, reader, "log", `"seq":1`)
|
||||
}
|
||||
|
||||
func TestLogEventsSSESkipsBufferedBackfillAfterOpen(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
hello := createLogIngestAPIFixtures(t, router)
|
||||
|
||||
@@ -333,13 +333,13 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
|
||||
runDownloadRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/run/download", map[string]string{}, adminSession)
|
||||
assertErrorResponse(t, runDownloadRecorder, http.StatusNotFound, errorCodeNotFound)
|
||||
|
||||
clientDistribution := postJSONWithAuth[dto.ClientManagerDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/generate", dto.ClientManagerBuildRequest{ProfileKey: "scum-client-manager", TargetOS: "windows", TargetArch: "amd64", RepositoryURL: "https://git.npc0.com/admin343/browser.git", SourceRevision: "main", IdempotencyKey: "api-client-manager"}, adminSession)
|
||||
clientDistribution := postJSONWithAuth[dto.ClientManagerDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/generate", dto.ClientManagerBuildRequest{ProfileKey: "scum-client-manager", TargetOS: "windows", TargetArch: "amd64", RepositoryURL: "https://github.com/F88888/scum_client.git", SourceRevision: "main", IdempotencyKey: "api-client-manager"}, adminSession)
|
||||
if clientDistribution.ArtifactID == "" || clientDistribution.BuildJobID == "" || clientDistribution.SecretRef == runDistribution.SecretRef {
|
||||
t.Fatalf("unexpected client distribution: %+v", clientDistribution)
|
||||
}
|
||||
clientDownloadRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/client-managers/download", dto.ClientManagerDownloadRequest{ProfileKey: "scum-client-manager"}, adminSession)
|
||||
assertErrorResponse(t, clientDownloadRecorder, http.StatusNotFound, errorCodeNotFound)
|
||||
clientLinux := postJSONWithAuth[dto.ClientManagerDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/generate", dto.ClientManagerBuildRequest{ProfileKey: "scum-client-manager", TargetOS: "linux", TargetArch: "amd64", RepositoryURL: "https://git.npc0.com/admin343/browser.git", SourceRevision: "main", IdempotencyKey: "api-client-manager-linux"}, adminSession)
|
||||
clientLinux := postJSONWithAuth[dto.ClientManagerDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/generate", dto.ClientManagerBuildRequest{ProfileKey: "scum-client-manager", TargetOS: "linux", TargetArch: "amd64", RepositoryURL: "https://github.com/F88888/scum_client.git", SourceRevision: "main", IdempotencyKey: "api-client-manager-linux"}, adminSession)
|
||||
lifecycleList := getJSONWithAuth[dto.ClientManagerInstallationListResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers", adminSession)
|
||||
if lifecycleList.Count != 1 || lifecycleList.Items[0].Status != string(domain.ClientManagerLifecycleBuilding) || lifecycleList.Items[0].Distribution == nil {
|
||||
t.Fatalf("expected safe client-manager lifecycle projection, got %+v", lifecycleList)
|
||||
@@ -1864,7 +1864,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://git.npc0.com/admin343/browser.git", RevisionPolicy: "branch", Branch: "main"}, SupportedTargets: []dto.RuntimeTargetBody{{OS: "windows", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}, Build: dto.RuntimeBuildBody{System: "go", WorkspaceRef: "plugins/examples/scum-server-plugin/companion", EntryRef: "cmd/scum-companion"}, 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}}}
|
||||
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)
|
||||
|
||||
endpoint := validRunEndpointRequest()
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/dto"
|
||||
"browser.local/platform/repo"
|
||||
)
|
||||
|
||||
type liveLogRelayCore interface {
|
||||
RelayLiveLogBatch(domain.LogBatchIngest) (domain.LogBatchIngestResult, error)
|
||||
}
|
||||
|
||||
// runLiveLogRelay accepts current Run output and immediately fans it out to
|
||||
// subscribers. It has no durable body or delivery acknowledgement contract.
|
||||
func (h *coreHandlers) runLiveLogRelay(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
core, ok := h.core.(liveLogRelayCore)
|
||||
if !ok {
|
||||
writeServiceError(w, repo.ErrNotFound)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.LogBatchIngestRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
result, err := core.RelayLiveLogBatch(request.ToDomain())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.LogBatchIngestFromDomain(result))
|
||||
}
|
||||
@@ -123,9 +123,6 @@ type DistributionBuildInput struct {
|
||||
PackageFormat string
|
||||
RepositoryURL string
|
||||
SourceRevision string
|
||||
WorkspaceRef string
|
||||
EntryRef string
|
||||
ConfigTemplateRef string
|
||||
ArtifactID string
|
||||
OutputFilename string
|
||||
SecretRef string
|
||||
@@ -152,6 +149,7 @@ type RunAutonomousLifecyclePlan struct {
|
||||
InstallPlans []RunAutonomousInstallPlan `json:"installPlans,omitempty"`
|
||||
LogSources []RunAutonomousLogSource `json:"logSources,omitempty"`
|
||||
DLLExtensions []RunAutonomousDLLExtension `json:"dllExtensions,omitempty"`
|
||||
DataTargets []RunAutonomousDataTarget `json:"dataTargets,omitempty"`
|
||||
RuntimeBindings map[string]string `json:"runtimeBindings,omitempty"`
|
||||
Deployment *RunAutonomousDeployment `json:"deployment,omitempty"`
|
||||
}
|
||||
@@ -212,6 +210,20 @@ type RunAutonomousDLLExtension struct {
|
||||
RCONPort int `json:"rconPort,omitempty"`
|
||||
}
|
||||
|
||||
// RunAutonomousDataTarget is a package-local snapshot declaration. The source
|
||||
// root is resolved only inside Run from a private generated package binding.
|
||||
type RunAutonomousDataTarget struct {
|
||||
Key string `json:"key"`
|
||||
Kind string `json:"kind"`
|
||||
TransportKey string `json:"transportKey"`
|
||||
SourceRootKey string `json:"sourceRootKey"`
|
||||
SourcePath string `json:"sourcePath"`
|
||||
WorkspaceKey string `json:"workspaceKey"`
|
||||
RefreshPolicy string `json:"refreshPolicy"`
|
||||
MaxBytes int64 `json:"maxBytes,omitempty"`
|
||||
Platforms []string `json:"platforms,omitempty"`
|
||||
}
|
||||
|
||||
type RunAutonomousDeployment struct {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
Mode ServerDeploymentMode `json:"mode"`
|
||||
@@ -474,6 +486,10 @@ func CopyRunAutonomousLifecyclePlanPtr(plan *RunAutonomousLifecyclePlan) *RunAut
|
||||
}
|
||||
copy.LogSources = append([]RunAutonomousLogSource(nil), plan.LogSources...)
|
||||
copy.DLLExtensions = append([]RunAutonomousDLLExtension(nil), plan.DLLExtensions...)
|
||||
copy.DataTargets = append([]RunAutonomousDataTarget(nil), plan.DataTargets...)
|
||||
for i := range copy.DataTargets {
|
||||
copy.DataTargets[i].Platforms = CopyStringSlice(plan.DataTargets[i].Platforms)
|
||||
}
|
||||
copy.RuntimeBindings = CopyStringMap(plan.RuntimeBindings)
|
||||
if plan.Deployment != nil {
|
||||
deployment := *plan.Deployment
|
||||
|
||||
+38
-44
@@ -209,28 +209,25 @@ type DistributionBuildInputRequest struct {
|
||||
}
|
||||
|
||||
type DistributionBuildInputResponse struct {
|
||||
JobID string `json:"jobId"`
|
||||
ComponentKind string `json:"componentKind"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
PluginID string `json:"pluginId"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
ProfileKey string `json:"profileKey,omitempty"`
|
||||
TargetOS string `json:"targetOs"`
|
||||
TargetArch string `json:"targetArch"`
|
||||
TargetRelease string `json:"targetRelease"`
|
||||
PlatformURL string `json:"platformUrl,omitempty"`
|
||||
PackageFormat string `json:"packageFormat"`
|
||||
RepositoryURL string `json:"repositoryUrl,omitempty"`
|
||||
SourceRevision string `json:"sourceRevision,omitempty"`
|
||||
WorkspaceRef string `json:"workspaceRef,omitempty"`
|
||||
EntryRef string `json:"entryRef,omitempty"`
|
||||
ConfigTemplateRef string `json:"configTemplateRef,omitempty"`
|
||||
ArtifactID string `json:"artifactId"`
|
||||
OutputFilename string `json:"outputFilename"`
|
||||
SecretRef string `json:"secretRef"`
|
||||
KeyGeneration int `json:"keyGeneration"`
|
||||
AuthKey string `json:"authKey"`
|
||||
WorkspaceSeed string `json:"workspaceSeed,omitempty"`
|
||||
JobID string `json:"jobId"`
|
||||
ComponentKind string `json:"componentKind"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
PluginID string `json:"pluginId"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
ProfileKey string `json:"profileKey,omitempty"`
|
||||
TargetOS string `json:"targetOs"`
|
||||
TargetArch string `json:"targetArch"`
|
||||
TargetRelease string `json:"targetRelease"`
|
||||
PlatformURL string `json:"platformUrl,omitempty"`
|
||||
PackageFormat string `json:"packageFormat"`
|
||||
RepositoryURL string `json:"repositoryUrl,omitempty"`
|
||||
SourceRevision string `json:"sourceRevision,omitempty"`
|
||||
ArtifactID string `json:"artifactId"`
|
||||
OutputFilename string `json:"outputFilename"`
|
||||
SecretRef string `json:"secretRef"`
|
||||
KeyGeneration int `json:"keyGeneration"`
|
||||
AuthKey string `json:"authKey"`
|
||||
WorkspaceSeed string `json:"workspaceSeed,omitempty"`
|
||||
}
|
||||
|
||||
type DependencyExecutionInputRequest struct {
|
||||
@@ -554,28 +551,25 @@ func RunJobResultFromDomain(result domain.RunJobResultResult) RunJobResultRespon
|
||||
|
||||
func DistributionBuildInputFromDomain(input domain.DistributionBuildInput) DistributionBuildInputResponse {
|
||||
return DistributionBuildInputResponse{
|
||||
JobID: input.JobID,
|
||||
ComponentKind: string(input.ComponentKind),
|
||||
ServerInstanceID: input.ServerInstanceID,
|
||||
PluginID: input.PluginID,
|
||||
RunEndpointID: input.RunEndpointID,
|
||||
ProfileKey: input.ProfileKey,
|
||||
TargetOS: input.TargetOS,
|
||||
TargetArch: input.TargetArch,
|
||||
TargetRelease: input.TargetRelease,
|
||||
PlatformURL: input.PlatformURL,
|
||||
PackageFormat: input.PackageFormat,
|
||||
RepositoryURL: input.RepositoryURL,
|
||||
SourceRevision: input.SourceRevision,
|
||||
WorkspaceRef: input.WorkspaceRef,
|
||||
EntryRef: input.EntryRef,
|
||||
ConfigTemplateRef: input.ConfigTemplateRef,
|
||||
ArtifactID: input.ArtifactID,
|
||||
OutputFilename: input.OutputFilename,
|
||||
SecretRef: input.SecretRef,
|
||||
KeyGeneration: input.KeyGeneration,
|
||||
AuthKey: input.AuthKey,
|
||||
WorkspaceSeed: input.WorkspaceSeed,
|
||||
JobID: input.JobID,
|
||||
ComponentKind: string(input.ComponentKind),
|
||||
ServerInstanceID: input.ServerInstanceID,
|
||||
PluginID: input.PluginID,
|
||||
RunEndpointID: input.RunEndpointID,
|
||||
ProfileKey: input.ProfileKey,
|
||||
TargetOS: input.TargetOS,
|
||||
TargetArch: input.TargetArch,
|
||||
TargetRelease: input.TargetRelease,
|
||||
PlatformURL: input.PlatformURL,
|
||||
PackageFormat: input.PackageFormat,
|
||||
RepositoryURL: input.RepositoryURL,
|
||||
SourceRevision: input.SourceRevision,
|
||||
ArtifactID: input.ArtifactID,
|
||||
OutputFilename: input.OutputFilename,
|
||||
SecretRef: input.SecretRef,
|
||||
KeyGeneration: input.KeyGeneration,
|
||||
AuthKey: input.AuthKey,
|
||||
WorkspaceSeed: input.WorkspaceSeed,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-115
@@ -306,25 +306,6 @@ type GameClientBridgeQueryProjectionDeclarationBody struct {
|
||||
MergeExisting bool `json:"mergeExisting,omitempty"`
|
||||
}
|
||||
|
||||
type GameClientBridgeLogProjectionStepDeclarationBody struct {
|
||||
Pattern string `json:"pattern"`
|
||||
}
|
||||
|
||||
type GameClientBridgeLogProjectionTargetDeclarationBody struct {
|
||||
Collection string `json:"collection"`
|
||||
UpsertKeys []string `json:"upsertKeys"`
|
||||
CaptureMappings map[string]string `json:"captureMappings"`
|
||||
HashMappings map[string]string `json:"hashMappings,omitempty"`
|
||||
FixedValues map[string]string `json:"fixedValues,omitempty"`
|
||||
ObservedAtField string `json:"observedAtField,omitempty"`
|
||||
}
|
||||
|
||||
type GameClientBridgeLogProjectionPresenceDeclarationBody struct {
|
||||
TimestampField string `json:"timestampField"`
|
||||
ActiveWindowSeconds int `json:"activeWindowSeconds"`
|
||||
ActivityTarget *GameClientBridgeLogProjectionTargetDeclarationBody `json:"activityTarget,omitempty"`
|
||||
}
|
||||
|
||||
type GameClientBridgeLifecycleProjectionDeclarationBody struct {
|
||||
Key string `json:"key"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
@@ -349,20 +330,9 @@ type GameClientBridgeBulkActivityTargetBody struct {
|
||||
ObservedAtField string `json:"observedAtField,omitempty"`
|
||||
}
|
||||
|
||||
type GameClientBridgeLogProjectionDeclarationBody struct {
|
||||
Key string `json:"key"`
|
||||
StreamKeys []string `json:"streamKeys"`
|
||||
Steps []GameClientBridgeLogProjectionStepDeclarationBody `json:"steps"`
|
||||
CorrelationFields []string `json:"correlationFields"`
|
||||
MaxInterveningLines int `json:"maxInterveningLines"`
|
||||
Target GameClientBridgeLogProjectionTargetDeclarationBody `json:"target"`
|
||||
Presence *GameClientBridgeLogProjectionPresenceDeclarationBody `json:"presence,omitempty"`
|
||||
}
|
||||
|
||||
type GameClientBridgeDataPackDeclarationBody struct {
|
||||
Key string `json:"key"`
|
||||
DatabaseUserVersion int `json:"databaseUserVersion"`
|
||||
LogParserRefs []string `json:"logParserRefs"`
|
||||
ConfigMapRefs []string `json:"configMapRefs"`
|
||||
DataRefs []string `json:"dataRefs,omitempty"`
|
||||
}
|
||||
@@ -403,7 +373,6 @@ type GameClientBridgeManifestBody struct {
|
||||
Commands []GameClientBridgeCommandDeclarationBody `json:"commands"`
|
||||
Snapshots []GameClientBridgeSnapshotDeclarationBody `json:"snapshots"`
|
||||
QueryTemplates []GameClientBridgeQueryTemplateDeclarationBody `json:"queryTemplates,omitempty"`
|
||||
LogProjections []GameClientBridgeLogProjectionDeclarationBody `json:"logProjections,omitempty"`
|
||||
LifecycleProjections []GameClientBridgeLifecycleProjectionDeclarationBody `json:"lifecycleProjections,omitempty"`
|
||||
DataPacks []GameClientBridgeDataPackDeclarationBody `json:"dataPacks,omitempty"`
|
||||
CommandRetentionSeconds int `json:"commandRetentionSeconds"`
|
||||
@@ -1283,17 +1252,13 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif
|
||||
for index, template := range body.QueryTemplates {
|
||||
queryTemplates[index] = domain.GameClientBridgeQueryTemplateDeclaration{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, SQLRef: template.SQLRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds, PollIntervalSeconds: template.PollIntervalSeconds, Projections: gameClientBridgeQueryProjectionsToDomain(template.Projections)}
|
||||
}
|
||||
logProjections := make([]domain.GameClientBridgeLogProjectionDeclaration, len(body.LogProjections))
|
||||
for index, projection := range body.LogProjections {
|
||||
logProjections[index] = gameClientBridgeLogProjectionToDomain(projection)
|
||||
}
|
||||
lifecycleProjections := make([]domain.GameClientBridgeLifecycleProjectionDeclaration, len(body.LifecycleProjections))
|
||||
for index, projection := range body.LifecycleProjections {
|
||||
lifecycleProjections[index] = gameClientBridgeLifecycleProjectionToDomain(projection)
|
||||
}
|
||||
dataPacks := make([]domain.GameClientBridgeDataPackDeclaration, len(body.DataPacks))
|
||||
for index, dataPack := range body.DataPacks {
|
||||
dataPacks[index] = domain.GameClientBridgeDataPackDeclaration{Key: dataPack.Key, DatabaseUserVersion: dataPack.DatabaseUserVersion, LogParserRefs: domain.CopyStringSlice(dataPack.LogParserRefs), ConfigMapRefs: domain.CopyStringSlice(dataPack.ConfigMapRefs), DataRefs: domain.CopyStringSlice(dataPack.DataRefs)}
|
||||
dataPacks[index] = domain.GameClientBridgeDataPackDeclaration{Key: dataPack.Key, DatabaseUserVersion: dataPack.DatabaseUserVersion, ConfigMapRefs: domain.CopyStringSlice(dataPack.ConfigMapRefs), DataRefs: domain.CopyStringSlice(dataPack.DataRefs)}
|
||||
}
|
||||
pages := make([]domain.GameClientBridgePageContract, len(body.Pages))
|
||||
for index, page := range body.Pages {
|
||||
@@ -1307,31 +1272,7 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif
|
||||
if body.Companion != nil {
|
||||
companion = domain.GameClientBridgeCompanionDeclaration{ProfileKey: body.Companion.ProfileKey, ConfigTemplateKey: body.Companion.ConfigTemplateKey, ConfigSchemaRef: body.Companion.ConfigSchemaRef, ConfigFormat: body.Companion.ConfigFormat, PlatformBaseURLSource: body.Companion.PlatformBaseURLSource, RegistrationProof: body.Companion.RegistrationProof, ProofMaterialSource: body.Companion.ProofMaterialSource, ProofMaterialEnv: body.Companion.ProofMaterialEnv, SessionMode: body.Companion.SessionMode, TLSPolicy: body.Companion.TLSPolicy, HeartbeatIntervalSeconds: body.Companion.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: body.Companion.CommandPollIntervalSeconds, RequestTimeoutSeconds: body.Companion.RequestTimeoutSeconds}
|
||||
}
|
||||
return domain.GameClientBridgeManifest{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, LogProjections: logProjections, LifecycleProjections: lifecycleProjections, DataPacks: dataPacks, Retention: domain.GameClientBridgeRetention{KeepForSeconds: body.CommandRetentionSeconds, MaxRecords: body.MaxCommands}, Pages: pages, Features: features, Companion: companion}
|
||||
}
|
||||
|
||||
func gameClientBridgeLogProjectionToDomain(value GameClientBridgeLogProjectionDeclarationBody) domain.GameClientBridgeLogProjectionDeclaration {
|
||||
steps := make([]domain.GameClientBridgeLogProjectionStepDeclaration, len(value.Steps))
|
||||
for index, step := range value.Steps {
|
||||
steps[index] = domain.GameClientBridgeLogProjectionStepDeclaration{Pattern: step.Pattern}
|
||||
}
|
||||
var presence *domain.GameClientBridgeLogProjectionPresenceDeclaration
|
||||
if value.Presence != nil {
|
||||
presence = &domain.GameClientBridgeLogProjectionPresenceDeclaration{
|
||||
TimestampField: value.Presence.TimestampField,
|
||||
ActiveWindowSeconds: value.Presence.ActiveWindowSeconds,
|
||||
ActivityTarget: gameClientBridgeLogProjectionTargetToDomainPointer(value.Presence.ActivityTarget),
|
||||
}
|
||||
}
|
||||
return domain.GameClientBridgeLogProjectionDeclaration{
|
||||
Key: value.Key,
|
||||
StreamKeys: domain.CopyStringSlice(value.StreamKeys),
|
||||
Steps: steps,
|
||||
CorrelationFields: domain.CopyStringSlice(value.CorrelationFields),
|
||||
MaxInterveningLines: value.MaxInterveningLines,
|
||||
Target: gameClientBridgeLogProjectionTargetToDomain(value.Target),
|
||||
Presence: presence,
|
||||
}
|
||||
return domain.GameClientBridgeManifest{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, LifecycleProjections: lifecycleProjections, DataPacks: dataPacks, Retention: domain.GameClientBridgeRetention{KeepForSeconds: body.CommandRetentionSeconds, MaxRecords: body.MaxCommands}, Pages: pages, Features: features, Companion: companion}
|
||||
}
|
||||
|
||||
func gameClientBridgeQueryProjectionsToDomain(values []GameClientBridgeQueryProjectionDeclarationBody) []domain.GameClientBridgeQueryProjectionDeclaration {
|
||||
@@ -1361,18 +1302,6 @@ func gameClientBridgeBulkActivityTargetToDomainPointer(value *GameClientBridgeBu
|
||||
return &target
|
||||
}
|
||||
|
||||
func gameClientBridgeLogProjectionTargetToDomain(value GameClientBridgeLogProjectionTargetDeclarationBody) domain.GameClientBridgeLogProjectionTargetDeclaration {
|
||||
return domain.GameClientBridgeLogProjectionTargetDeclaration{Collection: value.Collection, UpsertKeys: domain.CopyStringSlice(value.UpsertKeys), CaptureMappings: domain.CopyStringMap(value.CaptureMappings), HashMappings: domain.CopyStringMap(value.HashMappings), FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField}
|
||||
}
|
||||
|
||||
func gameClientBridgeLogProjectionTargetToDomainPointer(value *GameClientBridgeLogProjectionTargetDeclarationBody) *domain.GameClientBridgeLogProjectionTargetDeclaration {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
target := gameClientBridgeLogProjectionTargetToDomain(*value)
|
||||
return &target
|
||||
}
|
||||
|
||||
func (actions PluginLifecycleActionsBody) ToDomain() domain.PluginLifecycleActions {
|
||||
return domain.PluginLifecycleActions{
|
||||
Install: actions.Install,
|
||||
@@ -1782,17 +1711,13 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
|
||||
for index, template := range value.QueryTemplates {
|
||||
queryTemplates[index] = GameClientBridgeQueryTemplateDeclarationBody{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, SQLRef: template.SQLRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds, PollIntervalSeconds: template.PollIntervalSeconds, Projections: gameClientBridgeQueryProjectionsFromDomain(template.Projections)}
|
||||
}
|
||||
logProjections := make([]GameClientBridgeLogProjectionDeclarationBody, len(value.LogProjections))
|
||||
for index, projection := range value.LogProjections {
|
||||
logProjections[index] = gameClientBridgeLogProjectionFromDomain(projection)
|
||||
}
|
||||
lifecycleProjections := make([]GameClientBridgeLifecycleProjectionDeclarationBody, len(value.LifecycleProjections))
|
||||
for index, projection := range value.LifecycleProjections {
|
||||
lifecycleProjections[index] = gameClientBridgeLifecycleProjectionFromDomain(projection)
|
||||
}
|
||||
dataPacks := make([]GameClientBridgeDataPackDeclarationBody, len(value.DataPacks))
|
||||
for index, dataPack := range value.DataPacks {
|
||||
dataPacks[index] = GameClientBridgeDataPackDeclarationBody{Key: dataPack.Key, DatabaseUserVersion: dataPack.DatabaseUserVersion, LogParserRefs: domain.CopyStringSlice(dataPack.LogParserRefs), ConfigMapRefs: domain.CopyStringSlice(dataPack.ConfigMapRefs), DataRefs: domain.CopyStringSlice(dataPack.DataRefs)}
|
||||
dataPacks[index] = GameClientBridgeDataPackDeclarationBody{Key: dataPack.Key, DatabaseUserVersion: dataPack.DatabaseUserVersion, ConfigMapRefs: domain.CopyStringSlice(dataPack.ConfigMapRefs), DataRefs: domain.CopyStringSlice(dataPack.DataRefs)}
|
||||
}
|
||||
pages := make([]GameClientBridgePageContractBody, len(value.Pages))
|
||||
for index, page := range value.Pages {
|
||||
@@ -1806,31 +1731,7 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
|
||||
if value.Companion.ProfileKey != "" {
|
||||
companion = &GameClientBridgeCompanionDeclarationBody{ProfileKey: value.Companion.ProfileKey, ConfigTemplateKey: value.Companion.ConfigTemplateKey, ConfigSchemaRef: value.Companion.ConfigSchemaRef, ConfigFormat: value.Companion.ConfigFormat, PlatformBaseURLSource: value.Companion.PlatformBaseURLSource, RegistrationProof: value.Companion.RegistrationProof, ProofMaterialSource: value.Companion.ProofMaterialSource, ProofMaterialEnv: value.Companion.ProofMaterialEnv, SessionMode: value.Companion.SessionMode, TLSPolicy: value.Companion.TLSPolicy, HeartbeatIntervalSeconds: value.Companion.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: value.Companion.CommandPollIntervalSeconds, RequestTimeoutSeconds: value.Companion.RequestTimeoutSeconds}
|
||||
}
|
||||
return GameClientBridgeManifestBody{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, LogProjections: logProjections, LifecycleProjections: lifecycleProjections, DataPacks: dataPacks, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Features: features, Companion: companion}
|
||||
}
|
||||
|
||||
func gameClientBridgeLogProjectionFromDomain(value domain.GameClientBridgeLogProjectionDeclaration) GameClientBridgeLogProjectionDeclarationBody {
|
||||
steps := make([]GameClientBridgeLogProjectionStepDeclarationBody, len(value.Steps))
|
||||
for index, step := range value.Steps {
|
||||
steps[index] = GameClientBridgeLogProjectionStepDeclarationBody{Pattern: step.Pattern}
|
||||
}
|
||||
var presence *GameClientBridgeLogProjectionPresenceDeclarationBody
|
||||
if value.Presence != nil {
|
||||
presence = &GameClientBridgeLogProjectionPresenceDeclarationBody{
|
||||
TimestampField: value.Presence.TimestampField,
|
||||
ActiveWindowSeconds: value.Presence.ActiveWindowSeconds,
|
||||
ActivityTarget: gameClientBridgeLogProjectionTargetFromDomainPointer(value.Presence.ActivityTarget),
|
||||
}
|
||||
}
|
||||
return GameClientBridgeLogProjectionDeclarationBody{
|
||||
Key: value.Key,
|
||||
StreamKeys: domain.CopyStringSlice(value.StreamKeys),
|
||||
Steps: steps,
|
||||
CorrelationFields: domain.CopyStringSlice(value.CorrelationFields),
|
||||
MaxInterveningLines: value.MaxInterveningLines,
|
||||
Target: gameClientBridgeLogProjectionTargetFromDomain(value.Target),
|
||||
Presence: presence,
|
||||
}
|
||||
return GameClientBridgeManifestBody{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, LifecycleProjections: lifecycleProjections, DataPacks: dataPacks, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Features: features, Companion: companion}
|
||||
}
|
||||
|
||||
func gameClientBridgeQueryProjectionsFromDomain(values []domain.GameClientBridgeQueryProjectionDeclaration) []GameClientBridgeQueryProjectionDeclarationBody {
|
||||
@@ -1860,18 +1761,6 @@ func gameClientBridgeBulkActivityTargetFromDomainPointer(value *domain.GameClien
|
||||
return &target
|
||||
}
|
||||
|
||||
func gameClientBridgeLogProjectionTargetFromDomain(value domain.GameClientBridgeLogProjectionTargetDeclaration) GameClientBridgeLogProjectionTargetDeclarationBody {
|
||||
return GameClientBridgeLogProjectionTargetDeclarationBody{Collection: value.Collection, UpsertKeys: domain.CopyStringSlice(value.UpsertKeys), CaptureMappings: domain.CopyStringMap(value.CaptureMappings), HashMappings: domain.CopyStringMap(value.HashMappings), FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField}
|
||||
}
|
||||
|
||||
func gameClientBridgeLogProjectionTargetFromDomainPointer(value *domain.GameClientBridgeLogProjectionTargetDeclaration) *GameClientBridgeLogProjectionTargetDeclarationBody {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
target := gameClientBridgeLogProjectionTargetFromDomain(*value)
|
||||
return &target
|
||||
}
|
||||
|
||||
func MarketplacePluginListFromDomain(plugins []domain.PluginMarketplacePlugin) MarketplacePluginListResponse {
|
||||
items := make([]MarketplacePluginResponse, len(plugins))
|
||||
for i, plugin := range plugins {
|
||||
|
||||
@@ -109,17 +109,6 @@ type RuntimeLogSourceBody struct {
|
||||
RetentionDays int `json:"retentionDays,omitempty"`
|
||||
}
|
||||
|
||||
type RuntimeLogEventBody struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
SourceKey string `json:"sourceKey"`
|
||||
EventType string `json:"eventType"`
|
||||
Permission string `json:"permission"`
|
||||
SchemaRef string `json:"schemaRef"`
|
||||
RetentionDays int `json:"retentionDays"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
|
||||
type RuntimeTransportProfileBody struct {
|
||||
Key string `json:"key"`
|
||||
Kind string `json:"kind"`
|
||||
@@ -127,6 +116,18 @@ type RuntimeTransportProfileBody struct {
|
||||
Capabilities []string `json:"capabilities"`
|
||||
}
|
||||
|
||||
type RuntimeDataTargetBody struct {
|
||||
Key string `json:"key"`
|
||||
Kind string `json:"kind"`
|
||||
TransportKey string `json:"transportKey"`
|
||||
SourceRootKey string `json:"sourceRootKey"`
|
||||
SourcePath string `json:"sourcePath"`
|
||||
WorkspaceKey string `json:"workspaceKey"`
|
||||
RefreshPolicy string `json:"refreshPolicy"`
|
||||
MaxBytes int64 `json:"maxBytes"`
|
||||
Platforms []string `json:"platforms,omitempty"`
|
||||
}
|
||||
|
||||
type RuntimeRepositoryBody struct {
|
||||
URL string `json:"url"`
|
||||
RevisionPolicy string `json:"revisionPolicy"`
|
||||
@@ -261,8 +262,8 @@ type GamePluginRuntimeProfilesBody struct {
|
||||
InstallPlans []RuntimeInstallPlanBody `json:"installPlans,omitempty"`
|
||||
ServerDeployments []RuntimeServerDeploymentProfileBody `json:"serverDeployments,omitempty"`
|
||||
LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"`
|
||||
LogEvents []RuntimeLogEventBody `json:"logEvents,omitempty"`
|
||||
TransportProfiles []RuntimeTransportProfileBody `json:"transportProfiles,omitempty"`
|
||||
DataTargets []RuntimeDataTargetBody `json:"dataTargets,omitempty"`
|
||||
ClientManagers []RuntimeClientManagerProfileBody `json:"clientManagers,omitempty"`
|
||||
DLLExtensions []RuntimeDLLExtensionProfileBody `json:"dllExtensions,omitempty"`
|
||||
}
|
||||
@@ -277,8 +278,8 @@ type GamePluginRuntimeProfilesResponseBody struct {
|
||||
InstallPlans []RuntimeInstallPlanBody `json:"installPlans,omitempty"`
|
||||
ServerDeployments []RuntimeServerDeploymentProfileBody `json:"serverDeployments,omitempty"`
|
||||
LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"`
|
||||
LogEvents []RuntimeLogEventBody `json:"logEvents,omitempty"`
|
||||
TransportProfiles []RuntimeTransportProfileBody `json:"transportProfiles,omitempty"`
|
||||
DataTargets []RuntimeDataTargetBody `json:"dataTargets,omitempty"`
|
||||
ClientManagers []RuntimeClientManagerProfileBody `json:"clientManagers,omitempty"`
|
||||
DLLExtensions []RuntimeDLLExtensionProfileResponseBody `json:"dllExtensions,omitempty"`
|
||||
}
|
||||
@@ -323,12 +324,12 @@ func (body GamePluginRuntimeProfilesBody) ToDomain() domain.GamePluginRuntimePro
|
||||
for _, item := range body.LogSources {
|
||||
profiles.LogSources = append(profiles.LogSources, domain.RuntimeLogSource{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, StreamKey: item.StreamKey, CursorKind: item.CursorKind, RetentionDays: item.RetentionDays})
|
||||
}
|
||||
for _, item := range body.LogEvents {
|
||||
profiles.LogEvents = append(profiles.LogEvents, domain.RuntimeLogEvent{Key: item.Key, Title: item.Title, SourceKey: item.SourceKey, EventType: item.EventType, Permission: item.Permission, SchemaRef: item.SchemaRef, RetentionDays: item.RetentionDays, Severity: domain.RuntimeLogEventSeverity(item.Severity)})
|
||||
}
|
||||
for _, item := range body.TransportProfiles {
|
||||
profiles.TransportProfiles = append(profiles.TransportProfiles, domain.RuntimeTransportProfile{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Capabilities: domain.CopyStringSlice(item.Capabilities)})
|
||||
}
|
||||
for _, item := range body.DataTargets {
|
||||
profiles.DataTargets = append(profiles.DataTargets, domain.RuntimeDataTarget{Key: item.Key, Kind: item.Kind, TransportKey: item.TransportKey, SourceRootKey: item.SourceRootKey, SourcePath: item.SourcePath, WorkspaceKey: item.WorkspaceKey, RefreshPolicy: item.RefreshPolicy, MaxBytes: item.MaxBytes, Platforms: domain.CopyStringSlice(item.Platforms)})
|
||||
}
|
||||
for _, item := range body.ClientManagers {
|
||||
manager := domain.RuntimeClientManagerProfile{
|
||||
Key: item.Key, DisplayName: item.DisplayName, Version: item.Version,
|
||||
@@ -399,12 +400,12 @@ func runtimeProfilesFromDomain(profiles domain.GamePluginRuntimeProfiles) GamePl
|
||||
for _, item := range profiles.LogSources {
|
||||
body.LogSources = append(body.LogSources, RuntimeLogSourceBody{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, StreamKey: item.StreamKey, CursorKind: item.CursorKind, RetentionDays: item.RetentionDays})
|
||||
}
|
||||
for _, item := range profiles.LogEvents {
|
||||
body.LogEvents = append(body.LogEvents, RuntimeLogEventBody{Key: item.Key, Title: item.Title, SourceKey: item.SourceKey, EventType: item.EventType, Permission: item.Permission, SchemaRef: item.SchemaRef, RetentionDays: item.RetentionDays, Severity: string(item.Severity)})
|
||||
}
|
||||
for _, item := range profiles.TransportProfiles {
|
||||
body.TransportProfiles = append(body.TransportProfiles, RuntimeTransportProfileBody{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Capabilities: item.Capabilities})
|
||||
}
|
||||
for _, item := range profiles.DataTargets {
|
||||
body.DataTargets = append(body.DataTargets, RuntimeDataTargetBody{Key: item.Key, Kind: item.Kind, TransportKey: item.TransportKey, SourceRootKey: item.SourceRootKey, SourcePath: item.SourcePath, WorkspaceKey: item.WorkspaceKey, RefreshPolicy: item.RefreshPolicy, MaxBytes: item.MaxBytes, Platforms: item.Platforms})
|
||||
}
|
||||
for _, item := range profiles.ClientManagers {
|
||||
manager := RuntimeClientManagerProfileBody{
|
||||
Key: item.Key, DisplayName: item.DisplayName, Version: item.Version,
|
||||
|
||||
@@ -23,7 +23,7 @@ Named control DTOs:
|
||||
|
||||
Control payloads must remain small and must not include logs, artifact chunks, host paths, raw credentials, direct sockets, job assignments, execution input, or long task results. Hello creates or updates run endpoint metadata and issues an in-memory platform session token. A server-scoped generated Run must use the endpoint identity reserved for its server; Platform rejects a valid component key presented for another endpoint. Registration is binding/authentication only for generated Run bootstrap and must not enqueue lifecycle or status jobs merely because Run appeared. Heartbeat requires that active session token and may request capability refresh when the fingerprint changes. The control event stream is a signed Run-only `text/event-stream` wake channel; events such as `job.changed` only tell Run to claim durable work through `/run/jobs/claim`.
|
||||
|
||||
Capacity reports include bounded `maxJobs`, `runningJobs`, `queuedJobs`, compatibility `logBacklogBatches`, `artifactBacklogChunks`, and enumerated pressure codes. Current Run implementations must not use `logBacklogBatches` as a durable live-log spool; capacity reports never include log bodies, artifact chunks, machine paths, PIDs, sockets, credentials, or transport endpoints.
|
||||
Capacity reports include bounded `maxJobs`, `runningJobs`, `queuedJobs`, `logBacklogBatches`, `artifactBacklogChunks`, and enumerated pressure codes. They report queue and spool counts only, never log bodies, artifact chunks, machine paths, PIDs, sockets, credentials, or transport endpoints.
|
||||
|
||||
Control is the highest-priority run/platform path. Artifact/file transfer load must not delay heartbeat acceptance or mutate heartbeat capacity state through heavy payload fields.
|
||||
|
||||
@@ -65,34 +65,31 @@ The plan is build input for the generated package, not a machine-side job-channe
|
||||
|
||||
Autonomous lifecycle reports use `POST /api/v1/run/lifecycle/report` with the active Run session and signed envelope when required. The route accepts only bounded terminal lifecycle facts for `process.install`, `process.start`, `process.stop`, or `process.status`; it validates the server/run binding, records bounded evidence, and projects server state from Run-reported process facts without creating or completing a Platform job. A managed-process report includes an opaque `managedProcessId`, monotonic `observationSeq`, and `observedAt`; retries are idempotent and a lower sequence cannot regress a newer fact for that process.
|
||||
|
||||
## Live Log Relay And Compatibility Log Ingest
|
||||
## Log Ingest
|
||||
|
||||
|
||||
Implemented HTTP JSON routes:
|
||||
|
||||
- `POST /api/v1/run/logs/relay`
|
||||
- `POST /api/v1/run/logs/batches`
|
||||
- `GET /api/v1/server-instances/{id}/logs/events`
|
||||
- `POST /api/v1/game-client-bridge/companion/logs/events`
|
||||
- `POST /api/v1/log-streams/query`
|
||||
|
||||
Named log DTOs:
|
||||
|
||||
- `LogBatchIngestRequest`
|
||||
- `LogBatchIngestResponse`
|
||||
- `RunLogStreamProgressRequest` / `RunLogStreamProgressResponse`: compatibility signed Run-only cursor metadata for older durable-ingest streams. Current live relay does not depend on this route for delivery or progress.
|
||||
- `RunLogStreamProgressRequest` / `RunLogStreamProgressResponse`: signed Run-only sequence recovery for a server-bound `run.<endpoint>.<server>.*` stream. The response contains only the latest acknowledged sequence.
|
||||
- `LogEntry`
|
||||
- `LogStreamCursorRequest`
|
||||
- `LogStreamCursorResponse`
|
||||
- `LogStreamEventResponse`
|
||||
|
||||
Current Run output uses `POST /api/v1/run/logs/relay`. The route validates the active Run session and stream binding, updates only bounded stream metadata such as latest sequence/session identity, and immediately fans entries out to live subscribers. It does not persist log bodies, does not create a resend backlog, does not require platform sequence acknowledgements for progress, and does not block lifecycle/control/job work on delivery success.
|
||||
Log ingest supports bounded batches, sequence ranges, checksum validation, retry-safe duplicate acknowledgement, latest sequence tracking, cursor query, and browser SSE fan-out from already-ingested platform logs. Log payloads must not carry artifact chunks, host paths, raw credentials, direct sockets, or unbounded inline data.
|
||||
|
||||
Server terminal streaming uses `GET /api/v1/server-instances/{id}/logs/events`; SCUM companion streaming uses `POST /api/v1/game-client-bridge/companion/logs/events` with the component session token in the typed JSON body. Both emit only the current supervised process session and do not replay retained platform log bodies. Platform is the live relay only. The game plugin/companion owns game-log storage, semantic analysis, and console-page fan-out behavior.
|
||||
Run-assigned Platform jobs use `job.<jobId>.<streamKey>` log stream IDs. Autonomous lifecycle bootstrap is Run-owned machine execution rather than a Platform job, so its durable process logs use `run.<runEndpointId>.<serverInstanceId>.<streamKey>`. Platform may auto-create those streams only after validating the active Run session and the server-to-Run binding. For retry compatibility, legacy spooled `job.autonomous-*.<streamKey>` batches are accepted as Run-owned autonomous streams without creating or completing a Platform job.
|
||||
|
||||
`POST /api/v1/run/logs/batches` and `POST /api/v1/log-streams/query` remain compatibility/internal maintenance contracts for older durable-ingest flows. New Run workers must not use durable local log spool/cache/resend semantics for current supervised stdout/stderr. Log payloads must 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 batch acknowledgement, duplicate acknowledgement, cursor state updates, or spool cleanup.
|
||||
|
||||
The platform stores log stream metadata through `repo.Store`. Game-specific durable log bodies and derived semantic records belong to the owning game plugin; for SCUM that storage uses plugin-owned SQL tables and stores raw world coordinates without map projection or coordinate conversion.
|
||||
The platform stores log stream metadata through `repo.Store` and stores log bodies through the configured `LogBodyStore`. The default `file` backend persists platform metadata to `PLATFORM_METADATA_PATH` and appends log entries to segmented JSONL files under `PLATFORM_LOG_DIR`; the `memory` backend is only for tests and disposable local development. MySQL/Postgres are appropriate for platform metadata, stream state, retention policy, indexes, and operational records, but should not be the primary row-per-log-line store for hundreds or thousands of servers. Production log bodies should move behind the same boundary to append/query backends such as ClickHouse, Loki, OpenSearch/Elasticsearch, or object-storage segments with compact indexes.
|
||||
|
||||
## Artifact
|
||||
|
||||
@@ -115,9 +112,9 @@ Named artifact DTOs:
|
||||
- `ArtifactTransferCompleteResponse`
|
||||
- `ArtifactResponse`
|
||||
|
||||
Artifact upload supports active run session validation, job/server-instance owner scoping, bounded JSON chunk payloads, per-chunk checksum validation, duplicate chunk acknowledgement, resume status, and final checksum verification before an artifact becomes available. Artifact transport is separate from control, job result, live log relay, plugin bridge, and browser file APIs.
|
||||
Artifact upload supports active run session validation, job/server-instance owner scoping, bounded JSON chunk payloads, per-chunk checksum validation, duplicate chunk acknowledgement, resume status, and final checksum verification before an artifact becomes available. Artifact transport is separate from control, job result, log ingest, plugin bridge, and browser file APIs.
|
||||
|
||||
Artifact/file transfer is the lower-priority heavy channel. Chunk upload and completion must not block control heartbeat, job ack/result delivery, cancellation/reconcile calls, or current live log relay. Lightweight routes must reject heavy transfer payloads instead of accepting or storing them.
|
||||
Artifact/file transfer is the lower-priority heavy channel. Chunk upload and completion must not block control heartbeat, job ack/result delivery, cancellation/reconcile calls, or log ingest acknowledgement. Lightweight routes must reject heavy transfer payloads instead of accepting or storing them.
|
||||
|
||||
## Server File Manager Transfer
|
||||
|
||||
@@ -131,7 +128,7 @@ Browser-facing file management uses server-instance scoped routes on Platform fo
|
||||
|
||||
Browser uploads are first staged as server-instance artifacts. Platform then queues a `files.write` job whose `inputRef` is `artifact://<id>` and whose execution input names the dedicated `run-file-transfer` channel. Run pulls those bytes through `POST /api/v1/run/files/input-chunk` while proving the active endpoint session plus job attempt and lease. The chunk route is fenced to the active file-write job, validates artifact ownership/checksum, and returns bounded byte ranges only.
|
||||
|
||||
File-manager transfer is a separate, low-priority heavy path. Slow uploads, downloads, retries, or file input chunk pulls must not block control heartbeat, job claim/ack/progress/result/cancel/reconcile, current live log relay, or artifact upload acknowledgements. Control, jobs, logs, artifacts, file transfer, and optional game-client bridge remain independently backpressured channels.
|
||||
File-manager transfer is a separate, low-priority heavy path. Slow uploads, downloads, retries, or file input chunk pulls must not block control heartbeat, job claim/ack/progress/result/cancel/reconcile, durable log batch ingest, or artifact upload acknowledgements. Control, jobs, logs, artifacts, file transfer, and optional game-client bridge remain independently backpressured channels.
|
||||
|
||||
## Client Manager lifecycle channel
|
||||
|
||||
@@ -139,8 +136,8 @@ Client Manager lifecycle jobs use the independent capabilities `client-manager.d
|
||||
|
||||
Run materializes the declared output such as `config.yaml` from the fenced values and its own configured Platform control URL. Source template values are not credentials and must not override the generated component identity or policy. The lifecycle input never contains the component proof itself, a component session, a browser credential, a host path, or a direct socket; proof remains inside the component package and is supplied to the supervised process only through the declared environment-variable name.
|
||||
|
||||
Run persists staging/active/previous slots and a local journal. It rejects stale lease/attempt/generation/target fences, traversal/link/device-file archives, checksum mismatches, undeclared executables, and arbitrary shell. Terminal results use `client-manager.deployed`, `client-manager.control`, `client-manager.updated`, `client-manager.rolled-back`, `client-manager.rollback.restored`, or `client-manager.uninstalled` with logical process/health state only. A stalled Client Manager download must not delay Run heartbeat, job ack/result/cancel, or current live log relay.
|
||||
Run persists staging/active/previous slots and a local journal. It rejects stale lease/attempt/generation/target fences, traversal/link/device-file archives, checksum mismatches, undeclared executables, and arbitrary shell. Terminal results use `client-manager.deployed`, `client-manager.control`, `client-manager.updated`, `client-manager.rolled-back`, `client-manager.rollback.restored`, or `client-manager.uninstalled` with logical process/health state only. A stalled Client Manager download must not delay Run heartbeat, job ack/result/cancel, or log spool acknowledgement.
|
||||
|
||||
## Game Client Bridge
|
||||
|
||||
The optional game client bridge is separate from run lifecycle, control registration, job handling, compatibility log ingest, and artifact transport. A plugin companion may subscribe to the current live log relay through its component session so it can own game-log storage and analysis without requiring Platform to persist or interpret game log bodies.
|
||||
The optional game client bridge is separate from run lifecycle, control registration, job handling, log ingest, and artifact transport.
|
||||
|
||||
@@ -231,7 +231,7 @@ func buildLifecycleDistribution(t *testing.T, svc *CoreService, session string,
|
||||
}
|
||||
payload := []byte("client-manager-package-" + version)
|
||||
svc.ConfigureDistributionBuilder(staticDistributionBuilder{payload: payload})
|
||||
distribution, err := svc.GenerateClientManagerDistributionForSession(session, domain.ClientManagerBuildRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", TargetOS: "linux", TargetArch: "amd64", RepositoryURL: "https://git.npc0.com/admin343/browser.git", SourceRevision: "main", IdempotencyKey: idempotency})
|
||||
distribution, err := svc.GenerateClientManagerDistributionForSession(session, domain.ClientManagerBuildRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", TargetOS: "linux", TargetArch: "amd64", RepositoryURL: "https://github.com/F88888/scum_client.git", SourceRevision: "main", IdempotencyKey: idempotency})
|
||||
if err != nil {
|
||||
t.Fatalf("generate lifecycle distribution: %v", err)
|
||||
}
|
||||
|
||||
@@ -188,60 +188,29 @@ func (svc *CoreService) platformDistributionBuildInput(job domain.Job) (domain.D
|
||||
if err != nil {
|
||||
return domain.DistributionBuildInput{}, err
|
||||
}
|
||||
profile, err := svc.clientManagerDistributionBuildProfile(distribution)
|
||||
if err != nil {
|
||||
return domain.DistributionBuildInput{}, err
|
||||
}
|
||||
return domain.DistributionBuildInput{
|
||||
JobID: job.ID,
|
||||
ComponentKind: domain.DistributionComponentClientManager,
|
||||
ServerInstanceID: distribution.ServerInstanceID,
|
||||
PluginID: distribution.PluginID,
|
||||
RunEndpointID: job.RunEndpointID,
|
||||
ProfileKey: distribution.ProfileKey,
|
||||
TargetOS: distribution.TargetOS,
|
||||
TargetArch: distribution.TargetArch,
|
||||
PlatformURL: runReleasePlatformURL(),
|
||||
PackageFormat: packageFormatForTarget(distribution.TargetOS),
|
||||
RepositoryURL: distribution.RepositoryURL,
|
||||
SourceRevision: distribution.SourceRevision,
|
||||
WorkspaceRef: profile.WorkspaceRef,
|
||||
EntryRef: profile.EntryRef,
|
||||
ConfigTemplateRef: clientManagerBuildConfigTemplateRef(profile),
|
||||
ArtifactID: distribution.ArtifactID,
|
||||
OutputFilename: clientManagerOutputName(distribution.ProfileKey, distribution.TargetOS),
|
||||
SecretRef: distribution.SecretRef,
|
||||
KeyGeneration: distribution.KeyGeneration,
|
||||
AuthKey: plainKey,
|
||||
JobID: job.ID,
|
||||
ComponentKind: domain.DistributionComponentClientManager,
|
||||
ServerInstanceID: distribution.ServerInstanceID,
|
||||
PluginID: distribution.PluginID,
|
||||
RunEndpointID: job.RunEndpointID,
|
||||
ProfileKey: distribution.ProfileKey,
|
||||
TargetOS: distribution.TargetOS,
|
||||
TargetArch: distribution.TargetArch,
|
||||
PlatformURL: runReleasePlatformURL(),
|
||||
PackageFormat: packageFormatForTarget(distribution.TargetOS),
|
||||
RepositoryURL: distribution.RepositoryURL,
|
||||
SourceRevision: distribution.SourceRevision,
|
||||
ArtifactID: distribution.ArtifactID,
|
||||
OutputFilename: clientManagerOutputName(distribution.ProfileKey, distribution.TargetOS),
|
||||
SecretRef: distribution.SecretRef,
|
||||
KeyGeneration: distribution.KeyGeneration,
|
||||
AuthKey: plainKey,
|
||||
}, nil
|
||||
}
|
||||
return domain.DistributionBuildInput{}, repo.ErrNotFound
|
||||
}
|
||||
|
||||
func (svc *CoreService) clientManagerDistributionBuildProfile(distribution domain.ClientManagerDistribution) (domain.RuntimeClientManagerProfile, error) {
|
||||
plugin, err := svc.store.GamePlugins().Get(distribution.PluginID)
|
||||
if err != nil {
|
||||
return domain.RuntimeClientManagerProfile{}, err
|
||||
}
|
||||
profile, err := findRuntimeClientManagerProfile(plugin, distribution.ProfileKey)
|
||||
if err != nil {
|
||||
return domain.RuntimeClientManagerProfile{}, err
|
||||
}
|
||||
if profile.RepositoryURL != distribution.RepositoryURL || !clientManagerProfileAllowsRevision(profile, distribution.SourceRevision) || !clientManagerProfileSupportsTarget(profile, distribution.TargetOS, distribution.TargetArch) {
|
||||
return domain.RuntimeClientManagerProfile{}, validationError("client-manager build no longer matches the declared profile")
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func clientManagerBuildConfigTemplateRef(profile domain.RuntimeClientManagerProfile) string {
|
||||
for _, candidate := range profile.ConfigTemplates {
|
||||
if candidate.OutputRef == "config.yaml" && strings.TrimSpace(candidate.TemplateRef) != "" {
|
||||
return candidate.TemplateRef
|
||||
}
|
||||
}
|
||||
return "config.yaml.example"
|
||||
}
|
||||
|
||||
type runDistributionPackageContext struct {
|
||||
profileKey string
|
||||
workspaceSeed string
|
||||
@@ -277,39 +246,8 @@ func (svc *CoreService) runDistributionPackageContext(distribution domain.RunDis
|
||||
return runDistributionPackageContext{profileKey: profileKey, workspaceSeed: seed, autonomousLifecycle: plan}, nil
|
||||
}
|
||||
|
||||
func defaultRunDistributionProfileKey(plugin domain.GamePlugin, profileKey string) string {
|
||||
profileKey = strings.TrimSpace(profileKey)
|
||||
if profileKey != "" {
|
||||
if profile, ok := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, profileKey); ok {
|
||||
return profile.Key
|
||||
}
|
||||
return profileKey
|
||||
}
|
||||
return firstRuntimeLifecycleProfileKey(plugin)
|
||||
}
|
||||
|
||||
func lifecycleDefaultProfileKey(instance domain.ServerInstance, plugin domain.GamePlugin, profileKey string) string {
|
||||
profileKey = strings.TrimSpace(profileKey)
|
||||
if profileKey != "" {
|
||||
if profile, ok := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, profileKey); ok {
|
||||
return profile.Key
|
||||
}
|
||||
return profileKey
|
||||
}
|
||||
if instance.RunEndpointID != "" && instance.RunEndpointID == generatedRunEndpointID(instance.ID) {
|
||||
return firstRuntimeLifecycleProfileKey(plugin)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstRuntimeLifecycleProfileKey(plugin domain.GamePlugin) string {
|
||||
if len(plugin.RuntimeProfiles.LifecycleProfiles) == 0 {
|
||||
return ""
|
||||
}
|
||||
return plugin.RuntimeProfiles.LifecycleProfiles[0].Key
|
||||
}
|
||||
|
||||
func runAutonomousLifecyclePlan(distribution domain.RunDistribution, instance domain.ServerInstance, plugin domain.GamePlugin, profileKey string, bindings map[string]string) (*domain.RunAutonomousLifecyclePlan, error) {
|
||||
privateBindings := autonomousDataTargetBindings(bindings, instance, plugin.RuntimeProfiles.DataTargets)
|
||||
plan := &domain.RunAutonomousLifecyclePlan{
|
||||
SchemaVersion: "1",
|
||||
ServerInstanceID: instance.ID,
|
||||
@@ -321,8 +259,8 @@ func runAutonomousLifecyclePlan(distribution domain.RunDistribution, instance do
|
||||
TargetArch: distribution.TargetArch,
|
||||
TargetRelease: distribution.ID,
|
||||
DeploymentRevision: instance.Deployment.Revision,
|
||||
RuntimeBindings: domain.CopyStringMap(bindings),
|
||||
Deployment: autonomousDeploymentFromDefinition(instance.Deployment, profileKey, bindings),
|
||||
RuntimeBindings: privateBindings,
|
||||
Deployment: autonomousDeploymentFromDefinition(instance.Deployment, profileKey, privateBindings),
|
||||
}
|
||||
profile, hasProfile := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, profileKey)
|
||||
for _, action := range []domain.ServerLifecycleAction{domain.ServerLifecycleActionCreate, domain.ServerLifecycleActionStart, domain.ServerLifecycleActionStop, domain.ServerLifecycleActionStatus} {
|
||||
@@ -351,6 +289,11 @@ func runAutonomousLifecyclePlan(distribution domain.RunDistribution, instance do
|
||||
plan.LogSources = append(plan.LogSources, autonomousLogSource(source))
|
||||
}
|
||||
}
|
||||
for _, target := range plugin.RuntimeProfiles.DataTargets {
|
||||
if runtimePlatformsContain(target.Platforms, distribution.TargetOS) {
|
||||
plan.DataTargets = append(plan.DataTargets, autonomousDataTarget(target))
|
||||
}
|
||||
}
|
||||
if hasProfile && len(profile.DLLExtensionRefs) > 0 {
|
||||
endpoint := domain.RunEndpoint{ID: distribution.RunEndpointID, Platform: distribution.TargetOS, Architecture: distribution.TargetArch}
|
||||
extensions, err := lifecycleDLLExtensionPlans(plugin.RuntimeProfiles, profile, endpoint)
|
||||
@@ -364,6 +307,19 @@ func runAutonomousLifecyclePlan(distribution domain.RunDistribution, instance do
|
||||
return domain.CopyRunAutonomousLifecyclePlanPtr(plan), nil
|
||||
}
|
||||
|
||||
func autonomousDataTargetBindings(bindings map[string]string, instance domain.ServerInstance, targets []domain.RuntimeDataTarget) map[string]string {
|
||||
result := domain.CopyStringMap(bindings)
|
||||
if result == nil {
|
||||
result = map[string]string{}
|
||||
}
|
||||
for _, target := range targets {
|
||||
if result[target.SourceRootKey] == "" && target.SourceRootKey == "server-root" && instance.Deployment.ServerRoot != "" {
|
||||
result[target.SourceRootKey] = instance.Deployment.ServerRoot
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func autonomousLifecycleAction(plugin domain.GamePlugin, profile domain.RuntimeLifecycleProfile, hasProfile bool, action domain.ServerLifecycleAction) domain.RunAutonomousLifecycleAction {
|
||||
targetKey := ""
|
||||
if hasProfile {
|
||||
@@ -409,6 +365,10 @@ func autonomousDLLExtension(extension domain.RuntimeDLLExtensionPlan) domain.Run
|
||||
return domain.RunAutonomousDLLExtension{Key: extension.Key, Version: extension.Version, ReleaseURL: extension.ReleaseURL, Checksum: extension.Checksum, SizeBytes: extension.SizeBytes, TargetKey: extension.TargetKey, ModKey: extension.ModKey, DLLRef: extension.DLLRef, SCUMExecutableChecksum: extension.SCUMExecutableChecksum, UE4SSABI: extension.UE4SSABI, RCONPort: extension.RCONPort}
|
||||
}
|
||||
|
||||
func autonomousDataTarget(target domain.RuntimeDataTarget) domain.RunAutonomousDataTarget {
|
||||
return domain.RunAutonomousDataTarget{Key: target.Key, Kind: target.Kind, TransportKey: target.TransportKey, SourceRootKey: target.SourceRootKey, SourcePath: target.SourcePath, WorkspaceKey: target.WorkspaceKey, RefreshPolicy: target.RefreshPolicy, MaxBytes: target.MaxBytes, Platforms: domain.CopyStringSlice(target.Platforms)}
|
||||
}
|
||||
|
||||
func autonomousDeploymentFromDefinition(definition domain.ServerDeploymentDefinition, profileKey string, bindings map[string]string) *domain.RunAutonomousDeployment {
|
||||
if definition.Mode == "" {
|
||||
return nil
|
||||
|
||||
@@ -101,9 +101,24 @@ func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) {
|
||||
domain.RuntimeLogSource{Key: "console", Kind: "process.stdout", TargetKey: "server/process", StreamKey: "console", CursorKind: "sequence", RetentionDays: 14},
|
||||
domain.RuntimeLogSource{Key: "server-events", Kind: "file.tail", TargetKey: "logs/server", StreamKey: "scum.server", CursorKind: "fingerprint", RetentionDays: 90},
|
||||
)
|
||||
plugin.RuntimeProfiles.DataTargets = []domain.RuntimeDataTarget{{
|
||||
Key: "scum-database",
|
||||
Kind: "sqlite.snapshot",
|
||||
TransportKey: "scum-database",
|
||||
SourceRootKey: "server-root",
|
||||
SourcePath: "SCUM/Saved/SaveFiles/SCUM.db",
|
||||
WorkspaceKey: "databases/scum-database",
|
||||
RefreshPolicy: "on-demand-snapshot",
|
||||
MaxBytes: 1024 * 1024,
|
||||
Platforms: []string{"linux"},
|
||||
}}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("seed plugin lifecycle assets: %v", err)
|
||||
}
|
||||
instance.Deployment.ServerRoot = "/srv/scum"
|
||||
if err := svc.store.ServerInstances().Update(instance); err != nil {
|
||||
t.Fatalf("seed instance deployment root: %v", err)
|
||||
}
|
||||
inputs := make(chan domain.DistributionBuildInput, 1)
|
||||
release := make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
@@ -149,6 +164,9 @@ func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) {
|
||||
if len(plan.DependencyProbes) != 1 || plan.DependencyProbes[0].Key != "java-runtime" || len(plan.InstallPlans) != 1 || plan.InstallPlans[0].Key != "java-install" || len(plan.LogSources) != 1 || !hasAutonomousLogSource(plan.LogSources, "process.stdout", "console") || plan.RuntimeBindings["logs/latest"] != "runtime.logs.latest" {
|
||||
t.Fatalf("autonomous lifecycle plan lost plugin runtime declarations: %+v", plan)
|
||||
}
|
||||
if len(plan.DataTargets) != 1 || plan.DataTargets[0].Key != "scum-database" || plan.DataTargets[0].Kind != "sqlite.snapshot" || plan.DataTargets[0].SourcePath != "SCUM/Saved/SaveFiles/SCUM.db" || plan.RuntimeBindings["server-root"] != instance.Deployment.ServerRoot {
|
||||
t.Fatalf("autonomous lifecycle plan lost private database target bindings: %+v", plan)
|
||||
}
|
||||
if hasAutonomousLogSource(plan.LogSources, "file.tail", "latest-log") || hasAutonomousLogSource(plan.LogSources, "file.tail", "scum.server") {
|
||||
t.Fatalf("autonomous lifecycle plan must not carry file-tail sources into process.start: %+v", plan.LogSources)
|
||||
}
|
||||
@@ -214,55 +232,6 @@ func hasAutonomousLogSource(sources []domain.RunAutonomousLogSource, kind string
|
||||
return false
|
||||
}
|
||||
|
||||
func TestCoreServiceRunDistributionDefaultsEmptyDeploymentProfileToPluginLifecycleProfile(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
plugin := scumDeploymentTestPlugin()
|
||||
plugin.Name = "SCUM"
|
||||
plugin.Version = "0.1.1"
|
||||
plugin.ServerType = "scum"
|
||||
plugin.Status = domain.GamePluginStatusInstalled
|
||||
plugin.SupportedOS = []string{"windows"}
|
||||
plugin.DeclaredPermissions = []string{"server.run.distribution"}
|
||||
plugin.LifecycleActions = domain.PluginLifecycleActions{Install: "actions/install.json", Start: "actions/start.json", Stop: "actions/stop.json", Status: "actions/status.json"}
|
||||
plugin.RuntimeProfiles.LifecycleProfiles = []domain.RuntimeLifecycleProfile{{Key: "run-local", Mode: "local-process", Capabilities: []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, domain.LifecycleCapabilityStatus}, ActionRefs: domain.PluginLifecycleActions{Install: "actions/install.json", Start: "actions/start.json", Stop: "actions/stop.json", Status: "actions/status.json"}, Platforms: []string{"windows"}}}
|
||||
requireManualSCUMRuntimeBindings(&plugin, "run-local")
|
||||
if err := svc.store.GamePlugins().Create(plugin); err != nil {
|
||||
t.Fatalf("create SCUM plugin: %v", err)
|
||||
}
|
||||
session := createServiceUserAndLogin(t, svc, domain.User{ID: "scum-default-profile-owner", DisplayName: "SCUM Default Profile Owner", Email: "scum-default-profile@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
||||
created, err := svc.CreateServerInstanceWorkflowForSession(session, domain.ServerLifecycleCreate{
|
||||
ID: "scum-default-profile-package", PluginID: plugin.ID, Name: "SCUM Default Profile Package", IdempotencyKey: "scum-default-profile-package-create",
|
||||
Deployment: domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided, ServerRoot: `D:\scum-default-profile`, CreateInputs: map[string]string{"serverName": "Moon", "gamePort": "27000", "queryPort": "27015", "maxPlayers": "128"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create SCUM guided draft without explicit profile: %v", err)
|
||||
}
|
||||
if created.Instance.Deployment.ProfileKey != "run-local" {
|
||||
t.Fatalf("expected create defaults to persist plugin lifecycle profile, got %+v", created.Instance.Deployment)
|
||||
}
|
||||
|
||||
legacy := created.Instance
|
||||
legacy.Deployment.ProfileKey = ""
|
||||
if err := svc.store.ServerInstances().Update(legacy); err != nil {
|
||||
t.Fatalf("simulate legacy empty deployment profile: %v", err)
|
||||
}
|
||||
inputs := make(chan domain.DistributionBuildInput, 1)
|
||||
svc.ConfigureDistributionBuilder(captureDistributionBuilder{inputs: inputs, payload: []byte("profile-default-build")})
|
||||
if _, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{ServerInstanceID: legacy.ID, TargetOS: "windows", TargetArch: "amd64", IdempotencyKey: "scum-default-profile-package"}); err != nil {
|
||||
t.Fatalf("generate Run distribution with empty deployment profile: %v", err)
|
||||
}
|
||||
var platformInput domain.DistributionBuildInput
|
||||
select {
|
||||
case platformInput = <-inputs:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("platform builder did not receive profile-defaulted input")
|
||||
}
|
||||
plan := platformInput.AutonomousLifecycle
|
||||
if platformInput.ProfileKey != "run-local" || plan == nil || plan.ProfileKey != "run-local" || plan.Deployment == nil || plan.Deployment.ProfileKey != "run-local" {
|
||||
t.Fatalf("expected package context to default empty deployment profile to run-local, input=%+v plan=%+v", platformInput, plan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceBuildsWithoutRegisteredDistributionWorker(t *testing.T) {
|
||||
svc, session, instance := newDistributionTestFixture(t)
|
||||
bootstrapEndpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
|
||||
|
||||
@@ -96,30 +96,23 @@ func (svc *CoreService) GetDistributionBuildInput(request domain.DistributionBui
|
||||
if err != nil {
|
||||
return domain.DistributionBuildInput{}, err
|
||||
}
|
||||
profile, err := svc.clientManagerDistributionBuildProfile(distribution)
|
||||
if err != nil {
|
||||
return domain.DistributionBuildInput{}, err
|
||||
}
|
||||
return domain.DistributionBuildInput{
|
||||
JobID: job.ID,
|
||||
ComponentKind: domain.DistributionComponentClientManager,
|
||||
ServerInstanceID: distribution.ServerInstanceID,
|
||||
PluginID: distribution.PluginID,
|
||||
RunEndpointID: job.RunEndpointID,
|
||||
ProfileKey: distribution.ProfileKey,
|
||||
TargetOS: distribution.TargetOS,
|
||||
TargetArch: distribution.TargetArch,
|
||||
PackageFormat: packageFormatForTarget(distribution.TargetOS),
|
||||
RepositoryURL: distribution.RepositoryURL,
|
||||
SourceRevision: distribution.SourceRevision,
|
||||
WorkspaceRef: profile.WorkspaceRef,
|
||||
EntryRef: profile.EntryRef,
|
||||
ConfigTemplateRef: clientManagerBuildConfigTemplateRef(profile),
|
||||
ArtifactID: distribution.ArtifactID,
|
||||
OutputFilename: clientManagerOutputName(distribution.ProfileKey, distribution.TargetOS),
|
||||
SecretRef: distribution.SecretRef,
|
||||
KeyGeneration: distribution.KeyGeneration,
|
||||
AuthKey: plainKey,
|
||||
JobID: job.ID,
|
||||
ComponentKind: domain.DistributionComponentClientManager,
|
||||
ServerInstanceID: distribution.ServerInstanceID,
|
||||
PluginID: distribution.PluginID,
|
||||
RunEndpointID: job.RunEndpointID,
|
||||
ProfileKey: distribution.ProfileKey,
|
||||
TargetOS: distribution.TargetOS,
|
||||
TargetArch: distribution.TargetArch,
|
||||
PackageFormat: packageFormatForTarget(distribution.TargetOS),
|
||||
RepositoryURL: distribution.RepositoryURL,
|
||||
SourceRevision: distribution.SourceRevision,
|
||||
ArtifactID: distribution.ArtifactID,
|
||||
OutputFilename: clientManagerOutputName(distribution.ProfileKey, distribution.TargetOS),
|
||||
SecretRef: distribution.SecretRef,
|
||||
KeyGeneration: distribution.KeyGeneration,
|
||||
AuthKey: plainKey,
|
||||
}, nil
|
||||
}
|
||||
return domain.DistributionBuildInput{}, repo.ErrNotFound
|
||||
|
||||
@@ -508,7 +508,7 @@ EOF
|
||||
ldflags="$ldflags -X browser.local/run/config.BuildServerInstanceID=$SERVER_INSTANCE_ID"
|
||||
ldflags="$ldflags -X browser.local/run/config.BuildPluginID=$PLUGIN_ID"
|
||||
ldflags="$ldflags -X browser.local/run/config.BuildComponentKind=$COMPONENT_KIND"
|
||||
ldflags="$ldflags -X browser.local/run/config.BuildComponentKey="
|
||||
ldflags="$ldflags -X browser.local/run/config.BuildComponentKey=$PROFILE_KEY"
|
||||
ldflags="$ldflags -X browser.local/run/config.BuildKeyGeneration=$KEY_GENERATION"
|
||||
ldflags="$ldflags -X browser.local/run/config.BuildVersion=$TARGET_RELEASE"
|
||||
progress 48 'build_compile: downloading Go modules'
|
||||
@@ -533,39 +533,20 @@ git init --quiet
|
||||
git remote add origin "$REPOSITORY_URL"
|
||||
git fetch --quiet --depth 1 origin "$SOURCE_REVISION"
|
||||
git checkout --quiet --detach FETCH_HEAD
|
||||
workspace_ref="${CLIENT_MANAGER_WORKSPACE_REF:-}"
|
||||
entry_ref="${CLIENT_MANAGER_ENTRY_REF:-.}"
|
||||
config_template_ref="${CLIENT_MANAGER_CONFIG_TEMPLATE_REF:-config.yaml.example}"
|
||||
for safe_ref in "$workspace_ref" "$entry_ref" "$config_template_ref"; do
|
||||
case "$safe_ref" in
|
||||
*..*|*://*|/*|*\\*) printf 'client-manager build reference is unsafe\n' >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
build_workdir=/workspace/build
|
||||
if [ -n "$workspace_ref" ]; then
|
||||
build_workdir="/workspace/build/$workspace_ref"
|
||||
fi
|
||||
if [ ! -d "$build_workdir" ]; then
|
||||
printf 'client-manager workspace is missing\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
cd "$build_workdir"
|
||||
if [ ! -f "$config_template_ref" ]; then
|
||||
printf 'client-manager config template is missing\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
progress 36 'env_check: materializing client-manager configuration template'
|
||||
cp "$config_template_ref" config.yaml
|
||||
build_target="$entry_ref"
|
||||
case "$build_target" in
|
||||
''|.) build_target=. ;;
|
||||
./*) ;;
|
||||
*) build_target="./$build_target" ;;
|
||||
esac
|
||||
progress 36 'env_check: writing client-manager configuration'
|
||||
{
|
||||
printf 'server_url: "%s"\n' "$PLATFORM_URL"
|
||||
printf 'server_instance_id: "%s"\n' "$SERVER_INSTANCE_ID"
|
||||
printf 'scum_client_credential: "%s"\n' "$auth_key"
|
||||
printf 'scum_client_name: "%s"\n' "$PROFILE_KEY"
|
||||
printf 'scum_client_version: "platform-build"\n'
|
||||
printf 'scum_client_machine_label: "managed-client"\n'
|
||||
printf 'ftp_provider: 3\n'
|
||||
} > config.yaml
|
||||
progress 52 'deps_download: downloading Go modules'
|
||||
go mod download
|
||||
progress 76 'build_compile: compiling client-manager executable'
|
||||
go build -trimpath -ldflags '-s -w' -o "/workspace/output/$OUTPUT_FILENAME" "$build_target"
|
||||
go build -trimpath -ldflags '-s -w' -o "/workspace/output/$OUTPUT_FILENAME" .
|
||||
cp config.yaml /workspace/output/config.yaml
|
||||
progress 88 'package_finalize: client-manager package inputs written'
|
||||
`
|
||||
@@ -601,9 +582,6 @@ func (builder *DockerDistributionBuilder) containerArgs(input domain.Distributio
|
||||
"-e", "PLATFORM_URL=" + platformURL,
|
||||
"-e", "REPOSITORY_URL=" + input.RepositoryURL,
|
||||
"-e", "SOURCE_REVISION=" + input.SourceRevision,
|
||||
"-e", "CLIENT_MANAGER_WORKSPACE_REF=" + input.WorkspaceRef,
|
||||
"-e", "CLIENT_MANAGER_ENTRY_REF=" + input.EntryRef,
|
||||
"-e", "CLIENT_MANAGER_CONFIG_TEMPLATE_REF=" + input.ConfigTemplateRef,
|
||||
"-e", "OUTPUT_FILENAME=" + outputName,
|
||||
builder.config.Image,
|
||||
"/workspace/input/build.sh",
|
||||
|
||||
@@ -203,9 +203,6 @@ func TestDockerDistributionBuilderKeepsSecretInIsolatedInput(t *testing.T) {
|
||||
if bytes.Contains(script, []byte(secret)) {
|
||||
t.Fatal("build script must not embed the component auth key")
|
||||
}
|
||||
if bytes.Contains(script, []byte("BuildComponentKey=$PROFILE_KEY")) || !bytes.Contains(script, []byte("BuildComponentKey=")) {
|
||||
t.Fatalf("Run build script must not use lifecycle profile as component identity: %s", script)
|
||||
}
|
||||
for _, ordered := range [][2]string{{"find /workspace/source -mindepth 1 -maxdepth 1 ! -name pax_global_header", "run_command_dir=\"$(find /workspace/build/run-source -type d -path '*/cmd/run' -print -quit)\""}, {"run_command_dir=\"$(find /workspace/build/run-source -type d -path '*/cmd/run' -print -quit)\"", "run_module_dir=\"${run_command_dir%/cmd/run}\""}, {"run_module_dir=\"${run_command_dir%/cmd/run}\"", "mkdir -p \"$run_module_dir/config\""}, {"mkdir -p \"$run_module_dir/config\"", "workspace_seed_generated.go"}, {"workspace_seed_generated.go", "cd \"$run_module_dir\""}, {"cd \"$run_module_dir\"", "go build -trimpath -ldflags"}} {
|
||||
if bytes.Index(script, []byte(ordered[0])) >= bytes.Index(script, []byte(ordered[1])) {
|
||||
t.Fatalf("Run build script must prepare source before injecting config and compiling: %q before %q", ordered[0], ordered[1])
|
||||
@@ -230,7 +227,6 @@ func TestDockerDistributionBuilderKeepsSecretInIsolatedInput(t *testing.T) {
|
||||
ServerInstanceID: "server-one",
|
||||
PluginID: "game.scum",
|
||||
RunEndpointID: "server-run-server-one",
|
||||
ProfileKey: "run-local",
|
||||
TargetOS: "windows",
|
||||
TargetArch: "amd64",
|
||||
TargetRelease: "release-one",
|
||||
|
||||
@@ -533,7 +533,7 @@ func TestCoreServiceBuildsClientManagerWithDistinctKeyAndRedactsSensitiveOperati
|
||||
ProfileKey: "scum-client-manager",
|
||||
TargetOS: "windows",
|
||||
TargetArch: "amd64",
|
||||
RepositoryURL: "https://git.npc0.com/admin343/browser.git",
|
||||
RepositoryURL: "https://github.com/F88888/scum_client.git",
|
||||
SourceRevision: "main",
|
||||
IdempotencyKey: "idem-client-manager",
|
||||
})
|
||||
@@ -551,7 +551,7 @@ func TestCoreServiceBuildsClientManagerWithDistinctKeyAndRedactsSensitiveOperati
|
||||
if err != nil {
|
||||
t.Fatalf("get build job: %v", err)
|
||||
}
|
||||
if build.Status != domain.DistributionJobStatusQueued || build.RepositoryURL != "https://git.npc0.com/admin343/browser.git" || build.SourceRevision != "main" {
|
||||
if build.Status != domain.DistributionJobStatusQueued || build.RepositoryURL != "https://github.com/F88888/scum_client.git" || build.SourceRevision != "main" {
|
||||
t.Fatalf("unexpected build job: %+v", build)
|
||||
}
|
||||
clientDistribution = completeClientDistributionBuild(t, svc, clientDistribution, []byte("compiled client archive"))
|
||||
@@ -565,7 +565,7 @@ func TestCoreServiceBuildsClientManagerWithDistinctKeyAndRedactsSensitiveOperati
|
||||
ProfileKey: "scum-client-manager",
|
||||
TargetOS: "darwin",
|
||||
TargetArch: "amd64",
|
||||
RepositoryURL: "https://git.npc0.com/admin343/browser.git",
|
||||
RepositoryURL: "https://github.com/F88888/scum_client.git",
|
||||
IdempotencyKey: "idem-client-manager-denied",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "targetOs") {
|
||||
@@ -625,7 +625,7 @@ func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.Serv
|
||||
plugin.RuntimeProfiles.DependencyProbes = []domain.RuntimeDependencyProbe{{Key: "java-runtime", Kind: "command.version", TargetKey: "java", Platforms: []string{"linux"}}}
|
||||
plugin.RuntimeProfiles.InstallPlans = []domain.RuntimeInstallPlan{{Key: "java-install", Title: "Install Java", Platforms: []string{"linux"}, Steps: []domain.RuntimeInstallStep{{Type: "package", TargetKey: "java", PackageManager: "apt", PackageName: "openjdk-21-jre"}}}}
|
||||
plugin.RuntimeProfiles.LogSources = []domain.RuntimeLogSource{{Key: "latest", Kind: "file.tail", TargetKey: "logs/latest", StreamKey: "latest-log", CursorKind: "offset", RetentionDays: 30}}
|
||||
plugin.RuntimeProfiles.ClientManagers = []domain.RuntimeClientManagerProfile{{Key: "scum-client-manager", DisplayName: "SCUM Client Manager", Version: "1.0.0", RepositoryURL: "https://git.npc0.com/admin343/browser.git", RevisionPolicy: "branch", Branch: "main", SupportedTargets: []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}, BuildSystem: "go", WorkspaceRef: "plugins/examples/scum-server-plugin/companion", EntryRef: "cmd/scum-companion", OutputArtifacts: []string{"scum_client.exe"}, Deployment: domain.RuntimeClientManagerDeployment{Mode: "run-supervised", ExecutableRef: "scum_client.exe", RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: domain.RuntimeClientManagerLifecycle{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: domain.RuntimeClientManagerHealth{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health"}}, Compatibility: domain.RuntimeClientManagerCompatibility{MinimumVersion: "1.0.0"}, UpdatePolicy: domain.RuntimeClientManagerUpdatePolicy{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}}
|
||||
plugin.RuntimeProfiles.ClientManagers = []domain.RuntimeClientManagerProfile{{Key: "scum-client-manager", DisplayName: "SCUM Client Manager", Version: "1.0.0", RepositoryURL: "https://github.com/F88888/scum_client.git", RevisionPolicy: "branch", Branch: "main", SupportedTargets: []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}, BuildSystem: "go", EntryRef: "main.go", OutputArtifacts: []string{"scum_client.exe"}, Deployment: domain.RuntimeClientManagerDeployment{Mode: "run-supervised", ExecutableRef: "scum_client.exe", RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: domain.RuntimeClientManagerLifecycle{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: domain.RuntimeClientManagerHealth{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health"}}, Compatibility: domain.RuntimeClientManagerCompatibility{MinimumVersion: "1.0.0"}, UpdatePolicy: domain.RuntimeClientManagerUpdatePolicy{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update plugin fixture: %v", err)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
func seedGameClientBridgeComponentSession(t *testing.T, svc *CoreService, now time.Time, token string) (domain.ClientManagerInstallation, domain.ClientManagerSession) {
|
||||
t.Helper()
|
||||
installation := domain.ClientManagerInstallation{ID: "installation-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", RunEndpointID: "run-1", Status: domain.ClientManagerLifecycleOnline, ActiveArtifactID: "artifact-1", KeyGeneration: 2, DeploymentGeneration: 3}
|
||||
session := domain.ClientManagerSession{ID: "component-session-1", InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, RunEndpointID: installation.RunEndpointID, ArtifactID: installation.ActiveArtifactID, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, TokenHash: tokenHash(token), Capabilities: []string{"component.heartbeat", gameClientBridgeCapability, gameClientBridgeLogStreamCapability}, Status: domain.ClientManagerSessionActive, ExpiresAt: now.Add(time.Hour)}
|
||||
session := domain.ClientManagerSession{ID: "component-session-1", InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, RunEndpointID: installation.RunEndpointID, ArtifactID: installation.ActiveArtifactID, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, TokenHash: tokenHash(token), Capabilities: []string{"component.heartbeat", gameClientBridgeCapability}, Status: domain.ClientManagerSessionActive, ExpiresAt: now.Add(time.Hour)}
|
||||
key := domain.EncryptedComponentKey{ID: "key-1", ServerInstanceID: installation.ServerInstanceID, ComponentKind: domain.DistributionComponentClientManager, ComponentKey: installation.ProfileKey, Generation: installation.KeyGeneration, Status: domain.ComponentKeyStatusActive}
|
||||
if err := svc.store.ClientManagerInstallations().Create(installation); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -80,34 +80,6 @@ func TestGameClientBridgeComponentSessionAuthorizesCommandsAndSnapshots(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeComponentSessionAuthorizesLiveLogStream(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
const token = "component-session-token"
|
||||
_, session := seedGameClientBridgeComponentSession(t, svc, *clock, token)
|
||||
if err := svc.store.RunEndpoints().Create(domain.RunEndpoint{ID: session.RunEndpointID, DisplayName: "Component Run", Status: domain.RunEndpointStatusOnline, Capabilities: []string{"process.start", "logs.read"}, Capacity: domain.RunCapacity{MaxJobs: 4}, LastHeartbeatAt: *clock}); err != nil {
|
||||
t.Fatalf("seed run endpoint: %v", err)
|
||||
}
|
||||
server := domain.ServerInstance{ID: session.ServerInstanceID, PluginID: "game.scum", PluginVersion: "1.0.0", RunEndpointID: session.RunEndpointID, Name: "SCUM"}
|
||||
if err := svc.store.ServerInstances().Create(server); err != nil {
|
||||
t.Fatalf("create server instance: %v", err)
|
||||
}
|
||||
instance, err := svc.AuthorizeGameClientBridgeLogStream(domain.GameClientBridgeLogStreamRequest{SessionToken: token})
|
||||
if err != nil || instance.ID != server.ID || instance.PluginID != server.PluginID {
|
||||
t.Fatalf("authorize companion log stream: instance=%#v err=%v", instance, err)
|
||||
}
|
||||
if instance.RunEndpointID != session.RunEndpointID {
|
||||
t.Fatalf("authorized stream was not bound to component server: %#v", instance)
|
||||
}
|
||||
|
||||
session.Capabilities = []string{"component.heartbeat", gameClientBridgeCapability}
|
||||
if err := svc.store.ClientManagerSessions().Update(session); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.AuthorizeGameClientBridgeLogStream(domain.GameClientBridgeLogStreamRequest{SessionToken: token}); !errors.Is(err, ErrForbidden) {
|
||||
t.Fatalf("expected missing logs.stream rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeClaimCannotBeCompletedByAnotherCurrentSession(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
const firstToken = "component-session-token-one"
|
||||
|
||||
@@ -12,7 +12,7 @@ func newGameClientBridgeService(t *testing.T) (*CoreService, *time.Time) {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
|
||||
store := repo.NewMemoryStore()
|
||||
plugin := domain.GamePlugin{ID: "game.scum", Name: "SCUM", Version: "1.0.0", ServerType: "scum", RuntimeProfiles: domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{{Key: "scum-client", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{gameClientBridgeCapability, gameClientBridgeLogStreamCapability}}}}}, GameClientBridge: domain.GameClientBridgeManifest{Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "diagnostic.ping", TimeoutSeconds: 600, MaxPayloadBytes: 4096}}, Snapshots: []domain.GameClientBridgeSnapshotDeclaration{{Type: "players", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}, {Type: "health", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 60}}, {Type: "companion.health", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}}}
|
||||
plugin := domain.GamePlugin{ID: "game.scum", RuntimeProfiles: domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{{Key: "scum-client", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{gameClientBridgeCapability}}}}}, GameClientBridge: domain.GameClientBridgeManifest{Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "diagnostic.ping", TimeoutSeconds: 600, MaxPayloadBytes: 4096}}, Snapshots: []domain.GameClientBridgeSnapshotDeclaration{{Type: "players", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}, {Type: "health", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 60}}, {Type: "companion.health", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}}}
|
||||
if err := store.GamePlugins().Create(plugin); err != nil {
|
||||
t.Fatalf("seed bridge plugin: %v", err)
|
||||
}
|
||||
|
||||
@@ -50,7 +50,17 @@ func (svc *CoreService) ClaimRunJob(claim domain.RunJobClaim) (domain.RunJobClai
|
||||
}
|
||||
job, ok := firstEligibleSupportedJob(jobs, claim.Capabilities, stamp)
|
||||
if !ok {
|
||||
return emptyJobClaim(claim.RunEndpointID, stamp), nil
|
||||
if err := svc.scheduleDuePluginQueryProjectionJobs(claim, stamp); err != nil {
|
||||
return domain.RunJobClaimResult{}, err
|
||||
}
|
||||
jobs, err = svc.store.Jobs().List(domain.JobFilter{RunEndpointID: claim.RunEndpointID})
|
||||
if err != nil {
|
||||
return domain.RunJobClaimResult{}, err
|
||||
}
|
||||
job, ok = firstEligibleSupportedJob(jobs, claim.Capabilities, stamp)
|
||||
if !ok {
|
||||
return emptyJobClaim(claim.RunEndpointID, stamp), nil
|
||||
}
|
||||
}
|
||||
|
||||
leaseToken, err := randomToken()
|
||||
@@ -343,6 +353,9 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo
|
||||
if err := svc.projectPluginOperationsJobResult(job, stamp); err != nil {
|
||||
return domain.RunJobResultResult{}, err
|
||||
}
|
||||
if err := svc.projectPluginQueryJobResult(job, stamp); err != nil {
|
||||
return domain.RunJobResultResult{}, err
|
||||
}
|
||||
return domain.RunJobResultResult{Accepted: true, Job: assignmentFromJob(job, result.LeaseToken), ServerTime: stamp}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ const (
|
||||
type LogEventSubscriptionEvent struct {
|
||||
Kind LogEventSubscriptionEventKind
|
||||
LogEvent domain.LogStreamEvent
|
||||
Live bool
|
||||
ServerInstanceID string
|
||||
ProcessState domain.ServerInstanceState
|
||||
}
|
||||
@@ -67,14 +66,6 @@ func (svc *CoreService) SubscribeLogEventsForSession(sessionID string, serverIns
|
||||
}
|
||||
|
||||
func (svc *CoreService) publishLogEvents(stream domain.LogStream, entries []domain.LogEntry) {
|
||||
svc.publishLogEventsWithMode(stream, entries, false)
|
||||
}
|
||||
|
||||
func (svc *CoreService) publishLiveLogEvents(stream domain.LogStream, entries []domain.LogEntry) {
|
||||
svc.publishLogEventsWithMode(stream, entries, true)
|
||||
}
|
||||
|
||||
func (svc *CoreService) publishLogEventsWithMode(stream domain.LogStream, entries []domain.LogEntry, live bool) {
|
||||
if len(entries) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -82,7 +73,6 @@ func (svc *CoreService) publishLogEventsWithMode(stream domain.LogStream, entrie
|
||||
for index, entry := range entries {
|
||||
events[index] = LogEventSubscriptionEvent{
|
||||
Kind: LogEventSubscriptionEventLog,
|
||||
Live: live,
|
||||
LogEvent: domain.CopyLogStreamEvent(domain.LogStreamEvent{
|
||||
ServerInstanceID: stream.ServerInstanceID,
|
||||
Stream: stream,
|
||||
|
||||
@@ -12,65 +12,6 @@ import (
|
||||
|
||||
const defaultLogQueryLimit = 100
|
||||
|
||||
// RelayLiveLogBatch forwards output observed by Run to live subscribers. It
|
||||
// intentionally updates only stream metadata; the log body is not written to
|
||||
// the platform log store. Game plugins own durable log storage and analysis.
|
||||
func (svc *CoreService) RelayLiveLogBatch(batch domain.LogBatchIngest) (domain.LogBatchIngestResult, error) {
|
||||
batch = domain.CopyLogBatchIngest(batch)
|
||||
if err := validator.ValidateLogBatchIngest(batch); err != nil {
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
if err := svc.validateRunSession(batch.RunEndpointID, batch.SessionToken); err != nil {
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
|
||||
lock := svc.logIngestLock(batch.ServerInstanceID)
|
||||
lock.Lock()
|
||||
stamp := svc.now()
|
||||
stream, err := svc.store.LogStreams().Get(batch.LogStreamID)
|
||||
if errors.Is(err, repo.ErrNotFound) {
|
||||
if repairErr := svc.ensureLogStreamForBatch(batch, stamp); repairErr != nil {
|
||||
lock.Unlock()
|
||||
return domain.LogBatchIngestResult{}, repairErr
|
||||
}
|
||||
stream, err = svc.store.LogStreams().Get(batch.LogStreamID)
|
||||
}
|
||||
if err != nil {
|
||||
lock.Unlock()
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
if err := validateLogBatchStream(batch, stream); err != nil {
|
||||
lock.Unlock()
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
if batch.LastSeq > stream.LatestSeq {
|
||||
stream.LatestSeq = batch.LastSeq
|
||||
}
|
||||
stream.UpdatedAt = stamp
|
||||
if err := svc.store.LogStreams().Update(stream); err != nil {
|
||||
lock.Unlock()
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
lock.Unlock()
|
||||
|
||||
entries := domain.CopyLogEntries(batch.Entries)
|
||||
// Run may be on a machine whose wall clock is skewed. Relay time is the
|
||||
// authoritative observation time for this best-effort live event; using it
|
||||
// keeps the SSE live boundary from treating current output as old history.
|
||||
for index := range entries {
|
||||
entries[index].Timestamp = stamp.Add(time.Duration(index) * time.Nanosecond)
|
||||
}
|
||||
svc.publishLiveLogEvents(stream, entries)
|
||||
return domain.LogBatchIngestResult{
|
||||
Accepted: true,
|
||||
LogStreamID: batch.LogStreamID,
|
||||
AcceptedFrom: batch.FirstSeq,
|
||||
AcceptedTo: batch.LastSeq,
|
||||
LatestSeq: stream.LatestSeq,
|
||||
ServerTime: stamp,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogBatchIngestResult, error) {
|
||||
batch = domain.CopyLogBatchIngest(batch)
|
||||
if err := validator.ValidateLogBatchIngest(batch); err != nil {
|
||||
@@ -110,9 +51,6 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
|
||||
if exists && record.LastSeq == batch.LastSeq && logBatchRecordMatches(record, batch) {
|
||||
locked = false
|
||||
lock.Unlock()
|
||||
if err := svc.projectPluginLogBatch(stream, storedLogEntries(batch.Entries)); err != nil {
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
return domain.LogBatchIngestResult{
|
||||
Accepted: true,
|
||||
LogStreamID: batch.LogStreamID,
|
||||
@@ -130,7 +68,6 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
|
||||
}
|
||||
|
||||
storedBatch := domain.CopyLogBatchIngest(batch)
|
||||
sanitizeLogNetworkFields(&storedBatch)
|
||||
record := domain.CopyLogBatchRecord(domain.LogBatchRecord{
|
||||
Checksum: batch.Checksum,
|
||||
FirstSeq: batch.FirstSeq,
|
||||
@@ -147,9 +84,6 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
|
||||
}
|
||||
locked = false
|
||||
lock.Unlock()
|
||||
if err := svc.projectPluginLogBatch(stream, storedBatch.Entries); err != nil {
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
svc.publishLogEvents(stream, storedBatch.Entries)
|
||||
return domain.LogBatchIngestResult{
|
||||
Accepted: true,
|
||||
@@ -162,10 +96,7 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
|
||||
}
|
||||
|
||||
func storedLogEntries(entries []domain.LogEntry) []domain.LogEntry {
|
||||
stored := domain.CopyLogEntries(entries)
|
||||
batch := domain.LogBatchIngest{Entries: stored}
|
||||
sanitizeLogNetworkFields(&batch)
|
||||
return batch.Entries
|
||||
return domain.CopyLogEntries(entries)
|
||||
}
|
||||
|
||||
func (svc *CoreService) ensureJobLogStreamForBatch(batch domain.LogBatchIngest, stamp time.Time) error {
|
||||
@@ -209,7 +140,7 @@ func (svc *CoreService) ensureRunLogStreamForBatch(batch domain.LogBatchIngest,
|
||||
expectedStreamID = runSessionLogStreamID(batch.RunEndpointID, batch.ServerInstanceID, batch.LogSessionID, batch.StreamKey)
|
||||
}
|
||||
if batch.LogStreamID != expectedStreamID {
|
||||
if !legacySessionRunLogStream(batch) && !legacyAutonomousLogStream(batch) {
|
||||
if batch.LogSessionID != "" || !legacyAutonomousLogStream(batch) {
|
||||
return repo.ErrNotFound
|
||||
}
|
||||
}
|
||||
@@ -227,10 +158,6 @@ func (svc *CoreService) ensureRunLogStreamForBatch(batch domain.LogBatchIngest,
|
||||
return err
|
||||
}
|
||||
|
||||
func legacySessionRunLogStream(batch domain.LogBatchIngest) bool {
|
||||
return batch.LogSessionID != "" && batch.LogStreamID == runLogStreamID(batch.RunEndpointID, batch.ServerInstanceID, batch.StreamKey)
|
||||
}
|
||||
|
||||
func legacyAutonomousLogStream(batch domain.LogBatchIngest) bool {
|
||||
jobID, ok := jobIDFromLogBatch(batch)
|
||||
return ok && strings.HasPrefix(jobID, "autonomous-")
|
||||
@@ -260,19 +187,6 @@ func logBatchRecordMatches(record domain.LogBatchRecord, batch domain.LogBatchIn
|
||||
return false
|
||||
}
|
||||
|
||||
// sanitizeLogNetworkFields removes raw network material before the durable log body is written.
|
||||
func sanitizeLogNetworkFields(batch *domain.LogBatchIngest) {
|
||||
for index := range batch.Entries {
|
||||
fields := batch.Entries[index].Fields
|
||||
if fields == nil {
|
||||
continue
|
||||
}
|
||||
delete(fields, "networkFingerprint")
|
||||
delete(fields, "ip")
|
||||
delete(fields, "ipAddress")
|
||||
}
|
||||
}
|
||||
|
||||
func (svc *CoreService) QueryLogStream(query domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error) {
|
||||
if err := validator.ValidateLogStreamCursorQuery(query); err != nil {
|
||||
return domain.LogStreamCursorResult{}, err
|
||||
|
||||
@@ -92,45 +92,6 @@ func TestCoreServicePublishesLogEventsForAcceptedBatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceRelaysLiveBatchWithoutPersistingBody(t *testing.T) {
|
||||
svc, sessionToken := newRegisteredLogIngestService(t)
|
||||
createLogStreamFixture(t, svc)
|
||||
subscription, err := svc.SubscribeLogEvents("server-1")
|
||||
if err != nil {
|
||||
t.Fatalf("subscribe log events: %v", err)
|
||||
}
|
||||
defer subscription.Close()
|
||||
|
||||
batch := validLogBatch(t, sessionToken, 1, 1)
|
||||
batch.Entries[0].Timestamp = time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
batch.Checksum, err = validator.LogEntriesChecksum(batch.Entries)
|
||||
if err != nil {
|
||||
t.Fatalf("checksum live batch: %v", err)
|
||||
}
|
||||
ack, err := svc.RelayLiveLogBatch(batch)
|
||||
if err != nil || !ack.Accepted || ack.LatestSeq != 1 {
|
||||
t.Fatalf("relay live batch: ack=%+v err=%v", ack, err)
|
||||
}
|
||||
query, err := svc.QueryLogStream(domain.LogStreamCursorQuery{LogStreamID: batch.LogStreamID, AfterSeq: 0, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("query relayed log stream: %v", err)
|
||||
}
|
||||
if len(query.Entries) != 0 || query.LatestSeq != 1 {
|
||||
t.Fatalf("live relay wrote a platform log body: %+v", query)
|
||||
}
|
||||
select {
|
||||
case event := <-subscription.Events:
|
||||
if !event.Live {
|
||||
t.Fatalf("expected live relay event marker: %+v", event)
|
||||
}
|
||||
if event.LogEvent.Entry.Timestamp.Before(fixedTime) {
|
||||
t.Fatalf("live relay kept stale source timestamp: %+v", event.LogEvent.Entry)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected relayed live log event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServicePersistsAndEnforcesImmutableProcessLogSessionMetadata(t *testing.T) {
|
||||
svc, sessionToken := newRegisteredLogIngestService(t)
|
||||
startedAt := time.Date(2026, 7, 3, 12, 30, 0, 0, time.UTC)
|
||||
@@ -480,47 +441,14 @@ func TestCoreServiceAcceptsLegacyAutonomousJobLogStreamWithoutPlatformJob(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceAcceptsSessionMetadataOnLegacyAutonomousStreamID(t *testing.T) {
|
||||
func TestCoreServiceRejectsSessionMetadataOnLegacyAutonomousStreamID(t *testing.T) {
|
||||
svc, sessionToken := newRegisteredLogIngestService(t)
|
||||
batch := validLogBatch(t, sessionToken, 1, 1)
|
||||
batch.LogStreamID = jobLogStreamID("autonomous-bootstrap-start", "stdout")
|
||||
batch.LogSessionID = "session-a"
|
||||
batch.SessionStartedAt = time.Date(2026, 7, 3, 12, 30, 0, 0, time.UTC)
|
||||
ack, err := svc.IngestLogBatch(batch)
|
||||
if err != nil {
|
||||
t.Fatalf("ingest session-scoped legacy autonomous stream: %v", err)
|
||||
}
|
||||
if !ack.Accepted || ack.LogStreamID != batch.LogStreamID || ack.LatestSeq != batch.LastSeq {
|
||||
t.Fatalf("unexpected legacy autonomous session ack: %+v", ack)
|
||||
}
|
||||
stream, err := svc.GetLogStream(batch.LogStreamID)
|
||||
if err != nil {
|
||||
t.Fatalf("get session-scoped legacy autonomous stream: %v", err)
|
||||
}
|
||||
if stream.LogSessionID != batch.LogSessionID || !stream.SessionStartedAt.Equal(batch.SessionStartedAt) {
|
||||
t.Fatalf("session metadata was not persisted: %+v", stream)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceAcceptsSessionMetadataOnLegacyRunStreamID(t *testing.T) {
|
||||
svc, sessionToken := newRegisteredLogIngestService(t)
|
||||
batch := validLogBatch(t, sessionToken, 1, 1)
|
||||
batch.LogStreamID = runLogStreamID("run-local", "server-1", "stdout")
|
||||
batch.LogSessionID = "session-a"
|
||||
batch.SessionStartedAt = time.Date(2026, 7, 3, 12, 30, 0, 0, time.UTC)
|
||||
ack, err := svc.IngestLogBatch(batch)
|
||||
if err != nil {
|
||||
t.Fatalf("ingest session-scoped legacy run stream: %v", err)
|
||||
}
|
||||
if !ack.Accepted || ack.LogStreamID != batch.LogStreamID || ack.LatestSeq != batch.LastSeq {
|
||||
t.Fatalf("unexpected legacy run session ack: %+v", ack)
|
||||
}
|
||||
stream, err := svc.GetLogStream(batch.LogStreamID)
|
||||
if err != nil {
|
||||
t.Fatalf("get session-scoped legacy run stream: %v", err)
|
||||
}
|
||||
if stream.LogSessionID != batch.LogSessionID || !stream.SessionStartedAt.Equal(batch.SessionStartedAt) {
|
||||
t.Fatalf("session metadata was not persisted: %+v", stream)
|
||||
if _, err := svc.IngestLogBatch(batch); err == nil {
|
||||
t.Fatal("expected session-scoped batch with legacy autonomous stream ID to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -601,6 +529,31 @@ func TestFileLogBodyStoreReloadsBatchesAndCursorEntries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileLogBodyStoreReloadsVerbatimLongLogLine(t *testing.T) {
|
||||
rootDir := filepath.Join(t.TempDir(), "logs")
|
||||
store, err := NewFileLogBodyStore(rootDir)
|
||||
if err != nil {
|
||||
t.Fatalf("create file log store: %v", err)
|
||||
}
|
||||
line := strings.Repeat("log-output-", 16*1024)
|
||||
entries := []domain.LogEntry{{Seq: 1, Timestamp: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC), Line: line}}
|
||||
checksum, err := validator.LogEntriesChecksum(entries)
|
||||
if err != nil {
|
||||
t.Fatalf("checksum: %v", err)
|
||||
}
|
||||
if err := store.AppendBatch("log-long", domain.LogBatchRecord{Checksum: checksum, FirstSeq: 1, LastSeq: 1, Entries: entries}); err != nil {
|
||||
t.Fatalf("append long log batch: %v", err)
|
||||
}
|
||||
reloaded, err := NewFileLogBodyStore(rootDir)
|
||||
if err != nil {
|
||||
t.Fatalf("reload long log store: %v", err)
|
||||
}
|
||||
batch, exists, err := reloaded.GetBatch("log-long", 1)
|
||||
if err != nil || !exists || len(batch.Entries) != 1 || batch.Entries[0].Line != line {
|
||||
t.Fatalf("long log line changed after reload: batch=%+v exists=%t err=%v", batch, exists, err)
|
||||
}
|
||||
}
|
||||
|
||||
func newRegisteredLogIngestService(t *testing.T) (*CoreService, string) {
|
||||
t.Helper()
|
||||
svc := newTestCoreService()
|
||||
|
||||
@@ -137,7 +137,6 @@ func TestDeclaredSQLiteQueryDoesNotMutatePluginOrPlatformUserData(t *testing.T)
|
||||
func TestRunPollDoesNotSchedulePluginDataProjectionQueries(t *testing.T) {
|
||||
svc, plugin, endpoint, _, _ := createSQLiteQueryBridgeFixture(t)
|
||||
plugin.GameClientBridge.QueryTemplates[0].PollIntervalSeconds = 3
|
||||
plugin.GameClientBridge.QueryTemplates[0].Projections = []domain.GameClientBridgeQueryProjectionDeclaration{{Collection: "scum_users", RowPath: "rows", UpsertKeys: []string{"steamId"}}}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("enable query refresh hint: %v", err)
|
||||
}
|
||||
@@ -158,8 +157,9 @@ func TestRunPollDoesNotSchedulePluginDataProjectionQueries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclaredSQLiteQueryProjectionsAreNotAppliedByPlatform(t *testing.T) {
|
||||
func TestRunPollSchedulesAndProjectsDeclaredSQLiteQuery(t *testing.T) {
|
||||
svc, plugin, endpoint, session, instance := createSQLiteQueryBridgeFixture(t)
|
||||
plugin.GameClientBridge.QueryTemplates[0].PollIntervalSeconds = 3
|
||||
plugin.GameClientBridge.QueryTemplates[0].Projections = []domain.GameClientBridgeQueryProjectionDeclaration{{
|
||||
Collection: "scum_users", RowPath: "rows", MatchField: "kind", MatchValue: "player", UpsertKeys: []string{"steamId"},
|
||||
FieldMappings: map[string]string{"steamId": "steamId", "displayName": "displayName"}, FixedValues: map[string]string{"source": "sqlite"}, ObservedAtField: "sampledAt",
|
||||
@@ -168,38 +168,39 @@ func TestDeclaredSQLiteQueryProjectionsAreNotAppliedByPlatform(t *testing.T) {
|
||||
FieldMappings: map[string]string{"className": "displayName"}, FixedValues: map[string]string{"code": "#spawnvehicle {{displayName}}", "spawnCommand": "#spawnvehicle {{displayName}}", "catalogType": "vehicle", "type": "21", "typeName": "其他载具", "imagePath": "/original/{{displayName}}.webp", "source": "sqlite"}, ObservedAtField: "lastSeenAt", MergeExisting: true,
|
||||
}}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("store legacy query projection declarations: %v", err)
|
||||
t.Fatalf("enable query projection polling: %v", err)
|
||||
}
|
||||
queued, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{RequestID: "query-projection-ignored-1", PluginID: plugin.ID, RouteKey: "remote", ServerInstanceID: instance.ID, Action: domain.PluginBridgeActionRemoteAccessRequest, Payload: map[string]string{
|
||||
"capability": domain.JobCapabilityRemoteRunDBSQLiteQuery, "declarationKey": "scum-db-read", "targetKey": "scum-db.player-lookup", "idempotencyKey": "query-projection-ignored-1", "input.templateKey": "players.by-id",
|
||||
}})
|
||||
if err != nil || queued.Status != "queued" {
|
||||
t.Fatalf("queue declared query=%+v err=%v", queued, err)
|
||||
if _, err := svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_trade_goods", Mutations: []domain.PluginDataMutation{{Operation: domain.PluginDataMutationPut, Key: "#spawnvehicle Truck", Value: map[string]any{"code": "#spawnvehicle Truck", "name": "Named Truck"}}}}); err != nil {
|
||||
t.Fatalf("seed vehicle catalog: %v", err)
|
||||
}
|
||||
helloRequest := validRunControlHello()
|
||||
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery)
|
||||
helloRequest.CapabilityReport.Fingerprint = "cap-plugin-query-projection-ignored"
|
||||
helloRequest.CapabilityReport.Fingerprint = "cap-plugin-query-scheduler"
|
||||
hello, err := svc.RegisterRunHello(helloRequest)
|
||||
if err != nil {
|
||||
t.Fatalf("register Run: %v", err)
|
||||
}
|
||||
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, Capacity: domain.RunCapacity{MaxJobs: 1}})
|
||||
if err != nil || !claim.HasJob || claim.Job == nil {
|
||||
t.Fatalf("declared query job was not claimable: %+v err=%v", claim, err)
|
||||
t.Fatalf("projection query was not scheduled: %+v err=%v", claim, err)
|
||||
}
|
||||
if claim.Job.ExecutionInput.Inputs["templateKey"] != "players.by-id" || claim.Job.ExecutionInput.Inputs["sqlRef"] != "sql/players.by-id.sql" {
|
||||
t.Fatalf("declared query lost template inputs: %+v", claim.Job.ExecutionInput.Inputs)
|
||||
t.Fatalf("scheduled projection query lost template inputs: %+v", claim.Job.ExecutionInput.Inputs)
|
||||
}
|
||||
_, err = svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "sqlite.query", Content: `{"rows":[{"kind":"player","steamId":"steam-1","displayName":"Ada"},{"kind":"vehicle","steamId":"vehicle-1","displayName":"Truck"}]}`}})
|
||||
if err != nil {
|
||||
t.Fatalf("complete declared query job: %v", err)
|
||||
t.Fatalf("complete projection query job: %v", err)
|
||||
}
|
||||
items, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_users"})
|
||||
if err != nil || len(items) != 0 {
|
||||
t.Fatalf("platform applied query projection to plugin data=%+v err=%v", items, err)
|
||||
if err != nil || len(items) != 1 || items[0].Key != "steam-1" || items[0].Value["displayName"] != "Ada" || items[0].Value["source"] != "sqlite" || items[0].Value["sampledAt"] == nil {
|
||||
t.Fatalf("declared projection did not write scoped plugin data=%+v err=%v", items, err)
|
||||
}
|
||||
goods, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_trade_goods"})
|
||||
if err != nil || len(goods) != 0 {
|
||||
t.Fatalf("platform applied vehicle catalog query projection=%+v err=%v", goods, err)
|
||||
if err != nil || len(goods) != 1 || goods[0].Key != "#spawnvehicle Truck" || goods[0].Value["name"] != "Named Truck" || goods[0].Value["className"] != "Truck" || goods[0].Value["type"] != "21" || goods[0].Value["lastSeenAt"] == nil {
|
||||
t.Fatalf("declared vehicle catalog projection did not merge scoped plugin data=%+v err=%v", goods, err)
|
||||
}
|
||||
second, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, Capacity: domain.RunCapacity{MaxJobs: 1}})
|
||||
if err != nil || second.HasJob {
|
||||
t.Fatalf("fresh projection poll should not reschedule immediately: %+v err=%v", second, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ func (svc *CoreService) applyPluginBulkProjection(instance domain.ServerInstance
|
||||
func pluginBulkProjectionValues(fixedValues map[string]string, stamp time.Time, row map[string]any, observedAtField string) map[string]any {
|
||||
value := make(map[string]any, len(fixedValues)+1)
|
||||
for key, fixed := range fixedValues {
|
||||
value[key] = renderPluginValueTemplate(fixed, row)
|
||||
value[key] = renderQueryProjectionTemplate(fixed, row)
|
||||
}
|
||||
if observedAtField != "" {
|
||||
value[observedAtField] = stamp.UTC().Format(time.RFC3339Nano)
|
||||
@@ -78,18 +78,10 @@ func pluginBulkActivityValue(target domain.GameClientBridgeBulkActivityTargetDec
|
||||
value[destination] = row[source]
|
||||
}
|
||||
for key, fixed := range target.FixedValues {
|
||||
value[key] = renderPluginValueTemplate(fixed, row)
|
||||
value[key] = renderQueryProjectionTemplate(fixed, row)
|
||||
}
|
||||
if target.ObservedAtField != "" {
|
||||
value[target.ObservedAtField] = stamp.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func renderPluginValueTemplate(template string, row map[string]any) string {
|
||||
result := template
|
||||
for key, value := range row {
|
||||
result = strings.ReplaceAll(result, "{{"+key+"}}", fmt.Sprint(value))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
)
|
||||
|
||||
func (svc *CoreService) scheduleDuePluginQueryProjectionJobs(claim domain.RunJobClaim, stamp time.Time) error {
|
||||
if !containsString(claim.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) {
|
||||
return nil
|
||||
}
|
||||
endpoint, err := svc.store.RunEndpoints().Get(claim.RunEndpointID)
|
||||
if err != nil || !containsString(endpoint.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) {
|
||||
return err
|
||||
}
|
||||
jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: claim.RunEndpointID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
instances, err := svc.store.ServerInstances().List(domain.ServerInstanceFilter{RunEndpointID: claim.RunEndpointID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, instance := range instances {
|
||||
if instance.State != domain.ServerInstanceStateRunning || strings.TrimSpace(instance.PluginID) == "" {
|
||||
continue
|
||||
}
|
||||
plugin, pluginErr := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if pluginErr != nil || !plugin.Permissions.RemoteAccess || !containsString(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) || !containsString(plugin.RemoteAccess.RunCapabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) {
|
||||
continue
|
||||
}
|
||||
for _, template := range plugin.GameClientBridge.QueryTemplates {
|
||||
if template.PollIntervalSeconds <= 0 || len(template.Projections) == 0 || !pluginQueryTemplateTransportReady(plugin, endpoint, template) {
|
||||
continue
|
||||
}
|
||||
interval := time.Duration(template.PollIntervalSeconds) * time.Second
|
||||
prefix := pluginQueryPollPrefix(instance.ID, plugin.ID, template.Key)
|
||||
if pluginQueryPollActiveOrFresh(jobs, prefix, stamp, interval) {
|
||||
continue
|
||||
}
|
||||
bucket := stamp.Unix() / int64(template.PollIntervalSeconds)
|
||||
idempotencyKey := fmt.Sprintf("%s%d", prefix, bucket)
|
||||
inputs := map[string]string{"templateKey": template.Key, "maxRows": fmt.Sprint(template.MaxRows)}
|
||||
if template.SQLRef != "" {
|
||||
inputs["sqlRef"] = template.SQLRef
|
||||
}
|
||||
job := domain.Job{
|
||||
ID: jobIDFromParts("job-plugin-query-poll", instance.ID, idempotencyKey),
|
||||
ServerInstanceID: instance.ID,
|
||||
RunEndpointID: instance.RunEndpointID,
|
||||
Capability: domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||
TargetKey: template.TargetKey,
|
||||
InputRef: fmt.Sprintf("input://plugin-query-poll/%s/%s", instance.ID, template.Key),
|
||||
IdempotencyKey: idempotencyKey,
|
||||
Progress: domain.JobProgress{Percent: 0, Message: "plugin query projection poll queued"},
|
||||
RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 2, MaxBackoffSeconds: 2},
|
||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), RemoteAdapterKey: template.TransportKey, RemoteAdapterKind: string(domain.RemoteAdapterDatabase), TimeoutSeconds: template.TimeoutSeconds, PluginID: plugin.ID, Inputs: inputs},
|
||||
}
|
||||
if _, createErr := svc.CreateJob(job); createErr != nil {
|
||||
return createErr
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pluginQueryTemplateTransportReady(plugin domain.GamePlugin, endpoint domain.RunEndpoint, template domain.GameClientBridgeQueryTemplateDeclaration) bool {
|
||||
if template.Engine != "sqlite" || template.TransportKey == "" || template.TargetKey == "" {
|
||||
return false
|
||||
}
|
||||
for _, profile := range plugin.RuntimeProfiles.TransportProfiles {
|
||||
if profile.Key == template.TransportKey && profile.Kind == "sqlite" && profile.TargetKey == template.TargetKey && containsString(profile.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) {
|
||||
return containsString(endpoint.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func pluginQueryPollPrefix(serverID, pluginID, templateKey string) string {
|
||||
return fmt.Sprintf("plugin-query-poll:%s:%s:%s:", serverID, pluginID, templateKey)
|
||||
}
|
||||
|
||||
func pluginQueryPollActiveOrFresh(jobs []domain.Job, prefix string, stamp time.Time, interval time.Duration) bool {
|
||||
for _, job := range jobs {
|
||||
if !strings.HasPrefix(job.IdempotencyKey, prefix) {
|
||||
continue
|
||||
}
|
||||
if !isTerminalJobState(job.State) {
|
||||
return true
|
||||
}
|
||||
freshAt := job.TerminalAt
|
||||
if freshAt.IsZero() {
|
||||
freshAt = job.UpdatedAt
|
||||
}
|
||||
if !freshAt.IsZero() && stamp.Sub(freshAt) < interval {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (svc *CoreService) projectPluginQueryJobResult(job domain.Job, stamp time.Time) error {
|
||||
if job.State != domain.JobStateSucceeded || job.Capability != domain.JobCapabilityRemoteRunDBSQLiteQuery || job.ExecutionResult.Kind != "sqlite.query" {
|
||||
return nil
|
||||
}
|
||||
templateKey := strings.TrimSpace(job.ExecutionInput.Inputs["templateKey"])
|
||||
if templateKey == "" || strings.TrimSpace(job.ServerInstanceID) == "" {
|
||||
return nil
|
||||
}
|
||||
instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
template, ok := pluginQueryTemplateByKey(plugin, templateKey)
|
||||
if !ok || len(template.Projections) == 0 {
|
||||
return nil
|
||||
}
|
||||
rows, err := pluginQueryRows(job.ExecutionResult.Content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mutationsByCollection := map[string]map[string]domain.PluginDataMutation{}
|
||||
for _, projection := range template.Projections {
|
||||
if projection.RowPath != "rows" {
|
||||
continue
|
||||
}
|
||||
collectionMutations := mutationsByCollection[projection.Collection]
|
||||
if collectionMutations == nil {
|
||||
collectionMutations = map[string]domain.PluginDataMutation{}
|
||||
mutationsByCollection[projection.Collection] = collectionMutations
|
||||
}
|
||||
for _, row := range rows {
|
||||
if projection.MatchField != "" && strings.TrimSpace(fmt.Sprint(row[projection.MatchField])) != projection.MatchValue {
|
||||
continue
|
||||
}
|
||||
value := pluginQueryProjectionValue(projection, row, stamp)
|
||||
key, keyErr := pluginDataRowKey(value, projection.UpsertKeys)
|
||||
if keyErr != nil {
|
||||
return keyErr
|
||||
}
|
||||
if projection.MergeExisting {
|
||||
if existing, existingErr := svc.store.PluginDataRecords().Get(pluginDataID(instance.ID, plugin.ID, projection.Collection, key)); existingErr == nil {
|
||||
value = mergePluginDataValues(existing.Value, value)
|
||||
} else if !errors.Is(existingErr, repo.ErrNotFound) {
|
||||
return existingErr
|
||||
}
|
||||
}
|
||||
collectionMutations[key] = domain.PluginDataMutation{Operation: domain.PluginDataMutationPut, Key: key, Value: value}
|
||||
}
|
||||
}
|
||||
for collection, keyed := range mutationsByCollection {
|
||||
mutations := make([]domain.PluginDataMutation, 0, len(keyed))
|
||||
for _, mutation := range keyed {
|
||||
mutations = append(mutations, mutation)
|
||||
}
|
||||
if len(mutations) == 0 {
|
||||
continue
|
||||
}
|
||||
if _, err := svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: collection, Mutations: mutations}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pluginQueryTemplateByKey(plugin domain.GamePlugin, templateKey string) (domain.GameClientBridgeQueryTemplateDeclaration, bool) {
|
||||
for _, template := range plugin.GameClientBridge.QueryTemplates {
|
||||
if template.Key == templateKey {
|
||||
return template, true
|
||||
}
|
||||
}
|
||||
return domain.GameClientBridgeQueryTemplateDeclaration{}, false
|
||||
}
|
||||
|
||||
func pluginQueryRows(content string) ([]map[string]any, error) {
|
||||
var payload struct {
|
||||
Rows []map[string]any `json:"rows"`
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewBufferString(content))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&payload); err != nil {
|
||||
return nil, validationError("sqlite query result content is not a row payload")
|
||||
}
|
||||
return payload.Rows, nil
|
||||
}
|
||||
|
||||
func pluginQueryProjectionValue(projection domain.GameClientBridgeQueryProjectionDeclaration, row map[string]any, observedAt time.Time) map[string]any {
|
||||
value := map[string]any{}
|
||||
if len(projection.FieldMappings) == 0 {
|
||||
for key, item := range row {
|
||||
value[key] = item
|
||||
}
|
||||
} else {
|
||||
for destination, source := range projection.FieldMappings {
|
||||
value[destination] = row[source]
|
||||
}
|
||||
}
|
||||
for key, fixed := range projection.FixedValues {
|
||||
value[key] = renderQueryProjectionTemplate(fixed, row)
|
||||
}
|
||||
if projection.ObservedAtField != "" {
|
||||
value[projection.ObservedAtField] = observedAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func renderQueryProjectionTemplate(template string, row map[string]any) string {
|
||||
result := template
|
||||
for key, value := range row {
|
||||
result = strings.ReplaceAll(result, "{{"+key+"}}", fmt.Sprint(value))
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -57,9 +57,7 @@ func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecyc
|
||||
instance.State = domain.ServerInstanceStateDraft
|
||||
}
|
||||
if instance.Deployment.Mode != "" {
|
||||
if strings.TrimSpace(create.ProfileKey) != "" {
|
||||
instance.Deployment.ProfileKey = create.ProfileKey
|
||||
}
|
||||
instance.Deployment.ProfileKey = create.ProfileKey
|
||||
instance.Deployment.RuntimeBindings = domain.CopyStringMap(create.Bindings)
|
||||
instance.Deployment.Revision = maxInt(1, instance.Deployment.Revision)
|
||||
instance.Deployment.UpdatedAt = stamp
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
)
|
||||
|
||||
func TestCoreServiceServerLifecycleWorkflows(t *testing.T) {
|
||||
@@ -106,43 +104,6 @@ func TestLifecycleProjectedStateUsesRunProcessFacts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedRunLifecycleUsesPackageDefaultProfileWithoutRuntimeBinding(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
plugin := createLifecyclePlugin(t, svc)
|
||||
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.LifecycleCapabilityStatus)
|
||||
plugin.LifecycleActions.Status = "actions/status.json"
|
||||
plugin.RuntimeProfiles.LifecycleProfiles = []domain.RuntimeLifecycleProfile{{
|
||||
Key: "run-local",
|
||||
Mode: "local-process",
|
||||
Capabilities: []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, domain.LifecycleCapabilityStatus},
|
||||
ActionRefs: domain.PluginLifecycleActions{Install: "actions/install.json", Start: "actions/start.json", Stop: "actions/stop.json", Status: "actions/status.json"},
|
||||
}}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update plugin profile: %v", err)
|
||||
}
|
||||
ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "generated-run-owner", DisplayName: "Generated Run Owner", Email: "generated-run-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
||||
serverID := "generated-run-server"
|
||||
endpointID := generatedRunEndpointID(serverID)
|
||||
if err := svc.store.RunEndpoints().Create(domain.RunEndpoint{ID: endpointID, DisplayName: "Generated Run", Version: "0.1.0", Status: domain.RunEndpointStatusOnline, Capabilities: []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, domain.LifecycleCapabilityStatus}, Capacity: domain.RunCapacity{MaxJobs: 1}, LastHeartbeatAt: fixedTime}); err != nil {
|
||||
t.Fatalf("create generated run endpoint: %v", err)
|
||||
}
|
||||
instance := domain.ServerInstance{ID: serverID, PluginID: plugin.ID, PluginVersion: plugin.Version, RunEndpointID: endpointID, Name: "Generated Run Server", OwnerUserID: "generated-run-owner", State: domain.ServerInstanceStateFailed, ConfigVersion: 1, Deployment: domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeExisting, ServerRoot: `C:\scumserver`, Revision: 1}, CreatedAt: fixedTime, UpdatedAt: fixedTime}
|
||||
if err := svc.store.ServerInstances().Create(instance); err != nil {
|
||||
t.Fatalf("create generated run server: %v", err)
|
||||
}
|
||||
if _, err := svc.runtimeBindingForServer(serverID); !errors.Is(err, repo.ErrNotFound) {
|
||||
t.Fatalf("expected generated run server to have no manual runtime binding: %v", err)
|
||||
}
|
||||
|
||||
result, err := svc.QueryServerInstanceProcessForSession(ownerSession, domain.ServerLifecycleCommand{ServerInstanceID: serverID, ExpectedConfigVersion: 1, IdempotencyKey: "generated-run-status"})
|
||||
if err != nil {
|
||||
t.Fatalf("query generated run status: %v", err)
|
||||
}
|
||||
if result.Job.ExecutionInput.WorkspaceScope != "run-local" || result.Job.TargetKey != "actions/status.json" {
|
||||
t.Fatalf("expected generated run status to use packaged profile scope, job=%+v", result.Job)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLifecycleJobResultsPublishProcessStateEvents(t *testing.T) {
|
||||
svc, sessionToken := newLifecycleRunService(t)
|
||||
createLifecyclePlugin(t, svc)
|
||||
|
||||
@@ -86,9 +86,7 @@ describe("ServerManagementTerminalDrawer", () => {
|
||||
expect(Array.from(container?.querySelectorAll(".terminal-text") ?? []).filter((node) => node.textContent === "generation A live output")).toHaveLength(1);
|
||||
expect(apiMocks.openServerLogEvents).toHaveBeenCalledTimes(1);
|
||||
expect(apiMocks.listLogStreams).not.toHaveBeenCalled();
|
||||
expect(apiMocks.queryLogStream).not.toHaveBeenCalled();
|
||||
expect(apiMocks.dispatchSourceRCONCommand).not.toHaveBeenCalled();
|
||||
expect(container?.textContent).not.toContain("查看历史");
|
||||
});
|
||||
|
||||
it("uses the server-provided clock for terminal system lines", async () => {
|
||||
@@ -101,21 +99,6 @@ describe("ServerManagementTerminalDrawer", () => {
|
||||
expect(systemLine?.querySelector("time")?.textContent).toBe(formatTerminalServerTime(serverTime));
|
||||
});
|
||||
|
||||
it("does not load stored output when current stream metadata arrives", async () => {
|
||||
const stream = logStream("stdout-current", "session-current", "process.stdout");
|
||||
stream.latestSeq = 42;
|
||||
apiMocks.queryLogStream.mockResolvedValue({ logStreamId: stream.id, entries: [logEntry(41, "tail before drawer opened"), logEntry(42, "latest stored output")], nextSeq: 42, latestSeq: 42 });
|
||||
await renderDrawer();
|
||||
|
||||
await emitSession("session-current");
|
||||
await emitStream(stream);
|
||||
await flushPromises();
|
||||
|
||||
expect(container?.textContent).not.toContain("tail before drawer opened");
|
||||
expect(container?.textContent).not.toContain("latest stored output");
|
||||
expect(apiMocks.queryLogStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders an empty current session without accepting unrelated or sessionless logs", async () => {
|
||||
await renderDrawer();
|
||||
|
||||
@@ -128,7 +111,7 @@ describe("ServerManagementTerminalDrawer", () => {
|
||||
expect(apiMocks.dispatchSourceRCONCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps visible output on a new session and rejects late generation A events", async () => {
|
||||
it("clears generation A on a new session and rejects late generation A events", async () => {
|
||||
await renderDrawer();
|
||||
await emitSession("session-a");
|
||||
await emitStream(logStream("stdout-a", "session-a", "process.stdout"));
|
||||
@@ -139,14 +122,14 @@ describe("ServerManagementTerminalDrawer", () => {
|
||||
await emitStream(logStream("stdout-b", "session-b", "process.stdout"));
|
||||
await emitLog("stdout-b", "session-b", "process.stdout", logEntry(1, "generation B output"));
|
||||
|
||||
expect(container?.textContent).toContain("generation A output");
|
||||
expect(container?.textContent).not.toContain("generation A output");
|
||||
expect(container?.textContent).not.toContain("late generation A output");
|
||||
expect(container?.textContent).toContain("generation B output");
|
||||
expect(container?.textContent).toContain("Run 已切换到新的受管进程输出会话");
|
||||
expect(apiMocks.openServerLogEvents).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps visible output through a stopped boundary and restores the running session on the same SSE connection", async () => {
|
||||
it("clears a stopped session and restores the running session on the same SSE connection", async () => {
|
||||
await renderDrawer();
|
||||
await emitSession("session-a");
|
||||
await emitStream(logStream("stdout-a", "session-a", "process.stdout"));
|
||||
@@ -154,29 +137,46 @@ describe("ServerManagementTerminalDrawer", () => {
|
||||
|
||||
await emitSession();
|
||||
await emitLog("stdout-a", "session-a", "process.stdout", logEntry(2, "late output after stop"));
|
||||
expect(container?.textContent).toContain("running output before stop");
|
||||
expect(container?.textContent).not.toContain("running output before stop");
|
||||
expect(container?.textContent).not.toContain("late output after stop");
|
||||
expect(container?.textContent).toContain("当前没有可跟随的受管进程输出");
|
||||
|
||||
await emitSession("session-b");
|
||||
await emitStream(logStream("stdout-b", "session-b", "process.stdout"));
|
||||
await emitLog("stdout-b", "session-b", "process.stdout", logEntry(1, "running output after recovery"));
|
||||
expect(container?.textContent).toContain("running output before stop");
|
||||
expect(container?.textContent).not.toContain("running output before stop");
|
||||
expect(container?.textContent).toContain("running output after recovery");
|
||||
expect(apiMocks.openServerLogEvents).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps the terminal as a pure current live stream and never opens platform history", async () => {
|
||||
it("keeps selected historical output separate while live output continues in the background", async () => {
|
||||
const oldStream = logStream("stdout-old", "session-old", "process.stdout", "2026-08-01T00:00:00Z");
|
||||
oldStream.latestSeq = 900;
|
||||
apiMocks.listLogStreams.mockResolvedValue({ items: [oldStream], count: 1 });
|
||||
apiMocks.queryLogStream.mockResolvedValue({ logStreamId: oldStream.id, entries: [logEntry(1, "selected historical output", "2026-08-01T00:00:01Z")], nextSeq: 1, latestSeq: 1 });
|
||||
await renderDrawer();
|
||||
await emitSession("session-current");
|
||||
await emitStream(logStream("stdout-current", "session-current", "process.stdout"));
|
||||
await emitLog("stdout-current", "session-current", "process.stdout", logEntry(1, "current live output"));
|
||||
|
||||
await clickButton("查看历史");
|
||||
await flushPromises();
|
||||
const select = container?.querySelector<HTMLSelectElement>('select[aria-label="选择历史日志流"]');
|
||||
if (!select) throw new Error("history stream selector not found");
|
||||
await act(async () => setSelectValue(select, oldStream.id));
|
||||
await flushPromises();
|
||||
|
||||
expect(container?.textContent).toContain("selected historical output");
|
||||
expect(container?.textContent).not.toContain("current live output");
|
||||
await emitLog("stdout-current", "session-current", "process.stdout", logEntry(2, "new live output while viewing history"));
|
||||
expect(container?.textContent).not.toContain("new live output while viewing history");
|
||||
|
||||
await clickButton("实时输出");
|
||||
expect(container?.textContent).toContain("current live output");
|
||||
expect(container?.textContent).not.toContain("查看历史");
|
||||
expect(container?.querySelector('select[aria-label="选择历史日志流"]')).toBeNull();
|
||||
expect(apiMocks.listLogStreams).not.toHaveBeenCalled();
|
||||
expect(apiMocks.queryLogStream).not.toHaveBeenCalled();
|
||||
expect(container?.textContent).toContain("new live output while viewing history");
|
||||
expect(container?.textContent).not.toContain("selected historical output");
|
||||
expect(apiMocks.listLogStreams).toHaveBeenCalledWith("server-1");
|
||||
expect(apiMocks.queryLogStream).toHaveBeenCalledWith({ logStreamId: oldStream.id, afterSeq: 400, limit: 500 });
|
||||
expect(apiMocks.dispatchSourceRCONCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -245,6 +245,11 @@ function setInputValue(input: HTMLInputElement, value: string) {
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
function setSelectValue(select: HTMLSelectElement, value: string) {
|
||||
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set?.call(select, value);
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
|
||||
function logStream(id: string, logSessionId: string, streamKey: string, updatedAt = "2026-08-14T00:00:00Z"): LogStreamResponse {
|
||||
return { id, serverInstanceId: "server-1", source: "process", streamKey, logSessionId, sessionStartedAt: updatedAt, latestSeq: 1, storageBackend: "database", retentionPolicy: "default", createdAt: updatedAt, updatedAt };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ListChecks, Send, Sparkles, Terminal, Trash2, X } from "lucide-react";
|
||||
import { History, ListChecks, Send, Sparkles, Terminal, Trash2, X } from "lucide-react";
|
||||
import { type FormEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { platformApiClient } from "../api/client";
|
||||
@@ -10,11 +10,13 @@ import { formatTerminalLogTime, formatTerminalServerTime } from "../utils/logTim
|
||||
import { EmptyState, ResultBadge } from "./StateViews";
|
||||
|
||||
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
|
||||
type HistoryLineState = { status: "idle" } | LoadState<TerminalLine[]>;
|
||||
type TerminalLine = { id: string; tone: "input" | "info" | "success" | "warn" | "error"; text: string; at: string; sortKey: number; streamKey?: string; level?: string; seq?: number };
|
||||
type TerminalQuickCommand = { label: string; command: string; hint: string };
|
||||
|
||||
const terminalJobResultPollMs = 1000;
|
||||
const terminalJobResultPollAttempts = 30;
|
||||
const terminalHistoryWindow = 500;
|
||||
const maxTerminalLines = 10000;
|
||||
const terminalQuickCommandCatalog: Record<string, TerminalQuickCommand[]> = {
|
||||
"game.scum": [
|
||||
@@ -90,11 +92,16 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
||||
const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
|
||||
const [followLatest, setFollowLatest] = useState(true);
|
||||
const [liveSessionId, setLiveSessionId] = useState<string | null>(null);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [historyStreams, setHistoryStreams] = useState<LoadState<LogStreamResponse[]>>({ status: "loading" });
|
||||
const [historyLines, setHistoryLines] = useState<HistoryLineState>({ status: "idle" });
|
||||
const [selectedHistoryStreamId, setSelectedHistoryStreamId] = useState("");
|
||||
const outputRef = useRef<HTMLDivElement>(null);
|
||||
const followLatestRef = useRef(true);
|
||||
const serverTimeRef = useRef<string | undefined>(undefined);
|
||||
const initialHistoryPendingRef = useRef(false);
|
||||
const liveSessionRef = useRef<string | null | undefined>(undefined);
|
||||
const historyRequestRef = useRef(0);
|
||||
const quickCommands = useMemo(() => terminalQuickCommandsForPlugin(pluginId), [pluginId]);
|
||||
const supportsCommands = quickCommands.length > 0;
|
||||
|
||||
@@ -125,8 +132,13 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
||||
setResult(null);
|
||||
setHistoryIndex(null);
|
||||
setLiveSessionId(null);
|
||||
setHistoryOpen(false);
|
||||
setHistoryStreams({ status: "loading" });
|
||||
setHistoryLines({ status: "idle" });
|
||||
setSelectedHistoryStreamId("");
|
||||
liveSessionRef.current = undefined;
|
||||
serverTimeRef.current = undefined;
|
||||
historyRequestRef.current += 1;
|
||||
initialHistoryPendingRef.current = true;
|
||||
followLatestRef.current = true;
|
||||
setFollowLatest(true);
|
||||
@@ -157,10 +169,9 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
||||
setLiveSessionId(nextSessionId);
|
||||
if (previousSessionId === nextSessionId) return;
|
||||
setStreams({ status: "ready", data: [] });
|
||||
setLines((current) => mergeTerminalLines(current, [nextSessionId
|
||||
? terminalSystemLine("info", previousSessionId === undefined ? "已跟随当前受管进程输出会话。" : "Run 已切换到新的受管进程输出会话。", "SYSTEM", `session-${nextSessionId}`, serverTimeRef.current)
|
||||
: terminalSystemLine("warn", "当前没有可跟随的受管进程输出。", "SYSTEM", "session-empty", serverTimeRef.current)
|
||||
]));
|
||||
setLines(nextSessionId
|
||||
? [terminalSystemLine("info", previousSessionId === undefined ? "已跟随当前受管进程输出会话。" : "Run 已切换到新的受管进程输出会话。", "SYSTEM", `session-${nextSessionId}`, serverTimeRef.current)]
|
||||
: [terminalSystemLine("warn", "当前没有可跟随的受管进程输出;旧日志可从历史查看。", "SYSTEM", "session-empty", serverTimeRef.current)]);
|
||||
lockTerminalFollow();
|
||||
});
|
||||
events.addEventListener("stream", (event) => {
|
||||
@@ -188,6 +199,35 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
||||
return () => events.close();
|
||||
}, [appendLines, lockTerminalFollow, open, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !historyOpen) return;
|
||||
let cancelled = false;
|
||||
setHistoryStreams({ status: "loading" });
|
||||
void platformApiClient.listLogStreams(serverId).then((response) => {
|
||||
if (!cancelled) setHistoryStreams({ status: "ready", data: [...response.items].sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt)) });
|
||||
}).catch((error) => {
|
||||
if (!cancelled) setHistoryStreams({ status: "error", reason: error instanceof Error ? error.message : "历史日志列表加载失败" });
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [historyOpen, open, serverId]);
|
||||
|
||||
async function selectHistoryStream(streamId: string) {
|
||||
const stream = historyStreams.status === "ready" ? historyStreams.data.find((item) => item.id === streamId) : undefined;
|
||||
if (!stream) return;
|
||||
const requestId = historyRequestRef.current + 1;
|
||||
historyRequestRef.current = requestId;
|
||||
setSelectedHistoryStreamId(streamId);
|
||||
setHistoryLines({ status: "loading" });
|
||||
try {
|
||||
const response = await platformApiClient.queryLogStream({ logStreamId: streamId, afterSeq: Math.max(0, stream.latestSeq - terminalHistoryWindow), limit: terminalHistoryWindow });
|
||||
if (historyRequestRef.current !== requestId) return;
|
||||
setHistoryLines({ status: "ready", data: response.entries.map((entry) => terminalLineFromLog(stream, entry)) });
|
||||
} catch (error) {
|
||||
if (historyRequestRef.current !== requestId) return;
|
||||
setHistoryLines({ status: "error", reason: error instanceof Error ? error.message : "历史日志加载失败" });
|
||||
}
|
||||
}
|
||||
|
||||
function selectQuickCommand(item: TerminalQuickCommand) {
|
||||
setCommand(item.command);
|
||||
setHistoryIndex(null);
|
||||
@@ -216,9 +256,20 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
||||
}
|
||||
|
||||
function clearTerminalBuffer() {
|
||||
if (historyOpen) {
|
||||
setHistoryLines({ status: "ready", data: [] });
|
||||
return;
|
||||
}
|
||||
setLines([]);
|
||||
}
|
||||
|
||||
function toggleHistory() {
|
||||
historyRequestRef.current += 1;
|
||||
setHistoryOpen((current) => !current);
|
||||
setSelectedHistoryStreamId("");
|
||||
setHistoryLines({ status: "idle" });
|
||||
}
|
||||
|
||||
function handleTerminalScroll() {
|
||||
const output = outputRef.current;
|
||||
if (!output || initialHistoryPendingRef.current) return;
|
||||
@@ -276,17 +327,19 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
||||
<div className="terminal-output-topbar">
|
||||
<div>
|
||||
<strong>{serverName}</strong>
|
||||
<span>{`当前受管进程会话${liveSessionId ? " · SSE 实时推送" : " · 等待 Run 输出"}`} · {streams.status === "ready" ? "已连接" : streams.status === "loading" ? "连接日志流" : "日志流异常"} · {followLatest ? "自动置底" : "已解锁滚动"}</span>
|
||||
<span>{historyOpen ? "历史日志(独立于实时终端)" : `当前受管进程会话${liveSessionId ? " · SSE 实时推送" : " · 等待 Run 输出"}`} · {streams.status === "ready" ? "已连接" : streams.status === "loading" ? "连接日志流" : "日志流异常"} · {followLatest ? "自动置底" : "已解锁滚动"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" className="terminal-output-action" onClick={clearTerminalBuffer}><Trash2 size={14} /><span>清屏</span></button>
|
||||
<button type="button" className="terminal-output-action" onClick={toggleHistory}><History size={14} /><span>{historyOpen ? "实时输出" : "查看历史"}</span></button>
|
||||
<button type="button" className="terminal-output-action" aria-label="关闭打开终端" onClick={onClose}><X size={14} /><span>关闭</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={outputRef} className="terminal-output" role="log" aria-live="polite" onScroll={handleTerminalScroll}>
|
||||
{streams.status === "error" && <div className="terminal-line terminal-line-error terminal-source-system"><time>{formatTerminalServerTime(serverTimeRef.current)}</time><span className="terminal-text">{streams.reason}</span></div>}
|
||||
{streams.status === "ready" && lines.length === 0 && <div className="terminal-line terminal-line-warn terminal-source-system"><time>{formatTerminalServerTime(serverTimeRef.current)}</time><span className="terminal-text">{liveSessionId ? "当前受管进程会话暂无输出,后续输出会自动追加。" : "当前没有可跟随的受管进程输出。"}</span></div>}
|
||||
{lines.map((line) => <div key={line.id} className={terminalLineClassName(line)}><time>{line.at}</time><span className="terminal-text">{line.text}</span></div>)}
|
||||
{!historyOpen && streams.status === "error" && <div className="terminal-line terminal-line-error terminal-source-system"><time>{formatTerminalServerTime(serverTimeRef.current)}</time><span className="terminal-text">{streams.reason}</span></div>}
|
||||
{historyOpen && <HistoryLogView streams={historyStreams} lines={historyLines} selectedStreamId={selectedHistoryStreamId} onSelect={selectHistoryStream} serverTime={serverTimeRef.current} />}
|
||||
{!historyOpen && streams.status === "ready" && lines.length === 0 && <div className="terminal-line terminal-line-warn terminal-source-system"><time>{formatTerminalServerTime(serverTimeRef.current)}</time><span className="terminal-text">{liveSessionId ? "当前受管进程会话暂无输出,后续输出会自动追加。" : "当前没有可跟随的受管进程输出;旧日志可从历史查看。"}</span></div>}
|
||||
{!historyOpen && lines.map((line) => <div key={line.id} className={terminalLineClassName(line)}><time>{line.at}</time><span className="terminal-text">{line.text}</span></div>)}
|
||||
</div>
|
||||
</section>
|
||||
<section className="terminal-command-dock" aria-label="terminal command controls">
|
||||
@@ -313,6 +366,42 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
||||
);
|
||||
}
|
||||
|
||||
interface HistoryLogViewProps {
|
||||
streams: LoadState<LogStreamResponse[]>;
|
||||
lines: HistoryLineState;
|
||||
selectedStreamId: string;
|
||||
onSelect: (streamId: string) => Promise<void>;
|
||||
serverTime?: string;
|
||||
}
|
||||
|
||||
function HistoryLogView({ streams, lines, selectedStreamId, onSelect, serverTime }: HistoryLogViewProps) {
|
||||
if (streams.status === "loading") return <TerminalStatusLine tone="info" label="正在加载历史日志列表。" serverTime={serverTime} />;
|
||||
if (streams.status === "error") return <TerminalStatusLine tone="error" label={streams.reason} serverTime={serverTime} />;
|
||||
if (streams.data.length === 0) return <TerminalStatusLine tone="warn" label="暂无可查看的历史日志流。" serverTime={serverTime} />;
|
||||
return (
|
||||
<>
|
||||
<div className="terminal-line terminal-line-info terminal-source-system">
|
||||
<time>历史</time>
|
||||
<span className="terminal-text">
|
||||
<select aria-label="选择历史日志流" value={selectedStreamId} onChange={(event) => void onSelect(event.target.value)}>
|
||||
<option value="" disabled>选择一个历史日志流</option>
|
||||
{streams.data.map((stream) => <option key={stream.id} value={stream.id}>{stream.streamKey} · {stream.updatedAt}</option>)}
|
||||
</select>
|
||||
</span>
|
||||
</div>
|
||||
{lines.status === "idle" && <TerminalStatusLine tone="info" label="请选择一个历史日志流。" serverTime={serverTime} />}
|
||||
{lines.status === "loading" && <TerminalStatusLine tone="info" label="正在读取所选历史日志。" serverTime={serverTime} />}
|
||||
{lines.status === "error" && <TerminalStatusLine tone="error" label={lines.reason} serverTime={serverTime} />}
|
||||
{lines.status === "ready" && lines.data.length === 0 && <TerminalStatusLine tone="warn" label="所选历史日志流暂无保留内容。" serverTime={serverTime} />}
|
||||
{lines.status === "ready" && lines.data.map((line) => <div key={line.id} className={terminalLineClassName(line)}><time>{line.at}</time><span className="terminal-text">{line.text}</span></div>)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalStatusLine({ tone, label, serverTime }: { tone: "info" | "warn" | "error"; label: string; serverTime?: string }) {
|
||||
return <div className={`terminal-line terminal-line-${tone} terminal-source-system`}><time>{formatTerminalServerTime(serverTime)}</time><span className="terminal-text">{label}</span></div>;
|
||||
}
|
||||
|
||||
function terminalQuickCommandsForPlugin(pluginId: string): TerminalQuickCommand[] {
|
||||
return terminalQuickCommandCatalog[pluginId] ?? [];
|
||||
}
|
||||
|
||||
@@ -778,7 +778,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
|
||||
.console-stat-strip>div,.operations-pulse-strip>div{display:grid;gap:3px;min-width:0;padding:9px 10px;border:1px solid color-mix(in srgb,var(--line) 78%,transparent);border-radius:6px;background:color-mix(in srgb,var(--surface-solid) 78%,var(--accent-soft))}
|
||||
.console-stat-strip dt,.operations-pulse-strip dt{color:var(--ink-faint);font-size:11px}
|
||||
.console-stat-strip dd,.operations-pulse-strip dd{margin:0;color:var(--ink);font-size:18px;font-weight:850}
|
||||
.map-world-board{position:relative;min-height:320px;border:1px solid color-mix(in srgb,var(--line) 76%,transparent);border-radius:14px;overflow:hidden;background:radial-gradient(circle at 50% 50%,color-mix(in srgb,var(--accent-soft) 42%,transparent),transparent 58%),linear-gradient(135deg,color-mix(in srgb,var(--surface-solid) 78%,#000),#05070d);background-size:cover;background-position:center}.map-grid-overlay{position:absolute;inset:0;z-index:1;pointer-events:none}.map-grid-line{position:absolute;background:color-mix(in srgb,var(--line) 62%,transparent)}.map-grid-line-v{top:0;bottom:0;width:1px}.map-grid-line-h{left:0;right:0;height:1px}.map-grid-label{position:absolute;transform:translate(-50%,-50%);padding:1px 5px;border:1px solid color-mix(in srgb,var(--line) 70%,transparent);border-radius:999px;background:color-mix(in srgb,var(--surface-solid) 72%,transparent);color:var(--ink);font:800 10px/1 var(--font-mono);text-shadow:0 1px 4px rgba(0,0,0,.55)}.map-grid-col-label{top:10px}.map-grid-row-label{left:12px}.map-world-dot{position:absolute;z-index:3;width:9px;height:9px;padding:0;border:0;border-radius:999px;background:var(--accent);box-shadow:0 0 16px color-mix(in srgb,var(--accent) 80%,transparent);transform:translate(-50%,-50%);cursor:pointer}.map-world-dot img{display:block;width:100%;height:100%;object-fit:contain;filter:drop-shadow(0 0 8px color-mix(in srgb,var(--accent) 76%,transparent))}.map-world-dot.map-layer-vehicles{width:26px;height:26px;background:color-mix(in srgb,var(--surface-solid) 72%,transparent)}.map-world-dot.map-layer-flags{background:var(--gold)}.map-world-dot.map-layer-regions{background:var(--success)}.map-world-dot-riding{outline:2px solid var(--gold);box-shadow:0 0 0 4px color-mix(in srgb,var(--gold) 24%,transparent),0 0 18px color-mix(in srgb,var(--gold) 80%,transparent)}.map-trajectory-dot{position:absolute;z-index:2;width:4px;height:4px;border-radius:999px;background:color-mix(in srgb,var(--accent) 82%,transparent);box-shadow:0 0 8px color-mix(in srgb,var(--accent) 66%,transparent);transform:translate(-50%,-50%);pointer-events:none}.map-trajectory-dot.map-layer-vehicles{width:5px;height:5px;background:color-mix(in srgb,var(--gold) 86%,transparent)}
|
||||
.map-projection-board{position:relative;min-height:320px;border:1px solid color-mix(in srgb,var(--line) 76%,transparent);border-radius:14px;overflow:hidden;background:radial-gradient(circle at 50% 50%,color-mix(in srgb,var(--accent-soft) 42%,transparent),transparent 58%),linear-gradient(135deg,color-mix(in srgb,var(--surface-solid) 78%,#000),#05070d);background-size:cover;background-position:center}.map-grid-overlay{position:absolute;inset:0;z-index:1;pointer-events:none}.map-grid-line{position:absolute;background:color-mix(in srgb,var(--line) 62%,transparent)}.map-grid-line-v{top:0;bottom:0;width:1px}.map-grid-line-h{left:0;right:0;height:1px}.map-grid-label{position:absolute;transform:translate(-50%,-50%);padding:1px 5px;border:1px solid color-mix(in srgb,var(--line) 70%,transparent);border-radius:999px;background:color-mix(in srgb,var(--surface-solid) 72%,transparent);color:var(--ink);font:800 10px/1 var(--font-mono);text-shadow:0 1px 4px rgba(0,0,0,.55)}.map-grid-col-label{top:10px}.map-grid-row-label{left:12px}.map-projection-dot{position:absolute;z-index:3;width:9px;height:9px;padding:0;border:0;border-radius:999px;background:var(--accent);box-shadow:0 0 16px color-mix(in srgb,var(--accent) 80%,transparent);transform:translate(-50%,-50%);cursor:pointer}.map-projection-dot img{display:block;width:100%;height:100%;object-fit:contain;filter:drop-shadow(0 0 8px color-mix(in srgb,var(--accent) 76%,transparent))}.map-projection-dot.map-layer-vehicles{width:26px;height:26px;background:color-mix(in srgb,var(--surface-solid) 72%,transparent)}.map-projection-dot.map-layer-flags{background:var(--gold)}.map-projection-dot.map-layer-regions{background:var(--success)}.map-projection-dot-riding{outline:2px solid var(--gold);box-shadow:0 0 0 4px color-mix(in srgb,var(--gold) 24%,transparent),0 0 18px color-mix(in srgb,var(--gold) 80%,transparent)}.map-trajectory-dot{position:absolute;z-index:2;width:4px;height:4px;border-radius:999px;background:color-mix(in srgb,var(--accent) 82%,transparent);box-shadow:0 0 8px color-mix(in srgb,var(--accent) 66%,transparent);transform:translate(-50%,-50%);pointer-events:none}.map-trajectory-dot.map-layer-vehicles{width:5px;height:5px;background:color-mix(in srgb,var(--gold) 86%,transparent)}
|
||||
.console-row-list,.operations-endpoint-list,.operations-job-list{display:grid;gap:6px;margin-top:10px}
|
||||
.console-row,.operations-endpoint-row,.operations-job-row{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:10px;min-width:0;padding:8px 10px;border:1px solid var(--line);border-radius:6px;background:var(--control-surface);color:var(--ink-soft);text-align:left}
|
||||
.console-row-button,.operations-job-row{width:100%;cursor:pointer}
|
||||
|
||||
@@ -199,6 +199,7 @@ func TestCompanionProductionSourceHasNoForbiddenAdapterPaths(t *testing.T) {
|
||||
t.Fatal("resolve companion source directory")
|
||||
}
|
||||
forbidden := map[string]*regexp.Regexp{
|
||||
"raw SQL or direct database access": regexp.MustCompile(`(?im)"(?:database/sql|github\.com/(?:mattn/go-sqlite3|go-sql-driver/mysql)|gorm\.io/gorm)"|\b(?:sql|db|database)\.(?:Open|Exec(?:Context)?|Query(?:Context)?|Prepare(?:Context)?)\s*\(`),
|
||||
"unrestricted RCON or command execution": regexp.MustCompile(`(?i)\b(?:send|execute|run|dispatch)[a-z0-9_]*(?:rcon|rawcommand|command)\s*\(`),
|
||||
"desktop automation or screen capture": regexp.MustCompile(`(?i)\b(?:tesseract|gosseract|screenshot|robotgo|autogui|keybd_event|mouse_event|sendinput)\b`),
|
||||
"direct socket transport": regexp.MustCompile(`\bnet\.(?:Dial|DialTimeout)\s*\(`),
|
||||
|
||||
@@ -11,15 +11,10 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
ConfigSchemaVersion = 1
|
||||
PluginID = "game.scum"
|
||||
ProfileKey = "scum-client-manager"
|
||||
ProofEnvironment = "SCUM_COMPONENT_PROOF"
|
||||
SCUMDatabaseFileEnvironment = "SCUM_DB_FILE"
|
||||
TrajectorySourceSCUMSQLite = "scum-sqlite"
|
||||
TrajectoryStoreSharedPlatformMySQL = "shared-platform-mysql"
|
||||
DefaultTrajectoryCollectionIntervalSecs = 3
|
||||
DefaultTrajectoryCollectionMaxRows = 500
|
||||
ConfigSchemaVersion = 1
|
||||
PluginID = "game.scum"
|
||||
ProfileKey = "scum-client-manager"
|
||||
ProofEnvironment = "SCUM_COMPONENT_PROOF"
|
||||
)
|
||||
|
||||
var requiredCapabilities = []string{
|
||||
@@ -46,7 +41,6 @@ type Config struct {
|
||||
Capabilities []string `json:"capabilities" yaml:"capabilities"`
|
||||
Timing TimingConfig `json:"timing" yaml:"timing"`
|
||||
TLS TransportTLSConfig `json:"tls" yaml:"tls"`
|
||||
Trajectory TrajectoryConfig `json:"trajectory" yaml:"trajectory"`
|
||||
}
|
||||
|
||||
type PlatformConfig struct {
|
||||
@@ -86,15 +80,6 @@ type TransportTLSConfig struct {
|
||||
Policy string `json:"policy" yaml:"policy"`
|
||||
}
|
||||
|
||||
type TrajectoryConfig struct {
|
||||
Enabled bool `json:"enabled" yaml:"enabled"`
|
||||
Source string `json:"source" yaml:"source"`
|
||||
Store string `json:"store" yaml:"store"`
|
||||
FileEnv string `json:"fileEnv" yaml:"fileEnv"`
|
||||
IntervalSeconds int `json:"intervalSeconds" yaml:"intervalSeconds"`
|
||||
MaxRows int `json:"maxRows" yaml:"maxRows"`
|
||||
}
|
||||
|
||||
func LoadConfig(reader io.Reader) (Config, error) {
|
||||
decoder := yaml.NewDecoder(reader)
|
||||
decoder.KnownFields(true)
|
||||
@@ -109,7 +94,6 @@ func LoadConfig(reader io.Reader) (Config, error) {
|
||||
}
|
||||
return Config{}, fmt.Errorf("decode companion config: %w", err)
|
||||
}
|
||||
config.applyDefaults()
|
||||
if err := config.Validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
@@ -118,24 +102,6 @@ func LoadConfig(reader io.Reader) (Config, error) {
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (config *Config) applyDefaults() {
|
||||
if config.Trajectory.Source == "" {
|
||||
config.Trajectory.Source = TrajectorySourceSCUMSQLite
|
||||
}
|
||||
if config.Trajectory.Store == "" {
|
||||
config.Trajectory.Store = TrajectoryStoreSharedPlatformMySQL
|
||||
}
|
||||
if config.Trajectory.FileEnv == "" {
|
||||
config.Trajectory.FileEnv = SCUMDatabaseFileEnvironment
|
||||
}
|
||||
if config.Trajectory.IntervalSeconds == 0 {
|
||||
config.Trajectory.IntervalSeconds = DefaultTrajectoryCollectionIntervalSecs
|
||||
}
|
||||
if config.Trajectory.MaxRows == 0 {
|
||||
config.Trajectory.MaxRows = DefaultTrajectoryCollectionMaxRows
|
||||
}
|
||||
}
|
||||
|
||||
func (config Config) Validate() error {
|
||||
if config.SchemaVersion != ConfigSchemaVersion {
|
||||
return fmt.Errorf("companion config schema version is unsupported")
|
||||
@@ -168,43 +134,9 @@ func (config Config) Validate() error {
|
||||
if config.Timing.HeartbeatIntervalSeconds < 5 || config.Timing.HeartbeatIntervalSeconds > 300 || config.Timing.CommandPollIntervalSeconds < 1 || config.Timing.CommandPollIntervalSeconds > 60 || config.Timing.RequestTimeoutSeconds < 1 || config.Timing.RequestTimeoutSeconds > 60 {
|
||||
return fmt.Errorf("companion timing policy is invalid")
|
||||
}
|
||||
if err := config.Trajectory.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (config TrajectoryConfig) Validate() error {
|
||||
if config.Source != TrajectorySourceSCUMSQLite || config.Store != TrajectoryStoreSharedPlatformMySQL {
|
||||
return fmt.Errorf("SCUM trajectory collection mode is unsupported")
|
||||
}
|
||||
if !validCompanionEnvironmentName(config.FileEnv) {
|
||||
return fmt.Errorf("SCUM database file environment name is invalid")
|
||||
}
|
||||
if config.IntervalSeconds < 1 || config.IntervalSeconds > 3600 || config.MaxRows < 1 || config.MaxRows > 5000 {
|
||||
return fmt.Errorf("SCUM trajectory collection bounds are invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validCompanionEnvironmentName(value string) bool {
|
||||
if len(value) < 3 || len(value) > 64 || value[0] < 'A' || value[0] > 'Z' {
|
||||
return false
|
||||
}
|
||||
for _, char := range value[1:] {
|
||||
if char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || char == '_' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
switch value {
|
||||
case "PATH", "LD_PRELOAD", "DYLD_INSERT_LIBRARIES":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalPlatformOrigin(value string) (string, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(value))
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.Hostname() == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Path != "" && parsed.Path != "/" {
|
||||
|
||||
@@ -31,10 +31,3 @@ timing:
|
||||
requestTimeoutSeconds: 15
|
||||
tls:
|
||||
policy: verify-system-roots
|
||||
trajectory:
|
||||
enabled: true
|
||||
source: scum-sqlite
|
||||
store: shared-platform-mysql
|
||||
fileEnv: SCUM_DB_FILE
|
||||
intervalSeconds: 3
|
||||
maxRows: 500
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
func TestCompanionVehicleHandlerCapabilityIsExplicitAndBounded(t *testing.T) {
|
||||
base := append([]string(nil), requiredCapabilities...)
|
||||
@@ -18,29 +14,3 @@ func TestCompanionVehicleHandlerCapabilityIsExplicitAndBounded(t *testing.T) {
|
||||
t.Fatal("undeclared raw command handler capability must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigDeclaresRawTrajectoryCollection(t *testing.T) {
|
||||
config := loadTestConfig(t)
|
||||
if !config.Trajectory.Enabled || config.Trajectory.Source != TrajectorySourceSCUMSQLite || config.Trajectory.Store != TrajectoryStoreSharedPlatformMySQL {
|
||||
t.Fatalf("trajectory collection is not enabled with plugin-owned source/store: %+v", config.Trajectory)
|
||||
}
|
||||
if config.Trajectory.FileEnv != SCUMDatabaseFileEnvironment || config.Trajectory.IntervalSeconds != DefaultTrajectoryCollectionIntervalSecs || config.Trajectory.MaxRows != DefaultTrajectoryCollectionMaxRows {
|
||||
t.Fatalf("trajectory collection did not use bounded defaults: %+v", config.Trajectory)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrajectoryConfigRejectsUnsafeEnvironmentNames(t *testing.T) {
|
||||
fixture := strings.ReplaceAll(string(mustReadConfigFixture(t)), "fileEnv: SCUM_DB_FILE", "fileEnv: PATH")
|
||||
if _, err := LoadConfig(strings.NewReader(fixture)); err == nil || !strings.Contains(err.Error(), "environment") {
|
||||
t.Fatalf("expected reserved env name to be rejected, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustReadConfigFixture(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
payload, err := os.ReadFile("config.yaml.example")
|
||||
if err != nil {
|
||||
t.Fatalf("read config fixture: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type recordingConsoleLogStore struct {
|
||||
ensureCalls int
|
||||
records []ConsoleRecord
|
||||
batches []SemanticEventBatch
|
||||
}
|
||||
|
||||
func (store *recordingConsoleLogStore) EnsureSchema(context.Context) error {
|
||||
store.ensureCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *recordingConsoleLogStore) StoreConsoleRecords(_ context.Context, records []ConsoleRecord) (int, error) {
|
||||
store.records = append(store.records, records...)
|
||||
return len(records), nil
|
||||
}
|
||||
|
||||
func (store *recordingConsoleLogStore) StoreSemanticEventBatch(_ context.Context, batch SemanticEventBatch) (int, error) {
|
||||
store.batches = append(store.batches, batch)
|
||||
return len(batch.Events), nil
|
||||
}
|
||||
|
||||
func TestConsoleLogCollectorStoresLiveConsoleEventAndSemanticBatch(t *testing.T) {
|
||||
stamp := time.Date(2026, 8, 31, 4, 0, 0, 0, time.UTC)
|
||||
store := &recordingConsoleLogStore{}
|
||||
collector := NewConsoleLogCollector(nil, store, "server-1", "correlation-secret")
|
||||
handle := collector.handleEvent(context.Background())
|
||||
|
||||
if err := handle(LogStreamEvent{ServerInstanceID: "server-1", StreamID: "stream-1", Source: "process", StreamKey: "stdout", Entry: LogEntry{Seq: 11, Timestamp: stamp, Line: "SCUM LOGIN 76561198000000001 10.0.0.1"}}); err != nil {
|
||||
t.Fatalf("handle log event: %v", err)
|
||||
}
|
||||
if len(store.records) != 1 || store.records[0].ServerID != "server-1" || store.records[0].Stream != "stdout" || store.records[0].Sequence != 11 {
|
||||
t.Fatalf("collector did not store raw console record: %#v", store.records)
|
||||
}
|
||||
if len(store.batches) != 1 || len(store.batches[0].Events) != 1 {
|
||||
t.Fatalf("collector did not store semantic event batch: %#v", store.batches)
|
||||
}
|
||||
event := store.batches[0].Events[0]
|
||||
if event.Type != "scum.login" || event.PlayerID != "76561198000000001" || event.NetworkCorrelation == "" || event.NetworkCorrelation == "10.0.0.1" {
|
||||
t.Fatalf("unexpected semantic event: %#v", event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleLogCollectorIgnoresNonConsoleLiveLogEvents(t *testing.T) {
|
||||
store := &recordingConsoleLogStore{}
|
||||
collector := NewConsoleLogCollector(nil, store, "server-1", "correlation-secret")
|
||||
handle := collector.handleEvent(context.Background())
|
||||
|
||||
if err := handle(LogStreamEvent{ServerInstanceID: "server-1", StreamID: "stream-1", Source: "process", StreamKey: "scum.file", Entry: LogEntry{Seq: 12, Timestamp: time.Now().UTC(), Line: "not console"}}); err != nil {
|
||||
t.Fatalf("handle non-console event: %v", err)
|
||||
}
|
||||
if len(store.records) != 0 || len(store.batches) != 0 {
|
||||
t.Fatalf("non-console event was stored: records=%#v batches=%#v", store.records, store.batches)
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (store *SCUMSQLStore) StoreConsoleRecords(ctx context.Context, records []ConsoleRecord) (int, error) {
|
||||
if store == nil || store.db == nil {
|
||||
return 0, fmt.Errorf("SCUM plugin SQL store is not configured")
|
||||
}
|
||||
normalized := make([]ConsoleRecord, 0, len(records))
|
||||
for _, record := range records {
|
||||
value, err := normalizeConsoleRecord(record)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
normalized = append(normalized, value)
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
tx, err := store.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("begin SCUM console log write: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
stamp := time.Now().UTC()
|
||||
for _, record := range normalized {
|
||||
if _, err := tx.ExecContext(ctx, scumConsoleLogInsertSQL, scumConsoleRecordKey(record), record.ServerID, record.Stream, record.Sequence, record.OccurredAt, record.Text, stamp, stamp); err != nil {
|
||||
return 0, fmt.Errorf("write SCUM console log: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, fmt.Errorf("commit SCUM console logs: %w", err)
|
||||
}
|
||||
return len(normalized), nil
|
||||
}
|
||||
|
||||
func (store *SCUMSQLStore) StoreSemanticEventBatch(ctx context.Context, batch SemanticEventBatch) (int, error) {
|
||||
if store == nil || store.db == nil {
|
||||
return 0, fmt.Errorf("SCUM plugin SQL store is not configured")
|
||||
}
|
||||
normalized := make([]SemanticEvent, 0, len(batch.Events))
|
||||
for _, event := range batch.Events {
|
||||
value, err := normalizeSemanticEvent(event)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
normalized = append(normalized, value)
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
tx, err := store.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("begin SCUM semantic event write: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
stamp := time.Now().UTC()
|
||||
for _, event := range normalized {
|
||||
if _, err := tx.ExecContext(ctx, scumSemanticEventInsertSQL, scumSemanticEventRecordKey(event), event.ServerID, event.Sequence, event.Type, event.PlayerID, nullText(event.DisplayName), event.OccurredAt, nullText(event.NetworkCorrelation), stamp, stamp); err != nil {
|
||||
return 0, fmt.Errorf("write SCUM semantic event: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, fmt.Errorf("commit SCUM semantic events: %w", err)
|
||||
}
|
||||
return len(normalized), nil
|
||||
}
|
||||
|
||||
func normalizeConsoleRecord(record ConsoleRecord) (ConsoleRecord, error) {
|
||||
record.ServerID = strings.TrimSpace(record.ServerID)
|
||||
record.Stream = strings.TrimSpace(record.Stream)
|
||||
record.Text = strings.TrimRight(record.Text, "\r\n")
|
||||
if record.ServerID == "" || (record.Stream != "stdout" && record.Stream != "stderr") || record.Sequence == 0 || record.OccurredAt.IsZero() || strings.TrimSpace(record.Text) == "" || len(record.Text) > 8192 {
|
||||
return ConsoleRecord{}, fmt.Errorf("SCUM console record is invalid")
|
||||
}
|
||||
record.OccurredAt = record.OccurredAt.UTC()
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func normalizeSemanticEvent(event SemanticEvent) (SemanticEvent, error) {
|
||||
event.ServerID = strings.TrimSpace(event.ServerID)
|
||||
event.Type = strings.TrimSpace(event.Type)
|
||||
event.PlayerID = strings.TrimSpace(event.PlayerID)
|
||||
event.DisplayName = strings.TrimSpace(event.DisplayName)
|
||||
event.NetworkCorrelation = strings.TrimSpace(event.NetworkCorrelation)
|
||||
if event.ServerID == "" || event.Sequence == 0 || event.Type == "" || event.PlayerID == "" || event.OccurredAt.IsZero() || len(event.Type) > 80 || len(event.PlayerID) > 80 || len(event.DisplayName) > 120 || len(event.NetworkCorrelation) > 128 {
|
||||
return SemanticEvent{}, fmt.Errorf("SCUM semantic event is invalid")
|
||||
}
|
||||
event.OccurredAt = event.OccurredAt.UTC()
|
||||
return event, nil
|
||||
}
|
||||
|
||||
func scumConsoleRecordKey(record ConsoleRecord) string {
|
||||
digest := sha256.Sum256([]byte(strings.Join([]string{record.ServerID, record.Stream, fmt.Sprintf("%d", record.Sequence)}, "\x00")))
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func scumSemanticEventRecordKey(event SemanticEvent) string {
|
||||
digest := sha256.Sum256([]byte(strings.Join([]string{event.ServerID, event.Type, event.PlayerID, fmt.Sprintf("%d", event.Sequence)}, "\x00")))
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
const scumConsoleLogInsertSQL = `
|
||||
INSERT INTO scum_console_logs (
|
||||
record_key, server_instance_id, stream, sequence, occurred_at, line_text, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
occurred_at = VALUES(occurred_at),
|
||||
line_text = VALUES(line_text),
|
||||
updated_at = VALUES(updated_at)`
|
||||
|
||||
const scumSemanticEventInsertSQL = `
|
||||
INSERT INTO scum_semantic_events (
|
||||
record_key, server_instance_id, sequence, event_type, player_id, display_name, occurred_at, network_correlation, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
display_name = VALUES(display_name),
|
||||
occurred_at = VALUES(occurred_at),
|
||||
network_correlation = VALUES(network_correlation),
|
||||
updated_at = VALUES(updated_at)`
|
||||
@@ -10,9 +10,8 @@ import (
|
||||
)
|
||||
|
||||
// SafeAdapter is intentionally narrow: it receives typed values only and has
|
||||
// no direct transport, host-path, credential, or shell access. Game SQLite,
|
||||
// RCON, and management-program text stay behind declared typed ports; plugin-owned
|
||||
// durable writes use the dedicated SCUMSQLStore instead of browser page payloads.
|
||||
// no direct transport, host-path, credential, or shell access. Protected SQL,
|
||||
// RCON, and management-program text is forwarded to Run by Platform, not here.
|
||||
type SafeAdapter interface {
|
||||
ReadConfiguration(context.Context) (map[string]any, error)
|
||||
PatchConfiguration(context.Context, map[string]any) (map[string]any, error)
|
||||
|
||||
@@ -2,22 +2,4 @@ module browser.local/plugins/scum-server-plugin/companion
|
||||
|
||||
go 1.25.1
|
||||
|
||||
require (
|
||||
github.com/go-sql-driver/mysql v1.10.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.38.2
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
modernc.org/libc v1.66.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
|
||||
@@ -1,57 +1,4 @@
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
|
||||
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
||||
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
|
||||
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
||||
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
|
||||
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestClientStreamLogEventsUsesComponentSessionBodyAndSSE(t *testing.T) {
|
||||
stamp := time.Date(2026, 8, 31, 2, 0, 0, 0, time.UTC)
|
||||
config := loadTestConfig(t)
|
||||
client := newTestClient(t, config, roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
if request.Method != http.MethodPost || request.URL.Scheme != "https" || request.URL.Host != "platform.example.test" || request.URL.Path != logEventsPath {
|
||||
t.Fatalf("unexpected log stream request: %s %s", request.Method, request.URL.String())
|
||||
}
|
||||
if request.Header.Get("Authorization") != "" {
|
||||
t.Fatalf("component session must stay in the typed JSON body, got Authorization header")
|
||||
}
|
||||
if request.Header.Get("Accept") != "text/event-stream" || request.Header.Get("Content-Type") != "application/json" {
|
||||
t.Fatalf("unexpected log stream headers: %+v", request.Header)
|
||||
}
|
||||
var body logStreamRequest
|
||||
decodeRequest(t, request, &body)
|
||||
if body.SessionToken != "session-token" {
|
||||
t.Fatalf("unexpected session token body: %#v", body)
|
||||
}
|
||||
logPayload, _ := json.Marshal(LogStreamEvent{ServerInstanceID: "server-1", StreamID: "stream-1", Source: "process", StreamKey: "stdout", LogSessionID: "session-live", SessionStartedAt: stamp, LatestSeq: 7, Entry: LogEntry{Seq: 7, Timestamp: stamp.Add(time.Second), Line: "SCUM LOGIN 76561198000000001 10.0.0.1", Redacted: true}})
|
||||
bodyText := strings.Join([]string{
|
||||
"event: ready",
|
||||
"data: {\"serverInstanceId\":\"server-1\"}",
|
||||
"",
|
||||
"event: log",
|
||||
"data: " + string(logPayload),
|
||||
"",
|
||||
": heartbeat",
|
||||
"",
|
||||
}, "\n")
|
||||
return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(bodyText)), Request: request}, nil
|
||||
}), stamp)
|
||||
client.mu.Lock()
|
||||
client.sessionToken = "session-token"
|
||||
client.sessionExpiresAt = stamp.Add(time.Hour)
|
||||
client.mu.Unlock()
|
||||
|
||||
var events []LogStreamEvent
|
||||
if err := client.StreamLogEvents(context.Background(), func(event LogStreamEvent) error {
|
||||
events = append(events, event)
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("stream log events: %v", err)
|
||||
}
|
||||
if len(events) != 1 || events[0].Entry.Seq != 7 || events[0].Entry.Line != "SCUM LOGIN 76561198000000001 10.0.0.1" {
|
||||
t.Fatalf("unexpected streamed log events: %#v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLogEventStreamRejectsMalformedLogEvent(t *testing.T) {
|
||||
err := readLogEventStream(context.Background(), strings.NewReader("event: log\ndata: {not-json}\n\n"), func(LogStreamEvent) error { return nil })
|
||||
if err == nil || !strings.Contains(err.Error(), "decode log event") {
|
||||
t.Fatalf("expected malformed log event rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
var openSQLite = sql.Open
|
||||
|
||||
type SCUMSQLiteSource struct{ db *sql.DB }
|
||||
|
||||
func NewSCUMSQLiteSource(db *sql.DB) (*SCUMSQLiteSource, error) {
|
||||
if db == nil {
|
||||
return nil, fmt.Errorf("SCUM database source is required")
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
return &SCUMSQLiteSource{db: db}, nil
|
||||
}
|
||||
|
||||
func OpenSCUMSQLiteSourceFromEnv(envName string) (*SCUMSQLiteSource, error) {
|
||||
name := strings.TrimSpace(envName)
|
||||
if name == "" {
|
||||
name = SCUMDatabaseFileEnvironment
|
||||
}
|
||||
databaseFile := strings.TrimSpace(os.Getenv(name))
|
||||
if databaseFile == "" {
|
||||
return nil, fmt.Errorf("%s is required for SCUM database collection", name)
|
||||
}
|
||||
info, err := os.Stat(databaseFile)
|
||||
if err != nil || info.IsDir() {
|
||||
return nil, fmt.Errorf("%s must reference a readable SCUM database file", name)
|
||||
}
|
||||
db, err := openSQLite("sqlite", databaseFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open SCUM database source: %w", err)
|
||||
}
|
||||
if _, err := db.Exec("PRAGMA query_only = ON"); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("prepare SCUM database source for read-only collection: %w", err)
|
||||
}
|
||||
if _, err := db.Exec("PRAGMA busy_timeout = 5000"); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("prepare SCUM database source timeout: %w", err)
|
||||
}
|
||||
return NewSCUMSQLiteSource(db)
|
||||
}
|
||||
|
||||
func (source *SCUMSQLiteSource) Close() error {
|
||||
if source == nil || source.db == nil {
|
||||
return nil
|
||||
}
|
||||
return source.db.Close()
|
||||
}
|
||||
|
||||
func (source *SCUMSQLiteSource) ReadPositionRows(ctx context.Context, limit int) ([]map[string]any, error) {
|
||||
return source.readRows(ctx, scumPositionRowsSQL, limit,
|
||||
sql.Named("subjectType", nil),
|
||||
sql.Named("subjectId", nil),
|
||||
sql.Named("limit", boundedTrajectoryLimit(limit)),
|
||||
)
|
||||
}
|
||||
|
||||
func (source *SCUMSQLiteSource) ReadVehicleRows(ctx context.Context, limit int) ([]map[string]any, error) {
|
||||
return source.readRows(ctx, scumVehicleRowsSQL, limit,
|
||||
sql.Named("vehicleId", nil),
|
||||
sql.Named("search", nil),
|
||||
sql.Named("limit", boundedTrajectoryLimit(limit)),
|
||||
)
|
||||
}
|
||||
|
||||
func (source *SCUMSQLiteSource) readRows(ctx context.Context, query string, limit int, args ...any) ([]map[string]any, error) {
|
||||
if source == nil || source.db == nil {
|
||||
return nil, fmt.Errorf("SCUM database source is not configured")
|
||||
}
|
||||
rows, err := source.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read SCUM database rows: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
columns, err := rows.Columns()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read SCUM database columns: %w", err)
|
||||
}
|
||||
maxRows := boundedTrajectoryLimit(limit)
|
||||
result := make([]map[string]any, 0, maxRows)
|
||||
values := make([]any, len(columns))
|
||||
scanTargets := make([]any, len(columns))
|
||||
for index := range values {
|
||||
scanTargets[index] = &values[index]
|
||||
}
|
||||
for rows.Next() {
|
||||
if len(result) >= maxRows {
|
||||
break
|
||||
}
|
||||
if err := rows.Scan(scanTargets...); err != nil {
|
||||
return nil, fmt.Errorf("scan SCUM database rows: %w", err)
|
||||
}
|
||||
row := make(map[string]any, len(columns))
|
||||
for index, column := range columns {
|
||||
row[column] = normalizeSQLiteValue(values[index])
|
||||
}
|
||||
result = append(result, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("read SCUM database rows: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func boundedTrajectoryLimit(limit int) int {
|
||||
if limit <= 0 {
|
||||
return DefaultTrajectoryCollectionMaxRows
|
||||
}
|
||||
if limit > 5000 {
|
||||
return 5000
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func normalizeSQLiteValue(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case []byte:
|
||||
return string(typed)
|
||||
case time.Time:
|
||||
return typed.UTC().Format(time.RFC3339Nano)
|
||||
default:
|
||||
return typed
|
||||
}
|
||||
}
|
||||
|
||||
const scumPositionRowsSQL = `SELECT
|
||||
'player' AS subjectType,
|
||||
account.id AS subjectId,
|
||||
CAST(profile.id AS TEXT) AS userProfileId,
|
||||
CAST(prisoner.id AS TEXT) AS gamePlayerId,
|
||||
NULL AS vehicleId,
|
||||
CAST(entity.id AS TEXT) AS entityId,
|
||||
NULL AS baseId,
|
||||
entity.location_x AS x,
|
||||
entity.location_y AS y,
|
||||
entity.location_z AS z,
|
||||
strftime('%Y-%m-%dT%H:%M:%SZ', prisoner.last_save_time, 'unixepoch') AS observedAt
|
||||
FROM user_profile profile
|
||||
JOIN user account ON account.id = profile.user_id
|
||||
JOIN prisoner ON prisoner.id = profile.prisoner_id
|
||||
JOIN prisoner_entity ON prisoner_entity.prisoner_id = prisoner.id
|
||||
JOIN entity ON entity.id = prisoner_entity.entity_id
|
||||
WHERE (:subjectType IS NULL OR :subjectType = 'player')
|
||||
AND (:subjectId IS NULL OR account.id = :subjectId)
|
||||
UNION ALL
|
||||
SELECT
|
||||
'vehicle', CAST(spawner.vehicle_entity_id AS TEXT), NULL, NULL,
|
||||
CAST(spawner.vehicle_entity_id AS TEXT), CAST(entity.id AS TEXT), NULL,
|
||||
entity.location_x, entity.location_y, entity.location_z,
|
||||
strftime('%Y-%m-%dT%H:%M:%SZ', spawner.vehicle_last_access_time, 'unixepoch')
|
||||
FROM vehicle_spawner spawner
|
||||
JOIN entity ON entity.id = spawner.vehicle_entity_id
|
||||
WHERE (:subjectType IS NULL OR :subjectType = 'vehicle')
|
||||
AND (:subjectId IS NULL OR CAST(spawner.vehicle_entity_id AS TEXT) = :subjectId)
|
||||
LIMIT COALESCE(:limit, 500)`
|
||||
|
||||
const scumVehicleRowsSQL = `SELECT
|
||||
CAST(spawner.vehicle_entity_id AS TEXT) AS vehicleId,
|
||||
CAST(spawner.vehicle_entity_id AS TEXT) AS entityId,
|
||||
entity.class AS className,
|
||||
spawner.vehicle_alias AS label,
|
||||
entity.location_x AS x,
|
||||
entity.location_y AS y,
|
||||
entity.location_z AS z,
|
||||
strftime('%Y-%m-%dT%H:%M:%SZ', spawner.vehicle_last_access_time, 'unixepoch') AS lastAccessTime,
|
||||
spawner.is_vehicle_functional AS isFunctional
|
||||
FROM vehicle_spawner spawner
|
||||
JOIN entity ON entity.id = spawner.vehicle_entity_id
|
||||
WHERE (:vehicleId IS NULL OR CAST(spawner.vehicle_entity_id AS TEXT) = :vehicleId)
|
||||
AND (:search IS NULL OR spawner.vehicle_alias LIKE '%' || :search || '%' OR entity.class LIKE '%' || :search || '%')
|
||||
ORDER BY spawner.vehicle_last_access_time DESC
|
||||
LIMIT COALESCE(:limit, 500)`
|
||||
@@ -1,80 +0,0 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSCUMSQLiteSourceReadsRawCoordinates(t *testing.T) {
|
||||
databaseFile := filepath.Join(t.TempDir(), "SCUM.db")
|
||||
db, err := sql.Open("sqlite", databaseFile)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite fixture: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
for _, statement := range []string{
|
||||
`CREATE TABLE user (id TEXT PRIMARY KEY)`,
|
||||
`CREATE TABLE user_profile (id INTEGER PRIMARY KEY, user_id TEXT NOT NULL, prisoner_id INTEGER NOT NULL)`,
|
||||
`CREATE TABLE prisoner (id INTEGER PRIMARY KEY, last_save_time INTEGER NOT NULL)`,
|
||||
`CREATE TABLE prisoner_entity (prisoner_id INTEGER NOT NULL, entity_id INTEGER NOT NULL)`,
|
||||
`CREATE TABLE entity (id INTEGER PRIMARY KEY, class TEXT, location_x REAL NOT NULL, location_y REAL NOT NULL, location_z REAL NOT NULL)`,
|
||||
`CREATE TABLE vehicle_spawner (vehicle_entity_id INTEGER PRIMARY KEY, vehicle_alias TEXT, vehicle_last_access_time INTEGER NOT NULL, is_vehicle_functional INTEGER NOT NULL)`,
|
||||
`INSERT INTO user (id) VALUES ('76561198000000001')`,
|
||||
`INSERT INTO prisoner (id, last_save_time) VALUES (2001, 1788146999)`,
|
||||
`INSERT INTO user_profile (id, user_id, prisoner_id) VALUES (1001, '76561198000000001', 2001)`,
|
||||
`INSERT INTO entity (id, class, location_x, location_y, location_z) VALUES (3001, 'BP_Prisoner_C', 123.25, -456.5, 7.75)`,
|
||||
`INSERT INTO prisoner_entity (prisoner_id, entity_id) VALUES (2001, 3001)`,
|
||||
`INSERT INTO entity (id, class, location_x, location_y, location_z) VALUES (4001, 'BPC_Laika_C', -10.5, 20.25, 0)`,
|
||||
`INSERT INTO vehicle_spawner (vehicle_entity_id, vehicle_alias, vehicle_last_access_time, is_vehicle_functional) VALUES (4001, 'Laika', 1788146988, 1)`,
|
||||
} {
|
||||
if _, err := db.Exec(statement); err != nil {
|
||||
t.Fatalf("exec sqlite fixture statement %q: %v", statement, err)
|
||||
}
|
||||
}
|
||||
source, err := NewSCUMSQLiteSource(db)
|
||||
if err != nil {
|
||||
t.Fatalf("create sqlite source: %v", err)
|
||||
}
|
||||
positions, err := source.ReadPositionRows(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("read positions: %v", err)
|
||||
}
|
||||
vehicles, err := source.ReadVehicleRows(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("read vehicles: %v", err)
|
||||
}
|
||||
player := rowByText(t, positions, "subjectType", "player")
|
||||
vehiclePosition := rowByText(t, positions, "subjectType", "vehicle")
|
||||
vehicle := rowByText(t, vehicles, "vehicleId", "4001")
|
||||
assertNumber(t, player["x"], 123.25)
|
||||
assertNumber(t, player["y"], -456.5)
|
||||
assertNumber(t, player["z"], 7.75)
|
||||
assertNumber(t, vehiclePosition["x"], -10.5)
|
||||
assertNumber(t, vehiclePosition["y"], 20.25)
|
||||
assertNumber(t, vehicle["x"], -10.5)
|
||||
assertNumber(t, vehicle["y"], 20.25)
|
||||
if vehicle["className"] != "BPC_Laika_C" || vehicle["label"] != "Laika" {
|
||||
t.Fatalf("vehicle metadata changed: %+v", vehicle)
|
||||
}
|
||||
}
|
||||
|
||||
func rowByText(t *testing.T, rows []map[string]any, key string, value string) map[string]any {
|
||||
t.Helper()
|
||||
for _, row := range rows {
|
||||
if textFromRow(row[key]) == value {
|
||||
return row
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing row where %s=%s: %+v", key, value, rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
func assertNumber(t *testing.T, value any, expected float64) {
|
||||
t.Helper()
|
||||
actual, ok := numberFromRow(value)
|
||||
if !ok || actual != expected {
|
||||
t.Fatalf("number = %v, want %v", value, expected)
|
||||
}
|
||||
}
|
||||
@@ -1,400 +0,0 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
const PlatformMySQLDSNEnvironment = "PLATFORM_MYSQL_DSN"
|
||||
|
||||
var openSQL = sql.Open
|
||||
|
||||
type SCUMSQLStore struct{ db *sql.DB }
|
||||
|
||||
type TrajectorySample struct {
|
||||
ServerInstanceID string
|
||||
SubjectType string
|
||||
SubjectID string
|
||||
SteamID string
|
||||
UserProfileID string
|
||||
GamePlayerID string
|
||||
VehicleID string
|
||||
EntityID string
|
||||
BaseID string
|
||||
DisplayName string
|
||||
Label string
|
||||
ClassName string
|
||||
WorldX float64
|
||||
WorldY float64
|
||||
WorldZ *float64
|
||||
ObservedAt time.Time
|
||||
SampledAt time.Time
|
||||
Source string
|
||||
}
|
||||
|
||||
func NewSCUMSQLStore(db *sql.DB) (*SCUMSQLStore, error) {
|
||||
if db == nil {
|
||||
return nil, fmt.Errorf("platform SQL handle is required")
|
||||
}
|
||||
return &SCUMSQLStore{db: db}, nil
|
||||
}
|
||||
|
||||
func (store *SCUMSQLStore) Close() error {
|
||||
if store == nil || store.db == nil {
|
||||
return nil
|
||||
}
|
||||
return store.db.Close()
|
||||
}
|
||||
|
||||
func OpenSCUMSQLStoreFromEnv(envName string) (*SCUMSQLStore, error) {
|
||||
name := strings.TrimSpace(envName)
|
||||
if name == "" {
|
||||
name = PlatformMySQLDSNEnvironment
|
||||
}
|
||||
dsn := strings.TrimSpace(os.Getenv(name))
|
||||
if dsn == "" {
|
||||
return nil, fmt.Errorf("%s is required for SCUM plugin SQL storage", name)
|
||||
}
|
||||
db, err := openSQL("mysql", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open SCUM plugin SQL storage: %w", err)
|
||||
}
|
||||
return NewSCUMSQLStore(db)
|
||||
}
|
||||
|
||||
func (store *SCUMSQLStore) EnsureSchema(ctx context.Context) error {
|
||||
if store == nil || store.db == nil {
|
||||
return fmt.Errorf("SCUM plugin SQL store is not configured")
|
||||
}
|
||||
for _, statement := range scumSQLStoreMigrations {
|
||||
if _, err := store.db.ExecContext(ctx, statement); err != nil {
|
||||
return fmt.Errorf("apply SCUM plugin SQL migration: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *SCUMSQLStore) StorePositionRows(ctx context.Context, serverInstanceID string, rows []map[string]any, sampledAt time.Time) (int, error) {
|
||||
samples, err := TrajectorySamplesFromPositionRows(serverInstanceID, rows, sampledAt)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return store.StoreTrajectorySamples(ctx, samples)
|
||||
}
|
||||
|
||||
func (store *SCUMSQLStore) StoreVehicleRows(ctx context.Context, serverInstanceID string, rows []map[string]any, sampledAt time.Time) (int, error) {
|
||||
samples, err := TrajectorySamplesFromVehicleRows(serverInstanceID, rows, sampledAt)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return store.StoreTrajectorySamples(ctx, samples)
|
||||
}
|
||||
|
||||
func (store *SCUMSQLStore) StoreTrajectorySamples(ctx context.Context, samples []TrajectorySample) (int, error) {
|
||||
if store == nil || store.db == nil {
|
||||
return 0, fmt.Errorf("SCUM plugin SQL store is not configured")
|
||||
}
|
||||
normalized := make([]TrajectorySample, 0, len(samples))
|
||||
for _, sample := range samples {
|
||||
value, err := normalizeTrajectorySample(sample)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
normalized = append(normalized, value)
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
tx, err := store.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("begin SCUM trajectory write: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
stamp := time.Now().UTC()
|
||||
for _, sample := range normalized {
|
||||
args := scumTrajectoryInsertArgs(sample, stamp)
|
||||
if _, err := tx.ExecContext(ctx, scumTrajectoryInsertSQL, args...); err != nil {
|
||||
return 0, fmt.Errorf("write SCUM trajectory sample: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, fmt.Errorf("commit SCUM trajectory samples: %w", err)
|
||||
}
|
||||
return len(normalized), nil
|
||||
}
|
||||
|
||||
func TrajectorySamplesFromPositionRows(serverInstanceID string, rows []map[string]any, sampledAt time.Time) ([]TrajectorySample, error) {
|
||||
result := make([]TrajectorySample, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
subjectType := textFromRow(row["subjectType"])
|
||||
if subjectType != "player" && subjectType != "vehicle" {
|
||||
continue
|
||||
}
|
||||
x, xOK := numberFromRow(row["x"])
|
||||
y, yOK := numberFromRow(row["y"])
|
||||
if !xOK || !yOK {
|
||||
continue
|
||||
}
|
||||
z := optionalNumberFromRow(row["z"])
|
||||
subjectID := textFromRow(row["subjectId"])
|
||||
sample := TrajectorySample{
|
||||
ServerInstanceID: serverInstanceID,
|
||||
SubjectType: subjectType,
|
||||
SubjectID: subjectID,
|
||||
UserProfileID: textFromRow(row["userProfileId"]),
|
||||
GamePlayerID: textFromRow(row["gamePlayerId"]),
|
||||
VehicleID: textFromRow(row["vehicleId"]),
|
||||
EntityID: textFromRow(row["entityId"]),
|
||||
BaseID: textFromRow(row["baseId"]),
|
||||
WorldX: x,
|
||||
WorldY: y,
|
||||
WorldZ: z,
|
||||
ObservedAt: timestampFromRow(row["observedAt"], sampledAt),
|
||||
SampledAt: sampledAt,
|
||||
Source: "plugin.sql.scum.positions",
|
||||
}
|
||||
if sample.SubjectType == "player" {
|
||||
sample.SteamID = sample.SubjectID
|
||||
}
|
||||
result = append(result, sample)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func TrajectorySamplesFromVehicleRows(serverInstanceID string, rows []map[string]any, sampledAt time.Time) ([]TrajectorySample, error) {
|
||||
result := make([]TrajectorySample, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
x, xOK := numberFromRow(row["x"])
|
||||
y, yOK := numberFromRow(row["y"])
|
||||
if !xOK || !yOK {
|
||||
continue
|
||||
}
|
||||
vehicleID := textFromRow(row["vehicleId"])
|
||||
result = append(result, TrajectorySample{
|
||||
ServerInstanceID: serverInstanceID,
|
||||
SubjectType: "vehicle",
|
||||
SubjectID: vehicleID,
|
||||
VehicleID: vehicleID,
|
||||
EntityID: textFromRow(row["entityId"]),
|
||||
Label: textFromRow(row["label"]),
|
||||
ClassName: textFromRow(row["className"]),
|
||||
WorldX: x,
|
||||
WorldY: y,
|
||||
WorldZ: optionalNumberFromRow(row["z"]),
|
||||
ObservedAt: timestampFromRow(row["lastAccessTime"], sampledAt),
|
||||
SampledAt: sampledAt,
|
||||
Source: "plugin.sql.scum.vehicles",
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizeTrajectorySample(sample TrajectorySample) (TrajectorySample, error) {
|
||||
sample.ServerInstanceID = strings.TrimSpace(sample.ServerInstanceID)
|
||||
sample.SubjectType = strings.TrimSpace(sample.SubjectType)
|
||||
sample.SubjectID = strings.TrimSpace(sample.SubjectID)
|
||||
sample.SteamID = strings.TrimSpace(sample.SteamID)
|
||||
sample.UserProfileID = strings.TrimSpace(sample.UserProfileID)
|
||||
sample.GamePlayerID = strings.TrimSpace(sample.GamePlayerID)
|
||||
sample.VehicleID = strings.TrimSpace(sample.VehicleID)
|
||||
sample.EntityID = strings.TrimSpace(sample.EntityID)
|
||||
sample.BaseID = strings.TrimSpace(sample.BaseID)
|
||||
sample.DisplayName = strings.TrimSpace(sample.DisplayName)
|
||||
sample.Label = strings.TrimSpace(sample.Label)
|
||||
sample.ClassName = strings.TrimSpace(sample.ClassName)
|
||||
sample.Source = strings.TrimSpace(sample.Source)
|
||||
if sample.ServerInstanceID == "" || sample.SubjectID == "" || sample.SampledAt.IsZero() || !validTrajectorySubjectType(sample.SubjectType) || !finite(sample.WorldX) || !finite(sample.WorldY) || sample.WorldZ != nil && !finite(*sample.WorldZ) {
|
||||
return TrajectorySample{}, fmt.Errorf("SCUM trajectory sample is invalid")
|
||||
}
|
||||
if sample.ObservedAt.IsZero() {
|
||||
sample.ObservedAt = sample.SampledAt
|
||||
}
|
||||
if sample.Source == "" {
|
||||
sample.Source = "plugin.sql.scum"
|
||||
}
|
||||
if !boundedTrajectoryTexts(sample) {
|
||||
return TrajectorySample{}, fmt.Errorf("SCUM trajectory sample text is too long")
|
||||
}
|
||||
sample.ObservedAt = sample.ObservedAt.UTC()
|
||||
sample.SampledAt = sample.SampledAt.UTC()
|
||||
return sample, nil
|
||||
}
|
||||
|
||||
func scumTrajectoryInsertArgs(sample TrajectorySample, stamp time.Time) []any {
|
||||
return []any{
|
||||
scumTrajectoryRecordKey(sample), sample.ServerInstanceID, sample.SubjectType, sample.SubjectID,
|
||||
nullText(sample.SteamID), nullText(sample.UserProfileID), nullText(sample.GamePlayerID), nullText(sample.VehicleID), nullText(sample.EntityID), nullText(sample.BaseID),
|
||||
nullText(sample.DisplayName), nullText(sample.Label), nullText(sample.ClassName), sample.WorldX, sample.WorldY, nullFloat(sample.WorldZ), sample.ObservedAt, sample.SampledAt, sample.Source, stamp, stamp,
|
||||
}
|
||||
}
|
||||
|
||||
func scumTrajectoryRecordKey(sample TrajectorySample) string {
|
||||
digest := sha256.Sum256([]byte(strings.Join([]string{sample.ServerInstanceID, sample.SubjectType, sample.SubjectID, sample.SampledAt.UTC().Format(time.RFC3339Nano)}, "\x00")))
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func validTrajectorySubjectType(value string) bool { return value == "player" || value == "vehicle" }
|
||||
func finite(value float64) bool { return !math.IsNaN(value) && !math.IsInf(value, 0) }
|
||||
func nullText(value string) any {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
func nullFloat(value *float64) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func boundedTrajectoryTexts(sample TrajectorySample) bool {
|
||||
return len(sample.ServerInstanceID) <= 96 && len(sample.SubjectType) <= 16 && len(sample.SubjectID) <= 128 && len(sample.SteamID) <= 32 && len(sample.UserProfileID) <= 96 && len(sample.GamePlayerID) <= 96 && len(sample.VehicleID) <= 96 && len(sample.EntityID) <= 96 && len(sample.BaseID) <= 96 && len(sample.DisplayName) <= 120 && len(sample.Label) <= 120 && len(sample.ClassName) <= 160 && len(sample.Source) <= 80
|
||||
}
|
||||
|
||||
func textFromRow(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return strings.TrimSpace(typed)
|
||||
case json.Number:
|
||||
return typed.String()
|
||||
default:
|
||||
return strings.TrimSpace(fmt.Sprint(typed))
|
||||
}
|
||||
}
|
||||
|
||||
func numberFromRow(value any) (float64, bool) {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return 0, false
|
||||
case float64:
|
||||
return typed, finite(typed)
|
||||
case float32:
|
||||
value := float64(typed)
|
||||
return value, finite(value)
|
||||
case int:
|
||||
return float64(typed), true
|
||||
case int64:
|
||||
return float64(typed), true
|
||||
case int32:
|
||||
return float64(typed), true
|
||||
case json.Number:
|
||||
value, err := typed.Float64()
|
||||
return value, err == nil && finite(value)
|
||||
case string:
|
||||
value, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
|
||||
return value, err == nil && finite(value)
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func optionalNumberFromRow(value any) *float64 {
|
||||
number, ok := numberFromRow(value)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return &number
|
||||
}
|
||||
|
||||
func timestampFromRow(value any, fallback time.Time) time.Time {
|
||||
text := textFromRow(value)
|
||||
if text == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339Nano, text)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
var scumSQLStoreMigrations = []string{`
|
||||
CREATE TABLE IF NOT EXISTS scum_trajectories (
|
||||
record_key CHAR(64) PRIMARY KEY,
|
||||
server_instance_id VARCHAR(96) NOT NULL,
|
||||
subject_type VARCHAR(16) NOT NULL,
|
||||
subject_id VARCHAR(128) NOT NULL,
|
||||
steam_id VARCHAR(32) NULL,
|
||||
user_profile_id VARCHAR(96) NULL,
|
||||
game_player_id VARCHAR(96) NULL,
|
||||
vehicle_id VARCHAR(96) NULL,
|
||||
entity_id VARCHAR(96) NULL,
|
||||
base_id VARCHAR(96) NULL,
|
||||
display_name VARCHAR(120) NULL,
|
||||
label VARCHAR(120) NULL,
|
||||
class_name VARCHAR(160) NULL,
|
||||
world_x DOUBLE NOT NULL,
|
||||
world_y DOUBLE NOT NULL,
|
||||
world_z DOUBLE NULL,
|
||||
observed_at DATETIME(6) NOT NULL,
|
||||
sampled_at DATETIME(6) NOT NULL,
|
||||
source VARCHAR(80) NOT NULL,
|
||||
created_at DATETIME(6) NOT NULL,
|
||||
updated_at DATETIME(6) NOT NULL,
|
||||
UNIQUE KEY scum_trajectories_sample_uq (server_instance_id, subject_type, subject_id, sampled_at),
|
||||
KEY scum_trajectories_subject_idx (server_instance_id, subject_type, subject_id, observed_at),
|
||||
KEY scum_trajectories_sampled_idx (server_instance_id, sampled_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `
|
||||
CREATE TABLE IF NOT EXISTS scum_console_logs (
|
||||
record_key CHAR(64) PRIMARY KEY,
|
||||
server_instance_id VARCHAR(96) NOT NULL,
|
||||
stream VARCHAR(16) NOT NULL,
|
||||
sequence BIGINT UNSIGNED NOT NULL,
|
||||
occurred_at DATETIME(6) NOT NULL,
|
||||
line_text TEXT NOT NULL,
|
||||
created_at DATETIME(6) NOT NULL,
|
||||
updated_at DATETIME(6) NOT NULL,
|
||||
UNIQUE KEY scum_console_logs_stream_uq (server_instance_id, stream, sequence),
|
||||
KEY scum_console_logs_time_idx (server_instance_id, occurred_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `
|
||||
CREATE TABLE IF NOT EXISTS scum_semantic_events (
|
||||
record_key CHAR(64) PRIMARY KEY,
|
||||
server_instance_id VARCHAR(96) NOT NULL,
|
||||
sequence BIGINT UNSIGNED NOT NULL,
|
||||
event_type VARCHAR(80) NOT NULL,
|
||||
player_id VARCHAR(80) NOT NULL,
|
||||
display_name VARCHAR(120) NULL,
|
||||
occurred_at DATETIME(6) NOT NULL,
|
||||
network_correlation VARCHAR(128) NULL,
|
||||
created_at DATETIME(6) NOT NULL,
|
||||
updated_at DATETIME(6) NOT NULL,
|
||||
UNIQUE KEY scum_semantic_events_uq (server_instance_id, event_type, player_id, sequence),
|
||||
KEY scum_semantic_events_player_idx (server_instance_id, player_id, occurred_at),
|
||||
KEY scum_semantic_events_type_idx (server_instance_id, event_type, occurred_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`}
|
||||
|
||||
const scumTrajectoryInsertSQL = `
|
||||
INSERT INTO scum_trajectories (
|
||||
record_key, server_instance_id, subject_type, subject_id, steam_id, user_profile_id, game_player_id, vehicle_id, entity_id, base_id,
|
||||
display_name, label, class_name, world_x, world_y, world_z, observed_at, sampled_at, source, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
steam_id = VALUES(steam_id),
|
||||
user_profile_id = VALUES(user_profile_id),
|
||||
game_player_id = VALUES(game_player_id),
|
||||
vehicle_id = VALUES(vehicle_id),
|
||||
entity_id = VALUES(entity_id),
|
||||
base_id = VALUES(base_id),
|
||||
display_name = VALUES(display_name),
|
||||
label = VALUES(label),
|
||||
class_name = VALUES(class_name),
|
||||
world_x = VALUES(world_x),
|
||||
world_y = VALUES(world_y),
|
||||
world_z = VALUES(world_z),
|
||||
observed_at = VALUES(observed_at),
|
||||
source = VALUES(source),
|
||||
updated_at = VALUES(updated_at)`
|
||||
@@ -1,238 +0,0 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const recordingSQLDriverName = "scum_companion_recording_sql"
|
||||
|
||||
var (
|
||||
recordingSQLDriverOnce sync.Once
|
||||
activeSQLRecorder *sqlRecorder
|
||||
)
|
||||
|
||||
type sqlRecorder struct {
|
||||
mu sync.Mutex
|
||||
dsn string
|
||||
statements []string
|
||||
args [][]driver.NamedValue
|
||||
commits int
|
||||
}
|
||||
|
||||
func (recorder *sqlRecorder) append(query string, args []driver.NamedValue) {
|
||||
recorder.mu.Lock()
|
||||
defer recorder.mu.Unlock()
|
||||
recorder.statements = append(recorder.statements, query)
|
||||
recorder.args = append(recorder.args, append([]driver.NamedValue(nil), args...))
|
||||
}
|
||||
|
||||
func (recorder *sqlRecorder) committed() {
|
||||
recorder.mu.Lock()
|
||||
defer recorder.mu.Unlock()
|
||||
recorder.commits++
|
||||
}
|
||||
|
||||
type recordingSQLDriver struct{}
|
||||
type recordingSQLConn struct{ recorder *sqlRecorder }
|
||||
type recordingSQLTx struct{ recorder *sqlRecorder }
|
||||
|
||||
func (recordingSQLDriver) Open(dsn string) (driver.Conn, error) {
|
||||
if activeSQLRecorder == nil {
|
||||
return nil, errors.New("recording SQL recorder is not configured")
|
||||
}
|
||||
activeSQLRecorder.dsn = dsn
|
||||
return &recordingSQLConn{recorder: activeSQLRecorder}, nil
|
||||
}
|
||||
func (conn *recordingSQLConn) Prepare(string) (driver.Stmt, error) {
|
||||
return nil, errors.New("prepare is not supported")
|
||||
}
|
||||
func (conn *recordingSQLConn) Close() error { return nil }
|
||||
func (conn *recordingSQLConn) Begin() (driver.Tx, error) {
|
||||
return &recordingSQLTx{recorder: conn.recorder}, nil
|
||||
}
|
||||
func (conn *recordingSQLConn) BeginTx(context.Context, driver.TxOptions) (driver.Tx, error) {
|
||||
return &recordingSQLTx{recorder: conn.recorder}, nil
|
||||
}
|
||||
func (conn *recordingSQLConn) ExecContext(_ context.Context, query string, args []driver.NamedValue) (driver.Result, error) {
|
||||
conn.recorder.append(query, args)
|
||||
return driver.RowsAffected(1), nil
|
||||
}
|
||||
func (tx *recordingSQLTx) Commit() error { tx.recorder.committed(); return nil }
|
||||
func (tx *recordingSQLTx) Rollback() error { return nil }
|
||||
|
||||
func TestSCUMSQLStoreWritesTrajectorySamplesWithoutCoordinateConversion(t *testing.T) {
|
||||
db, recorder := newRecordingSQLDB(t, "direct-db")
|
||||
store, err := NewSCUMSQLStore(db)
|
||||
if err != nil {
|
||||
t.Fatalf("create SCUM SQL store: %v", err)
|
||||
}
|
||||
if err := store.EnsureSchema(context.Background()); err != nil {
|
||||
t.Fatalf("ensure schema: %v", err)
|
||||
}
|
||||
z := 7.75
|
||||
sampledAt := time.Date(2026, 8, 28, 7, 30, 0, 123456000, time.UTC)
|
||||
written, err := store.StoreTrajectorySamples(context.Background(), []TrajectorySample{{
|
||||
ServerInstanceID: "server-1", SubjectType: "player", SubjectID: "76561198000000001", SteamID: "76561198000000001", UserProfileID: "profile-1", GamePlayerID: "player-1",
|
||||
WorldX: 123.5, WorldY: -456.25, WorldZ: &z, ObservedAt: sampledAt.Add(-time.Second), SampledAt: sampledAt, Source: "plugin.sql.scum.positions",
|
||||
}})
|
||||
if err != nil || written != 1 {
|
||||
t.Fatalf("store trajectory sample: written=%d err=%v", written, err)
|
||||
}
|
||||
if recorder.commits != 1 {
|
||||
t.Fatalf("trajectory write did not commit once: %d", recorder.commits)
|
||||
}
|
||||
insertIndex := findStatement(recorder.statements, "INSERT INTO scum_trajectories")
|
||||
if insertIndex < 0 {
|
||||
t.Fatalf("missing trajectory insert statement: %v", recorder.statements)
|
||||
}
|
||||
insert := recorder.statements[insertIndex]
|
||||
if !strings.Contains(insert, "world_x") || !strings.Contains(insert, "world_y") || !strings.Contains(insert, "world_z") || strings.Contains(insert, "map_x") || strings.Contains(insert, "pixel") {
|
||||
t.Fatalf("trajectory SQL does not use raw world coordinate columns: %s", insert)
|
||||
}
|
||||
args := recorder.args[insertIndex]
|
||||
if args[13].Value != 123.5 || args[14].Value != -456.25 || args[15].Value != 7.75 {
|
||||
t.Fatalf("coordinates were changed before storage: %+v", args[13:16])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSCUMSQLStoreWritesConsoleAndSemanticEventsToPluginTables(t *testing.T) {
|
||||
db, recorder := newRecordingSQLDB(t, "console-db")
|
||||
store, err := NewSCUMSQLStore(db)
|
||||
if err != nil {
|
||||
t.Fatalf("create SCUM SQL store: %v", err)
|
||||
}
|
||||
stamp := time.Date(2026, 8, 31, 3, 0, 0, 0, time.UTC)
|
||||
records := []ConsoleRecord{{ServerID: "server-1", Stream: "stdout", Sequence: 9, OccurredAt: stamp, Text: "SCUM LOGIN 76561198000000001 10.0.0.1"}}
|
||||
written, err := store.StoreConsoleRecords(context.Background(), records)
|
||||
if err != nil || written != 1 {
|
||||
t.Fatalf("store console records: written=%d err=%v", written, err)
|
||||
}
|
||||
batch := ParseConsoleRecords("server-1", records, "correlation-secret")
|
||||
if len(batch.Events) != 1 || batch.Events[0].NetworkCorrelation == "" {
|
||||
t.Fatalf("expected one correlated semantic event: %#v", batch)
|
||||
}
|
||||
semanticWritten, err := store.StoreSemanticEventBatch(context.Background(), batch)
|
||||
if err != nil || semanticWritten != 1 {
|
||||
t.Fatalf("store semantic events: written=%d err=%v", semanticWritten, err)
|
||||
}
|
||||
if recorder.commits != 2 {
|
||||
t.Fatalf("console and semantic writes did not commit once each: %d", recorder.commits)
|
||||
}
|
||||
|
||||
consoleIndex := findStatement(recorder.statements, "INSERT INTO scum_console_logs")
|
||||
if consoleIndex < 0 {
|
||||
t.Fatalf("missing console insert statement: %v", recorder.statements)
|
||||
}
|
||||
consoleInsert := recorder.statements[consoleIndex]
|
||||
if !strings.Contains(consoleInsert, "line_text") || strings.Contains(consoleInsert, "platform_logs") {
|
||||
t.Fatalf("console SQL must write the SCUM plugin table only: %s", consoleInsert)
|
||||
}
|
||||
consoleArgs := recorder.args[consoleIndex]
|
||||
if consoleArgs[1].Value != "server-1" || consoleArgs[2].Value != "stdout" || !driverNumberEquals(consoleArgs[3].Value, 9) || consoleArgs[5].Value != records[0].Text {
|
||||
t.Fatalf("unexpected console insert args: %+v", consoleArgs)
|
||||
}
|
||||
|
||||
semanticIndex := findStatement(recorder.statements, "INSERT INTO scum_semantic_events")
|
||||
if semanticIndex < 0 {
|
||||
t.Fatalf("missing semantic event insert statement: %v", recorder.statements)
|
||||
}
|
||||
semanticInsert := recorder.statements[semanticIndex]
|
||||
if strings.Contains(semanticInsert, "platform_logs") || strings.Contains(semanticInsert, "run_logs") {
|
||||
t.Fatalf("semantic SQL must write the SCUM plugin table only: %s", semanticInsert)
|
||||
}
|
||||
semanticArgs := recorder.args[semanticIndex]
|
||||
if semanticArgs[1].Value != "server-1" || !driverNumberEquals(semanticArgs[2].Value, 9) || semanticArgs[3].Value != "scum.login" || semanticArgs[4].Value != "76561198000000001" {
|
||||
t.Fatalf("unexpected semantic insert args: %+v", semanticArgs)
|
||||
}
|
||||
correlation, ok := semanticArgs[7].Value.(string)
|
||||
if !ok || correlation == "10.0.0.1" || len(correlation) != 64 {
|
||||
t.Fatalf("semantic event stored raw or missing network correlation: %+v", semanticArgs[7])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrajectorySamplesFromSCUMRowsKeepWorldCoordinates(t *testing.T) {
|
||||
sampledAt := time.Date(2026, 8, 28, 8, 0, 0, 0, time.UTC)
|
||||
positionSamples, err := TrajectorySamplesFromPositionRows("server-1", []map[string]any{
|
||||
{"subjectType": "player", "subjectId": "76561198000000001", "userProfileId": "profile-1", "gamePlayerId": "player-1", "x": 10.25, "y": -20.5, "z": 3.75, "observedAt": "2026-08-28T07:59:00Z"},
|
||||
{"subjectType": "base", "subjectId": "base-1", "x": 1, "y": 2, "z": 0},
|
||||
}, sampledAt)
|
||||
if err != nil || len(positionSamples) != 1 {
|
||||
t.Fatalf("position samples=%+v err=%v", positionSamples, err)
|
||||
}
|
||||
if positionSamples[0].WorldX != 10.25 || positionSamples[0].WorldY != -20.5 || positionSamples[0].WorldZ == nil || *positionSamples[0].WorldZ != 3.75 || positionSamples[0].Source != "plugin.sql.scum.positions" {
|
||||
t.Fatalf("position coordinates were not preserved: %+v", positionSamples[0])
|
||||
}
|
||||
vehicleSamples, err := TrajectorySamplesFromVehicleRows("server-1", []map[string]any{{"vehicleId": "vehicle-1", "entityId": "entity-1", "className": "BPC_Laika_C", "label": "Laika", "x": "400.5", "y": 200, "z": 0, "lastAccessTime": "2026-08-28T07:58:00Z"}}, sampledAt)
|
||||
if err != nil || len(vehicleSamples) != 1 {
|
||||
t.Fatalf("vehicle samples=%+v err=%v", vehicleSamples, err)
|
||||
}
|
||||
if vehicleSamples[0].SubjectType != "vehicle" || vehicleSamples[0].WorldX != 400.5 || vehicleSamples[0].WorldY != 200 || vehicleSamples[0].ClassName != "BPC_Laika_C" || vehicleSamples[0].Source != "plugin.sql.scum.vehicles" {
|
||||
t.Fatalf("vehicle coordinates were not preserved: %+v", vehicleSamples[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenSCUMSQLStoreFromEnvUsesSharedPlatformDSNName(t *testing.T) {
|
||||
recorder := &sqlRecorder{}
|
||||
activeSQLRecorder = recorder
|
||||
previousOpenSQL := openSQL
|
||||
t.Cleanup(func() { openSQL = previousOpenSQL })
|
||||
openSQL = func(driverName, dsn string) (*sql.DB, error) {
|
||||
if driverName != "mysql" {
|
||||
t.Fatalf("unexpected SQL driver: %s", driverName)
|
||||
}
|
||||
return sql.Open(recordingSQLDriverName, dsn)
|
||||
}
|
||||
t.Setenv(PlatformMySQLDSNEnvironment, "platform:platform@tcp(127.0.0.1:3306)/platform?parseTime=true")
|
||||
store, err := OpenSCUMSQLStoreFromEnv(PlatformMySQLDSNEnvironment)
|
||||
if err != nil {
|
||||
t.Fatalf("open store from shared env: %v", err)
|
||||
}
|
||||
if err := store.EnsureSchema(context.Background()); err != nil {
|
||||
t.Fatalf("ensure schema from shared env: %v", err)
|
||||
}
|
||||
store.db.Close()
|
||||
if recorder.dsn != "platform:platform@tcp(127.0.0.1:3306)/platform?parseTime=true" {
|
||||
t.Fatalf("store did not use the shared platform DSN environment: %q", recorder.dsn)
|
||||
}
|
||||
}
|
||||
|
||||
func newRecordingSQLDB(t *testing.T, dsn string) (*sql.DB, *sqlRecorder) {
|
||||
t.Helper()
|
||||
recordingSQLDriverOnce.Do(func() { sql.Register(recordingSQLDriverName, recordingSQLDriver{}) })
|
||||
recorder := &sqlRecorder{}
|
||||
activeSQLRecorder = recorder
|
||||
db, err := sql.Open(recordingSQLDriverName, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open recording SQL db: %v", err)
|
||||
}
|
||||
return db, recorder
|
||||
}
|
||||
|
||||
func findStatement(statements []string, prefix string) int {
|
||||
for index, statement := range statements {
|
||||
if strings.Contains(statement, prefix) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func driverNumberEquals(value any, want int64) bool {
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return int64(typed) == want
|
||||
case int64:
|
||||
return typed == want
|
||||
case uint64:
|
||||
return typed == uint64(want)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TrajectorySource interface {
|
||||
ReadPositionRows(context.Context, int) ([]map[string]any, error)
|
||||
ReadVehicleRows(context.Context, int) ([]map[string]any, error)
|
||||
}
|
||||
|
||||
type TrajectoryStore interface {
|
||||
EnsureSchema(context.Context) error
|
||||
StorePositionRows(context.Context, string, []map[string]any, time.Time) (int, error)
|
||||
StoreVehicleRows(context.Context, string, []map[string]any, time.Time) (int, error)
|
||||
}
|
||||
|
||||
type TrajectoryCollectionReport struct {
|
||||
CollectedAt time.Time
|
||||
PositionRows int
|
||||
VehicleRows int
|
||||
StoredSamples int
|
||||
Status string
|
||||
Reason string
|
||||
}
|
||||
|
||||
type TrajectoryCollector struct {
|
||||
Source TrajectorySource
|
||||
Store TrajectoryStore
|
||||
ServerInstanceID string
|
||||
Interval time.Duration
|
||||
MaxRows int
|
||||
Now func() time.Time
|
||||
schemaOnce sync.Once
|
||||
schemaErr error
|
||||
}
|
||||
|
||||
func NewTrajectoryCollector(config Config, source TrajectorySource, store TrajectoryStore) *TrajectoryCollector {
|
||||
return &TrajectoryCollector{
|
||||
Source: source,
|
||||
Store: store,
|
||||
ServerInstanceID: config.Component.ServerInstanceID,
|
||||
Interval: time.Duration(config.Trajectory.IntervalSeconds) * time.Second,
|
||||
MaxRows: config.Trajectory.MaxRows,
|
||||
}
|
||||
}
|
||||
|
||||
func (collector *TrajectoryCollector) CollectOnce(ctx context.Context) (TrajectoryCollectionReport, error) {
|
||||
if collector == nil || collector.Source == nil || collector.Store == nil || strings.TrimSpace(collector.ServerInstanceID) == "" {
|
||||
return TrajectoryCollectionReport{}, fmt.Errorf("SCUM trajectory collector is not configured")
|
||||
}
|
||||
collector.schemaOnce.Do(func() { collector.schemaErr = collector.Store.EnsureSchema(ctx) })
|
||||
if collector.schemaErr != nil {
|
||||
return TrajectoryCollectionReport{}, collector.schemaErr
|
||||
}
|
||||
sampledAt := collector.clock()().UTC()
|
||||
report := TrajectoryCollectionReport{CollectedAt: sampledAt, Status: "healthy"}
|
||||
positions, err := collector.Source.ReadPositionRows(ctx, collector.MaxRows)
|
||||
if err != nil {
|
||||
report.Status, report.Reason = "degraded", "position collection failed"
|
||||
return report, err
|
||||
}
|
||||
report.PositionRows = len(positions)
|
||||
written, err := collector.Store.StorePositionRows(ctx, collector.ServerInstanceID, positions, sampledAt)
|
||||
if err != nil {
|
||||
report.Status, report.Reason = "degraded", "position storage failed"
|
||||
return report, err
|
||||
}
|
||||
report.StoredSamples += written
|
||||
vehicles, err := collector.Source.ReadVehicleRows(ctx, collector.MaxRows)
|
||||
if err != nil {
|
||||
report.Status, report.Reason = "degraded", "vehicle collection failed"
|
||||
return report, err
|
||||
}
|
||||
report.VehicleRows = len(vehicles)
|
||||
written, err = collector.Store.StoreVehicleRows(ctx, collector.ServerInstanceID, vehicles, sampledAt)
|
||||
if err != nil {
|
||||
report.Status, report.Reason = "degraded", "vehicle storage failed"
|
||||
return report, err
|
||||
}
|
||||
report.StoredSamples += written
|
||||
report.Reason = "raw world coordinates stored"
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func (collector *TrajectoryCollector) Run(ctx context.Context, status *TrajectoryCollectionStatus) error {
|
||||
interval := collector.Interval
|
||||
if interval < time.Second {
|
||||
interval = time.Duration(DefaultTrajectoryCollectionIntervalSecs) * time.Second
|
||||
}
|
||||
if report, err := collector.CollectOnce(ctx); status != nil {
|
||||
status.Record(report, err)
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
report, err := collector.CollectOnce(ctx)
|
||||
if status != nil {
|
||||
status.Record(report, err)
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (collector *TrajectoryCollector) clock() func() time.Time {
|
||||
if collector.Now != nil {
|
||||
return collector.Now
|
||||
}
|
||||
return time.Now
|
||||
}
|
||||
|
||||
type TrajectoryCollectionStatus struct {
|
||||
mu sync.Mutex
|
||||
latest TrajectoryCollectionReport
|
||||
err error
|
||||
}
|
||||
|
||||
func (status *TrajectoryCollectionStatus) Record(report TrajectoryCollectionReport, err error) {
|
||||
if status == nil {
|
||||
return
|
||||
}
|
||||
status.mu.Lock()
|
||||
defer status.mu.Unlock()
|
||||
status.latest = report
|
||||
status.err = err
|
||||
}
|
||||
|
||||
func (status *TrajectoryCollectionStatus) HealthReport() HealthReport {
|
||||
if status == nil {
|
||||
return HealthReport{Status: "healthy", Reason: "typed companion dispatcher ready"}
|
||||
}
|
||||
status.mu.Lock()
|
||||
defer status.mu.Unlock()
|
||||
if status.latest.Status == "healthy" && status.err == nil {
|
||||
return HealthReport{Status: "healthy", Reason: safeHealthReason(status.latest.Reason, "typed companion dispatcher ready")}
|
||||
}
|
||||
if !status.latest.CollectedAt.IsZero() && status.err == nil {
|
||||
return HealthReport{Status: "healthy", Reason: "raw world coordinate collection ready"}
|
||||
}
|
||||
if status.err != nil {
|
||||
return HealthReport{Status: "degraded", Reason: safeHealthReason(status.latest.Reason, "trajectory collection waiting for source data")}
|
||||
}
|
||||
return HealthReport{Status: "degraded", Reason: "trajectory collection waiting for first sample"}
|
||||
}
|
||||
|
||||
func safeHealthReason(value string, fallback string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type trajectorySourceFixture struct {
|
||||
positions []map[string]any
|
||||
vehicles []map[string]any
|
||||
limits []int
|
||||
}
|
||||
|
||||
func (source *trajectorySourceFixture) ReadPositionRows(_ context.Context, limit int) ([]map[string]any, error) {
|
||||
source.limits = append(source.limits, limit)
|
||||
return source.positions, nil
|
||||
}
|
||||
|
||||
func (source *trajectorySourceFixture) ReadVehicleRows(_ context.Context, limit int) ([]map[string]any, error) {
|
||||
source.limits = append(source.limits, limit)
|
||||
return source.vehicles, nil
|
||||
}
|
||||
|
||||
type trajectoryStoreFixture struct {
|
||||
ensureCalls int
|
||||
samples []TrajectorySample
|
||||
}
|
||||
|
||||
func (store *trajectoryStoreFixture) EnsureSchema(context.Context) error {
|
||||
store.ensureCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *trajectoryStoreFixture) StorePositionRows(_ context.Context, serverInstanceID string, rows []map[string]any, sampledAt time.Time) (int, error) {
|
||||
samples, err := TrajectorySamplesFromPositionRows(serverInstanceID, rows, sampledAt)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
store.samples = append(store.samples, samples...)
|
||||
return len(samples), nil
|
||||
}
|
||||
|
||||
func (store *trajectoryStoreFixture) StoreVehicleRows(_ context.Context, serverInstanceID string, rows []map[string]any, sampledAt time.Time) (int, error) {
|
||||
samples, err := TrajectorySamplesFromVehicleRows(serverInstanceID, rows, sampledAt)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
store.samples = append(store.samples, samples...)
|
||||
return len(samples), nil
|
||||
}
|
||||
|
||||
func TestTrajectoryCollectorStoresRawSCUMWorldCoordinates(t *testing.T) {
|
||||
sampledAt := time.Date(2026, 8, 31, 3, 30, 0, 0, time.UTC)
|
||||
source := &trajectorySourceFixture{
|
||||
positions: []map[string]any{{"subjectType": "player", "subjectId": "76561198000000001", "gamePlayerId": "player-1", "x": 123.25, "y": -456.5, "z": 7.75, "observedAt": "2026-08-31T03:29:59Z"}},
|
||||
vehicles: []map[string]any{{"vehicleId": "vehicle-1", "entityId": "entity-1", "className": "BPC_Laika_C", "label": "Laika", "x": -10.5, "y": 20.25, "z": 0}},
|
||||
}
|
||||
store := &trajectoryStoreFixture{}
|
||||
collector := &TrajectoryCollector{Source: source, Store: store, ServerInstanceID: "server-1", MaxRows: 777, Now: func() time.Time { return sampledAt }}
|
||||
report, err := collector.CollectOnce(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("collect trajectories: %v", err)
|
||||
}
|
||||
if report.PositionRows != 1 || report.VehicleRows != 1 || report.StoredSamples != 2 || report.Reason != "raw world coordinates stored" {
|
||||
t.Fatalf("unexpected collection report: %+v", report)
|
||||
}
|
||||
if store.ensureCalls != 1 || len(source.limits) != 2 || source.limits[0] != 777 || source.limits[1] != 777 {
|
||||
t.Fatalf("collector did not use bounded source/store once: ensure=%d limits=%v", store.ensureCalls, source.limits)
|
||||
}
|
||||
if len(store.samples) != 2 {
|
||||
t.Fatalf("expected two trajectory samples, got %+v", store.samples)
|
||||
}
|
||||
if store.samples[0].WorldX != 123.25 || store.samples[0].WorldY != -456.5 || store.samples[0].WorldZ == nil || *store.samples[0].WorldZ != 7.75 {
|
||||
t.Fatalf("player coordinates were changed before storage: %+v", store.samples[0])
|
||||
}
|
||||
if store.samples[1].SubjectType != "vehicle" || store.samples[1].WorldX != -10.5 || store.samples[1].WorldY != 20.25 || store.samples[1].Source != "plugin.sql.scum.vehicles" {
|
||||
t.Fatalf("vehicle coordinates were changed before storage: %+v", store.samples[1])
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"databaseUserVersion": 57,
|
||||
"owner": "game.scum",
|
||||
"store": "plugin-shared-platform-mysql",
|
||||
"tables": [
|
||||
{
|
||||
"name": "scum_trajectories",
|
||||
"writer": "companion.SCUMSQLStore.StoreTrajectorySamples",
|
||||
"migration": "companion.SCUMSQLStore.EnsureSchema",
|
||||
"primaryKey": "record_key",
|
||||
"uniqueSampleKey": ["server_instance_id", "subject_type", "subject_id", "sampled_at"],
|
||||
"coordinateColumns": ["world_x", "world_y", "world_z"],
|
||||
"coordinatePolicy": "store-game-world-coordinates-only"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
import type { SCUMCommandResult, SCUMConfigPatch, SCUMConfigRead, SCUMFeatureAvailability, SCUMFeatureKey, SCUMGiftGrant, SCUMPlayerProfile, SCUMStatePatch, SCUMStateSnapshot, SCUMTrajectoryCollection, SCUMVehicleSpawn } from "./contracts.js";
|
||||
import type { SCUMCommandResult, SCUMConfigPatch, SCUMConfigRead, SCUMFeatureAvailability, SCUMFeatureKey, SCUMStatePatch, SCUMStateSnapshot, SCUMVehicleSpawn } from "./contracts.js";
|
||||
import { validateConfigPatch, validateStatePatch, validateVehicleSpawn } from "./schemas.js";
|
||||
|
||||
export type PluginFeatureBridge = { dispatch(action: "game-client.command" | "game-client.snapshot.read", payload: Record<string, string>): Promise<{ status: string; result?: Record<string, string>; error?: { message: string } }> };
|
||||
export type PluginFeatureBridge = { dispatch(action: "game-client.command", payload: Record<string, string>): Promise<{ status: string; result?: Record<string, string>; error?: { message: string } }> };
|
||||
export type SCUMFeatureAPI = {
|
||||
availability(feature: SCUMFeatureKey): Promise<SCUMFeatureAvailability>; readConfig(): Promise<SCUMConfigRead | null>; patchConfig(patch: SCUMConfigPatch): Promise<SCUMCommandResult>;
|
||||
playerProfile(playerId: string): Promise<SCUMPlayerProfile | null>; stateSnapshot(playerId: string): Promise<SCUMStateSnapshot | null>; requestStatePatch(patch: SCUMStatePatch): Promise<SCUMCommandResult>;
|
||||
requestVehicleSpawn(spawn: SCUMVehicleSpawn): Promise<SCUMCommandResult>;
|
||||
giftGrants(): Promise<SCUMGiftGrant[]>; trajectories(): Promise<SCUMTrajectoryCollection>;
|
||||
stateSnapshot(playerId: string): Promise<SCUMStateSnapshot | null>; requestStatePatch(patch: SCUMStatePatch): Promise<SCUMCommandResult>; requestVehicleSpawn(spawn: SCUMVehicleSpawn): Promise<SCUMCommandResult>;
|
||||
};
|
||||
|
||||
export function createSCUMFeatureAPI(bridge: PluginFeatureBridge, availableFeatures: readonly SCUMFeatureAvailability[]): SCUMFeatureAPI {
|
||||
@@ -15,12 +13,9 @@ export function createSCUMFeatureAPI(bridge: PluginFeatureBridge, availableFeatu
|
||||
availability,
|
||||
async readConfig() { const result = await bridge.dispatch("game-client.command", { type: "config.read" }); return result.status === "ok" ? decode<SCUMConfigRead>(result.result) : null; },
|
||||
async patchConfig(patch) { const error = validateConfigPatch(patch); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "config.patch", patch: JSON.stringify(patch) })); },
|
||||
async playerProfile(playerId) { const result = await bridge.dispatch("game-client.snapshot.read", { type: "semantic.events", subjectId: playerId }); return result.status === "ok" ? decode<SCUMPlayerProfile>(result.result) : null; },
|
||||
async stateSnapshot(playerId) { const result = await bridge.dispatch("game-client.command", { type: "player.lookup", playerId }); return result.status === "ok" ? decode<SCUMStateSnapshot>(result.result) : null; },
|
||||
async requestStatePatch(patch) { const error = validateStatePatch(patch.changes); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "game-state.patch", patch: JSON.stringify(patch) })); },
|
||||
async requestVehicleSpawn(spawn) { const error = validateVehicleSpawn(spawn); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "vehicle.spawn", vehicleCode: spawn.vehicleCode })); },
|
||||
async giftGrants() { const result = await bridge.dispatch("game-client.snapshot.read", { type: "semantic.events", view: "gifts" }); return result.status === "ok" ? decode<SCUMGiftGrant[]>(result.result) ?? [] : []; },
|
||||
async trajectories() { return { available: false, reason: "轨迹由 SCUM 插件 companion 直接写入 scum_trajectories;页面数据请读取插件表。", trajectories: [] }; }
|
||||
async requestVehicleSpawn(spawn) { const error = validateVehicleSpawn(spawn); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "vehicle.spawn", vehicleCode: spawn.vehicleCode })); }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -311,12 +311,12 @@ function playerItemsPanel(e: ReactLike["createElement"], player: RecordMap) {
|
||||
|
||||
function playerHistoryPanel(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData) {
|
||||
const rows = playerRecords(data.activityEvents, player).filter((row) => ["login", "logout", "scum.login", "scum.logout"].includes(textField(row, "eventType", "type").toLowerCase()));
|
||||
return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "登录历史来自插件自有 SCUM 登录事件记录。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: idOf(row, `history-${index}`), className: "console-row" }, e("span", null, textField(row, "eventType", "type") || "登录事件"), e("strong", null, dateField(row, "occurredAt", "observedAt", "createdAt")), e("strong", null, textField(row, "lastLoginIp", "loginIp", "ipAddress", "ip") || shortHash(textField(row, "networkCorrelation")) || textField(row, "reason") || "无网络字段"))) : e("p", { className: "page-status" }, "没有该用户的真实登录历史。")));
|
||||
return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "登录历史来自插件声明的 SCUM 登录日志投影。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: idOf(row, `history-${index}`), className: "console-row" }, e("span", null, textField(row, "eventType", "type") || "登录事件"), e("strong", null, dateField(row, "occurredAt", "observedAt", "createdAt")), e("strong", null, textField(row, "lastLoginIp", "loginIp", "ipAddress", "ip") || shortHash(textField(row, "networkCorrelation")) || textField(row, "reason") || "无网络字段"))) : e("p", { className: "page-status" }, "没有该用户的真实登录历史。")));
|
||||
}
|
||||
|
||||
function playerTrajectoryPanel(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData) {
|
||||
const rows = playerRecords(data.trajectories, player).filter((row) => layerOf(row) === "players" && hasCoordinates(positionOf(row))).sort((left, right) => trajectoryOrder(right) - trajectoryOrder(left)).slice(0, 120);
|
||||
return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "用户轨迹来自 SCUM 插件自有轨迹表;保存的是游戏原始 world 坐标,地图像素只在前端显示时计算。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => { const ride = nearbyVehicle(row, data.vehicles); return e("div", { key: idOf(row, `trajectory-${index}`), className: "console-row" }, e("span", null, dateField(row, "sampledAt", "observedAt", "createdAt")), e("strong", null, coords(positionOf(row))), e("strong", null, ride ? `疑似乘坐 ${pointTitle(ride)}` : textField(row, "source") || "plugin.sql")); }) : e("p", { className: "page-status" }, "没有该用户的真实轨迹记录。")));
|
||||
return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "用户轨迹来自 Run 每 3 秒查询 SCUM.db 的采样投影;乘车状态按同一时刻附近载具保守标识。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => { const ride = nearbyVehicle(row, data.vehicles); return e("div", { key: idOf(row, `trajectory-${index}`), className: "console-row" }, e("span", null, dateField(row, "sampledAt", "observedAt", "createdAt")), e("strong", null, coords(positionOf(row))), e("strong", null, ride ? `疑似乘坐 ${pointTitle(ride)}` : textField(row, "source") || "run.sqlite")); }) : e("p", { className: "page-status" }, "没有该用户的真实轨迹记录。")));
|
||||
}
|
||||
|
||||
function playerRecords(rows: RecordMap[], player: RecordMap): RecordMap[] { const identities = playerIdentities(player); return rows.filter((row) => identities.includes(textField(row, "steamId", "playerId", "gamePlayerId", "userProfileId", "profileId"))); }
|
||||
@@ -563,8 +563,8 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input:
|
||||
e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在保存地图范围…", async () => { await saveMapSettings(actions ?? {}, { customMapEnabled: customEnabled, centerX: numberInput(centerX, 0), centerY: numberInput(centerY, 0), widthKm: numberInput(widthKm, 15.24), heightKm: numberInput(heightKm, 15.24) }); view.refresh(); return "地图范围已保存。"; }) }, "保存地图范围"))
|
||||
),
|
||||
e("div", { className: "overview-two-col" },
|
||||
e("div", { className: "map-world-board", "aria-label": "SCUM 地图图层", style: { backgroundImage: `url(${scumMapBackground})` } }, mapGridOverlay(e), trails.map((point, index) => e("span", { key: `trail-${index}-${idOf(point, "sample")}`, className: `map-trajectory-dot map-layer-${layerOf(point)}`, title: `${pointTitle(point)} ${dateField(point, "sampledAt", "observedAt")}`, style: mapPointStyle(point, bounds) })), visible.map((point, index) => { const ride = layerOf(point) === "players" ? nearbyVehicle(point, data.vehicles) : undefined; return e("button", { key: idOf(point, `point-${index}`), type: "button", className: `map-world-dot map-layer-${layerOf(point)}${ride ? " map-world-dot-riding" : ""}`, title: `${pointTitle(point)} ${coords(point)}${ride ? ` · 疑似乘坐 ${pointTitle(ride)}` : ""}`, "aria-label": pointTitle(point), style: mapPointStyle(point, bounds), onClick: () => view.setSelectedMapPoint(idOf(point, `point-${index}`)) }, vehicleIconFor(point) ? e("img", { src: vehicleIconFor(point), alt: "" }) : ""); })),
|
||||
e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, "地图点详情"), e("span", { className: "page-status" }, `${visible.length} 个可见点`)), selected ? e("div", { className: "console-record" }, e("strong", null, pointTitle(selected)), e("span", { className: "status-pill status-active" }, layerLabel(layerOf(selected))), e("span", { className: "provider-id" }, coords(selected)), e("div", { className: "console-record-meta" }, e("span", null, `ID ${textField(selected, "subjectId", "id", "_recordKey") || "unknown"}`), e("span", null, `来源 ${textField(selected, "source") || "plugin collection"}`), e("span", null, freshness(selected))), layerOf(selected) === "vehicles" ? e("div", { className: "console-record-meta" }, e("span", null, `类型 ${textField(selected, "className", "vehicleClass", "vehicleType") || "unknown"}`), e("span", null, `状态 ${textField(selected, "status", "state", "isFunctional") || "unknown"}`), e("span", null, `访问 ${dateField(selected, "lastAccessTime", "vehicleObservedAt", "sampledAt")}`)) : null, selectedTrails.length ? e("div", { className: "console-row-list" }, selectedTrails.map((row, index) => e("div", { key: `selected-trail-${index}`, className: "console-row" }, e("span", null, dateField(row, "sampledAt", "observedAt")), e("strong", null, coords(row)), e("strong", null, textField(row, "source") || "plugin.sql")))) : null) : e("p", { className: "page-status" }, "当前图层和筛选条件下没有真实地图点。"))
|
||||
e("div", { className: "map-projection-board", "aria-label": "SCUM 地图图层", style: { backgroundImage: `url(${scumMapBackground})` } }, mapGridOverlay(e), trails.map((point, index) => e("span", { key: `trail-${index}-${idOf(point, "sample")}`, className: `map-trajectory-dot map-layer-${layerOf(point)}`, title: `${pointTitle(point)} ${dateField(point, "sampledAt", "observedAt")}`, style: mapPointStyle(point, bounds) })), visible.map((point, index) => { const ride = layerOf(point) === "players" ? nearbyVehicle(point, data.vehicles) : undefined; return e("button", { key: idOf(point, `point-${index}`), type: "button", className: `map-projection-dot map-layer-${layerOf(point)}${ride ? " map-projection-dot-riding" : ""}`, title: `${pointTitle(point)} ${coords(point)}${ride ? ` · 疑似乘坐 ${pointTitle(ride)}` : ""}`, "aria-label": pointTitle(point), style: mapPointStyle(point, bounds), onClick: () => view.setSelectedMapPoint(idOf(point, `point-${index}`)) }, vehicleIconFor(point) ? e("img", { src: vehicleIconFor(point), alt: "" }) : ""); })),
|
||||
e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, "地图点详情"), e("span", { className: "page-status" }, `${visible.length} 个可见点`)), selected ? e("div", { className: "console-record" }, e("strong", null, pointTitle(selected)), e("span", { className: "status-pill status-active" }, layerLabel(layerOf(selected))), e("span", { className: "provider-id" }, coords(selected)), e("div", { className: "console-record-meta" }, e("span", null, `ID ${textField(selected, "subjectId", "id", "_recordKey") || "unknown"}`), e("span", null, `来源 ${textField(selected, "source") || "plugin collection"}`), e("span", null, freshness(selected))), layerOf(selected) === "vehicles" ? e("div", { className: "console-record-meta" }, e("span", null, `类型 ${textField(selected, "className", "vehicleClass", "vehicleType") || "unknown"}`), e("span", null, `状态 ${textField(selected, "status", "state", "isFunctional") || "unknown"}`), e("span", null, `访问 ${dateField(selected, "lastAccessTime", "vehicleObservedAt", "sampledAt")}`)) : null, selectedTrails.length ? e("div", { className: "console-row-list" }, selectedTrails.map((row, index) => e("div", { key: `selected-trail-${index}`, className: "console-row" }, e("span", null, dateField(row, "sampledAt", "observedAt")), e("strong", null, coords(row)), e("strong", null, textField(row, "source") || "run.sqlite")))) : null) : e("p", { className: "page-status" }, "当前图层和筛选条件下没有真实地图点。"))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"id": "game.scum",
|
||||
"name": "SCUM Server",
|
||||
"description": "First-party SCUM game server operations plugin with platform-mediated lifecycle and companion bridge support.",
|
||||
"version": "0.1.14",
|
||||
"version": "0.1.15",
|
||||
"kind": "game-plugin",
|
||||
"tags": [
|
||||
"scum",
|
||||
@@ -205,13 +205,6 @@
|
||||
"keepForSeconds": 604800,
|
||||
"maxRecords": 1000
|
||||
},
|
||||
{
|
||||
"type": "semantic.events",
|
||||
"schemaVersion": "1",
|
||||
"schemaRef": "schemas/bridge/semantic-events.snapshot.schema.json",
|
||||
"keepForSeconds": 604800,
|
||||
"maxRecords": 1000
|
||||
},
|
||||
{
|
||||
"type": "online.sessions",
|
||||
"schemaVersion": "1",
|
||||
@@ -268,7 +261,21 @@
|
||||
"sqlRef": "sql/scum-db-v57/users.sql",
|
||||
"pollIntervalSeconds": 3,
|
||||
"maxRows": 500,
|
||||
"timeoutSeconds": 15
|
||||
"timeoutSeconds": 15,
|
||||
"projections": [
|
||||
{
|
||||
"collection": "scum_users",
|
||||
"rowPath": "rows",
|
||||
"upsertKeys": [
|
||||
"steamId"
|
||||
],
|
||||
"fixedValues": {
|
||||
"source": "run.sqlite.scum.player.profile"
|
||||
},
|
||||
"observedAtField": "profileSampledAt",
|
||||
"mergeExisting": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "scum.squads",
|
||||
@@ -310,7 +317,66 @@
|
||||
"sqlRef": "sql/scum-db-v57/vehicles.sql",
|
||||
"pollIntervalSeconds": 3,
|
||||
"maxRows": 500,
|
||||
"timeoutSeconds": 15
|
||||
"timeoutSeconds": 15,
|
||||
"projections": [
|
||||
{
|
||||
"collection": "scum_vehicles",
|
||||
"rowPath": "rows",
|
||||
"upsertKeys": [
|
||||
"vehicleId"
|
||||
],
|
||||
"fixedValues": {
|
||||
"source": "run.sqlite.scum.vehicles"
|
||||
},
|
||||
"observedAtField": "sampledAt"
|
||||
},
|
||||
{
|
||||
"collection": "scum_trajectories",
|
||||
"rowPath": "rows",
|
||||
"upsertKeys": [
|
||||
"subjectType",
|
||||
"subjectId",
|
||||
"sampledAt"
|
||||
],
|
||||
"fieldMappings": {
|
||||
"subjectId": "vehicleId",
|
||||
"vehicleId": "vehicleId",
|
||||
"entityId": "entityId",
|
||||
"className": "className",
|
||||
"label": "label",
|
||||
"x": "x",
|
||||
"y": "y",
|
||||
"z": "z",
|
||||
"lastAccessTime": "lastAccessTime"
|
||||
},
|
||||
"fixedValues": {
|
||||
"subjectType": "vehicle",
|
||||
"source": "run.sqlite.scum.vehicles"
|
||||
},
|
||||
"observedAtField": "sampledAt"
|
||||
},
|
||||
{
|
||||
"collection": "scum_trade_goods",
|
||||
"rowPath": "rows",
|
||||
"upsertKeys": [
|
||||
"code"
|
||||
],
|
||||
"fieldMappings": {
|
||||
"className": "className"
|
||||
},
|
||||
"fixedValues": {
|
||||
"code": "#spawnvehicle {{className}}",
|
||||
"spawnCommand": "#spawnvehicle {{className}}",
|
||||
"catalogType": "vehicle",
|
||||
"type": "21",
|
||||
"typeName": "其他载具",
|
||||
"imagePath": "/original/{{className}}.webp",
|
||||
"source": "run.sqlite.scum.vehicles"
|
||||
},
|
||||
"observedAtField": "lastSeenAt",
|
||||
"mergeExisting": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "scum.flags",
|
||||
@@ -338,7 +404,69 @@
|
||||
"sqlRef": "sql/scum-db-v57/map-points.sql",
|
||||
"pollIntervalSeconds": 3,
|
||||
"maxRows": 500,
|
||||
"timeoutSeconds": 15
|
||||
"timeoutSeconds": 15,
|
||||
"projections": [
|
||||
{
|
||||
"collection": "scum_users",
|
||||
"rowPath": "rows",
|
||||
"matchField": "subjectType",
|
||||
"matchValue": "player",
|
||||
"upsertKeys": [
|
||||
"steamId"
|
||||
],
|
||||
"fieldMappings": {
|
||||
"steamId": "subjectId",
|
||||
"userProfileId": "userProfileId",
|
||||
"gamePlayerId": "gamePlayerId",
|
||||
"x": "x",
|
||||
"y": "y",
|
||||
"z": "z",
|
||||
"lastPositionObservedAt": "observedAt"
|
||||
},
|
||||
"fixedValues": {
|
||||
"source": "run.sqlite.scum.positions"
|
||||
},
|
||||
"observedAtField": "positionSampledAt"
|
||||
},
|
||||
{
|
||||
"collection": "scum_map_points",
|
||||
"rowPath": "rows",
|
||||
"upsertKeys": [
|
||||
"subjectType",
|
||||
"subjectId"
|
||||
],
|
||||
"fixedValues": {
|
||||
"source": "run.sqlite.scum.positions"
|
||||
},
|
||||
"observedAtField": "sampledAt"
|
||||
},
|
||||
{
|
||||
"collection": "scum_trajectories",
|
||||
"rowPath": "rows",
|
||||
"matchField": "subjectType",
|
||||
"matchValue": "player",
|
||||
"upsertKeys": [
|
||||
"subjectType",
|
||||
"subjectId",
|
||||
"sampledAt"
|
||||
],
|
||||
"fieldMappings": {
|
||||
"subjectType": "subjectType",
|
||||
"subjectId": "subjectId",
|
||||
"steamId": "subjectId",
|
||||
"userProfileId": "userProfileId",
|
||||
"gamePlayerId": "gamePlayerId",
|
||||
"x": "x",
|
||||
"y": "y",
|
||||
"z": "z",
|
||||
"observedAt": "observedAt"
|
||||
},
|
||||
"fixedValues": {
|
||||
"source": "run.sqlite.scum.positions"
|
||||
},
|
||||
"observedAtField": "sampledAt"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "scum.tasks",
|
||||
@@ -383,249 +511,6 @@
|
||||
"timeoutSeconds": 15
|
||||
}
|
||||
],
|
||||
"logProjections": [
|
||||
{
|
||||
"key": "scum.trade.catalog",
|
||||
"streamKeys": [
|
||||
"scum.trade"
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"pattern": "^\\d{4}\\.\\d{2}\\.\\d{2}-\\d{2}\\.\\d{2}\\.\\d{2}: \\[Trade\\] Tradeable \\((?P<itemCode>[A-Za-z0-9_.-]{1,128}) \\(x(?P<quantity>\\d{1,9})\\)\\) (?P<tradeVerb>purchased|sold) by .*?\\((?P<steamId>\\d{1,50})\\) for (?P<price>-?\\d{1,12})$"
|
||||
}
|
||||
],
|
||||
"correlationFields": [
|
||||
"itemCode"
|
||||
],
|
||||
"maxInterveningLines": 0,
|
||||
"target": {
|
||||
"collection": "scum_trade_goods",
|
||||
"upsertKeys": [
|
||||
"code"
|
||||
],
|
||||
"captureMappings": {
|
||||
"code": "itemCode"
|
||||
},
|
||||
"fixedValues": {
|
||||
"catalogType": "item",
|
||||
"source": "scum.trade"
|
||||
},
|
||||
"observedAtField": "lastSeenAt"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scum.trade.events",
|
||||
"streamKeys": [
|
||||
"scum.trade"
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"pattern": "^\\d{4}\\.\\d{2}\\.\\d{2}-\\d{2}\\.\\d{2}\\.\\d{2}: \\[Trade\\] Tradeable \\((?P<itemCode>[A-Za-z0-9_.-]{1,128}) \\(x(?P<quantity>\\d{1,9})\\)\\) (?P<tradeVerb>purchased|sold) by .*?\\((?P<steamId>\\d{1,50})\\) for (?P<price>-?\\d{1,12})$"
|
||||
}
|
||||
],
|
||||
"correlationFields": [
|
||||
"steamId",
|
||||
"itemCode",
|
||||
"tradeVerb"
|
||||
],
|
||||
"maxInterveningLines": 0,
|
||||
"target": {
|
||||
"collection": "scum_trade_events",
|
||||
"upsertKeys": [
|
||||
"steamId",
|
||||
"itemCode",
|
||||
"tradeVerb",
|
||||
"quantity",
|
||||
"price",
|
||||
"observedAt"
|
||||
],
|
||||
"captureMappings": {
|
||||
"steamId": "steamId",
|
||||
"itemCode": "itemCode",
|
||||
"tradeVerb": "tradeVerb",
|
||||
"quantity": "quantity",
|
||||
"price": "price"
|
||||
},
|
||||
"fixedValues": {
|
||||
"eventType": "trade",
|
||||
"source": "scum.trade"
|
||||
},
|
||||
"observedAtField": "observedAt"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scum.battleye.login",
|
||||
"streamKeys": [
|
||||
"scum.console.stdout"
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"pattern": "Player \"(?P<displayName>[^\"]+)\" reported as player (?P<slot>\\d+)"
|
||||
},
|
||||
{
|
||||
"pattern": "Player (?P<slot>\\d+) SteamID \\(assumed\\): (?P<steamId>\\d+)"
|
||||
}
|
||||
],
|
||||
"correlationFields": [
|
||||
"slot"
|
||||
],
|
||||
"maxInterveningLines": 8,
|
||||
"target": {
|
||||
"collection": "scum_users",
|
||||
"upsertKeys": [
|
||||
"steamId"
|
||||
],
|
||||
"captureMappings": {
|
||||
"steamId": "steamId",
|
||||
"displayName": "displayName",
|
||||
"slot": "slot"
|
||||
},
|
||||
"fixedValues": {
|
||||
"online": "true",
|
||||
"source": "process.stdout"
|
||||
},
|
||||
"observedAtField": "lastLoginObservedAt"
|
||||
},
|
||||
"presence": {
|
||||
"timestampField": "lastLoginObservedAt",
|
||||
"activeWindowSeconds": 600,
|
||||
"activityTarget": {
|
||||
"collection": "scum_activity_events",
|
||||
"upsertKeys": [
|
||||
"steamId",
|
||||
"observedAt"
|
||||
],
|
||||
"captureMappings": {
|
||||
"steamId": "steamId",
|
||||
"displayName": "displayName"
|
||||
},
|
||||
"fixedValues": {
|
||||
"eventType": "login",
|
||||
"source": "process.stdout"
|
||||
},
|
||||
"observedAtField": "observedAt"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scum.login-log.login",
|
||||
"streamKeys": [
|
||||
"scum.login"
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"pattern": "^\\d{4}\\.\\d{2}\\.\\d{2}-\\d{2}\\.\\d{2}\\.\\d{2}: '(?P<ip>[0-9.]+) (?P<steamId>\\d{1,50}):(?P<displayName>[^']{1,80})\\(\\d+\\)' logged in(?: at: X=.*)?$"
|
||||
}
|
||||
],
|
||||
"correlationFields": [
|
||||
"steamId"
|
||||
],
|
||||
"maxInterveningLines": 0,
|
||||
"target": {
|
||||
"collection": "scum_users",
|
||||
"upsertKeys": [
|
||||
"steamId"
|
||||
],
|
||||
"captureMappings": {
|
||||
"steamId": "steamId",
|
||||
"displayName": "displayName",
|
||||
"lastLoginIp": "ip"
|
||||
},
|
||||
"hashMappings": {
|
||||
"networkCorrelation": "ip"
|
||||
},
|
||||
"fixedValues": {
|
||||
"online": "true",
|
||||
"status": "online",
|
||||
"source": "scum.login"
|
||||
},
|
||||
"observedAtField": "lastLoginObservedAt"
|
||||
},
|
||||
"presence": {
|
||||
"timestampField": "lastLoginObservedAt",
|
||||
"activeWindowSeconds": 1,
|
||||
"activityTarget": {
|
||||
"collection": "scum_activity_events",
|
||||
"upsertKeys": [
|
||||
"steamId",
|
||||
"observedAt"
|
||||
],
|
||||
"captureMappings": {
|
||||
"steamId": "steamId",
|
||||
"displayName": "displayName",
|
||||
"lastLoginIp": "ip"
|
||||
},
|
||||
"hashMappings": {
|
||||
"networkCorrelation": "ip"
|
||||
},
|
||||
"fixedValues": {
|
||||
"eventType": "login",
|
||||
"source": "scum.login"
|
||||
},
|
||||
"observedAtField": "observedAt"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scum.login-log.logout",
|
||||
"streamKeys": [
|
||||
"scum.login"
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"pattern": "^\\d{4}\\.\\d{2}\\.\\d{2}-\\d{2}\\.\\d{2}\\.\\d{2}: '(?P<ip>[0-9.]+) (?P<steamId>\\d{1,50}):(?P<displayName>[^']{1,80})\\(\\d+\\)' logged out.*$"
|
||||
}
|
||||
],
|
||||
"correlationFields": [
|
||||
"steamId"
|
||||
],
|
||||
"maxInterveningLines": 0,
|
||||
"target": {
|
||||
"collection": "scum_users",
|
||||
"upsertKeys": [
|
||||
"steamId"
|
||||
],
|
||||
"captureMappings": {
|
||||
"steamId": "steamId",
|
||||
"displayName": "displayName"
|
||||
},
|
||||
"hashMappings": {
|
||||
"networkCorrelation": "ip"
|
||||
},
|
||||
"fixedValues": {
|
||||
"online": "false",
|
||||
"status": "offline",
|
||||
"logoutReason": "disconnect",
|
||||
"source": "scum.login"
|
||||
},
|
||||
"observedAtField": "lastLogoutObservedAt"
|
||||
},
|
||||
"presence": {
|
||||
"timestampField": "lastLogoutObservedAt",
|
||||
"activeWindowSeconds": 1,
|
||||
"activityTarget": {
|
||||
"collection": "scum_activity_events",
|
||||
"upsertKeys": [
|
||||
"steamId",
|
||||
"observedAt"
|
||||
],
|
||||
"captureMappings": {
|
||||
"steamId": "steamId",
|
||||
"displayName": "displayName"
|
||||
},
|
||||
"hashMappings": {
|
||||
"networkCorrelation": "ip"
|
||||
},
|
||||
"fixedValues": {
|
||||
"eventType": "logout",
|
||||
"reason": "disconnect",
|
||||
"source": "scum.login"
|
||||
},
|
||||
"observedAtField": "observedAt"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"lifecycleProjections": [
|
||||
{
|
||||
"key": "scum.lifecycle.stop-logout",
|
||||
@@ -745,16 +630,12 @@
|
||||
{
|
||||
"key": "scum-db-v57",
|
||||
"databaseUserVersion": 57,
|
||||
"logParserRefs": [
|
||||
"data-packs/scum-db-v57/log-parsers.json"
|
||||
],
|
||||
"configMapRefs": [
|
||||
"data-packs/scum-db-v57/config-maps.json"
|
||||
],
|
||||
"dataRefs": [
|
||||
"data-packs/scum-db-v57/gift-items.json",
|
||||
"data-packs/scum-db-v57/map-geometry.json",
|
||||
"data-packs/scum-db-v57/storage-model.json"
|
||||
"data-packs/scum-db-v57/map-geometry.json"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -767,9 +648,6 @@
|
||||
"permission": "server.game-client.read",
|
||||
"requiredHandlers": [
|
||||
"player.lookup"
|
||||
],
|
||||
"requiredEventProducers": [
|
||||
"semantic.events"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -792,10 +670,7 @@
|
||||
{
|
||||
"key": "trajectory.collect",
|
||||
"title": "SCUM trajectories",
|
||||
"permission": "server.game-client.read",
|
||||
"requiredEventProducers": [
|
||||
"semantic.events"
|
||||
]
|
||||
"permission": "server.game-client.read"
|
||||
}
|
||||
],
|
||||
"pages": [
|
||||
@@ -1196,10 +1071,6 @@
|
||||
"path": "data-packs/scum-db-v57/config-maps.json",
|
||||
"mode": 384
|
||||
},
|
||||
{
|
||||
"path": "data-packs/scum-db-v57/log-parsers.json",
|
||||
"mode": 384
|
||||
},
|
||||
{
|
||||
"path": "data-packs/scum-db-v57/gift-items.json",
|
||||
"mode": 384
|
||||
@@ -1207,10 +1078,6 @@
|
||||
{
|
||||
"path": "data-packs/scum-db-v57/map-geometry.json",
|
||||
"mode": 384
|
||||
},
|
||||
{
|
||||
"path": "data-packs/scum-db-v57/storage-model.json",
|
||||
"mode": 384
|
||||
}
|
||||
],
|
||||
"productionLifecycle": {
|
||||
@@ -1379,6 +1246,7 @@
|
||||
},
|
||||
"transportKeys": [
|
||||
"server-files",
|
||||
"scum-database",
|
||||
"scum-management"
|
||||
],
|
||||
"dllExtensionRefs": [
|
||||
@@ -1538,138 +1406,6 @@
|
||||
"retentionDays": 30
|
||||
}
|
||||
],
|
||||
"logEvents": [
|
||||
{
|
||||
"key": "scum-player-position",
|
||||
"title": "SCUM player position",
|
||||
"sourceKey": "scum-client-events",
|
||||
"eventType": "player.position",
|
||||
"permission": "server.logs.read",
|
||||
"schemaRef": "schemas/log-events/player-position.event.schema.json",
|
||||
"retentionDays": 7,
|
||||
"severity": "info"
|
||||
},
|
||||
{
|
||||
"key": "scum-vehicle-position",
|
||||
"title": "SCUM vehicle position",
|
||||
"sourceKey": "scum-client-events",
|
||||
"eventType": "vehicle.position",
|
||||
"permission": "server.logs.read",
|
||||
"schemaRef": "schemas/log-events/vehicle-position.event.schema.json",
|
||||
"retentionDays": 7,
|
||||
"severity": "info"
|
||||
},
|
||||
{
|
||||
"key": "scum-player-vehicle-enter",
|
||||
"title": "SCUM player vehicle enter",
|
||||
"sourceKey": "scum-client-events",
|
||||
"eventType": "player.vehicle.enter",
|
||||
"permission": "server.logs.read",
|
||||
"schemaRef": "schemas/log-events/player-vehicle-enter.event.schema.json",
|
||||
"retentionDays": 7,
|
||||
"severity": "info"
|
||||
},
|
||||
{
|
||||
"key": "scum-player-vehicle-leave",
|
||||
"title": "SCUM player vehicle leave",
|
||||
"sourceKey": "scum-client-events",
|
||||
"eventType": "player.vehicle.leave",
|
||||
"permission": "server.logs.read",
|
||||
"schemaRef": "schemas/log-events/player-vehicle-leave.event.schema.json",
|
||||
"retentionDays": 7,
|
||||
"severity": "info"
|
||||
},
|
||||
{
|
||||
"key": "scum-chat",
|
||||
"title": "SCUM chat message",
|
||||
"sourceKey": "scum-chat-events",
|
||||
"eventType": "scum.chat",
|
||||
"permission": "server.logs.read",
|
||||
"schemaRef": "schemas/log-events/chat.event.schema.json",
|
||||
"retentionDays": 90,
|
||||
"severity": "info"
|
||||
},
|
||||
{
|
||||
"key": "scum-login",
|
||||
"title": "SCUM player login",
|
||||
"sourceKey": "scum-login-events",
|
||||
"eventType": "scum.login",
|
||||
"permission": "server.logs.read",
|
||||
"schemaRef": "schemas/log-events/login.event.schema.json",
|
||||
"retentionDays": 90,
|
||||
"severity": "info"
|
||||
},
|
||||
{
|
||||
"key": "scum-logout",
|
||||
"title": "SCUM player logout",
|
||||
"sourceKey": "scum-login-events",
|
||||
"eventType": "scum.logout",
|
||||
"permission": "server.logs.read",
|
||||
"schemaRef": "schemas/log-events/logout.event.schema.json",
|
||||
"retentionDays": 90,
|
||||
"severity": "info"
|
||||
},
|
||||
{
|
||||
"key": "scum-kill",
|
||||
"title": "SCUM player kill",
|
||||
"sourceKey": "scum-kill-events",
|
||||
"eventType": "scum.kill",
|
||||
"permission": "server.logs.read",
|
||||
"schemaRef": "schemas/log-events/kill.event.schema.json",
|
||||
"retentionDays": 90,
|
||||
"severity": "info"
|
||||
},
|
||||
{
|
||||
"key": "scum-trade",
|
||||
"title": "SCUM trade activity",
|
||||
"sourceKey": "scum-trade-events",
|
||||
"eventType": "scum.trade",
|
||||
"permission": "server.logs.read",
|
||||
"schemaRef": "schemas/log-events/trade.event.schema.json",
|
||||
"retentionDays": 90,
|
||||
"severity": "info"
|
||||
},
|
||||
{
|
||||
"key": "scum-mine",
|
||||
"title": "SCUM mine activity",
|
||||
"sourceKey": "scum-server-events",
|
||||
"eventType": "scum.mine",
|
||||
"permission": "server.logs.read",
|
||||
"schemaRef": "schemas/log-events/mine.event.schema.json",
|
||||
"retentionDays": 90,
|
||||
"severity": "warning"
|
||||
},
|
||||
{
|
||||
"key": "scum-unlock",
|
||||
"title": "SCUM unlock activity",
|
||||
"sourceKey": "scum-server-events",
|
||||
"eventType": "scum.unlock",
|
||||
"permission": "server.logs.read",
|
||||
"schemaRef": "schemas/log-events/unlock.event.schema.json",
|
||||
"retentionDays": 90,
|
||||
"severity": "warning"
|
||||
},
|
||||
{
|
||||
"key": "scum-admin",
|
||||
"title": "SCUM admin activity",
|
||||
"sourceKey": "scum-admin-events",
|
||||
"eventType": "scum.admin",
|
||||
"permission": "server.logs.read",
|
||||
"schemaRef": "schemas/log-events/admin.event.schema.json",
|
||||
"retentionDays": 90,
|
||||
"severity": "warning"
|
||||
},
|
||||
{
|
||||
"key": "scum-performance",
|
||||
"title": "SCUM server performance",
|
||||
"sourceKey": "scum-performance-events",
|
||||
"eventType": "scum.performance",
|
||||
"permission": "server.logs.read",
|
||||
"schemaRef": "schemas/log-events/performance.event.schema.json",
|
||||
"retentionDays": 30,
|
||||
"severity": "info"
|
||||
}
|
||||
],
|
||||
"transportProfiles": [
|
||||
{
|
||||
"key": "server-files",
|
||||
@@ -1724,6 +1460,21 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"dataTargets": [
|
||||
{
|
||||
"key": "scum-database",
|
||||
"kind": "sqlite.snapshot",
|
||||
"transportKey": "scum-database",
|
||||
"sourceRootKey": "server-root",
|
||||
"sourcePath": "SCUM/Saved/SaveFiles/SCUM.db",
|
||||
"workspaceKey": "databases/scum-database",
|
||||
"refreshPolicy": "on-demand-snapshot",
|
||||
"maxBytes": 1073741824,
|
||||
"platforms": [
|
||||
"windows"
|
||||
]
|
||||
}
|
||||
],
|
||||
"dllExtensions": [
|
||||
{
|
||||
"key": "scum-simple-rcon",
|
||||
@@ -1756,7 +1507,7 @@
|
||||
"displayName": "SCUM Client Manager",
|
||||
"version": "1.0.0",
|
||||
"repository": {
|
||||
"url": "https://git.npc0.com/admin343/browser.git",
|
||||
"url": "https://github.com/F88888/scum_client.git",
|
||||
"revisionPolicy": "branch",
|
||||
"branch": "main"
|
||||
},
|
||||
@@ -1768,8 +1519,7 @@
|
||||
],
|
||||
"build": {
|
||||
"system": "go",
|
||||
"workspaceRef": "plugins/examples/scum-server-plugin/companion",
|
||||
"entryRef": "cmd/scum-companion"
|
||||
"entryRef": "main.go"
|
||||
},
|
||||
"configTemplates": [
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
These query template keys are browser-safe declarations. They intentionally do not carry SQL text, host paths, DSNs, sockets, or credentials. The bound run/agent beside the current SCUM service owns the actual SQLite read implementation and must return rows matching the referenced result schemas.
|
||||
|
||||
| Template key | SCUM.db source tables | Plugin-owned output |
|
||||
| Template key | SCUM.db source tables | Projection target |
|
||||
| --- | --- | --- |
|
||||
| `scum.player.profile` | `user_profile`, `prisoner`, `prisoner_entity`, `entity`, `bank_account_registry`, `bank_account_registry_currencies`, optional `squad_member` / `squad` joins | Player identity, economy, squad summary, and current position |
|
||||
| `scum.squads` | `squad`, optional `squad_member`, optional `user_profile` leader joins | Squad records and leader/member counts |
|
||||
|
||||
@@ -38,13 +38,5 @@
|
||||
},
|
||||
"tls": {
|
||||
"policy": "verify-system-roots"
|
||||
},
|
||||
"trajectory": {
|
||||
"enabled": true,
|
||||
"source": "scum-sqlite",
|
||||
"store": "shared-platform-mysql",
|
||||
"fileEnv": "SCUM_DB_FILE",
|
||||
"intervalSeconds": 3,
|
||||
"maxRows": 500
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,19 +86,6 @@
|
||||
"properties": {
|
||||
"policy": { "const": "verify-system-roots" }
|
||||
}
|
||||
},
|
||||
"trajectory": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["enabled", "source", "store", "fileEnv", "intervalSeconds", "maxRows"],
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"source": { "const": "scum-sqlite" },
|
||||
"store": { "const": "shared-platform-mysql" },
|
||||
"fileEnv": { "const": "SCUM_DB_FILE" },
|
||||
"intervalSeconds": { "type": "integer", "minimum": 1, "maximum": 3600 },
|
||||
"maxRows": { "type": "integer", "minimum": 1, "maximum": 5000 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
|
||||
-1
@@ -1 +0,0 @@
|
||||
{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","additionalProperties":false,"required":["occurredAt","collectedAt","source","mapId","mapVersion","playerId","worldX","worldY"],"properties":{"occurredAt":{"type":"string","format":"date-time","maxLength":40},"collectedAt":{"type":"string","format":"date-time","maxLength":40},"source":{"enum":["companion","plugin.sql"]},"mapId":{"const":"scum-island"},"mapVersion":{"type":"string","maxLength":80},"playerId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96},"worldX":{"type":"number","minimum":-500000,"maximum":500000},"worldY":{"type":"number","minimum":-500000,"maximum":500000}}}
|
||||
-1
@@ -1 +0,0 @@
|
||||
{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","additionalProperties":false,"required":["occurredAt","source","mapId","mapVersion","playerId","vehicleId"],"properties":{"occurredAt":{"type":"string","format":"date-time","maxLength":40},"source":{"enum":["companion","plugin.sql"]},"mapId":{"const":"scum-island"},"mapVersion":{"type":"string","maxLength":80},"playerId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96},"vehicleId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96}}}
|
||||
-1
@@ -1 +0,0 @@
|
||||
{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","additionalProperties":false,"required":["occurredAt","source","mapId","mapVersion","playerId","vehicleId"],"properties":{"occurredAt":{"type":"string","format":"date-time","maxLength":40},"source":{"enum":["companion","plugin.sql"]},"mapId":{"const":"scum-island"},"mapVersion":{"type":"string","maxLength":80},"playerId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96},"vehicleId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96}}}
|
||||
-1
@@ -1 +0,0 @@
|
||||
{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","additionalProperties":false,"required":["occurredAt","collectedAt","source","mapId","mapVersion","vehicleId","worldX","worldY"],"properties":{"occurredAt":{"type":"string","format":"date-time","maxLength":40},"collectedAt":{"type":"string","format":"date-time","maxLength":40},"source":{"enum":["companion","plugin.sql"]},"mapId":{"const":"scum-island"},"mapVersion":{"type":"string","maxLength":80},"vehicleId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96},"worldX":{"type":"number","minimum":-500000,"maximum":500000},"worldY":{"type":"number","minimum":-500000,"maximum":500000}}}
|
||||
+1
-1
@@ -16,7 +16,7 @@ export const scumMigrationParityFixtures = {
|
||||
expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "patch-1", recordedAt: "2026-07-29T00:30:00Z", payload: { id: "patch-1", playerId: "player-1", expectedStateVersion: "state-1", safetyWindow: "maintenance", reason: "verified test", status: "unknown", createdAt: "2026-07-29T00:30:00Z", changes: [{ fieldKey: "skills.running", before: 1, after: 2 }] } }
|
||||
},
|
||||
trajectory: {
|
||||
source: { id: "trajectory-1", updatedAt: "2026-07-29T00:40:00Z", kind: "player", entityId: "steam-1", gamePlayerRecordId: "player-1", points: [{ mapX: 10, mapY: 20, occurredAt: "2026-07-29T00:39:00Z", source: "legacy-log" }, { mapX: 30, mapY: 40, occurredAt: "not-a-timestamp" }] },
|
||||
source: { id: "trajectory-1", updatedAt: "2026-07-29T00:40:00Z", kind: "player", entityId: "steam-1", gamePlayerRecordId: "player-1", points: [{ mapX: 10, mapY: 20, occurredAt: "2026-07-29T00:39:00Z", source: "log-projection" }, { mapX: 30, mapY: 40, occurredAt: "not-a-timestamp" }] },
|
||||
expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "trajectory-1", recordedAt: "2026-07-29T00:40:00Z", payload: { subjectId: "player-1", subjectType: "player", provenance: "transitional-read-only", points: [{ occurredAt: "2026-07-29T00:39:00Z", subjectId: "player-1", subjectType: "player", x: 10, y: 20, source: "transitional-read-only" }] } }
|
||||
}
|
||||
} as const;
|
||||
|
||||
@@ -26,15 +26,13 @@ import {
|
||||
parseBridgeExecutionResponse,
|
||||
parseAIInvocationResponse,
|
||||
type GameClientBridgeQueryTemplateDeclaration,
|
||||
type GameClientBridgeLogProjectionDeclaration,
|
||||
type GameClientBridgeCompanionDeclaration,
|
||||
type GamePluginManifest,
|
||||
type RuntimeLogEventDeclaration,
|
||||
type RuntimeClientManagerProfile,
|
||||
type PluginLifecycleActionDeclaration,
|
||||
type PluginBridgeContext
|
||||
} from "../sdk/index.js";
|
||||
import { validateGameClientBridgeCatalog, validateLifecycleActionFile, validateManifestFile } from "../scripts/validate-manifest.js";
|
||||
import { validateLifecycleActionFile, validateManifestFile } from "../scripts/validate-manifest.js";
|
||||
|
||||
const pluginsRoot = fileURLToPath(new URL("..", import.meta.url));
|
||||
|
||||
@@ -131,33 +129,6 @@ function validateTemporaryBridgeManifest(mutate?: (manifest: MutableBridgeManife
|
||||
}
|
||||
}
|
||||
|
||||
type MutableLogEventManifest = {
|
||||
permissions: string[];
|
||||
runtimeProfiles?: {
|
||||
logSources?: Array<Record<string, unknown>>;
|
||||
logEvents?: Array<Record<string, unknown>>;
|
||||
};
|
||||
};
|
||||
|
||||
function validateTemporaryLogEventManifest(mutate?: (manifest: MutableLogEventManifest, fixtureDir: string) => void): string[] {
|
||||
const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "browser-log-event-manifest-"));
|
||||
try {
|
||||
fs.cpSync(path.join(pluginsRoot, "examples/dev-game-plugin"), fixtureDir, { recursive: true });
|
||||
const manifestPath = path.join(fixtureDir, "manifest.json");
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as MutableLogEventManifest;
|
||||
manifest.runtimeProfiles = {
|
||||
logSources: [{ key: "server-events", kind: "file.tail", targetKey: "logs/server", streamKey: "game.server", cursorKind: "fingerprint", retentionDays: 30 }],
|
||||
logEvents: [{ key: "player-login", title: "Player login", sourceKey: "server-events", eventType: "game.login", permission: "server.logs.read", schemaRef: "schemas/log-events/login.event.schema.json", retentionDays: 30, severity: "info" }]
|
||||
};
|
||||
writeFixtureJSON(fixtureDir, "schemas/log-events/login.event.schema.json", bridgeObjectSchema({ occurredAt: { type: "string", minLength: 1, maxLength: 40 }, playerId: { type: "string", minLength: 1, maxLength: 96 } }, ["occurredAt", "playerId"]));
|
||||
mutate?.(manifest, fixtureDir);
|
||||
writeFixtureJSON(fixtureDir, "manifest.json", manifest);
|
||||
return validateManifestFile(manifestPath);
|
||||
} finally {
|
||||
fs.rmSync(fixtureDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function validateTemporaryScumCompanionManifest(mutate: (manifest: Record<string, any>, fixtureDir: string) => void): string[] {
|
||||
const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "browser-scum-companion-manifest-"));
|
||||
try {
|
||||
@@ -190,24 +161,13 @@ describe("plugin manifest validation", () => {
|
||||
expect(fs.existsSync(path.join(pluginDir, "schemas/bridge/queries/SCUM_DB_CONTRACT.md"))).toBe(true);
|
||||
});
|
||||
|
||||
it("declares BattlEye login projection and presence deduplication", () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"), "utf8")) as any;
|
||||
const projection = manifest.gameClientBridge.logProjections.find((candidate: { key: string }) => candidate.key === "scum.battleye.login");
|
||||
expect(projection).toMatchObject({
|
||||
streamKeys: ["scum.console.stdout"], correlationFields: ["slot"], maxInterveningLines: 8,
|
||||
target: { collection: "scum_users", upsertKeys: ["steamId"], captureMappings: { steamId: "steamId", displayName: "displayName", slot: "slot" }, fixedValues: { online: "true", source: "process.stdout" }, observedAtField: "lastLoginObservedAt" },
|
||||
presence: { timestampField: "lastLoginObservedAt", activeWindowSeconds: 600, activityTarget: { collection: "scum_activity_events", upsertKeys: ["steamId", "observedAt"], captureMappings: { steamId: "steamId", displayName: "displayName" }, fixedValues: { eventType: "login", source: "process.stdout" }, observedAtField: "observedAt" } }
|
||||
});
|
||||
expect(projection.steps.map((step: { pattern: string }) => step.pattern)).toEqual([
|
||||
'Player "(?P<displayName>[^\"]+)" reported as player (?P<slot>\\d+)',
|
||||
"Player (?P<slot>\\d+) SteamID \\(assumed\\): (?P<steamId>\\d+)"
|
||||
]);
|
||||
const compile = (pattern: string) => new RegExp(pattern.replaceAll("(?P<", "(?<"));
|
||||
expect(compile(projection.steps[0].pattern).exec('LogBattlEye: Display: Player "love_fitting" reported as player 0')?.groups).toMatchObject({ displayName: "love_fitting", slot: "0" });
|
||||
expect(compile(projection.steps[1].pattern).exec("LogBattlEye: Display: Player 0 SteamID (assumed): 76561199510658111")?.groups).toMatchObject({ slot: "0", steamId: "76561199510658111" });
|
||||
const loginLogProjection = manifest.gameClientBridge.logProjections.find((candidate: { key: string }) => candidate.key === "scum.login-log.login");
|
||||
expect(loginLogProjection.target.captureMappings).toMatchObject({ steamId: "steamId", displayName: "displayName", lastLoginIp: "ip" });
|
||||
expect(loginLogProjection.presence.activityTarget.captureMappings).toMatchObject({ steamId: "steamId", displayName: "displayName", lastLoginIp: "ip" });
|
||||
it("declares SCUM user projection through SQLite", () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"), "utf8")) as {
|
||||
gameClientBridge: { queryTemplates: Array<{ key: string; engine: string; transportKey: string; targetKey: string; projections?: Array<{ collection: string; fixedValues?: Record<string, string> }> }> };
|
||||
};
|
||||
const users = manifest.gameClientBridge.queryTemplates.find((template) => template.key === "scum.player.profile");
|
||||
expect(users).toMatchObject({ engine: "sqlite", transportKey: "scum-database", targetKey: "scum-database" });
|
||||
expect(users?.projections).toEqual(expect.arrayContaining([expect.objectContaining({ collection: "scum_users", fixedValues: { source: "run.sqlite.scum.player.profile" } })]));
|
||||
});
|
||||
|
||||
it("declares SCUM install/update and start lifecycle through plugin assets", () => {
|
||||
@@ -309,7 +269,6 @@ describe("plugin manifest validation", () => {
|
||||
expect(validate(example), JSON.stringify(validate.errors)).toBe(true);
|
||||
expect(JSON.stringify(example)).not.toMatch(/authKey|componentKey|credential|password|sessionToken|secret|\/api\/v1\/scum-clients\//i);
|
||||
expect(example).toMatchObject({ proof: { materialEnv: "SCUM_COMPONENT_PROOF" }, session: { mode: "component-session" }, tls: { policy: "verify-system-roots" } });
|
||||
expect(example).toMatchObject({ trajectory: { enabled: true, source: "scum-sqlite", store: "shared-platform-mysql", fileEnv: "SCUM_DB_FILE", intervalSeconds: 3, maxRows: 500 } });
|
||||
});
|
||||
|
||||
it("rejects unsafe SCUM companion bootstrap policy and inline session material", () => {
|
||||
@@ -461,7 +420,6 @@ describe("plugin manifest validation", () => {
|
||||
}>;
|
||||
snapshots: Array<{ type: string; schemaVersion: string; schemaRef: string }>;
|
||||
queryTemplates: Array<{ key: string; projections?: Array<{ collection?: string; fixedValues?: Record<string, string>; mergeExisting?: boolean }> }>;
|
||||
logProjections?: Array<{ key: string; streamKeys?: string[]; target?: { collection?: string; upsertKeys?: string[]; captureMappings?: Record<string, string> } }>;
|
||||
pages: Array<{ pageKey: string; commandTypes?: string[]; snapshotTypes?: string[]; queryTemplateKeys?: string[] }>;
|
||||
};
|
||||
pages: Array<{ key: string; permissions?: string[] }>;
|
||||
@@ -488,7 +446,7 @@ describe("plugin manifest validation", () => {
|
||||
const serialized = JSON.stringify(manifest).toLowerCase();
|
||||
|
||||
expect(serialized).not.toContain("local-proof");
|
||||
expect(manifest.version).toBe("0.1.14");
|
||||
expect(manifest.version).toBe("0.1.15");
|
||||
expect(installAction.environment?.SERVER_TEMPLATE).toBe("scum-server");
|
||||
expect(manifest.permissions).toEqual(expect.arrayContaining(["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"]));
|
||||
expect(manifest.gameClientBridge.commands.map((command) => command.type)).toEqual(expect.arrayContaining([
|
||||
@@ -502,10 +460,9 @@ describe("plugin manifest validation", () => {
|
||||
"maintenance.prepare"
|
||||
]));
|
||||
expect(manifest.gameClientBridge.snapshots.map((snapshot) => snapshot.type)).toEqual(expect.arrayContaining(["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"]));
|
||||
expect(manifest.gameClientBridge.queryTemplates.every((template) => !template.projections?.length)).toBe(true);
|
||||
expect(manifest.gameClientBridge.logProjections?.map((projection) => projection.key)).toEqual(expect.arrayContaining(["scum.trade.catalog", "scum.trade.events"]));
|
||||
expect(manifest.gameClientBridge.logProjections?.find((projection) => projection.key === "scum.trade.catalog")).toMatchObject({ streamKeys: ["scum.trade"], target: { collection: "scum_trade_goods", upsertKeys: ["code"], captureMappings: { code: "itemCode" } } });
|
||||
expect(manifest.gameClientBridge.logProjections?.find((projection) => projection.key === "scum.trade.events")?.target?.collection).toBe("scum_trade_events");
|
||||
expect(manifest.gameClientBridge.queryTemplates.find((template) => template.key === "scum.vehicles")?.projections).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ collection: "scum_trade_goods", mergeExisting: true, fixedValues: expect.objectContaining({ catalogType: "vehicle", type: "21", typeName: "其他载具" }) })
|
||||
]));
|
||||
expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).toEqual(expect.arrayContaining(["players", "squads", "live-map", "gifts", "workflows"]));
|
||||
expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).not.toContain("files-config");
|
||||
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "workflows")?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.player.profile", "scum.squads", "scum.vehicles", "scum.flags", "scum.positions"]));
|
||||
@@ -594,7 +551,7 @@ describe("plugin manifest validation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("declares bounded SCUM snapshot schemas for operations views", () => {
|
||||
it("declares bounded SCUM snapshot schemas for operations projections", () => {
|
||||
const manifestPath = path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json");
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as {
|
||||
gameClientBridge: {
|
||||
@@ -672,7 +629,7 @@ describe("plugin manifest validation", () => {
|
||||
const fastTemplates = new Set(["scum.player.profile", "scum.vehicles", "scum.positions"]);
|
||||
const templatesByKey = new Map(manifest.gameClientBridge.queryTemplates.map((template) => [template.key, template]));
|
||||
expect([...templatesByKey.keys()]).toEqual(expect.arrayContaining(expectedKeys));
|
||||
expect([...templatesByKey.values()].every((template) => !template.projections?.length)).toBe(true);
|
||||
expect(templatesByKey.get("scum.player.profile")?.projections).toEqual([expect.objectContaining({ collection: "scum_users", rowPath: "rows", upsertKeys: ["steamId"], observedAtField: "profileSampledAt", mergeExisting: true })]);
|
||||
expect(manifest.capabilities).toContain("remote.run.db.sqlite.query");
|
||||
expect(manifest.capabilities).toContain("remote.run.db.sqlite.execute");
|
||||
expect(manifest.remoteAccess?.runCapabilities).toContain("remote.run.db.sqlite.query");
|
||||
@@ -682,6 +639,7 @@ describe("plugin manifest validation", () => {
|
||||
const sqliteTransport = manifest.runtimeProfiles?.transportProfiles?.find((profile) => profile.key === "scum-database");
|
||||
expect(sqliteTransport).toMatchObject({ kind: "sqlite", targetKey: "scum-database" });
|
||||
expect(sqliteTransport?.capabilities).toEqual(expect.arrayContaining(["remote.run.db.sqlite.query", "remote.run.db.sqlite.execute"]));
|
||||
expect(manifest.runtimeProfiles?.dataTargets).toEqual(expect.arrayContaining([expect.objectContaining({ key: "scum-database", kind: "sqlite.snapshot", sourcePath: "SCUM/Saved/SaveFiles/SCUM.db", workspaceKey: "databases/scum-database" })]));
|
||||
for (const key of expectedKeys) {
|
||||
const template = templatesByKey.get(key)!;
|
||||
expect(template.engine).toBe("sqlite");
|
||||
@@ -729,122 +687,26 @@ describe("plugin manifest validation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("packages SCUM v57 config, UTF-16LE logs, and gift metadata inside the plugin", () => {
|
||||
it("packages SCUM v57 config and gift metadata inside the plugin", () => {
|
||||
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
|
||||
gameClientBridge: { dataPacks: Array<{ key: string; databaseUserVersion: number; logParserRefs: string[]; configMapRefs: string[]; dataRefs?: string[] }> };
|
||||
gameClientBridge: { dataPacks: Array<{ key: string; databaseUserVersion: number; configMapRefs: string[]; dataRefs?: string[] }> };
|
||||
};
|
||||
const pack = manifest.gameClientBridge.dataPacks.find((candidate) => candidate.key === "scum-db-v57");
|
||||
expect(pack).toMatchObject({ databaseUserVersion: 57 });
|
||||
const logParsers = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.logParserRefs[0]), "utf8"));
|
||||
const configMaps = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.configMapRefs[0]), "utf8"));
|
||||
const giftMetadata = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.dataRefs![0]), "utf8"));
|
||||
const mapGeometry = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.dataRefs![1]), "utf8"));
|
||||
const storageModel = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.dataRefs![2]), "utf8"));
|
||||
expect(logParsers).toMatchObject({ encoding: "utf-16le", lineEnding: "lf", continuationPolicy: "append-to-previous-timestamped-record", timestampFormat: "yyyy.MM.dd-HH.mm.ss" });
|
||||
expect(logParsers.parsers.map((parser: { key: string }) => parser.key)).toEqual(expect.arrayContaining(["login", "chat", "admin", "kill", "event-kill", "quests", "vehicle-destruction"]));
|
||||
expect(configMaps.maps.map((map: { key: string }) => map.key)).toEqual(expect.arrayContaining(["server-settings", "economy-override", "raid-times", "notifications", "admin-users", "banned-users"]));
|
||||
expect(giftMetadata).toMatchObject({ databaseUserVersion: 57, catalogSource: { configMapKey: "economy-override" } });
|
||||
expect(mapGeometry).toMatchObject({ databaseUserVersion: 57, image: { path: "assets/map/scum-map-overview.jpg", width: 256, height: 256 }, runtimeOverride: { kilometersToWorldUnits: 100000 } });
|
||||
expect(storageModel).toMatchObject({ databaseUserVersion: 57, store: "plugin-shared-platform-mysql", tables: [expect.objectContaining({ name: "scum_trajectories", writer: "companion.SCUMSQLStore.StoreTrajectorySamples", coordinateColumns: ["world_x", "world_y", "world_z"], coordinatePolicy: "store-game-world-coordinates-only" })] });
|
||||
});
|
||||
|
||||
it("declares typed SCUM semantic log events with bounded schemas", () => {
|
||||
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
|
||||
permissions: string[];
|
||||
runtimeProfiles?: {
|
||||
logSources?: Array<{ key: string; kind?: string; streamKey?: string; retentionDays?: number }>;
|
||||
logEvents?: Array<RuntimeLogEventDeclaration>;
|
||||
};
|
||||
};
|
||||
const expectedTypes = ["scum.chat", "scum.login", "scum.logout", "scum.kill", "scum.trade", "scum.mine", "scum.unlock", "scum.admin", "scum.performance"];
|
||||
const logSources = new Map((manifest.runtimeProfiles?.logSources ?? []).map((source) => [source.key, source]));
|
||||
const logEvents = manifest.runtimeProfiles?.logEvents ?? [];
|
||||
|
||||
expect(logEvents.map((event) => event.eventType)).toEqual(expect.arrayContaining(expectedTypes));
|
||||
expect(logSources.get("scum-console-stdout")).toMatchObject({ kind: "process.stdout", streamKey: "scum.console.stdout" });
|
||||
expect(logSources.get("scum-console-stderr")).toMatchObject({ kind: "process.stderr", streamKey: "scum.console.stderr" });
|
||||
expect(new Set(logEvents.map((event) => event.key)).size).toBe(logEvents.length);
|
||||
expect(new Set(logEvents.map((event) => event.eventType)).size).toBe(logEvents.length);
|
||||
for (const event of logEvents) {
|
||||
const source = logSources.get(event.sourceKey);
|
||||
expect(source).toBeDefined();
|
||||
expect(manifest.permissions).toContain(event.permission);
|
||||
expect(event.retentionDays).toBeGreaterThanOrEqual(1);
|
||||
expect(event.retentionDays).toBeLessThanOrEqual(source?.retentionDays ?? 365);
|
||||
expect(["info", "notice", "warning", "critical"]).toContain(event.severity);
|
||||
expect(event.schemaRef).toMatch(/^schemas\/log-events\/[a-z-]+\.event\.schema\.json$/);
|
||||
|
||||
const schema = JSON.parse(fs.readFileSync(path.join(pluginDir, event.schemaRef), "utf8")) as Record<string, unknown>;
|
||||
expect((schema.properties as Record<string, Record<string, unknown>>).occurredAt).toMatchObject({ type: "string", format: "date-time" });
|
||||
const visit = (value: unknown): void => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(visit);
|
||||
return;
|
||||
}
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.type === "object" || Object.hasOwn(record, "properties")) {
|
||||
expect(record.additionalProperties).toBe(false);
|
||||
}
|
||||
if (record.type === "array") {
|
||||
expect(record.maxItems).toBeGreaterThan(0);
|
||||
}
|
||||
if (record.type === "string" && !Object.hasOwn(record, "enum") && !Object.hasOwn(record, "const")) {
|
||||
expect(record.maxLength).toBeGreaterThan(0);
|
||||
}
|
||||
if (record.type === "integer" || record.type === "number") {
|
||||
expect(record.minimum).toBeDefined();
|
||||
expect(record.maximum).toBeDefined();
|
||||
}
|
||||
Object.values(record).forEach(visit);
|
||||
};
|
||||
expect(schema.type).toBe("object");
|
||||
expect(JSON.stringify(schema).toLowerCase()).not.toMatch(/sqltext|shellcommand|hostpath|rawpath|password|credential|runsocket|directsocket/);
|
||||
visit(schema);
|
||||
}
|
||||
const login = logEvents.find((event) => event.eventType === "scum.login");
|
||||
const loginSchema = JSON.parse(fs.readFileSync(path.join(pluginDir, login?.schemaRef ?? ""), "utf8")) as { properties?: Record<string, Record<string, unknown>> };
|
||||
expect(loginSchema.properties?.networkFingerprint).toMatchObject({ type: "string", writeOnly: true });
|
||||
});
|
||||
|
||||
it("rejects unsafe semantic log declarations and missing references", () => {
|
||||
const errors = validateTemporaryLogEventManifest((manifest) => {
|
||||
const event = manifest.runtimeProfiles!.logEvents![0];
|
||||
event.eventType = "shell.execute";
|
||||
event.sourceKey = "missing-source";
|
||||
event.permission = "server.game-client.read";
|
||||
event.schemaRef = "schemas/log-events/missing.event.schema.json";
|
||||
event.retentionDays = 366;
|
||||
event.severity = "urgent";
|
||||
});
|
||||
|
||||
expect(errors.some((error) => error.includes("eventType") && error.includes("not allowed"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("sourceKey") && error.includes("undeclared log source"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("permission") && error.includes("declared"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("schemaRef") && error.includes("missing semantic log event schema"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("retentionDays"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("severity"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unsafe or unbounded semantic log event schemas", () => {
|
||||
const errors = validateTemporaryLogEventManifest((_manifest, fixtureDir) => {
|
||||
writeFixtureJSON(fixtureDir, "schemas/log-events/login.event.schema.json", bridgeObjectSchema({ hostPath: { type: "string" }, details: { type: "string" }, count: { type: "integer" } }, ["hostPath", "details", "count"]));
|
||||
});
|
||||
|
||||
expect(errors.some((error) => error.includes("schemaRef") && error.includes("raw host path"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("maxLength") && error.includes("bounded event strings"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("bounded event numbers"))).toBe(true);
|
||||
});
|
||||
|
||||
it("aligns the SCUM Client Manager declaration with the real Go bootstrap", () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"), "utf8")) as {
|
||||
runtimeProfiles?: { clientManagers?: Array<{
|
||||
key: string;
|
||||
repository?: { url?: string; branch?: string };
|
||||
build?: { workspaceRef?: string; entryRef?: string };
|
||||
configTemplates?: Array<{ key?: string; templateRef?: string; outputRef?: string }>;
|
||||
deployment?: { arguments?: string[] };
|
||||
@@ -852,8 +714,8 @@ describe("plugin manifest validation", () => {
|
||||
}> };
|
||||
};
|
||||
const manager = manifest.runtimeProfiles?.clientManagers?.find((profile) => profile.key === "scum-client-manager");
|
||||
expect(manager?.repository).toMatchObject({ url: "https://git.npc0.com/admin343/browser.git", branch: "main" });
|
||||
expect(manager?.build).toMatchObject({ workspaceRef: "plugins/examples/scum-server-plugin/companion", entryRef: "cmd/scum-companion" });
|
||||
expect(manager?.build).toMatchObject({ entryRef: "main.go" });
|
||||
expect(manager?.build).not.toHaveProperty("workspaceRef");
|
||||
expect(manager?.configTemplates).toEqual([{ key: "client-config", templateRef: "config.yaml.example", outputRef: "config.yaml" }]);
|
||||
expect(manager?.deployment?.arguments).toBeUndefined();
|
||||
expect(manager?.health).toMatchObject({ intervalSeconds: 30, degradedAfterSeconds: 90, offlineAfterSeconds: 120 });
|
||||
@@ -873,7 +735,6 @@ describe("plugin manifest validation", () => {
|
||||
manifest.gameClientBridge = {
|
||||
commands: [{ type: "diagnostic.ping", title: "Diagnostic ping", permission: "server.game-client.command", payloadSchemaRef: "schemas/bridge/diagnostic-ping.schema.json", resultSchemaRef: "schemas/bridge/diagnostic-ping-result.schema.json", timeoutSeconds: 60, maxPayloadBytes: 4096 }],
|
||||
snapshots: [{ type: "players", schemaVersion: "1", schemaRef: "schemas/bridge/players.schema.json", keepForSeconds: 3600, maxRecords: 100 }],
|
||||
logProjections: [{ key: "player.login", streamKeys: ["process.stdout"], steps: [{ pattern: "Player \\\"(?<name>[^\\\"]+)\\\" reported as player (?<slot>\\\\d+)" }, { pattern: "Player (?<slot>\\\\d+) SteamID: (?<steamId>\\\\d+)" }], correlationFields: ["slot"], maxInterveningLines: 16, target: { collection: "users", upsertKeys: ["steamId"], captureMappings: { steamId: "steamId", name: "name" }, observedAtField: "lastLoginAt" }, presence: { timestampField: "lastLoginAt", activeWindowSeconds: 600 } }],
|
||||
commandRetentionSeconds: 86400,
|
||||
maxCommands: 1000,
|
||||
pages: []
|
||||
@@ -884,38 +745,6 @@ describe("plugin manifest validation", () => {
|
||||
expect(validate(manifest)).toBe(false);
|
||||
});
|
||||
|
||||
it("validates ordered log projections and repeated correlation captures", () => {
|
||||
const projection = {
|
||||
key: "player.login",
|
||||
streamKeys: ["process.stdout"],
|
||||
steps: [
|
||||
{ pattern: "Player \\\"(?<name>[^\\\"]+)\\\" reported as player (?<slot>\\\\d+)" },
|
||||
{ pattern: "Player (?<slot>\\\\d+) SteamID: (?<steamId>\\\\d+)" }
|
||||
],
|
||||
correlationFields: ["slot"],
|
||||
maxInterveningLines: 16,
|
||||
target: { collection: "users", upsertKeys: ["steamId"], captureMappings: { steamId: "steamId", name: "name" }, observedAtField: "lastLoginAt" },
|
||||
presence: {
|
||||
timestampField: "lastLoginAt",
|
||||
activeWindowSeconds: 600,
|
||||
activityTarget: { collection: "activity", upsertKeys: ["steamId"], captureMappings: { steamId: "steamId" }, observedAtField: "observedAt" }
|
||||
}
|
||||
};
|
||||
const manifest = {
|
||||
permissions: ["server.game-client.command"],
|
||||
runtimeProfiles: { clientManagers: [{ key: "scum-client", health: { requiredCapabilities: ["game-client.bridge"] } }] },
|
||||
gameClientBridge: {
|
||||
commands: [{ type: "diagnostic.ping", payloadSchemaRef: "schemas/bridge/diagnostic-ping.schema.json" }],
|
||||
snapshots: [],
|
||||
logProjections: [projection]
|
||||
}
|
||||
};
|
||||
expect(validateGameClientBridgeCatalog(manifest)).toEqual([]);
|
||||
|
||||
projection.target.captureMappings.steamId = "missing";
|
||||
const errors = validateGameClientBridgeCatalog(manifest);
|
||||
expect(errors.some((error) => error.includes("references undeclared capture missing"))).toBe(true);
|
||||
});
|
||||
|
||||
it("loads and validates every schema referenced by a safe game-client bridge manifest", () => {
|
||||
expect(validateTemporaryBridgeManifest()).toEqual([]);
|
||||
@@ -1102,19 +931,6 @@ describe("plugin manifest validation", () => {
|
||||
|
||||
describe("plugin SDK", () => {
|
||||
|
||||
it("types generic runtime semantic log event declarations", () => {
|
||||
const declaration: RuntimeLogEventDeclaration = {
|
||||
key: "scum-performance",
|
||||
title: "SCUM server performance",
|
||||
sourceKey: "scum-performance-events",
|
||||
eventType: "scum.performance",
|
||||
permission: "server.logs.read",
|
||||
schemaRef: "schemas/log-events/performance.event.schema.json",
|
||||
retentionDays: 30,
|
||||
severity: "info"
|
||||
};
|
||||
expect(declaration).toMatchObject({ eventType: "scum.performance", permission: "server.logs.read", severity: "info" });
|
||||
});
|
||||
|
||||
it("types read-only SQLite query template declarations", () => {
|
||||
const declaration: GameClientBridgeQueryTemplateDeclaration = {
|
||||
@@ -1134,17 +950,6 @@ describe("plugin SDK", () => {
|
||||
expect(JSON.stringify(declaration).toLowerCase()).not.toMatch(/sqltext|dsn|hostpath|socket|credential/);
|
||||
});
|
||||
|
||||
it("types plugin-declared ordered log projections", () => {
|
||||
const declaration: GameClientBridgeLogProjectionDeclaration = {
|
||||
key: "scum.player.login",
|
||||
streamKeys: ["process.stdout"],
|
||||
steps: [{ pattern: "Player (?<slot>\\d+) SteamID: (?<steamId>\\d+)" }],
|
||||
correlationFields: ["slot"],
|
||||
maxInterveningLines: 16,
|
||||
target: { collection: "scum_users", upsertKeys: ["steamId"], captureMappings: { steamId: "steamId" }, observedAtField: "lastLoginAt" }
|
||||
};
|
||||
expect(declaration).toMatchObject({ key: "scum.player.login", correlationFields: ["slot"] });
|
||||
});
|
||||
|
||||
it("builds safe game-client bridge requests without component transport material", () => {
|
||||
const request = createGameClientBridgeQueueRequest({
|
||||
|
||||
@@ -39,8 +39,8 @@ const surfaceData: SCUMSurfaceData = {
|
||||
mapSettings: [],
|
||||
vehicles: [{ vehicleId: "veh-1", label: "Laika", className: "BPC_Laika_C", position: { x: 400, y: 200, z: 0 }, freshness: { status: "fresh" } }],
|
||||
trajectories: [
|
||||
{ subjectType: "player", subjectId: "76561198000000001", steamId: "76561198000000001", displayName: "Mira", x: 10, y: 20, z: 3, sampledAt: "2026-08-10T00:00:03Z", source: "plugin.sql.scum.positions" },
|
||||
{ subjectType: "vehicle", subjectId: "veh-1", vehicleId: "veh-1", label: "Laika", className: "BPC_Laika_C", x: 400, y: 200, z: 0, sampledAt: "2026-08-10T00:00:03Z", source: "plugin.sql.scum.vehicles" }
|
||||
{ subjectType: "player", subjectId: "76561198000000001", steamId: "76561198000000001", displayName: "Mira", x: 10, y: 20, z: 3, sampledAt: "2026-08-10T00:00:03Z", source: "run.sqlite.scum.positions" },
|
||||
{ subjectType: "vehicle", subjectId: "veh-1", vehicleId: "veh-1", label: "Laika", className: "BPC_Laika_C", x: 400, y: 200, z: 0, sampledAt: "2026-08-10T00:00:03Z", source: "run.sqlite.scum.vehicles" }
|
||||
],
|
||||
flags: [{ flagId: "flag-1", name: "Wolves Flag", ownerSquadId: "squad-1", ownershipConfidence: "verified", position: { x: 100, y: 80, z: 0 }, freshness: { status: "fresh" } }]
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user