priv/repo/migrations/20260825113459_add_gym_run_lifecycle.exs

main at 58e6347eeb72 · 2 KB

defmodule OpenAgents.Repo.Migrations.AddGymRunLifecycle do
  use Ecto.Migration

  def up do
    # A run now exists while it runs. `status` defaults to `graded` so every
    # legacy row — recorded only after the fact — keeps the meaning it had,
    # and `updated_at`/`completed_at` are backfilled from `inserted_at` for
    # the same reason: a one-shot row was complete the moment it was written.
    alter table(:gym_runs) do
      add :status, :text, null: false, default: "graded"
      add :completed_at, :utc_datetime_usec
      add :updated_at, :utc_datetime_usec
    end

    execute "UPDATE gym_runs SET updated_at = inserted_at, completed_at = inserted_at"

    execute "ALTER TABLE gym_runs ALTER COLUMN updated_at SET NOT NULL"

    # A running row has no grades yet; the changeset requires them by status
    # and the constraint below keeps a graded row honest at the database.
    execute "ALTER TABLE gym_runs ALTER COLUMN tasks_total DROP NOT NULL"
    execute "ALTER TABLE gym_runs ALTER COLUMN tasks_passed DROP NOT NULL"

    create constraint(:gym_runs, :gym_runs_status_check,
             check: "status IN ('running', 'graded', 'abandoned')"
           )

    create constraint(:gym_runs, :gym_runs_graded_shape_check,
             check:
               "status <> 'graded' OR (tasks_total IS NOT NULL AND tasks_passed IS NOT NULL AND completed_at IS NOT NULL)"
           )

    # The lazy staleness sweep reads exactly this slice.
    create index(:gym_runs, [:updated_at], where: "status = 'running'")

    create table(:gym_trials, primary_key: false) do
      add :id, :binary_id, primary_key: true

      add :run_id, references(:gym_runs, type: :binary_id, on_delete: :delete_all), null: false

      add :task, :text, null: false
      add :state, :text, null: false
      add :thread_id, :binary_id

      timestamps(type: :utc_datetime_usec)
    end

    create constraint(:gym_trials, :gym_trials_task_bound_check,
             check: "octet_length(task) BETWEEN 1 AND 200"
           )

    create constraint(:gym_trials, :gym_trials_state_check,
             check: "state IN ('running', 'passed', 'failed', 'ungraded')"
           )

    # One row per task per run: a re-reported trial updates in place.
    create unique_index(:gym_trials, [:run_id, :task])
  end

  def down do
    drop table(:gym_trials)

    drop index(:gym_runs, [:updated_at], where: "status = 'running'")
    drop constraint(:gym_runs, :gym_runs_graded_shape_check)
    drop constraint(:gym_runs, :gym_runs_status_check)

    execute "DELETE FROM gym_runs WHERE tasks_total IS NULL OR tasks_passed IS NULL"
    execute "ALTER TABLE gym_runs ALTER COLUMN tasks_total SET NOT NULL"
    execute "ALTER TABLE gym_runs ALTER COLUMN tasks_passed SET NOT NULL"

    alter table(:gym_runs) do
      remove :status
      remove :completed_at
      remove :updated_at
    end
  end
end