lib/openagents/stacks/operation.ex

58e6347eeb72 · 3 KB

defmodule OpenAgents.Stacks.Operation do
  @moduledoc """
  One durable server-side stack operation.

  The row is the recovery record: it carries the request, the snapshot the
  worker took before touching anything, the planned result, and — for a
  paused rebase — the conflict workspace. A worker crash leaves the row
  claimable again after its lease expires, so the operation resumes instead
  of vanishing.
  """
  use Ecto.Schema
  import Ecto.Changeset

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id

  @kinds ~w(create append restructure rebase merge queue unstack dissolve repair)
  @states ~w(pending running waiting_for_conflict_resolution waiting_for_checks succeeded partially_succeeded failed cancelled)
  @active_states ~w(pending running waiting_for_conflict_resolution)

  schema "stack_operations" do
    belongs_to :stack, OpenAgents.Stacks.Stack
    field :kind, :string
    field :state, :string, default: "pending"
    field :target_position, :integer
    field :expected_stack_version, :integer
    field :idempotency_key, :string

    field :request, :map, default: %{}
    field :snapshot, :map
    field :planned_result, :map
    field :conflict, :map
    field :error, :map

    belongs_to :created_by_user, OpenAgents.Accounts.User

    field :attempt_count, :integer, default: 0
    field :retry_at, :utc_datetime_usec
    field :claimed_at, :utc_datetime_usec
    field :started_at, :utc_datetime_usec
    field :completed_at, :utc_datetime_usec

    timestamps(type: :utc_datetime_usec)
  end

  def kinds, do: @kinds
  def states, do: @states
  def active_states, do: @active_states

  def changeset(operation, attrs) do
    operation
    |> cast(attrs, [
      :kind,
      :state,
      :target_position,
      :expected_stack_version,
      :idempotency_key,
      :request,
      :retry_at
    ])
    |> put_change(:stack_id, Map.fetch!(attrs, :stack_id))
    |> put_change(:created_by_user_id, Map.get(attrs, :created_by_user_id))
    |> validate_required([:stack_id, :kind, :state, :expected_stack_version, :idempotency_key])
    |> validate_inclusion(:kind, @kinds)
    |> validate_inclusion(:state, @states)
    |> validate_number(:expected_stack_version, greater_than_or_equal_to: 1)
    |> validate_length(:idempotency_key, min: 1, max: 255)
    |> unique_constraint([:stack_id, :idempotency_key])
    |> unique_constraint(:stack_id, name: :stack_operations_active_stack_index)
    |> check_constraint(:kind, name: :stack_operations_kind_check)
    |> check_constraint(:state, name: :stack_operations_state_check)
    |> foreign_key_constraint(:stack_id)
    |> foreign_key_constraint(:created_by_user_id)
  end

  def transition_changeset(operation, attrs) do
    operation
    |> cast(attrs, [
      :state,
      :snapshot,
      :planned_result,
      :conflict,
      :error,
      :attempt_count,
      :retry_at,
      :claimed_at,
      :started_at,
      :completed_at
    ])
    |> validate_required([:state])
    |> validate_inclusion(:state, @states)
    |> check_constraint(:state, name: :stack_operations_state_check)
  end
end