diff --git a/platform/domain/resources.go b/platform/domain/resources.go index ccd810b..d18c32a 100644 --- a/platform/domain/resources.go +++ b/platform/domain/resources.go @@ -329,8 +329,8 @@ type PluginFileWorkspace struct { type PluginLogicalDirectory struct{ Key, Label, Scope string } type PluginLogicalFile struct { - Key, DirectoryKey, Label, Kind, StreamKey string - Editable bool + Key, DirectoryKey, Label, Kind, StreamKey, TargetKey string + Editable bool } type PluginConfigField struct { Key, FileKey, ConfigKey, Label, Description, Control, DefaultValue, RestartImpact string @@ -1209,6 +1209,7 @@ type JobRetryPolicy struct { type JobExecutionInput struct { WorkspaceScope string Content string + FileTargetKey string ExpectedVersion int ExpectedChecksum string MaxReadBytes int diff --git a/platform/dto/job_channel.go b/platform/dto/job_channel.go index ad17d7d..7c805ef 100644 --- a/platform/dto/job_channel.go +++ b/platform/dto/job_channel.go @@ -96,6 +96,7 @@ type RunJobResultRequest struct { type RunJobExecutionInputBody struct { WorkspaceScope string `json:"workspaceScope,omitempty"` Content string `json:"content,omitempty"` + FileTargetKey string `json:"fileTargetKey,omitempty"` ExpectedVersion int `json:"expectedVersion,omitempty"` ExpectedChecksum string `json:"expectedChecksum,omitempty"` MaxReadBytes int `json:"maxReadBytes,omitempty"` @@ -663,7 +664,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, FileTargetKey: assignment.ExecutionInput.FileTargetKey, 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)}, LeaseToken: assignment.LeaseToken, Attempt: assignment.Attempt, FencingToken: assignment.FencingToken, diff --git a/platform/dto/resources.go b/platform/dto/resources.go index 9cd4777..e9762d0 100644 --- a/platform/dto/resources.go +++ b/platform/dto/resources.go @@ -198,6 +198,7 @@ type PluginLogicalFileBody struct { Label string `json:"label"` Kind string `json:"kind"` StreamKey string `json:"streamKey,omitempty"` + TargetKey string `json:"targetKey,omitempty"` Editable bool `json:"editable,omitempty"` } type PluginConfigFieldBody struct { @@ -1146,7 +1147,7 @@ func fileWorkspaceToDomain(body PluginFileWorkspaceBody) domain.PluginFileWorksp workspace.Directories = append(workspace.Directories, domain.PluginLogicalDirectory{Key: item.Key, Label: item.Label, Scope: item.Scope}) } for _, item := range body.Files { - workspace.Files = append(workspace.Files, domain.PluginLogicalFile{Key: item.Key, DirectoryKey: item.DirectoryKey, Label: item.Label, Kind: item.Kind, StreamKey: item.StreamKey, Editable: item.Editable}) + workspace.Files = append(workspace.Files, domain.PluginLogicalFile{Key: item.Key, DirectoryKey: item.DirectoryKey, Label: item.Label, Kind: item.Kind, StreamKey: item.StreamKey, TargetKey: item.TargetKey, Editable: item.Editable}) } for _, item := range body.ConfigFields { workspace.ConfigFields = append(workspace.ConfigFields, domain.PluginConfigField{Key: item.Key, FileKey: item.FileKey, ConfigKey: item.ConfigKey, Label: item.Label, Description: item.Description, Control: item.Control, Minimum: item.Minimum, Maximum: item.Maximum, DefaultValue: item.DefaultValue, RestartImpact: item.RestartImpact}) @@ -1160,7 +1161,7 @@ func fileWorkspaceFromDomain(workspace domain.PluginFileWorkspace) PluginFileWor body.Directories = append(body.Directories, PluginLogicalDirectoryBody{Key: item.Key, Label: item.Label, Scope: item.Scope}) } for _, item := range workspace.Files { - body.Files = append(body.Files, PluginLogicalFileBody{Key: item.Key, DirectoryKey: item.DirectoryKey, Label: item.Label, Kind: item.Kind, StreamKey: item.StreamKey, Editable: item.Editable}) + body.Files = append(body.Files, PluginLogicalFileBody{Key: item.Key, DirectoryKey: item.DirectoryKey, Label: item.Label, Kind: item.Kind, StreamKey: item.StreamKey, TargetKey: item.TargetKey, Editable: item.Editable}) } for _, item := range workspace.ConfigFields { body.ConfigFields = append(body.ConfigFields, PluginConfigFieldBody{Key: item.Key, FileKey: item.FileKey, ConfigKey: item.ConfigKey, Label: item.Label, Description: item.Description, Control: item.Control, Minimum: item.Minimum, Maximum: item.Maximum, DefaultValue: item.DefaultValue, RestartImpact: item.RestartImpact}) diff --git a/platform/model/resources.go b/platform/model/resources.go index 4b60be4..d305ebe 100644 --- a/platform/model/resources.go +++ b/platform/model/resources.go @@ -291,6 +291,7 @@ type JobRetryPolicy struct { type JobExecutionInput struct { WorkspaceScope string `json:"workspaceScope,omitempty" db:"workspace_scope"` Content string `json:"content,omitempty" db:"content"` + FileTargetKey string `json:"fileTargetKey,omitempty" db:"file_target_key"` ExpectedVersion int `json:"expectedVersion,omitempty" db:"expected_version"` ExpectedChecksum string `json:"expectedChecksum,omitempty" db:"expected_checksum"` MaxReadBytes int `json:"maxReadBytes,omitempty" db:"max_read_bytes"` @@ -868,7 +869,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, FileTargetKey: input.FileTargetKey, 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)} } func (input JobExecutionInput) ToDomain() domain.JobExecutionInput { @@ -877,7 +878,7 @@ 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, FileTargetKey: input.FileTargetKey, 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)} } func executionResultFromDomain(result domain.JobExecutionResult) JobExecutionResult { diff --git a/platform/model/resources_test.go b/platform/model/resources_test.go index a5f5e24..6937f7b 100644 --- a/platform/model/resources_test.go +++ b/platform/model/resources_test.go @@ -95,6 +95,7 @@ func TestGamePluginModelRoundTripCopiesSlices(t *testing.T) { func TestJobExecutionInputModelRoundTripPreservesLifecycleMetadata(t *testing.T) { source := domain.JobExecutionInput{ WorkspaceScope: "server-workspace", + FileTargetKey: "SCUM/Saved/Config/WindowsServer/ServerSettings.ini", PluginID: "game.scum", LifecycleOperation: "upgrade", TargetVersion: "2.0.0", diff --git a/platform/service/job_channel.go b/platform/service/job_channel.go index dfc35a0..23765f5 100644 --- a/platform/service/job_channel.go +++ b/platform/service/job_channel.go @@ -691,7 +691,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, FileTargetKey: job.ExecutionInput.FileTargetKey, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds, PluginID: job.ExecutionInput.PluginID, LifecycleOperation: job.ExecutionInput.LifecycleOperation, TargetVersion: job.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(job.ExecutionInput.Inputs), LogSource: domain.CopyRuntimeLogSourcePtr(job.ExecutionInput.LogSource), LogSources: domain.CopyRuntimeLogSources(job.ExecutionInput.LogSources), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON), Deployment: deploymentPlanForDispatchValue(job.ExecutionInput.Deployment), ServerDeploymentPlan: domain.CopyServerDeploymentPlan(job.ExecutionInput.ServerDeploymentPlan)}, LeaseToken: leaseToken, Attempt: job.Attempt, FencingToken: 0, diff --git a/platform/service/resources.go b/platform/service/resources.go index 7adc88c..1ce1d76 100644 --- a/platform/service/resources.go +++ b/platform/service/resources.go @@ -2114,6 +2114,7 @@ func (svc *CoreService) GetDeclaredFileReadSnapshotForSession(sessionID string, } var completed *domain.Job var pending *domain.Job + var terminalFailure *domain.Job for i := range jobs { job := jobs[i] if job.Capability != domain.JobCapabilityFilesRead || job.TargetKey != fileKey { @@ -2129,6 +2130,11 @@ func (svc *CoreService) GetDeclaredFileReadSnapshotForSession(sessionID string, if declaredFileReadPendingState(job.State) && (pending == nil || newerJob(job, *pending)) { copy := job pending = © + continue + } + if (job.State == domain.JobStateFailed || job.State == domain.JobStateCancelled) && (terminalFailure == nil || newerJob(job, *terminalFailure)) { + copy := job + terminalFailure = © } } base := domain.DeclaredFileReadSnapshot{ServerInstanceID: instance.ID, PluginID: plugin.ID, Key: fileKey} @@ -2152,6 +2158,16 @@ func (svc *CoreService) GetDeclaredFileReadSnapshotForSession(sessionID string, base.Reason = "等待运行端完成文件读取。" return base, nil } + if terminalFailure != nil { + base.State = "failed" + if terminalFailure.State == domain.JobStateCancelled { + base.State = "cancelled" + } + base.JobID = terminalFailure.ID + base.ReadAt = jobCompletedAt(*terminalFailure) + base.Reason = declaredFileReadFailureReason(*terminalFailure) + return base, nil + } base.State = "not-read" base.Reason = "尚未读取此文件。" return base, nil @@ -2166,6 +2182,24 @@ func declaredFileReadPendingState(state domain.JobState) bool { } } +func declaredFileReadFailureReason(job domain.Job) string { + prefix := "Run 文件读取失败" + if job.State == domain.JobStateCancelled { + prefix = "Run 文件读取已取消" + } + detail := strings.TrimSpace(job.CancelReason) + if detail == "" { + detail = strings.TrimSpace(job.Progress.Message) + } + if detail == "" { + detail = strings.TrimSpace(job.ExecutionResult.Summary) + } + if detail == "" { + return prefix + "。" + } + return prefix + ":" + detail +} + func newerJob(left domain.Job, right domain.Job) bool { leftTime, rightTime := jobCompletedAt(left), jobCompletedAt(right) if !leftTime.Equal(rightTime) { @@ -2293,14 +2327,18 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques return domain.FileOperationDispatchResult{}, ErrForbidden } } + pluginID := strings.TrimSpace(request.PluginID) + if pluginID == "" { + pluginID = instance.PluginID + } + plugin, err := svc.store.GamePlugins().Get(pluginID) + if err != nil { + return domain.FileOperationDispatchResult{}, err + } + if plugin.ID != instance.PluginID { + return domain.FileOperationDispatchResult{}, validationError("pluginId must match server instance") + } if request.PluginID != "" { - plugin, err := svc.store.GamePlugins().Get(request.PluginID) - if err != nil { - return domain.FileOperationDispatchResult{}, err - } - if plugin.ID != instance.PluginID { - return domain.FileOperationDispatchResult{}, validationError("pluginId must match server instance") - } if plugin.Status != domain.GamePluginStatusInstalled { return domain.FileOperationDispatchResult{}, validationError("plugin must be installed") } @@ -2311,6 +2349,13 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques return domain.FileOperationDispatchResult{}, ErrForbidden } } + fileTargetKey := "" + if request.Operation != domain.FileOperationList { + file, declared, allowed := declaredPluginFileRequest(plugin.FileWorkspace, request) + if declared && allowed { + fileTargetKey = file.TargetKey + } + } capability := domain.JobCapabilityFilesRead message := "file read queued" if request.Operation == domain.FileOperationList { @@ -2328,7 +2373,7 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques Capability: capability, TargetKey: request.Key, InputRef: request.InputRef, - ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), Content: content, ExpectedVersion: request.ExpectedConfigVersion, ExpectedChecksum: request.ExpectedChecksum, MaxReadBytes: 64 * 1024, Deployment: deploymentPlanForDispatch(instance.Deployment)}, + ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), Content: content, FileTargetKey: fileTargetKey, ExpectedVersion: request.ExpectedConfigVersion, ExpectedChecksum: request.ExpectedChecksum, MaxReadBytes: 64 * 1024, PluginID: plugin.ID, Deployment: deploymentPlanForDispatch(instance.Deployment)}, IdempotencyKey: request.IdempotencyKey, Progress: domain.JobProgress{Percent: 0, Message: message}, }) @@ -2337,7 +2382,7 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques } return domain.CopyFileOperationDispatchResult(domain.FileOperationDispatchResult{ ServerInstanceID: request.ServerInstanceID, - PluginID: request.PluginID, + PluginID: plugin.ID, Operation: request.Operation, Key: request.Key, InputRef: request.InputRef, diff --git a/platform/service/resources_test.go b/platform/service/resources_test.go index 26743a6..f3308b3 100644 --- a/platform/service/resources_test.go +++ b/platform/service/resources_test.go @@ -1019,7 +1019,7 @@ func TestPluginFileWorkspaceDoesNotConstrainServerFileDispatch(t *testing.T) { if err != nil { t.Fatalf("dispatch declared file read: %v", err) } - if allowed.Job.TargetKey != "scum-server-settings" || allowed.Job.Capability != domain.JobCapabilityFilesRead || allowed.Job.ExecutionInput.Deployment == nil || allowed.Job.ExecutionInput.Deployment.ServerRoot != `C:\scumserver` { + if allowed.Job.TargetKey != "scum-server-settings" || allowed.Job.ExecutionInput.FileTargetKey != "SCUM/Saved/Config/WindowsServer/ServerSettings.ini" || allowed.Job.Capability != domain.JobCapabilityFilesRead || allowed.Job.ExecutionInput.Deployment == nil || allowed.Job.ExecutionInput.Deployment.ServerRoot != `C:\scumserver` { t.Fatalf("unexpected declared file dispatch: %+v", allowed) } unknown, err := svc.DispatchFileOperationForSession(ownerSession, domain.FileOperationDispatchRequest{ @@ -1313,8 +1313,8 @@ func TestDeclaredFileReadSnapshotProjectionStatesAndPassThroughContent(t *testin } createDeclaredFileReadJob(t, svc, instance, endpoint, "job-file-snapshot-cancelled", domain.JobStateCancelled, 3, "") snapshot, err = svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "scum-server-settings") - if err != nil || snapshot.State != "not-read" { - t.Fatalf("failed/cancelled reads must not mask not-read, snapshot=%+v err=%v", snapshot, err) + if err != nil || snapshot.State != "cancelled" || !strings.Contains(snapshot.Reason, "fixture read cancelled") { + t.Fatalf("failed/cancelled reads must surface the latest Run failure, snapshot=%+v err=%v", snapshot, err) } createDeclaredFileReadJob(t, svc, instance, endpoint, "job-file-snapshot-success-old", domain.JobStateSucceeded, 4, "ServerName=Old\nRconPassword=secret\n") createDeclaredFileReadJob(t, svc, instance, endpoint, "job-file-snapshot-failed-newer", domain.JobStateFailed, 5, "") @@ -1344,7 +1344,7 @@ func scumTestFileWorkspace() domain.PluginFileWorkspace { {Key: "scum-logs", Label: "日志文件", Scope: "logs"}, }, Files: []domain.PluginLogicalFile{ - {Key: "scum-server-settings", DirectoryKey: "scum-config", Label: "ServerSettings.ini", Kind: "config", Editable: true}, + {Key: "scum-server-settings", DirectoryKey: "scum-config", Label: "ServerSettings.ini", Kind: "config", TargetKey: "SCUM/Saved/Config/WindowsServer/ServerSettings.ini", Editable: true}, {Key: "scum-chat-log", DirectoryKey: "scum-logs", Label: "Chat.log", Kind: "log", StreamKey: "scum.chat"}, }, ConfigFields: []domain.PluginConfigField{ @@ -1374,6 +1374,11 @@ func createDeclaredFileReadJob(t *testing.T, svc *CoreService, instance domain.S } if state == domain.JobStateSucceeded { job.ExecutionResult = domain.JobExecutionResult{Kind: "file.read", Version: minuteOffset, Checksum: validator.BytesChecksum([]byte(content)), SizeBytes: int64(len(content)), Content: content} + } else if state == domain.JobStateFailed { + job.Progress.Message = "fixture read failed" + job.ExecutionResult.Summary = "file_read_failed" + } else if state == domain.JobStateCancelled { + job.CancelReason = "fixture read cancelled" } if err := svc.store.Jobs().Update(job); err != nil { t.Fatalf("update declared file read job: %v", err) diff --git a/platform/validator/resources.go b/platform/validator/resources.go index be821db..e4ba443 100644 --- a/platform/validator/resources.go +++ b/platform/validator/resources.go @@ -1949,7 +1949,7 @@ func validatePluginFileWorkspace(prefix string, workspace domain.PluginFileWorks } for i, item := range workspace.Files { field := fmt.Sprintf("%s.files[%d]", prefix, i) - if !validDistributionLogicalKey(item.Key) || !directories[item.DirectoryKey] || item.Label == "" || !oneOf(item.Kind, "config", "log") || (item.Kind == "log" && item.StreamKey == "") { + if !validDistributionLogicalKey(item.Key) || !directories[item.DirectoryKey] || item.Label == "" || !oneOf(item.Kind, "config", "log") || (item.Kind == "log" && item.StreamKey == "") || (item.TargetKey != "" && len(validateSafeRelativeRuntimePath(field+".targetKey", item.TargetKey)) > 0) { violations = append(violations, field+" is invalid") } if _, exists := files[item.Key]; exists { diff --git a/platform/validator/resources_test.go b/platform/validator/resources_test.go index c9b291d..583e478 100644 --- a/platform/validator/resources_test.go +++ b/platform/validator/resources_test.go @@ -47,6 +47,11 @@ func TestValidateGamePluginManifestRegistrationValidatesLogicalFileWorkspace(t * if err := ValidateGamePluginManifestRegistration(registration); err != nil { t.Fatalf("expected safe logical file workspace: %v", err) } + registration.Manifest.FileWorkspace.Files[0].TargetKey = "../server-settings.ini" + if err := ValidateGamePluginManifestRegistration(registration); err == nil { + t.Fatal("expected unsafe file target reference to fail") + } + registration.Manifest.FileWorkspace.Files[0].TargetKey = "SCUM/Saved/Config/WindowsServer/ServerSettings.ini" registration.Manifest.FileWorkspace.Files[0].DirectoryKey = "../host" if err := ValidateGamePluginManifestRegistration(registration); err == nil { t.Fatal("expected unsafe logical directory reference to fail") diff --git a/platform_web/api/types.ts b/platform_web/api/types.ts index 67291c9..5f1b8f2 100644 --- a/platform_web/api/types.ts +++ b/platform_web/api/types.ts @@ -403,7 +403,7 @@ export interface RuntimeLogSourceResponse { } export interface PluginLogicalDirectoryResponse { key: string; label: string; scope: "config" | "logs"; } -export interface PluginLogicalFileResponse { key: string; directoryKey: string; label: string; kind: "config" | "log"; streamKey?: string; editable?: boolean; } +export interface PluginLogicalFileResponse { key: string; directoryKey: string; label: string; kind: "config" | "log"; streamKey?: string; targetKey?: string; editable?: boolean; } export interface PluginConfigFieldResponse { key: string; fileKey: string; configKey: string; label: string; description: string; control: "text" | "number" | "boolean" | "port"; minimum?: number; maximum?: number; defaultValue?: string; restartImpact: "none" | "restart-required"; } export interface PluginFileWorkspaceResponse { defaultDirectoryKey: string; directories: PluginLogicalDirectoryResponse[]; files: PluginLogicalFileResponse[]; configFields: PluginConfigFieldResponse[]; } @@ -1373,7 +1373,7 @@ export interface DeclaredFileReadSnapshotResponse { serverInstanceId: string; pluginId: string; key: string; - state: "ready" | "pending" | "not-read" | string; + state: "ready" | "pending" | "not-read" | "failed" | "cancelled" | string; content?: string; version?: number; checksum?: string; diff --git a/platform_web/components/ServerConfigEditor.test.tsx b/platform_web/components/ServerConfigEditor.test.tsx index 80d3a86..d99e5f4 100644 --- a/platform_web/components/ServerConfigEditor.test.tsx +++ b/platform_web/components/ServerConfigEditor.test.tsx @@ -80,4 +80,64 @@ describe("ServerConfigEditor", () => { expect(apiMocks.readServerFile).toHaveBeenCalledTimes(1); expect(apiMocks.getServerFileReadSnapshot).toHaveBeenCalledTimes(2); }); + + it("surfaces a terminal Run read failure without polling for thirty seconds", async () => { + apiMocks.getServerFileReadSnapshot.mockResolvedValue({ serverInstanceId: "server-1", pluginId: "game.scum", key: "scum-server-settings", state: "failed", reason: "Run 文件读取失败:file is missing" }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + const operations: OperationTracker = { operations: [], begin: () => "operation-read", update: () => undefined, succeed: () => undefined, fail: () => undefined, isPending: () => false }; + + await act(async () => root?.render( undefined} />)); + await act(async () => { await Promise.resolve(); await Promise.resolve(); }); + + expect(apiMocks.readServerFile).not.toHaveBeenCalled(); + expect(apiMocks.getServerFileReadSnapshot).toHaveBeenCalledTimes(1); + expect(container.textContent).toContain("file is missing"); + expect(container.textContent).toContain("重试"); + }); + + it("does not dispatch a second read while an existing Run read is pending", async () => { + apiMocks.getServerFileReadSnapshot.mockResolvedValue({ serverInstanceId: "server-1", pluginId: "game.scum", key: "scum-server-settings", state: "pending", reason: "等待运行端完成文件读取。" }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + const operations: OperationTracker = { operations: [], begin: () => "operation-read", update: () => undefined, succeed: () => undefined, fail: () => undefined, isPending: () => false }; + + await act(async () => root?.render( undefined} />)); + await act(async () => { await Promise.resolve(); await Promise.resolve(); }); + expect(apiMocks.readServerFile).not.toHaveBeenCalled(); + expect(apiMocks.getServerFileReadSnapshot).toHaveBeenCalledTimes(1); + + await act(async () => { await vi.advanceTimersByTimeAsync(500); }); + expect(apiMocks.readServerFile).not.toHaveBeenCalled(); + expect(apiMocks.getServerFileReadSnapshot).toHaveBeenCalledTimes(2); + }); + + it("retries a failed read with a new idempotency key and loads the file", async () => { + let retryRequested = false; + apiMocks.getServerFileReadSnapshot.mockImplementation(async () => retryRequested + ? { serverInstanceId: "server-1", pluginId: "game.scum", key: "scum-server-settings", state: "ready", content: "[General]\nscum.MaxPlayers=63\n", version: 1, checksum: "sha256:test" } + : { serverInstanceId: "server-1", pluginId: "game.scum", key: "scum-server-settings", state: "failed", reason: "Run 文件读取失败:file is missing" }); + apiMocks.readServerFile.mockImplementation(async (_serverId: string, request: { idempotencyKey: string }) => { + retryRequested = true; + return { status: "queued", serverInstanceId: "server-1", pluginId: "game.scum", operation: "read", key: "scum-server-settings", job: { id: "job-read-retry", state: "queued" }, request }; + }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + const operations: OperationTracker = { operations: [], begin: () => "operation-read", update: () => undefined, succeed: () => undefined, fail: () => undefined, isPending: () => false }; + + await act(async () => root?.render( undefined} />)); + await act(async () => { await Promise.resolve(); await Promise.resolve(); }); + const retry = [...(container.querySelectorAll("button"))].find((button) => button.textContent?.includes("重试")); + expect(retry).toBeDefined(); + + await act(async () => retry?.click()); + await act(async () => { await vi.advanceTimersByTimeAsync(500); await Promise.resolve(); }); + + expect(apiMocks.readServerFile).toHaveBeenCalledTimes(1); + expect(apiMocks.readServerFile.mock.calls[0][1].idempotencyKey).toBe("web:server-config:read:server-1:scum-server-settings:1"); + expect(container.textContent).toContain("已读取 ServerSettings.ini"); + }); }); diff --git a/platform_web/components/ServerConfigEditor.tsx b/platform_web/components/ServerConfigEditor.tsx index 94e59e7..8631e7a 100644 --- a/platform_web/components/ServerConfigEditor.tsx +++ b/platform_web/components/ServerConfigEditor.tsx @@ -28,6 +28,7 @@ export function ServerConfigEditor({ instance, operations, requester, onClose }: const [message, setMessage] = useState(""); const [error, setError] = useState(""); const readInFlightRef = useRef(""); + const readAttemptRef = useRef(new Map()); const operationsRef = useRef(operations); operationsRef.current = operations; @@ -35,7 +36,7 @@ export function ServerConfigEditor({ instance, operations, requester, onClose }: const selectedFile = configFiles.find((file) => file.key === selectedKey) ?? configFiles[0]; const fields = useMemo(() => (workspace?.configFields ?? []).filter((field) => field.fileKey === selectedFile?.key), [selectedFile?.key, workspace?.configFields]); - const readSelectedFile = useCallback(async (file: PluginLogicalFileResponse) => { + const readSelectedFile = useCallback(async (file: PluginLogicalFileResponse, forceRetry = false) => { const readKey = `${instance.id}:${file.key}`; if (readInFlightRef.current === readKey) return; readInFlightRef.current = readKey; @@ -44,16 +45,23 @@ export function ServerConfigEditor({ instance, operations, requester, onClose }: setMessage("正在通过 Run 读取配置文件…"); try { let next = await platformApiClient.getServerFileReadSnapshot(instance.id, file.key); - if (next.state !== "ready") { + const failedState = next.state === "failed" || next.state === "cancelled"; + let dispatched = false; + if (next.state === "not-read" || (forceRetry && failedState)) { + const readAttempt = (readAttemptRef.current.get(readKey) ?? 0) + 1; + readAttemptRef.current.set(readKey, readAttempt); const operationId = operationsRef.current.begin({ intent: "读取服务器配置", targetKind: "config", targetId: instance.id, requester }); - const dispatch = await platformApiClient.readServerFile(instance.id, { key: file.key, idempotencyKey: configOperationKey("read", instance.id, file.key) }); + const dispatch = await platformApiClient.readServerFile(instance.id, { key: file.key, idempotencyKey: configOperationKey("read", instance.id, file.key, readAttempt) }); operationsRef.current.succeed(operationId, `配置读取任务 ${dispatch.job.id} 已派发`, dispatch.job); - for (let attempt = 0; attempt < 60 && next.state !== "ready"; attempt += 1) { + dispatched = true; + } + if (dispatched || next.state === "pending") { + for (let pollAttempt = 0; pollAttempt < 60 && next.state !== "ready"; pollAttempt += 1) { await new Promise((resolve) => window.setTimeout(resolve, 500)); next = await platformApiClient.getServerFileReadSnapshot(instance.id, file.key); } } - if (next.state !== "ready" || next.content === undefined) throw new Error(next.reason ?? "Run 尚未返回配置文件内容。"); + if (next.state !== "ready" || next.content === undefined) throw new Error(next.reason ?? (next.state === "pending" ? "Run 仍在读取配置文件,请稍后重试。" : "Run 未返回配置文件内容。")); setSnapshot(next); setRaw(next.content); setValues(Object.fromEntries(fields.map((field) => [field.key, iniValue(next.content ?? "", field.configKey)]))); @@ -120,7 +128,7 @@ export function ServerConfigEditor({ instance, operations, requester, onClose }:

{selectedFile?.label}

配置内容由 Run 返回
{mode === "friendly" ? "人类可读模式:只修改插件声明的配置项" : "源码模式:编辑完整原始文件"}
{message && } - {error && workspace && } + {error && workspace && void readSelectedFile(selectedFile, true) : undefined} compact />} {loading && } {!loading && mode === "friendly" &&
{fields.length === 0 && 该配置文件没有声明可视化字段,请切换到源码模式。}{configFieldGroups(fields).map((group) =>

{group.label}

{group.fields.map((field) => setValues((current) => ({ ...current, [field.key]: value }))} />)}
)}
} {!loading && mode === "source" &&