The SCUM 用户管理 list stayed empty because the typed scum_user and scum_vehicle tables had no producer: the run ingest endpoint is signed-run only, and the previously registered plugin templates asked for a Run database capability the endpoint never advertises. The plugin now declares bounded, read-only SQLite projections for players and vehicles (sql/scum-db-v57/*.sql with query schemas), and the platform dispatches those templates as durable remote.run.db.sqlite.query jobs on run heartbeat and page open, then projects the returned rows into the typed SCUM tables through the same shared ingest path used by signed run facts.
228 lines
8.3 KiB
Go
228 lines
8.3 KiB
Go
package service
|
|
|
|
import (
|
|
"log"
|
|
"strings"
|
|
"time"
|
|
|
|
"browser.local/platform/domain"
|
|
"browser.local/platform/validator"
|
|
)
|
|
|
|
func (svc *CoreService) ReportRunLifecycle(report domain.RunLifecycleReport) (domain.RunLifecycleReportResult, error) {
|
|
report = domain.CopyRunLifecycleReport(report)
|
|
if err := validator.ValidateRunLifecycleReport(report); err != nil {
|
|
return domain.RunLifecycleReportResult{}, err
|
|
}
|
|
if _, err := svc.validatedRunSession(report.RunEndpointID, report.SessionToken); err != nil {
|
|
return domain.RunLifecycleReportResult{}, err
|
|
}
|
|
instance, err := svc.store.ServerInstances().Get(report.ServerInstanceID)
|
|
if err != nil {
|
|
return domain.RunLifecycleReportResult{}, err
|
|
}
|
|
if instance.RunEndpointID != report.RunEndpointID {
|
|
return domain.RunLifecycleReportResult{}, validationError("runEndpointId must match server instance")
|
|
}
|
|
if instance.State == domain.ServerInstanceStateDeleted {
|
|
return domain.RunLifecycleReportResult{}, validationError("server instance must not be deleted")
|
|
}
|
|
|
|
stamp := svc.now()
|
|
nextState, projected := lifecycleProjectedState(report.Capability, report.State, report.ExecutionResult)
|
|
staleObservation := lifecycleObservationIsStale(instance, report)
|
|
if !projected || staleObservation {
|
|
projected = false
|
|
nextState = instance.State
|
|
}
|
|
if projected {
|
|
instance.State = nextState
|
|
if report.ManagedProcessID != "" {
|
|
instance.LifecycleProcessID = report.ManagedProcessID
|
|
instance.LifecycleObservationSeq = report.ObservationSeq
|
|
instance.LifecycleObservedAt = report.ObservedAt
|
|
if instance.LifecycleObservedAt.IsZero() {
|
|
instance.LifecycleObservedAt = stamp
|
|
}
|
|
}
|
|
instance.UpdatedAt = stamp
|
|
if err := validator.ValidateServerInstance(instance); err != nil {
|
|
return domain.RunLifecycleReportResult{}, err
|
|
}
|
|
if err := svc.store.ServerInstances().Update(instance); err != nil {
|
|
return domain.RunLifecycleReportResult{}, err
|
|
}
|
|
svc.publishLogProcessState(instance)
|
|
}
|
|
return domain.CopyRunLifecycleReportResult(domain.RunLifecycleReportResult{Accepted: true, RunEndpointID: report.RunEndpointID, ServerInstanceID: report.ServerInstanceID, ProjectedState: nextState, ServerTime: stamp}), nil
|
|
}
|
|
|
|
func lifecycleObservationIsStale(instance domain.ServerInstance, report domain.RunLifecycleReport) bool {
|
|
if report.ManagedProcessID == "" || instance.LifecycleProcessID == "" {
|
|
return false
|
|
}
|
|
if report.ManagedProcessID == instance.LifecycleProcessID {
|
|
return report.ObservationSeq <= instance.LifecycleObservationSeq
|
|
}
|
|
return !report.ObservedAt.IsZero() && !instance.LifecycleObservedAt.IsZero() && report.ObservedAt.Before(instance.LifecycleObservedAt)
|
|
}
|
|
|
|
func (svc *CoreService) projectRemoteAdapterJobResult(job domain.Job, stamp time.Time) error {
|
|
if !strings.HasPrefix(job.Capability, "remote.") || job.ServerInstanceID == "" || !isTerminalJobState(job.State) {
|
|
return nil
|
|
}
|
|
if job.Capability != domain.JobCapabilityRemoteRunDBSQLiteQuery || job.State != domain.JobStateSucceeded {
|
|
return nil
|
|
}
|
|
return svc.projectSCUMQueryTemplateResult(job, stamp)
|
|
}
|
|
|
|
func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Time) error {
|
|
if job.Capability == domain.JobCapabilityConfigWrite {
|
|
if job.State != domain.JobStateSucceeded {
|
|
return nil
|
|
}
|
|
instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
instance.ConfigKey = job.TargetKey
|
|
instance.ConfigContent = job.ExecutionInput.Content
|
|
instance.ConfigChecksum = job.ExecutionResult.Checksum
|
|
instance.ConfigVersion = job.ExecutionResult.Version
|
|
instance.ConfigUpdatedAt = stamp
|
|
instance.UpdatedAt = stamp
|
|
if err := validator.ValidateServerInstance(instance); err != nil {
|
|
return err
|
|
}
|
|
if err := svc.store.ServerInstances().Update(instance); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
nextState, ok := lifecycleProjectedState(job.Capability, job.State, job.ExecutionResult)
|
|
if !ok || job.ServerInstanceID == "" {
|
|
return nil
|
|
}
|
|
instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
instance.State = nextState
|
|
instance.UpdatedAt = stamp
|
|
if err := validator.ValidateServerInstance(instance); err != nil {
|
|
return err
|
|
}
|
|
if err := svc.store.ServerInstances().Update(instance); err != nil {
|
|
return err
|
|
}
|
|
svc.publishLogProcessState(instance)
|
|
svc.dispatchFollowUpLifecycle(job, instance)
|
|
return nil
|
|
}
|
|
|
|
// dispatchFollowUpLifecycle starts the server again after a declared graceful
|
|
// stop or a completed game update. A failed or cancelled stop/update leaves the
|
|
// instance in its projected state so the operator sees the failure before any
|
|
// start is attempted.
|
|
func (svc *CoreService) dispatchFollowUpLifecycle(job domain.Job, instance domain.ServerInstance) {
|
|
if job.State != domain.JobStateSucceeded {
|
|
return
|
|
}
|
|
operation := job.ExecutionInput.LifecycleOperation
|
|
if operation != string(domain.ServerLifecycleActionRestart) && operation != string(domain.ServerLifecycleActionUpdate) {
|
|
return
|
|
}
|
|
command := domain.ServerLifecycleCommand{
|
|
ServerInstanceID: instance.ID,
|
|
ExpectedConfigVersion: instance.ConfigVersion,
|
|
IdempotencyKey: job.IdempotencyKey + ":start",
|
|
}
|
|
result, err := svc.dispatchExistingServerLifecycle(command, domain.ServerLifecycleActionStart, []domain.ServerInstanceState{
|
|
domain.ServerInstanceStateReady,
|
|
domain.ServerInstanceStateStopped,
|
|
domain.ServerInstanceStateFailed,
|
|
})
|
|
if err != nil {
|
|
log.Printf("PLATFORM phase=lifecycle status=follow_up_failed operation=%s server=%s error=%s", safeLogValue(operation), safeLogValue(instance.ID), safeLogValue(err.Error()))
|
|
return
|
|
}
|
|
log.Printf("PLATFORM phase=lifecycle status=follow_up_queued operation=%s server=%s job=%s", safeLogValue(operation), safeLogValue(instance.ID), safeLogValue(result.Job.ID))
|
|
}
|
|
|
|
func (svc *CoreService) projectServerDeploymentProgress(job domain.Job, stamp time.Time) error {
|
|
if job.ExecutionInput.Deployment == nil || job.ServerInstanceID == "" {
|
|
return nil
|
|
}
|
|
instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
projection := domain.CopyServerDeploymentProjection(instance.DeploymentProjection)
|
|
projection.State = "running"
|
|
projection.Operation = job.ExecutionInput.LifecycleOperation
|
|
switch job.Progress.Phase {
|
|
case "preflight":
|
|
projection.PreflightState = "running"
|
|
case "scan", "discover", "discovery":
|
|
projection.DiscoveryState = "running"
|
|
case "install", "configure", "mapping":
|
|
projection.MappingState = "running"
|
|
case "start", "health":
|
|
projection.VerificationState = "running"
|
|
}
|
|
projection.UpdatedAt = stamp
|
|
instance.DeploymentProjection = projection
|
|
instance.UpdatedAt = stamp
|
|
return svc.store.ServerInstances().Update(instance)
|
|
}
|
|
|
|
func lifecycleProjectedState(capability string, jobState domain.JobState, result domain.JobExecutionResult) (domain.ServerInstanceState, bool) {
|
|
if capability != domain.LifecycleCapabilityInstall && capability != domain.LifecycleCapabilityStart && capability != domain.LifecycleCapabilityStop && capability != domain.LifecycleCapabilityStatus {
|
|
return "", false
|
|
}
|
|
if capability == domain.LifecycleCapabilityStatus {
|
|
if jobState != domain.JobStateSucceeded {
|
|
return "", false
|
|
}
|
|
return lifecycleStateFromProcessResult(result)
|
|
}
|
|
if jobState == domain.JobStateFailed || jobState == domain.JobStateCancelled {
|
|
return domain.ServerInstanceStateFailed, true
|
|
}
|
|
if jobState != domain.JobStateSucceeded {
|
|
return "", false
|
|
}
|
|
switch capability {
|
|
case domain.LifecycleCapabilityInstall:
|
|
return domain.ServerInstanceStateReady, true
|
|
case domain.LifecycleCapabilityStart:
|
|
return lifecycleStateFromProcessResult(result)
|
|
case domain.LifecycleCapabilityStop:
|
|
if state, ok := lifecycleStateFromProcessResult(result); ok {
|
|
return state, true
|
|
}
|
|
return domain.ServerInstanceStateStopped, true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
func lifecycleStateFromProcessResult(result domain.JobExecutionResult) (domain.ServerInstanceState, bool) {
|
|
switch strings.TrimSpace(result.ProcessState) {
|
|
case "running":
|
|
return domain.ServerInstanceStateRunning, true
|
|
case "stopped", "not-started":
|
|
return domain.ServerInstanceStateStopped, true
|
|
case "exited":
|
|
switch strings.TrimSpace(result.ExitClassification) {
|
|
case "requested-stop", "forced-stop", "already-stopped":
|
|
return domain.ServerInstanceStateStopped, true
|
|
default:
|
|
return domain.ServerInstanceStateFailed, true
|
|
}
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|