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