lib/openagents/deployments/event.ex

main at 58e6347eeb72 · 2 KB

defmodule OpenAgents.Deployments.Event do
  @moduledoc """
  One append-only record of something that happened to a run.

  Events carry a per-run monotonic `sequence`, so a reader can poll or stream
  from a cursor and know it missed nothing. The table has no update timestamp
  because nothing rewrites a transition after it commits; a correction is a new
  event.
  """

  use Ecto.Schema

  import Ecto.Changeset

  alias OpenAgents.Deployments.Run

  @actor_types ~w(user workflow operator system provider)
  @maximum_detail_bytes 4_096

  @type t :: %__MODULE__{}

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id
  @timestamps_opts [type: :utc_datetime_usec, updated_at: false]

  schema "deployment_events" do
    field :sequence, :integer
    field :type, :string
    field :from_state, :string
    field :to_state, :string
    field :detail, :map, default: %{}
    field :actor_type, :string
    field :actor_id, :string
    field :occurred_at, :utc_datetime_usec

    belongs_to :repository, OpenAgents.Repositories.Repository
    belongs_to :deployment_run, OpenAgents.Deployments.Run

    timestamps()
  end

  @doc false
  def changeset(event, attrs) do
    event
    |> cast(attrs, [:sequence, :type, :from_state, :to_state, :detail, :actor_type, :actor_id])
    |> validate_required([:sequence, :type, :detail, :actor_type])
    |> validate_number(:sequence, greater_than: 0)
    |> validate_length(:type, min: 1, max: 60)
    |> validate_inclusion(:actor_type, @actor_types)
    |> validate_length(:actor_id, max: 64)
    |> validate_state(:from_state)
    |> validate_state(:to_state)
    |> validate_detail()
    |> put_change(:occurred_at, DateTime.utc_now())
    |> unique_constraint(:sequence, name: :deployment_events_deployment_run_id_sequence_index)
  end

  @doc "The actor kinds an event can attribute a transition to."
  @spec actor_types() :: [String.t()]
  def actor_types, do: @actor_types

  defp validate_state(changeset, field),
    do: validate_inclusion(changeset, field, Run.states())

  # Event detail reaches tenant reads, webhooks, and audit exports, so it is
  # bounded here rather than trusted from the caller.
  defp validate_detail(changeset) do
    validate_change(changeset, :detail, fn :detail, detail ->
      encoded = Jason.encode_to_iodata!(detail)

      if IO.iodata_length(encoded) > @maximum_detail_bytes do
        [detail: "exceeds #{@maximum_detail_bytes} bytes"]
      else
        []
      end
    end)
  end
end