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