defmodule OpenAgentsWeb.ChangelogLive do
@moduledoc """
The public changelog (#138, spec §3): two layers over one timeline — a
human note per change, and an expandable agent layer with the receipt
chain (sha, modules, measured push→live, deploy result, provenance).
Same public posture as the leaderboard and status pages (UI-001 lineage,
TRANSPARENCY-001): read-only, mounts without a session, cannot invoke
OpenAgents, renders only `OpenAgentsWeb.UI` primitives (UI-003), and updates live
off the forge PubSub — a hot-load appears here seconds after it lands.
"""
use OpenAgentsWeb, :live_view
alias OpenAgents.Changelog
@repo "openagents.com"
@impl true
def mount(_params, _session, socket) do
rows =
case Changelog.timeline(@repo, viewer: socket.assigns.current_user) do
{:ok, rows} -> rows
{:error, :not_public} -> raise OpenAgentsWeb.PublicNotFoundError
end
if connected?(socket), do: Changelog.subscribe()
{:ok,
socket
|> assign(:page_title, "Changelog · OpenAgents")
|> assign(:repo, @repo)
|> assign(:base, OpenAgents.Forge.Visibility.repo_path(@repo) || "")
|> assign(:rows, rows)
|> assign(:filter, nil)}
end
@impl true
def handle_event("filter", %{"category" => category}, socket) do
filter = if category == socket.assigns.filter, do: nil, else: category
{:noreply, assign(socket, :filter, filter)}
end
@impl true
def handle_info(message, socket) do
# The set of events that move the ledger is stated once, in `Changelog`,
# beside the projection that reads them, so this page and the homepage rail
# cannot come to disagree about what counts as a change.
if Changelog.ledger_event?(message) do
case Changelog.timeline(@repo, refresh: true, viewer: socket.assigns.current_user) do
{:ok, rows} -> {:noreply, assign(socket, :rows, rows)}
_ -> {:noreply, socket}
end
else
{:noreply, socket}
end
end
defp visible_rows(rows, nil), do: rows
defp visible_rows(rows, filter), do: Enum.filter(rows, &(&1.category == filter))
defp categories_present(rows) do
rows |> Enum.map(& &1.category) |> Enum.uniq() |> Enum.sort()
end
defp category_variant("ui"), do: :info
defp category_variant("fix"), do: :warning
defp category_variant("incident"), do: :danger
defp category_variant("forge"), do: :success
defp category_variant(_), do: :default
defp result_variant("live"), do: :success
defp result_variant("reverted"), do: :danger
defp result_variant("failed"), do: :danger
defp result_variant(_), do: :warning
defp stamp(nil), do: "—"
defp stamp(%DateTime{} = at), do: Calendar.strftime(at, "%Y-%m-%d %H:%M UTC")
defp ms_text(nil), do: "—"
defp ms_text(ms) when ms < 1_000, do: "#{ms} ms"
defp ms_text(ms), do: "#{Float.round(ms / 1_000, 1)} s"
@impl true
def render(assigns) do
~H"""
<Layouts.app
flash={@flash}
sidebar_sections={assigns[:sidebar_sections]}
current_scope={@current_scope}
title="Changelog"
>
<main id="changelog-page" class="app-shell changelog-shell">
<section class="changelog" aria-label="Changelog">
<header class="changelog-heading">
<div>
<h1>Changelog</h1>
<p>
Every change to OpenAgents, two layers deep: what changed in plain
words, and — expand any entry — the receipts: the commit, the
modules hot-loaded, and the measured push→live time. OpenAgents
ships her own code through her own forge; this page is how
you watch her do it.
</p>
</div>
</header>
<div class="changelog-filters" role="group" aria-label="Filter by category">
<.button
:for={category <- categories_present(@rows)}
id={"changelog-filter-#{category}"}
variant={:chip}
size={:xs}
phx-click="filter"
phx-value-category={category}
aria-pressed={to_string(@filter == category)}
>
{category}
</.button>
</div>
<.empty :if={@rows == []} id="changelog-empty" title="Nothing yet">
The first receipted change will appear here as it ships.
</.empty>
<ol class="changelog-rows" aria-label="Changes, newest first">
<li :for={{row, index} <- Enum.with_index(visible_rows(@rows, @filter))}>
<.card id={"changelog-row-#{index}"}>
<div class="changelog-row__head">
<.badge variant={category_variant(row.category)}>{row.category}</.badge>
<span class="changelog-row__stamp">{stamp(row.entry_at)}</span>
<.badge :if={row.deploy} variant={result_variant(row.deploy.result)}>
{row.deploy.result}
</.badge>
</div>
<p :if={row.summary} class="changelog-row__summary">{row.summary}</p>
<p
:if={is_nil(row.summary)}
class="changelog-row__summary changelog-row__summary--bare"
>
Receipted deploy of <code>{row.short_sha}</code> — no release note was written.
</p>
<details class="changelog-row__detail">
<summary>detail</summary>
<dl class="changelog-facts">
<div :if={row.short_sha}>
<dt>commit</dt>
<dd>
<.text_button navigate={"#{@base}/commit/#{row.short_sha}"}>
<code>{row.short_sha}</code>
</.text_button>
</dd>
</div>
<div :if={row.deploy}>
<dt>push→live</dt>
<dd>{ms_text(row.deploy.push_to_live_ms)}</dd>
</div>
<div :if={row.deploy}>
<dt>modules</dt>
<dd>{row.deploy.modules} on {row.deploy.nodes} nodes</dd>
</div>
<div :if={row.push}>
<dt>pushed by</dt>
<dd>{row.push.principal_role} (wal seq {row.push.wal_seq})</dd>
</div>
<div :if={row.build}>
<dt>build</dt>
<dd>{ms_text(row.build.duration_ms)}, {row.build.modules} modules</dd>
</div>
<div>
<dt>source</dt>
<dd>{row.source}</dd>
</div>
<div :if={row.trace_digest}>
<dt>trace digest</dt>
<dd><code>{row.trace_digest}</code></dd>
</div>
</dl>
</details>
</.card>
</li>
</ol>
<footer class="changelog-footer">
<p>
Machine-readable: <code>GET /api/changelog</code>
(schema <code>{Changelog.schema_version()}</code>) ·
<.text_button navigate={"#{@base}/blob/main/CHANGELOG.md"}>
this changelog as a document
</.text_button>
· <.text_button navigate="/status">fleet status</.text_button>.
Entries are projections; the receipt chain is the authority.
</p>
</footer>
</section>
</main>
</Layouts.app>
"""
end
end