diff --git a/platform/api/game_client_bridge_companion_handlers.go b/platform/api/game_client_bridge_companion_handlers.go index 035b555..8fcef0c 100644 --- a/platform/api/game_client_bridge_companion_handlers.go +++ b/platform/api/game_client_bridge_companion_handlers.go @@ -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) diff --git a/platform/api/game_client_bridge_handlers_test.go b/platform/api/game_client_bridge_handlers_test.go index 8e09a43..0c29eab 100644 --- a/platform/api/game_client_bridge_handlers_test.go +++ b/platform/api/game_client_bridge_handlers_test.go @@ -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") -} diff --git a/platform/api/log_ingest_handlers_test.go b/platform/api/log_ingest_handlers_test.go index 8dfc552..ba7390a 100644 --- a/platform/api/log_ingest_handlers_test.go +++ b/platform/api/log_ingest_handlers_test.go @@ -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) diff --git a/platform/api/resource_handlers_test.go b/platform/api/resource_handlers_test.go index b9b43a5..c3afe37 100644 --- a/platform/api/resource_handlers_test.go +++ b/platform/api/resource_handlers_test.go @@ -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() diff --git a/platform/api/run_log_relay_handlers.go b/platform/api/run_log_relay_handlers.go deleted file mode 100644 index 2bf43e8..0000000 --- a/platform/api/run_log_relay_handlers.go +++ /dev/null @@ -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)) -} diff --git a/platform/domain/job_channel.go b/platform/domain/job_channel.go index f53f485..e9609b8 100644 --- a/platform/domain/job_channel.go +++ b/platform/domain/job_channel.go @@ -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 diff --git a/platform/dto/job_channel.go b/platform/dto/job_channel.go index 89edc32..46d40bd 100644 --- a/platform/dto/job_channel.go +++ b/platform/dto/job_channel.go @@ -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, } } diff --git a/platform/dto/resources.go b/platform/dto/resources.go index f82d901..119770e 100644 --- a/platform/dto/resources.go +++ b/platform/dto/resources.go @@ -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 { diff --git a/platform/dto/runtime_profiles.go b/platform/dto/runtime_profiles.go index 83c5546..e9a15c2 100644 --- a/platform/dto/runtime_profiles.go +++ b/platform/dto/runtime_profiles.go @@ -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, diff --git a/platform/protocol/run-contracts.md b/platform/protocol/run-contracts.md index 71fa1a8..7e4122f 100644 --- a/platform/protocol/run-contracts.md +++ b/platform/protocol/run-contracts.md @@ -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...*` 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..` log stream IDs. Autonomous lifecycle bootstrap is Run-owned machine execution rather than a Platform job, so its durable process logs use `run...`. 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-*.` 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://` 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. diff --git a/platform/service/client_manager_lifecycle_test.go b/platform/service/client_manager_lifecycle_test.go index 75fc042..b4b553a 100644 --- a/platform/service/client_manager_lifecycle_test.go +++ b/platform/service/client_manager_lifecycle_test.go @@ -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) } diff --git a/platform/service/distribution_build_execution.go b/platform/service/distribution_build_execution.go index b390e09..6e82569 100644 --- a/platform/service/distribution_build_execution.go +++ b/platform/service/distribution_build_execution.go @@ -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 diff --git a/platform/service/distribution_build_execution_test.go b/platform/service/distribution_build_execution_test.go index a21cb69..fd450c3 100644 --- a/platform/service/distribution_build_execution_test.go +++ b/platform/service/distribution_build_execution_test.go @@ -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) diff --git a/platform/service/distribution_build_jobs.go b/platform/service/distribution_build_jobs.go index 140ef77..6ac75e5 100644 --- a/platform/service/distribution_build_jobs.go +++ b/platform/service/distribution_build_jobs.go @@ -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 diff --git a/platform/service/distribution_builder.go b/platform/service/distribution_builder.go index 743b36d..824b24c 100644 --- a/platform/service/distribution_builder.go +++ b/platform/service/distribution_builder.go @@ -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", diff --git a/platform/service/distribution_builder_test.go b/platform/service/distribution_builder_test.go index d683833..445b7a3 100644 --- a/platform/service/distribution_builder_test.go +++ b/platform/service/distribution_builder_test.go @@ -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", diff --git a/platform/service/distributions_test.go b/platform/service/distributions_test.go index 261f38e..014be8d 100644 --- a/platform/service/distributions_test.go +++ b/platform/service/distributions_test.go @@ -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) } diff --git a/platform/service/game_client_bridge_sessions_test.go b/platform/service/game_client_bridge_sessions_test.go index d777677..d23ca98 100644 --- a/platform/service/game_client_bridge_sessions_test.go +++ b/platform/service/game_client_bridge_sessions_test.go @@ -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" diff --git a/platform/service/game_client_bridge_test.go b/platform/service/game_client_bridge_test.go index 59e5ebd..c7a369a 100644 --- a/platform/service/game_client_bridge_test.go +++ b/platform/service/game_client_bridge_test.go @@ -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) } diff --git a/platform/service/job_channel.go b/platform/service/job_channel.go index 225a0a3..f9421e4 100644 --- a/platform/service/job_channel.go +++ b/platform/service/job_channel.go @@ -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 } diff --git a/platform/service/log_events.go b/platform/service/log_events.go index 1ad01b2..992862d 100644 --- a/platform/service/log_events.go +++ b/platform/service/log_events.go @@ -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, diff --git a/platform/service/log_ingest.go b/platform/service/log_ingest.go index dce6416..79f4c62 100644 --- a/platform/service/log_ingest.go +++ b/platform/service/log_ingest.go @@ -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 diff --git a/platform/service/log_ingest_test.go b/platform/service/log_ingest_test.go index 1c57a3f..a7f405f 100644 --- a/platform/service/log_ingest_test.go +++ b/platform/service/log_ingest_test.go @@ -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() diff --git a/platform/service/plugin_data_test.go b/platform/service/plugin_data_test.go index 51152b8..e337512 100644 --- a/platform/service/plugin_data_test.go +++ b/platform/service/plugin_data_test.go @@ -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) } } diff --git a/platform/service/plugin_lifecycle_projection.go b/platform/service/plugin_lifecycle_projection.go index 30e8ff8..eccb407 100644 --- a/platform/service/plugin_lifecycle_projection.go +++ b/platform/service/plugin_lifecycle_projection.go @@ -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 -} diff --git a/platform/service/plugin_query_projection.go b/platform/service/plugin_query_projection.go new file mode 100644 index 0000000..1c2d09c --- /dev/null +++ b/platform/service/plugin_query_projection.go @@ -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 +} diff --git a/platform/service/server_lifecycle.go b/platform/service/server_lifecycle.go index 5c3a9c6..260362c 100644 --- a/platform/service/server_lifecycle.go +++ b/platform/service/server_lifecycle.go @@ -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 diff --git a/platform/service/server_lifecycle_test.go b/platform/service/server_lifecycle_test.go index d0f9dea..7a8d6a6 100644 --- a/platform/service/server_lifecycle_test.go +++ b/platform/service/server_lifecycle_test.go @@ -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) diff --git a/platform_web/components/ServerManagementTerminalDrawer.test.tsx b/platform_web/components/ServerManagementTerminalDrawer.test.tsx index 2d86a1d..c45cacc 100644 --- a/platform_web/components/ServerManagementTerminalDrawer.test.tsx +++ b/platform_web/components/ServerManagementTerminalDrawer.test.tsx @@ -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('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 }; } diff --git a/platform_web/components/ServerManagementTerminalDrawer.tsx b/platform_web/components/ServerManagementTerminalDrawer.tsx index f98d6a9..adb1980 100644 --- a/platform_web/components/ServerManagementTerminalDrawer.tsx +++ b/platform_web/components/ServerManagementTerminalDrawer.tsx @@ -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 = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T }; +type HistoryLineState = { status: "idle" } | LoadState; 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 = { "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(null); + const [historyOpen, setHistoryOpen] = useState(false); + const [historyStreams, setHistoryStreams] = useState>({ status: "loading" }); + const [historyLines, setHistoryLines] = useState({ status: "idle" }); + const [selectedHistoryStreamId, setSelectedHistoryStreamId] = useState(""); const outputRef = useRef(null); const followLatestRef = useRef(true); const serverTimeRef = useRef(undefined); const initialHistoryPendingRef = useRef(false); const liveSessionRef = useRef(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
{serverName} - {`当前受管进程会话${liveSessionId ? " · SSE 实时推送" : " · 等待 Run 输出"}`} · {streams.status === "ready" ? "已连接" : streams.status === "loading" ? "连接日志流" : "日志流异常"} · {followLatest ? "自动置底" : "已解锁滚动"} + {historyOpen ? "历史日志(独立于实时终端)" : `当前受管进程会话${liveSessionId ? " · SSE 实时推送" : " · 等待 Run 输出"}`} · {streams.status === "ready" ? "已连接" : streams.status === "loading" ? "连接日志流" : "日志流异常"} · {followLatest ? "自动置底" : "已解锁滚动"}
+
- {streams.status === "error" &&
{streams.reason}
} - {streams.status === "ready" && lines.length === 0 &&
{liveSessionId ? "当前受管进程会话暂无输出,后续输出会自动追加。" : "当前没有可跟随的受管进程输出。"}
} - {lines.map((line) =>
{line.text}
)} + {!historyOpen && streams.status === "error" &&
{streams.reason}
} + {historyOpen && } + {!historyOpen && streams.status === "ready" && lines.length === 0 &&
{liveSessionId ? "当前受管进程会话暂无输出,后续输出会自动追加。" : "当前没有可跟随的受管进程输出;旧日志可从历史查看。"}
} + {!historyOpen && lines.map((line) =>
{line.text}
)}
@@ -313,6 +366,42 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu ); } +interface HistoryLogViewProps { + streams: LoadState; + lines: HistoryLineState; + selectedStreamId: string; + onSelect: (streamId: string) => Promise; + serverTime?: string; +} + +function HistoryLogView({ streams, lines, selectedStreamId, onSelect, serverTime }: HistoryLogViewProps) { + if (streams.status === "loading") return ; + if (streams.status === "error") return ; + if (streams.data.length === 0) return ; + return ( + <> +
+ + + + +
+ {lines.status === "idle" && } + {lines.status === "loading" && } + {lines.status === "error" && } + {lines.status === "ready" && lines.data.length === 0 && } + {lines.status === "ready" && lines.data.map((line) =>
{line.text}
)} + + ); +} + +function TerminalStatusLine({ tone, label, serverTime }: { tone: "info" | "warn" | "error"; label: string; serverTime?: string }) { + return
{label}
; +} + function terminalQuickCommandsForPlugin(pluginId: string): TerminalQuickCommand[] { return terminalQuickCommandCatalog[pluginId] ?? []; } diff --git a/platform_web/theme/base.css b/platform_web/theme/base.css index 4d45317..4299358 100644 --- a/platform_web/theme/base.css +++ b/platform_web/theme/base.css @@ -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} diff --git a/plugins/examples/scum-server-plugin/companion/adapters_e2e_test.go b/plugins/examples/scum-server-plugin/companion/adapters_e2e_test.go index 30baf15..b1c6b28 100644 --- a/plugins/examples/scum-server-plugin/companion/adapters_e2e_test.go +++ b/plugins/examples/scum-server-plugin/companion/adapters_e2e_test.go @@ -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*\(`), diff --git a/plugins/examples/scum-server-plugin/companion/config.go b/plugins/examples/scum-server-plugin/companion/config.go index 881385f..06d1104 100644 --- a/plugins/examples/scum-server-plugin/companion/config.go +++ b/plugins/examples/scum-server-plugin/companion/config.go @@ -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 != "/" { diff --git a/plugins/examples/scum-server-plugin/companion/config.yaml.example b/plugins/examples/scum-server-plugin/companion/config.yaml.example index 7d90178..81c2718 100644 --- a/plugins/examples/scum-server-plugin/companion/config.yaml.example +++ b/plugins/examples/scum-server-plugin/companion/config.yaml.example @@ -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 diff --git a/plugins/examples/scum-server-plugin/companion/config_test.go b/plugins/examples/scum-server-plugin/companion/config_test.go index d111b30..8aea965 100644 --- a/plugins/examples/scum-server-plugin/companion/config_test.go +++ b/plugins/examples/scum-server-plugin/companion/config_test.go @@ -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 -} diff --git a/plugins/examples/scum-server-plugin/companion/console_collector_test.go b/plugins/examples/scum-server-plugin/companion/console_collector_test.go deleted file mode 100644 index 5339e43..0000000 --- a/plugins/examples/scum-server-plugin/companion/console_collector_test.go +++ /dev/null @@ -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) - } -} diff --git a/plugins/examples/scum-server-plugin/companion/console_storage.go b/plugins/examples/scum-server-plugin/companion/console_storage.go deleted file mode 100644 index 5fdb855..0000000 --- a/plugins/examples/scum-server-plugin/companion/console_storage.go +++ /dev/null @@ -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)` diff --git a/plugins/examples/scum-server-plugin/companion/dispatcher.go b/plugins/examples/scum-server-plugin/companion/dispatcher.go index ca9a985..e0f9a8c 100644 --- a/plugins/examples/scum-server-plugin/companion/dispatcher.go +++ b/plugins/examples/scum-server-plugin/companion/dispatcher.go @@ -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) diff --git a/plugins/examples/scum-server-plugin/companion/go.mod b/plugins/examples/scum-server-plugin/companion/go.mod index ddf316e..9f76220 100644 --- a/plugins/examples/scum-server-plugin/companion/go.mod +++ b/plugins/examples/scum-server-plugin/companion/go.mod @@ -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 diff --git a/plugins/examples/scum-server-plugin/companion/go.sum b/plugins/examples/scum-server-plugin/companion/go.sum index 6350872..a62c313 100644 --- a/plugins/examples/scum-server-plugin/companion/go.sum +++ b/plugins/examples/scum-server-plugin/companion/go.sum @@ -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= diff --git a/plugins/examples/scum-server-plugin/companion/log_stream_test.go b/plugins/examples/scum-server-plugin/companion/log_stream_test.go deleted file mode 100644 index b08c9cd..0000000 --- a/plugins/examples/scum-server-plugin/companion/log_stream_test.go +++ /dev/null @@ -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) - } -} diff --git a/plugins/examples/scum-server-plugin/companion/sqlite_source.go b/plugins/examples/scum-server-plugin/companion/sqlite_source.go deleted file mode 100644 index c6a5425..0000000 --- a/plugins/examples/scum-server-plugin/companion/sqlite_source.go +++ /dev/null @@ -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)` diff --git a/plugins/examples/scum-server-plugin/companion/sqlite_source_test.go b/plugins/examples/scum-server-plugin/companion/sqlite_source_test.go deleted file mode 100644 index 2a8ed78..0000000 --- a/plugins/examples/scum-server-plugin/companion/sqlite_source_test.go +++ /dev/null @@ -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) - } -} diff --git a/plugins/examples/scum-server-plugin/companion/storage.go b/plugins/examples/scum-server-plugin/companion/storage.go deleted file mode 100644 index f1a7b18..0000000 --- a/plugins/examples/scum-server-plugin/companion/storage.go +++ /dev/null @@ -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)` diff --git a/plugins/examples/scum-server-plugin/companion/storage_test.go b/plugins/examples/scum-server-plugin/companion/storage_test.go deleted file mode 100644 index 000493c..0000000 --- a/plugins/examples/scum-server-plugin/companion/storage_test.go +++ /dev/null @@ -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 - } -} diff --git a/plugins/examples/scum-server-plugin/companion/trajectory_collector.go b/plugins/examples/scum-server-plugin/companion/trajectory_collector.go deleted file mode 100644 index 1295cf7..0000000 --- a/plugins/examples/scum-server-plugin/companion/trajectory_collector.go +++ /dev/null @@ -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 -} diff --git a/plugins/examples/scum-server-plugin/companion/trajectory_collector_test.go b/plugins/examples/scum-server-plugin/companion/trajectory_collector_test.go deleted file mode 100644 index 687001d..0000000 --- a/plugins/examples/scum-server-plugin/companion/trajectory_collector_test.go +++ /dev/null @@ -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]) - } -} diff --git a/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/storage-model.json b/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/storage-model.json deleted file mode 100644 index 8184b0b..0000000 --- a/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/storage-model.json +++ /dev/null @@ -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" - } - ] -} diff --git a/plugins/examples/scum-server-plugin/features/api.ts b/plugins/examples/scum-server-plugin/features/api.ts index 6ea785f..73a6add 100644 --- a/plugins/examples/scum-server-plugin/features/api.ts +++ b/plugins/examples/scum-server-plugin/features/api.ts @@ -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): Promise<{ status: string; result?: Record; error?: { message: string } }> }; +export type PluginFeatureBridge = { dispatch(action: "game-client.command", payload: Record): Promise<{ status: string; result?: Record; error?: { message: string } }> }; export type SCUMFeatureAPI = { availability(feature: SCUMFeatureKey): Promise; readConfig(): Promise; patchConfig(patch: SCUMConfigPatch): Promise; - playerProfile(playerId: string): Promise; stateSnapshot(playerId: string): Promise; requestStatePatch(patch: SCUMStatePatch): Promise; - requestVehicleSpawn(spawn: SCUMVehicleSpawn): Promise; - giftGrants(): Promise; trajectories(): Promise; + stateSnapshot(playerId: string): Promise; requestStatePatch(patch: SCUMStatePatch): Promise; requestVehicleSpawn(spawn: SCUMVehicleSpawn): Promise; }; 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(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(result.result) : null; }, async stateSnapshot(playerId) { const result = await bridge.dispatch("game-client.command", { type: "player.lookup", playerId }); return result.status === "ok" ? decode(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(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 })); } }; } diff --git a/plugins/examples/scum-server-plugin/features/page.ts b/plugins/examples/scum-server-plugin/features/page.ts index 8053e03..0182faf 100644 --- a/plugins/examples/scum-server-plugin/features/page.ts +++ b/plugins/examples/scum-server-plugin/features/page.ts @@ -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" }, "当前图层和筛选条件下没有真实地图点。")) ) ); } diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index 04c8037..b6cd88a 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -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[A-Za-z0-9_.-]{1,128}) \\(x(?P\\d{1,9})\\)\\) (?Ppurchased|sold) by .*?\\((?P\\d{1,50})\\) for (?P-?\\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[A-Za-z0-9_.-]{1,128}) \\(x(?P\\d{1,9})\\)\\) (?Ppurchased|sold) by .*?\\((?P\\d{1,50})\\) for (?P-?\\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[^\"]+)\" reported as player (?P\\d+)" - }, - { - "pattern": "Player (?P\\d+) SteamID \\(assumed\\): (?P\\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[0-9.]+) (?P\\d{1,50}):(?P[^']{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[0-9.]+) (?P\\d{1,50}):(?P[^']{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": [ { diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/queries/SCUM_DB_CONTRACT.md b/plugins/examples/scum-server-plugin/schemas/bridge/queries/SCUM_DB_CONTRACT.md index 1581529..6711fc8 100644 --- a/plugins/examples/scum-server-plugin/schemas/bridge/queries/SCUM_DB_CONTRACT.md +++ b/plugins/examples/scum-server-plugin/schemas/bridge/queries/SCUM_DB_CONTRACT.md @@ -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 | diff --git a/plugins/examples/scum-server-plugin/schemas/companion/config.generated.example.json b/plugins/examples/scum-server-plugin/schemas/companion/config.generated.example.json index 28c4112..00057cb 100644 --- a/plugins/examples/scum-server-plugin/schemas/companion/config.generated.example.json +++ b/plugins/examples/scum-server-plugin/schemas/companion/config.generated.example.json @@ -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 } } diff --git a/plugins/examples/scum-server-plugin/schemas/companion/config.schema.json b/plugins/examples/scum-server-plugin/schemas/companion/config.schema.json index 013e8a7..450ec8d 100644 --- a/plugins/examples/scum-server-plugin/schemas/companion/config.schema.json +++ b/plugins/examples/scum-server-plugin/schemas/companion/config.schema.json @@ -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": { diff --git a/plugins/examples/scum-server-plugin/schemas/log-events/player-position.event.schema.json b/plugins/examples/scum-server-plugin/schemas/log-events/player-position.event.schema.json deleted file mode 100644 index c2697d8..0000000 --- a/plugins/examples/scum-server-plugin/schemas/log-events/player-position.event.schema.json +++ /dev/null @@ -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}}} diff --git a/plugins/examples/scum-server-plugin/schemas/log-events/player-vehicle-enter.event.schema.json b/plugins/examples/scum-server-plugin/schemas/log-events/player-vehicle-enter.event.schema.json deleted file mode 100644 index d06dd9c..0000000 --- a/plugins/examples/scum-server-plugin/schemas/log-events/player-vehicle-enter.event.schema.json +++ /dev/null @@ -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}}} diff --git a/plugins/examples/scum-server-plugin/schemas/log-events/player-vehicle-leave.event.schema.json b/plugins/examples/scum-server-plugin/schemas/log-events/player-vehicle-leave.event.schema.json deleted file mode 100644 index d06dd9c..0000000 --- a/plugins/examples/scum-server-plugin/schemas/log-events/player-vehicle-leave.event.schema.json +++ /dev/null @@ -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}}} diff --git a/plugins/examples/scum-server-plugin/schemas/log-events/vehicle-position.event.schema.json b/plugins/examples/scum-server-plugin/schemas/log-events/vehicle-position.event.schema.json deleted file mode 100644 index fa2dd9b..0000000 --- a/plugins/examples/scum-server-plugin/schemas/log-events/vehicle-position.event.schema.json +++ /dev/null @@ -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}}} diff --git a/plugins/tests/fixtures/scum-migration-parity.ts b/plugins/tests/fixtures/scum-migration-parity.ts index 305b2fd..8bad2ef 100644 --- a/plugins/tests/fixtures/scum-migration-parity.ts +++ b/plugins/tests/fixtures/scum-migration-parity.ts @@ -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; diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index b821cdf..b8c8e83 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -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>; - logEvents?: Array>; - }; -}; - -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, 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[^\"]+)" reported as player (?P\\d+)', - "Player (?P\\d+) SteamID \\(assumed\\): (?P\\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 }> }> }; + }; + 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; mergeExisting?: boolean }> }>; - logProjections?: Array<{ key: string; streamKeys?: string[]; target?: { collection?: string; upsertKeys?: string[]; captureMappings?: Record } }>; 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; - }; - }; - 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; - expect((schema.properties as Record>).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; - 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> }; - 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 \\\"(?[^\\\"]+)\\\" reported as player (?\\\\d+)" }, { pattern: "Player (?\\\\d+) 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 \\\"(?[^\\\"]+)\\\" reported as player (?\\\\d+)" }, - { pattern: "Player (?\\\\d+) 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 (?\\d+) 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({ diff --git a/plugins/tests/scum-feature-module.test.ts b/plugins/tests/scum-feature-module.test.ts index e6e6dfc..f701c17 100644 --- a/plugins/tests/scum-feature-module.test.ts +++ b/plugins/tests/scum-feature-module.test.ts @@ -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" } }] };