lib/openagents/scv/deployments.ex

58e6347eeb72 · 6 KB

defmodule OpenAgents.SCV.Deployments do
  @moduledoc """
  The one admitted entry point for deploying an SCV on our own capacity
  (SCV-001).

  Every surface that can start an SCV — Sarah's `scv_deploy` tool today, an
  operator surface tomorrow — enters here, so operator authority, the
  repository's identity, the exact revision, the objective bound, and the
  concurrency ceiling cannot drift apart between callers. This mirrors
  `OpenAgents.ComputerAgentJobs`, which does the same job for delegations to a
  person's own machine.

  Two facts make this lane different from every other tool Sarah holds, and
  both are enforced here rather than described:

  - **It spends our capacity, not the caller's.** A delegation ends on hardware
    the person owns and powers; an SCV ends on ours. So the authority required
    is operator authority — `OpenAgents.Accounts.admin?/1` — checked against the
    account behind the conversation, in the code that starts the run, not only
    in whatever advertised the tool.
  - **It is bounded before it starts.** The objective is capped, the wall clock
    and output ceiling are snapshotted onto the row at admission, and the number
    of SCVs running at once across the whole application is capped, so a model
    that decides to deploy in a loop is refused at the second or third call
    rather than at the invoice.

  The run itself is a `work_jobs` row of kind `scv`; nothing here is a second
  job system.
  """

  import Ecto.Query

  alias OpenAgents.Accounts
  alias OpenAgents.Accounts.User
  alias OpenAgents.Forge.Repos
  alias OpenAgents.Repo
  alias OpenAgents.Repositories
  alias OpenAgents.Repositories.Repository
  alias OpenAgents.Work
  alias OpenAgents.Work.Job
  alias OpenAgents.Work.Scv

  @active_statuses ~w(queued running)

  @doc """
  Start one bounded SCV deployment for an operator.

  Returns `{:ok, job}` with a queued-or-running `work_jobs` row, or a typed
  refusal. The caller acknowledges the job reference immediately; the run
  reports back into the conversation when it ends.
  """
  @spec start(User.t(), map()) :: {:ok, Job.t()} | {:error, atom()}
  def start(%User{} = user, attributes) when is_map(attributes) do
    with :ok <- feature_enabled(),
         :ok <- operator(user),
         {:ok, objective} <- objective(attributes),
         {:ok, conversation_id} <- identifier(attributes, :conversation_id),
         {:ok, owner_visitor_id} <- identifier(attributes, :owner_visitor_id),
         {:ok, repository} <- repository(user, attributes),
         {:ok, revision} <- revision(repository),
         :ok <- capacity() do
      Work.start_scv(%{
        conversation_id: conversation_id,
        owner_visitor_id: owner_visitor_id,
        surface: surface(attributes),
        goal: objective,
        delegation: %{
          "objective" => objective,
          "repository_path" => "#{repository.owner}/#{repository.name}"
        },
        authority_snapshot:
          Scv.authority_snapshot(%{owner: user, repository: repository, revision: revision}),
        budget_snapshot: Scv.budget_snapshot()
      })
    end
  end

  def start(_user, _attributes), do: {:error, :operator_required}

  @doc "How many SCV deployments are queued or running right now."
  @spec active_count() :: non_neg_integer()
  def active_count do
    Repo.aggregate(
      from(job in Job, where: job.kind == ^Scv.kind() and job.status in ^@active_statuses),
      :count
    )
  end

  @doc """
  The approval receipts that admit the SCV deployment module for one operator.

  Operating the SCV lane is an operator act, so the receipt carries
  `explicit_operator_approval` and points at the operator account. A
  non-operator receives no receipt at all, which is what makes
  `OpenAgents.Modules.SurfacePolicy` refuse the call a second time,
  independently of the check in `start/2`.
  """
  @spec approval_receipts(User.t() | nil, String.t()) :: [map()]
  def approval_receipts(user, scope_ref) when is_binary(scope_ref) do
    if Accounts.admin?(user) do
      [
        %{
          "schema" => "sarah.module_approval.v1",
          "approval_class" => "explicit_operator_approval",
          "module_id" => "sarah.tool.scv_deploy.v1",
          "version" => 1,
          "scope_ref" => scope_ref,
          "explicit" => true,
          "actor_type" => "operator",
          "receipt_ref" => "operator:#{user.id}"
        }
      ]
    else
      []
    end
  end

  # ── admission ──────────────────────────────────────────────────────────────

  defp feature_enabled do
    if Scv.enabled?(), do: :ok, else: {:error, :scv_deploy_disabled}
  end

  defp operator(user) do
    if Accounts.admin?(user), do: :ok, else: {:error, :operator_required}
  end

  defp objective(attributes) do
    case Map.get(attributes, :objective) do
      value when is_binary(value) ->
        trimmed = String.trim(value)

        if trimmed != "" and byte_size(trimmed) <= Scv.maximum_objective_bytes(),
          do: {:ok, trimmed},
          else: {:error, :scv_objective_invalid}

      _missing ->
        {:error, :scv_objective_invalid}
    end
  end

  defp identifier(attributes, key) do
    case Map.get(attributes, key) do
      value when is_binary(value) -> {:ok, value}
      _missing -> {:error, :scope_refused}
    end
  end

  defp surface(attributes) do
    case Map.get(attributes, :surface) do
      value when value in ["text", "voice"] -> value
      _other -> "text"
    end
  end

  # The repository is named the way a person names it, and resolved to a row
  # the operator may actually read. An SCV never reaches a repository through a
  # filesystem path the caller supplied.
  defp repository(user, attributes) do
    # The read predicate is composed, not restated. The copy this replaced
    # admitted any membership row rather than one in a reading role, and
    # resolved the path without the namespace-alias join a rename leaves behind
    # (REPOSITORY-001).
    with path when is_binary(path) <- Map.get(attributes, :repository),
         [owner, name] <- String.split(String.trim(path), "/", parts: 2),
         %Repository{} = repository <- Repositories.visible_by_path(owner, name, user) do
      {:ok, repository}
    else
      _unavailable -> {:error, :scv_repository_not_found}
    end
  end

  defp revision(%Repository{} = repository) do
    if Repos.valid_storage_key?(repository.storage_key) do
      refs = Repos.refs(repository.storage_key)

      case Map.get(refs, "refs/heads/#{repository.default_branch}") do
        sha when is_binary(sha) ->
          if Regex.match?(~r/\A[0-9a-f]{40}\z/, sha),
            do: {:ok, sha},
            else: {:error, :scv_repository_revision_unavailable}

        _missing ->
          {:error, :scv_repository_revision_unavailable}
      end
    else
      {:error, :scv_repository_not_found}
    end
  end

  defp capacity do
    if active_count() < Scv.concurrency_limit(),
      do: :ok,
      else: {:error, :scv_capacity_reached}
  end
end