// Package ports defines the stable interfaces that connect Game Master // use cases to external state and external services. package ports import ( "context" "fmt" "strings" "time" "galaxy/gamemaster/internal/domain/runtime" ) //go:generate go run go.uber.org/mock/mockgen -destination=../adapters/mocks/mock_runtimerecordstore.go -package=mocks galaxy/gamemaster/internal/ports RuntimeRecordStore // RuntimeRecordStore stores runtime records and exposes the operations // used by the service layer (Stages 13+) and the workers (Stages 15-18). // Adapters must preserve domain semantics: // // - Get returns runtime.ErrNotFound when no record exists for gameID. // - Insert installs a fresh record and returns runtime.ErrConflict // when a row already exists. // - UpdateStatus applies one transition through a compare-and-swap // guard on the stored status and returns runtime.ErrConflict on a // stale CAS. // - UpdateScheduling mutates `next_generation_at`, `skip_next_tick`, // and `current_turn` together; the destination status is unaffected. // - UpdateImage rotates `current_image_ref` and // `current_engine_version` under a compare-and-swap guard on the // stored status and returns runtime.ErrConflict on a stale CAS. // - UpdateEngineHealth rotates the `engine_health` column without // touching status. The call applies from any status (including // stopped and finished) so late-arriving health observations still // bookkeep correctly. Returns runtime.ErrNotFound when no row // matches. // - Delete removes the record identified by gameID. The call is // idempotent: it returns nil even when no row matches. // - ListDueRunning returns every running record with // `next_generation_at <= now`. // - ListByStatus returns every record currently indexed under status. // - List returns every record ordered by `created_at` descending. Used // by the `internalListRuntimes` REST handler when no status filter // is supplied. type RuntimeRecordStore interface { // Get returns the record identified by gameID. It returns // runtime.ErrNotFound when no record exists. Get(ctx context.Context, gameID string) (runtime.RuntimeRecord, error) // Insert installs record into the store. It returns // runtime.ErrConflict when a row already exists for record.GameID. Insert(ctx context.Context, record runtime.RuntimeRecord) error // UpdateStatus applies one status transition in a compare-and-swap // fashion. The adapter must first call runtime.Transition to reject // invalid pairs without touching the store, then verify that the // stored status equals input.ExpectedFrom. Optional fields on the // input (CurrentImageRef, CurrentEngineVersion, EngineHealthSummary) // are persisted only when non-nil. UpdateStatus(ctx context.Context, input UpdateStatusInput) error // UpdateScheduling mutates the scheduling columns // (`next_generation_at`, `skip_next_tick`, `current_turn`) of the // record identified by input.GameID. The store does not validate // the runtime status; callers issue UpdateScheduling alongside an // UpdateStatus when the destination status changes. UpdateScheduling(ctx context.Context, input UpdateSchedulingInput) error // UpdateImage rotates `current_image_ref` and // `current_engine_version` of the record identified by // input.GameID under a compare-and-swap guard on the stored status. // The destination status is unchanged. Used by the admin patch // flow (Stage 17) where the runtime stays `running` while the // engine container is recreated by Runtime Manager with a new // image. Returns runtime.ErrNotFound when no row matches and // runtime.ErrConflict when the stored status differs from // input.ExpectedStatus. UpdateImage(ctx context.Context, input UpdateImageInput) error // UpdateEngineHealth rotates the `engine_health` column of the // record identified by input.GameID without touching status. Used // by the runtime:health_events consumer (Stage 18) when an // observation should refresh the summary regardless of the current // runtime status (including stopped and finished, so late-arriving // events still bookkeep correctly). Returns runtime.ErrNotFound // when no row matches. UpdateEngineHealth(ctx context.Context, input UpdateEngineHealthInput) error // Delete removes the record identified by gameID. The call is // idempotent: it returns nil even when no row matches. Used by the // register-runtime rollback path (Stage 13) when the engine // /admin/init call or any later setup step fails after the row has // been installed with status=starting. Delete(ctx context.Context, gameID string) error // ListDueRunning returns every record whose status is `running` // and whose `next_generation_at <= now`. The order is // adapter-defined; callers may reorder as needed. ListDueRunning(ctx context.Context, now time.Time) ([]runtime.RuntimeRecord, error) // ListByStatus returns every record currently indexed under status. // The order is adapter-defined; callers may reorder as needed. ListByStatus(ctx context.Context, status runtime.Status) ([]runtime.RuntimeRecord, error) // List returns every record in the store, ordered by `created_at` // descending. Used by the `internalListRuntimes` REST handler when no // status filter is supplied. List(ctx context.Context) ([]runtime.RuntimeRecord, error) } // UpdateStatusInput stores the arguments required to apply one status // transition through a RuntimeRecordStore. The optional fields are // pointers so the adapter can distinguish «leave alone» from «write // the zero value». type UpdateStatusInput struct { // GameID identifies the record to mutate. GameID string // ExpectedFrom stores the status the caller believes the record // currently has. A mismatch results in runtime.ErrConflict. ExpectedFrom runtime.Status // To stores the destination status. To runtime.Status // Now stores the wall-clock used to derive the lifecycle timestamps // (started_at, stopped_at, finished_at, updated_at) according to // To. Now time.Time // EngineHealthSummary is the new value of the `engine_health` // column. Nil leaves the column unchanged. EngineHealthSummary *string // CurrentImageRef is the new value of the `current_image_ref` // column. Nil leaves the column unchanged. Used by the patch flow // (Stage 17) when the image reference rotates together with the // status update. CurrentImageRef *string // CurrentEngineVersion is the new value of the // `current_engine_version` column. Nil leaves the column unchanged. // Used by the patch flow when the engine version rotates together // with the status update. CurrentEngineVersion *string } // Validate reports whether input contains a structurally valid status // transition request. Adapters call Validate before touching the store. func (input UpdateStatusInput) Validate() error { if strings.TrimSpace(input.GameID) == "" { return fmt.Errorf("update runtime status: game id must not be empty") } if !input.ExpectedFrom.IsKnown() { return fmt.Errorf( "update runtime status: expected from status %q is unsupported", input.ExpectedFrom, ) } if !input.To.IsKnown() { return fmt.Errorf( "update runtime status: to status %q is unsupported", input.To, ) } if err := runtime.Transition(input.ExpectedFrom, input.To); err != nil { return fmt.Errorf("update runtime status: %w", err) } if input.Now.IsZero() { return fmt.Errorf("update runtime status: now must not be zero") } if input.CurrentImageRef != nil && strings.TrimSpace(*input.CurrentImageRef) == "" { return fmt.Errorf( "update runtime status: current image ref must not be empty when set", ) } if input.CurrentEngineVersion != nil && strings.TrimSpace(*input.CurrentEngineVersion) == "" { return fmt.Errorf( "update runtime status: current engine version must not be empty when set", ) } return nil } // UpdateSchedulingInput stores the arguments required to mutate the // scheduling columns of one runtime record. The status enum is // deliberately absent: scheduling and status updates are independent // operations and the service layer composes them when both must change. type UpdateSchedulingInput struct { // GameID identifies the record to mutate. GameID string // NextGenerationAt is the new value of the column. Nil writes SQL // NULL (used to clear the tick when the runtime leaves running). NextGenerationAt *time.Time // SkipNextTick is the new value of the column. The store overwrites // the column unconditionally. SkipNextTick bool // CurrentTurn is the new value of the column. Must be non-negative. CurrentTurn int // Now stores the wall-clock used to refresh `updated_at`. Now time.Time } // Validate reports whether input contains structurally valid scheduling // arguments. Adapters call Validate before touching the store. func (input UpdateSchedulingInput) Validate() error { if strings.TrimSpace(input.GameID) == "" { return fmt.Errorf("update runtime scheduling: game id must not be empty") } if input.CurrentTurn < 0 { return fmt.Errorf("update runtime scheduling: current turn must not be negative") } if input.NextGenerationAt != nil && input.NextGenerationAt.IsZero() { return fmt.Errorf( "update runtime scheduling: next generation at must not be zero when set", ) } if input.Now.IsZero() { return fmt.Errorf("update runtime scheduling: now must not be zero") } return nil } // UpdateImageInput stores the arguments required to rotate the engine // image reference and version of one runtime record without changing // its status. The store applies a compare-and-swap guard on // `(game_id, status)` so callers can reject the update if the runtime // has drifted out of the expected status. type UpdateImageInput struct { // GameID identifies the record to mutate. GameID string // ExpectedStatus stores the status the caller believes the record // currently has. A mismatch results in runtime.ErrConflict. ExpectedStatus runtime.Status // CurrentImageRef stores the new value of the // `current_image_ref` column. Must not be empty. CurrentImageRef string // CurrentEngineVersion stores the new value of the // `current_engine_version` column. Must not be empty. CurrentEngineVersion string // Now stores the wall-clock used to refresh `updated_at`. Now time.Time } // Validate reports whether input contains structurally valid image // rotation arguments. Adapters call Validate before touching the store. func (input UpdateImageInput) Validate() error { if strings.TrimSpace(input.GameID) == "" { return fmt.Errorf("update runtime image: game id must not be empty") } if !input.ExpectedStatus.IsKnown() { return fmt.Errorf( "update runtime image: expected status %q is unsupported", input.ExpectedStatus, ) } if strings.TrimSpace(input.CurrentImageRef) == "" { return fmt.Errorf("update runtime image: current image ref must not be empty") } if strings.TrimSpace(input.CurrentEngineVersion) == "" { return fmt.Errorf("update runtime image: current engine version must not be empty") } if input.Now.IsZero() { return fmt.Errorf("update runtime image: now must not be zero") } return nil } // UpdateEngineHealthInput stores the arguments required to rotate the // `engine_health` column of one runtime record without touching its // status. The store performs no compare-and-swap so callers can apply // the update from any runtime status (including stopped and finished) // to keep the summary current for late-arriving runtime:health_events. type UpdateEngineHealthInput struct { // GameID identifies the record to mutate. GameID string // EngineHealthSummary stores the new value of the `engine_health` // column. The summary is a free-form short string drawn from the // vocabulary documented in // `gamemaster/README.md §Persistence Layout` and produced by the // Stage 18 consumer. EngineHealthSummary string // Now stores the wall-clock used to refresh `updated_at`. Now time.Time } // Validate reports whether input carries structurally valid arguments // for an engine-health update. Adapters call Validate before touching // the store. func (input UpdateEngineHealthInput) Validate() error { if strings.TrimSpace(input.GameID) == "" { return fmt.Errorf("update runtime engine health: game id must not be empty") } if input.Now.IsZero() { return fmt.Errorf("update runtime engine health: now must not be zero") } return nil }