From d854d6fda3a04cc2fc43c26117777ea7f1bd0a74 Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Wed, 12 Aug 2026 16:49:05 +0800 Subject: [PATCH] Wire SCUM schema probe durable run path --- .../tasks.md | 13 ++-- platform/api/resource_handlers.go | 1 + platform/api/scum_handlers.go | 20 ++++++ platform/api/scum_handlers_test.go | 67 +++++++++++++++++++ platform/domain/job_channel.go | 1 + platform/domain/observability.go | 2 + platform/domain/resources.go | 2 + platform/domain/scum_live_data.go | 19 ++++++ platform/dto/job_channel.go | 45 +++++++------ platform/dto/resources_test.go | 6 +- platform/dto/scum_live_data.go | 32 +++++++++ platform/model/resources.go | 29 ++++---- platform/model/resources_test.go | 19 +++++- platform/service/job_channel.go | 4 +- platform/service/remote_adapters.go | 24 ++++++- platform/service/remote_adapters_test.go | 34 ++++++++-- platform/service/scum_live_data.go | 58 ++++------------ platform/validator/resources.go | 1 - platform/validator/scum_live_data.go | 13 +++- platform/validator/scum_live_data_test.go | 15 +++++ scripts/local-debug/smoke.sh | 4 +- 21 files changed, 309 insertions(+), 100 deletions(-) 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 e415c73..d40dcfa 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 @@ -11,19 +11,20 @@ - The separately rooted Run task implemented and pushed generic schema-probe support at `git@git.npc0.com:admin343/run.git` commit `6cb6ba3` (`add bounded sqlite schema probe`); its focused protocol/runtime tests and `go test ./...` passed. - The executor advertises `remote.run.db.sqlite.probe`, accepts only package-scoped logical `databases/...` SQLite targets, applies query-only fixed introspection plus binding/job/fence and output bounds, and returns SHA-256-fingerprinted redacted envelopes without SCUM-specific branches or raw database content. -- The active binding `server-run-server-scum-1785923898033` has not yet been rebuilt and deployed from that commit, and its current capability report does not include `remote.run.db.sqlite.probe`. Keep every probe/read/write gate disabled and do not submit a current-service probe until deployment compatibility is verified. +- The active binding `server-run-server-scum-1785923898033` on `枣庄服务器` has been updated and reports `remote.run.db.sqlite.probe`; the endpoint was observed online through the Platform API at `2026-08-12T08:21:27Z` with 28 capabilities including the schema-probe capability. ### Server-management diagnostic evidence (2026-08-12) - Per operator direction, the Run install target is `枣庄服务器` (`FyBDIohqPhRx7Cia`); personal server-management MCP inventory and `test_connection` both succeeded for that device. -- Bounded SSH diagnostics checked only process/service/capability metadata and emitted no raw SQL, database content, credentials, SCUM rows, or database reads. The server has a Windows Run process for `server-run-server-scum-1785923898033` and the current SCUM server process is running. -- The installed Windows Run binary on `枣庄服务器` points at `https://scum.npc0.com` and advertises `remote.run.db.sqlite.query`, but its smoke capability report does not include `remote.run.db.sqlite.probe`; because Run credentials are embedded by the platform distribution build, this browser-repository task did not manually extract secrets or rebuild the binary outside the platform-owned distribution path. -- Because the active Windows Run deployment still cannot be proven to advertise `remote.run.db.sqlite.probe`, no Platform durable schema probe was submitted and current-service evidence tasks remain blocked. +- Bounded SSH diagnostics checked only process/service/capability metadata and emitted no raw SQL, database content, credentials, SCUM rows, or database reads. The server has a Windows Run process for `server-run-server-scum-1785923898033`, the current SCUM server process is running, and `https://scum.npc0.com/healthz` returned `200` from the server side; per operator clarification, `scum.npc0.com` is the NAT entry back to the local Platform. +- 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. - [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. -- [ ] 2.3 If the active binding lacks the minimal bounded query-only probe executor, create/hand off a separately authorized task rooted in the independent Run repository, wait for its tests/commit/deployment evidence, and record that evidence here; do not edit or vendor Run source from this change. -- [ ] 2.4 Use the personal server-management MCP (`list_devices`, `test_connection`, then `ssh_exec` only when needed) for device inventory, connectivity checks, and bounded diagnostics. Execute the actual schema probe only as a Platform durable job through the active authenticated Run binding; never run SCUM SQL directly over SSH or bypass the Run channel. +- [x] 2.3 If the active binding lacks the minimal bounded query-only probe executor, create/hand off a separately authorized task rooted in the independent Run repository, wait for its tests/commit/deployment evidence, and record that evidence here; do not edit or vendor Run source from this change. +- [x] 2.4 Use the personal server-management MCP (`list_devices`, `test_connection`, then `ssh_exec` only when needed) for device inventory, connectivity checks, and bounded diagnostics. Execute the actual schema probe only as a Platform durable job through the active authenticated Run binding; never run SCUM SQL directly over SSH or bypass the Run channel. - [ ] 2.5 Capture `sqlite_master`, applicable read-only PRAGMA metadata, indexes, foreign keys, declared types, cardinalities, and small redacted samples for candidate player, profile/entity, squad/member, vehicle, flag/base, economy, coordinate, and character-profile payload sources. - [ ] 2.6 Verify actual joins and meanings for external player identity, profiles/entities, squad ranks/leaders, flag ownership, vehicle identity, currency units/types, nullable fields, and the real table/column containing character XML; do not assume that `user_profile.template_xml` or any reference-project field exists. - [ ] 2.7 Measure coordinate ranges and update cadence, query latency, lock/busy behavior, snapshot consistency, safe timeout/row limits, and whether a verified companion position source is needed for the advertised realtime-map cadence. diff --git a/platform/api/resource_handlers.go b/platform/api/resource_handlers.go index 7cfc9c9..a9af8ec 100644 --- a/platform/api/resource_handlers.go +++ b/platform/api/resource_handlers.go @@ -103,6 +103,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) { mux.HandleFunc("/api/v1/server-instances/{id}/game-gifts/{catalogId}/revisions", h.serverGameGiftCatalogRevisions) mux.HandleFunc("/api/v1/server-instances/{id}/game-gift-grants", h.serverGameGiftGrants) mux.HandleFunc("/api/v1/server-instances/{id}/game-gift-grants/{grantId}/approve", h.serverGameGiftGrantApprove) + mux.HandleFunc("/api/v1/server-instances/{id}/scum/schema-probe", h.serverSCUMSchemaProbe) mux.HandleFunc("/api/v1/server-instances/{id}/scum/players", h.serverSCUMPlayers) mux.HandleFunc("/api/v1/server-instances/{id}/scum/squads", h.serverSCUMSquads) mux.HandleFunc("/api/v1/server-instances/{id}/scum/squad-members", h.serverSCUMSquadMembers) diff --git a/platform/api/scum_handlers.go b/platform/api/scum_handlers.go index 71a4c29..3ef8630 100644 --- a/platform/api/scum_handlers.go +++ b/platform/api/scum_handlers.go @@ -2,8 +2,28 @@ package api import ( "net/http" + + "browser.local/platform/dto" ) +func (h *coreHandlers) serverSCUMSchemaProbe(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.SCUMSchemaProbeDispatchRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + probeRequest, queued, err := h.core.RequestSCUMSchemaProbeForSession(bearerToken(r), r.PathValue("id"), request.IdempotencyKey) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusAccepted, dto.SCUMSchemaProbeDispatchFromDomain(probeRequest, queued)) +} + func (h *coreHandlers) serverSCUMPlayers(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeMethodNotAllowed(w, http.MethodGet) diff --git a/platform/api/scum_handlers_test.go b/platform/api/scum_handlers_test.go index d2b4717..a0aa557 100644 --- a/platform/api/scum_handlers_test.go +++ b/platform/api/scum_handlers_test.go @@ -2,6 +2,7 @@ package api import ( "net/http" + "strings" "testing" "browser.local/platform/domain" @@ -9,6 +10,72 @@ import ( "browser.local/platform/service" ) +func TestSCUMSchemaProbeEndpointQueuesPlatformScheduledDurableJob(t *testing.T) { + store := repo.NewMemoryStore() + core := service.NewCoreService(store) + plugin, err := core.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: []string{"process.start", 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}}}, + }, + 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}}) + if err != nil { + t.Fatalf("create run endpoint: %v", err) + } + if _, err := core.CreateUser(domain.User{ID: "scum-probe-owner", DisplayName: "SCUM Probe Owner", Email: "scum-probe-owner@example.test", Status: domain.UserStatusActive, Roles: []string{"server-owner"}, PasswordHash: "secret-password"}); err != nil { + t.Fatalf("create owner: %v", err) + } + auth, err := core.LoginUser(domain.UserLogin{Account: "scum-probe-owner@example.test", Password: "secret-password"}) + if err != nil { + t.Fatalf("login: %v", err) + } + instance, err := core.CreateServerInstance(domain.ServerInstance{ID: "server-scum-probe-api", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "SCUM Probe API", OwnerUserID: "scum-probe-owner", State: domain.ServerInstanceStateRunning, ConfigVersion: 1}) + if err != nil { + t.Fatalf("create server: %v", err) + } + if _, err := core.UpdateServerRuntimeBindingForSession(auth.SessionID, instance.ID, domain.RuntimeBindingUpdate{ProfileKey: "local", Bindings: map[string]string{}}); err != nil { + t.Fatalf("create runtime binding: %v", err) + } + + router := NewAuthorizedRouterWithCore(core) + unauthorized := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+instance.ID+"/scum/schema-probe", map[string]string{"idempotencyKey": "probe-api-denied"}, "") + assertStatus(t, unauthorized, http.StatusUnauthorized) + recorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+instance.ID+"/scum/schema-probe", map[string]string{"idempotencyKey": "probe-api-current"}, auth.SessionID) + assertStatus(t, recorder, http.StatusAccepted) + body := recorder.Body.String() + for _, forbidden := range []string{"SCUM.db", "sqlite_master", "SELECT", "C:\\", "secret://", "password"} { + if strings.Contains(body, forbidden) { + t.Fatalf("schema probe dispatch response leaked forbidden material %q: %s", forbidden, body) + } + } + jobs, err := core.ListJobsForSession(auth.SessionID, domain.JobFilter{ServerInstanceID: instance.ID}) + if err != nil { + t.Fatalf("list jobs: %v", err) + } + if len(jobs) != 1 || jobs[0].Capability != domain.JobCapabilityRemoteRunDBSQLiteProbe || jobs[0].TargetKey != "databases/scum-database" || jobs[0].InputRef != "" || len(jobs[0].ExecutionInput.Inputs) != 0 || jobs[0].ExecutionInput.RemoteAdapterKey != "" || jobs[0].ExecutionInput.RemoteAdapterKind != "" { + t.Fatalf("schema probe endpoint did not queue fenced durable job: %+v", jobs) + } + if jobs[0].ExecutionInput.SQLiteSchemaProbe == nil || jobs[0].ExecutionInput.SQLiteSchemaProbe.RequestID != jobs[0].ID || jobs[0].ExecutionInput.SQLiteSchemaProbe.Binding.DatabaseIdentity != "scum-database" || jobs[0].ExecutionInput.SQLiteSchemaProbe.Bounds.MaxResultBytes != 524288 { + t.Fatalf("schema probe endpoint did not attach typed probe request: %+v", jobs[0].ExecutionInput.SQLiteSchemaProbe) + } +} + func TestLegacySCUMEndpointsReturnNotFoundWithoutDispatchingJobs(t *testing.T) { store := repo.NewMemoryStore() core := service.NewCoreService(store) diff --git a/platform/domain/job_channel.go b/platform/domain/job_channel.go index 13dc73c..cea6c57 100644 --- a/platform/domain/job_channel.go +++ b/platform/domain/job_channel.go @@ -422,6 +422,7 @@ func CopyRunJobAssignment(assignment RunJobAssignment) RunJobAssignment { assignment.ExecutionInput.LogSources = CopyRuntimeLogSources(assignment.ExecutionInput.LogSources) assignment.ExecutionInput.DLLExtensions = append([]RuntimeDLLExtensionPlan(nil), assignment.ExecutionInput.DLLExtensions...) assignment.ExecutionInput.SourceRCON = CopyRuntimeSourceRCONPlan(assignment.ExecutionInput.SourceRCON) + assignment.ExecutionInput.SQLiteSchemaProbe = CopySCUMSchemaProbeRequestPtr(assignment.ExecutionInput.SQLiteSchemaProbe) return assignment } diff --git a/platform/domain/observability.go b/platform/domain/observability.go index 541ad9e..14e0686 100644 --- a/platform/domain/observability.go +++ b/platform/domain/observability.go @@ -97,6 +97,7 @@ type RemoteAdapterRequest struct { InputRef string Inputs map[string]string PlatformScheduled bool + SQLiteSchemaProbe *SCUMSchemaProbeRequest } type RemoteAdapterResult struct { @@ -212,6 +213,7 @@ func CopyRemoteAdapterDeclarations(declarations []RemoteAdapterDeclaration) []Re func CopyRemoteAdapterRequest(request RemoteAdapterRequest) RemoteAdapterRequest { request.Inputs = CopyStringMap(request.Inputs) + request.SQLiteSchemaProbe = CopySCUMSchemaProbeRequestPtr(request.SQLiteSchemaProbe) return request } func CopyRemoteAdapterResult(result RemoteAdapterResult) RemoteAdapterResult { return result } diff --git a/platform/domain/resources.go b/platform/domain/resources.go index 6205bac..6b4eb22 100644 --- a/platform/domain/resources.go +++ b/platform/domain/resources.go @@ -1143,6 +1143,7 @@ type JobExecutionInput struct { SourceRCON *RuntimeSourceRCONPlan Deployment *ServerDeploymentDefinition ServerDeploymentPlan *ServerDeploymentPlan + SQLiteSchemaProbe *SCUMSchemaProbeRequest } type ServerDeploymentPlan struct { @@ -2026,6 +2027,7 @@ func CopyJob(job Job) Job { job.ExecutionInput.DLLExtensions = append([]RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...) job.ExecutionInput.SourceRCON = CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON) job.ExecutionInput.ServerDeploymentPlan = CopyServerDeploymentPlan(job.ExecutionInput.ServerDeploymentPlan) + job.ExecutionInput.SQLiteSchemaProbe = CopySCUMSchemaProbeRequestPtr(job.ExecutionInput.SQLiteSchemaProbe) job.ExecutionResult.SQLiteSchemaProbe = CopySCUMSchemaProbeResultPtr(job.ExecutionResult.SQLiteSchemaProbe) job.ExecutionResult.ServerDeploymentEvidence = CopyServerDeploymentEvidence(job.ExecutionResult.ServerDeploymentEvidence) job.ExecutionResult.DeploymentReceipt = CopyServerDeploymentExecutionReceipt(job.ExecutionResult.DeploymentReceipt) diff --git a/platform/domain/scum_live_data.go b/platform/domain/scum_live_data.go index c7144ae..b053086 100644 --- a/platform/domain/scum_live_data.go +++ b/platform/domain/scum_live_data.go @@ -21,6 +21,7 @@ const ( type SCUMCapabilityEvidenceStatus string const ( + SCUMSchemaProbeStatusSucceeded SCUMCapabilityEvidenceStatus = "succeeded" SCUMCapabilityEvidenceMissing SCUMCapabilityEvidenceStatus = "missing" SCUMCapabilityEvidenceCompatible SCUMCapabilityEvidenceStatus = "compatible" SCUMCapabilityEvidenceIncompatible SCUMCapabilityEvidenceStatus = "incompatible" @@ -48,6 +49,16 @@ const ( SCUMSafeErrorDigestMismatch SCUMSafeErrorCode = "digest_mismatch" SCUMSafeErrorEvidenceExpired SCUMSafeErrorCode = "evidence_expired" SCUMSafeErrorInvalidProbePayload SCUMSafeErrorCode = "invalid_probe_payload" + SCUMSafeErrorInvalidRequest SCUMSafeErrorCode = "invalid_request" + SCUMSafeErrorTargetUnavailable SCUMSafeErrorCode = "target_unavailable" + SCUMSafeErrorSourceUnavailable SCUMSafeErrorCode = "source_unavailable" + SCUMSafeErrorSQLiteOpenFailed SCUMSafeErrorCode = "sqlite_open_failed" + SCUMSafeErrorSQLiteReadFailed SCUMSafeErrorCode = "sqlite_read_failed" + SCUMSafeErrorDatabaseBusy SCUMSafeErrorCode = "database_busy" + SCUMSafeErrorTimeout SCUMSafeErrorCode = "timeout" + SCUMSafeErrorCancelled SCUMSafeErrorCode = "cancelled" + SCUMSafeErrorSourceChanged SCUMSafeErrorCode = "source_changed" + SCUMSafeErrorResultLimitExceeded SCUMSafeErrorCode = "result_limit_exceeded" ) type SCUMSafeError struct { @@ -262,6 +273,14 @@ func CopySCUMSchemaProbeResult(value SCUMSchemaProbeResult) SCUMSchemaProbeResul return value } +func CopySCUMSchemaProbeRequestPtr(value *SCUMSchemaProbeRequest) *SCUMSchemaProbeRequest { + if value == nil { + return nil + } + copy := *value + return © +} + func CopySCUMSchemaProbeResultPtr(value *SCUMSchemaProbeResult) *SCUMSchemaProbeResult { if value == nil { return nil diff --git a/platform/dto/job_channel.go b/platform/dto/job_channel.go index 695583d..e153aed 100644 --- a/platform/dto/job_channel.go +++ b/platform/dto/job_channel.go @@ -93,24 +93,31 @@ type RunJobResultRequest struct { } type RunJobExecutionInputBody struct { - WorkspaceScope string `json:"workspaceScope,omitempty"` - Content string `json:"content,omitempty"` - ExpectedVersion int `json:"expectedVersion,omitempty"` - ExpectedChecksum string `json:"expectedChecksum,omitempty"` - MaxReadBytes int `json:"maxReadBytes,omitempty"` - RemoteAdapterKey string `json:"remoteAdapterKey,omitempty"` - RemoteAdapterKind string `json:"remoteAdapterKind,omitempty"` - TimeoutSeconds int `json:"timeoutSeconds,omitempty"` - PluginID string `json:"pluginId,omitempty"` - LifecycleOperation string `json:"lifecycleOperation,omitempty"` - TargetVersion string `json:"targetVersion,omitempty"` - Inputs map[string]string `json:"inputs,omitempty"` - LogSource *RuntimeLogSourceBody `json:"logSource,omitempty"` - LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"` - DLLExtensions []RuntimeDLLExtensionPlanBody `json:"dllExtensions,omitempty"` - SourceRCON *RuntimeSourceRCONPlanBody `json:"sourceRcon,omitempty"` - Deployment *ServerDeploymentExecutionBody `json:"deployment,omitempty"` - ServerDeploymentPlan *ServerDeploymentPlanBody `json:"serverDeploymentPlan,omitempty"` + WorkspaceScope string `json:"workspaceScope,omitempty"` + Content string `json:"content,omitempty"` + ExpectedVersion int `json:"expectedVersion,omitempty"` + ExpectedChecksum string `json:"expectedChecksum,omitempty"` + MaxReadBytes int `json:"maxReadBytes,omitempty"` + RemoteAdapterKey string `json:"remoteAdapterKey,omitempty"` + RemoteAdapterKind string `json:"remoteAdapterKind,omitempty"` + TimeoutSeconds int `json:"timeoutSeconds,omitempty"` + PluginID string `json:"pluginId,omitempty"` + LifecycleOperation string `json:"lifecycleOperation,omitempty"` + TargetVersion string `json:"targetVersion,omitempty"` + Inputs map[string]string `json:"inputs,omitempty"` + LogSource *RuntimeLogSourceBody `json:"logSource,omitempty"` + LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"` + DLLExtensions []RuntimeDLLExtensionPlanBody `json:"dllExtensions,omitempty"` + SourceRCON *RuntimeSourceRCONPlanBody `json:"sourceRcon,omitempty"` + Deployment *ServerDeploymentExecutionBody `json:"deployment,omitempty"` + ServerDeploymentPlan *ServerDeploymentPlanBody `json:"serverDeploymentPlan,omitempty"` + SQLiteSchemaProbe *RunSQLiteSchemaProbeRequestBody `json:"sqliteSchemaProbe,omitempty"` +} + +type RunSQLiteSchemaProbeRequestBody struct { + RequestID string `json:"requestId"` + Binding SCUMBindingIdentityDTO `json:"binding"` + Limits SCUMSchemaProbeBoundsDTO `json:"limits"` } type ServerDeploymentPlanBody struct { @@ -664,7 +671,7 @@ func RunJobAssignmentFromDomain(assignment domain.RunJobAssignment) RunJobAssign State: assignment.State, Progress: progressReportFromDomain(assignment.Progress), ResultRef: assignment.ResultRef, - ExecutionInput: RunJobExecutionInputBody{WorkspaceScope: assignment.ExecutionInput.WorkspaceScope, Content: assignment.ExecutionInput.Content, ExpectedVersion: assignment.ExecutionInput.ExpectedVersion, ExpectedChecksum: assignment.ExecutionInput.ExpectedChecksum, MaxReadBytes: assignment.ExecutionInput.MaxReadBytes, RemoteAdapterKey: assignment.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: assignment.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: assignment.ExecutionInput.TimeoutSeconds, PluginID: assignment.ExecutionInput.PluginID, LifecycleOperation: assignment.ExecutionInput.LifecycleOperation, TargetVersion: assignment.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(assignment.ExecutionInput.Inputs), LogSource: runtimeLogSourceFromDomain(assignment.ExecutionInput.LogSource), LogSources: runtimeLogSourcesFromDomain(assignment.ExecutionInput.LogSources), DLLExtensions: dllExtensionPlansFromDomain(assignment.ExecutionInput.DLLExtensions), SourceRCON: runtimeSourceRCONPlanFromDomain(assignment.ExecutionInput.SourceRCON), Deployment: deploymentExecutionFromDomain(assignment.ExecutionInput.Deployment), ServerDeploymentPlan: serverDeploymentPlanFromDomain(assignment.ExecutionInput.ServerDeploymentPlan)}, + ExecutionInput: RunJobExecutionInputBody{WorkspaceScope: assignment.ExecutionInput.WorkspaceScope, Content: assignment.ExecutionInput.Content, ExpectedVersion: assignment.ExecutionInput.ExpectedVersion, ExpectedChecksum: assignment.ExecutionInput.ExpectedChecksum, MaxReadBytes: assignment.ExecutionInput.MaxReadBytes, RemoteAdapterKey: assignment.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: assignment.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: assignment.ExecutionInput.TimeoutSeconds, PluginID: assignment.ExecutionInput.PluginID, LifecycleOperation: assignment.ExecutionInput.LifecycleOperation, TargetVersion: assignment.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(assignment.ExecutionInput.Inputs), LogSource: runtimeLogSourceFromDomain(assignment.ExecutionInput.LogSource), LogSources: runtimeLogSourcesFromDomain(assignment.ExecutionInput.LogSources), DLLExtensions: dllExtensionPlansFromDomain(assignment.ExecutionInput.DLLExtensions), SourceRCON: runtimeSourceRCONPlanFromDomain(assignment.ExecutionInput.SourceRCON), Deployment: deploymentExecutionFromDomain(assignment.ExecutionInput.Deployment), ServerDeploymentPlan: serverDeploymentPlanFromDomain(assignment.ExecutionInput.ServerDeploymentPlan), SQLiteSchemaProbe: runSQLiteSchemaProbeRequestPtrFromDomain(assignment.ExecutionInput.SQLiteSchemaProbe)}, LeaseToken: assignment.LeaseToken, Attempt: assignment.Attempt, FencingToken: assignment.FencingToken, diff --git a/platform/dto/resources_test.go b/platform/dto/resources_test.go index a8cebc1..24b0e40 100644 --- a/platform/dto/resources_test.go +++ b/platform/dto/resources_test.go @@ -41,11 +41,11 @@ func TestRunJobResultRequestParsesSQLiteSchemaProbeEnvelope(t *testing.T) { "state":"succeeded", "progress":{"percent":100,"message":"done"}, "executionResult":{ - "kind":"sqlite.schema.probe", + "kind":"sqlite.schema-probe", "sqliteSchemaProbe":{ "requestId":"job-probe", "jobId":"job-probe", - "binding":{"serverInstanceId":"server-scum","runBindingId":"runtime-binding-server-scum","runEndpointId":"run-local","pluginId":"server.scum","pluginVersion":"1.0.0","adapterVersion":"scum-live-data-v0","gameVersion":"1.0.0","databaseIdentity":"logical:scum-database"}, + "binding":{"serverInstanceId":"server-scum","runBindingId":"runtime-binding-server-scum","runEndpointId":"run-local","pluginId":"server.scum","pluginVersion":"1.0.0","adapterVersion":"scum-live-data-v0","gameVersion":"1.0.0","databaseIdentity":"scum-database"}, "status":"compatible", "schemaFingerprint":"sha256:` + strings.Repeat("a", 64) + `", "observedAt":"2026-08-12T00:00:00Z", @@ -60,7 +60,7 @@ func TestRunJobResultRequestParsesSQLiteSchemaProbeEnvelope(t *testing.T) { } domainRequest := request.ToDomain() probe := domainRequest.ExecutionResult.SQLiteSchemaProbe - if probe == nil || probe.JobID != "job-probe" || probe.Binding.DatabaseIdentity != "logical:scum-database" || probe.ResultDigest != "sha256:"+strings.Repeat("b", 64) { + if probe == nil || probe.JobID != "job-probe" || probe.Binding.DatabaseIdentity != "scum-database" || probe.ResultDigest != "sha256:"+strings.Repeat("b", 64) { t.Fatalf("sqliteSchemaProbe envelope did not parse: %+v", probe) } } diff --git a/platform/dto/scum_live_data.go b/platform/dto/scum_live_data.go index 0c4bdc4..14ac3d8 100644 --- a/platform/dto/scum_live_data.go +++ b/platform/dto/scum_live_data.go @@ -36,6 +36,15 @@ type SCUMSchemaProbeRequestDTO struct { RequestedAt time.Time `json:"requestedAt"` } +type SCUMSchemaProbeDispatchRequest struct { + IdempotencyKey string `json:"idempotencyKey"` +} + +type SCUMSchemaProbeDispatchResponse struct { + ProbeRequest SCUMSchemaProbeRequestDTO `json:"probeRequest"` + QueuedJob RemoteAdapterResponse `json:"queuedJob"` +} + type SCUMSafeErrorDTO struct { Code string `json:"code"` Message string `json:"message,omitempty"` @@ -98,6 +107,29 @@ func SCUMSchemaProbeRequestToDomain(value SCUMSchemaProbeRequestDTO) domain.SCUM return domain.SCUMSchemaProbeRequest{RequestID: value.RequestID, JobID: value.JobID, Binding: scumBindingIdentityToDomain(value.Binding), Bounds: scumProbeBoundsToDomain(value.Bounds), RequestedAt: value.RequestedAt} } +func SCUMSchemaProbeRequestFromDomain(value domain.SCUMSchemaProbeRequest) SCUMSchemaProbeRequestDTO { + return SCUMSchemaProbeRequestDTO{RequestID: value.RequestID, JobID: value.JobID, Binding: scumBindingIdentityFromDomain(value.Binding), Bounds: scumProbeBoundsFromDomain(value.Bounds), RequestedAt: value.RequestedAt} +} + +func SCUMSchemaProbeRequestPtrFromDomain(value *domain.SCUMSchemaProbeRequest) *SCUMSchemaProbeRequestDTO { + if value == nil { + return nil + } + result := SCUMSchemaProbeRequestFromDomain(*value) + return &result +} + +func runSQLiteSchemaProbeRequestPtrFromDomain(value *domain.SCUMSchemaProbeRequest) *RunSQLiteSchemaProbeRequestBody { + if value == nil { + return nil + } + return &RunSQLiteSchemaProbeRequestBody{RequestID: value.RequestID, Binding: scumBindingIdentityFromDomain(value.Binding), Limits: scumProbeBoundsFromDomain(value.Bounds)} +} + +func SCUMSchemaProbeDispatchFromDomain(request domain.SCUMSchemaProbeRequest, queued domain.RemoteAdapterResult) SCUMSchemaProbeDispatchResponse { + return SCUMSchemaProbeDispatchResponse{ProbeRequest: SCUMSchemaProbeRequestFromDomain(request), QueuedJob: RemoteAdapterFromDomain(queued)} +} + func SCUMSchemaProbeResultToDomain(value SCUMSchemaProbeResultDTO) domain.SCUMSchemaProbeResult { objects := make([]domain.SCUMSchemaObjectEvidence, len(value.Objects)) for index, object := range value.Objects { diff --git a/platform/model/resources.go b/platform/model/resources.go index 419a44f..92e09f2 100644 --- a/platform/model/resources.go +++ b/platform/model/resources.go @@ -309,18 +309,21 @@ type JobExecutionInput struct { Deployment *domain.ServerDeploymentDefinition `json:"deployment,omitempty" db:"deployment"` // ServerDeploymentPlan is the legacy generic deployment-plan payload. ServerDeploymentPlan *domain.ServerDeploymentPlan `json:"serverDeploymentPlan,omitempty" db:"server_deployment_plan"` + // SQLiteSchemaProbe is the typed, bounded diagnostic request delivered only to fenced Run assignments. + SQLiteSchemaProbe *domain.SCUMSchemaProbeRequest `json:"sqliteSchemaProbe,omitempty" db:"sqlite_schema_probe"` } type JobExecutionResult struct { - Kind string `json:"kind,omitempty" db:"kind"` - ProcessState string `json:"processState,omitempty" db:"process_state"` - ExitClassification string `json:"exitClassification,omitempty" db:"exit_classification"` - ExitCode int `json:"exitCode,omitempty" db:"exit_code"` - Version int `json:"version,omitempty" db:"version"` - Checksum string `json:"checksum,omitempty" db:"checksum"` - SizeBytes int64 `json:"sizeBytes,omitempty" db:"size_bytes"` - AuditSummary string `json:"auditSummary,omitempty" db:"audit_summary"` - Content string `json:"content,omitempty" db:"content"` + Kind string `json:"kind,omitempty" db:"kind"` + ProcessState string `json:"processState,omitempty" db:"process_state"` + ExitClassification string `json:"exitClassification,omitempty" db:"exit_classification"` + ExitCode int `json:"exitCode,omitempty" db:"exit_code"` + Version int `json:"version,omitempty" db:"version"` + Checksum string `json:"checksum,omitempty" db:"checksum"` + SizeBytes int64 `json:"sizeBytes,omitempty" db:"size_bytes"` + AuditSummary string `json:"auditSummary,omitempty" db:"audit_summary"` + Content string `json:"content,omitempty" db:"content"` + SQLiteSchemaProbe *domain.SCUMSchemaProbeResult `json:"sqliteSchemaProbe,omitempty" db:"sqlite_schema_probe"` } type Job struct { @@ -913,7 +916,7 @@ func executionInputFromDomain(input domain.JobExecutionInput) JobExecutionInput copy := domain.CopyServerDeploymentDefinition(*input.Deployment) deployment = © } - return JobExecutionInput{WorkspaceScope: input.WorkspaceScope, Content: input.Content, ExpectedVersion: input.ExpectedVersion, ExpectedChecksum: input.ExpectedChecksum, MaxReadBytes: input.MaxReadBytes, RemoteAdapterKey: input.RemoteAdapterKey, RemoteAdapterKind: input.RemoteAdapterKind, TimeoutSeconds: input.TimeoutSeconds, PluginID: input.PluginID, LifecycleOperation: input.LifecycleOperation, TargetVersion: input.TargetVersion, Inputs: domain.CopyStringMap(input.Inputs), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), input.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(input.SourceRCON), Deployment: deployment, ServerDeploymentPlan: domain.CopyServerDeploymentPlan(input.ServerDeploymentPlan)} + return JobExecutionInput{WorkspaceScope: input.WorkspaceScope, Content: input.Content, ExpectedVersion: input.ExpectedVersion, ExpectedChecksum: input.ExpectedChecksum, MaxReadBytes: input.MaxReadBytes, RemoteAdapterKey: input.RemoteAdapterKey, RemoteAdapterKind: input.RemoteAdapterKind, TimeoutSeconds: input.TimeoutSeconds, PluginID: input.PluginID, LifecycleOperation: input.LifecycleOperation, TargetVersion: input.TargetVersion, Inputs: domain.CopyStringMap(input.Inputs), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), input.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(input.SourceRCON), Deployment: deployment, ServerDeploymentPlan: domain.CopyServerDeploymentPlan(input.ServerDeploymentPlan), SQLiteSchemaProbe: domain.CopySCUMSchemaProbeRequestPtr(input.SQLiteSchemaProbe)} } func (input JobExecutionInput) ToDomain() domain.JobExecutionInput { @@ -922,15 +925,15 @@ func (input JobExecutionInput) ToDomain() domain.JobExecutionInput { copy := domain.CopyServerDeploymentDefinition(*input.Deployment) deployment = © } - return domain.JobExecutionInput{WorkspaceScope: input.WorkspaceScope, Content: input.Content, ExpectedVersion: input.ExpectedVersion, ExpectedChecksum: input.ExpectedChecksum, MaxReadBytes: input.MaxReadBytes, RemoteAdapterKey: input.RemoteAdapterKey, RemoteAdapterKind: input.RemoteAdapterKind, TimeoutSeconds: input.TimeoutSeconds, PluginID: input.PluginID, LifecycleOperation: input.LifecycleOperation, TargetVersion: input.TargetVersion, Inputs: domain.CopyStringMap(input.Inputs), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), input.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(input.SourceRCON), Deployment: deployment, ServerDeploymentPlan: domain.CopyServerDeploymentPlan(input.ServerDeploymentPlan)} + return domain.JobExecutionInput{WorkspaceScope: input.WorkspaceScope, Content: input.Content, ExpectedVersion: input.ExpectedVersion, ExpectedChecksum: input.ExpectedChecksum, MaxReadBytes: input.MaxReadBytes, RemoteAdapterKey: input.RemoteAdapterKey, RemoteAdapterKind: input.RemoteAdapterKind, TimeoutSeconds: input.TimeoutSeconds, PluginID: input.PluginID, LifecycleOperation: input.LifecycleOperation, TargetVersion: input.TargetVersion, Inputs: domain.CopyStringMap(input.Inputs), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), input.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(input.SourceRCON), Deployment: deployment, ServerDeploymentPlan: domain.CopyServerDeploymentPlan(input.ServerDeploymentPlan), SQLiteSchemaProbe: domain.CopySCUMSchemaProbeRequestPtr(input.SQLiteSchemaProbe)} } func executionResultFromDomain(result domain.JobExecutionResult) JobExecutionResult { - return JobExecutionResult{Kind: result.Kind, ProcessState: result.ProcessState, ExitClassification: result.ExitClassification, ExitCode: result.ExitCode, Version: result.Version, Checksum: result.Checksum, SizeBytes: result.SizeBytes, AuditSummary: result.AuditSummary, Content: result.Content} + return JobExecutionResult{Kind: result.Kind, ProcessState: result.ProcessState, ExitClassification: result.ExitClassification, ExitCode: result.ExitCode, Version: result.Version, Checksum: result.Checksum, SizeBytes: result.SizeBytes, AuditSummary: result.AuditSummary, Content: result.Content, SQLiteSchemaProbe: domain.CopySCUMSchemaProbeResultPtr(result.SQLiteSchemaProbe)} } func (result JobExecutionResult) ToDomain() domain.JobExecutionResult { - return domain.JobExecutionResult{Kind: result.Kind, ProcessState: result.ProcessState, ExitClassification: result.ExitClassification, ExitCode: result.ExitCode, Version: result.Version, Checksum: result.Checksum, SizeBytes: result.SizeBytes, AuditSummary: result.AuditSummary, Content: result.Content} + return domain.JobExecutionResult{Kind: result.Kind, ProcessState: result.ProcessState, ExitClassification: result.ExitClassification, ExitCode: result.ExitCode, Version: result.Version, Checksum: result.Checksum, SizeBytes: result.SizeBytes, AuditSummary: result.AuditSummary, Content: result.Content, SQLiteSchemaProbe: domain.CopySCUMSchemaProbeResultPtr(result.SQLiteSchemaProbe)} } func (policy JobRetryPolicy) ToDomain() domain.JobRetryPolicy { diff --git a/platform/model/resources_test.go b/platform/model/resources_test.go index 51959ab..52c6637 100644 --- a/platform/model/resources_test.go +++ b/platform/model/resources_test.go @@ -119,6 +119,12 @@ func TestJobExecutionInputModelRoundTripPreservesLifecycleMetadata(t *testing.T) PluginID: "game.scum", Prerequisites: []domain.RuntimeServerPrerequisite{{Key: "steamcmd", Kind: "tool"}}, }, + SQLiteSchemaProbe: &domain.SCUMSchemaProbeRequest{ + RequestID: "probe-1", + JobID: "job-1", + Binding: domain.SCUMBindingIdentity{ServerInstanceID: "server-1", RunBindingID: "runtime-binding-1", RunEndpointID: "run-1", PluginID: "game.scum", PluginVersion: "0.1.6", AdapterVersion: "adapter-1", DatabaseIdentity: "scum-database"}, + Bounds: domain.DefaultSCUMSchemaProbeBounds(), + }, } row := executionInputFromDomain(source) @@ -130,8 +136,13 @@ func TestJobExecutionInputModelRoundTripPreservesLifecycleMetadata(t *testing.T) if row.Deployment == nil || row.Deployment.CreateInputs["maxPlayers"] != "128" { t.Fatalf("expected model deployment inputs to be isolated from source mutation, row=%+v", row.Deployment) } + source.SQLiteSchemaProbe.Bounds.MaxObjects = 1 + if row.SQLiteSchemaProbe == nil || row.SQLiteSchemaProbe.Bounds.MaxObjects != domain.DefaultSCUMSchemaProbeBounds().MaxObjects { + t.Fatalf("expected model schema probe to be isolated from source mutation, row=%+v", row.SQLiteSchemaProbe) + } source.Inputs["playerId"] = "steam-123" source.Deployment.CreateInputs["maxPlayers"] = "128" + source.SQLiteSchemaProbe.Bounds.MaxObjects = domain.DefaultSCUMSchemaProbeBounds().MaxObjects row.Inputs["playerId"] = "row-mutated" if source.Inputs["playerId"] != "steam-123" { t.Fatalf("expected source execution inputs to be isolated from model mutation, source=%+v", source.Inputs) @@ -140,8 +151,13 @@ func TestJobExecutionInputModelRoundTripPreservesLifecycleMetadata(t *testing.T) if source.Deployment.CreateInputs["maxPlayers"] != "128" { t.Fatalf("expected source deployment inputs to be isolated from model mutation, source=%+v", source.Deployment) } + row.SQLiteSchemaProbe.Bounds.MaxObjects = 2 + if source.SQLiteSchemaProbe.Bounds.MaxObjects != domain.DefaultSCUMSchemaProbeBounds().MaxObjects { + t.Fatalf("expected source schema probe to be isolated from model mutation, source=%+v", source.SQLiteSchemaProbe) + } row.Inputs["playerId"] = "steam-123" row.Deployment.CreateInputs["maxPlayers"] = "128" + row.SQLiteSchemaProbe.Bounds.MaxObjects = domain.DefaultSCUMSchemaProbeBounds().MaxObjects roundTrip := row.ToDomain() if !reflect.DeepEqual(roundTrip, source) { @@ -149,7 +165,8 @@ func TestJobExecutionInputModelRoundTripPreservesLifecycleMetadata(t *testing.T) } roundTrip.Inputs["playerId"] = "mutated" roundTrip.Deployment.CreateInputs["maxPlayers"] = "16" - if source.Inputs["playerId"] != "steam-123" || row.Inputs["playerId"] != "steam-123" || source.Deployment.CreateInputs["maxPlayers"] != "128" || row.Deployment.CreateInputs["maxPlayers"] != "128" { + roundTrip.SQLiteSchemaProbe.Bounds.MaxObjects = 3 + if source.Inputs["playerId"] != "steam-123" || row.Inputs["playerId"] != "steam-123" || source.Deployment.CreateInputs["maxPlayers"] != "128" || row.Deployment.CreateInputs["maxPlayers"] != "128" || source.SQLiteSchemaProbe.Bounds.MaxObjects != domain.DefaultSCUMSchemaProbeBounds().MaxObjects || row.SQLiteSchemaProbe.Bounds.MaxObjects != domain.DefaultSCUMSchemaProbeBounds().MaxObjects { t.Fatalf("expected execution inputs to round-trip without aliasing, source=%+v row=%+v", source, row) } } diff --git a/platform/service/job_channel.go b/platform/service/job_channel.go index 3512216..96228e1 100644 --- a/platform/service/job_channel.go +++ b/platform/service/job_channel.go @@ -637,7 +637,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) { + if isProtectedRequestCapability(job.Capability) || job.Capability == domain.JobCapabilityRemoteRunDBSQLiteProbe { fencingToken = uint64(job.Attempt) } return domain.RunJobAssignment{ @@ -651,7 +651,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)}, + 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)}, LeaseToken: leaseToken, Attempt: job.Attempt, FencingToken: fencingToken, diff --git a/platform/service/remote_adapters.go b/platform/service/remote_adapters.go index cb8e4cb..3c9fbc0 100644 --- a/platform/service/remote_adapters.go +++ b/platform/service/remote_adapters.go @@ -86,20 +86,30 @@ func (svc *CoreService) RequestRemoteAdapterForSession(sessionID string, request return domain.RemoteAdapterResult{}, validationError("remote adapter timeout or retry exceeds declaration") } inputRef := request.InputRef - if inputRef == "" { + isSchemaProbe := request.Capability == domain.JobCapabilityRemoteRunDBSQLiteProbe && request.PlatformScheduled && request.SQLiteSchemaProbe != nil + if inputRef == "" && !isSchemaProbe { 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: request.TargetKey, + TargetKey: 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: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), RemoteAdapterKey: selected.Key, RemoteAdapterKind: string(selected.Kind), TimeoutSeconds: timeout, Inputs: domain.CopyStringMap(request.Inputs)}, + ExecutionInput: executionInput, } created, err := svc.CreateJob(job) if err != nil { @@ -116,6 +126,14 @@ 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 { diff --git a/platform/service/remote_adapters_test.go b/platform/service/remote_adapters_test.go index 3845da5..ac13bd4 100644 --- a/platform/service/remote_adapters_test.go +++ b/platform/service/remote_adapters_test.go @@ -140,11 +140,11 @@ func TestSCUMSchemaProbeDispatchIsPlatformScheduledAndFenced(t *testing.T) { if err != nil { t.Fatalf("get probe job: %v", err) } - if job.Capability != domain.JobCapabilityRemoteRunDBSQLiteProbe || job.TargetKey != "scum-database" || job.ExecutionInput.RemoteAdapterKind != string(domain.RemoteAdapterDatabase) || job.ExecutionInput.Inputs["databaseIdentity"] != "logical:scum-database" { + 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.Inputs["jobId"] != job.ID || job.ExecutionInput.Inputs["requestId"] != probeRequest.RequestID || job.ExecutionInput.Inputs["maxResultBytes"] != "524288" { - t.Fatalf("probe inputs are not fenced and bounded: %+v", job.ExecutionInput.Inputs) + 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() @@ -158,8 +158,32 @@ func TestSCUMSchemaProbeDispatchIsPlatformScheduledAndFenced(t *testing.T) { if err != nil { t.Fatalf("claim probe job: %v", err) } - if !claim.HasJob || claim.Job == nil || claim.Job.ExecutionInput.Inputs["adapterVersion"] != "scum-live-data-v0" || claim.Job.ExecutionInput.Inputs["targetKey"] != "scum-database" { - t.Fatalf("claimed probe job lost typed inputs: %+v", claim.Job) + 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" diff --git a/platform/service/scum_live_data.go b/platform/service/scum_live_data.go index 487eba2..bc5e61a 100644 --- a/platform/service/scum_live_data.go +++ b/platform/service/scum_live_data.go @@ -2,7 +2,6 @@ package service import ( "errors" - "strconv" "strings" "browser.local/platform/domain" @@ -10,7 +9,7 @@ import ( "browser.local/platform/validator" ) -const scumSchemaProbeExecutionKind = "sqlite.schema.probe" +const scumSchemaProbeExecutionKind = "sqlite.schema-probe" func (svc *CoreService) RequestSCUMSchemaProbeForSession(sessionID, serverInstanceID, idempotencyKey string) (domain.SCUMSchemaProbeRequest, domain.RemoteAdapterResult, error) { idempotencyKey = strings.TrimSpace(idempotencyKey) @@ -65,7 +64,7 @@ func (svc *CoreService) RequestSCUMSchemaProbeForSession(sessionID, serverInstan PluginID: plugin.ID, PluginVersion: plugin.Version, AdapterVersion: adapterVersion, - DatabaseIdentity: "logical:" + probe.TargetKey, + DatabaseIdentity: scumSchemaProbeDatabaseIdentity(probe.TargetKey), }, Bounds: bounds, RequestedAt: svc.now(), @@ -81,9 +80,8 @@ func (svc *CoreService) RequestSCUMSchemaProbeForSession(sessionID, serverInstan TimeoutSeconds: scumSchemaProbeTimeoutSeconds(bounds), MaxAttempts: 1, IdempotencyKey: idempotencyKey, - InputRef: "input://scum-schema-probe/" + instance.ID + "/" + idempotencyKey, - Inputs: scumSchemaProbeInputs(request, probe.TargetKey), PlatformScheduled: true, + SQLiteSchemaProbe: &request, }) if err != nil { return domain.SCUMSchemaProbeRequest{}, domain.RemoteAdapterResult{}, err @@ -91,6 +89,11 @@ func (svc *CoreService) RequestSCUMSchemaProbeForSession(sessionID, serverInstan return request, result, nil } +func scumSchemaProbeDatabaseIdentity(targetKey string) string { + trimmed := strings.TrimSpace(targetKey) + return strings.TrimPrefix(trimmed, "databases/") +} + func scumSchemaProbeAdapterVersion(manifest domain.SCUMLiveDataManifest) string { for _, gate := range manifest.CapabilityGates { if gate.Capability == domain.SCUMDataCapabilitySchemaProbe { @@ -116,51 +119,18 @@ func scumSchemaProbeTimeoutSeconds(bounds domain.SCUMSchemaProbeBounds) int { return seconds } -func scumSchemaProbeInputs(request domain.SCUMSchemaProbeRequest, targetKey string) map[string]string { - return map[string]string{ - "requestId": request.RequestID, - "jobId": request.JobID, - "targetKey": targetKey, - "serverInstanceId": request.Binding.ServerInstanceID, - "runBindingId": request.Binding.RunBindingID, - "runEndpointId": request.Binding.RunEndpointID, - "pluginId": request.Binding.PluginID, - "pluginVersion": request.Binding.PluginVersion, - "adapterVersion": request.Binding.AdapterVersion, - "gameVersion": request.Binding.GameVersion, - "databaseIdentity": request.Binding.DatabaseIdentity, - "maxObjects": strconv.Itoa(request.Bounds.MaxObjects), - "maxColumnsPerObject": strconv.Itoa(request.Bounds.MaxColumnsPerObject), - "maxIndexesPerObject": strconv.Itoa(request.Bounds.MaxIndexesPerObject), - "maxForeignKeys": strconv.Itoa(request.Bounds.MaxForeignKeys), - "maxCardinalityReads": strconv.Itoa(request.Bounds.MaxCardinalityReads), - "maxSampleRows": strconv.Itoa(request.Bounds.MaxSampleRows), - "timeoutMs": strconv.Itoa(request.Bounds.TimeoutMS), - "maxResultBytes": strconv.Itoa(request.Bounds.MaxResultBytes), - } -} - -func scumSchemaProbeBindingFromInputs(inputs map[string]string) domain.SCUMBindingIdentity { - return domain.SCUMBindingIdentity{ - ServerInstanceID: inputs["serverInstanceId"], - RunBindingID: inputs["runBindingId"], - RunEndpointID: inputs["runEndpointId"], - PluginID: inputs["pluginId"], - PluginVersion: inputs["pluginVersion"], - AdapterVersion: inputs["adapterVersion"], - GameVersion: inputs["gameVersion"], - DatabaseIdentity: inputs["databaseIdentity"], - } -} - func validateSCUMSchemaProbeResultForJob(job domain.Job, result domain.SCUMSchemaProbeResult) error { if err := validator.ValidateSCUMSchemaProbeResult(result); err != nil { return err } - if result.JobID != job.ID || result.JobID != job.ExecutionInput.Inputs["jobId"] || result.RequestID != job.ExecutionInput.Inputs["requestId"] { + 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, scumSchemaProbeBindingFromInputs(job.ExecutionInput.Inputs)) { + if !sameSCUMSchemaProbeBinding(result.Binding, expected.Binding) { return validationError("SQLite schema probe result does not match leased binding identity") } return nil diff --git a/platform/validator/resources.go b/platform/validator/resources.go index 99632fd..c045e77 100644 --- a/platform/validator/resources.go +++ b/platform/validator/resources.go @@ -2240,7 +2240,6 @@ func remoteCapabilityRequiresInputRef(capability string) bool { domain.JobCapabilityRemoteRsyncWrite, domain.JobCapabilityRemoteRunFilesWrite, domain.JobCapabilityRemoteRunDBMySQLQuery, - domain.JobCapabilityRemoteRunDBSQLiteProbe, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedSQL, domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProgram: diff --git a/platform/validator/scum_live_data.go b/platform/validator/scum_live_data.go index 58fb675..60c374b 100644 --- a/platform/validator/scum_live_data.go +++ b/platform/validator/scum_live_data.go @@ -34,7 +34,7 @@ func ValidateSCUMSchemaProbeResult(result domain.SCUMSchemaProbeResult) error { violations = appendRequired(violations, "requestId", result.RequestID) violations = appendRequired(violations, "jobId", result.JobID) violations = append(violations, validateSCUMBindingIdentity("binding", result.Binding)...) - if !validSCUMCapabilityEvidenceStatus(result.Status) { + if !validSCUMSchemaProbeResultStatus(result.Status) { violations = append(violations, "status is invalid") } if result.SchemaFingerprint != "" && !validSCUMFingerprint(result.SchemaFingerprint) { @@ -273,9 +273,18 @@ func validSCUMCapabilityEvidenceStatus(value domain.SCUMCapabilityEvidenceStatus } } +func validSCUMSchemaProbeResultStatus(value domain.SCUMCapabilityEvidenceStatus) bool { + switch value { + case domain.SCUMSchemaProbeStatusSucceeded, domain.SCUMCapabilityEvidenceCompatible, domain.SCUMCapabilityEvidenceFailed: + return true + default: + return false + } +} + func validSCUMSafeErrorCode(value domain.SCUMSafeErrorCode) bool { switch value { - case "", domain.SCUMSafeErrorNone, domain.SCUMSafeErrorProbeExecutorAbsent, domain.SCUMSafeErrorProbeMissing, domain.SCUMSafeErrorProbeFailed, domain.SCUMSafeErrorSchemaIncompatible, domain.SCUMSafeErrorBindingMismatch, domain.SCUMSafeErrorAdapterMismatch, domain.SCUMSafeErrorFingerprintMismatch, domain.SCUMSafeErrorDigestMismatch, domain.SCUMSafeErrorEvidenceExpired, domain.SCUMSafeErrorInvalidProbePayload: + case "", domain.SCUMSafeErrorNone, domain.SCUMSafeErrorProbeExecutorAbsent, domain.SCUMSafeErrorProbeMissing, domain.SCUMSafeErrorProbeFailed, domain.SCUMSafeErrorSchemaIncompatible, domain.SCUMSafeErrorBindingMismatch, domain.SCUMSafeErrorAdapterMismatch, domain.SCUMSafeErrorFingerprintMismatch, domain.SCUMSafeErrorDigestMismatch, domain.SCUMSafeErrorEvidenceExpired, domain.SCUMSafeErrorInvalidProbePayload, domain.SCUMSafeErrorInvalidRequest, domain.SCUMSafeErrorTargetUnavailable, domain.SCUMSafeErrorSourceUnavailable, domain.SCUMSafeErrorSQLiteOpenFailed, domain.SCUMSafeErrorSQLiteReadFailed, domain.SCUMSafeErrorDatabaseBusy, domain.SCUMSafeErrorTimeout, domain.SCUMSafeErrorCancelled, domain.SCUMSafeErrorSourceChanged, domain.SCUMSafeErrorResultLimitExceeded: return true default: return false diff --git a/platform/validator/scum_live_data_test.go b/platform/validator/scum_live_data_test.go index 0e19332..851d880 100644 --- a/platform/validator/scum_live_data_test.go +++ b/platform/validator/scum_live_data_test.go @@ -54,6 +54,21 @@ func TestValidateSCUMSchemaProbeResultRejectsRawSQLAndRows(t *testing.T) { } } +func TestValidateSCUMSchemaProbeResultAcceptsRunTerminalStatuses(t *testing.T) { + succeeded := domain.SCUMSchemaProbeResult{RequestID: "probe-1", JobID: "job-1", Binding: validatorSCUMBinding(), Status: domain.SCUMSchemaProbeStatusSucceeded, SchemaFingerprint: scumProbeHash, ObservedAt: time.Now(), ResultDigest: scumProbeHash, Limits: domain.DefaultSCUMSchemaProbeBounds(), SafeError: domain.SCUMSafeError{Code: domain.SCUMSafeErrorNone}} + if err := ValidateSCUMSchemaProbeResult(succeeded); err != nil { + t.Fatalf("expected succeeded Run probe result to validate, got %v", err) + } + + failed := succeeded + failed.Status = domain.SCUMCapabilityEvidenceFailed + failed.SchemaFingerprint = "" + failed.SafeError = domain.SCUMSafeError{Code: domain.SCUMSafeErrorTargetUnavailable, Retryable: false} + if err := ValidateSCUMSchemaProbeResult(failed); err != nil { + t.Fatalf("expected safe failed Run probe result to validate, got %v", err) + } +} + func TestValidateSCUMCapabilityEvidenceRequiresSafeCurrentServiceIdentity(t *testing.T) { evidence := domain.SCUMCapabilityEvidence{Capability: domain.SCUMDataCapabilityPlayerRead, Status: domain.SCUMCapabilityEvidenceCompatible, Binding: validatorSCUMBinding(), AdapterVersion: "adapter-1", SchemaFingerprint: scumProbeHash, ProbeResultDigest: scumProbeHash, AssetDigests: []string{scumProbeHash}, SafeError: domain.SCUMSafeError{Code: domain.SCUMSafeErrorNone}} if err := ValidateSCUMCapabilityEvidence(evidence); err != nil { diff --git a/scripts/local-debug/smoke.sh b/scripts/local-debug/smoke.sh index 20d5607..0a405f4 100755 --- a/scripts/local-debug/smoke.sh +++ b/scripts/local-debug/smoke.sh @@ -780,7 +780,9 @@ const manifest = { productionLifecycle: source.productionLifecycle, remoteAccess: source.remoteAccess, runtimeProfiles: localRuntimeProfiles, - gameClientBridge: localGameClientBridge + gameClientBridge: localGameClientBridge, + scumLiveData: source.scumLiveData, + mapTrajectories: source.mapTrajectories }; fs.writeFileSync(outputPath, JSON.stringify({ manifestRef: `artifact://manifests/game.scum/${source.version}`,