lib/openagents/threads/thread.ex

main at 58e6347eeb72 · 11 KB

defmodule OpenAgents.Threads.Thread do
  @moduledoc """
  A thread: one objective, its turns, its transcript, and its budget
  (`docs/taxonomy.md`).

  A thread is account-scoped, plural, and disposable. It belongs to the
  account's owner visitor, never to a conversation — the account has exactly
  one conversation (DATA-002), and a thread is not one. Nothing on this record
  requires a conversation to exist.

  The columns follow `OpenAgents.SCV.Execution`, which is the durable
  execution record this repository already has with no conversation: a bounded
  objective, the admitted execution shape, a status ladder, a monotonic
  generation, a terminal report with its digest, and a metering map. The
  generation is the authority fence — see `OpenAgents.Threads.mint_grant/1`.
  """

  use Ecto.Schema
  import Ecto.Changeset

  alias OpenAgents.Conversations.Visitor
  alias OpenAgents.Issues.Issue
  alias OpenAgents.Threads.Event

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

  @statuses ~w(open succeeded failed cancelled)
  @terminal_statuses ~w(succeeded failed cancelled)
  @succeeded "succeeded"
  @cancelled "cancelled"
  @permission_profiles ~w(read_only workspace_write)
  @reasoning_efforts ~w(none minimal low medium high max)
  @objective_bytes 32_768
  @repository_bytes 200

  # Which lane opened the thread. `thread` is the granted lane: the model is a
  # catalog id and the open mints a grant. `local` is the transcript-only lane:
  # the model is the vendor string a local runtime serves, and no grant is ever
  # minted (issue #243).
  @lanes ~w(thread local)
  @default_lane "thread"
  @local_lane "local"

  # The disclosure vocabulary is `OpenAgents.Transparency`'s — `dark`, `pulse`,
  # `ledger`, `glass` (`docs/taxonomy.md`) — and a thread offers the two rungs
  # this surface can enforce, not a fifth word of its own.
  # `ThreadVisibilityTest` proves the set stays a subset of that vocabulary.
  @visibilities ~w(dark ledger)
  @default_visibility "dark"

  schema "threads" do
    belongs_to :owner_visitor, Visitor
    field :objective, :string, redact: true
    field :repository, :string
    field :visibility, :string, default: "dark"
    field :lane, :string, default: "thread"
    field :status, :string, default: "open"
    field :model, :string
    field :reasoning_effort, :string
    field :permission_profile, :string, default: "read_only"
    field :generation, :integer, default: 0
    field :report, :string, redact: true
    field :report_digest, :string
    field :error_code, :string
    field :event_count, :integer, default: 0
    field :usage, :map, default: %{}
    field :started_at, :utc_datetime_usec
    field :completed_at, :utc_datetime_usec
    field :report_type, :string
    has_many :events, Event, foreign_key: :thread_id
    belongs_to :parent, __MODULE__, foreign_key: :parent_thread_id
    belongs_to :issue, Issue, type: :id
    timestamps()
  end

  @type t :: %__MODULE__{}

  def statuses, do: @statuses
  def terminal_statuses, do: @terminal_statuses
  def permission_profiles, do: @permission_profiles
  def reasoning_efforts, do: @reasoning_efforts

  @doc """
  The transparency tiers a thread may be opened at, narrowest first.

  Two rungs of the shared `dark/pulse/ledger/glass` ladder, because two are
  what a thread read path enforces. `pulse` would need a metadata-only
  projection of the transcript and `glass` would need a capability beyond
  reading it; neither exists, so neither is offered (THREAD-002).
  """
  def visibilities, do: @visibilities

  @doc "The tier a thread takes when its opener names none: owner-only."
  def default_visibility, do: @default_visibility

  @doc """
  The lanes a thread may be opened on.

  `thread` is the granted lane — the model is admitted against the catalog and
  the open mints a grant. `local` is the transcript-only lane — the model is a
  bounded vendor string and no grant is ever minted, so the thread records a
  run whose model calls never touch this server (issue #243).
  """
  def lanes, do: @lanes

  @doc "The lane a thread takes when its opener names none: the granted one."
  def default_lane, do: @default_lane

  @doc "Whether `thread` is on the transcript-only local lane."
  @spec local?(t()) :: boolean()
  def local?(%__MODULE__{lane: lane}), do: lane == @local_lane

  @doc """
  The tiers that admit a reader who is not the account that opened the thread.

  Every rung above the default, derived rather than restated, so adding a rung
  to `visibilities/0` cannot leave the read path enforcing the old set.
  """
  def wide_visibilities, do: @visibilities -- [@default_visibility]

  @doc "Whether `thread` is readable by somebody other than its owner."
  @spec wide?(t()) :: boolean()
  def wide?(%__MODULE__{visibility: visibility}), do: visibility in wide_visibilities()

  @spec open?(t()) :: boolean()
  def open?(%__MODULE__{status: "open"}), do: true
  def open?(%__MODULE__{}), do: false

  @doc "The status a thread takes when its report names no outcome to disagree with."
  def succeeded, do: @succeeded

  @doc """
  Whether `thread` was cancelled — the one end that cannot be reopened.

  A cancelled thread was disposed of on purpose: `DELETE /api/v1/threads/{id}`
  is the verb, and a caller that used it asked for the thread to be over.
  Every other end is a state the work reached, and a later session may be
  granted authority on it again (THREAD-001).
  """
  @spec cancelled?(t()) :: boolean()
  def cancelled?(%__MODULE__{status: @cancelled}), do: true
  def cancelled?(%__MODULE__{}), do: false

  @doc """
  The immutable capture at open time. `owner_visitor_id`, `status`,
  `generation`, and `started_at` are set by the context, never cast from a
  caller.

  `repository` is optional and deliberately unvalidated against the forge's
  repository table: a thread may concern a repository the forge does not host,
  so the field records the opener's `owner/name` string, bounded, with no
  foreign key and no format rule beyond non-blank.

  `visibility` is optional and defaults to `dark`, the owner-only rung. It is
  the one field here a caller can use to widen who reads the transcript, so it
  is cast rather than put: naming it is the explicit act, and omitting it
  leaves the thread private (THREAD-002).
  """
  def open_changeset(attributes, owner_visitor_id, now) do
    %__MODULE__{}
    |> cast(attributes, [
      :objective,
      :model,
      :reasoning_effort,
      :permission_profile,
      :repository,
      :visibility,
      :lane,
      :parent_thread_id,
      :issue_id
    ])
    |> put_change(:owner_visitor_id, owner_visitor_id)
    |> put_change(:status, "open")
    |> put_change(:generation, 0)
    |> put_change(:started_at, now)
    |> validate_required([
      :objective,
      :model,
      :reasoning_effort,
      :permission_profile,
      :visibility
    ])
    |> validate_length(:objective, min: 1, max: @objective_bytes, count: :bytes)
    |> validate_length(:model, min: 1, max: 200)
    |> validate_length(:repository, min: 1, max: @repository_bytes, count: :bytes)
    |> validate_inclusion(:reasoning_effort, @reasoning_efforts)
    |> validate_inclusion(:permission_profile, @permission_profiles)
    |> validate_inclusion(:visibility, @visibilities)
    |> validate_inclusion(:lane, @lanes)
    |> foreign_key_constraint(:owner_visitor_id)
    |> foreign_key_constraint(:parent_thread_id)
    |> foreign_key_constraint(:issue_id)
    |> check_constraint(:parent_thread_id, name: :threads_no_self_parent)
    |> check_constraint(:status, name: :threads_status_check)
    |> check_constraint(:objective, name: :threads_objective_bound_check)
    |> check_constraint(:repository, name: :threads_repository_bound_check)
    |> check_constraint(:visibility, name: :threads_visibility_check)
    |> check_constraint(:lane, name: :threads_lane_check)
    |> check_constraint(:reasoning_effort, name: :threads_reasoning_effort_check)
    |> check_constraint(:permission_profile, name: :threads_permission_profile_check)
  end

  @doc "Bump the authority fence. Every mint advances it; nothing lowers it."
  def generation_changeset(%__MODULE__{} = thread) do
    thread
    |> change(%{generation: thread.generation + 1})
    |> check_constraint(:generation, name: :threads_generation_nonnegative_check)
  end

  @doc "Advance the retained event counter alongside an appended event."
  def event_count_changeset(%__MODULE__{} = thread, count) do
    thread
    |> change(%{event_count: count})
    |> check_constraint(:event_count, name: :threads_event_count_nonnegative_check)
  end

  @doc """
  The terminal receipt. A thread ends once and carries a typed report when it
  does.

  The status and the error code have to agree, and the agreement is checked
  here rather than at each caller: `succeeded` means the thread carries no
  error code, and every other terminal status has to name one. Without that
  rule a caller could file an interrupted run, a failed one, or one that ran
  out of steps as `succeeded`, and the durable record would read as the
  opposite of what happened — which is the same class of bug as recording a
  session that answered correctly as `cancelled` (issue #106). The database
  refuses the same pair (`threads_terminal_outcome_check`), so no writer that
  skips this changeset can file one either.
  """
  def terminal_changeset(%__MODULE__{} = thread, attributes) do
    thread
    |> cast(attributes, [
      :status,
      :report,
      :report_digest,
      :report_type,
      :usage,
      :error_code,
      :completed_at
    ])
    |> validate_required([:status, :report, :report_digest, :report_type, :completed_at])
    |> validate_inclusion(:status, @terminal_statuses)
    |> validate_length(:report, min: 1, max: @objective_bytes, count: :bytes)
    |> validate_format(:report_digest, ~r/\Asha256:[0-9a-f]{64}\z/)
    |> validate_length(:report_type, max: 80)
    |> validate_length(:error_code, max: 80)
    |> validate_outcome()
    |> check_constraint(:status, name: :threads_status_check)
    |> check_constraint(:report, name: :threads_report_bound_check)
    |> check_constraint(:report_type, name: :threads_report_type_bound_check)
    |> check_constraint(:error_code, name: :threads_terminal_outcome_check)
    |> check_constraint(:completed_at, name: :threads_terminal_shape_check)
  end

  defp validate_outcome(changeset) do
    status = get_field(changeset, :status)
    error_code = get_field(changeset, :error_code)

    cond do
      status == @succeeded and present?(error_code) ->
        add_error(
          changeset,
          :error_code,
          "must be empty on a thread that succeeded; name the status the outcome actually had"
        )

      status in @terminal_statuses and status != @succeeded and not present?(error_code) ->
        add_error(changeset, :error_code, "is required on a thread that did not succeed")

      true ->
        changeset
    end
  end

  defp present?(value), do: is_binary(value) and String.trim(value) != ""

  @doc """
  Reopen a thread that ended, so a later session can be granted authority on it
  again.

  A thread that reported is the thing `oa coder --resume` comes back to, and a
  grant cannot be minted for a terminal thread (THREAD-001). Reopening clears
  the terminal columns, which the shape constraint requires of an open row, and
  `OpenAgents.Threads.mint_grant/1` writes the report it is clearing into the
  transcript first, so nothing the thread reported is lost. Cancelling is the
  one end this does not undo: it is a disposal, not a pause.
  """
  def reopen_changeset(%__MODULE__{} = thread) do
    thread
    |> change(%{
      status: "open",
      report: nil,
      report_digest: nil,
      report_type: nil,
      error_code: nil,
      completed_at: nil
    })
    |> check_constraint(:status, name: :threads_status_check)
    |> check_constraint(:completed_at, name: :threads_terminal_shape_check)
  end
end