Revert SCUM real data management change

This commit is contained in:
npc0-hue
2026-08-13 15:33:34 +08:00
parent d831e4ade9
commit b07a792784
163 changed files with 5443 additions and 9174 deletions
@@ -268,11 +268,6 @@ 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)
@@ -331,10 +326,6 @@ 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
@@ -45,13 +45,6 @@ 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.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles,
domain.RuntimeTransportProfile{Key: "server-files", Kind: "file", TargetKey: "server-root", Capabilities: []string{domain.JobCapabilityRemoteRunFilesRead}},
domain.RuntimeTransportProfile{Key: "world-db", Kind: "sqlite", TargetKey: "world-db", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteProbe}},
)
plugin.RuntimeProfiles.DataTargets = append(plugin.RuntimeProfiles.DataTargets,
domain.RuntimeDataTarget{Key: "world-db", Kind: "sqlite.snapshot", TransportKey: "world-db", SourceRootKey: "server-root", SourcePath: "world/current.db", WorkspaceKey: "databases/world-db", RefreshPolicy: "on-demand-snapshot", MaxBytes: 128 * 1024 * 1024, Platforms: []string{"linux"}},
)
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("seed plugin lifecycle assets: %v", err)
}
@@ -97,14 +90,14 @@ func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) {
if plan == nil || plan.SchemaVersion != "1" || plan.ServerInstanceID != instance.ID || plan.PluginID != plugin.ID || plan.ProfileKey != "local" || plan.Bootstrap == nil || plan.Bootstrap.Action != domain.ServerLifecycleActionStart || plan.Bootstrap.TargetKey != "actions/start.json" {
t.Fatalf("platform builder received incomplete autonomous lifecycle plan: %+v", plan)
}
if len(plan.DependencyProbes) != 1 || plan.DependencyProbes[0].Key != "java-runtime" || len(plan.InstallPlans) != 1 || plan.InstallPlans[0].Key != "java-install" || len(plan.LogSources) != 3 || !hasAutonomousLogSource(plan.LogSources, "process.stdout", "console") || !hasAutonomousLogSource(plan.LogSources, "file.tail", "latest-log") || !hasAutonomousLogSource(plan.LogSources, "file.tail", "scum.server") || len(plan.DataTargets) != 1 || plan.DataTargets[0].WorkspaceKey != "databases/world-db" || plan.DataTargets[0].SourcePath != "world/current.db" || plan.RuntimeBindings["logs/latest"] != "runtime.logs.latest" {
if len(plan.DependencyProbes) != 1 || plan.DependencyProbes[0].Key != "java-runtime" || len(plan.InstallPlans) != 1 || plan.InstallPlans[0].Key != "java-install" || len(plan.LogSources) != 3 || !hasAutonomousLogSource(plan.LogSources, "process.stdout", "console") || !hasAutonomousLogSource(plan.LogSources, "file.tail", "latest-log") || !hasAutonomousLogSource(plan.LogSources, "file.tail", "scum.server") || plan.RuntimeBindings["logs/latest"] != "runtime.logs.latest" {
t.Fatalf("autonomous lifecycle plan lost plugin runtime declarations: %+v", plan)
}
var seededPlan domain.RunAutonomousLifecyclePlan
if err := json.Unmarshal([]byte(seedFiles[2].Content), &seededPlan); err != nil {
t.Fatalf("unmarshal seeded autonomous lifecycle plan: %v", err)
}
if seededPlan.ServerInstanceID != plan.ServerInstanceID || seededPlan.Bootstrap == nil || seededPlan.Bootstrap.TargetKey != plan.Bootstrap.TargetKey || len(seededPlan.DataTargets) != 1 || seededPlan.DataTargets[0].WorkspaceKey != "databases/world-db" {
if seededPlan.ServerInstanceID != plan.ServerInstanceID || seededPlan.Bootstrap == nil || seededPlan.Bootstrap.TargetKey != plan.Bootstrap.TargetKey {
t.Fatalf("seeded lifecycle plan differs from build input: seed=%+v input=%+v", seededPlan, plan)
}
auth, err := svc.AuthenticateComponent(domain.ComponentAuthenticationRequest{
+1 -1
View File
@@ -764,7 +764,7 @@ func (svc *CoreService) QueueLogBackfillForSession(sessionID string, request dom
InputRef: request.CheckpointRef,
IdempotencyKey: request.IdempotencyKey,
Progress: domain.JobProgress{Percent: 0, Message: "historical log backfill queued"},
ExecutionInput: domain.JobExecutionInput{PluginID: plugin.ID, TargetVersion: plugin.Version, LogSource: &source},
ExecutionInput: domain.JobExecutionInput{LogSource: &source},
})
if err != nil {
_ = svc.recordAuditEvent(user.ID, "logs.backfill.denied", "server-instance", instance.ID, domain.AuditResultDenied, "log backfill denied: endpoint unsupported or offline")
+6
View File
@@ -125,6 +125,9 @@ func (svc *CoreService) projectGamePlayerEvent(batch domain.LogBatchIngest, entr
if eventType == "scum.login" {
outcome := strings.TrimSpace(fields["outcome"])
if outcome == "accepted" {
if err := svc.projectSCUMLoginLiveState(player, batch, entry, occurred, true, ""); err != nil {
return err
}
if err := svc.recordSuccessfulGameAccess(player, batch, entry, occurred, strings.TrimSpace(fields["networkFingerprint"])); err != nil {
return err
}
@@ -132,6 +135,9 @@ func (svc *CoreService) projectGamePlayerEvent(batch domain.LogBatchIngest, entr
}
return svc.recordFailedGameAccess(player, batch, entry, occurred, strings.TrimSpace(fields["networkFingerprint"]))
}
if err := svc.projectSCUMLoginLiveState(player, batch, entry, occurred, false, strings.TrimSpace(fields["reason"])); err != nil {
return err
}
return svc.closeGamePlayerSession(player, sourceSession, occurred, strings.TrimSpace(fields["reason"]))
}
+3 -92
View File
@@ -288,9 +288,6 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo
}
func validateExecutionResultForJob(job domain.Job, result domain.RunJobResult) error {
if result.ExecutionResult.ParsedLogBatch != nil && job.Capability != domain.JobCapabilityLogsBackfill {
return validationError("parsed log batch result is allowed only for logs.backfill jobs")
}
if definition := job.ExecutionInput.Deployment; definition != nil {
receipt := result.ExecutionResult.DeploymentReceipt
if result.State == domain.JobStateSucceeded && definition.Mode == domain.ServerDeploymentModeCustom && receipt == nil {
@@ -337,72 +334,6 @@ func validateExecutionResultForJob(job domain.Job, result domain.RunJobResult) e
if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "run.update.staged" {
return validationError("Run self-update result type is invalid")
}
case domain.JobCapabilityRemoteRunDBSQLiteProbe:
if result.ExecutionResult.Kind != "" && result.ExecutionResult.Kind != scumSchemaProbeExecutionKind {
return validationError("SQLite schema probe result type is invalid")
}
if result.State == domain.JobStateSucceeded {
if result.ExecutionResult.SQLiteSchemaProbe == nil {
return validationError("SQLite schema probe terminal result is required")
}
if err := validateSCUMSchemaProbeResultForJob(job, *result.ExecutionResult.SQLiteSchemaProbe); err != nil {
return err
}
}
case domain.JobCapabilityRemoteRunDBSQLiteQuery:
if result.ExecutionResult.Kind != "" && result.ExecutionResult.Kind != scumSQLiteTemplateExecutionKind {
return validationError("SQLite template query result type is invalid")
}
if result.State == domain.JobStateSucceeded {
if result.ExecutionResult.SQLiteTemplate == nil {
return validationError("SQLite template terminal result is required")
}
if err := validateSCUMSQLiteTemplateResultForJob(job, *result.ExecutionResult.SQLiteTemplate); err != nil {
return err
}
}
case domain.JobCapabilityRemoteRunProtectedRCON:
if job.ExecutionInput.RCONTemplate == nil {
break
}
if result.ExecutionResult.Kind != "" && result.ExecutionResult.Kind != scumRCONTemplateExecutionKind {
return validationError("typed RCON template result type is invalid")
}
if result.State == domain.JobStateSucceeded {
if result.ExecutionResult.RCONTemplate == nil {
return validationError("typed RCON template terminal result is required")
}
if err := validateSCUMTypedRCONTemplateResultForJob(job, *result.ExecutionResult.RCONTemplate); err != nil {
return err
}
}
case domain.JobCapabilityRemoteRunProtectedSQL:
if job.ExecutionInput.GuardedMutation == nil {
break
}
if result.ExecutionResult.Kind != "" && result.ExecutionResult.Kind != scumGuardedMutationExecutionKind {
return validationError("guarded mutation result type is invalid")
}
if result.State == domain.JobStateSucceeded {
if result.ExecutionResult.GuardedMutation == nil {
return validationError("guarded mutation terminal result is required")
}
if err := validateSCUMGuardedMutationResultForJob(job, *result.ExecutionResult.GuardedMutation); err != nil {
return err
}
}
case domain.JobCapabilityLogsBackfill:
if result.ExecutionResult.ParsedLogBatch == nil {
break
}
if result.ExecutionResult.Kind != "" && result.ExecutionResult.Kind != scumParsedLogBatchExecutionKind {
return validationError("parsed log batch result type is invalid")
}
if result.State == domain.JobStateSucceeded {
if err := validateSCUMParsedLogBatchResultForJob(job, *result.ExecutionResult.ParsedLogBatch); err != nil {
return err
}
}
case domain.JobCapabilityClientManagerDeploy:
if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "client-manager.deployed" {
return validationError("client-manager deploy result type is invalid")
@@ -694,7 +625,7 @@ func firstEligibleSupportedJob(jobs []domain.Job, capabilities []string, stamp t
func assignmentFromJob(job domain.Job, leaseToken string) domain.RunJobAssignment {
fencingToken := uint64(0)
if isProtectedRequestCapability(job.Capability) || job.Capability == domain.JobCapabilityRemoteRunDBSQLiteProbe || job.Capability == domain.JobCapabilityRemoteRunDBSQLiteQuery {
if isProtectedRequestCapability(job.Capability) {
fencingToken = uint64(job.Attempt)
}
return domain.RunJobAssignment{
@@ -708,7 +639,7 @@ func assignmentFromJob(job domain.Job, leaseToken string) domain.RunJobAssignmen
State: job.State,
Progress: domain.RunJobProgressReport{Percent: job.Progress.Percent, Phase: job.Progress.Phase, Message: job.Progress.Message},
ResultRef: job.ResultRef,
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: job.ExecutionInput.WorkspaceScope, Content: job.ExecutionInput.Content, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds, PluginID: job.ExecutionInput.PluginID, LifecycleOperation: job.ExecutionInput.LifecycleOperation, TargetVersion: job.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(job.ExecutionInput.Inputs), LogSource: domain.CopyRuntimeLogSourcePtr(job.ExecutionInput.LogSource), LogSources: domain.CopyRuntimeLogSources(job.ExecutionInput.LogSources), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON), Deployment: deploymentPlanForDispatchValue(job.ExecutionInput.Deployment), ServerDeploymentPlan: domain.CopyServerDeploymentPlan(job.ExecutionInput.ServerDeploymentPlan), SQLiteSchemaProbe: domain.CopySCUMSchemaProbeRequestPtr(job.ExecutionInput.SQLiteSchemaProbe), SQLiteTemplate: domain.CopySCUMSQLiteTemplateRequestPtr(job.ExecutionInput.SQLiteTemplate), RCONTemplate: domain.CopySCUMTypedRCONTemplateRequestPtr(job.ExecutionInput.RCONTemplate), GuardedMutation: domain.CopySCUMGuardedMutationRequestPtr(job.ExecutionInput.GuardedMutation)},
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: job.ExecutionInput.WorkspaceScope, Content: job.ExecutionInput.Content, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds, PluginID: job.ExecutionInput.PluginID, LifecycleOperation: job.ExecutionInput.LifecycleOperation, TargetVersion: job.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(job.ExecutionInput.Inputs), LogSource: domain.CopyRuntimeLogSourcePtr(job.ExecutionInput.LogSource), LogSources: domain.CopyRuntimeLogSources(job.ExecutionInput.LogSources), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON), Deployment: deploymentPlanForDispatchValue(job.ExecutionInput.Deployment), ServerDeploymentPlan: domain.CopyServerDeploymentPlan(job.ExecutionInput.ServerDeploymentPlan)},
LeaseToken: leaseToken,
Attempt: job.Attempt,
FencingToken: fencingToken,
@@ -786,27 +717,7 @@ func jobRetryBackoff(policy domain.JobRetryPolicy, attempt int) time.Duration {
}
func terminalFingerprint(result domain.RunJobResult) string {
schemaProbeFingerprint := ""
if result.ExecutionResult.SQLiteSchemaProbe != nil {
schemaProbeFingerprint = result.ExecutionResult.SQLiteSchemaProbe.ResultDigest
}
sqliteTemplateFingerprint := ""
if result.ExecutionResult.SQLiteTemplate != nil {
sqliteTemplateFingerprint = fmt.Sprintf("%s|%s|%s|%d", result.ExecutionResult.SQLiteTemplate.ResultDigest, result.ExecutionResult.SQLiteTemplate.AssetDigest, result.ExecutionResult.SQLiteTemplate.ParameterDigest, result.ExecutionResult.SQLiteTemplate.RowCount)
}
rconTemplateFingerprint := ""
if result.ExecutionResult.RCONTemplate != nil {
rconTemplateFingerprint = fmt.Sprintf("%s|%s|%s|%s|%s", result.ExecutionResult.RCONTemplate.ResultDigest, result.ExecutionResult.RCONTemplate.AssetDigest, result.ExecutionResult.RCONTemplate.PayloadDigest, result.ExecutionResult.RCONTemplate.ConfirmationDigest, result.ExecutionResult.RCONTemplate.ConfirmationStatus)
}
guardedMutationFingerprint := ""
if result.ExecutionResult.GuardedMutation != nil {
guardedMutationFingerprint = fmt.Sprintf("%s|%s|%s|%d|%s", result.ExecutionResult.GuardedMutation.ResultDigest, result.ExecutionResult.GuardedMutation.AssetDigest, result.ExecutionResult.GuardedMutation.PatchDigest, result.ExecutionResult.GuardedMutation.AffectedRows, result.ExecutionResult.GuardedMutation.ReadbackStatus)
}
parsedLogBatchFingerprint := ""
if result.ExecutionResult.ParsedLogBatch != nil {
parsedLogBatchFingerprint = fmt.Sprintf("%s|%s|%s|%s|%d|%s", result.ExecutionResult.ParsedLogBatch.ResultDigest, result.ExecutionResult.ParsedLogBatch.AssetDigest, result.ExecutionResult.ParsedLogBatch.ParserDigest, result.ExecutionResult.ParsedLogBatch.FirstCursor.StreamGeneration, result.ExecutionResult.ParsedLogBatch.EventCount, result.ExecutionResult.ParsedLogBatch.TailState)
}
return fmt.Sprintf("%s|%d|%s|%s|%s|%s|%t|%s|%s|%s|%s|%s|%s|%s", result.State, result.Progress.Percent, result.ResultRef, result.Message, result.ErrorCode, result.Progress.Message, result.Retryable, result.ExecutionResult.Kind, result.ExecutionResult.Checksum, schemaProbeFingerprint, sqliteTemplateFingerprint, rconTemplateFingerprint, guardedMutationFingerprint, parsedLogBatchFingerprint)
return fmt.Sprintf("%s|%d|%s|%s|%s|%s|%t", result.State, result.Progress.Percent, result.ResultRef, result.Message, result.ErrorCode, result.Progress.Message, result.Retryable)
}
func terminalMessage(result domain.RunJobResult) string {
+1 -188
View File
@@ -3,7 +3,6 @@ package service
import (
"strings"
"testing"
"time"
"browser.local/platform/domain"
)
@@ -213,147 +212,6 @@ func TestCoreServiceRunJobTerminalResultIsIdempotent(t *testing.T) {
}
}
func TestCoreServiceRunJobSQLiteTemplateEnvelopeIsFencedToLease(t *testing.T) {
svc, sessionToken := newRegisteredRunJobService(t)
request := domain.SCUMSQLiteTemplateRequest{RequestID: "request-query", JobID: "job-query", Binding: scumTemplateTestBinding(), Capability: domain.SCUMDataCapabilityPlayerRead, TargetKey: "scum-database", TemplateKey: "players.active.v1", AdapterVersion: "adapter-1", RequiredSchemaFingerprint: scumTemplateTestHash(), AssetDigest: scumTemplateTestHash(), ParameterDigest: scumTemplateTestHash(), Parameters: map[string]any{"limit": 100.0}, Bounds: domain.DefaultSCUMSQLiteTemplateBounds(), RequestedAt: time.Now()}
createSCUMTemplateServerFixture(t, svc, request.Binding)
job, err := svc.CreateJob(domain.Job{ID: request.JobID, ServerInstanceID: request.Binding.ServerInstanceID, RunEndpointID: request.Binding.RunEndpointID, Capability: domain.JobCapabilityRemoteRunDBSQLiteQuery, TargetKey: request.TargetKey, InputRef: "input://sqlite-template/request-query", IdempotencyKey: "idem-query", ExecutionInput: domain.JobExecutionInput{SQLiteTemplate: &request}})
if err != nil {
t.Fatalf("create sqlite query job: %v", err)
}
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: request.Binding.RunEndpointID, SessionToken: sessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, Capacity: domain.RunCapacity{MaxJobs: 4}})
if err != nil || !claim.HasJob || claim.Job.JobID != job.ID || claim.Job.ExecutionInput.SQLiteTemplate == nil {
t.Fatalf("claim sqlite query job: claim=%+v err=%v", claim, err)
}
result := scumTemplateTestResult(request)
if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: request.Binding.RunEndpointID, SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "query complete"}, Message: "query complete", ExecutionResult: domain.JobExecutionResult{Kind: scumSQLiteTemplateExecutionKind, SQLiteTemplate: &result, AuditSummary: "redacted sqlite template query"}}); err != nil {
t.Fatalf("complete matching sqlite query: %v", err)
}
badRequest := request
badRequest.RequestID = "request-query-bad"
badRequest.JobID = "job-query-bad"
if _, err := svc.CreateJob(domain.Job{ID: badRequest.JobID, ServerInstanceID: badRequest.Binding.ServerInstanceID, RunEndpointID: badRequest.Binding.RunEndpointID, Capability: domain.JobCapabilityRemoteRunDBSQLiteQuery, TargetKey: badRequest.TargetKey, InputRef: "input://sqlite-template/request-query-bad", IdempotencyKey: "idem-query-bad", ExecutionInput: domain.JobExecutionInput{SQLiteTemplate: &badRequest}}); err != nil {
t.Fatalf("create bad sqlite query job: %v", err)
}
badClaim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: badRequest.Binding.RunEndpointID, SessionToken: sessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, Capacity: domain.RunCapacity{MaxJobs: 4}})
if err != nil {
t.Fatalf("claim bad sqlite query job: %v", err)
}
badResult := scumTemplateTestResult(badRequest)
badResult.AssetDigest = "sha256:" + strings.Repeat("b", 64)
_, err = svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: badRequest.Binding.RunEndpointID, SessionToken: sessionToken, JobID: badClaim.Job.JobID, LeaseToken: badClaim.Job.LeaseToken, Attempt: badClaim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "query complete"}, Message: "query complete", ExecutionResult: domain.JobExecutionResult{Kind: scumSQLiteTemplateExecutionKind, SQLiteTemplate: &badResult, AuditSummary: "redacted sqlite template query"}})
if err == nil || !strings.Contains(err.Error(), "digest") {
t.Fatalf("expected digest-fenced query result rejection, got %v", err)
}
}
func TestCoreServiceRunJobTypedRCONTemplateEnvelopeIsFencedToLease(t *testing.T) {
svc, sessionToken := newRegisteredRunJobService(t)
request := scumTypedRCONTemplateTestRequest()
createSCUMTemplateServerFixture(t, svc, request.Binding)
job, err := svc.CreateJob(domain.Job{ID: request.JobID, ServerInstanceID: request.Binding.ServerInstanceID, RunEndpointID: request.Binding.RunEndpointID, Capability: domain.JobCapabilityRemoteRunProtectedRCON, TargetKey: request.TargetKey, InputRef: "input://rcon-template/request-rcon", IdempotencyKey: request.IdempotencyKey, RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 1, MaxBackoffSeconds: 1}, ExecutionInput: domain.JobExecutionInput{RemoteAdapterKind: "protected-rcon", SourceRCON: scumTypedRCONSourcePlan(), RCONTemplate: &request}})
if err != nil {
t.Fatalf("create typed RCON job: %v", err)
}
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: request.Binding.RunEndpointID, SessionToken: sessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunProtectedRCON}, Capacity: domain.RunCapacity{MaxJobs: 4}})
if err != nil || !claim.HasJob || claim.Job.JobID != job.ID || claim.Job.ExecutionInput.RCONTemplate == nil || claim.Job.ExecutionInput.RCONTemplate.Payload["absoluteValue"].(float64) != 100.0 {
t.Fatalf("claim typed RCON job: claim=%+v err=%v", claim, err)
}
result := scumTypedRCONTemplateTestResult(request)
if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: request.Binding.RunEndpointID, SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "rcon complete"}, Message: "rcon complete", ExecutionResult: domain.JobExecutionResult{Kind: scumRCONTemplateExecutionKind, RCONTemplate: &result, AuditSummary: "redacted typed RCON template command"}}); err != nil {
t.Fatalf("complete matching typed RCON job: %v", err)
}
badRequest := request
badRequest.RequestID = "request-rcon-bad"
badRequest.JobID = "job-rcon-bad"
badRequest.IdempotencyKey = "idem-rcon-bad"
if _, err := svc.CreateJob(domain.Job{ID: badRequest.JobID, ServerInstanceID: badRequest.Binding.ServerInstanceID, RunEndpointID: badRequest.Binding.RunEndpointID, Capability: domain.JobCapabilityRemoteRunProtectedRCON, TargetKey: badRequest.TargetKey, InputRef: "input://rcon-template/request-rcon-bad", IdempotencyKey: badRequest.IdempotencyKey, RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 1, MaxBackoffSeconds: 1}, ExecutionInput: domain.JobExecutionInput{RemoteAdapterKind: "protected-rcon", SourceRCON: scumTypedRCONSourcePlan(), RCONTemplate: &badRequest}}); err != nil {
t.Fatalf("create bad typed RCON job: %v", err)
}
badClaim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: badRequest.Binding.RunEndpointID, SessionToken: sessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunProtectedRCON}, Capacity: domain.RunCapacity{MaxJobs: 4}})
if err != nil {
t.Fatalf("claim bad typed RCON job: %v", err)
}
badResult := scumTypedRCONTemplateTestResult(badRequest)
badResult.PayloadDigest = "sha256:" + strings.Repeat("b", 64)
_, err = svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: badRequest.Binding.RunEndpointID, SessionToken: sessionToken, JobID: badClaim.Job.JobID, LeaseToken: badClaim.Job.LeaseToken, Attempt: badClaim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "rcon complete"}, Message: "rcon complete", ExecutionResult: domain.JobExecutionResult{Kind: scumRCONTemplateExecutionKind, RCONTemplate: &badResult, AuditSummary: "redacted typed RCON template command"}})
if err == nil || !strings.Contains(err.Error(), "digest") {
t.Fatalf("expected digest-fenced typed RCON result rejection, got %v", err)
}
}
func TestCoreServiceRunJobGuardedMutationEnvelopeIsFencedToLease(t *testing.T) {
svc, sessionToken := newRegisteredRunJobService(t)
request := scumGuardedMutationTestRequest()
createSCUMTemplateServerFixture(t, svc, request.Binding)
job, err := svc.CreateJob(domain.Job{ID: request.JobID, ServerInstanceID: request.Binding.ServerInstanceID, RunEndpointID: request.Binding.RunEndpointID, Capability: domain.JobCapabilityRemoteRunProtectedSQL, TargetKey: request.TargetKey, InputRef: "input://guarded-mutation/request-mutation", IdempotencyKey: request.IdempotencyKey, RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 1, MaxBackoffSeconds: 1}, ExecutionInput: domain.JobExecutionInput{RemoteAdapterKind: "protected-sql", RemoteAdapterKey: request.TargetKey, GuardedMutation: &request}})
if err != nil {
t.Fatalf("create guarded mutation job: %v", err)
}
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: request.Binding.RunEndpointID, SessionToken: sessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}, Capacity: domain.RunCapacity{MaxJobs: 4}})
if err != nil || !claim.HasJob || claim.Job.JobID != job.ID || claim.Job.ExecutionInput.GuardedMutation == nil || claim.Job.ExecutionInput.GuardedMutation.Payload["attributeKey"].(string) != "Strength" {
t.Fatalf("claim guarded mutation job: claim=%+v err=%v", claim, err)
}
result := scumGuardedMutationTestResult(request)
if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: request.Binding.RunEndpointID, SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "mutation complete"}, Message: "mutation complete", ExecutionResult: domain.JobExecutionResult{Kind: scumGuardedMutationExecutionKind, GuardedMutation: &result, AuditSummary: "redacted guarded mutation"}}); err != nil {
t.Fatalf("complete matching guarded mutation job: %v", err)
}
badRequest := request
badRequest.RequestID = "request-mutation-bad"
badRequest.JobID = "job-mutation-bad"
badRequest.IdempotencyKey = "idem-mutation-bad"
if _, err := svc.CreateJob(domain.Job{ID: badRequest.JobID, ServerInstanceID: badRequest.Binding.ServerInstanceID, RunEndpointID: badRequest.Binding.RunEndpointID, Capability: domain.JobCapabilityRemoteRunProtectedSQL, TargetKey: badRequest.TargetKey, InputRef: "input://guarded-mutation/request-mutation-bad", IdempotencyKey: badRequest.IdempotencyKey, RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 1, MaxBackoffSeconds: 1}, ExecutionInput: domain.JobExecutionInput{RemoteAdapterKind: "protected-sql", RemoteAdapterKey: badRequest.TargetKey, GuardedMutation: &badRequest}}); err != nil {
t.Fatalf("create bad guarded mutation job: %v", err)
}
badClaim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: badRequest.Binding.RunEndpointID, SessionToken: sessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}, Capacity: domain.RunCapacity{MaxJobs: 4}})
if err != nil {
t.Fatalf("claim bad guarded mutation job: %v", err)
}
badResult := scumGuardedMutationTestResult(badRequest)
badResult.PatchDigest = "sha256:" + strings.Repeat("b", 64)
_, err = svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: badRequest.Binding.RunEndpointID, SessionToken: sessionToken, JobID: badClaim.Job.JobID, LeaseToken: badClaim.Job.LeaseToken, Attempt: badClaim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "mutation complete"}, Message: "mutation complete", ExecutionResult: domain.JobExecutionResult{Kind: scumGuardedMutationExecutionKind, GuardedMutation: &badResult, AuditSummary: "redacted guarded mutation"}})
if err == nil || !strings.Contains(err.Error(), "digest") {
t.Fatalf("expected digest-fenced guarded mutation result rejection, got %v", err)
}
}
func TestCoreServiceRunJobParsedLogBatchEnvelopeIsFencedToLease(t *testing.T) {
svc, sessionToken := newRegisteredRunJobService(t)
binding := scumTemplateTestBinding()
createSCUMTemplateServerFixture(t, svc, binding)
source := &domain.RuntimeLogSource{Key: "scum-login-events", Kind: "file.tail", TargetKey: "logs/login", StreamKey: "scum.login", CursorKind: "fingerprint", RetentionDays: 90}
job, err := svc.CreateJob(domain.Job{ID: "job-log", ServerInstanceID: binding.ServerInstanceID, RunEndpointID: binding.RunEndpointID, Capability: domain.JobCapabilityLogsBackfill, TargetKey: "logs/scum-login-events", InputRef: "artifact://logs/checkpoint/1", IdempotencyKey: "idem-log", ExecutionInput: domain.JobExecutionInput{PluginID: binding.PluginID, TargetVersion: binding.PluginVersion, LogSource: source, Inputs: map[string]string{"parserKey": "scum-login-log-login-parser", "parserVersion": "scum-login-log-v1", "parserDigest": scumTemplateTestHash(), "adapterVersion": binding.AdapterVersion}}})
if err != nil {
t.Fatalf("create parsed log backfill job: %v", err)
}
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: binding.RunEndpointID, SessionToken: sessionToken, Capabilities: []string{domain.JobCapabilityLogsBackfill}, Capacity: domain.RunCapacity{MaxJobs: 4}})
if err != nil || !claim.HasJob || claim.Job.JobID != job.ID || claim.Job.ExecutionInput.LogSource == nil {
t.Fatalf("claim parsed log job: claim=%+v err=%v", claim, err)
}
result := scumParsedLogBatchTestResult(binding, job.ID, *source)
if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: binding.RunEndpointID, SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "log parse complete"}, Message: "log parse complete", ExecutionResult: domain.JobExecutionResult{Kind: scumParsedLogBatchExecutionKind, ParsedLogBatch: &result, AuditSummary: "redacted parsed log batch"}}); err != nil {
t.Fatalf("complete matching parsed log job: %v", err)
}
badJob, err := svc.CreateJob(domain.Job{ID: "job-log-bad", ServerInstanceID: binding.ServerInstanceID, RunEndpointID: binding.RunEndpointID, Capability: domain.JobCapabilityLogsBackfill, TargetKey: "logs/scum-login-events", InputRef: "artifact://logs/checkpoint/2", IdempotencyKey: "idem-log-bad", ExecutionInput: domain.JobExecutionInput{PluginID: binding.PluginID, TargetVersion: binding.PluginVersion, LogSource: source, Inputs: map[string]string{"parserKey": "scum-login-log-login-parser", "parserVersion": "scum-login-log-v1", "parserDigest": scumTemplateTestHash(), "adapterVersion": binding.AdapterVersion}}})
if err != nil {
t.Fatalf("create bad parsed log job: %v", err)
}
badClaim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: binding.RunEndpointID, SessionToken: sessionToken, Capabilities: []string{domain.JobCapabilityLogsBackfill}, Capacity: domain.RunCapacity{MaxJobs: 4}})
if err != nil || !badClaim.HasJob || badClaim.Job.JobID != badJob.ID {
t.Fatalf("claim bad parsed log job: claim=%+v err=%v", badClaim, err)
}
badResult := scumParsedLogBatchTestResult(binding, badJob.ID, *source)
badResult.ParserDigest = "sha256:" + strings.Repeat("b", 64)
_, err = svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: binding.RunEndpointID, SessionToken: sessionToken, JobID: badClaim.Job.JobID, LeaseToken: badClaim.Job.LeaseToken, Attempt: badClaim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "log parse complete"}, Message: "log parse complete", ExecutionResult: domain.JobExecutionResult{Kind: scumParsedLogBatchExecutionKind, ParsedLogBatch: &badResult, AuditSummary: "redacted parsed log batch"}})
if err == nil || !strings.Contains(err.Error(), "parser identity") {
t.Fatalf("expected parser-fenced parsed log result rejection, got %v", err)
}
}
func TestCoreServiceRunJobReconcile(t *testing.T) {
svc, sessionToken := newRegisteredRunJobService(t)
createQueuedRunJob(t, svc, "job-1", "idem-1")
@@ -384,7 +242,7 @@ func newRegisteredRunJobService(t *testing.T) (*CoreService, string) {
t.Helper()
svc := newTestCoreService()
helloRequest := validRunControlHello()
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, "process.start", domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProtectedSQL, domain.JobCapabilityLogsBackfill)
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, "process.start")
helloRequest.CapabilityReport.Fingerprint = "cap-jobs"
hello, err := svc.RegisterRunHello(helloRequest)
if err != nil {
@@ -393,51 +251,6 @@ func newRegisteredRunJobService(t *testing.T) (*CoreService, string) {
return svc, hello.SessionToken
}
func scumTemplateTestHash() string { return "sha256:" + strings.Repeat("a", 64) }
func scumTemplateTestBinding() domain.SCUMBindingIdentity {
return domain.SCUMBindingIdentity{ServerInstanceID: "server-scum", RunBindingID: "binding-scum", RunEndpointID: "run-local", PluginID: "game.scum", PluginVersion: "0.1.6", AdapterVersion: "adapter-1", GameVersion: "scum-1", DatabaseIdentity: "scum-database"}
}
func scumTypedRCONTemplateTestRequest() domain.SCUMTypedRCONTemplateRequest {
return domain.SCUMTypedRCONTemplateRequest{RequestID: "request-rcon", JobID: "job-rcon", Binding: scumTemplateTestBinding(), Capability: domain.SCUMDataCapabilityEconomyCommand, TransportKey: "scum-rcon", TargetKey: "scum-rcon", TemplateKey: "economy.fame.set.v1", AdapterVersion: "adapter-1", RequiredSchemaFingerprint: scumTemplateTestHash(), AssetDigest: scumTemplateTestHash(), PayloadDigest: scumTemplateTestHash(), ConfirmationDigest: scumTemplateTestHash(), TargetIdentityDigest: scumTemplateTestHash(), IdempotencyKey: "idem-rcon", Payload: map[string]any{"externalPlayerId": "player-redacted", "absoluteValue": 100.0}, ReviewReason: "operator reviewed absolute fame update", Bounds: domain.DefaultSCUMTypedRCONTemplateBounds(), RequestedAt: time.Now()}
}
func scumGuardedMutationTestRequest() domain.SCUMGuardedMutationRequest {
return domain.SCUMGuardedMutationRequest{RequestID: "request-mutation", JobID: "job-mutation", Binding: scumTemplateTestBinding(), Capability: domain.SCUMDataCapabilityProfileXMLWrite, TargetKey: "scum-mutation-db", TemplateKey: "profile.attributes.patch.v1", AdapterVersion: "adapter-1", RequiredSchemaFingerprint: scumTemplateTestHash(), AssetDigest: scumTemplateTestHash(), TargetIdentityDigest: scumTemplateTestHash(), ExpectedRowDigest: scumTemplateTestHash(), ExpectedValueDigest: scumTemplateTestHash(), ExpectedXMLDigest: scumTemplateTestHash(), PatchDigest: scumTemplateTestHash(), BackupEvidenceDigest: scumTemplateTestHash(), OfflineEvidenceDigest: scumTemplateTestHash(), DangerConfirmationDigest: scumTemplateTestHash(), ReadbackExpectationDigest: scumTemplateTestHash(), IdempotencyKey: "idem-mutation", Payload: map[string]any{"attributeKey": "Strength", "absoluteValue": 8.5}, ReviewReason: "operator confirmed offline profile attribute patch", Bounds: domain.DefaultSCUMGuardedMutationBounds(), RequestedAt: time.Now()}
}
func scumTypedRCONSourcePlan() *domain.RuntimeSourceRCONPlan {
return &domain.RuntimeSourceRCONPlan{Protocol: "source-rcon", ExtensionKey: "scum-rcon", ModKey: "scum_simple_rcon", ConfigRef: "ue4ss/Mods/scum_simple_rcon/config.ini", DeploymentStateRef: "runtime/ue4ss-dll/scum-rcon/release.json", Port: 27015}
}
func createSCUMTemplateServerFixture(t *testing.T, svc *CoreService, binding domain.SCUMBindingIdentity) {
t.Helper()
if _, err := svc.CreateGamePlugin(domain.GamePlugin{ID: binding.PluginID, Name: "SCUM", Version: binding.PluginVersion, ServerType: "scum", ManifestRef: "artifact://manifests/game.scum/0.1.6", CreateFormSchemaRef: "artifact://schemas/game.scum/create-form/0.1.6", RequiredRunCapabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProtectedSQL, domain.JobCapabilityLogsBackfill}, DeclaredPermissions: []string{"server.remote.access", "server.game-client.command", "server.game-client.maintenance", "server.logs.read"}, Permissions: domain.PluginPermissions{RemoteAccess: true}}); err != nil {
t.Fatalf("create SCUM plugin fixture: %v", err)
}
if _, err := svc.CreateServerInstance(domain.ServerInstance{ID: binding.ServerInstanceID, PluginID: binding.PluginID, RunEndpointID: binding.RunEndpointID, Name: "SCUM"}); err != nil {
t.Fatalf("create SCUM server fixture: %v", err)
}
}
func scumTemplateTestResult(request domain.SCUMSQLiteTemplateRequest) domain.SCUMSQLiteTemplateResult {
return domain.SCUMSQLiteTemplateResult{RequestID: request.RequestID, JobID: request.JobID, Binding: request.Binding, Status: domain.SCUMTerminalResultSucceeded, Capability: request.Capability, TargetKey: request.TargetKey, TemplateKey: request.TemplateKey, AdapterVersion: request.AdapterVersion, SchemaFingerprint: request.RequiredSchemaFingerprint, AssetDigest: request.AssetDigest, ParameterDigest: request.ParameterDigest, SourceFingerprint: scumTemplateTestHash(), ObservedAt: time.Now(), ResultDigest: scumTemplateTestHash(), RowCount: 1, Rows: []map[string]any{{"externalPlayerId": "player-redacted", "displayName": "Known Player"}}, Limits: request.Bounds, SafeError: domain.SCUMSafeError{Code: domain.SCUMSafeErrorNone}}
}
func scumTypedRCONTemplateTestResult(request domain.SCUMTypedRCONTemplateRequest) domain.SCUMTypedRCONTemplateResult {
return domain.SCUMTypedRCONTemplateResult{RequestID: request.RequestID, JobID: request.JobID, Binding: request.Binding, Status: domain.SCUMTerminalResultSucceeded, Capability: request.Capability, TransportKey: request.TransportKey, TargetKey: request.TargetKey, TemplateKey: request.TemplateKey, AdapterVersion: request.AdapterVersion, SchemaFingerprint: request.RequiredSchemaFingerprint, AssetDigest: request.AssetDigest, PayloadDigest: request.PayloadDigest, ConfirmationDigest: request.ConfirmationDigest, TargetIdentityDigest: request.TargetIdentityDigest, ObservedAt: time.Now(), ResultDigest: scumTemplateTestHash(), ResponseDigest: scumTemplateTestHash(), ConfirmationStatus: domain.SCUMRCONConfirmationConfirmed, ConfirmationDigestID: scumTemplateTestHash(), SafeSummary: "confirmed by declared readback", Limits: request.Bounds, SafeError: domain.SCUMSafeError{Code: domain.SCUMSafeErrorNone}}
}
func scumGuardedMutationTestResult(request domain.SCUMGuardedMutationRequest) domain.SCUMGuardedMutationResult {
return domain.SCUMGuardedMutationResult{RequestID: request.RequestID, JobID: request.JobID, Binding: request.Binding, Status: domain.SCUMTerminalResultSucceeded, Capability: request.Capability, TargetKey: request.TargetKey, TemplateKey: request.TemplateKey, AdapterVersion: request.AdapterVersion, SchemaFingerprint: request.RequiredSchemaFingerprint, AssetDigest: request.AssetDigest, SourceFingerprint: scumTemplateTestHash(), TargetIdentityDigest: request.TargetIdentityDigest, ExpectedRowDigest: request.ExpectedRowDigest, ExpectedValueDigest: request.ExpectedValueDigest, ExpectedXMLDigest: request.ExpectedXMLDigest, PatchDigest: request.PatchDigest, BackupEvidenceDigest: request.BackupEvidenceDigest, OfflineEvidenceDigest: request.OfflineEvidenceDigest, DangerConfirmationDigest: request.DangerConfirmationDigest, ReadbackExpectationDigest: request.ReadbackExpectationDigest, ObservedAt: time.Now(), ResultDigest: scumTemplateTestHash(), BeforeDigest: scumTemplateTestHash(), AfterDigest: scumTemplateTestHash(), ReadbackDigest: scumTemplateTestHash(), AffectedRows: 1, ReadbackStatus: domain.SCUMMutationReadbackConfirmed, SafeSummary: "confirmed by declared readback", Limits: request.Bounds, SafeError: domain.SCUMSafeError{Code: domain.SCUMSafeErrorNone}}
}
func scumParsedLogBatchTestResult(binding domain.SCUMBindingIdentity, jobID string, source domain.RuntimeLogSource) domain.SCUMParsedLogBatchResult {
cursor := domain.SCUMParsedLogCursor{SourceIdentityDigest: scumTemplateTestHash(), StreamGeneration: scumTemplateTestHash(), Sequence: 7}
return domain.SCUMParsedLogBatchResult{RequestID: "request-log", JobID: jobID, Binding: binding, Status: domain.SCUMTerminalResultSucceeded, SourceKey: source.Key, StreamKey: source.StreamKey, ParserKey: "scum-login-log-login-parser", ParserVersion: "scum-login-log-v1", AdapterVersion: binding.AdapterVersion, AssetDigest: scumTemplateTestHash(), ParserDigest: scumTemplateTestHash(), ObservedAt: time.Now(), ResultDigest: scumTemplateTestHash(), FirstCursor: cursor, LastCursor: cursor, TailState: domain.SCUMLogTailRotated, Replay: true, EventCount: 1, Events: []domain.SCUMParsedLogEvent{{EventType: "scum.login", OccurredAt: time.Now(), Cursor: cursor, LogicalEventDigest: scumTemplateTestHash(), EventDigest: scumTemplateTestHash(), PayloadDigest: scumTemplateTestHash(), Payload: map[string]any{"externalPlayerId": "player-redacted", "displayName": "Known Player", "profileLocalId": "profile-redacted"}}}, SafeSummary: "one sanitized login event parsed from declared source", Limits: domain.DefaultSCUMParsedLogBatchBounds(), SafeError: domain.SCUMSafeError{Code: domain.SCUMSafeErrorNone}}
}
func createQueuedRunJob(t *testing.T, svc *CoreService, id string, idempotencyKey string) domain.Job {
t.Helper()
job, err := svc.CreateJob(domain.Job{
+5 -26
View File
@@ -44,9 +44,6 @@ func (svc *CoreService) RequestRemoteAdapterForSession(sessionID string, request
if err := validator.ValidateRemoteAdapterRequest(request); err != nil {
return domain.RemoteAdapterResult{}, err
}
if request.Capability == domain.JobCapabilityRemoteRunDBSQLiteProbe && !request.PlatformScheduled {
return domain.RemoteAdapterResult{}, forbiddenError("schema probe is scheduled by Platform and is not a direct remote-adapter request")
}
instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID)
if err != nil {
return domain.RemoteAdapterResult{}, err
@@ -86,30 +83,20 @@ func (svc *CoreService) RequestRemoteAdapterForSession(sessionID string, request
return domain.RemoteAdapterResult{}, validationError("remote adapter timeout or retry exceeds declaration")
}
inputRef := request.InputRef
isSchemaProbe := request.Capability == domain.JobCapabilityRemoteRunDBSQLiteProbe && request.PlatformScheduled && request.SQLiteSchemaProbe != nil
if inputRef == "" && !isSchemaProbe {
if inputRef == "" {
inputRef = fmt.Sprintf("input://remote-adapters/%s/%s", instance.ID, request.DeclarationKey)
}
targetKey := request.TargetKey
executionInput := domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), RemoteAdapterKey: selected.Key, RemoteAdapterKind: string(selected.Kind), TimeoutSeconds: timeout, Inputs: domain.CopyStringMap(request.Inputs), SQLiteSchemaProbe: domain.CopySCUMSchemaProbeRequestPtr(request.SQLiteSchemaProbe)}
if isSchemaProbe {
targetKey = sqliteSchemaProbeRunTargetKey(request.TargetKey)
inputRef = ""
executionInput.RemoteAdapterKey = ""
executionInput.RemoteAdapterKind = ""
executionInput.Inputs = nil
}
job := domain.Job{
ID: jobIDFromParts("job-remote-adapter", instance.ID, request.IdempotencyKey),
ServerInstanceID: instance.ID,
RunEndpointID: instance.RunEndpointID,
Capability: request.Capability,
TargetKey: targetKey,
TargetKey: request.TargetKey,
InputRef: inputRef,
IdempotencyKey: request.IdempotencyKey,
Progress: domain.JobProgress{Percent: 0, Message: "scoped remote adapter queued"},
RetryPolicy: domain.JobRetryPolicy{MaxAttempts: attempts, InitialBackoffSeconds: 2, MaxBackoffSeconds: 30},
ExecutionInput: executionInput,
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), RemoteAdapterKey: selected.Key, RemoteAdapterKind: string(selected.Kind), TimeoutSeconds: timeout, Inputs: domain.CopyStringMap(request.Inputs)},
}
created, err := svc.CreateJob(job)
if err != nil {
@@ -126,14 +113,6 @@ func (svc *CoreService) RequestRemoteAdapterForSession(sessionID string, request
return domain.RemoteAdapterResult{RequestID: created.ID, ServerInstanceID: instance.ID, DeclarationKey: selected.Key, TargetKey: request.TargetKey, Kind: selected.Kind, Status: string(created.State), Retryable: attempts > 1, Message: "scoped remote adapter queued", ResultRef: "job://" + created.ID, AuditEventID: auditID}, nil
}
func sqliteSchemaProbeRunTargetKey(targetKey string) string {
trimmed := strings.TrimSpace(targetKey)
if strings.HasPrefix(trimmed, "databases/") {
return trimmed
}
return "databases/" + trimmed
}
func intersectRemoteCapabilities(profile []string, declared []string, endpoint []string) []string {
result := make([]string, 0, len(profile))
for _, capability := range profile {
@@ -150,7 +129,7 @@ func isRemoteAdapterCapability(capability string) bool {
domain.JobCapabilityRemoteRsyncRead, domain.JobCapabilityRemoteRsyncWrite,
domain.JobCapabilityRemoteRunFilesRead, domain.JobCapabilityRemoteRunFilesWrite,
domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop,
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteProbe, domain.JobCapabilityRemoteRunDBSQLiteQuery,
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery,
domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand:
return true
default:
@@ -187,7 +166,7 @@ func remoteAdapterKindForCapability(capability string) domain.RemoteAdapterKind
return domain.RemoteAdapterRunFile
case domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop:
return domain.RemoteAdapterRunProcess
case domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteProbe, domain.JobCapabilityRemoteRunDBSQLiteQuery:
case domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery:
return domain.RemoteAdapterDatabase
case domain.JobCapabilityRemoteRunRCONCommand:
return domain.RemoteAdapterRCON
-123
View File
@@ -2,7 +2,6 @@ package service
import (
"encoding/json"
"strings"
"testing"
"browser.local/platform/domain"
@@ -87,125 +86,3 @@ func TestRemoteAdapterRequestPropagatesTypedInputsToRunJob(t *testing.T) {
t.Fatal("real Run claim aliases persisted remote inputs")
}
}
func TestSCUMSchemaProbeDispatchIsPlatformScheduledAndFenced(t *testing.T) {
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
plugin.Permissions.RemoteAccess = true
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.remote.access")
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunDBSQLiteProbe)
plugin.RemoteAccess = domain.GamePluginRemoteAccess{Methods: []string{"run"}, RunCapabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteProbe}, DatabaseEngines: []string{"sqlite"}}
plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles,
domain.RuntimeTransportProfile{Key: "server-files", Kind: "file", TargetKey: "server-root", Capabilities: []string{domain.JobCapabilityRemoteRunFilesRead}},
domain.RuntimeTransportProfile{Key: "scum-database", Kind: "sqlite", TargetKey: "scum-database", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteProbe}},
)
plugin.RuntimeProfiles.DataTargets = append(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 * 1024, Platforms: []string{"windows"}})
plugin.RuntimeProfiles.LifecycleProfiles[0].TransportKeys = append(plugin.RuntimeProfiles.LifecycleProfiles[0].TransportKeys, "scum-database")
plugin.SCUMLiveData = domain.SCUMLiveDataManifest{SchemaVersion: "1", Probe: domain.SCUMSchemaProbeDeclaration{Capability: domain.JobCapabilityRemoteRunDBSQLiteProbe, TargetKey: "scum-database", Bounds: domain.DefaultSCUMSchemaProbeBounds()}, CapabilityGates: []domain.SCUMLiveDataCapabilityGateDeclaration{{Capability: domain.SCUMDataCapabilitySchemaProbe, Gate: domain.SCUMCapabilityGateDisabled, AdapterVersion: "scum-live-data-v0", EvidenceStatus: domain.SCUMCapabilityEvidenceMissing, SafeReason: "waiting for current service evidence"}}}
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatal(err)
}
endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteProbe)
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatal(err)
}
session := createServiceUserAndLogin(t, svc, domain.User{ID: "user-scum-probe-owner", DisplayName: "SCUM Probe Owner", Email: "scum-probe-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
instance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{ID: "server-scum-probe", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "SCUM Probe"})
if err != nil {
t.Fatalf("create server instance: %v", err)
}
createCompleteRuntimeBinding(t, svc, instance, "local")
_, err = svc.RequestRemoteAdapterForSession(session, domain.RemoteAdapterRequest{ServerInstanceID: instance.ID, DeclarationKey: "scum-database", TargetKey: "scum-database", Capability: domain.JobCapabilityRemoteRunDBSQLiteProbe, IdempotencyKey: "direct-probe-denied", InputRef: "input://scum-schema-probe/direct-probe-denied"})
if err == nil || !strings.Contains(err.Error(), "scheduled by Platform") {
t.Fatalf("expected public probe request denial, got %v", err)
}
endpoint.Capabilities = withoutCapability(endpoint.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteProbe)
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatal(err)
}
if _, _, err = svc.RequestSCUMSchemaProbeForSession(session, instance.ID, "probe-missing-run-capability"); err == nil || !strings.Contains(err.Error(), "does not expose") {
t.Fatalf("expected missing active Run capability, got %v", err)
}
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
if err != nil || len(jobs) != 0 {
t.Fatalf("missing probe executor must not create jobs: len=%d err=%v", len(jobs), err)
}
endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteProbe)
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatal(err)
}
probeRequest, queued, err := svc.RequestSCUMSchemaProbeForSession(session, instance.ID, "probe-current-schema")
if err != nil {
t.Fatalf("queue SCUM schema probe: %v", err)
}
job, err := svc.store.Jobs().Get(queued.RequestID)
if err != nil {
t.Fatalf("get probe job: %v", err)
}
if job.Capability != domain.JobCapabilityRemoteRunDBSQLiteProbe || job.TargetKey != "databases/scum-database" || job.InputRef != "" || job.ExecutionInput.RemoteAdapterKey != "" || job.ExecutionInput.RemoteAdapterKind != "" || len(job.ExecutionInput.Inputs) != 0 {
t.Fatalf("unexpected probe job envelope: %+v", job)
}
if job.ExecutionInput.SQLiteSchemaProbe == nil || job.ExecutionInput.SQLiteSchemaProbe.RequestID != probeRequest.RequestID || job.ExecutionInput.SQLiteSchemaProbe.Binding.DatabaseIdentity != "scum-database" || job.ExecutionInput.SQLiteSchemaProbe.Bounds.MaxResultBytes != 524288 {
t.Fatalf("probe job did not include typed SQLite schema probe request: %+v", job.ExecutionInput.SQLiteSchemaProbe)
}
helloRequest := validRunControlHello()
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteProbe)
helloRequest.CapabilityReport.Fingerprint = "cap-scum-probe"
hello, err := svc.RegisterRunHello(helloRequest)
if err != nil {
t.Fatalf("register Run hello: %v", err)
}
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteProbe}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil {
t.Fatalf("claim probe job: %v", err)
}
if !claim.HasJob || claim.Job == nil || claim.Job.TargetKey != "databases/scum-database" || claim.Job.InputRef != "" || claim.Job.FencingToken == 0 || claim.Job.MaxAttempts != 1 || claim.Job.ExecutionInput.RemoteAdapterKey != "" || len(claim.Job.ExecutionInput.Inputs) != 0 {
t.Fatalf("claimed probe job lost fenced typed envelope: %+v", claim.Job)
}
if claim.Job.ExecutionInput.SQLiteSchemaProbe == nil || claim.Job.ExecutionInput.SQLiteSchemaProbe.RequestID != probeRequest.RequestID || claim.Job.ExecutionInput.SQLiteSchemaProbe.Binding.RunBindingID != probeRequest.Binding.RunBindingID {
t.Fatalf("claimed probe job lost typed schema probe request: %+v", claim.Job.ExecutionInput.SQLiteSchemaProbe)
}
assignmentBody := dto.RunJobAssignmentFromDomain(*claim.Job)
payload, err := json.Marshal(assignmentBody)
if err != nil {
t.Fatalf("marshal probe Run assignment: %v", err)
}
var runWire struct {
ExecutionInput struct {
SQLiteSchemaProbe struct {
RequestID string `json:"requestId"`
JobID string `json:"jobId"`
Bounds *dto.SCUMSchemaProbeBoundsDTO `json:"bounds"`
Limits dto.SCUMSchemaProbeBoundsDTO `json:"limits"`
} `json:"sqliteSchemaProbe"`
} `json:"executionInput"`
}
if err := json.Unmarshal(payload, &runWire); err != nil {
t.Fatalf("unmarshal probe Run assignment: %v", err)
}
if runWire.ExecutionInput.SQLiteSchemaProbe.RequestID != probeRequest.RequestID || runWire.ExecutionInput.SQLiteSchemaProbe.JobID != "" || runWire.ExecutionInput.SQLiteSchemaProbe.Bounds != nil || runWire.ExecutionInput.SQLiteSchemaProbe.Limits.MaxResultBytes != probeRequest.Bounds.MaxResultBytes {
t.Fatalf("probe Run assignment JSON does not match Run contract: %s", payload)
}
badBinding := probeRequest.Binding
badBinding.RunBindingID = "runtime-binding-other"
badProbe := domain.SCUMSchemaProbeResult{RequestID: probeRequest.RequestID, JobID: probeRequest.JobID, Binding: badBinding, Status: domain.SCUMCapabilityEvidenceCompatible, SourceFingerprint: "sha256:" + strings.Repeat("c", 64), SchemaFingerprint: "sha256:" + strings.Repeat("a", 64), ObservedAt: fixedTime, ResultDigest: "sha256:" + strings.Repeat("b", 64), Limits: probeRequest.Bounds}
_, 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, Message: "probe complete"}, Message: "probe complete", ExecutionResult: domain.JobExecutionResult{Kind: scumSchemaProbeExecutionKind, SQLiteSchemaProbe: &badProbe, AuditSummary: "redacted schema probe"}})
if err == nil || !strings.Contains(err.Error(), "binding identity") {
t.Fatalf("expected binding mismatch rejection, got %v", err)
}
goodProbe := badProbe
goodProbe.Binding = probeRequest.Binding
if _, 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, Message: "probe complete"}, Message: "probe complete", ExecutionResult: domain.JobExecutionResult{Kind: scumSchemaProbeExecutionKind, SQLiteSchemaProbe: &goodProbe, AuditSummary: "redacted schema probe"}}); err != nil {
t.Fatalf("complete fenced probe job: %v", err)
}
stored, err := svc.store.Jobs().Get(job.ID)
if err != nil {
t.Fatalf("get completed probe job: %v", err)
}
if stored.ExecutionResult.SQLiteSchemaProbe == nil || stored.ExecutionResult.SQLiteSchemaProbe.SourceFingerprint != goodProbe.SourceFingerprint || stored.ExecutionResult.SQLiteSchemaProbe.ResultDigest != goodProbe.ResultDigest || stored.ExecutionResult.SQLiteSchemaProbe.Binding.RunBindingID != probeRequest.Binding.RunBindingID {
t.Fatalf("typed probe result was not persisted safely: %+v", stored.ExecutionResult.SQLiteSchemaProbe)
}
}
+14 -8
View File
@@ -124,8 +124,6 @@ type Core interface {
ListBackupsForSession(string, domain.BackupFilter) ([]domain.BackupRecord, error)
ListRemoteAdapterDeclarationsForSession(string, string) ([]domain.RemoteAdapterDeclaration, error)
RequestRemoteAdapterForSession(string, domain.RemoteAdapterRequest) (domain.RemoteAdapterResult, error)
NegotiateSCUMCapabilitiesForSession(string, string) (domain.SCUMCapabilityNegotiation, error)
RequestSCUMSchemaProbeForSession(string, string, string) (domain.SCUMSchemaProbeRequest, domain.RemoteAdapterResult, error)
GetServerConfigForSession(string, string) (domain.ServerConfig, error)
GetDeclaredFileReadSnapshotForSession(string, string, string) (domain.DeclaredFileReadSnapshot, error)
PreviewServerConfigWriteForSession(string, domain.ServerConfigDiffRequest) (domain.ServerConfigDiffPreview, error)
@@ -227,6 +225,20 @@ type Core interface {
RequestGameGiftGrantForSession(string, string, domain.GameGiftGrantRequest) (domain.GameGiftGrant, error)
ApproveGameGiftGrantForSession(string, string) (domain.GameGiftGrant, error)
ListGameGiftGrantsForSession(string, string) ([]domain.GameGiftGrant, error)
ListSCUMPlayerLiveStatesForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMPlayerLiveState, error)
ListSCUMSquadsForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMSquad, error)
ListSCUMSquadMembersForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMSquadMember, error)
ListSCUMVehiclesForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMVehicle, error)
ListSCUMFlagsForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMFlag, error)
ListSCUMCurrentPositionsForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMCurrentPosition, error)
RequestSCUMOperationForSession(string, string, domain.SCUMOperationRequest) (domain.SCUMOperationRequest, error)
ListSCUMOperationsForSession(string, domain.SCUMOperationRequestFilter) ([]domain.SCUMOperationRequest, error)
ApproveSCUMOperationForSession(string, string) (domain.SCUMOperationRequest, error)
ReconcileSCUMOperation(string) (domain.SCUMOperationRequest, error)
ConfirmSCUMOperation(string, domain.SCUMOperationConfirmation) (domain.SCUMOperationRequest, error)
CreateSCUMWorkflowForSession(string, string, domain.SCUMWorkflowInstance) (domain.SCUMWorkflowInstance, error)
ListSCUMWorkflowsForSession(string, domain.SCUMWorkflowInstanceFilter) ([]domain.SCUMWorkflowInstance, error)
ListSCUMWorkflowStepsForSession(string, domain.SCUMWorkflowStepFilter) ([]domain.SCUMWorkflowStep, error)
CreateAuditEvent(domain.AuditEvent) (domain.AuditEvent, error)
GetAuditEvent(string) (domain.AuditEvent, error)
ListAuditEvents(domain.AuditEventFilter) ([]domain.AuditEvent, error)
@@ -814,7 +826,6 @@ func gamePluginFromManifestRegistration(registration domain.GamePluginManifestRe
RemoteAccess: manifest.RemoteAccess,
RuntimeProfiles: manifest.RuntimeProfiles,
GameClientBridge: manifest.GameClientBridge,
SCUMLiveData: manifest.SCUMLiveData,
MapTrajectories: manifest.MapTrajectories,
Status: domain.GamePluginStatusInstalled,
}
@@ -1169,11 +1180,6 @@ func (svc *CoreService) executeBridgeRemoteAccessRequest(sessionID string, base
base.Error = &domain.PluginBridgeSafeError{Code: "capability_denied", Message: "requested remote capability is not declared by plugin"}
return base
}
if capability == domain.JobCapabilityRemoteRunDBSQLiteProbe {
base.Status = "denied"
base.Error = &domain.PluginBridgeSafeError{Code: "capability_denied", Message: "schema probe is scheduled by Platform and is not a plugin page action"}
return base
}
declarationKey := strings.TrimSpace(payload["declarationKey"])
if declarationKey == "" {
for _, profile := range plugin.RuntimeProfiles.TransportProfiles {
-397
View File
@@ -1,397 +0,0 @@
package service
import (
"errors"
"strings"
"browser.local/platform/domain"
"browser.local/platform/repo"
"browser.local/platform/validator"
)
const (
scumSchemaProbeExecutionKind = "sqlite.schema-probe"
scumSQLiteTemplateExecutionKind = "sqlite.template-query"
scumRCONTemplateExecutionKind = "rcon.template-command"
scumGuardedMutationExecutionKind = "sqlite.guarded-mutation"
scumParsedLogBatchExecutionKind = "log.parsed-events"
)
func (svc *CoreService) NegotiateSCUMCapabilitiesForSession(sessionID, serverInstanceID string) (domain.SCUMCapabilityNegotiation, error) {
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
if err != nil {
return domain.SCUMCapabilityNegotiation{}, err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return domain.SCUMCapabilityNegotiation{}, err
}
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
if err != nil {
return domain.SCUMCapabilityNegotiation{}, err
}
adapterVersion := scumSchemaProbeAdapterVersion(plugin.SCUMLiveData)
active := domain.SCUMBindingIdentity{ServerInstanceID: instance.ID, RunEndpointID: instance.RunEndpointID, PluginID: plugin.ID, PluginVersion: plugin.Version, AdapterVersion: adapterVersion, DatabaseIdentity: scumSchemaProbeDatabaseIdentity(plugin.SCUMLiveData.Probe.TargetKey)}
binding, bindingErr := svc.runtimeBindingForServer(instance.ID)
if bindingErr == nil {
binding, bindingErr = normalizeRuntimeBinding(plugin, binding)
}
if bindingErr == nil {
active.RunBindingID = binding.ID
}
probeExecutorAvailable := scumRunCapabilityAvailable(endpoint, domain.SCUMDataCapabilitySchemaProbe)
negotiation := domain.SCUMCapabilityNegotiation{ServerInstanceID: instance.ID, RunEndpointID: instance.RunEndpointID, RunBindingID: active.RunBindingID, PluginID: plugin.ID, PluginVersion: plugin.Version, AdapterVersion: adapterVersion, GameVersion: active.GameVersion, DatabaseIdentity: active.DatabaseIdentity, ProbeExecutorAvailable: probeExecutorAvailable, EvaluatedAt: svc.now()}
evidenceByCapability := svc.latestSCUMCapabilityEvidenceByCapability(instance.ID)
for _, declaration := range plugin.SCUMLiveData.CapabilityGates {
gate := domain.SCUMCapabilityGate{Capability: declaration.Capability, State: domain.SCUMCapabilityGateDisabled, ReasonCode: domain.SCUMSafeErrorProbeMissing, Reason: safeSCUMGateReason(declaration.SafeReason, "current-service evidence is required before this SCUM capability can run")}
if requiredRunCapability := scumRequiredRunCapabilityForDataCapability(declaration.Capability); requiredRunCapability != "" && !containsString(endpoint.Capabilities, requiredRunCapability) {
gate.ReasonCode = domain.SCUMSafeErrorProbeExecutorAbsent
gate.Reason = "bound Run does not expose the generic executor required for this SCUM capability"
negotiation.Gates = append(negotiation.Gates, gate)
continue
}
if bindingErr != nil || binding.Status != domain.RuntimeBindingStatusComplete || binding.PluginVersion != plugin.Version {
gate.ReasonCode = domain.SCUMSafeErrorBindingMismatch
gate.Reason = "active runtime binding is missing, incomplete, or stale for this plugin version"
negotiation.Gates = append(negotiation.Gates, gate)
continue
}
if declaration.Gate != domain.SCUMCapabilityGateEnabled {
gate.ReasonCode = scumReasonCodeForEvidenceStatus(declaration.EvidenceStatus)
negotiation.Gates = append(negotiation.Gates, gate)
continue
}
requirement := domain.SCUMCapabilityRequirement{Capability: declaration.Capability, AdapterVersion: declaration.AdapterVersion, SchemaFingerprint: declaration.RequiredSchemaFingerprint, AssetDigests: domain.CopyStringSlice(declaration.RequiredAssetDigests)}
gate = domain.EvaluateSCUMCapabilityGate(requirement, evidenceByCapability[declaration.Capability], active, probeExecutorAvailable, negotiation.EvaluatedAt)
negotiation.Gates = append(negotiation.Gates, gate)
}
return negotiation, nil
}
func (svc *CoreService) latestSCUMCapabilityEvidenceByCapability(serverInstanceID string) map[domain.SCUMDataCapability]domain.SCUMCapabilityEvidence {
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: serverInstanceID})
if err != nil {
return map[domain.SCUMDataCapability]domain.SCUMCapabilityEvidence{}
}
evidence := map[domain.SCUMDataCapability]domain.SCUMCapabilityEvidence{}
for _, job := range jobs {
for _, candidate := range scumCapabilityEvidenceFromJob(job) {
current, exists := evidence[candidate.Capability]
if !exists || current.ObservedAt.Before(candidate.ObservedAt) {
evidence[candidate.Capability] = candidate
}
}
}
return evidence
}
func scumCapabilityEvidenceFromJob(job domain.Job) []domain.SCUMCapabilityEvidence {
var evidence []domain.SCUMCapabilityEvidence
if result := job.ExecutionResult.SQLiteSchemaProbe; result != nil {
status := domain.SCUMCapabilityEvidenceFailed
if result.Status == domain.SCUMSchemaProbeStatusSucceeded || result.Status == domain.SCUMCapabilityEvidenceCompatible {
status = domain.SCUMCapabilityEvidenceCompatible
} else if result.Status == domain.SCUMCapabilityEvidenceIncompatible {
status = domain.SCUMCapabilityEvidenceIncompatible
}
evidence = append(evidence, domain.SCUMCapabilityEvidence{Capability: domain.SCUMDataCapabilitySchemaProbe, Status: status, Binding: result.Binding, AdapterVersion: result.Binding.AdapterVersion, SchemaFingerprint: result.SchemaFingerprint, ProbeResultDigest: result.ResultDigest, ObservedAt: result.ObservedAt, SafeError: result.SafeError})
}
if result := job.ExecutionResult.SQLiteTemplate; result != nil {
evidence = append(evidence, domain.SCUMCapabilityEvidence{Capability: result.Capability, Status: scumTerminalEvidenceStatus(result.Status), Binding: result.Binding, AdapterVersion: result.AdapterVersion, SchemaFingerprint: result.SchemaFingerprint, ProbeResultDigest: result.ResultDigest, AssetDigests: []string{result.AssetDigest}, ObservedAt: result.ObservedAt, SafeError: result.SafeError})
}
if result := job.ExecutionResult.RCONTemplate; result != nil {
evidence = append(evidence, domain.SCUMCapabilityEvidence{Capability: result.Capability, Status: scumTerminalEvidenceStatus(result.Status), Binding: result.Binding, AdapterVersion: result.AdapterVersion, SchemaFingerprint: result.SchemaFingerprint, ProbeResultDigest: result.ResultDigest, AssetDigests: []string{result.AssetDigest}, ObservedAt: result.ObservedAt, SafeError: result.SafeError})
}
if result := job.ExecutionResult.GuardedMutation; result != nil {
evidence = append(evidence, domain.SCUMCapabilityEvidence{Capability: result.Capability, Status: scumTerminalEvidenceStatus(result.Status), Binding: result.Binding, AdapterVersion: result.AdapterVersion, SchemaFingerprint: result.SchemaFingerprint, ProbeResultDigest: result.ResultDigest, AssetDigests: []string{result.AssetDigest}, ObservedAt: result.ObservedAt, SafeError: result.SafeError})
}
return evidence
}
func scumTerminalEvidenceStatus(status domain.SCUMTerminalResultStatus) domain.SCUMCapabilityEvidenceStatus {
if status == domain.SCUMTerminalResultSucceeded {
return domain.SCUMCapabilityEvidenceCompatible
}
return domain.SCUMCapabilityEvidenceFailed
}
func scumRunCapabilityAvailable(endpoint domain.RunEndpoint, capability domain.SCUMDataCapability) bool {
return containsString(endpoint.Capabilities, scumRequiredRunCapabilityForDataCapability(capability))
}
func scumRequiredRunCapabilityForDataCapability(capability domain.SCUMDataCapability) string {
switch capability {
case domain.SCUMDataCapabilitySchemaProbe:
return domain.JobCapabilityRemoteRunDBSQLiteProbe
case domain.SCUMDataCapabilityPlayerRead, domain.SCUMDataCapabilityPlayerDetailRead, domain.SCUMDataCapabilitySquadRead, domain.SCUMDataCapabilitySquadMemberRead, domain.SCUMDataCapabilityVehicleRead, domain.SCUMDataCapabilityFlagRead, domain.SCUMDataCapabilityPositionRead:
return domain.JobCapabilityRemoteRunDBSQLiteQuery
case domain.SCUMDataCapabilityEconomyCommand, domain.SCUMDataCapabilityGiftCommand:
return domain.JobCapabilityRemoteRunProtectedRCON
case domain.SCUMDataCapabilityProfileXMLWrite:
return domain.JobCapabilityRemoteRunProtectedSQL
default:
return ""
}
}
func scumReasonCodeForEvidenceStatus(status domain.SCUMCapabilityEvidenceStatus) domain.SCUMSafeErrorCode {
switch status {
case domain.SCUMCapabilityEvidenceFailed:
return domain.SCUMSafeErrorProbeFailed
case domain.SCUMCapabilityEvidenceIncompatible:
return domain.SCUMSafeErrorSchemaIncompatible
default:
return domain.SCUMSafeErrorProbeMissing
}
}
func safeSCUMGateReason(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}
func (svc *CoreService) RequestSCUMSchemaProbeForSession(sessionID, serverInstanceID, idempotencyKey string) (domain.SCUMSchemaProbeRequest, domain.RemoteAdapterResult, error) {
idempotencyKey = strings.TrimSpace(idempotencyKey)
if idempotencyKey == "" {
return domain.SCUMSchemaProbeRequest{}, domain.RemoteAdapterResult{}, validationError("idempotencyKey is required")
}
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
if err != nil {
return domain.SCUMSchemaProbeRequest{}, domain.RemoteAdapterResult{}, err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return domain.SCUMSchemaProbeRequest{}, domain.RemoteAdapterResult{}, err
}
probe := plugin.SCUMLiveData.Probe
if plugin.SCUMLiveData.SchemaVersion == "" || probe.Capability != domain.JobCapabilityRemoteRunDBSQLiteProbe || strings.TrimSpace(probe.TargetKey) == "" {
return domain.SCUMSchemaProbeRequest{}, domain.RemoteAdapterResult{}, validationError("SCUM schema probe is not declared by the plugin")
}
if !scumSchemaProbeHasDataTarget(plugin.RuntimeProfiles, probe.TargetKey) {
return domain.SCUMSchemaProbeRequest{}, domain.RemoteAdapterResult{}, validationError("SCUM schema probe target is not declared as a generated Run data target")
}
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
if err != nil {
return domain.SCUMSchemaProbeRequest{}, domain.RemoteAdapterResult{}, err
}
if endpoint.Status != domain.RunEndpointStatusOnline && endpoint.Status != domain.RunEndpointStatusDegraded {
return domain.SCUMSchemaProbeRequest{}, domain.RemoteAdapterResult{}, validationError("bound Run is not online for SCUM schema probe")
}
if !containsString(endpoint.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteProbe) {
return domain.SCUMSchemaProbeRequest{}, domain.RemoteAdapterResult{}, validationError("bound Run does not expose the generic SQLite schema-probe executor")
}
binding, err := svc.runtimeBindingForServer(instance.ID)
if errors.Is(err, repo.ErrNotFound) {
return domain.SCUMSchemaProbeRequest{}, domain.RemoteAdapterResult{}, validationError("runtime binding is required before SCUM schema probe")
}
if err != nil {
return domain.SCUMSchemaProbeRequest{}, domain.RemoteAdapterResult{}, err
}
adapterVersion := scumSchemaProbeAdapterVersion(plugin.SCUMLiveData)
if adapterVersion == "" {
return domain.SCUMSchemaProbeRequest{}, domain.RemoteAdapterResult{}, validationError("SCUM schema-probe adapter version is not declared")
}
bounds := probe.Bounds
if bounds.MaxObjects == 0 {
bounds = domain.DefaultSCUMSchemaProbeBounds()
}
jobID := jobIDFromParts("job-remote-adapter", instance.ID, idempotencyKey)
request := domain.SCUMSchemaProbeRequest{
RequestID: jobID,
JobID: jobID,
Binding: domain.SCUMBindingIdentity{
ServerInstanceID: instance.ID,
RunBindingID: binding.ID,
RunEndpointID: instance.RunEndpointID,
PluginID: plugin.ID,
PluginVersion: plugin.Version,
AdapterVersion: adapterVersion,
DatabaseIdentity: scumSchemaProbeDatabaseIdentity(probe.TargetKey),
},
Bounds: bounds,
RequestedAt: svc.now(),
}
if err := validator.ValidateSCUMSchemaProbeRequest(request); err != nil {
return domain.SCUMSchemaProbeRequest{}, domain.RemoteAdapterResult{}, err
}
result, err := svc.RequestRemoteAdapterForSession(sessionID, domain.RemoteAdapterRequest{
ServerInstanceID: instance.ID,
DeclarationKey: probe.TargetKey,
TargetKey: probe.TargetKey,
Capability: domain.JobCapabilityRemoteRunDBSQLiteProbe,
TimeoutSeconds: scumSchemaProbeTimeoutSeconds(bounds),
MaxAttempts: 1,
IdempotencyKey: idempotencyKey,
PlatformScheduled: true,
SQLiteSchemaProbe: &request,
})
if err != nil {
return domain.SCUMSchemaProbeRequest{}, domain.RemoteAdapterResult{}, err
}
return request, result, nil
}
func scumSchemaProbeDatabaseIdentity(targetKey string) string {
trimmed := strings.TrimSpace(targetKey)
return strings.TrimPrefix(trimmed, "databases/")
}
func scumSchemaProbeHasDataTarget(profiles domain.GamePluginRuntimeProfiles, targetKey string) bool {
expectedWorkspaceKey := "databases/" + scumSchemaProbeDatabaseIdentity(targetKey)
for _, target := range profiles.DataTargets {
if target.Key == targetKey && target.Kind == "sqlite.snapshot" && target.WorkspaceKey == expectedWorkspaceKey && target.RefreshPolicy == "on-demand-snapshot" {
return true
}
}
return false
}
func scumSchemaProbeAdapterVersion(manifest domain.SCUMLiveDataManifest) string {
for _, gate := range manifest.CapabilityGates {
if gate.Capability == domain.SCUMDataCapabilitySchemaProbe {
return strings.TrimSpace(gate.AdapterVersion)
}
}
for _, gate := range manifest.CapabilityGates {
if strings.TrimSpace(gate.AdapterVersion) != "" {
return strings.TrimSpace(gate.AdapterVersion)
}
}
return ""
}
func scumSchemaProbeTimeoutSeconds(bounds domain.SCUMSchemaProbeBounds) int {
if bounds.TimeoutMS <= 0 {
return 1
}
seconds := (bounds.TimeoutMS + 999) / 1000
if seconds <= 0 {
return 1
}
return seconds
}
func validateSCUMSchemaProbeResultForJob(job domain.Job, result domain.SCUMSchemaProbeResult) error {
if err := validator.ValidateSCUMSchemaProbeResult(result); err != nil {
return err
}
expected := job.ExecutionInput.SQLiteSchemaProbe
if expected == nil {
return validationError("SQLite schema probe request is missing from leased job")
}
if result.JobID != job.ID || result.JobID != expected.JobID || result.RequestID != expected.RequestID {
return validationError("SQLite schema probe result does not match leased job identity")
}
if !sameSCUMSchemaProbeBinding(result.Binding, expected.Binding) {
return validationError("SQLite schema probe result does not match leased binding identity")
}
return nil
}
func validateSCUMSQLiteTemplateResultForJob(job domain.Job, result domain.SCUMSQLiteTemplateResult) error {
if err := validator.ValidateSCUMSQLiteTemplateResult(result); err != nil {
return err
}
expected := job.ExecutionInput.SQLiteTemplate
if expected == nil {
return validationError("SQLite template request is missing from leased job")
}
if result.JobID != job.ID || result.JobID != expected.JobID || result.RequestID != expected.RequestID {
return validationError("SQLite template result does not match leased job identity")
}
if !sameSCUMSchemaProbeBinding(result.Binding, expected.Binding) {
return validationError("SQLite template result does not match leased binding identity")
}
if result.Capability != expected.Capability || result.TargetKey != expected.TargetKey || result.TemplateKey != expected.TemplateKey || result.AdapterVersion != expected.AdapterVersion || result.SchemaFingerprint != expected.RequiredSchemaFingerprint || result.AssetDigest != expected.AssetDigest || result.ParameterDigest != expected.ParameterDigest {
return validationError("SQLite template result does not match leased template, adapter, digest, or parameter identity")
}
return nil
}
func validateSCUMTypedRCONTemplateResultForJob(job domain.Job, result domain.SCUMTypedRCONTemplateResult) error {
if err := validator.ValidateSCUMTypedRCONTemplateResult(result); err != nil {
return err
}
expected := job.ExecutionInput.RCONTemplate
if expected == nil {
return validationError("typed RCON template request is missing from leased job")
}
if result.JobID != job.ID || result.JobID != expected.JobID || result.RequestID != expected.RequestID {
return validationError("typed RCON template result does not match leased job identity")
}
if !sameSCUMSchemaProbeBinding(result.Binding, expected.Binding) {
return validationError("typed RCON template result does not match leased binding identity")
}
if result.Capability != expected.Capability || result.TransportKey != expected.TransportKey || result.TargetKey != expected.TargetKey || result.TemplateKey != expected.TemplateKey || result.AdapterVersion != expected.AdapterVersion || result.AssetDigest != expected.AssetDigest || result.PayloadDigest != expected.PayloadDigest || result.ConfirmationDigest != expected.ConfirmationDigest || result.TargetIdentityDigest != expected.TargetIdentityDigest {
return validationError("typed RCON template result does not match leased template, target, digest, or payload identity")
}
if expected.RequiredSchemaFingerprint != "" && result.SchemaFingerprint != expected.RequiredSchemaFingerprint {
return validationError("typed RCON template result does not match leased schema fingerprint")
}
return nil
}
func validateSCUMGuardedMutationResultForJob(job domain.Job, result domain.SCUMGuardedMutationResult) error {
if err := validator.ValidateSCUMGuardedMutationResult(result); err != nil {
return err
}
expected := job.ExecutionInput.GuardedMutation
if expected == nil {
return validationError("guarded mutation request is missing from leased job")
}
if result.JobID != job.ID || result.JobID != expected.JobID || result.RequestID != expected.RequestID {
return validationError("guarded mutation result does not match leased job identity")
}
if !sameSCUMSchemaProbeBinding(result.Binding, expected.Binding) {
return validationError("guarded mutation result does not match leased binding identity")
}
if result.Capability != expected.Capability || result.TargetKey != expected.TargetKey || result.TemplateKey != expected.TemplateKey || result.AdapterVersion != expected.AdapterVersion || result.SchemaFingerprint != expected.RequiredSchemaFingerprint || result.AssetDigest != expected.AssetDigest || result.TargetIdentityDigest != expected.TargetIdentityDigest || result.ExpectedRowDigest != expected.ExpectedRowDigest || result.ExpectedValueDigest != expected.ExpectedValueDigest || result.ExpectedXMLDigest != expected.ExpectedXMLDigest || result.PatchDigest != expected.PatchDigest || result.BackupEvidenceDigest != expected.BackupEvidenceDigest || result.OfflineEvidenceDigest != expected.OfflineEvidenceDigest || result.DangerConfirmationDigest != expected.DangerConfirmationDigest || result.ReadbackExpectationDigest != expected.ReadbackExpectationDigest {
return validationError("guarded mutation result does not match leased template, target, guard, digest, or readback identity")
}
return nil
}
func validateSCUMParsedLogBatchResultForJob(job domain.Job, result domain.SCUMParsedLogBatchResult) error {
if err := validator.ValidateSCUMParsedLogBatchResult(result); err != nil {
return err
}
expected := job.ExecutionInput.LogSource
if expected == nil {
return validationError("parsed log batch request is missing from leased job")
}
if result.JobID != job.ID {
return validationError("parsed log batch result does not match leased job identity")
}
if result.Binding.ServerInstanceID != job.ServerInstanceID || result.Binding.RunEndpointID != job.RunEndpointID {
return validationError("parsed log batch result does not match leased server or Run endpoint")
}
if result.SourceKey != expected.Key || result.StreamKey != expected.StreamKey {
return validationError("parsed log batch result does not match leased log source identity")
}
for key, value := range map[string]string{
"parserKey": result.ParserKey,
"parserVersion": result.ParserVersion,
"parserDigest": result.ParserDigest,
"adapterVersion": result.AdapterVersion,
} {
if expectedValue := strings.TrimSpace(job.ExecutionInput.Inputs[key]); expectedValue != "" && value != expectedValue {
return validationError("parsed log batch result does not match leased parser identity")
}
}
if job.ExecutionInput.PluginID != "" && result.Binding.PluginID != job.ExecutionInput.PluginID {
return validationError("parsed log batch result does not match leased plugin identity")
}
if job.ExecutionInput.TargetVersion != "" && result.Binding.PluginVersion != job.ExecutionInput.TargetVersion {
return validationError("parsed log batch result does not match leased plugin version")
}
if result.FirstCursor.SourceIdentityDigest != result.LastCursor.SourceIdentityDigest || result.FirstCursor.StreamGeneration != result.LastCursor.StreamGeneration {
return validationError("parsed log batch result crosses source identity or generation boundaries")
}
return nil
}
func sameSCUMSchemaProbeBinding(a, b domain.SCUMBindingIdentity) bool {
return a.ServerInstanceID == b.ServerInstanceID && a.RunBindingID == b.RunBindingID && a.RunEndpointID == b.RunEndpointID && a.PluginID == b.PluginID && a.PluginVersion == b.PluginVersion && a.AdapterVersion == b.AdapterVersion && a.GameVersion == b.GameVersion && a.DatabaseIdentity == b.DatabaseIdentity
}
@@ -1,121 +0,0 @@
package service
import (
"strings"
"testing"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
)
func TestSCUMCapabilityNegotiationEvaluatesActiveBindingEvidenceIndependently(t *testing.T) {
store := repo.NewMemoryStore()
svc := NewCoreService(store)
now := time.Date(2026, 8, 13, 7, 0, 0, 0, time.UTC)
svc.now = func() time.Time { return now }
sessionID := seedSCUMCapabilityNegotiationFixture(t, svc, store)
binding := scumCapabilityNegotiationBinding()
schema := scumNegotiationDigest("a")
playerAsset := scumNegotiationDigest("b")
if err := store.Jobs().Create(domain.Job{ID: "job-scum-probe", ServerInstanceID: binding.ServerInstanceID, RunEndpointID: binding.RunEndpointID, State: domain.JobStateSucceeded, ExecutionResult: domain.JobExecutionResult{Kind: scumSchemaProbeExecutionKind, SQLiteSchemaProbe: &domain.SCUMSchemaProbeResult{RequestID: "job-scum-probe", JobID: "job-scum-probe", Binding: binding, Status: domain.SCUMSchemaProbeStatusSucceeded, SourceFingerprint: scumNegotiationDigest("c"), SchemaFingerprint: schema, ObservedAt: now.Add(-2 * time.Minute), ResultDigest: scumNegotiationDigest("d"), Limits: domain.DefaultSCUMSchemaProbeBounds(), SafeError: domain.SCUMSafeError{Code: domain.SCUMSafeErrorNone}}}}); err != nil {
t.Fatalf("create probe evidence job: %v", err)
}
if err := store.Jobs().Create(domain.Job{ID: "job-scum-player-query", ServerInstanceID: binding.ServerInstanceID, RunEndpointID: binding.RunEndpointID, State: domain.JobStateSucceeded, ExecutionResult: domain.JobExecutionResult{Kind: scumSQLiteTemplateExecutionKind, SQLiteTemplate: &domain.SCUMSQLiteTemplateResult{RequestID: "job-scum-player-query", JobID: "job-scum-player-query", Binding: binding, Status: domain.SCUMTerminalResultSucceeded, Capability: domain.SCUMDataCapabilityPlayerRead, TargetKey: "scum-database", TemplateKey: "players-read", AdapterVersion: binding.AdapterVersion, SchemaFingerprint: schema, AssetDigest: playerAsset, ParameterDigest: scumNegotiationDigest("e"), SourceFingerprint: scumNegotiationDigest("f"), ObservedAt: now.Add(-time.Minute), ResultDigest: scumNegotiationDigest("1"), RowCount: 0, Limits: domain.DefaultSCUMSQLiteTemplateBounds(), SafeError: domain.SCUMSafeError{Code: domain.SCUMSafeErrorNone}}}}); err != nil {
t.Fatalf("create player evidence job: %v", err)
}
negotiation, err := svc.NegotiateSCUMCapabilitiesForSession(sessionID, binding.ServerInstanceID)
if err != nil {
t.Fatalf("negotiate capabilities: %v", err)
}
gates := map[domain.SCUMDataCapability]domain.SCUMCapabilityGate{}
for _, gate := range negotiation.Gates {
gates[gate.Capability] = gate
}
if !negotiation.ProbeExecutorAvailable || negotiation.RunBindingID != binding.RunBindingID || negotiation.DatabaseIdentity != binding.DatabaseIdentity {
t.Fatalf("unexpected negotiation identity: %+v", negotiation)
}
if gate := gates[domain.SCUMDataCapabilitySchemaProbe]; !gate.Enabled || gate.ReasonCode != domain.SCUMSafeErrorNone {
t.Fatalf("schema probe should be enabled from accepted probe evidence, got %+v", gate)
}
if gate := gates[domain.SCUMDataCapabilityPlayerRead]; !gate.Enabled || gate.ReasonCode != domain.SCUMSafeErrorNone {
t.Fatalf("players.read should be enabled from matching template evidence, got %+v", gate)
}
if gate := gates[domain.SCUMDataCapabilitySquadRead]; gate.Enabled || gate.ReasonCode != domain.SCUMSafeErrorProbeMissing {
t.Fatalf("squads.read should remain independently disabled without template evidence, got %+v", gate)
}
if gate := gates[domain.SCUMDataCapabilityEconomyCommand]; gate.Enabled || gate.ReasonCode != domain.SCUMSafeErrorProbeExecutorAbsent {
t.Fatalf("economy-command.write should remain disabled when Run lacks protected RCON, got %+v", gate)
}
}
func TestSCUMCapabilityNegotiationRejectsEvidenceFromAnotherBinding(t *testing.T) {
store := repo.NewMemoryStore()
svc := NewCoreService(store)
now := time.Date(2026, 8, 13, 7, 30, 0, 0, time.UTC)
svc.now = func() time.Time { return now }
sessionID := seedSCUMCapabilityNegotiationFixture(t, svc, store)
binding := scumCapabilityNegotiationBinding()
stale := binding
stale.RunBindingID = "runtime-binding-old"
if err := store.Jobs().Create(domain.Job{ID: "job-stale-player-query", ServerInstanceID: binding.ServerInstanceID, RunEndpointID: binding.RunEndpointID, State: domain.JobStateSucceeded, ExecutionResult: domain.JobExecutionResult{Kind: scumSQLiteTemplateExecutionKind, SQLiteTemplate: &domain.SCUMSQLiteTemplateResult{RequestID: "job-stale-player-query", JobID: "job-stale-player-query", Binding: stale, Status: domain.SCUMTerminalResultSucceeded, Capability: domain.SCUMDataCapabilityPlayerRead, TargetKey: "scum-database", TemplateKey: "players-read", AdapterVersion: binding.AdapterVersion, SchemaFingerprint: scumNegotiationDigest("a"), AssetDigest: scumNegotiationDigest("b"), ParameterDigest: scumNegotiationDigest("e"), SourceFingerprint: scumNegotiationDigest("f"), ObservedAt: now, ResultDigest: scumNegotiationDigest("1"), RowCount: 0, Limits: domain.DefaultSCUMSQLiteTemplateBounds(), SafeError: domain.SCUMSafeError{Code: domain.SCUMSafeErrorNone}}}}); err != nil {
t.Fatalf("create stale evidence job: %v", err)
}
negotiation, err := svc.NegotiateSCUMCapabilitiesForSession(sessionID, binding.ServerInstanceID)
if err != nil {
t.Fatalf("negotiate capabilities: %v", err)
}
for _, gate := range negotiation.Gates {
if gate.Capability == domain.SCUMDataCapabilityPlayerRead {
if gate.Enabled || gate.ReasonCode != domain.SCUMSafeErrorBindingMismatch {
t.Fatalf("players.read should reject stale binding evidence, got %+v", gate)
}
return
}
}
t.Fatal("players.read gate missing")
}
func seedSCUMCapabilityNegotiationFixture(t *testing.T, svc *CoreService, store *repo.MemoryStore) string {
t.Helper()
if _, err := svc.CreateUser(domain.User{ID: "scum-negotiation-owner", DisplayName: "SCUM Negotiation Owner", Email: "scum-negotiation@example.test", Status: domain.UserStatusActive, Roles: []string{"server-owner"}, PasswordHash: "secret-password"}); err != nil {
t.Fatalf("create user: %v", err)
}
auth, err := svc.LoginUser(domain.UserLogin{Account: "scum-negotiation@example.test", Password: "secret-password"})
if err != nil {
t.Fatalf("login: %v", err)
}
plugin := domain.GamePlugin{ID: "game.scum", Version: "1.0.0", Status: domain.GamePluginStatusInstalled, SCUMLiveData: domain.SCUMLiveDataManifest{SchemaVersion: "1", Probe: domain.SCUMSchemaProbeDeclaration{Capability: domain.JobCapabilityRemoteRunDBSQLiteProbe, TargetKey: "scum-database", Bounds: domain.DefaultSCUMSchemaProbeBounds()}, CapabilityGates: []domain.SCUMLiveDataCapabilityGateDeclaration{
{Capability: domain.SCUMDataCapabilitySchemaProbe, Gate: domain.SCUMCapabilityGateEnabled, AdapterVersion: "adapter-1", RequiredSchemaFingerprint: scumNegotiationDigest("a"), EvidenceStatus: domain.SCUMCapabilityEvidenceCompatible, SafeReason: "schema probe evidence is compatible"},
{Capability: domain.SCUMDataCapabilityPlayerRead, Gate: domain.SCUMCapabilityGateEnabled, AdapterVersion: "adapter-1", RequiredSchemaFingerprint: scumNegotiationDigest("a"), RequiredAssetDigests: []string{scumNegotiationDigest("b")}, EvidenceStatus: domain.SCUMCapabilityEvidenceCompatible, SafeReason: "players query evidence is compatible"},
{Capability: domain.SCUMDataCapabilitySquadRead, Gate: domain.SCUMCapabilityGateEnabled, AdapterVersion: "adapter-1", RequiredSchemaFingerprint: scumNegotiationDigest("a"), RequiredAssetDigests: []string{scumNegotiationDigest("2")}, EvidenceStatus: domain.SCUMCapabilityEvidenceCompatible, SafeReason: "squad query evidence is compatible"},
{Capability: domain.SCUMDataCapabilityEconomyCommand, Gate: domain.SCUMCapabilityGateEnabled, AdapterVersion: "adapter-1", RequiredSchemaFingerprint: scumNegotiationDigest("a"), RequiredAssetDigests: []string{scumNegotiationDigest("3")}, EvidenceStatus: domain.SCUMCapabilityEvidenceCompatible, SafeReason: "economy command evidence is compatible"},
{Capability: domain.SCUMDataCapabilityGiftCommand, Gate: domain.SCUMCapabilityGateDisabled, AdapterVersion: "adapter-1", RequiredSchemaFingerprint: scumNegotiationDigest("a"), EvidenceStatus: domain.SCUMCapabilityEvidenceMissing, SafeReason: "gift command evidence is missing"},
{Capability: domain.SCUMDataCapabilityProfileXMLWrite, Gate: domain.SCUMCapabilityGateDisabled, AdapterVersion: "adapter-1", RequiredSchemaFingerprint: scumNegotiationDigest("a"), EvidenceStatus: domain.SCUMCapabilityEvidenceMissing, SafeReason: "XML mutation evidence is missing"},
}}, RuntimeProfiles: domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process"}}}}
if err := store.GamePlugins().Create(plugin); err != nil {
t.Fatalf("create plugin: %v", err)
}
endpoint := domain.RunEndpoint{ID: "run-scum-negotiation", DisplayName: "Run SCUM Negotiation", Version: "0.1.0", Status: domain.RunEndpointStatusOnline, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteProbe, domain.JobCapabilityRemoteRunDBSQLiteQuery}}
if err := store.RunEndpoints().Create(endpoint); err != nil {
t.Fatalf("create endpoint: %v", err)
}
if err := store.ServerInstances().Create(domain.ServerInstance{ID: "server-scum-negotiation", PluginID: plugin.ID, PluginVersion: plugin.Version, RunEndpointID: endpoint.ID, Name: "SCUM Negotiation", OwnerUserID: "scum-negotiation-owner", State: domain.ServerInstanceStateRunning}); err != nil {
t.Fatalf("create server: %v", err)
}
if _, err := svc.UpdateServerRuntimeBindingForSession(auth.SessionID, "server-scum-negotiation", domain.RuntimeBindingUpdate{ProfileKey: "local", Bindings: map[string]string{}}); err != nil {
t.Fatalf("create runtime binding: %v", err)
}
return auth.SessionID
}
func scumCapabilityNegotiationBinding() domain.SCUMBindingIdentity {
return domain.SCUMBindingIdentity{ServerInstanceID: "server-scum-negotiation", RunBindingID: "runtime-binding-server-scum-negotiation", RunEndpointID: "run-scum-negotiation", PluginID: "game.scum", PluginVersion: "1.0.0", AdapterVersion: "adapter-1", DatabaseIdentity: "scum-database"}
}
func scumNegotiationDigest(char string) string {
return "sha256:" + strings.Repeat(char, 64)
}
+770
View File
@@ -0,0 +1,770 @@
package service
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"browser.local/platform/domain"
"browser.local/platform/repo"
)
type scumSQLiteMutationJobResult struct {
Outcome string `json:"outcome"`
AffectedRows int `json:"affectedRows"`
MutationChecksum string `json:"mutationChecksum"`
ConfirmationRows []map[string]any `json:"confirmationRows"`
SafeMessage string `json:"safeMessage"`
}
func (svc *CoreService) RequestSCUMOperationForSession(sessionID, serverID string, request domain.SCUMOperationRequest) (domain.SCUMOperationRequest, error) {
request = domain.CopySCUMOperationRequest(request)
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
if err := svc.authorizeServerLifecycle(sessionID, serverID); err != nil {
return domain.SCUMOperationRequest{}, err
}
instance, err := svc.store.ServerInstances().Get(serverID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
template, ok := scumOperationTemplate(plugin, request.TemplateKey)
if !ok {
return domain.SCUMOperationRequest{}, validationError("SCUM operation template is not declared")
}
if !containsString(plugin.DeclaredPermissions, template.Permission) {
return domain.SCUMOperationRequest{}, validationError("SCUM operation permission is not declared")
}
if strings.TrimSpace(request.IdempotencyKey) == "" || len(request.IdempotencyKey) > 120 {
return domain.SCUMOperationRequest{}, validationError("operation idempotency key is required")
}
existing, err := svc.store.SCUMOperationRequests().List(domain.SCUMOperationRequestFilter{ServerInstanceID: serverID, IdempotencyKey: request.IdempotencyKey})
if err != nil {
return domain.SCUMOperationRequest{}, err
}
if len(existing) > 0 {
return domain.CopySCUMOperationRequest(existing[0]), nil
}
playerID := coalesceString(request.PlayerID, firstString(request.Payload, "playerId", "steamId"))
if playerID == "" && request.TemplateKey != "server.reward.command.deliver" {
return domain.SCUMOperationRequest{}, validationError("operation playerId is required")
}
summary := operationSafeSummary(request.TemplateKey, playerID, request.Payload)
switch template.Kind {
case domain.GameClientBridgeOperationKindRCON:
if err := validateSCUMRCONOperationPayload(request.TemplateKey, playerID, request.Payload); err != nil {
return domain.SCUMOperationRequest{}, err
}
case domain.GameClientBridgeOperationKindSQLiteMutation:
guard, payload, err := normalizeSCUMSQLiteMutationRequest(template, playerID, request.Payload, request.Guard)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
request.Guard = guard
request.Payload = payload
summary = scumSQLiteMutationSafeSummary(request.TemplateKey, playerID, guard)
default:
return domain.SCUMOperationRequest{}, validationError("SCUM operation kind is unsupported")
}
stamp := svc.now()
operation := domain.SCUMOperationRequest{ID: "scum-operation-" + fingerprintID(serverID, request.IdempotencyKey), ServerInstanceID: serverID, PluginID: instance.PluginID, TemplateKey: request.TemplateKey, PlayerID: playerID, RequesterID: user.ID, ApprovalLevel: template.ApprovalLevel, Payload: domain.CopyGameClientBridgePayload(request.Payload), Guard: request.Guard, Status: domain.SCUMWorkflowStepWaiting, Reason: bounded(request.Reason, 240), IdempotencyKey: request.IdempotencyKey, SafeSummary: summary, CreatedAt: stamp, UpdatedAt: stamp}
if err := svc.store.SCUMOperationRequests().Create(operation); err != nil {
return domain.SCUMOperationRequest{}, err
}
_, err = svc.recordAuditEventWithID(user.ID, "scum.operation.request", "scum-operation", operation.ID, domain.AuditResultQueued, "typed SCUM operation awaiting approval")
return domain.CopySCUMOperationRequest(operation), err
}
func (svc *CoreService) ListSCUMOperationsForSession(sessionID string, filter domain.SCUMOperationRequestFilter) ([]domain.SCUMOperationRequest, error) {
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
values, err := svc.store.SCUMOperationRequests().List(filter)
if err != nil {
return nil, err
}
limitSCUMProjectionSlice(&values, filter.Limit)
return values, nil
}
func (svc *CoreService) ApproveSCUMOperationForSession(sessionID, operationID string) (domain.SCUMOperationRequest, error) {
operation, err := svc.store.SCUMOperationRequests().Get(operationID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
if err := svc.authorizeServerLifecycle(sessionID, operation.ServerInstanceID); err != nil {
return domain.SCUMOperationRequest{}, err
}
if operation.ApprovalLevel == domain.GameClientBridgeApprovalLevelPlatformAdmin && !isPlatformAdmin(user) {
return domain.SCUMOperationRequest{}, ErrForbidden
}
if operation.Status != domain.SCUMWorkflowStepWaiting {
return domain.SCUMOperationRequest{}, validationError("SCUM operation is not awaiting approval")
}
instance, err := svc.store.ServerInstances().Get(operation.ServerInstanceID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
template, ok := scumOperationTemplate(plugin, operation.TemplateKey)
if !ok {
return domain.SCUMOperationRequest{}, validationError("SCUM operation template is not declared")
}
var jobID string
var auditSummary string
switch template.Kind {
case domain.GameClientBridgeOperationKindRCON:
request, err := svc.sourceRCONRequestForSCUMOperation(operation)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
dispatch, err := svc.DispatchSourceRCONCommandForSession(sessionID, request)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
jobID = dispatch.JobID
auditSummary = "typed SCUM operation dispatched through transient RCON input"
case domain.GameClientBridgeOperationKindSQLiteMutation:
gated, ready, err := svc.applySCUMSQLiteMutationApprovalGate(operation, template)
if err != nil || !ready {
return gated, err
}
operation = gated
job, err := svc.dispatchSCUMSQLiteMutationOperation(operation, template)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
jobID = job.ID
auditSummary = "typed SCUM DB mutation dispatched through template-bound Run job"
default:
return domain.SCUMOperationRequest{}, validationError("SCUM operation kind is unsupported")
}
stamp := svc.now()
operation.ApproverID = user.ID
operation.ApprovedAt = stamp
operation.Status = domain.SCUMWorkflowStepQueued
operation.RunJobID = jobID
operation.UpdatedAt = stamp
operation.AuditReferences = append(operation.AuditReferences, "job:"+jobID)
if err := svc.store.SCUMOperationRequests().Update(operation); err != nil {
return domain.SCUMOperationRequest{}, err
}
_, err = svc.recordAuditEventWithID(user.ID, "scum.operation.approve", "scum-operation", operation.ID, domain.AuditResultQueued, auditSummary)
return domain.CopySCUMOperationRequest(operation), err
}
func (svc *CoreService) ReconcileSCUMOperation(operationID string) (domain.SCUMOperationRequest, error) {
operation, err := svc.store.SCUMOperationRequests().Get(operationID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
if strings.TrimSpace(operation.RunJobID) == "" {
return domain.CopySCUMOperationRequest(operation), nil
}
job, err := svc.store.Jobs().Get(operation.RunJobID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
instance, err := svc.store.ServerInstances().Get(operation.ServerInstanceID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
template, _ := scumOperationTemplate(plugin, operation.TemplateKey)
stamp := svc.now()
switch job.State {
case domain.JobStateSucceeded:
if template.Kind == domain.GameClientBridgeOperationKindSQLiteMutation {
if updated, terminal := reconcileSCUMSQLiteMutationJobResult(operation, template, job); terminal {
operation = updated
} else {
operation = updated
operation.Status = domain.SCUMWorkflowStepConfirming
}
} else if operation.Confirmation.Status == "confirmed" {
operation.Status = domain.SCUMWorkflowStepConfirmed
} else {
operation.Status = domain.SCUMWorkflowStepConfirming
}
case domain.JobStateFailed:
if strings.Contains(strings.ToLower(job.ExecutionResult.Kind), "unknown") || strings.Contains(strings.ToLower(job.ExecutionResult.AuditSummary), "unknown") {
operation.Status = domain.SCUMWorkflowStepUnknown
} else {
operation.Status = domain.SCUMWorkflowStepFailed
}
operation.CompletedAt = stamp
case domain.JobStateCancelled:
operation.Status = domain.SCUMWorkflowStepUnknown
operation.CompletedAt = stamp
}
operation.UpdatedAt = stamp
if (operation.Status == domain.SCUMWorkflowStepConfirmed || operation.Status == domain.SCUMWorkflowStepFailed || operation.Status == domain.SCUMWorkflowStepUnknown) && operation.CompletedAt.IsZero() {
operation.CompletedAt = stamp
}
if err := svc.store.SCUMOperationRequests().Update(operation); err != nil {
return domain.SCUMOperationRequest{}, err
}
return domain.CopySCUMOperationRequest(operation), nil
}
func (svc *CoreService) ConfirmSCUMOperation(operationID string, confirmation domain.SCUMOperationConfirmation) (domain.SCUMOperationRequest, error) {
operation, err := svc.store.SCUMOperationRequests().Get(operationID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
instance, err := svc.store.ServerInstances().Get(operation.ServerInstanceID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
template, _ := scumOperationTemplate(plugin, operation.TemplateKey)
confirmation = domain.CopySCUMOperationConfirmation(confirmation)
stamp := svc.now()
if confirmation.Status != "confirmed" {
operation.Status = domain.SCUMWorkflowStepFailed
operation.Confirmation = confirmation
operation.CompletedAt = stamp
operation.UpdatedAt = stamp
if err := svc.store.SCUMOperationRequests().Update(operation); err != nil {
return domain.SCUMOperationRequest{}, err
}
return domain.CopySCUMOperationRequest(operation), nil
}
if template.Kind == domain.GameClientBridgeOperationKindSQLiteMutation && !scumSQLiteMutationConfirmationMatches(operation, confirmation.ConfirmedFields) {
confirmation.Status = "failed"
confirmation.SafeSummary = domain.SCUMSafeSummary{Title: "DB mutation confirmation mismatch", Message: "Run readback did not prove the requested SCUM player field value."}
operation.Status = domain.SCUMWorkflowStepFailed
operation.Confirmation = confirmation
operation.CompletedAt = stamp
operation.UpdatedAt = stamp
if err := svc.store.SCUMOperationRequests().Update(operation); err != nil {
return domain.SCUMOperationRequest{}, err
}
return domain.CopySCUMOperationRequest(operation), nil
}
operation.Confirmation = confirmation
operation.Status = domain.SCUMWorkflowStepConfirmed
operation.CompletedAt = stamp
operation.UpdatedAt = stamp
if err := svc.store.SCUMOperationRequests().Update(operation); err != nil {
return domain.SCUMOperationRequest{}, err
}
return domain.CopySCUMOperationRequest(operation), nil
}
func (svc *CoreService) sourceRCONRequestForSCUMOperation(operation domain.SCUMOperationRequest) (domain.SourceRCONCommandRequest, error) {
command, chat, err := scumRCONCommandForOperation(operation)
if err != nil {
return domain.SourceRCONCommandRequest{}, err
}
request := domain.SourceRCONCommandRequest{ServerInstanceID: operation.ServerInstanceID, IdempotencyKey: "scum-operation-" + operation.IdempotencyKey}
if chat != "" {
request.Kind = domain.SourceRCONCommandKindChat
request.ChatType = 4
request.TargetSteamID = operation.PlayerID
request.Message = chat
return request, nil
}
request.Kind = domain.SourceRCONCommandKindCommand
request.Command = command
return request, nil
}
func scumRCONCommandForOperation(operation domain.SCUMOperationRequest) (command string, chat string, err error) {
playerID := operation.PlayerID
switch operation.TemplateKey {
case "player.fame.set":
amount, ok := operationInteger(operation.Payload, "fame", "amount", "value")
if !ok {
return "", "", validationError("fame amount is required")
}
return fmt.Sprintf("#SetFamePoints %d %q", amount, playerID), "", nil
case "player.currency.normal.set":
amount, ok := operationInteger(operation.Payload, "amount", "balance", "normalBalance")
if !ok {
return "", "", validationError("normal currency amount is required")
}
return fmt.Sprintf("#SetCurrencyBalance Normal %d %q", amount, playerID), "", nil
case "player.currency.gold.set":
amount, ok := operationInteger(operation.Payload, "amount", "balance", "goldBalance")
if !ok {
return "", "", validationError("gold currency amount is required")
}
return fmt.Sprintf("#SetCurrencyBalance Gold %d %q", amount, playerID), "", nil
case "player.notify":
message := strings.TrimSpace(firstString(operation.Payload, "message", "notice"))
if message == "" || len(message) > 200 {
return "", "", validationError("notification message is required")
}
return "", message, nil
default:
return "", "", validationError("unsupported SCUM RCON operation template")
}
}
func validateSCUMRCONOperationPayload(templateKey, playerID string, payload map[string]any) error {
operation := domain.SCUMOperationRequest{TemplateKey: templateKey, PlayerID: playerID, Payload: payload}
command, chat, err := scumRCONCommandForOperation(operation)
if err != nil {
return err
}
if strings.ContainsAny(command, "\r\n") || strings.ContainsAny(chat, "\r\n") {
return validationError("operation payload contains invalid control characters")
}
return nil
}
func normalizeSCUMSQLiteMutationRequest(template domain.GameClientBridgeOperationTemplateDeclaration, playerID string, payload map[string]any, guard domain.SCUMMutationGuard) (domain.SCUMMutationGuard, map[string]any, error) {
lowerKey := strings.ToLower(template.Key)
if strings.Contains(lowerKey, "fame") || strings.Contains(lowerKey, "currency") {
return domain.SCUMMutationGuard{}, nil, validationError("SCUM fame and currency edits must use RCON operation templates")
}
if template.ApprovalLevel != domain.GameClientBridgeApprovalLevelPlatformAdmin {
return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation requires platform-admin approval")
}
if template.Mutation.FieldKey == "" || template.Mutation.ConfirmationQueryKey == "" || template.Mutation.TableKey == "" || template.Mutation.IdentityKey == "" || template.Mutation.ValueKey == "" {
return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation metadata is incomplete")
}
if template.MaxRowsAffected < 1 {
return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation row bound is required")
}
payload = domain.CopyGameClientBridgePayload(payload)
if guard.FieldKey == "" {
guard.FieldKey = coalesceString(firstString(payload, "fieldKey"), template.Mutation.FieldKey)
}
if guard.Before == nil {
guard.Before = payload["before"]
}
if guard.After == nil {
guard.After = payload["after"]
if guard.After == nil {
guard.After = payload["value"]
}
}
if guard.MaxRowsAffected == 0 {
guard.MaxRowsAffected = template.MaxRowsAffected
}
guard.SafetyWindow = coalesceString(guard.SafetyWindow, firstString(payload, "safetyWindow", "maintenanceWindow"))
guard.BackupRef = coalesceString(guard.BackupRef, firstString(payload, "backupRef", "snapshotRef"))
guard.RequiresOfflinePlayer = template.Safety.RequiresOfflinePlayer
guard.RequiresMaintenance = template.Safety.RequiresMaintenanceWindow
guard.RequiresBackup = template.Safety.BackupRequired
if playerID == "" {
return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation playerId is required")
}
if guard.FieldKey != template.Mutation.FieldKey {
return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation field key does not match template")
}
if guard.Before == nil || guard.After == nil {
return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation before and after values are required")
}
if guard.MaxRowsAffected < 1 || guard.MaxRowsAffected > template.MaxRowsAffected {
return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation maxRowsAffected exceeds template bound")
}
if err := validateSCUMMutationValue(template, guard.Before, "before"); err != nil {
return domain.SCUMMutationGuard{}, nil, err
}
if err := validateSCUMMutationValue(template, guard.After, "after"); err != nil {
return domain.SCUMMutationGuard{}, nil, err
}
for key, value := range map[string]any{"playerId": playerID, "fieldKey": guard.FieldKey, "before": guard.Before, "after": guard.After, "safetyWindow": guard.SafetyWindow, "backupRef": guard.BackupRef} {
if value != nil && value != "" {
payload[key] = value
}
}
return guard, payload, nil
}
func validateSCUMMutationValue(template domain.GameClientBridgeOperationTemplateDeclaration, value any, label string) error {
switch template.Mutation.AllowedValueType {
case "integer":
parsed, ok := anyInt64(value)
if !ok {
return validationError("SCUM DB mutation " + label + " value must be an integer")
}
if template.Mutation.MinValue != 0 && float64(parsed) < template.Mutation.MinValue || template.Mutation.MaxValue != 0 && float64(parsed) > template.Mutation.MaxValue {
return validationError("SCUM DB mutation " + label + " value is outside the template range")
}
case "number":
parsed, ok := anyFloat64(value)
if !ok {
return validationError("SCUM DB mutation " + label + " value must be numeric")
}
if template.Mutation.MinValue != 0 && parsed < template.Mutation.MinValue || template.Mutation.MaxValue != 0 && parsed > template.Mutation.MaxValue {
return validationError("SCUM DB mutation " + label + " value is outside the template range")
}
case "string":
if strings.TrimSpace(fmt.Sprint(value)) == "" || strings.ContainsAny(fmt.Sprint(value), "\r\n") {
return validationError("SCUM DB mutation " + label + " value is invalid")
}
case "boolean":
if _, ok := value.(bool); !ok {
return validationError("SCUM DB mutation " + label + " value must be boolean")
}
default:
return validationError("SCUM DB mutation value type is unsupported")
}
return nil
}
func (svc *CoreService) applySCUMSQLiteMutationApprovalGate(operation domain.SCUMOperationRequest, template domain.GameClientBridgeOperationTemplateDeclaration) (domain.SCUMOperationRequest, bool, error) {
state, err := svc.latestSCUMPlayerLiveState(operation.ServerInstanceID, operation.PlayerID)
if err != nil {
if err == repo.ErrNotFound {
return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待真实玩家投影", "需要先从当前服务的登录日志或 SCUM.db 读取玩家数据。")
}
return domain.SCUMOperationRequest{}, false, err
}
if state.Freshness.Status != domain.SCUMProjectionFresh {
return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待新鲜投影", "玩家投影不是 fresh,需先刷新 SCUM.db/readback。")
}
if template.Safety.RequiresOfflinePlayer && state.Online {
return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待玩家离线", "DB-only 玩家字段修改必须等玩家离线或进入维护窗口。")
}
if template.Safety.RequiresMaintenanceWindow && strings.TrimSpace(operation.Guard.SafetyWindow) == "" {
return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "缺少维护窗口", "DB mutation 需要记录维护窗口/离线安全证据。")
}
if template.Safety.BackupRequired && strings.TrimSpace(operation.Guard.BackupRef) == "" {
return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "缺少备份快照", "DB mutation 需要 run 或管理员提供 backup/snapshot evidence。")
}
current, ok := scumCurrentMutationFieldValue(state, operation.Guard.FieldKey)
if !ok {
return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待字段读回", "当前投影没有该 DB-only 字段,需先执行确认查询。")
}
if !scumScalarEqual(current, operation.Guard.Before) {
return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepBlocked, "before value 已过期", "当前投影值与审批时 before guard 不一致,已阻止写入。")
}
return domain.CopySCUMOperationRequest(operation), true, nil
}
func (svc *CoreService) updateSCUMOperationGate(operation domain.SCUMOperationRequest, status domain.SCUMWorkflowStepStatus, title string, message string) (domain.SCUMOperationRequest, bool, error) {
operation.Status = status
operation.SafeSummary = domain.SCUMSafeSummary{Title: title, Message: message, Details: map[string]string{"template": operation.TemplateKey, "playerId": operation.PlayerID}}
operation.UpdatedAt = svc.now()
if err := svc.store.SCUMOperationRequests().Update(operation); err != nil {
return domain.SCUMOperationRequest{}, false, err
}
return domain.CopySCUMOperationRequest(operation), false, nil
}
func (svc *CoreService) latestSCUMPlayerLiveState(serverID, playerID string) (domain.SCUMPlayerLiveState, error) {
states, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: serverID, GamePlayerID: playerID})
if err != nil {
return domain.SCUMPlayerLiveState{}, err
}
if len(states) == 0 {
states, err = svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: serverID, SteamID: playerID})
if err != nil {
return domain.SCUMPlayerLiveState{}, err
}
}
if len(states) == 0 {
return domain.SCUMPlayerLiveState{}, repo.ErrNotFound
}
best := states[0]
for _, state := range states[1:] {
if state.Freshness.ObservedAt.After(best.Freshness.ObservedAt) || state.UpdatedAt.After(best.UpdatedAt) {
best = state
}
}
return domain.CopySCUMPlayerLiveState(best), nil
}
func scumCurrentMutationFieldValue(state domain.SCUMPlayerLiveState, fieldKey string) (any, bool) {
if state.UnknownFields != nil {
for _, key := range []string{fieldKey, "field" + fieldKey, "attribute" + fieldKey, "attribute_" + fieldKey, "stat" + fieldKey, "stat_" + fieldKey} {
if value, ok := state.UnknownFields[key]; ok {
return value, true
}
}
}
return nil, false
}
func (svc *CoreService) dispatchSCUMSQLiteMutationOperation(operation domain.SCUMOperationRequest, template domain.GameClientBridgeOperationTemplateDeclaration) (domain.Job, error) {
instance, err := svc.store.ServerInstances().Get(operation.ServerInstanceID)
if err != nil {
return domain.Job{}, err
}
jobID := jobIDFromParts("job-scum-sqlite-mutation", instance.ID, operation.IdempotencyKey)
job := domain.Job{ID: jobID, ServerInstanceID: instance.ID, RunEndpointID: instance.RunEndpointID, Capability: domain.JobCapabilityRemoteRunProtectedSQL, TargetKey: template.TargetKey, InputRef: "input://scum-operation/" + operation.ID, IdempotencyKey: "scum-sqlite-mutation:" + operation.IdempotencyKey, Progress: domain.JobProgress{Percent: 0, Message: "typed SCUM DB mutation queued"}, RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 1, MaxBackoffSeconds: 1}, ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), RemoteAdapterKey: template.TransportKey, RemoteAdapterKind: "protected-sql", TimeoutSeconds: template.TimeoutSeconds, PluginID: operation.PluginID, Inputs: scumSQLiteMutationJobInputs(operation, template)}}
created, err := svc.CreateJob(job)
if err != nil {
return domain.Job{}, err
}
if created.ID != jobID || created.Capability != domain.JobCapabilityRemoteRunProtectedSQL || created.TargetKey != template.TargetKey || created.ExecutionInput.RemoteAdapterKey != template.TransportKey {
return domain.Job{}, validationError("SCUM DB mutation idempotency key is already bound")
}
return created, nil
}
func scumSQLiteMutationJobInputs(operation domain.SCUMOperationRequest, template domain.GameClientBridgeOperationTemplateDeclaration) map[string]string {
return map[string]string{
"operationId": operation.ID,
"templateKey": operation.TemplateKey,
"playerId": operation.PlayerID,
"fieldKey": operation.Guard.FieldKey,
"tableKey": template.Mutation.TableKey,
"identityKey": template.Mutation.IdentityKey,
"valueKey": template.Mutation.ValueKey,
"before": scumScalarString(operation.Guard.Before),
"after": scumScalarString(operation.Guard.After),
"maxRowsAffected": strconv.Itoa(operation.Guard.MaxRowsAffected),
"confirmationQueryKey": template.Mutation.ConfirmationQueryKey,
"safetyWindow": operation.Guard.SafetyWindow,
"backupRef": operation.Guard.BackupRef,
}
}
func reconcileSCUMSQLiteMutationJobResult(operation domain.SCUMOperationRequest, template domain.GameClientBridgeOperationTemplateDeclaration, job domain.Job) (domain.SCUMOperationRequest, bool) {
result, ok := parseSCUMSQLiteMutationJobResult(job.ExecutionResult.Content)
if !ok || result.Outcome == "unknown" || strings.Contains(strings.ToLower(job.ExecutionResult.Kind), "unknown") {
operation.Status = domain.SCUMWorkflowStepUnknown
operation.Confirmation = domain.SCUMOperationConfirmation{Status: "unknown", SafeSummary: domain.SCUMSafeSummary{Title: "DB mutation state unknown", Message: "Run did not return a valid bounded mutation result."}}
return operation, true
}
operation.Confirmation.AffectedRows = result.AffectedRows
operation.Confirmation.MutationChecksum = result.MutationChecksum
operation.Confirmation.Checksum = coalesceString(operation.Confirmation.Checksum, coalesceString(result.MutationChecksum, job.ExecutionResult.Checksum))
if result.Outcome == "stale-before" {
operation.Status = domain.SCUMWorkflowStepFailed
operation.Confirmation.Status = "failed"
operation.SafeSummary = domain.SCUMSafeSummary{Title: "before value 已过期", Message: "Run 在写入前发现当前 DB 值与 approved before guard 不一致。"}
return operation, true
}
if result.Outcome != "succeeded" || result.AffectedRows < 1 {
operation.Status = domain.SCUMWorkflowStepFailed
operation.Confirmation.Status = "failed"
operation.SafeSummary = domain.SCUMSafeSummary{Title: "DB mutation failed", Message: bounded(coalesceString(result.SafeMessage, "Run reported the mutation did not succeed."), 240)}
return operation, true
}
if result.AffectedRows > template.MaxRowsAffected || result.AffectedRows > operation.Guard.MaxRowsAffected || strings.TrimSpace(result.MutationChecksum) == "" {
operation.Status = domain.SCUMWorkflowStepUnknown
operation.Confirmation.Status = "unknown"
operation.SafeSummary = domain.SCUMSafeSummary{Title: "DB mutation row bound unknown", Message: "Run result exceeded declared row bounds or omitted mutation checksum."}
return operation, true
}
if len(result.ConfirmationRows) > 0 {
for _, row := range result.ConfirmationRows {
if scumSQLiteMutationConfirmationMatches(operation, row) {
operation.Status = domain.SCUMWorkflowStepConfirmed
operation.Confirmation.Status = "confirmed"
operation.Confirmation.ConfirmedFields = domain.CopyGameClientBridgePayload(row)
return operation, true
}
}
operation.Status = domain.SCUMWorkflowStepFailed
operation.Confirmation.Status = "failed"
operation.SafeSummary = domain.SCUMSafeSummary{Title: "DB mutation confirmation mismatch", Message: "Run confirmation rows did not match the requested after value."}
return operation, true
}
operation.Confirmation.Status = "executed"
return operation, false
}
func parseSCUMSQLiteMutationJobResult(content string) (scumSQLiteMutationJobResult, bool) {
if strings.TrimSpace(content) == "" {
return scumSQLiteMutationJobResult{}, false
}
var result scumSQLiteMutationJobResult
if err := json.Unmarshal([]byte(content), &result); err != nil {
return scumSQLiteMutationJobResult{}, false
}
result.Outcome = strings.TrimSpace(result.Outcome)
return result, result.Outcome != ""
}
func scumSQLiteMutationConfirmationMatches(operation domain.SCUMOperationRequest, row map[string]any) bool {
if row == nil {
return false
}
rowPlayerID := firstString(row, "playerId", "gamePlayerId", "steamId", "steam_id")
if rowPlayerID != "" && rowPlayerID != operation.PlayerID {
return false
}
if field := firstString(row, "fieldKey", "field", "attributeKey"); field != "" && field != operation.Guard.FieldKey {
return false
}
for _, key := range []string{"value", "after", operation.Guard.FieldKey, "field" + operation.Guard.FieldKey, "attribute" + operation.Guard.FieldKey, "attribute_" + operation.Guard.FieldKey} {
if value, ok := row[key]; ok && scumScalarEqual(value, operation.Guard.After) {
return true
}
}
return false
}
func scumSQLiteMutationSafeSummary(templateKey, playerID string, guard domain.SCUMMutationGuard) domain.SCUMSafeSummary {
details := map[string]string{"template": templateKey, "fieldKey": guard.FieldKey, "maxRowsAffected": strconv.Itoa(guard.MaxRowsAffected)}
if playerID != "" {
details["playerId"] = playerID
}
if guard.SafetyWindow != "" {
details["safetyWindow"] = guard.SafetyWindow
}
if guard.BackupRef != "" {
details["backupRef"] = guard.BackupRef
}
return domain.SCUMSafeSummary{Title: "Typed SCUM DB mutation", Message: "Run executes this through a declared mutation template with before-value and row-bound guards; raw SQL is not stored.", Details: details}
}
func operationInteger(payload map[string]any, keys ...string) (int64, bool) {
for _, key := range keys {
value, exists := payload[key]
if !exists {
continue
}
switch typed := value.(type) {
case int:
return int64(typed), true
case int64:
return typed, true
case uint64:
if typed > uint64(^uint64(0)>>1) {
return 0, false
}
return int64(typed), true
case float64:
if typed == float64(int64(typed)) {
return int64(typed), true
}
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
if err == nil {
return parsed, true
}
}
}
return 0, false
}
func anyInt64(value any) (int64, bool) {
switch typed := value.(type) {
case int:
return int64(typed), true
case int8:
return int64(typed), true
case int16:
return int64(typed), true
case int32:
return int64(typed), true
case int64:
return typed, true
case uint:
return int64(typed), true
case uint8:
return int64(typed), true
case uint16:
return int64(typed), true
case uint32:
return int64(typed), true
case uint64:
if typed > uint64(^uint64(0)>>1) {
return 0, false
}
return int64(typed), true
case float64:
if typed == float64(int64(typed)) {
return int64(typed), true
}
case float32:
if typed == float32(int64(typed)) {
return int64(typed), true
}
case json.Number:
parsed, err := typed.Int64()
return parsed, err == nil
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
return parsed, err == nil
}
return 0, false
}
func anyFloat64(value any) (float64, bool) {
switch typed := value.(type) {
case int:
return float64(typed), true
case int64:
return float64(typed), true
case uint64:
return float64(typed), true
case float64:
return typed, true
case float32:
return float64(typed), true
case json.Number:
parsed, err := typed.Float64()
return parsed, err == nil
case string:
parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
return parsed, err == nil
}
return 0, false
}
func scumScalarEqual(left any, right any) bool {
if leftInt, ok := anyInt64(left); ok {
if rightInt, rightOK := anyInt64(right); rightOK {
return leftInt == rightInt
}
}
if leftFloat, ok := anyFloat64(left); ok {
if rightFloat, rightOK := anyFloat64(right); rightOK {
return leftFloat == rightFloat
}
}
return strings.TrimSpace(fmt.Sprint(left)) == strings.TrimSpace(fmt.Sprint(right))
}
func scumScalarString(value any) string {
if parsed, ok := anyInt64(value); ok {
return strconv.FormatInt(parsed, 10)
}
if parsed, ok := anyFloat64(value); ok {
return strconv.FormatFloat(parsed, 'f', -1, 64)
}
if typed, ok := value.(bool); ok {
return strconv.FormatBool(typed)
}
return bounded(strings.TrimSpace(fmt.Sprint(value)), 512)
}
func operationSafeSummary(templateKey, playerID string, payload map[string]any) domain.SCUMSafeSummary {
details := map[string]string{"template": templateKey}
if playerID != "" {
details["playerId"] = playerID
}
if amount, ok := operationInteger(payload, "fame", "amount", "balance", "value", "normalBalance", "goldBalance"); ok {
details["value"] = fmt.Sprintf("%d", amount)
}
return domain.SCUMSafeSummary{Title: "Typed SCUM operation", Message: "RCON text is generated server-side and is not stored in the operation record.", Details: details}
}
func scumOperationTemplate(plugin domain.GamePlugin, key string) (domain.GameClientBridgeOperationTemplateDeclaration, bool) {
for _, template := range plugin.GameClientBridge.OperationTemplates {
if template.Key == key {
return template, true
}
}
return domain.GameClientBridgeOperationTemplateDeclaration{}, false
}
+273
View File
@@ -0,0 +1,273 @@
package service
import (
"encoding/json"
"strings"
"testing"
"time"
"browser.local/platform/domain"
)
func TestSCUMRCONOperationApprovalDispatchesTransientCommandAndConfirms(t *testing.T) {
svc, session, runSession, instance := newSourceRCONFixture(t)
seedSCUMOperationTemplates(t, svc, instance.PluginID)
request := domain.SCUMOperationRequest{TemplateKey: "player.fame.set", PlayerID: "76561198000000001", Payload: map[string]any{"fame": 123}, Reason: "restore fame", IdempotencyKey: "fame-restore-1"}
operation, err := svc.RequestSCUMOperationForSession(session, instance.ID, request)
if err != nil || operation.Status != domain.SCUMWorkflowStepWaiting {
t.Fatalf("request operation=%+v err=%v", operation, err)
}
duplicate, err := svc.RequestSCUMOperationForSession(session, instance.ID, request)
if err != nil || duplicate.ID != operation.ID {
t.Fatalf("duplicate should return original operation: duplicate=%+v err=%v", duplicate, err)
}
approved, err := svc.ApproveSCUMOperationForSession(session, operation.ID)
if err != nil || approved.Status != domain.SCUMWorkflowStepQueued || approved.RunJobID == "" {
t.Fatalf("approve operation=%+v err=%v", approved, err)
}
job, err := svc.store.Jobs().Get(approved.RunJobID)
if err != nil {
t.Fatalf("get operation job: %v", err)
}
serializedOperation, _ := json.Marshal(approved)
serializedJob, _ := json.Marshal(job)
for _, forbidden := range []string{"#SetFamePoints", "SetCurrencyBalance", "password="} {
if strings.Contains(string(serializedOperation), forbidden) || strings.Contains(string(serializedJob), forbidden) {
t.Fatalf("operation/job persisted raw RCON text %q: operation=%s job=%s", forbidden, serializedOperation, serializedJob)
}
}
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: runSession, Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || claim.Job == nil || claim.Job.JobID != approved.RunJobID {
t.Fatalf("claim operation RCON job: claim=%+v err=%v", claim, err)
}
ack, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "accepted"})
if err != nil || !ack.Accepted {
t.Fatalf("ack operation RCON job: ack=%+v err=%v", ack, err)
}
input, err := svc.GetSourceRCONExecutionInput(domain.SourceRCONExecutionInputRequest{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt})
if err != nil {
t.Fatalf("read transient operation command: %v", err)
}
if input.Command != "#SetFamePoints 123 \"76561198000000001\"" {
t.Fatalf("unexpected generated RCON command: %q", input.Command)
}
if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "source-rcon.succeeded", AuditSummary: "typed RCON delivered"}}); err != nil {
t.Fatalf("complete operation job: %v", err)
}
reconciled, err := svc.ReconcileSCUMOperation(approved.ID)
if err != nil || reconciled.Status != domain.SCUMWorkflowStepConfirming {
t.Fatalf("expected confirming after delivery before readback: %+v err=%v", reconciled, err)
}
confirmed, err := svc.ConfirmSCUMOperation(approved.ID, domain.SCUMOperationConfirmation{Status: "confirmed", ConfirmedFields: map[string]any{"fame": 123}, ObservedAt: fixedTime.Add(time.Minute)})
if err != nil || confirmed.Status != domain.SCUMWorkflowStepConfirmed || confirmed.CompletedAt.IsZero() {
t.Fatalf("confirm operation=%+v err=%v", confirmed, err)
}
}
func TestSCUMRCONOperationPermissionUnknownAndConfirmationFailure(t *testing.T) {
svc, session, runSession, instance := newSourceRCONFixture(t)
seedSCUMOperationTemplates(t, svc, instance.PluginID)
adminOnly, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.currency.gold.set", PlayerID: "76561198000000002", Payload: map[string]any{"amount": 9}, Reason: "admin-only", IdempotencyKey: "gold-admin-only"})
if err != nil {
t.Fatalf("request admin-only operation: %v", err)
}
if _, err := svc.ApproveSCUMOperationForSession(session, adminOnly.ID); err != ErrForbidden {
t.Fatalf("expected platform-admin approval denial, got %v", err)
}
operation, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.currency.normal.set", PlayerID: "76561198000000002", Payload: map[string]any{"amount": 500}, Reason: "repair balance", IdempotencyKey: "normal-unknown"})
if err != nil {
t.Fatalf("request normal currency operation: %v", err)
}
approved, err := svc.ApproveSCUMOperationForSession(session, operation.ID)
if err != nil {
t.Fatalf("approve normal currency operation: %v", err)
}
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: runSession, Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || claim.Job == nil || claim.Job.JobID != approved.RunJobID {
t.Fatalf("claim normal currency job: claim=%+v err=%v", claim, err)
}
ack, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt})
if err != nil {
t.Fatalf("ack normal currency job: %v", err)
}
if _, err := svc.GetSourceRCONExecutionInput(domain.SourceRCONExecutionInputRequest{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt}); err != nil {
t.Fatalf("consume normal currency command: %v", err)
}
if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt, State: domain.JobStateFailed, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "source-rcon.unknown", AuditSummary: "unknown command state"}}); err != nil {
t.Fatalf("complete unknown operation job: %v", err)
}
unknown, err := svc.ReconcileSCUMOperation(approved.ID)
if err != nil || unknown.Status != domain.SCUMWorkflowStepUnknown {
t.Fatalf("expected unknown terminal state: %+v err=%v", unknown, err)
}
failure, err := svc.ConfirmSCUMOperation(operation.ID, domain.SCUMOperationConfirmation{Status: "failed", SafeSummary: domain.SCUMSafeSummary{Title: "Readback mismatch", Message: "Projection did not match expected currency."}, ObservedAt: time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC)})
if err != nil || failure.Status != domain.SCUMWorkflowStepFailed {
t.Fatalf("expected confirmation failure: %+v err=%v", failure, err)
}
}
func TestSCUMSQLiteMutationOperationSafetyGatesAndDispatchesTypedJob(t *testing.T) {
svc, session, runSession, instance := newSourceRCONFixture(t)
seedSCUMOperationTemplates(t, svc, instance.PluginID)
adminSession := enableSCUMSQLiteMutationOperationSupport(t, svc, instance)
if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: instance.ID, PluginID: instance.PluginID, Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 1, Checksum: "sha256:profile-online", ObservedAt: fixedTime, Rows: []map[string]any{{"gamePlayerId": "76561198000000855", "displayName": "Attribute Tester", "online": true, "855": 100}}}); err != nil {
t.Fatalf("seed online projection: %v", err)
}
operation, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.attribute.855.set", PlayerID: "76561198000000855", Payload: map[string]any{"fieldKey": "855", "before": 100, "after": 150, "safetyWindow": "maintenance-2026-08-10", "backupRef": "snapshot://scum/server-rcon/20260810"}, Reason: "repair attribute 855", IdempotencyKey: "attribute-855-1"})
if err != nil || operation.Status != domain.SCUMWorkflowStepWaiting {
t.Fatalf("request sqlite mutation=%+v err=%v", operation, err)
}
waiting, err := svc.ApproveSCUMOperationForSession(adminSession, operation.ID)
if err != nil || waiting.Status != domain.SCUMWorkflowStepWaiting || waiting.RunJobID != "" || !strings.Contains(waiting.SafeSummary.Title, "离线") {
t.Fatalf("online player should block dispatch: %+v err=%v", waiting, err)
}
if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: instance.ID, PluginID: instance.PluginID, Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 2, Checksum: "sha256:profile-offline", ObservedAt: fixedTime.Add(time.Minute), Rows: []map[string]any{{"gamePlayerId": "76561198000000855", "displayName": "Attribute Tester", "online": false, "855": 100}}}); err != nil {
t.Fatalf("seed offline projection: %v", err)
}
approved, err := svc.ApproveSCUMOperationForSession(adminSession, operation.ID)
if err != nil || approved.Status != domain.SCUMWorkflowStepQueued || approved.RunJobID == "" {
t.Fatalf("approve sqlite mutation=%+v err=%v", approved, err)
}
job, err := svc.store.Jobs().Get(approved.RunJobID)
if err != nil {
t.Fatalf("get sqlite mutation job: %v", err)
}
if job.Capability != domain.JobCapabilityRemoteRunProtectedSQL || job.ExecutionInput.Inputs["fieldKey"] != "855" || job.ExecutionInput.Inputs["before"] != "100" || job.ExecutionInput.Inputs["after"] != "150" || job.ExecutionInput.Inputs["maxRowsAffected"] != "1" {
t.Fatalf("unexpected typed mutation job: %+v", job)
}
serializedOperation, _ := json.Marshal(approved)
serializedJob, _ := json.Marshal(job)
for _, forbidden := range []string{"UPDATE ", "DELETE ", "INSERT ", "SELECT ", "SCUM.db", "/Saved/", "requestText"} {
if strings.Contains(strings.ToUpper(string(serializedOperation)), strings.ToUpper(forbidden)) || strings.Contains(strings.ToUpper(string(serializedJob)), strings.ToUpper(forbidden)) {
t.Fatalf("operation/job persisted raw DB material %q: operation=%s job=%s", forbidden, serializedOperation, serializedJob)
}
}
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: runSession, Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || claim.Job == nil || claim.Job.JobID != approved.RunJobID {
t.Fatalf("claim sqlite mutation job: claim=%+v err=%v", claim, err)
}
ack, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "accepted"})
if err != nil || !ack.Accepted {
t.Fatalf("ack sqlite mutation job: ack=%+v err=%v", ack, err)
}
mutationChecksum := "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
content := mustJSON(t, map[string]any{"outcome": "succeeded", "affectedRows": 1, "mutationChecksum": mutationChecksum, "confirmationRows": []map[string]any{{"playerId": "76561198000000855", "fieldKey": "855", "value": 150}}})
if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "scum.sqlite-mutation.succeeded", Checksum: mutationChecksum, AuditSummary: "typed SCUM DB mutation result", Content: content}}); err != nil {
t.Fatalf("complete sqlite mutation job: %v", err)
}
confirmed, err := svc.ReconcileSCUMOperation(approved.ID)
if err != nil || confirmed.Status != domain.SCUMWorkflowStepConfirmed || confirmed.Confirmation.AffectedRows != 1 || confirmed.Confirmation.MutationChecksum != mutationChecksum {
t.Fatalf("expected confirmed sqlite mutation: %+v err=%v", confirmed, err)
}
}
func TestSCUMSQLiteMutationBlocksMissingSafetyAndStaleBefore(t *testing.T) {
svc, session, _, instance := newSourceRCONFixture(t)
seedSCUMOperationTemplates(t, svc, instance.PluginID)
adminSession := enableSCUMSQLiteMutationOperationSupport(t, svc, instance)
if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: instance.ID, PluginID: instance.PluginID, Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 1, Checksum: "sha256:profile-855", ObservedAt: fixedTime, Rows: []map[string]any{{"gamePlayerId": "steam-855", "displayName": "Guarded", "online": false, "855": 100}}}); err != nil {
t.Fatalf("seed projection: %v", err)
}
missingSafety, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.attribute.855.set", PlayerID: "steam-855", Payload: map[string]any{"fieldKey": "855", "before": 100, "after": 101}, Reason: "missing maintenance", IdempotencyKey: "attribute-855-missing-safety"})
if err != nil {
t.Fatalf("request missing safety mutation: %v", err)
}
waiting, err := svc.ApproveSCUMOperationForSession(adminSession, missingSafety.ID)
if err != nil || waiting.Status != domain.SCUMWorkflowStepWaiting || waiting.RunJobID != "" || !strings.Contains(waiting.SafeSummary.Title, "维护") {
t.Fatalf("expected missing maintenance/backup wait: %+v err=%v", waiting, err)
}
stale, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.attribute.855.set", PlayerID: "steam-855", Payload: map[string]any{"fieldKey": "855", "before": 99, "after": 101, "safetyWindow": "maintenance-2026-08-10", "backupRef": "snapshot://scum/server-rcon/stale"}, Reason: "stale before", IdempotencyKey: "attribute-855-stale-before"})
if err != nil {
t.Fatalf("request stale mutation: %v", err)
}
blocked, err := svc.ApproveSCUMOperationForSession(adminSession, stale.ID)
if err != nil || blocked.Status != domain.SCUMWorkflowStepBlocked || blocked.RunJobID != "" || !strings.Contains(blocked.SafeSummary.Title, "before") {
t.Fatalf("expected stale before block: %+v err=%v", blocked, err)
}
}
func TestSCUMSQLiteMutationResultValidationRejectsOverBoundRows(t *testing.T) {
svc, session, runSession, instance := newSourceRCONFixture(t)
seedSCUMOperationTemplates(t, svc, instance.PluginID)
adminSession := enableSCUMSQLiteMutationOperationSupport(t, svc, instance)
if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: instance.ID, PluginID: instance.PluginID, Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 1, Checksum: "sha256:profile-overbound", ObservedAt: fixedTime, Rows: []map[string]any{{"gamePlayerId": "steam-overbound", "online": false, "855": 10}}}); err != nil {
t.Fatalf("seed projection: %v", err)
}
operation, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.attribute.855.set", PlayerID: "steam-overbound", Payload: map[string]any{"fieldKey": "855", "before": 10, "after": 11, "safetyWindow": "maintenance-2026-08-10", "backupRef": "snapshot://scum/server-rcon/overbound"}, Reason: "overbound test", IdempotencyKey: "attribute-855-overbound"})
if err != nil {
t.Fatalf("request overbound mutation: %v", err)
}
approved, err := svc.ApproveSCUMOperationForSession(adminSession, operation.ID)
if err != nil || approved.RunJobID == "" {
t.Fatalf("approve overbound mutation=%+v err=%v", approved, err)
}
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: runSession, Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || claim.Job == nil {
t.Fatalf("claim overbound job: claim=%+v err=%v", claim, err)
}
ack, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt})
if err != nil {
t.Fatalf("ack overbound job: %v", err)
}
content := mustJSON(t, map[string]any{"outcome": "succeeded", "affectedRows": 2, "mutationChecksum": "sha256:mutation-overbound"})
if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "scum.sqlite-mutation.succeeded", Content: content, AuditSummary: "typed SCUM DB mutation result"}}); err != nil {
t.Fatalf("complete overbound job: %v", err)
}
unknown, err := svc.ReconcileSCUMOperation(approved.ID)
if err != nil || unknown.Status != domain.SCUMWorkflowStepUnknown {
t.Fatalf("expected over-bound rows to become unknown: %+v err=%v", unknown, err)
}
}
func seedSCUMOperationTemplates(t *testing.T, svc *CoreService, pluginID string) {
t.Helper()
plugin, err := svc.store.GamePlugins().Get(pluginID)
if err != nil {
t.Fatal(err)
}
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.game-client.command")
plugin.GameClientBridge.OperationTemplates = []domain.GameClientBridgeOperationTemplateDeclaration{
{Key: "player.fame.set", Title: "Set player fame", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, Kind: domain.GameClientBridgeOperationKindRCON, TransportKey: "rcon", TargetKey: "rcon", PayloadSchemaRef: "schemas/bridge/player-fame-set.payload.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 2048, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresConfirmation: true}},
{Key: "player.currency.normal.set", Title: "Set player normal currency", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, Kind: domain.GameClientBridgeOperationKindRCON, TransportKey: "rcon", TargetKey: "rcon", PayloadSchemaRef: "schemas/bridge/player-currency-set.payload.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 2048, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresConfirmation: true}},
{Key: "player.currency.gold.set", Title: "Set player gold currency", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, Kind: domain.GameClientBridgeOperationKindRCON, TransportKey: "rcon", TargetKey: "rcon", PayloadSchemaRef: "schemas/bridge/player-currency-set.payload.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 2048, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresConfirmation: true}},
}
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update plugin operation templates: %v", err)
}
}
func enableSCUMSQLiteMutationOperationSupport(t *testing.T, svc *CoreService, instance domain.ServerInstance) string {
t.Helper()
adminSession := createServiceUserAndLogin(t, svc, domain.User{ID: "platform-admin-scum", DisplayName: "SCUM Admin", Email: "scum-admin@example.test", Roles: []string{"platform-admin"}, PasswordHash: "secret-password"})
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
t.Fatal(err)
}
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.game-client.maintenance")
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunProtectedSQL)
plugin.RemoteAccess.RunCapabilities = append(plugin.RemoteAccess.RunCapabilities, domain.JobCapabilityRemoteRunProtectedSQL)
plugin.RemoteAccess.DatabaseEngines = append(plugin.RemoteAccess.DatabaseEngines, "sqlite")
plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{Key: "scum-database", Kind: "sqlite", TargetKey: "scum-database", Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}})
plugin.GameClientBridge.OperationTemplates = append(plugin.GameClientBridge.OperationTemplates, domain.GameClientBridgeOperationTemplateDeclaration{Key: "player.attribute.855.set", Title: "Set player attribute 855", Permission: "server.game-client.maintenance", ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, Kind: domain.GameClientBridgeOperationKindSQLiteMutation, TransportKey: "scum-database", TargetKey: "scum-database", PayloadSchemaRef: "schemas/bridge/player-attribute-855-set.payload.schema.json", ResultSchemaRef: "schemas/bridge/player-attribute-855-set.result.schema.json", ConfirmationSchemaRef: "schemas/bridge/player-attribute-855-set.confirmation.schema.json", TimeoutSeconds: 120, MaxPayloadBytes: 4096, MaxRowsAffected: 1, Mutation: domain.GameClientBridgeOperationMutationDeclaration{FieldKey: "855", TableKey: "prisoner", IdentityKey: "user_profile_id", ValueKey: "value", ConfirmationQueryKey: "scum.player.profile", AllowedValueType: "integer", MinValue: 0, MaxValue: 100000}, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresOfflinePlayer: true, RequiresMaintenanceWindow: true, RequiresBeforeValue: true, RequiresConfirmation: true, BackupRequired: true}})
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update SCUM DB mutation plugin: %v", err)
}
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
if err != nil {
t.Fatal(err)
}
endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityRemoteRunProtectedSQL)
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatalf("update SCUM DB mutation endpoint: %v", err)
}
return adminSession
}
func mustJSON(t *testing.T, value any) string {
t.Helper()
encoded, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal test JSON: %v", err)
}
return string(encoded)
}
+803
View File
@@ -0,0 +1,803 @@
package service
import (
"fmt"
"math"
"strconv"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
"browser.local/platform/validator"
)
func (svc *CoreService) ApplySCUMObservationResult(result domain.SCUMObservationResult) (domain.SCUMDataObservation, error) {
result = domain.CopySCUMObservationResult(result)
if strings.TrimSpace(result.ServerInstanceID) == "" {
return domain.SCUMDataObservation{}, validationError("serverInstanceId is required")
}
instance, err := svc.store.ServerInstances().Get(result.ServerInstanceID)
if err != nil {
return domain.SCUMDataObservation{}, err
}
if strings.TrimSpace(result.PluginID) == "" {
result.PluginID = instance.PluginID
}
if result.PluginID != instance.PluginID {
return domain.SCUMDataObservation{}, validationError("pluginId must match server instance")
}
if strings.TrimSpace(result.QueryKey) == "" {
return domain.SCUMDataObservation{}, validationError("queryKey is required")
}
if result.ReceivedAt.IsZero() {
result.ReceivedAt = svc.now()
}
if result.ObservedAt.IsZero() {
result.ObservedAt = result.ReceivedAt
}
if result.Status == "" {
result.Status = domain.SCUMObservationAccepted
}
latest, err := svc.latestSCUMObservation(result.ServerInstanceID, result.PluginID, result.QueryKey)
if err != nil {
return domain.SCUMDataObservation{}, err
}
if result.Status == domain.SCUMObservationAccepted && !latest.ObservedAt.IsZero() && scumObservationOlder(result, latest) {
result.Status = domain.SCUMObservationStale
result.ErrorCode = "older_observation"
result.SafeSummary = domain.SCUMSafeSummary{Title: "旧观察已忽略", Message: "Run 返回的 SCUM.db 观察早于当前本地投影,未覆盖 last-known-good 数据。"}
}
observation := domain.SCUMDataObservation{ID: scumObservationID(result), ServerInstanceID: result.ServerInstanceID, PluginID: result.PluginID, Source: result.Source, QueryKey: result.QueryKey, Sequence: result.Sequence, Checksum: result.Checksum, Status: result.Status, ErrorCode: result.ErrorCode, SafeSummary: result.SafeSummary, ObservedAt: result.ObservedAt, ReceivedAt: result.ReceivedAt}
if err := svc.upsertSCUMObservation(observation); err != nil {
return domain.SCUMDataObservation{}, err
}
if result.Status != domain.SCUMObservationAccepted {
if result.Status == domain.SCUMObservationFailed {
return observation, svc.markSCUMQueryStale(result, "observation_failed")
}
return observation, nil
}
freshness := domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionFresh, ObservationID: observation.ID, Source: observation.Source, QueryKey: observation.QueryKey, Sequence: observation.Sequence, Checksum: observation.Checksum, ObservedAt: observation.ObservedAt, ReceivedAt: observation.ReceivedAt}
if err := svc.applySCUMRows(result.QueryKey, result.ServerInstanceID, result.Rows, freshness); err != nil {
return domain.SCUMDataObservation{}, err
}
return observation, nil
}
func (svc *CoreService) ListSCUMPlayerLiveStatesForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMPlayerLiveState, error) {
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
values, err := svc.store.SCUMPlayerLiveStates().List(filter)
if err != nil {
return nil, err
}
limitSCUMProjectionSlice(&values, filter.Limit)
return values, nil
}
func (svc *CoreService) ListSCUMSquadsForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMSquad, error) {
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
values, err := svc.store.SCUMSquads().List(filter)
if err != nil {
return nil, err
}
limitSCUMProjectionSlice(&values, filter.Limit)
return values, nil
}
func (svc *CoreService) ListSCUMSquadMembersForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMSquadMember, error) {
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
values, err := svc.store.SCUMSquadMembers().List(filter)
if err != nil {
return nil, err
}
limitSCUMProjectionSlice(&values, filter.Limit)
return values, nil
}
func (svc *CoreService) ListSCUMVehiclesForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMVehicle, error) {
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
values, err := svc.store.SCUMVehicles().List(filter)
if err != nil {
return nil, err
}
limitSCUMProjectionSlice(&values, filter.Limit)
return values, nil
}
func (svc *CoreService) ListSCUMFlagsForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMFlag, error) {
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
values, err := svc.store.SCUMFlags().List(filter)
if err != nil {
return nil, err
}
limitSCUMProjectionSlice(&values, filter.Limit)
return values, nil
}
func (svc *CoreService) ListSCUMCurrentPositionsForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMCurrentPosition, error) {
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
values, err := svc.store.SCUMCurrentPositions().List(filter)
if err != nil {
return nil, err
}
limitSCUMProjectionSlice(&values, filter.Limit)
return values, nil
}
func (svc *CoreService) latestSCUMObservation(serverID, pluginID, queryKey string) (domain.SCUMDataObservation, error) {
observations, err := svc.store.SCUMDataObservations().List(domain.SCUMProjectionFilter{ServerInstanceID: serverID, QueryKey: queryKey})
if err != nil {
return domain.SCUMDataObservation{}, err
}
var latest domain.SCUMDataObservation
for _, observation := range observations {
if pluginID != "" && observation.PluginID != pluginID {
continue
}
if latest.ObservedAt.IsZero() || observation.Sequence > latest.Sequence || (observation.Sequence == latest.Sequence && observation.ObservedAt.After(latest.ObservedAt)) {
latest = observation
}
}
return latest, nil
}
func scumObservationOlder(next domain.SCUMObservationResult, latest domain.SCUMDataObservation) bool {
if next.Sequence > 0 && latest.Sequence > 0 && next.Sequence <= latest.Sequence {
return true
}
return !next.ObservedAt.IsZero() && !latest.ObservedAt.IsZero() && next.ObservedAt.Before(latest.ObservedAt)
}
func (svc *CoreService) upsertSCUMObservation(observation domain.SCUMDataObservation) error {
if existing, err := svc.store.SCUMDataObservations().Get(observation.ID); err == nil {
existing.Status = observation.Status
existing.ErrorCode = observation.ErrorCode
existing.SafeSummary = observation.SafeSummary
existing.ReceivedAt = observation.ReceivedAt
return svc.store.SCUMDataObservations().Update(existing)
} else if err != repo.ErrNotFound {
return err
}
return svc.store.SCUMDataObservations().Create(observation)
}
func (svc *CoreService) applySCUMRows(queryKey, serverID string, rows []map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
lower := strings.ToLower(queryKey)
if strings.Contains(lower, "player") || strings.Contains(lower, "profile") || strings.Contains(lower, "economy") {
for _, row := range rows {
if err := svc.applySCUMPlayerRow(serverID, row, freshness); err != nil {
return err
}
}
}
if strings.Contains(lower, "squad-member") || strings.Contains(lower, "squad.member") || strings.Contains(lower, "member") {
for _, row := range rows {
if err := svc.applySCUMSquadMemberRow(serverID, row, freshness); err != nil {
return err
}
}
} else if strings.Contains(lower, "squad") {
for _, row := range rows {
if err := svc.applySCUMSquadRow(serverID, row, freshness); err != nil {
return err
}
}
}
if strings.Contains(lower, "vehicle") {
for _, row := range rows {
if err := svc.applySCUMVehicleRow(serverID, row, freshness); err != nil {
return err
}
}
}
if strings.Contains(lower, "flag") {
for _, row := range rows {
if err := svc.applySCUMFlagRow(serverID, row, freshness); err != nil {
return err
}
}
}
if strings.Contains(lower, "position") || strings.Contains(lower, "coordinate") {
for _, row := range rows {
if err := svc.applySCUMPositionRow(serverID, row, freshness); err != nil {
return err
}
}
}
return nil
}
func (svc *CoreService) applySCUMPlayerRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
gamePlayerID := firstString(row, "gamePlayerId", "playerId", "steamId", "steam_id")
profileID := firstString(row, "userProfileId", "user_profile_id", "profileId")
steamID := firstString(row, "steamId", "steam_id")
name := firstString(row, "displayName", "name", "playerName")
if gamePlayerID == "" && steamID != "" {
gamePlayerID = steamID
}
if gamePlayerID == "" && profileID == "" {
return nil
}
playerRecordID := ""
if gamePlayerID != "" {
playerRecordID = gamePlayerRecordID(serverID, gamePlayerID)
if err := svc.upsertSCUMGamePlayer(serverID, playerRecordID, gamePlayerID, name, freshness.ObservedAt); err != nil {
return err
}
}
idSource := gamePlayerID
if idSource == "" {
idSource = "profile-" + profileID
}
id := scumProjectionID("player-live", serverID, idSource)
state, err := svc.store.SCUMPlayerLiveStates().Get(id)
if err == repo.ErrNotFound {
state = domain.SCUMPlayerLiveState{ID: id, ServerInstanceID: serverID, GamePlayerRecordID: playerRecordID, GamePlayerID: gamePlayerID, UserProfileID: profileID, SteamID: steamID, DisplayName: name, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()}
} else if err != nil {
return err
}
if isProjectionOlder(freshness, state.Freshness) {
return nil
}
state.GamePlayerRecordID = coalesceString(playerRecordID, state.GamePlayerRecordID)
state.GamePlayerID = coalesceString(gamePlayerID, state.GamePlayerID)
state.UserProfileID = coalesceString(profileID, state.UserProfileID)
state.SteamID = coalesceString(steamID, state.SteamID)
state.DisplayName = coalesceString(name, state.DisplayName)
state.SquadID = coalesceString(firstString(row, "squadId", "squad_id"), state.SquadID)
state.SquadName = coalesceString(firstString(row, "squadName", "squad_name"), state.SquadName)
if value, ok := firstFloat(row, "famePoints", "fame_points", "fame"); ok {
state.FamePoints = value
}
if value, ok := firstFloat(row, "normalBalance", "currencyNormal", "money", "normal_balance"); ok {
state.NormalBalance = value
}
if value, ok := firstFloat(row, "goldBalance", "currencyGold", "gold", "gold_balance"); ok {
state.GoldBalance = value
}
if value, ok := firstBool(row, "online", "isOnline"); ok {
state.Online = value
}
state.LastLoginAt = coalesceTime(firstTime(row, "lastLoginAt", "last_login_at"), state.LastLoginAt)
state.LastLogoutAt = coalesceTime(firstTime(row, "lastLogoutAt", "last_logout_at"), state.LastLogoutAt)
state.LastSaveTime = coalesceTime(firstTime(row, "lastSaveTime", "last_save_time"), state.LastSaveTime)
if position, ok := scumPositionFromRow(serverID, domain.SCUMProjectionSubjectPlayer, gamePlayerID, row, freshness); ok {
position.GamePlayerRecordID = playerRecordID
position.GamePlayerID = gamePlayerID
state.Position = position
if err := svc.upsertSCUMPosition(position); err != nil {
return err
}
}
state.UnknownFields = unknownRowFields(row, "gamePlayerId", "playerId", "steamId", "steam_id", "userProfileId", "user_profile_id", "profileId", "displayName", "name", "playerName", "squadId", "squad_id", "squadName", "squad_name", "famePoints", "fame_points", "fame", "normalBalance", "currencyNormal", "money", "normal_balance", "goldBalance", "currencyGold", "gold", "gold_balance", "online", "isOnline", "lastLoginAt", "last_login_at", "lastLogoutAt", "last_logout_at", "lastSaveTime", "last_save_time", "x", "y", "z", "worldX", "worldY", "worldZ", "mapId", "mapVersion")
state.Freshness = freshness
state.UpdatedAt = svc.now()
if err == repo.ErrNotFound {
return svc.store.SCUMPlayerLiveStates().Create(state)
}
return svc.store.SCUMPlayerLiveStates().Update(state)
}
func (svc *CoreService) applySCUMSquadRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
squadID := firstString(row, "squadId", "squad_id", "id")
if squadID == "" {
return nil
}
id := scumProjectionID("squad", serverID, squadID)
value, err := svc.store.SCUMSquads().Get(id)
if err == repo.ErrNotFound {
value = domain.SCUMSquad{ID: id, ServerInstanceID: serverID, SquadID: squadID, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()}
} else if err != nil {
return err
}
if isProjectionOlder(freshness, value.Freshness) {
return nil
}
value.Name = coalesceString(firstString(row, "name", "squadName", "squad_name"), value.Name)
value.LeaderProfileID = coalesceString(firstString(row, "leaderProfileId", "leader_profile_id"), value.LeaderProfileID)
value.LeaderPlayerID = coalesceString(firstString(row, "leaderPlayerId", "leader_player_id", "leaderSteamId"), value.LeaderPlayerID)
if memberCount, ok := firstInt(row, "memberCount", "member_count"); ok {
value.MemberCount = memberCount
}
if score, ok := firstFloat(row, "score", "fame", "points"); ok {
value.Score = score
}
value.UnknownFields = unknownRowFields(row, "squadId", "squad_id", "id", "name", "squadName", "squad_name", "leaderProfileId", "leader_profile_id", "leaderPlayerId", "leader_player_id", "leaderSteamId", "memberCount", "member_count", "score", "fame", "points")
value.Freshness = freshness
value.UpdatedAt = svc.now()
if err == repo.ErrNotFound {
return svc.store.SCUMSquads().Create(value)
}
return svc.store.SCUMSquads().Update(value)
}
func (svc *CoreService) applySCUMSquadMemberRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
squadID := firstString(row, "squadId", "squad_id")
profileID := firstString(row, "userProfileId", "user_profile_id", "profileId")
gamePlayerID := firstString(row, "gamePlayerId", "playerId", "steamId", "steam_id")
if squadID == "" || (profileID == "" && gamePlayerID == "") {
return nil
}
playerRecordID := ""
if gamePlayerID != "" {
playerRecordID = gamePlayerRecordID(serverID, gamePlayerID)
if err := svc.upsertSCUMGamePlayer(serverID, playerRecordID, gamePlayerID, firstString(row, "displayName", "name", "playerName"), freshness.ObservedAt); err != nil {
return err
}
}
id := scumProjectionID("squad-member", serverID, squadID+"/"+coalesceString(profileID, gamePlayerID))
value, err := svc.store.SCUMSquadMembers().Get(id)
if err == repo.ErrNotFound {
value = domain.SCUMSquadMember{ID: id, ServerInstanceID: serverID, SquadID: squadID, UserProfileID: profileID, GamePlayerRecordID: playerRecordID, GamePlayerID: gamePlayerID, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()}
} else if err != nil {
return err
}
if isProjectionOlder(freshness, value.Freshness) {
return nil
}
value.UserProfileID = coalesceString(profileID, value.UserProfileID)
value.GamePlayerRecordID = coalesceString(playerRecordID, value.GamePlayerRecordID)
value.GamePlayerID = coalesceString(gamePlayerID, value.GamePlayerID)
value.SteamID = coalesceString(firstString(row, "steamId", "steam_id"), value.SteamID)
value.DisplayName = coalesceString(firstString(row, "displayName", "name", "playerName"), value.DisplayName)
value.Rank = coalesceString(firstString(row, "rank", "role"), value.Rank)
if isLeader, ok := firstBool(row, "isLeader", "leader"); ok {
value.IsLeader = isLeader
}
value.JoinedAt = coalesceTime(firstTime(row, "joinedAt", "joined_at"), value.JoinedAt)
value.UnknownFields = unknownRowFields(row, "squadId", "squad_id", "userProfileId", "user_profile_id", "profileId", "gamePlayerId", "playerId", "steamId", "steam_id", "displayName", "name", "playerName", "rank", "role", "isLeader", "leader", "joinedAt", "joined_at")
value.Freshness = freshness
value.UpdatedAt = svc.now()
if err == repo.ErrNotFound {
return svc.store.SCUMSquadMembers().Create(value)
}
return svc.store.SCUMSquadMembers().Update(value)
}
func (svc *CoreService) applySCUMVehicleRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
vehicleID := firstString(row, "vehicleId", "vehicle_id", "id")
entityID := firstString(row, "entityId", "entity_id")
if vehicleID == "" && entityID != "" {
vehicleID = entityID
}
if vehicleID == "" {
return nil
}
id := scumProjectionID("vehicle", serverID, vehicleID)
value, err := svc.store.SCUMVehicles().Get(id)
if err == repo.ErrNotFound {
value = domain.SCUMVehicle{ID: id, ServerInstanceID: serverID, VehicleID: vehicleID, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()}
} else if err != nil {
return err
}
if isProjectionOlder(freshness, value.Freshness) {
return nil
}
value.EntityID = coalesceString(entityID, value.EntityID)
value.ClassName = coalesceString(firstString(row, "className", "class", "type"), value.ClassName)
value.Label = coalesceString(firstString(row, "label", "vehicleName", "name"), value.Label)
if value.Label == "" {
value.Label = coalesceString(value.ClassName, "Unknown vehicle")
}
value.OwnerProfileID = coalesceString(firstString(row, "ownerProfileId", "owner_profile_id", "userProfileId", "user_profile_id"), value.OwnerProfileID)
value.OwnerPlayerID = coalesceString(firstString(row, "ownerPlayerId", "owner_player_id", "steamId", "steam_id"), value.OwnerPlayerID)
value.SquadID = coalesceString(firstString(row, "squadId", "squad_id"), value.SquadID)
if position, ok := scumPositionFromRow(serverID, domain.SCUMProjectionSubjectVehicle, vehicleID, row, freshness); ok {
position.VehicleID = vehicleID
position.EntityID = entityID
value.Position = position
if err := svc.upsertSCUMPosition(position); err != nil {
return err
}
}
value.UnknownFields = unknownRowFields(row, "vehicleId", "vehicle_id", "id", "entityId", "entity_id", "className", "class", "type", "label", "vehicleName", "name", "ownerProfileId", "owner_profile_id", "userProfileId", "user_profile_id", "ownerPlayerId", "owner_player_id", "steamId", "steam_id", "squadId", "squad_id", "x", "y", "z", "worldX", "worldY", "worldZ", "mapId", "mapVersion")
value.Freshness = freshness
value.UpdatedAt = svc.now()
if err == repo.ErrNotFound {
return svc.store.SCUMVehicles().Create(value)
}
return svc.store.SCUMVehicles().Update(value)
}
func (svc *CoreService) applySCUMFlagRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
flagID := firstString(row, "flagId", "flag_id", "baseElementId", "base_element_id", "id")
entityID := firstString(row, "entityId", "entity_id")
if flagID == "" && entityID != "" {
flagID = entityID
}
if flagID == "" {
return nil
}
id := scumProjectionID("flag", serverID, flagID)
value, err := svc.store.SCUMFlags().Get(id)
if err == repo.ErrNotFound {
value = domain.SCUMFlag{ID: id, ServerInstanceID: serverID, FlagID: flagID, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()}
} else if err != nil {
return err
}
if isProjectionOlder(freshness, value.Freshness) {
return nil
}
value.EntityID = coalesceString(entityID, value.EntityID)
value.OwnerProfileID = coalesceString(firstString(row, "ownerProfileId", "owner_profile_id", "userProfileId", "user_profile_id"), value.OwnerProfileID)
value.OwnerPlayerID = coalesceString(firstString(row, "ownerPlayerId", "owner_player_id", "steamId", "steam_id"), value.OwnerPlayerID)
value.OwnerSquadID = coalesceString(firstString(row, "ownerSquadId", "owner_squad_id", "squadId", "squad_id"), value.OwnerSquadID)
value.OwnerSquadName = coalesceString(firstString(row, "ownerSquadName", "owner_squad_name", "squadName", "squad_name"), value.OwnerSquadName)
value.OwnershipConfidence = coalesceString(firstString(row, "ownershipConfidence", "ownership_confidence"), value.OwnershipConfidence)
if value.OwnershipConfidence == "" {
value.OwnershipConfidence = "unknown"
}
if position, ok := scumPositionFromRow(serverID, domain.SCUMProjectionSubjectFlag, flagID, row, freshness); ok {
position.EntityID = entityID
value.Position = position
if err := svc.upsertSCUMPosition(position); err != nil {
return err
}
}
value.UnknownFields = unknownRowFields(row, "flagId", "flag_id", "baseElementId", "base_element_id", "id", "entityId", "entity_id", "ownerProfileId", "owner_profile_id", "userProfileId", "user_profile_id", "ownerPlayerId", "owner_player_id", "steamId", "steam_id", "ownerSquadId", "owner_squad_id", "squadId", "squad_id", "ownerSquadName", "owner_squad_name", "squadName", "squad_name", "ownershipConfidence", "ownership_confidence", "x", "y", "z", "worldX", "worldY", "worldZ", "mapId", "mapVersion")
value.Freshness = freshness
value.UpdatedAt = svc.now()
if err == repo.ErrNotFound {
return svc.store.SCUMFlags().Create(value)
}
return svc.store.SCUMFlags().Update(value)
}
func (svc *CoreService) applySCUMPositionRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
subjectType := domain.SCUMProjectionSubject(firstString(row, "subjectType", "subject_type"))
if subjectType == "" {
if firstString(row, "vehicleId", "vehicle_id") != "" {
subjectType = domain.SCUMProjectionSubjectVehicle
} else {
subjectType = domain.SCUMProjectionSubjectPlayer
}
}
subjectID := firstString(row, "subjectId", "subject_id", "gamePlayerId", "playerId", "vehicleId", "flagId", "entityId", "id")
position, ok := scumPositionFromRow(serverID, subjectType, subjectID, row, freshness)
if !ok {
return nil
}
position.GamePlayerID = firstString(row, "gamePlayerId", "playerId", "steamId", "steam_id")
if position.GamePlayerID != "" {
position.GamePlayerRecordID = gamePlayerRecordID(serverID, position.GamePlayerID)
}
position.VehicleID = firstString(row, "vehicleId", "vehicle_id")
position.EntityID = firstString(row, "entityId", "entity_id")
return svc.upsertSCUMPosition(position)
}
func (svc *CoreService) upsertSCUMGamePlayer(serverID, recordID, gamePlayerID, displayName string, observedAt time.Time) error {
if gamePlayerID == "" || recordID == "" {
return nil
}
if observedAt.IsZero() {
observedAt = svc.now()
}
player, err := svc.store.GamePlayers().Get(recordID)
if err == repo.ErrNotFound {
return svc.store.GamePlayers().Create(domain.GamePlayer{ID: recordID, ServerInstanceID: serverID, GamePlayerID: gamePlayerID, DisplayName: displayName, FirstSeenAt: observedAt, LastSeenAt: observedAt, LastEventAt: observedAt, CreatedAt: svc.now(), UpdatedAt: svc.now()})
}
if err != nil {
return err
}
if observedAt.Before(player.LastEventAt) {
return nil
}
player.DisplayName = coalesceString(displayName, player.DisplayName)
player.LastSeenAt = maxTime(player.LastSeenAt, observedAt)
player.LastEventAt = observedAt
player.UpdatedAt = svc.now()
return svc.store.GamePlayers().Update(player)
}
func (svc *CoreService) projectSCUMLoginLiveState(player domain.GamePlayer, batch domain.LogBatchIngest, entry domain.LogEntry, observedAt time.Time, online bool, reason string) error {
if player.ID == "" || player.GamePlayerID == "" {
return nil
}
freshness := domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionFresh, ObservationID: entryID(batch.LogStreamID, entry.Seq), Source: "login-log", QueryKey: strings.TrimSpace(entry.Fields["eventType"]), Sequence: entry.Seq, Checksum: validator.LogLineChecksum(entry.Line), ObservedAt: observedAt, ReceivedAt: svc.now()}
id := scumProjectionID("player-live", player.ServerInstanceID, player.GamePlayerID)
state, err := svc.store.SCUMPlayerLiveStates().Get(id)
if err == repo.ErrNotFound {
state = domain.SCUMPlayerLiveState{ID: id, ServerInstanceID: player.ServerInstanceID, GamePlayerRecordID: player.ID, GamePlayerID: player.GamePlayerID, DisplayName: player.DisplayName, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()}
} else if err != nil {
return err
}
if isProjectionOlder(freshness, state.Freshness) {
return nil
}
state.GamePlayerRecordID = player.ID
state.GamePlayerID = player.GamePlayerID
state.DisplayName = player.DisplayName
state.Online = online
if online {
state.LastLoginAt = observedAt
} else {
state.LastLogoutAt = observedAt
}
state.Freshness = freshness
if reason != "" {
state.UnknownFields = domain.CopyGameClientBridgePayload(map[string]any{"lastLogoutReason": bounded(reason, 80)})
}
state.UpdatedAt = svc.now()
if err == repo.ErrNotFound {
return svc.store.SCUMPlayerLiveStates().Create(state)
}
return svc.store.SCUMPlayerLiveStates().Update(state)
}
func (svc *CoreService) upsertSCUMPosition(position domain.SCUMCurrentPosition) error {
existing, err := svc.store.SCUMCurrentPositions().Get(position.ID)
if err == repo.ErrNotFound {
position.CreatedAt = svc.now()
position.UpdatedAt = svc.now()
return svc.store.SCUMCurrentPositions().Create(position)
}
if err != nil {
return err
}
if isProjectionOlder(position.Freshness, existing.Freshness) {
return nil
}
position.CreatedAt = existing.CreatedAt
position.UpdatedAt = svc.now()
return svc.store.SCUMCurrentPositions().Update(position)
}
func (svc *CoreService) markSCUMQueryStale(result domain.SCUMObservationResult, reason string) error {
freshness := domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionStale, ObservationID: scumObservationID(result), Source: result.Source, QueryKey: result.QueryKey, Sequence: result.Sequence, Checksum: result.Checksum, StaleReason: reason, ObservedAt: result.ObservedAt, ReceivedAt: result.ReceivedAt}
lower := strings.ToLower(result.QueryKey)
if strings.Contains(lower, "player") || strings.Contains(lower, "profile") || strings.Contains(lower, "economy") {
values, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID})
if err != nil {
return err
}
for _, value := range values {
if !isProjectionOlder(freshness, value.Freshness) {
value.Freshness = freshness
value.UpdatedAt = svc.now()
if err := svc.store.SCUMPlayerLiveStates().Update(value); err != nil {
return err
}
}
}
}
if strings.Contains(lower, "squad") {
values, err := svc.store.SCUMSquads().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID})
if err != nil {
return err
}
for _, value := range values {
if !isProjectionOlder(freshness, value.Freshness) {
value.Freshness = freshness
value.UpdatedAt = svc.now()
if err := svc.store.SCUMSquads().Update(value); err != nil {
return err
}
}
}
}
if strings.Contains(lower, "vehicle") {
values, err := svc.store.SCUMVehicles().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID})
if err != nil {
return err
}
for _, value := range values {
if !isProjectionOlder(freshness, value.Freshness) {
value.Freshness = freshness
value.UpdatedAt = svc.now()
if err := svc.store.SCUMVehicles().Update(value); err != nil {
return err
}
}
}
}
if strings.Contains(lower, "flag") {
values, err := svc.store.SCUMFlags().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID})
if err != nil {
return err
}
for _, value := range values {
if !isProjectionOlder(freshness, value.Freshness) {
value.Freshness = freshness
value.UpdatedAt = svc.now()
if err := svc.store.SCUMFlags().Update(value); err != nil {
return err
}
}
}
}
return nil
}
func scumPositionFromRow(serverID string, subjectType domain.SCUMProjectionSubject, subjectID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) (domain.SCUMCurrentPosition, bool) {
x, hasX := firstFloat(row, "x", "worldX", "world_x", "locationX")
y, hasY := firstFloat(row, "y", "worldY", "world_y", "locationY")
z, hasZ := firstFloat(row, "z", "worldZ", "world_z", "locationZ")
if !hasX || !hasY {
return domain.SCUMCurrentPosition{}, false
}
if subjectID == "" {
return domain.SCUMCurrentPosition{}, false
}
position := domain.SCUMCurrentPosition{ID: scumProjectionID("position-"+string(subjectType), serverID, subjectID), ServerInstanceID: serverID, SubjectType: subjectType, SubjectID: subjectID, MapID: coalesceString(firstString(row, "mapId", "map_id"), domain.SCUMMapTrajectoryMapID), MapVersion: coalesceString(firstString(row, "mapVersion", "map_version"), "0.9"), X: x, Y: y, HasCoordinates: true, LastSaveTime: firstTime(row, "lastSaveTime", "last_save_time"), Freshness: freshness}
if hasZ && !math.IsNaN(z) {
position.Z = z
}
return position, true
}
func isProjectionOlder(next, current domain.SCUMProjectionFreshnessState) bool {
if current.Status == "" || current.Status == domain.SCUMProjectionUnknown {
return false
}
if next.Source == current.Source && next.QueryKey == current.QueryKey && next.Sequence > 0 && current.Sequence > 0 && next.Sequence < current.Sequence {
return true
}
return !next.ObservedAt.IsZero() && !current.ObservedAt.IsZero() && next.ObservedAt.Before(current.ObservedAt)
}
func scumObservationID(result domain.SCUMObservationResult) string {
seed := fmt.Sprintf("%s/%s/%s/%d/%s", result.ServerInstanceID, result.PluginID, result.QueryKey, result.Sequence, result.Checksum)
if result.Checksum == "" {
seed = fmt.Sprintf("%s/%s/%s/%d/%s", result.ServerInstanceID, result.PluginID, result.QueryKey, result.Sequence, result.ObservedAt.Format(time.RFC3339Nano))
}
return "scum-observation-" + fingerprintID(result.ServerInstanceID, seed)
}
func scumProjectionID(kind, serverID, subject string) string {
return "scum-" + kind + "-" + fingerprintID(serverID, subject)
}
func firstString(row map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := row[key]; ok {
switch typed := value.(type) {
case string:
if trimmed := strings.TrimSpace(typed); trimmed != "" {
return trimmed
}
case fmt.Stringer:
if trimmed := strings.TrimSpace(typed.String()); trimmed != "" {
return trimmed
}
case int, int64, uint64, float64:
return fmt.Sprint(typed)
}
}
}
return ""
}
func firstFloat(row map[string]any, keys ...string) (float64, bool) {
for _, key := range keys {
if value, ok := row[key]; ok {
switch typed := value.(type) {
case float64:
return typed, true
case float32:
return float64(typed), true
case int:
return float64(typed), true
case int64:
return float64(typed), true
case uint64:
return float64(typed), true
case string:
parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
if err == nil {
return parsed, true
}
}
}
}
return 0, false
}
func firstInt(row map[string]any, keys ...string) (int, bool) {
value, ok := firstFloat(row, keys...)
if !ok {
return 0, false
}
return int(value), true
}
func firstBool(row map[string]any, keys ...string) (bool, bool) {
for _, key := range keys {
if value, ok := row[key]; ok {
switch typed := value.(type) {
case bool:
return typed, true
case string:
parsed, err := strconv.ParseBool(strings.TrimSpace(typed))
if err == nil {
return parsed, true
}
case int:
return typed != 0, true
case int64:
return typed != 0, true
case float64:
return typed != 0, true
}
}
}
return false, false
}
func firstTime(row map[string]any, keys ...string) time.Time {
for _, key := range keys {
if value, ok := row[key]; ok {
switch typed := value.(type) {
case time.Time:
return typed
case string:
trimmed := strings.TrimSpace(typed)
if trimmed == "" {
continue
}
if parsed, err := time.Parse(time.RFC3339Nano, trimmed); err == nil {
return parsed
}
if parsed, err := time.Parse("2006-01-02 15:04:05", trimmed); err == nil {
return parsed.UTC()
}
case int64:
return time.Unix(typed, 0).UTC()
case float64:
return time.Unix(int64(typed), 0).UTC()
}
}
}
return time.Time{}
}
func unknownRowFields(row map[string]any, known ...string) map[string]any {
knownSet := map[string]struct{}{}
for _, key := range known {
knownSet[key] = struct{}{}
}
unknown := map[string]any{}
for key, value := range row {
if _, ok := knownSet[key]; ok {
continue
}
unknown[key] = value
}
if len(unknown) == 0 {
return nil
}
return domain.CopyGameClientBridgePayload(unknown)
}
func coalesceString(next, current string) string {
if strings.TrimSpace(next) != "" {
return strings.TrimSpace(next)
}
return current
}
func coalesceTime(next, current time.Time) time.Time {
if !next.IsZero() {
return next
}
return current
}
func limitSCUMProjectionSlice[T any](values *[]T, limit int) {
if limit > 0 && len(*values) > limit {
*values = (*values)[:limit]
}
}
+110
View File
@@ -0,0 +1,110 @@
package service
import (
"testing"
"time"
"browser.local/platform/domain"
)
func TestSCUMObservationProjectsRealRowsAndSeparatesProfileFromSteamID(t *testing.T) {
svc, _ := newRegisteredLogIngestService(t)
observed := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC)
observation, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{
ServerInstanceID: "server-1",
PluginID: "server.scum",
Source: "run.sqlite.read",
QueryKey: "scum.player.profile",
Sequence: 10,
Checksum: "sha256:profile-10",
ObservedAt: observed,
Rows: []map[string]any{{
"gamePlayerId": "steam-1",
"userProfileId": "profile-99",
"steamId": "steam-1",
"displayName": "Moon",
"squadId": "squad-1",
"famePoints": 42,
"normalBalance": 500.0,
"goldBalance": 7.0,
"x": 100,
"y": 200,
"z": 30,
"lastSaveTime": observed.Add(-time.Minute).Format(time.RFC3339),
"future_column": "preserved",
}},
})
if err != nil || observation.Status != domain.SCUMObservationAccepted {
t.Fatalf("apply observation=%+v err=%v", observation, err)
}
player, err := svc.store.GamePlayers().Get(gamePlayerRecordID("server-1", "steam-1"))
if err != nil || player.DisplayName != "Moon" {
t.Fatalf("expected game player from real row: player=%+v err=%v", player, err)
}
states, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", UserProfileID: "profile-99"})
if err != nil || len(states) != 1 {
t.Fatalf("states=%+v err=%v", states, err)
}
state := states[0]
if state.GamePlayerID != "steam-1" || state.UserProfileID != "profile-99" || state.SteamID != "steam-1" || state.NormalBalance != 500 || state.Online {
t.Fatalf("identity/economy projection mixed IDs or inferred online incorrectly: %+v", state)
}
if !state.Position.HasCoordinates || state.Position.X != 100 || state.Position.Y != 200 || state.UnknownFields["future_column"] != "preserved" {
t.Fatalf("position/unknown fields not projected safely: %+v", state)
}
stale, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-1", PluginID: "server.scum", Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 9, Checksum: "sha256:profile-9", ObservedAt: observed.Add(-time.Hour), Rows: []map[string]any{{"gamePlayerId": "steam-1", "userProfileId": "profile-99", "displayName": "Old", "normalBalance": 9999}}})
if err != nil || stale.Status != domain.SCUMObservationStale || stale.ErrorCode != "older_observation" {
t.Fatalf("expected older observation stale, got %+v err=%v", stale, err)
}
again, err := svc.store.SCUMPlayerLiveStates().Get(state.ID)
if err != nil || again.DisplayName != "Moon" || again.NormalBalance != 500 {
t.Fatalf("older observation overwrote last-known-good: %+v err=%v", again, err)
}
}
func TestSCUMFailedObservationMarksStaleWithoutOverwritingProjection(t *testing.T) {
svc, _ := newRegisteredLogIngestService(t)
observed := time.Date(2026, 8, 10, 10, 0, 0, 0, time.UTC)
if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-1", PluginID: "server.scum", Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 1, Checksum: "sha256:ok", ObservedAt: observed, Rows: []map[string]any{{"gamePlayerId": "steam-2", "userProfileId": "profile-2", "displayName": "Nova", "normalBalance": 125}}}); err != nil {
t.Fatalf("apply initial observation: %v", err)
}
failed, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-1", PluginID: "server.scum", Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 2, Checksum: "sha256:failed", Status: domain.SCUMObservationFailed, ErrorCode: "sqlite_busy", ObservedAt: observed.Add(time.Minute)})
if err != nil || failed.Status != domain.SCUMObservationFailed {
t.Fatalf("failed observation=%+v err=%v", failed, err)
}
states, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", GamePlayerID: "steam-2"})
if err != nil || len(states) != 1 {
t.Fatalf("states=%+v err=%v", states, err)
}
if states[0].NormalBalance != 125 || states[0].Freshness.Status != domain.SCUMProjectionStale || states[0].Freshness.StaleReason != "observation_failed" {
t.Fatalf("failed query did not preserve values and mark stale: %+v", states[0])
}
}
func TestSCUMLoginLogsProjectLiveStateAndDatabaseSaveTimeDoesNotProveOnline(t *testing.T) {
svc, token := newRegisteredLogIngestService(t)
createLogStreamFixture(t, svc)
base := time.Date(2026, 8, 10, 11, 0, 0, 0, time.UTC)
login := gamePlayerBatch(t, token, 1, []domain.LogEntry{{Seq: 1, Timestamp: base, Line: "login accepted", Fields: map[string]string{"eventType": "scum.login", "playerId": "steam-3", "playerName": "Comet", "sessionId": "session-3", "outcome": "accepted"}}})
if _, err := svc.IngestLogBatch(login); err != nil {
t.Fatalf("ingest login: %v", err)
}
states, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", GamePlayerID: "steam-3"})
if err != nil || len(states) != 1 || !states[0].Online {
t.Fatalf("login did not mark live state online: states=%+v err=%v", states, err)
}
logout := gamePlayerBatch(t, token, 2, []domain.LogEntry{{Seq: 2, Timestamp: base.Add(time.Minute), Line: "logout", Fields: map[string]string{"eventType": "scum.logout", "playerId": "steam-3", "playerName": "Comet", "sessionId": "session-3", "reason": "disconnect"}}})
if _, err := svc.IngestLogBatch(logout); err != nil {
t.Fatalf("ingest logout: %v", err)
}
if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-1", PluginID: "server.scum", Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 3, Checksum: "sha256:save-time", ObservedAt: base.Add(2 * time.Minute), Rows: []map[string]any{{"gamePlayerId": "steam-3", "userProfileId": "profile-3", "displayName": "Comet", "lastSaveTime": base.Add(90 * time.Second).Format(time.RFC3339)}}}); err != nil {
t.Fatalf("apply save-time observation: %v", err)
}
states, err = svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", GamePlayerID: "steam-3"})
if err != nil || len(states) != 1 {
t.Fatalf("states=%+v err=%v", states, err)
}
if states[0].Online || states[0].LastSaveTime.IsZero() {
t.Fatalf("last_save_time was incorrectly treated as online proof: %+v", states[0])
}
}
+393
View File
@@ -0,0 +1,393 @@
package service
import (
"fmt"
"sort"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
)
type scumWorkflowTemplateDefinition struct {
Key string
Title string
Steps []scumWorkflowStepDefinition
}
type scumWorkflowStepDefinition struct {
Key string
DependsOn []string
OperationKey string
QueryTemplateKey string
Capability string
TargetKey string
MutatesState bool
MaxAttempts int
Summary string
}
func (svc *CoreService) CreateSCUMWorkflowForSession(sessionID, serverID string, request domain.SCUMWorkflowInstance) (domain.SCUMWorkflowInstance, error) {
request = domain.CopySCUMWorkflowInstance(request)
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return domain.SCUMWorkflowInstance{}, err
}
if err := svc.authorizeServerLifecycle(sessionID, serverID); err != nil {
return domain.SCUMWorkflowInstance{}, err
}
instance, err := svc.store.ServerInstances().Get(serverID)
if err != nil {
return domain.SCUMWorkflowInstance{}, err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return domain.SCUMWorkflowInstance{}, err
}
template, ok := scumWorkflowTemplates()[request.TemplateKey]
if !ok {
return domain.SCUMWorkflowInstance{}, validationError("SCUM workflow template is not declared")
}
if strings.TrimSpace(request.IdempotencyKey) == "" || len(request.IdempotencyKey) > 120 {
return domain.SCUMWorkflowInstance{}, validationError("workflow idempotency key is required")
}
if existing, err := svc.store.SCUMWorkflowInstances().List(domain.SCUMWorkflowInstanceFilter{ServerInstanceID: serverID, IdempotencyKey: request.IdempotencyKey}); err == nil && len(existing) > 0 {
return domain.CopySCUMWorkflowInstance(existing[0]), nil
} else if err != nil {
return domain.SCUMWorkflowInstance{}, err
}
stamp := svc.now()
workflow := domain.SCUMWorkflowInstance{ID: "scum-workflow-" + fingerprintID(serverID, request.IdempotencyKey), ServerInstanceID: serverID, PluginID: plugin.ID, TemplateKey: template.Key, RequestedBy: user.ID, IdempotencyKey: request.IdempotencyKey, Status: domain.SCUMWorkflowQueued, Input: domain.CopyGameClientBridgePayload(request.Input), SafeSummary: domain.SCUMSafeSummary{Title: template.Title, Message: "SCUM workflow queued with typed steps and safe summaries."}, CreatedAt: stamp, UpdatedAt: stamp}
if err := svc.store.SCUMWorkflowInstances().Create(workflow); err != nil {
return domain.SCUMWorkflowInstance{}, err
}
for index, step := range template.Steps {
maxAttempts := step.MaxAttempts
if maxAttempts == 0 {
maxAttempts = 1
}
record := domain.SCUMWorkflowStep{ID: fmt.Sprintf("%s.step.%02d.%s", workflow.ID, index+1, step.Key), WorkflowID: workflow.ID, ServerInstanceID: serverID, StepKey: step.Key, DependsOn: domain.CopyStringSlice(step.DependsOn), Status: domain.SCUMWorkflowStepQueued, OperationKey: step.OperationKey, QueryTemplateKey: step.QueryTemplateKey, Capability: step.Capability, TargetKey: step.TargetKey, MaxAttempts: maxAttempts, MutatesState: step.MutatesState, SafeSummary: domain.SCUMSafeSummary{Title: step.Key, Message: step.Summary}, CreatedAt: stamp, UpdatedAt: stamp}
if err := svc.store.SCUMWorkflowSteps().Create(record); err != nil {
return domain.SCUMWorkflowInstance{}, err
}
}
_, err = svc.recordAuditEventWithID(user.ID, "scum.workflow.create", "scum-workflow", workflow.ID, domain.AuditResultQueued, "typed SCUM workflow queued")
return domain.CopySCUMWorkflowInstance(workflow), err
}
func (svc *CoreService) ListSCUMWorkflowsForSession(sessionID string, filter domain.SCUMWorkflowInstanceFilter) ([]domain.SCUMWorkflowInstance, error) {
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
values, err := svc.store.SCUMWorkflowInstances().List(filter)
if err != nil {
return nil, err
}
limitSCUMProjectionSlice(&values, filter.Limit)
return values, nil
}
func (svc *CoreService) ListSCUMWorkflowStepsForSession(sessionID string, filter domain.SCUMWorkflowStepFilter) ([]domain.SCUMWorkflowStep, error) {
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
values, err := svc.store.SCUMWorkflowSteps().List(filter)
if err != nil {
return nil, err
}
limitSCUMProjectionSlice(&values, filter.Limit)
return values, nil
}
func (svc *CoreService) DispatchNextSCUMWorkflowSteps(serverID string, limit int) ([]domain.SCUMWorkflowStep, error) {
if limit <= 0 {
limit = 1
}
workflows, err := svc.store.SCUMWorkflowInstances().List(domain.SCUMWorkflowInstanceFilter{ServerInstanceID: serverID})
if err != nil {
return nil, err
}
sort.SliceStable(workflows, func(i, j int) bool {
if workflows[i].CreatedAt.Equal(workflows[j].CreatedAt) {
return workflows[i].IdempotencyKey < workflows[j].IdempotencyKey
}
return workflows[i].CreatedAt.Before(workflows[j].CreatedAt)
})
dispatched := []domain.SCUMWorkflowStep{}
activeMutating, err := svc.hasActiveSCUMMutatingStep(serverID)
if err != nil {
return nil, err
}
for _, workflow := range workflows {
if !scumWorkflowRunnable(workflow.Status) || len(dispatched) >= limit {
continue
}
steps, err := svc.sortedSCUMWorkflowSteps(workflow.ID)
if err != nil {
return nil, err
}
for _, step := range steps {
if len(dispatched) >= limit || !scumWorkflowStepRunnable(step.Status) || !scumWorkflowDependenciesConfirmed(step, steps) {
continue
}
if step.MutatesState && activeMutating {
return dispatched, nil
}
if blocked, err := svc.blockSCUMStepIfRunUnavailable(workflow, step); err != nil || blocked.ID != "" {
if err != nil {
return nil, err
}
dispatched = append(dispatched, blocked)
return dispatched, nil
}
step.Status = domain.SCUMWorkflowStepRunning
step.Attempt++
step.UpdatedAt = svc.now()
if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil {
return nil, err
}
workflow.Status = domain.SCUMWorkflowRunning
workflow.CurrentStepKey = step.StepKey
workflow.UpdatedAt = step.UpdatedAt
if err := svc.store.SCUMWorkflowInstances().Update(workflow); err != nil {
return nil, err
}
dispatched = append(dispatched, domain.CopySCUMWorkflowStep(step))
if step.MutatesState {
activeMutating = true
return dispatched, nil
}
}
}
return dispatched, nil
}
func (svc *CoreService) CompleteSCUMWorkflowStep(stepID string, status domain.SCUMWorkflowStepStatus, confirmation domain.SCUMOperationConfirmation) (domain.SCUMWorkflowInstance, error) {
step, err := svc.store.SCUMWorkflowSteps().Get(stepID)
if err != nil {
return domain.SCUMWorkflowInstance{}, err
}
workflow, err := svc.store.SCUMWorkflowInstances().Get(step.WorkflowID)
if err != nil {
return domain.SCUMWorkflowInstance{}, err
}
if !scumWorkflowStepTerminal(status) {
return domain.SCUMWorkflowInstance{}, validationError("SCUM workflow step completion status must be terminal")
}
stamp := svc.now()
step.Status = status
step.Confirmation = domain.CopySCUMOperationConfirmation(confirmation)
step.CompletedAt = stamp
step.UpdatedAt = stamp
if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil {
return domain.SCUMWorkflowInstance{}, err
}
return svc.refreshSCUMWorkflowStatus(workflow)
}
func (svc *CoreService) RetrySCUMWorkflowStep(stepID string) (domain.SCUMWorkflowStep, error) {
step, err := svc.store.SCUMWorkflowSteps().Get(stepID)
if err != nil {
return domain.SCUMWorkflowStep{}, err
}
workflow, err := svc.store.SCUMWorkflowInstances().Get(step.WorkflowID)
if err != nil {
return domain.SCUMWorkflowStep{}, err
}
if step.Attempt >= step.MaxAttempts {
return domain.SCUMWorkflowStep{}, validationError("SCUM workflow step retry limit reached")
}
if step.MutatesState && step.Status == domain.SCUMWorkflowStepUnknown && step.Confirmation.Status != "confirmed" {
step.SafeSummary = domain.SCUMSafeSummary{Title: "确认后才能重试", Message: "State-changing SCUM step is unknown; workflow must run confirmation/readback before retry to avoid duplicate effects."}
step.UpdatedAt = svc.now()
if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil {
return domain.SCUMWorkflowStep{}, err
}
return domain.CopySCUMWorkflowStep(step), nil
}
step.Status = domain.SCUMWorkflowStepQueued
step.Confirmation = domain.SCUMOperationConfirmation{}
step.CompletedAt = time.Time{}
step.UpdatedAt = svc.now()
if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil {
return domain.SCUMWorkflowStep{}, err
}
workflow.Status = domain.SCUMWorkflowQueued
workflow.BlockerReason = ""
workflow.UpdatedAt = step.UpdatedAt
if err := svc.store.SCUMWorkflowInstances().Update(workflow); err != nil {
return domain.SCUMWorkflowStep{}, err
}
return domain.CopySCUMWorkflowStep(step), nil
}
func (svc *CoreService) sortedSCUMWorkflowSteps(workflowID string) ([]domain.SCUMWorkflowStep, error) {
steps, err := svc.store.SCUMWorkflowSteps().List(domain.SCUMWorkflowStepFilter{WorkflowID: workflowID})
if err != nil {
return nil, err
}
sort.SliceStable(steps, func(i, j int) bool {
if steps[i].CreatedAt.Equal(steps[j].CreatedAt) {
return steps[i].ID < steps[j].ID
}
return steps[i].CreatedAt.Before(steps[j].CreatedAt)
})
return steps, nil
}
func (svc *CoreService) hasActiveSCUMMutatingStep(serverID string) (bool, error) {
mutates := true
for _, status := range []domain.SCUMWorkflowStepStatus{domain.SCUMWorkflowStepRunning, domain.SCUMWorkflowStepConfirming} {
steps, err := svc.store.SCUMWorkflowSteps().List(domain.SCUMWorkflowStepFilter{ServerInstanceID: serverID, Status: status, MutatesState: &mutates})
if err != nil {
return false, err
}
if len(steps) > 0 {
return true, nil
}
}
return false, nil
}
func (svc *CoreService) blockSCUMStepIfRunUnavailable(workflow domain.SCUMWorkflowInstance, step domain.SCUMWorkflowStep) (domain.SCUMWorkflowStep, error) {
if strings.TrimSpace(step.Capability) == "" {
return domain.SCUMWorkflowStep{}, nil
}
instance, err := svc.store.ServerInstances().Get(workflow.ServerInstanceID)
if err != nil {
return domain.SCUMWorkflowStep{}, err
}
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
if err != nil {
if err == repo.ErrNotFound {
return svc.blockSCUMWorkflowStep(workflow, step, "Run unavailable", "No bound run endpoint is available for this typed SCUM workflow step.")
}
return domain.SCUMWorkflowStep{}, err
}
if err := svc.validateRunnableEndpoint(endpoint, step.Capability); err != nil {
return svc.blockSCUMWorkflowStep(workflow, step, "Run unavailable", "Bound run cannot currently claim the declared workflow capability.")
}
return domain.SCUMWorkflowStep{}, nil
}
func (svc *CoreService) blockSCUMWorkflowStep(workflow domain.SCUMWorkflowInstance, step domain.SCUMWorkflowStep, title string, message string) (domain.SCUMWorkflowStep, error) {
stamp := svc.now()
step.Status = domain.SCUMWorkflowStepBlocked
step.SafeSummary = domain.SCUMSafeSummary{Title: title, Message: message, Details: map[string]string{"stepKey": step.StepKey, "capability": step.Capability}}
step.UpdatedAt = stamp
workflow.Status = domain.SCUMWorkflowBlocked
workflow.CurrentStepKey = step.StepKey
workflow.BlockerReason = title
workflow.SafeSummary = step.SafeSummary
workflow.UpdatedAt = stamp
if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil {
return domain.SCUMWorkflowStep{}, err
}
if err := svc.store.SCUMWorkflowInstances().Update(workflow); err != nil {
return domain.SCUMWorkflowStep{}, err
}
return domain.CopySCUMWorkflowStep(step), nil
}
func (svc *CoreService) refreshSCUMWorkflowStatus(workflow domain.SCUMWorkflowInstance) (domain.SCUMWorkflowInstance, error) {
steps, err := svc.sortedSCUMWorkflowSteps(workflow.ID)
if err != nil {
return domain.SCUMWorkflowInstance{}, err
}
allConfirmed := len(steps) > 0
stamp := svc.now()
for _, step := range steps {
switch step.Status {
case domain.SCUMWorkflowStepFailed:
workflow.Status = domain.SCUMWorkflowFailed
case domain.SCUMWorkflowStepUnknown:
workflow.Status = domain.SCUMWorkflowUnknown
case domain.SCUMWorkflowStepCancelled:
workflow.Status = domain.SCUMWorkflowCancelled
case domain.SCUMWorkflowStepConfirmed:
default:
allConfirmed = false
}
if workflow.Status == domain.SCUMWorkflowFailed || workflow.Status == domain.SCUMWorkflowUnknown || workflow.Status == domain.SCUMWorkflowCancelled {
workflow.CurrentStepKey = step.StepKey
workflow.CompletedAt = stamp
workflow.UpdatedAt = stamp
return domain.CopySCUMWorkflowInstance(workflow), svc.store.SCUMWorkflowInstances().Update(workflow)
}
}
if allConfirmed {
workflow.Status = domain.SCUMWorkflowConfirmed
workflow.CurrentStepKey = ""
workflow.CompletedAt = stamp
} else {
workflow.Status = domain.SCUMWorkflowQueued
workflow.CurrentStepKey = ""
}
workflow.UpdatedAt = stamp
if err := svc.store.SCUMWorkflowInstances().Update(workflow); err != nil {
return domain.SCUMWorkflowInstance{}, err
}
return domain.CopySCUMWorkflowInstance(workflow), nil
}
func scumWorkflowDependenciesConfirmed(step domain.SCUMWorkflowStep, steps []domain.SCUMWorkflowStep) bool {
if len(step.DependsOn) == 0 {
return true
}
statuses := map[string]domain.SCUMWorkflowStepStatus{}
for _, candidate := range steps {
statuses[candidate.StepKey] = candidate.Status
}
for _, dependency := range step.DependsOn {
if statuses[dependency] != domain.SCUMWorkflowStepConfirmed {
return false
}
}
return true
}
func scumWorkflowRunnable(status domain.SCUMWorkflowStatus) bool {
switch status {
case domain.SCUMWorkflowQueued, domain.SCUMWorkflowRunning, domain.SCUMWorkflowWaiting:
return true
default:
return false
}
}
func scumWorkflowStepRunnable(status domain.SCUMWorkflowStepStatus) bool {
switch status {
case domain.SCUMWorkflowStepQueued, domain.SCUMWorkflowStepWaiting:
return true
default:
return false
}
}
func scumWorkflowStepTerminal(status domain.SCUMWorkflowStepStatus) bool {
switch status {
case domain.SCUMWorkflowStepConfirmed, domain.SCUMWorkflowStepFailed, domain.SCUMWorkflowStepUnknown, domain.SCUMWorkflowStepCancelled:
return true
default:
return false
}
}
func scumWorkflowTemplates() map[string]scumWorkflowTemplateDefinition {
read := domain.JobCapabilityRemoteRunDBSQLiteQuery
logs := domain.JobCapabilityRemoteRunLogsTransfer
protectedSQL := domain.JobCapabilityRemoteRunProtectedSQL
rcon := domain.JobCapabilityRemoteRunRCONCommand
return map[string]scumWorkflowTemplateDefinition{
"scum.bootstrap-real-data": {Key: "scum.bootstrap-real-data", Title: "Bootstrap SCUM real data", Steps: []scumWorkflowStepDefinition{{Key: "verify-run-binding", Capability: read, TargetKey: "scum-database", Summary: "Verify run binding and SCUM.db query capability."}, {Key: "schema-probe", DependsOn: []string{"verify-run-binding"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.schema.probe", Summary: "Probe SCUM.db schema before projection refresh."}, {Key: "login-cursor", DependsOn: []string{"schema-probe"}, Capability: logs, TargetKey: "scum-login", Summary: "Initialize login log observation cursor."}}},
"scum.player-refresh": {Key: "scum.player-refresh", Title: "Refresh SCUM player", Steps: []scumWorkflowStepDefinition{{Key: "login-evidence", Capability: logs, TargetKey: "scum-login", Summary: "Sync login/logout evidence."}, {Key: "player-profile", DependsOn: []string{"login-evidence"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.player.profile", Summary: "Read player profile/economy facts."}, {Key: "position-read", DependsOn: []string{"player-profile"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.positions", Summary: "Read current player coordinates."}}},
"scum.world-refresh": {Key: "scum.world-refresh", Title: "Refresh SCUM world", Steps: []scumWorkflowStepDefinition{{Key: "squad-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.squads", MaxAttempts: 2, Summary: "Refresh squads."}, {Key: "vehicle-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.vehicles", MaxAttempts: 2, Summary: "Refresh vehicles."}, {Key: "flag-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.flags", MaxAttempts: 2, Summary: "Refresh flags."}, {Key: "position-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.positions", MaxAttempts: 2, Summary: "Refresh map positions."}}},
"scum.player-correction": {Key: "scum.player-correction", Title: "SCUM player correction", Steps: []scumWorkflowStepDefinition{{Key: "safety-check", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.player.profile", Summary: "Verify current projection, before value, offline state, and backup evidence."}, {Key: "apply-operation", DependsOn: []string{"safety-check"}, Capability: protectedSQL, TargetKey: "scum-database", OperationKey: "player.attribute.855.set", MutatesState: true, Summary: "Apply the approved typed operation through Run."}, {Key: "confirmation-read", DependsOn: []string{"apply-operation"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.player.profile", Summary: "Confirm the requested value by readback."}}},
"scum.gift-delivery": {Key: "scum.gift-delivery", Title: "SCUM gift delivery", Steps: []scumWorkflowStepDefinition{{Key: "eligibility-check", Summary: "Evaluate gift eligibility and idempotency."}, {Key: "deliver-reward", DependsOn: []string{"eligibility-check"}, Capability: rcon, TargetKey: "scum-management", OperationKey: "reward.deliver", MutatesState: true, MaxAttempts: 2, Summary: "Deliver approved reward through typed operation."}, {Key: "notify-player", DependsOn: []string{"deliver-reward"}, Capability: rcon, TargetKey: "scum-management", OperationKey: "player.notify", MutatesState: true, Summary: "Notify the player after delivery."}, {Key: "confirmation-read", DependsOn: []string{"notify-player"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.player.profile", Summary: "Confirm grant state/readback before marking delivered."}}},
"scum.territory-audit": {Key: "scum.territory-audit", Title: "SCUM territory audit", Steps: []scumWorkflowStepDefinition{{Key: "squad-roster", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.squad-members", Summary: "Refresh squad rosters."}, {Key: "flag-ownership", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.flags", Summary: "Refresh flag ownership."}, {Key: "risk-signal", DependsOn: []string{"squad-roster", "flag-ownership"}, Summary: "Project stale owner/member risk signals."}}},
"scum.vehicle-audit": {Key: "scum.vehicle-audit", Title: "SCUM vehicle audit", Steps: []scumWorkflowStepDefinition{{Key: "vehicle-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.vehicles", Summary: "Refresh vehicle inventory."}, {Key: "vehicle-map", DependsOn: []string{"vehicle-read"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.positions", Summary: "Refresh vehicle map overlays."}}},
"scum.ai-assist": {Key: "scum.ai-assist", Title: "SCUM AI assist", Steps: []scumWorkflowStepDefinition{{Key: "collect-allowed-fields", Summary: "Collect plugin-declared config fields and workflow inputs."}, {Key: "draft-review", DependsOn: []string{"collect-allowed-fields"}, Summary: "Create a reviewable typed diff or workflow draft."}, {Key: "approved-dispatch", DependsOn: []string{"draft-review"}, MutatesState: true, Summary: "Dispatch only after human approval through typed paths."}}},
"scum.product-cleanup": {Key: "scum.product-cleanup", Title: "SCUM product cleanup", Steps: []scumWorkflowStepDefinition{{Key: "remove-raw-routes", Summary: "Remove raw logs, terminal, config, and operation-history product routes."}, {Key: "publish-safe-status", DependsOn: []string{"remove-raw-routes"}, Summary: "Route users to safe workflow/status surfaces."}}},
}
}
+132
View File
@@ -0,0 +1,132 @@
package service
import (
"strings"
"testing"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
)
func TestSCUMWorkflowDispatchesReadStepsWithBoundedConcurrencyAndIdempotency(t *testing.T) {
svc, session, instance := newSCUMWorkflowFixture(t, true)
workflow, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.world-refresh", IdempotencyKey: "world-refresh-1", Input: map[string]any{"scope": "world"}})
if err != nil || workflow.Status != domain.SCUMWorkflowQueued {
t.Fatalf("create world workflow=%+v err=%v", workflow, err)
}
duplicate, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.world-refresh", IdempotencyKey: "world-refresh-1"})
if err != nil || duplicate.ID != workflow.ID {
t.Fatalf("expected idempotent workflow create: duplicate=%+v err=%v", duplicate, err)
}
dispatched, err := svc.DispatchNextSCUMWorkflowSteps(instance.ID, 3)
if err != nil || len(dispatched) != 3 {
t.Fatalf("expected three bounded read steps dispatched: steps=%+v err=%v", dispatched, err)
}
for _, step := range dispatched {
if step.MutatesState || step.Status != domain.SCUMWorkflowStepRunning || step.Attempt != 1 {
t.Fatalf("unexpected read step dispatch: %+v", step)
}
}
}
func TestSCUMWorkflowSerializesMutatingStepsPerServer(t *testing.T) {
svc, session, instance := newSCUMWorkflowFixture(t, true)
first, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.gift-delivery", IdempotencyKey: "gift-1"})
if err != nil {
t.Fatalf("create first gift workflow: %v", err)
}
if _, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.gift-delivery", IdempotencyKey: "gift-2"}); err != nil {
t.Fatalf("create second gift workflow: %v", err)
}
steps, err := svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1)
if err != nil || len(steps) != 1 || steps[0].StepKey != "eligibility-check" {
t.Fatalf("expected first eligibility step: steps=%+v err=%v", steps, err)
}
if _, err := svc.CompleteSCUMWorkflowStep(steps[0].ID, domain.SCUMWorkflowStepConfirmed, domain.SCUMOperationConfirmation{Status: "confirmed"}); err != nil {
t.Fatalf("complete eligibility: %v", err)
}
steps, err = svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1)
if err != nil || len(steps) != 1 || steps[0].StepKey != "deliver-reward" || !steps[0].MutatesState {
t.Fatalf("expected first mutating reward step: steps=%+v err=%v", steps, err)
}
if steps[0].WorkflowID != first.ID {
t.Fatalf("expected first workflow to keep the mutation slot: step=%+v first=%+v", steps[0], first)
}
blockedByActiveMutation, err := svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1)
if err != nil {
t.Fatalf("dispatch while mutation active: %v", err)
}
for _, step := range blockedByActiveMutation {
if step.MutatesState {
t.Fatalf("second state-changing step should wait for first terminal state: steps=%+v", blockedByActiveMutation)
}
}
}
func TestSCUMWorkflowBlocksWhenRunUnavailable(t *testing.T) {
svc, session, instance := newSCUMWorkflowFixture(t, false)
workflow, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.player-refresh", IdempotencyKey: "player-refresh-blocked"})
if err != nil {
t.Fatalf("create player refresh workflow: %v", err)
}
steps, err := svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1)
if err != nil || len(steps) != 1 || steps[0].Status != domain.SCUMWorkflowStepBlocked {
t.Fatalf("expected blocked run step: steps=%+v err=%v", steps, err)
}
updated, err := svc.store.SCUMWorkflowInstances().Get(workflow.ID)
if err != nil || updated.Status != domain.SCUMWorkflowBlocked || strings.Contains(updated.SafeSummary.Message, "/") || strings.Contains(strings.ToLower(updated.SafeSummary.Message), "token") {
t.Fatalf("workflow blocker should be safe: workflow=%+v err=%v", updated, err)
}
}
func TestSCUMWorkflowRetryRequiresConfirmationAfterUnknownMutation(t *testing.T) {
svc, session, instance := newSCUMWorkflowFixture(t, true)
if _, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.gift-delivery", IdempotencyKey: "gift-unknown"}); err != nil {
t.Fatalf("create gift workflow: %v", err)
}
steps, err := svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1)
if err != nil || len(steps) != 1 {
t.Fatalf("dispatch eligibility: steps=%+v err=%v", steps, err)
}
if _, err := svc.CompleteSCUMWorkflowStep(steps[0].ID, domain.SCUMWorkflowStepConfirmed, domain.SCUMOperationConfirmation{Status: "confirmed"}); err != nil {
t.Fatalf("complete eligibility: %v", err)
}
steps, err = svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1)
if err != nil || len(steps) != 1 || !steps[0].MutatesState {
t.Fatalf("dispatch mutating reward: steps=%+v err=%v", steps, err)
}
if _, err := svc.CompleteSCUMWorkflowStep(steps[0].ID, domain.SCUMWorkflowStepUnknown, domain.SCUMOperationConfirmation{Status: "unknown"}); err != nil {
t.Fatalf("complete unknown mutation: %v", err)
}
retry, err := svc.RetrySCUMWorkflowStep(steps[0].ID)
if err != nil || retry.Status != domain.SCUMWorkflowStepUnknown || !strings.Contains(retry.SafeSummary.Title, "确认") {
t.Fatalf("unknown mutating retry should require confirmation: step=%+v err=%v", retry, err)
}
}
func newSCUMWorkflowFixture(t *testing.T, runAvailable bool) (*CoreService, string, domain.ServerInstance) {
t.Helper()
svc := newCoreService(repo.NewMemoryStore(), func() time.Time { return fixedTime })
capabilities := []string{domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunProtectedSQL, domain.JobCapabilityRemoteRunRCONCommand}
plugin, err := svc.CreateGamePlugin(domain.GamePlugin{ID: "server.scum", Name: "SCUM", Version: "1.0.0", ServerType: "scum", ManifestRef: "artifact://manifests/server.scum/1.0.0", CreateFormSchemaRef: "artifact://schemas/server.scum/create-form/1.0.0", RequiredRunCapabilities: capabilities, DeclaredPermissions: []string{"server.game-client.read", "server.game-client.command", "server.game-client.maintenance"}, Permissions: domain.PluginPermissions{Jobs: true, RemoteAccess: true}, RemoteAccess: domain.GamePluginRemoteAccess{Methods: []string{"run"}, RunCapabilities: capabilities, DatabaseEngines: []string{"sqlite"}, RCON: true, LogTransfer: true}, LifecycleActions: domain.PluginLifecycleActions{Start: "actions/start.json"}, RuntimeProfiles: domain.GamePluginRuntimeProfiles{TransportProfiles: []domain.RuntimeTransportProfile{{Key: "scum-database", Kind: "sqlite", TargetKey: "scum-database", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedSQL}}, {Key: "scum-management", Kind: "rcon", TargetKey: "scum-management", Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand}}}}})
if err != nil {
t.Fatalf("create workflow plugin: %v", err)
}
endpoint, err := svc.CreateRunEndpoint(domain.RunEndpoint{ID: "run-local", DisplayName: "Local Run", Version: "0.1.0", Platform: "windows", Architecture: "amd64", Status: domain.RunEndpointStatusOnline, Capabilities: capabilities, Capacity: domain.RunCapacity{MaxJobs: 4}, LastHeartbeatAt: fixedTime})
if err != nil {
t.Fatalf("create workflow endpoint: %v", err)
}
session := createServiceUserAndLogin(t, svc, domain.User{ID: "workflow-owner", DisplayName: "Workflow Owner", Email: "workflow-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
instance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{ID: "server-workflow", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "Workflow Server", State: domain.ServerInstanceStateRunning})
if err != nil {
t.Fatalf("create workflow server: %v", err)
}
if !runAvailable {
endpoint.Status = domain.RunEndpointStatusOffline
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatalf("mark workflow endpoint offline: %v", err)
}
}
return svc, session, instance
}