255 lines
8.2 KiB
Go
255 lines
8.2 KiB
Go
// Package runtime defines the runtime-record domain model, status
|
|
// machine, and sentinel errors owned by Game Master.
|
|
//
|
|
// The package mirrors the durable shape of the `runtime_records`
|
|
// PostgreSQL table (see
|
|
// `galaxy/gamemaster/internal/adapters/postgres/migrations/00001_init.sql`).
|
|
// Every status / transition / required-field rule already documented in
|
|
// `galaxy/gamemaster/README.md` lives here as code so adapter and service
|
|
// layers do not re-derive it.
|
|
package runtime
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Status identifies one runtime-record lifecycle state.
|
|
type Status string
|
|
|
|
const (
|
|
// StatusStarting reports that register-runtime has persisted the row
|
|
// but the engine /admin/init call has not yet succeeded.
|
|
StatusStarting Status = "starting"
|
|
|
|
// StatusRunning reports that the runtime is healthy and accepting
|
|
// player commands and turn generation.
|
|
StatusRunning Status = "running"
|
|
|
|
// StatusGenerationInProgress reports that the scheduler or admin
|
|
// force-next-turn flow has CAS'd the row to drive turn generation.
|
|
StatusGenerationInProgress Status = "generation_in_progress"
|
|
|
|
// StatusGenerationFailed reports that turn generation surfaced an
|
|
// engine error and the runtime is awaiting manual recovery.
|
|
StatusGenerationFailed Status = "generation_failed"
|
|
|
|
// StatusStopped reports that an admin stop has completed; the row
|
|
// stays in PostgreSQL for audit.
|
|
StatusStopped Status = "stopped"
|
|
|
|
// StatusEngineUnreachable reports that runtime:health_events observed
|
|
// an engine container failure (exited, OOM, disappeared, or repeated
|
|
// probe failures).
|
|
StatusEngineUnreachable Status = "engine_unreachable"
|
|
|
|
// StatusFinished reports that the engine returned `finished:true` on
|
|
// a turn-generation response. The state is terminal: the row stays
|
|
// here indefinitely; operator cleanup is the only path out.
|
|
StatusFinished Status = "finished"
|
|
)
|
|
|
|
// IsKnown reports whether status belongs to the frozen runtime status
|
|
// vocabulary.
|
|
func (status Status) IsKnown() bool {
|
|
switch status {
|
|
case StatusStarting,
|
|
StatusRunning,
|
|
StatusGenerationInProgress,
|
|
StatusGenerationFailed,
|
|
StatusStopped,
|
|
StatusEngineUnreachable,
|
|
StatusFinished:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// IsTerminal reports whether status can no longer accept lifecycle
|
|
// transitions. Per `gamemaster/README.md §Game Master status model`, only
|
|
// `finished` is terminal; `stopped` may still be observed but is treated
|
|
// as a non-terminal end-state for admin replay purposes (no transitions
|
|
// out of it are wired in v1, but the state machine does not forbid them
|
|
// architecturally).
|
|
func (status Status) IsTerminal() bool {
|
|
return status == StatusFinished
|
|
}
|
|
|
|
// AllStatuses returns the frozen list of every runtime status value. The
|
|
// slice order is stable across calls and matches the README §Persistence
|
|
// Layout listing.
|
|
func AllStatuses() []Status {
|
|
return []Status{
|
|
StatusStarting,
|
|
StatusRunning,
|
|
StatusGenerationInProgress,
|
|
StatusGenerationFailed,
|
|
StatusStopped,
|
|
StatusEngineUnreachable,
|
|
StatusFinished,
|
|
}
|
|
}
|
|
|
|
// RuntimeRecord stores one durable runtime record owned by Game Master.
|
|
// It mirrors one row of the `runtime_records` table.
|
|
//
|
|
// NextGenerationAt is *time.Time so a missing tick (e.g., a row that has
|
|
// just entered with status=starting) is unambiguous. StartedAt, StoppedAt,
|
|
// and FinishedAt are *time.Time for the same reason and align with the
|
|
// jet-generated model.
|
|
type RuntimeRecord struct {
|
|
// GameID identifies the platform game owning this runtime record.
|
|
GameID string
|
|
|
|
// Status stores the current lifecycle state.
|
|
Status Status
|
|
|
|
// EngineEndpoint stores the stable URL Game Master uses to reach the
|
|
// engine container, in `http://galaxy-game-{game_id}:8080` form.
|
|
EngineEndpoint string
|
|
|
|
// CurrentImageRef stores the Docker reference of the running engine
|
|
// image (or the most recent one for stopped/finished records).
|
|
CurrentImageRef string
|
|
|
|
// CurrentEngineVersion stores the semver of the currently-bound
|
|
// engine version (registered in `engine_versions`).
|
|
CurrentEngineVersion string
|
|
|
|
// TurnSchedule stores the five-field cron expression governing turn
|
|
// generation, copied from the platform game record at
|
|
// register-runtime time.
|
|
TurnSchedule string
|
|
|
|
// CurrentTurn stores the last completed turn number; zero until the
|
|
// first turn generates.
|
|
CurrentTurn int
|
|
|
|
// NextGenerationAt stores the next due tick. Nil when no tick is
|
|
// scheduled (e.g., status=starting, finished, stopped).
|
|
NextGenerationAt *time.Time
|
|
|
|
// SkipNextTick is true when force-next-turn has set the skip flag
|
|
// for the next regular tick. Cleared by the scheduler after the
|
|
// first scheduled step is skipped.
|
|
SkipNextTick bool
|
|
|
|
// EngineHealth stores the short text summary derived from
|
|
// runtime:health_events; empty until the first health observation.
|
|
EngineHealth string
|
|
|
|
// CreatedAt stores the wall-clock at which the record was created.
|
|
CreatedAt time.Time
|
|
|
|
// UpdatedAt stores the wall-clock of the most recent mutation.
|
|
UpdatedAt time.Time
|
|
|
|
// StartedAt stores the wall-clock at which the runtime first
|
|
// transitioned to running. Non-nil once the status leaves starting.
|
|
StartedAt *time.Time
|
|
|
|
// StoppedAt stores the wall-clock at which the runtime was stopped.
|
|
// Non-nil when status is stopped.
|
|
StoppedAt *time.Time
|
|
|
|
// FinishedAt stores the wall-clock at which the engine reported
|
|
// finish. Non-nil when status is finished.
|
|
FinishedAt *time.Time
|
|
}
|
|
|
|
// Validate reports whether record satisfies the runtime-record invariants
|
|
// implied by README §Lifecycles and the SQL CHECK on `runtime_records`.
|
|
func (record RuntimeRecord) Validate() error {
|
|
if strings.TrimSpace(record.GameID) == "" {
|
|
return fmt.Errorf("game id must not be empty")
|
|
}
|
|
if !record.Status.IsKnown() {
|
|
return fmt.Errorf("status %q is unsupported", record.Status)
|
|
}
|
|
if strings.TrimSpace(record.EngineEndpoint) == "" {
|
|
return fmt.Errorf("engine endpoint must not be empty")
|
|
}
|
|
if strings.TrimSpace(record.CurrentImageRef) == "" {
|
|
return fmt.Errorf("current image ref must not be empty")
|
|
}
|
|
if strings.TrimSpace(record.CurrentEngineVersion) == "" {
|
|
return fmt.Errorf("current engine version must not be empty")
|
|
}
|
|
if strings.TrimSpace(record.TurnSchedule) == "" {
|
|
return fmt.Errorf("turn schedule must not be empty")
|
|
}
|
|
if record.CurrentTurn < 0 {
|
|
return fmt.Errorf("current turn must not be negative")
|
|
}
|
|
if record.CreatedAt.IsZero() {
|
|
return fmt.Errorf("created at must not be zero")
|
|
}
|
|
if record.UpdatedAt.IsZero() {
|
|
return fmt.Errorf("updated at must not be zero")
|
|
}
|
|
if record.UpdatedAt.Before(record.CreatedAt) {
|
|
return fmt.Errorf("updated at must not be before created at")
|
|
}
|
|
|
|
if record.NextGenerationAt != nil && record.NextGenerationAt.IsZero() {
|
|
return fmt.Errorf("next generation at must not be zero when present")
|
|
}
|
|
|
|
switch record.Status {
|
|
case StatusStarting:
|
|
if record.StartedAt != nil {
|
|
return fmt.Errorf("started at must be nil for starting records")
|
|
}
|
|
|
|
case StatusRunning,
|
|
StatusGenerationInProgress,
|
|
StatusGenerationFailed,
|
|
StatusEngineUnreachable:
|
|
if record.StartedAt == nil {
|
|
return fmt.Errorf(
|
|
"started at must not be nil for %s records",
|
|
record.Status,
|
|
)
|
|
}
|
|
if record.StartedAt.IsZero() {
|
|
return fmt.Errorf("started at must not be zero when present")
|
|
}
|
|
|
|
case StatusStopped:
|
|
if record.StartedAt == nil {
|
|
return fmt.Errorf("started at must not be nil for stopped records")
|
|
}
|
|
if record.StoppedAt == nil {
|
|
return fmt.Errorf("stopped at must not be nil for stopped records")
|
|
}
|
|
if record.StoppedAt.IsZero() {
|
|
return fmt.Errorf("stopped at must not be zero when present")
|
|
}
|
|
if record.StoppedAt.Before(*record.StartedAt) {
|
|
return fmt.Errorf("stopped at must not be before started at")
|
|
}
|
|
|
|
case StatusFinished:
|
|
if record.StartedAt == nil {
|
|
return fmt.Errorf("started at must not be nil for finished records")
|
|
}
|
|
if record.FinishedAt == nil {
|
|
return fmt.Errorf("finished at must not be nil for finished records")
|
|
}
|
|
if record.FinishedAt.IsZero() {
|
|
return fmt.Errorf("finished at must not be zero when present")
|
|
}
|
|
if record.FinishedAt.Before(*record.StartedAt) {
|
|
return fmt.Errorf("finished at must not be before started at")
|
|
}
|
|
}
|
|
|
|
if record.StartedAt != nil && record.StartedAt.Before(record.CreatedAt) {
|
|
return fmt.Errorf("started at must not be before created at")
|
|
}
|
|
|
|
return nil
|
|
}
|