defmodule ScoriaWeb.ApprovalsLive.Index do
@moduledoc """
Approvals inbox — the operator's blocking queue of tool calls awaiting a
workflow-owned decision. Extracted from the Live Ops god-page so approving or
rejecting a gated call is a focused, linkable surface.
"""
use Phoenix.LiveView, layout: {ScoriaWeb.Layouts, :app}
import ScoriaWeb.UI,
only: [
badge: 1,
drawer: 1,
evidence_action_row: 1,
evidence_rows: 1,
flash_group: 1,
modal: 1,
toast: 1
]
import Ecto.Query, warn: false
alias Scoria.Repo
alias Scoria.SRE.AuditOutboxEvent
alias Scoria.Workflows
alias Scoria.Workflows.Resume
alias ScoriaWeb.ApprovalInboxComponent
@impl true
def mount(params, session, socket) do
tenant_id = params["tenant"] || session["tenant_id"] || "default"
socket =
socket
|> assign(:page_title, "Approvals")
|> assign(:active_approval, nil)
|> assign(:decision_modal, nil)
|> assign(:highlighted_approval_id, nil)
|> assign(:approval_table_density, :compact)
|> assign(:approval_inbox, [])
|> assign(:runtime_query, Map.get(params, "runtime"))
|> assign(
:actor_id,
session["actor_id"] || session["user_id"] || session["session_id"] || "operator"
)
|> assign(:tenant_id, tenant_id)
|> assign(:toasts, [])
if connected?(socket) do
Phoenix.PubSub.subscribe(Scoria.PubSub, "scoria:runs:#{tenant_id}")
end
{:ok, socket |> reload_inbox() |> maybe_seed_active_approval()}
end
@impl true
def handle_info({:approval_decided, approval_id, _status}, socket) do
socket =
socket
|> maybe_clear_active_approval(approval_id)
|> maybe_clear_highlighted_approval(approval_id)
|> reload_inbox()
{:noreply, socket}
end
def handle_info({:hitl_request, projection}, socket) do
socket = reload_inbox(socket)
socket =
if is_nil(socket.assigns.active_approval) or
approval_matches_focus?(projection, socket.assigns.runtime_query) do
socket
|> assign(:active_approval, projection)
|> assign(:highlighted_approval_id, nil)
else
assign(socket, :highlighted_approval_id, projection.id)
end
{:noreply, socket}
end
# Ignore the run/trace stream broadcasts the Live Ops page consumes.
def handle_info(_message, socket), do: {:noreply, socket}
@impl true
def handle_event("approve", _, socket) do
{:noreply, record_approval_decision(socket, "approved")}
end
def handle_event("reject", _, socket) do
{:noreply, record_approval_decision(socket, "rejected")}
end
def handle_event("dismiss_approval", _, socket) do
{:noreply, socket |> assign(:active_approval, nil) |> assign(:decision_modal, nil)}
end
def handle_event("select_approval", %{"id" => approval_id}, socket) do
case Enum.find(socket.assigns.approval_inbox, &(to_string(&1.id) == approval_id)) do
nil -> {:noreply, socket}
approval -> {:noreply, assign(socket, :active_approval, approval)}
end
end
def handle_event("open_decision_modal", %{"decision" => decision}, socket)
when decision in ["approve", "reject"] do
{:noreply, assign(socket, :decision_modal, decision)}
end
def handle_event("close_decision_modal", _, socket) do
{:noreply, assign(socket, :decision_modal, nil)}
end
def handle_event("set_density", %{"density" => density}, socket) do
density =
case density do
"compact" -> :compact
"comfortable" -> :comfortable
_ -> :default
end
{:noreply, assign(socket, :approval_table_density, density)}
end
@impl true
def render(assigns) do
~H"""
<div class="scoria-dashboard relative">
<div class="scoria-pagehead">
<h1>Approvals</h1>
<p>
Operator-gated tool calls awaiting a workflow-owned decision. Select one to review its arguments and approve or reject.
</p>
</div>
<.flash_group flash={@flash} />
<div id="toast-region" class="scoria-toast-region">
<.toast :for={t <- @toasts} id={t.id} tone={t.tone} message={t.message} duration_ms={t.duration_ms} />
</div>
<ApprovalInboxComponent.render
approvals={@approval_inbox}
highlight_approval_id={@highlighted_approval_id}
density={@approval_table_density}
on_density_change="set_density"
select_event="select_approval"
/>
<.drawer id="approval-detail-drawer" show={@active_approval != nil} on_dismiss="dismiss_approval">
<:eyebrow>Approval detail</:eyebrow>
<:title_slot>Approval Required</:title_slot>
<.evidence_rows rows={approval_detail_rows(@active_approval)} />
<.evidence_action_row :if={@active_approval && @active_approval[:workflow_run_id]} class="mt-4">
<a
href={(assigns[:scoria_base] || "") <> "/workflows/#{@active_approval[:workflow_run_id]}"}
class="scoria-button scoria-button--ghost scoria-button--sm"
>
View workflow run
</a>
</.evidence_action_row>
<p class="mt-4">
Record a workflow-owned decision. The approval state and audit evidence are written durably before any resume attempt.
</p>
<.evidence_action_row class="mt-6">
<button
type="button"
phx-click="open_decision_modal"
phx-value-decision="reject"
class="scoria-button scoria-button--danger"
>
Reject approval
</button>
<button
type="button"
phx-click="open_decision_modal"
phx-value-decision="approve"
class="scoria-button scoria-button--primary"
>
Approve workflow
</button>
</.evidence_action_row>
</.drawer>
<.modal id="approval-decision-modal" show={@decision_modal != nil} on_dismiss="close_decision_modal">
<:title_slot>{decision_title(@decision_modal)}</:title_slot>
<.badge tone={decision_tone(@decision_modal)} label={decision_badge(@decision_modal)} dot={false} />
<p>{decision_copy(@decision_modal)}</p>
<:footer>
<button type="button" phx-click="close_decision_modal" class="scoria-button scoria-button--ghost">
Keep reviewing
</button>
<button
:if={@decision_modal == "reject"}
type="button"
phx-click="reject"
class="scoria-button scoria-button--danger"
>
Reject approval
</button>
<button
:if={@decision_modal == "approve"}
type="button"
phx-click="approve"
class="scoria-button scoria-button--primary"
>
Approve workflow
</button>
</:footer>
</.modal>
</div>
"""
end
# ── Internals (ported from OrchestratorLive) ───────────────────────────────
defp reload_inbox(socket) do
assign(
socket,
:approval_inbox,
Workflows.list_pending_remote_approvals(%{tenant_id: socket.assigns.tenant_id})
)
end
defp maybe_seed_active_approval(%{assigns: %{active_approval: nil}} = socket) do
case Enum.find(
socket.assigns.approval_inbox,
&approval_matches_focus?(&1, socket.assigns.runtime_query)
) do
nil -> socket
projection -> assign(socket, :active_approval, projection)
end
end
defp maybe_seed_active_approval(socket), do: socket
defp record_approval_decision(socket, status) do
case socket.assigns.active_approval do
nil ->
socket
approval ->
attrs = approval_decision_attrs(socket, approval)
with {:ok, updated_approval} <- Workflows.approve(approval.id, status, attrs),
{:ok, updated_socket} <- maybe_resume_approval(socket, updated_approval, status) do
# WR-03: a rejection deliberately keeps the workflow paused, so it must not
# report the same green ":pass / decision recorded" toast as an approval —
# that blurs a safety-relevant distinction. Branch the toast on status.
toast_opts =
case status do
"approved" -> [tone: :pass, message: "Approval granted."]
_ -> [tone: :warn, message: "Approval rejected — workflow remains paused."]
end
updated_socket
|> assign(:active_approval, nil)
|> assign(:decision_modal, nil)
|> reload_inbox()
|> put_toast(toast_opts)
else
{:error, reason} ->
socket
|> put_flash(:error, approval_error_message(status, reason))
|> put_toast(tone: :fail, message: approval_error_message(status, reason))
end
end
end
defp maybe_resume_approval(socket, _approval, status) when status != "approved",
do: {:ok, socket}
defp maybe_resume_approval(socket, approval, "approved") do
case approval.workflow_run_id do
nil -> {:ok, socket}
run_id -> Resume.resume_run(run_id)
end
|> case do
{:ok, _run} -> {:ok, socket}
{:error, reason} -> {:error, reason}
end
end
defp approval_decision_attrs(socket, approval) do
request_event = approval_request_event(approval)
%{
actor_id: socket.assigns.actor_id || approval.session_id || "operator",
tenant_id:
socket.assigns.tenant_id || (request_event && request_event.tenant_id) || "default",
trace_id: request_event && request_event.trace_id
}
end
defp approval_request_event(approval) do
AuditOutboxEvent
|> where(
[event],
event.workflow_run_id == ^approval.workflow_run_id and
event.event_type == "approval.requested"
)
|> where([event], fragment("?->>? = ?", event.redacted_refs, "approval_id", ^approval.id))
|> order_by([event], desc: event.inserted_at)
|> limit(1)
|> Repo.one()
end
defp approval_error_message(_status, :not_pending) do
"This approval was already decided by another operator."
end
defp approval_error_message(_status, %Ecto.StaleEntryError{}) do
"This approval was already decided by another operator."
end
defp approval_error_message(status, reason) do
"Could not #{status} approval through workflow-owned state: #{inspect(reason)}"
end
defp approval_matches_focus?(_projection, query) when query in [nil, ""], do: true
defp approval_matches_focus?(projection, query) when is_binary(query) do
projection.session_id == query or projection.workflow_run_id == query
end
defp approval_matches_focus?(projection, query) when is_map(query) do
workflow_run_id = query["workflow_run_id"] || query[:workflow_run_id]
session_id = query["session_id"] || query[:session_id]
(is_binary(workflow_run_id) and projection.workflow_run_id == workflow_run_id) or
(is_binary(session_id) and projection.session_id == session_id)
end
defp approval_matches_focus?(_projection, _query), do: false
defp maybe_clear_active_approval(socket, approval_id) do
if socket.assigns.active_approval && socket.assigns.active_approval.id == approval_id do
socket
|> assign(:active_approval, nil)
|> assign(:decision_modal, nil)
else
socket
end
end
defp maybe_clear_highlighted_approval(socket, approval_id) do
if socket.assigns.highlighted_approval_id == approval_id do
assign(socket, :highlighted_approval_id, nil)
else
socket
end
end
defp put_toast(socket, opts) do
toast = %{
id: "toast-#{System.unique_integer([:positive])}",
tone: Keyword.get(opts, :tone, :neutral),
message: Keyword.fetch!(opts, :message),
duration_ms: Keyword.get(opts, :duration_ms, 4000)
}
Phoenix.Component.update(socket, :toasts, fn toasts -> [toast | toasts] end)
end
defp approval_detail_rows(nil), do: []
defp approval_detail_rows(approval) do
[
{"Tool", approval[:tool_name]},
{"Reason", approval[:reason]},
{"Arguments", approval[:arguments_preview]},
{"Connector", approval[:connector_label] || connector_label(approval)},
{"Workflow", approval[:workflow_run_id]},
{"Status", approval[:status]}
]
end
defp connector_label(%{blocker_kind: "connector"}), do: "connector approval"
defp connector_label(_approval), do: nil
defp decision_title("approve"), do: "Approve workflow"
defp decision_title("reject"), do: "Reject approval"
defp decision_title(_decision), do: "Review approval"
defp decision_badge("approve"), do: "Resume when possible"
defp decision_badge("reject"), do: "Keep workflow paused"
defp decision_badge(_decision), do: "Decision pending"
defp decision_tone("approve"), do: :pass
defp decision_tone("reject"), do: :warn
defp decision_tone(_decision), do: :neutral
defp decision_copy("approve") do
"Approval resumes the workflow when possible after the durable approval event is written."
end
defp decision_copy("reject") do
"Reject records a durable rejection and keeps the workflow paused. To continue, the run needs a new approval request or operator retry."
end
defp decision_copy(_decision), do: "Review the approval before recording a durable decision."
end