Files
browser/platform/service/server_lifecycle_projection.go
T
npc0-hue 300390dc4d Fill the SCUM user and vehicle tables from the plugin-declared database read path
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.
2026-09-16 18:01:33 +08:00

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
}
}