diff --git a/openspec/changes/replace-scum-projections-with-real-data-management/tasks.md b/openspec/changes/replace-scum-projections-with-real-data-management/tasks.md index d40dcfa..c1a8f97 100644 --- a/openspec/changes/replace-scum-projections-with-real-data-management/tasks.md +++ b/openspec/changes/replace-scum-projections-with-real-data-management/tasks.md @@ -20,6 +20,8 @@ - Platform durable probe job `job-remote-adapter-server-scum-1785923898033-3442596095552254276` was queued through `POST /api/v1/server-instances/server-scum-1785923898033/scum/schema-probe` with idempotency key `zao-zhuang-schema-probe-20260812-1632`, claimed by the authenticated active Run binding, acknowledged, and executed with target `databases/scum-database`, `MaxAttempts=1`, and a nonzero fencing token. - The probe terminal result was accepted by Platform as a typed `sqlite.schema-probe` result with safe status `failed`, safe error code `target_unavailable`, result digest `sha256:41624741855866ce10b3143edba66c3a6b771029256b9489a30f395885526b61`, and observed time `2026-08-12T08:31:45Z`. This proves the Platform durable job path and active Run probe executor are wired, but it does not prove current SCUM schema compatibility. - The generated Run workspace currently contains lifecycle package assets but no `databases/scum-database` logical database target, so current-service schema capture remains blocked at the package/database-target mapping layer. Keep every database-backed SCUM read/write gate disabled until a subsequent packaged target/snapshot fix produces successful schema metadata. +- Follow-up bounded SSH diagnostics on `2026-08-12` located exactly one active `SCUM.db` candidate by process-relative metadata only, with no SQL execution or row reads; the live file was locked for direct hashing/copying. This supports the package-target diagnosis but is not current-service schema evidence for tasks 2.5-2.9. +- Platform/plugin contracts now declare a plugin-owned `runtimeProfiles.dataTargets` sqlite snapshot target for `scum-database` that materializes to `databases/scum-database` inside the generated Run workspace; SCUM schema-probe dispatch fails closed when that data target is absent. The active external Run still needs the matching generic materializer before a successful current-service probe can complete. - [x] 2.1 Add a release gate that keeps every database-backed SCUM read and write capability disabled until capability-specific current-service evidence matches a versioned plugin adapter; do not add production SQL or mutation assets before this group is complete. - [x] 2.2 Define the minimal generic schema-probe request/result contract, safe error model, binding identity, bounds, and redacted evidence DTO needed by Platform and the plugin without embedding SCUM table names or host paths in Run-facing generic code. @@ -53,6 +55,7 @@ - Platform now has an internal-only `RequestSCUMSchemaProbeForSession` path that builds a durable `remote.run.db.sqlite.probe` job from the SCUM plugin's manifest declaration, active runtime binding, logical target key, adapter version, and bounded probe limits; public remote-adapter and plugin-page requests for the probe capability are denied. - Run terminal results may carry `executionResult.sqliteSchemaProbe`; Platform DTO/domain/validator/job-channel code validates the typed redacted envelope, job/request identity, and binding fence before persisting it on the durable job. +- Generated Run packages now carry redacted autonomous lifecycle `dataTargets` entries for plugin-owned sqlite snapshots, and browser-facing runtime-profile responses continue to omit those source declarations. - Focused evidence: `go test ./dto ./service -run 'Test(RunJobResultRequestParsesSQLiteSchemaProbeEnvelope|SCUMSchemaProbeDispatchIsPlatformScheduledAndFenced|RemoteAdapterRequestPropagatesTypedInputsToRunJob)'` and `(cd platform && go test ./...)` passed locally. These tests do not prove the active Windows Run deployment or current SCUM schema, so tasks 2.3-2.9 and 4.2-4.9 remain unchecked. - [x] 4.1 Add Platform protocol contracts under `platform/protocol`, API DTOs under `platform/dto`, validation under `platform/validator`, and plugin contracts/assets under `plugins/sdk` and `plugins/schemas`, plus contract documentation/mocks for probes, read-only template execution, typed RCON, guarded SQLite/XML mutation, parsed log events, and terminal result envelopes. diff --git a/platform/api/scum_handlers_test.go b/platform/api/scum_handlers_test.go index a0aa557..f546080 100644 --- a/platform/api/scum_handlers_test.go +++ b/platform/api/scum_handlers_test.go @@ -20,21 +20,25 @@ func TestSCUMSchemaProbeEndpointQueuesPlatformScheduledDurableJob(t *testing.T) ServerType: "scum", ManifestRef: "artifact://manifests/server.scum/1.0.0", CreateFormSchemaRef: "artifact://schemas/server.scum/create-form/1.0.0", - RequiredRunCapabilities: []string{"process.start", domain.JobCapabilityRemoteRunDBSQLiteProbe}, + RequiredRunCapabilities: []string{"process.start", domain.JobCapabilityRemoteRunFilesRead, domain.JobCapabilityRemoteRunDBSQLiteProbe}, DeclaredPermissions: []string{"server.remote.access"}, Permissions: domain.PluginPermissions{Jobs: true, RemoteAccess: true}, LifecycleActions: domain.PluginLifecycleActions{Start: "actions/start.json"}, RemoteAccess: domain.GamePluginRemoteAccess{Methods: []string{"run"}, RunCapabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteProbe}}, RuntimeProfiles: domain.GamePluginRuntimeProfiles{ LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{"process.start"}}}, - TransportProfiles: []domain.RuntimeTransportProfile{{Key: "scum-database", Kind: "sqlite", TargetKey: "scum-database", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteProbe}}}, + TransportProfiles: []domain.RuntimeTransportProfile{ + {Key: "server-files", Kind: "file", TargetKey: "server-root", Capabilities: []string{domain.JobCapabilityRemoteRunFilesRead}}, + {Key: "scum-database", Kind: "sqlite", TargetKey: "scum-database", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteProbe}}, + }, + 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"}}}, }, 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 != nil { t.Fatalf("create plugin: %v", err) } - endpoint, err := core.CreateRunEndpoint(domain.RunEndpoint{ID: "run-local", DisplayName: "Local Run", Version: "0.1.0", Capabilities: []string{"process.start", domain.JobCapabilityRemoteRunDBSQLiteProbe}, Capacity: domain.RunCapacity{MaxJobs: 4}}) + endpoint, err := core.CreateRunEndpoint(domain.RunEndpoint{ID: "run-local", DisplayName: "Local Run", Version: "0.1.0", Capabilities: []string{"process.start", domain.JobCapabilityRemoteRunFilesRead, domain.JobCapabilityRemoteRunDBSQLiteProbe}, Capacity: domain.RunCapacity{MaxJobs: 4}}) if err != nil { t.Fatalf("create run endpoint: %v", err) } diff --git a/platform/domain/job_channel.go b/platform/domain/job_channel.go index cea6c57..e0f8ba2 100644 --- a/platform/domain/job_channel.go +++ b/platform/domain/job_channel.go @@ -148,6 +148,7 @@ type RunAutonomousLifecyclePlan struct { InstallPlans []RunAutonomousInstallPlan `json:"installPlans,omitempty"` LogSources []RunAutonomousLogSource `json:"logSources,omitempty"` DLLExtensions []RunAutonomousDLLExtension `json:"dllExtensions,omitempty"` + DataTargets []RunAutonomousDataTarget `json:"dataTargets,omitempty"` RuntimeBindings map[string]string `json:"runtimeBindings,omitempty"` Deployment *RunAutonomousDeployment `json:"deployment,omitempty"` } @@ -208,6 +209,18 @@ type RunAutonomousDLLExtension struct { RCONPort int `json:"rconPort,omitempty"` } +type RunAutonomousDataTarget struct { + Key string `json:"key"` + Kind string `json:"kind"` + TransportKey string `json:"transportKey"` + SourceRootKey string `json:"sourceRootKey"` + SourcePath string `json:"sourcePath"` + WorkspaceKey string `json:"workspaceKey"` + RefreshPolicy string `json:"refreshPolicy"` + MaxBytes int64 `json:"maxBytes,omitempty"` + Platforms []string `json:"platforms,omitempty"` +} + type RunAutonomousDeployment struct { SchemaVersion string `json:"schemaVersion"` Mode ServerDeploymentMode `json:"mode"` @@ -497,6 +510,10 @@ func CopyRunAutonomousLifecyclePlanPtr(plan *RunAutonomousLifecyclePlan) *RunAut } copy.LogSources = append([]RunAutonomousLogSource(nil), plan.LogSources...) copy.DLLExtensions = append([]RunAutonomousDLLExtension(nil), plan.DLLExtensions...) + copy.DataTargets = append([]RunAutonomousDataTarget(nil), plan.DataTargets...) + for i := range copy.DataTargets { + copy.DataTargets[i].Platforms = CopyStringSlice(plan.DataTargets[i].Platforms) + } copy.RuntimeBindings = CopyStringMap(plan.RuntimeBindings) if plan.Deployment != nil { deployment := *plan.Deployment diff --git a/platform/domain/resources.go b/platform/domain/resources.go index 6b4eb22..5fef306 100644 --- a/platform/domain/resources.go +++ b/platform/domain/resources.go @@ -479,6 +479,18 @@ type RuntimeTransportProfile struct { Capabilities []string } +type RuntimeDataTarget struct { + Key string + Kind string + TransportKey string + SourceRootKey string + SourcePath string + WorkspaceKey string + RefreshPolicy string + MaxBytes int64 + Platforms []string +} + type RuntimeClientManagerProfile struct { Key string DisplayName string @@ -609,6 +621,7 @@ type GamePluginRuntimeProfiles struct { LogSources []RuntimeLogSource LogEvents []RuntimeLogEvent TransportProfiles []RuntimeTransportProfile + DataTargets []RuntimeDataTarget ClientManagers []RuntimeClientManagerProfile DLLExtensions []RuntimeDLLExtensionProfile } @@ -1833,6 +1846,10 @@ func CopyGamePluginRuntimeProfiles(profiles GamePluginRuntimeProfiles) GamePlugi for i := range profiles.TransportProfiles { profiles.TransportProfiles[i].Capabilities = CopyStringSlice(profiles.TransportProfiles[i].Capabilities) } + profiles.DataTargets = append([]RuntimeDataTarget(nil), profiles.DataTargets...) + for i := range profiles.DataTargets { + profiles.DataTargets[i].Platforms = CopyStringSlice(profiles.DataTargets[i].Platforms) + } profiles.ClientManagers = append([]RuntimeClientManagerProfile(nil), profiles.ClientManagers...) for i := range profiles.ClientManagers { profiles.ClientManagers[i].SupportedTargets = append([]RuntimeTarget(nil), profiles.ClientManagers[i].SupportedTargets...) diff --git a/platform/dto/runtime_profiles.go b/platform/dto/runtime_profiles.go index 83c5546..8059b9f 100644 --- a/platform/dto/runtime_profiles.go +++ b/platform/dto/runtime_profiles.go @@ -127,6 +127,18 @@ type RuntimeTransportProfileBody struct { Capabilities []string `json:"capabilities"` } +type RuntimeDataTargetBody struct { + Key string `json:"key"` + Kind string `json:"kind"` + TransportKey string `json:"transportKey"` + SourceRootKey string `json:"sourceRootKey"` + SourcePath string `json:"sourcePath"` + WorkspaceKey string `json:"workspaceKey"` + RefreshPolicy string `json:"refreshPolicy"` + MaxBytes int64 `json:"maxBytes,omitempty"` + Platforms []string `json:"platforms,omitempty"` +} + type RuntimeRepositoryBody struct { URL string `json:"url"` RevisionPolicy string `json:"revisionPolicy"` @@ -263,6 +275,7 @@ type GamePluginRuntimeProfilesBody struct { LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"` LogEvents []RuntimeLogEventBody `json:"logEvents,omitempty"` TransportProfiles []RuntimeTransportProfileBody `json:"transportProfiles,omitempty"` + DataTargets []RuntimeDataTargetBody `json:"dataTargets,omitempty"` ClientManagers []RuntimeClientManagerProfileBody `json:"clientManagers,omitempty"` DLLExtensions []RuntimeDLLExtensionProfileBody `json:"dllExtensions,omitempty"` } @@ -329,6 +342,9 @@ func (body GamePluginRuntimeProfilesBody) ToDomain() domain.GamePluginRuntimePro for _, item := range body.TransportProfiles { profiles.TransportProfiles = append(profiles.TransportProfiles, domain.RuntimeTransportProfile{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Capabilities: domain.CopyStringSlice(item.Capabilities)}) } + for _, item := range body.DataTargets { + profiles.DataTargets = append(profiles.DataTargets, domain.RuntimeDataTarget{Key: item.Key, Kind: item.Kind, TransportKey: item.TransportKey, SourceRootKey: item.SourceRootKey, SourcePath: item.SourcePath, WorkspaceKey: item.WorkspaceKey, RefreshPolicy: item.RefreshPolicy, MaxBytes: item.MaxBytes, Platforms: domain.CopyStringSlice(item.Platforms)}) + } for _, item := range body.ClientManagers { manager := domain.RuntimeClientManagerProfile{ Key: item.Key, DisplayName: item.DisplayName, Version: item.Version, diff --git a/platform/service/distribution_build_execution.go b/platform/service/distribution_build_execution.go index 0b2f8aa..3cf499a 100644 --- a/platform/service/distribution_build_execution.go +++ b/platform/service/distribution_build_execution.go @@ -268,6 +268,11 @@ func runAutonomousLifecyclePlan(distribution domain.RunDistribution, instance do plan.LogSources = append(plan.LogSources, autonomousLogSource(source)) } } + for _, target := range plugin.RuntimeProfiles.DataTargets { + if runtimePlatformsContain(target.Platforms, distribution.TargetOS) { + plan.DataTargets = append(plan.DataTargets, autonomousDataTarget(target)) + } + } if hasProfile && len(profile.DLLExtensionRefs) > 0 { endpoint := domain.RunEndpoint{ID: distribution.RunEndpointID, Platform: distribution.TargetOS, Architecture: distribution.TargetArch} extensions, err := lifecycleDLLExtensionPlans(plugin.RuntimeProfiles, profile, endpoint) @@ -326,6 +331,10 @@ func autonomousDLLExtension(extension domain.RuntimeDLLExtensionPlan) domain.Run return domain.RunAutonomousDLLExtension{Key: extension.Key, Version: extension.Version, ReleaseURL: extension.ReleaseURL, Checksum: extension.Checksum, SizeBytes: extension.SizeBytes, TargetKey: extension.TargetKey, ModKey: extension.ModKey, DLLRef: extension.DLLRef, SCUMExecutableChecksum: extension.SCUMExecutableChecksum, UE4SSABI: extension.UE4SSABI, RCONPort: extension.RCONPort} } +func autonomousDataTarget(target domain.RuntimeDataTarget) domain.RunAutonomousDataTarget { + return domain.RunAutonomousDataTarget{Key: target.Key, Kind: target.Kind, TransportKey: target.TransportKey, SourceRootKey: target.SourceRootKey, SourcePath: target.SourcePath, WorkspaceKey: target.WorkspaceKey, RefreshPolicy: target.RefreshPolicy, MaxBytes: target.MaxBytes, Platforms: domain.CopyStringSlice(target.Platforms)} +} + func autonomousDeploymentFromDefinition(definition domain.ServerDeploymentDefinition, profileKey string, bindings map[string]string) *domain.RunAutonomousDeployment { if definition.Mode == "" { return nil diff --git a/platform/service/distribution_build_execution_test.go b/platform/service/distribution_build_execution_test.go index 997f172..e4663db 100644 --- a/platform/service/distribution_build_execution_test.go +++ b/platform/service/distribution_build_execution_test.go @@ -45,6 +45,13 @@ 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) } @@ -90,14 +97,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") || 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") || 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" { 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 { + 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" { t.Fatalf("seeded lifecycle plan differs from build input: seed=%+v input=%+v", seededPlan, plan) } auth, err := svc.AuthenticateComponent(domain.ComponentAuthenticationRequest{ diff --git a/platform/service/remote_adapters_test.go b/platform/service/remote_adapters_test.go index ac13bd4..7e3e0e9 100644 --- a/platform/service/remote_adapters_test.go +++ b/platform/service/remote_adapters_test.go @@ -95,7 +95,11 @@ func TestSCUMSchemaProbeDispatchIsPlatformScheduledAndFenced(t *testing.T) { 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: "scum-database", Kind: "sqlite", TargetKey: "scum-database", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteProbe}}) + 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 { diff --git a/platform/service/scum_live_data.go b/platform/service/scum_live_data.go index bc5e61a..18ed8b8 100644 --- a/platform/service/scum_live_data.go +++ b/platform/service/scum_live_data.go @@ -28,6 +28,9 @@ func (svc *CoreService) RequestSCUMSchemaProbeForSession(sessionID, serverInstan 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 @@ -94,6 +97,16 @@ func scumSchemaProbeDatabaseIdentity(targetKey string) string { 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 { diff --git a/platform/validator/resources_test.go b/platform/validator/resources_test.go index bc8843c..b6adeb1 100644 --- a/platform/validator/resources_test.go +++ b/platform/validator/resources_test.go @@ -298,9 +298,13 @@ func TestValidateGamePluginManifestRegistrationRejectsUnsafeCapabilitiesAndPermi func TestValidateGamePluginManifestRegistrationValidatesSCUMLiveDataGate(t *testing.T) { registration := validGamePluginManifestRegistration() registration.Manifest.ID = "game.scum" - registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteProbe) + registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteProbe, domain.JobCapabilityRemoteRunFilesRead) registration.Manifest.RemoteAccess = domain.GamePluginRemoteAccess{Methods: []string{"run"}, RunCapabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteProbe}} - registration.Manifest.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "scum-database", Kind: "sqlite", TargetKey: "scum-database", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteProbe}}} + registration.Manifest.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{ + {Key: "server-files", Kind: "file", TargetKey: "server-root", Capabilities: []string{domain.JobCapabilityRemoteRunFilesRead}}, + {Key: "scum-database", Kind: "sqlite", TargetKey: "scum-database", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteProbe}}, + } + registration.Manifest.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"}}} registration.Manifest.SCUMLiveData = domain.SCUMLiveDataManifest{SchemaVersion: "1", Probe: domain.SCUMSchemaProbeDeclaration{Capability: domain.JobCapabilityRemoteRunDBSQLiteProbe, TargetKey: "scum-database", Bounds: domain.DefaultSCUMSchemaProbeBounds()}, CapabilityGates: []domain.SCUMLiveDataCapabilityGateDeclaration{{Capability: domain.SCUMDataCapabilityPlayerRead, Gate: domain.SCUMCapabilityGateDisabled, AdapterVersion: "scum-live-data-v0", EvidenceStatus: domain.SCUMCapabilityEvidenceMissing, SafeReason: "等待当前服务证据。"}}} if err := ValidateGamePluginManifestRegistration(registration); err != nil { diff --git a/platform/validator/runtime_profiles.go b/platform/validator/runtime_profiles.go index 5fb7657..ad2ad60 100644 --- a/platform/validator/runtime_profiles.go +++ b/platform/validator/runtime_profiles.go @@ -34,6 +34,8 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles logSourceRetentions := map[string]int{} logEventKeys := map[string]struct{}{} logEventTypes := map[string]struct{}{} + dataTargetKeys := map[string]struct{}{} + transportTargetKeys := map[string]struct{}{} for i, probe := range profiles.Discovery { prefix := fmt.Sprintf("runtimeProfiles.discovery[%d]", i) @@ -301,6 +303,7 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles } if transport.TargetKey != "" { violations = append(violations, validateProfileKey(prefix+".targetKey", transport.TargetKey)...) + transportTargetKeys[transport.TargetKey] = struct{}{} } if len(transport.Capabilities) == 0 { violations = append(violations, prefix+".capabilities must not be empty") @@ -312,6 +315,35 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles } violations = append(violations, duplicateViolations(prefix+".capabilities", transport.Capabilities)...) } + for i, target := range profiles.DataTargets { + prefix := fmt.Sprintf("runtimeProfiles.dataTargets[%d]", i) + violations = append(violations, validateProfileKey(prefix+".key", target.Key)...) + violations = append(violations, recordRuntimeProfileKey(dataTargetKeys, prefix+".key", target.Key)...) + if target.Kind != "sqlite.snapshot" { + violations = append(violations, prefix+".kind is invalid") + } + violations = append(violations, validateProfileKey(prefix+".transportKey", target.TransportKey)...) + transport, transportExists := transportByKey(profiles.TransportProfiles, target.TransportKey) + if !transportExists || transport.Kind != "sqlite" { + violations = append(violations, prefix+".transportKey must reference a declared sqlite transport") + } + violations = append(violations, validateProfileKey(prefix+".sourceRootKey", target.SourceRootKey)...) + if _, exists := transportTargetKeys[target.SourceRootKey]; !exists { + violations = append(violations, prefix+".sourceRootKey must reference a declared runtime transport target") + } + violations = append(violations, validateSafeRelativeRuntimePath(prefix+".sourcePath", target.SourcePath)...) + violations = append(violations, validateSafeRelativeRuntimePath(prefix+".workspaceKey", target.WorkspaceKey)...) + if !strings.HasPrefix(target.WorkspaceKey, "databases/") { + violations = append(violations, prefix+".workspaceKey must live under databases/") + } + if target.RefreshPolicy != "on-demand-snapshot" { + violations = append(violations, prefix+".refreshPolicy is invalid") + } + if target.MaxBytes < 1 || target.MaxBytes > 1024*1024*1024 { + violations = append(violations, prefix+".maxBytes is invalid") + } + violations = append(violations, validateRuntimePlatforms(prefix+".platforms", target.Platforms)...) + } for i, manager := range profiles.ClientManagers { prefix := fmt.Sprintf("runtimeProfiles.clientManagers[%d]", i) violations = append(violations, validateProfileKey(prefix+".key", manager.Key)...) @@ -656,6 +688,15 @@ func recordRuntimeProfileKey(seen map[string]struct{}, field, key string) []stri return nil } +func transportByKey(transports []domain.RuntimeTransportProfile, key string) (domain.RuntimeTransportProfile, bool) { + for _, transport := range transports { + if transport.Key == key { + return transport, true + } + } + return domain.RuntimeTransportProfile{}, false +} + func validateRuntimeProfileCapabilityDeclarations(profiles domain.GamePluginRuntimeProfiles, declared []string) []string { declaredSet := map[string]struct{}{} for _, capability := range declared { diff --git a/platform/validator/scum_live_data.go b/platform/validator/scum_live_data.go index 60c374b..d30f224 100644 --- a/platform/validator/scum_live_data.go +++ b/platform/validator/scum_live_data.go @@ -95,6 +95,8 @@ func validateSCUMLiveDataManifest(prefix string, value domain.SCUMLiveDataManife violations = append(violations, prefix+".probe requires declared remote.run.db.sqlite.probe capability") } probeTransportFound := false + probeDataTargetFound := false + expectedWorkspaceKey := "databases/" + strings.TrimPrefix(value.Probe.TargetKey, "databases/") for _, transport := range runtimeProfiles.TransportProfiles { if transport.Key != value.Probe.TargetKey && transport.TargetKey != value.Probe.TargetKey { continue @@ -107,6 +109,19 @@ func validateSCUMLiveDataManifest(prefix string, value domain.SCUMLiveDataManife if !probeTransportFound { violations = append(violations, prefix+".probe.targetKey must reference a declared transport") } + for _, target := range runtimeProfiles.DataTargets { + if target.Key != value.Probe.TargetKey { + continue + } + probeDataTargetFound = true + transport, ok := transportByKey(runtimeProfiles.TransportProfiles, target.TransportKey) + if target.Kind != "sqlite.snapshot" || target.WorkspaceKey != expectedWorkspaceKey || !ok || transport.Kind != "sqlite" || !containsString(transport.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteProbe) { + violations = append(violations, prefix+".probe.targetKey must reference a sqlite snapshot data target for the generated Run workspace") + } + } + if !probeDataTargetFound { + violations = append(violations, prefix+".probe.targetKey must reference a declared runtime data target") + } violations = append(violations, validateSCUMSchemaProbeBounds(prefix+".probe.bounds", value.Probe.Bounds)...) seen := map[domain.SCUMDataCapability]struct{}{} for i, gate := range value.CapabilityGates { diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index 06c242b..66addac 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -925,6 +925,21 @@ ] } ], + "dataTargets": [ + { + "key": "scum-database", + "kind": "sqlite.snapshot", + "transportKey": "scum-database", + "sourceRootKey": "server-root", + "sourcePath": "SCUM/Saved/SaveFiles/SCUM.db", + "workspaceKey": "databases/scum-database", + "refreshPolicy": "on-demand-snapshot", + "maxBytes": 1073741824, + "platforms": [ + "windows" + ] + } + ], "clientManagers": [ { "key": "scum-client-manager", diff --git a/plugins/manifests/game-plugin.manifest.schema.json b/plugins/manifests/game-plugin.manifest.schema.json index 237885f..2834f00 100644 --- a/plugins/manifests/game-plugin.manifest.schema.json +++ b/plugins/manifests/game-plugin.manifest.schema.json @@ -133,6 +133,12 @@ "items": { "$ref": "#/$defs/runtimeTransportProfile" }, "uniqueItems": true }, + "dataTargets": { + "type": "array", + "items": { "$ref": "#/$defs/runtimeDataTarget" }, + "uniqueItems": true, + "maxItems": 64 + }, "clientManagers": { "type": "array", "items": { "$ref": "#/$defs/runtimeClientManagerProfile" }, @@ -838,6 +844,22 @@ "capabilities": { "type": "array", "items": { "$ref": "#/$defs/runCapability" }, "uniqueItems": true, "minItems": 1 } } }, + "runtimeDataTarget": { + "type": "object", + "required": ["key", "kind", "transportKey", "sourceRootKey", "sourcePath", "workspaceKey", "refreshPolicy", "maxBytes"], + "additionalProperties": false, + "properties": { + "key": { "$ref": "#/$defs/logicalKey" }, + "kind": { "const": "sqlite.snapshot" }, + "transportKey": { "$ref": "#/$defs/logicalKey" }, + "sourceRootKey": { "$ref": "#/$defs/logicalKey" }, + "sourcePath": { "$ref": "#/$defs/relativePathRef" }, + "workspaceKey": { "type": "string", "pattern": "^databases/[a-z0-9][a-z0-9._/-]{0,119}$" }, + "refreshPolicy": { "const": "on-demand-snapshot" }, + "maxBytes": { "type": "integer", "minimum": 1, "maximum": 1073741824 }, + "platforms": { "type": "array", "items": { "enum": ["windows", "linux", "darwin"] }, "uniqueItems": true } + } + }, "relativePathRef": { "type": "string", "pattern": "^(?!/)(?![A-Za-z]:)(?!.*://)(?!.*\\.\\.)[a-zA-Z0-9_./-]+$", diff --git a/plugins/scripts/validate-manifest.ts b/plugins/scripts/validate-manifest.ts index fcac71b..d4d79e6 100644 --- a/plugins/scripts/validate-manifest.ts +++ b/plugins/scripts/validate-manifest.ts @@ -1064,6 +1064,7 @@ export function validateSCUMLiveDataManifest(manifest: unknown, manifestDir?: st type Gate = { capability?: string; gate?: string; adapterVersion?: string; requiredSchemaFingerprint?: string; requiredAssetDigests?: string[]; evidenceStatus?: string; safeReason?: string }; type Probe = { capability?: string; targetKey?: string; bounds?: { maxSampleRows?: number; timeoutMs?: number; maxResultBytes?: number } }; type Transport = { key?: string; kind?: string; targetKey?: string; capabilities?: string[] }; + type DataTarget = { key?: string; kind?: string; transportKey?: string; sourceRootKey?: string; sourcePath?: string; workspaceKey?: string; refreshPolicy?: string; maxBytes?: number; platforms?: string[] }; type RuntimeLogSource = { key?: string }; type AssetFile = { path?: string }; type Asset = { key?: string; adapterVersion?: string; assetPath?: string; digest?: string }; @@ -1075,7 +1076,7 @@ export function validateSCUMLiveDataManifest(manifest: unknown, manifestDir?: st type MapAsset = Asset & { requiredSchemaFingerprint?: string; metadataSchemaRef?: string; transformAssetPath?: string; transformDigest?: string; worldBounds?: { minX?: number; minY?: number; maxX?: number; maxY?: number }; image?: { width?: number; height?: number } }; type GiftCatalog = Asset & { catalogVersion?: string; itemSchemaRef?: string; transportTemplateKeys?: string[] }; type LiveData = { schemaVersion?: string; probe?: Probe; capabilityGates?: Gate[]; logParsers?: LogParser[]; sqliteQueries?: SQLiteQuery[]; syncCadences?: SyncCadence[]; typedRconTemplates?: TypedRCON[]; guardedMutations?: GuardedMutation[]; mapAssets?: MapAsset[]; giftCatalogs?: GiftCatalog[] }; - const declaration = manifest as { id?: string; capabilities?: string[]; permissions?: string[]; assetFiles?: AssetFile[]; remoteAccess?: { runCapabilities?: string[]; databaseEngines?: string[] }; runtimeProfiles?: { transportProfiles?: Transport[]; logSources?: RuntimeLogSource[] }; scumLiveData?: LiveData }; + const declaration = manifest as { id?: string; capabilities?: string[]; permissions?: string[]; assetFiles?: AssetFile[]; remoteAccess?: { runCapabilities?: string[]; databaseEngines?: string[] }; runtimeProfiles?: { transportProfiles?: Transport[]; dataTargets?: DataTarget[]; logSources?: RuntimeLogSource[] }; scumLiveData?: LiveData }; const liveData = declaration.scumLiveData; if (!liveData) return []; @@ -1085,6 +1086,7 @@ export function validateSCUMLiveDataManifest(manifest: unknown, manifestDir?: st const remoteCapabilities = new Set(declaration.remoteAccess?.runCapabilities ?? []); const remoteDatabaseEngines = new Set(declaration.remoteAccess?.databaseEngines ?? []); const transportProfiles = declaration.runtimeProfiles?.transportProfiles ?? []; + const dataTargets = declaration.runtimeProfiles?.dataTargets ?? []; const logSources = new Set((declaration.runtimeProfiles?.logSources ?? []).map((source) => source.key ?? "")); const assetFiles = new Set((declaration.assetFiles ?? []).map((asset) => asset.path ?? "")); const probe = liveData.probe; @@ -1157,6 +1159,17 @@ export function validateSCUMLiveDataManifest(manifest: unknown, manifestDir?: st } }; const transportByKey = (key?: string): Transport | undefined => transportProfiles.find((candidate) => candidate.key === key); + const probeDataTarget = dataTargets.find((candidate) => candidate.key === probe?.targetKey); + const expectedProbeWorkspaceKey = `databases/${String(probe?.targetKey ?? "").replace(/^databases\//, "")}`; + if (!probeDataTarget) { + errors.push(`${location}.probe.targetKey: must reference a declared runtime data target`); + } else { + const probeDataTargetTransport = transportByKey(probeDataTarget.transportKey); + if (probeDataTarget.kind !== "sqlite.snapshot" || probeDataTarget.workspaceKey !== expectedProbeWorkspaceKey || probeDataTarget.refreshPolicy !== "on-demand-snapshot" || !probeDataTargetTransport || probeDataTargetTransport.kind !== "sqlite" || !probeDataTargetTransport.capabilities?.includes("remote.run.db.sqlite.probe")) { + errors.push(`${location}.probe.targetKey: must reference a sqlite snapshot data target for the generated Run workspace`); + } + if (!Number.isInteger(probeDataTarget.maxBytes) || (probeDataTarget.maxBytes ?? 0) < 1 || (probeDataTarget.maxBytes ?? 0) > 1073741824) errors.push(`${location}.probe.targetKey.maxBytes: must be between 1 and 1073741824`); + } const requireRefs = (entry: Record, entryLocation: string, refs: string[]): void => { for (const field of refs) { const ref = entry[field]; diff --git a/plugins/sdk/index.ts b/plugins/sdk/index.ts index c38a721..d3fb184 100644 --- a/plugins/sdk/index.ts +++ b/plugins/sdk/index.ts @@ -655,11 +655,23 @@ export interface RuntimeLogEventDeclaration { export interface RuntimeTransportProfile { key: string; - kind: "file" | "ftp" | "rsync" | "mysql" | "sqlite" | "rcon"; + kind: "file" | "ftp" | "rsync" | "mysql" | "sqlite" | "rcon" | "program"; targetKey?: string; capabilities: RunCapability[]; } +export interface RuntimeDataTargetDeclaration { + key: string; + kind: "sqlite.snapshot"; + transportKey: string; + sourceRootKey: string; + sourcePath: string; + workspaceKey: string; + refreshPolicy: "on-demand-snapshot"; + maxBytes: number; + platforms?: RuntimePlatform[]; +} + export interface RuntimeClientManagerProfile { key: string; displayName?: string; @@ -739,6 +751,7 @@ export interface GamePluginRuntimeProfiles { logSources?: RuntimeLogSource[]; logEvents?: RuntimeLogEventDeclaration[]; transportProfiles?: RuntimeTransportProfile[]; + dataTargets?: RuntimeDataTargetDeclaration[]; clientManagers?: RuntimeClientManagerProfile[]; dllExtensions?: RuntimeDLLExtensionProfile[]; } diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index e592617..3eff0c0 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -212,11 +212,19 @@ describe("plugin manifest validation", () => { expect(manifest.capabilities).toContain("remote.run.db.sqlite.probe"); expect(manifest.remoteAccess.runCapabilities).toContain("remote.run.db.sqlite.probe"); expect(manifest.scumLiveData.probe).toMatchObject({ capability: "remote.run.db.sqlite.probe", targetKey: "scum-database" }); + expect(manifest.runtimeProfiles?.dataTargets?.find((target) => target.key === "scum-database")).toMatchObject({ kind: "sqlite.snapshot", transportKey: "scum-database", sourceRootKey: "server-root", sourcePath: "SCUM/Saved/SaveFiles/SCUM.db", workspaceKey: "databases/scum-database", refreshPolicy: "on-demand-snapshot" }); expect(manifest.scumLiveData.capabilityGates.map((gate) => gate.capability)).toEqual(expect.arrayContaining(["players.read", "squads.read", "vehicles.read", "flags.read", "positions.read", "profile-xml.write", "economy-command.write", "gift-command.write"])); expect(manifest.scumLiveData.capabilityGates.every((gate) => gate.gate === "disabled" && gate.evidenceStatus === "missing")).toBe(true); expect(JSON.stringify(manifest.scumLiveData).toLowerCase()).not.toMatch(/select\s+.+from|sqlite:\/\/|mysql:\/\/|password|credential|socket|hostpath/); }); + it("requires SCUM schema probe targets to have generated Run workspace data targets", () => { + const errors = validateTemporaryScumCompanionManifest((manifest) => { + manifest.runtimeProfiles.dataTargets = []; + }); + expect(errors.some((error) => error.includes("probe.targetKey") && error.includes("runtime data target"))).toBe(true); + }); + it("rejects enabling SCUM live-data gates without compatible evidence and immutable digests", () => { const errors = validateTemporaryScumCompanionManifest((manifest) => { manifest.scumLiveData.capabilityGates[1] = { ...manifest.scumLiveData.capabilityGates[1], gate: "enabled", evidenceStatus: "missing" }; @@ -338,6 +346,7 @@ describe("plugin manifest validation", () => { runtimeProfiles?: { lifecycleProfiles?: Array<{ key: string; capabilities?: string[]; transportKeys?: string[] }>; transportProfiles?: Array<{ key?: string; kind?: string; capabilities?: string[] }>; + dataTargets?: Array>; }; }; const local = manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "run-local"); @@ -348,6 +357,9 @@ describe("plugin manifest validation", () => { expect.objectContaining({ key: "scum-management", kind: "rcon", capabilities: ["remote.run.protected.rcon"] }), expect.objectContaining({ key: "scum-program", kind: "program", capabilities: ["remote.run.program.command"] }) ])); + expect(manifest.runtimeProfiles?.dataTargets).toEqual(expect.arrayContaining([ + expect.objectContaining({ key: "scum-database", kind: "sqlite.snapshot", workspaceKey: "databases/scum-database", refreshPolicy: "on-demand-snapshot" }) + ])); }); it("defines a generated SCUM companion config without inline proof or session material", () => {