Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions elixir/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ codex:
The observability UI now runs on a minimal Phoenix stack:

- LiveView for the dashboard at `/`
- Clickable running-session rows with an agent detail panel for current stage,
completed/pending execution steps, workspace metadata, and recent Codex events
- JSON API for operational debugging under `/api/v1/*`
- Bandit as the HTTP server
- Phoenix dependency static assets for the LiveView client bootstrap
Expand Down
18 changes: 17 additions & 1 deletion elixir/lib/symphony_elixir/orchestrator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ defmodule SymphonyElixir.Orchestrator do

@continuation_retry_delay_ms 1_000
@failure_retry_base_ms 10_000
@max_codex_update_history 20
# Slightly above the dashboard render interval so "checking now…" can render.
@poll_transition_render_delay_ms 20
@empty_codex_totals %{
Expand Down Expand Up @@ -711,6 +712,7 @@ defmodule SymphonyElixir.Orchestrator do
last_codex_message: nil,
last_codex_timestamp: nil,
last_codex_event: nil,
codex_updates: [],
codex_app_server_pid: nil,
codex_input_tokens: 0,
codex_output_tokens: 0,
Expand Down Expand Up @@ -1122,6 +1124,7 @@ defmodule SymphonyElixir.Orchestrator do
last_codex_timestamp: metadata.last_codex_timestamp,
last_codex_message: metadata.last_codex_message,
last_codex_event: metadata.last_codex_event,
codex_updates: Map.get(metadata, :codex_updates, []),
runtime_seconds: running_seconds(metadata.started_at, now)
}
end)
Expand Down Expand Up @@ -1170,6 +1173,7 @@ defmodule SymphonyElixir.Orchestrator do
end

defp integrate_codex_update(running_entry, %{event: event, timestamp: timestamp} = update) do
summarized_update = summarize_codex_update(update)
token_delta = extract_token_delta(running_entry, update)
codex_input_tokens = Map.get(running_entry, :codex_input_tokens, 0)
codex_output_tokens = Map.get(running_entry, :codex_output_tokens, 0)
Expand All @@ -1183,9 +1187,10 @@ defmodule SymphonyElixir.Orchestrator do
{
Map.merge(running_entry, %{
last_codex_timestamp: timestamp,
last_codex_message: summarize_codex_update(update),
last_codex_message: summarized_update,
session_id: session_id_for_update(running_entry.session_id, update),
last_codex_event: event,
codex_updates: append_codex_update(running_entry, summarized_update),
codex_app_server_pid: codex_app_server_pid_for_update(codex_app_server_pid, update),
codex_input_tokens: codex_input_tokens + token_delta.input_tokens,
codex_output_tokens: codex_output_tokens + token_delta.output_tokens,
Expand All @@ -1199,6 +1204,17 @@ defmodule SymphonyElixir.Orchestrator do
}
end

defp append_codex_update(running_entry, summarized_update) do
history =
case Map.get(running_entry, :codex_updates) do
updates when is_list(updates) -> updates
_ -> []
end

(history ++ [summarized_update])
|> Enum.take(-@max_codex_update_history)
end

defp codex_app_server_pid_for_update(_existing, %{codex_app_server_pid: pid})
when is_binary(pid),
do: pid
Expand Down
165 changes: 160 additions & 5 deletions elixir/lib/symphony_elixir_web/live/dashboard_live.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@ defmodule SymphonyElixirWeb.DashboardLive do

@impl true
def mount(_params, _session, socket) do
payload = load_payload()

socket =
socket
|> assign(:payload, load_payload())
|> assign(:payload, payload)
|> assign(:selected_issue_id, nil)
|> assign(:now, DateTime.utc_now())

if connected?(socket) do
Expand All @@ -31,12 +34,29 @@ defmodule SymphonyElixirWeb.DashboardLive do

@impl true
def handle_info(:observability_updated, socket) do
payload = load_payload()

{:noreply,
socket
|> assign(:payload, load_payload())
|> assign(:payload, payload)
|> assign(:selected_issue_id, retained_selected_issue_id(payload, socket.assigns[:selected_issue_id]))
|> assign(:now, DateTime.utc_now())}
end

@impl true
def handle_event("select_session", %{"issue-id" => issue_id}, socket) do
selected_issue_id =
if running_issue_id?(socket.assigns.payload, issue_id) do
issue_id
end

{:noreply, assign(socket, :selected_issue_id, selected_issue_id)}
end

def handle_event("clear_selected_session", _params, socket) do
{:noreply, assign(socket, :selected_issue_id, nil)}
end

@impl true
def render(assigns) do
~H"""
Expand Down Expand Up @@ -149,11 +169,21 @@ defmodule SymphonyElixirWeb.DashboardLive do
</tr>
</thead>
<tbody>
<tr :for={entry <- @payload.running}>
<tr
:for={entry <- @payload.running}
id={"running-session-#{running_entry_key(entry)}"}
class={running_row_class(entry, @selected_issue_id)}
phx-click="select_session"
phx-value-issue-id={running_entry_key(entry)}
>
<td>
<div class="issue-stack">
<span class="issue-id"><%= entry.issue_identifier %></span>
<a class="issue-link" href={"/api/v1/#{entry.issue_identifier}"}>JSON details</a>
<a
class="issue-link"
href={"/api/v1/#{entry.issue_identifier}"}
onclick="event.stopPropagation();"
>JSON details</a>
</div>
</td>
<td>
Expand All @@ -169,7 +199,7 @@ defmodule SymphonyElixirWeb.DashboardLive do
class="subtle-button"
data-label="Copy ID"
data-copy={entry.session_id}
onclick="navigator.clipboard.writeText(this.dataset.copy); this.textContent = 'Copied'; clearTimeout(this._copyTimer); this._copyTimer = setTimeout(() => { this.textContent = this.dataset.label }, 1200);"
onclick="event.stopPropagation(); navigator.clipboard.writeText(this.dataset.copy); this.textContent = 'Copied'; clearTimeout(this._copyTimer); this._copyTimer = setTimeout(() => { this.textContent = this.dataset.label }, 1200);"
>
Copy ID
</button>
Expand Down Expand Up @@ -203,6 +233,86 @@ defmodule SymphonyElixirWeb.DashboardLive do
</tbody>
</table>
</div>

<% selected_entry = selected_running_entry(@payload, @selected_issue_id) %>
<%= if selected_entry do %>
<section class="agent-detail-panel" id={"agent-detail-#{running_entry_key(selected_entry)}"}>
<div class="agent-detail-header">
<div>
<p class="detail-kicker">Agent details</p>
<h3 class="agent-detail-title"><%= selected_entry.issue_identifier %></h3>
<p class="section-copy">
<%= selected_entry.execution.current_stage %> · <%= selected_entry.execution.completed_count %> completed / <%= selected_entry.execution.pending_count %> pending
</p>
</div>
<button type="button" class="subtle-button" phx-click="clear_selected_session">Close</button>
</div>

<div class="agent-stage-band">
<span class="detail-label">Current stage</span>
<strong><%= selected_entry.execution.current_stage %></strong>
<span class="muted">
Last update: <%= selected_entry.last_message || to_string(selected_entry.last_event || "n/a") %>
</span>
</div>

<div class="agent-detail-grid">
<div>
<span class="detail-label">State</span>
<strong><%= selected_entry.state %></strong>
</div>
<div>
<span class="detail-label">Runtime / turns</span>
<strong class="numeric"><%= format_runtime_and_turns(selected_entry.started_at, selected_entry.turn_count, @now) %></strong>
</div>
<div>
<span class="detail-label">Session</span>
<span class="mono"><%= selected_entry.session_id || "n/a" %></span>
</div>
<div>
<span class="detail-label">Worker</span>
<span><%= selected_entry.worker_host || "local" %></span>
</div>
<div class="detail-grid-wide">
<span class="detail-label">Workspace</span>
<span class="mono"><%= selected_entry.workspace_path || "n/a" %></span>
</div>
</div>

<div class="agent-detail-columns">
<div>
<h4 class="detail-subtitle">Execution checklist</h4>
<ol class="execution-steps">
<li :for={step <- selected_entry.execution.steps} class={execution_step_class(step.status)}>
<span class="step-state"><%= step_status_label(step.status) %></span>
<div class="step-copy">
<strong><%= step.label %></strong>
<span class="muted"><%= step.detail %></span>
</div>
</li>
</ol>
</div>

<div>
<h4 class="detail-subtitle">Recent Codex events</h4>
<%= if selected_entry.recent_events == [] do %>
<p class="empty-state empty-state-compact">No timestamped Codex events captured yet.</p>
<% else %>
<div class="event-list">
<div :for={event <- recent_events_for_display(selected_entry.recent_events)} class="event-row">
<span class="mono event-time"><%= event.at %></span>
<span class="event-message">
<%= event.message || to_string(event.event || "n/a") %>
</span>
</div>
</div>
<% end %>
</div>
</div>
</section>
<% else %>
<p class="detail-hint">Click a running session to inspect live execution details.</p>
<% end %>
<% end %>
</section>

Expand Down Expand Up @@ -249,6 +359,51 @@ defmodule SymphonyElixirWeb.DashboardLive do
"""
end

defp selected_running_entry(%{running: running}, selected_issue_id) when is_list(running) and is_binary(selected_issue_id) do
Enum.find(running, &(running_entry_key(&1) == selected_issue_id))
end

defp selected_running_entry(_payload, _selected_issue_id), do: nil

defp retained_selected_issue_id(payload, selected_issue_id) do
if running_issue_id?(payload, selected_issue_id) do
selected_issue_id
end
end

defp running_issue_id?(%{running: running}, issue_id) when is_list(running) and is_binary(issue_id) do
Enum.any?(running, &(running_entry_key(&1) == issue_id))
end

defp running_issue_id?(_payload, _issue_id), do: false

defp running_entry_key(entry) do
Map.get(entry, :issue_id) || Map.get(entry, :issue_identifier) || "unknown"
end

defp running_row_class(entry, selected_issue_id) do
base = "selectable-row"

if running_entry_key(entry) == selected_issue_id do
"#{base} selectable-row-selected"
else
base
end
end

defp recent_events_for_display(events) when is_list(events) do
events
|> Enum.reverse()
|> Enum.take(8)
end

defp recent_events_for_display(_events), do: []

defp execution_step_class(status), do: "execution-step execution-step-#{status}"
defp step_status_label("done"), do: "Done"
defp step_status_label("active"), do: "Now"
defp step_status_label(_status), do: "Next"

defp load_payload do
Presenter.state_payload(orchestrator(), snapshot_timeout_ms())
end
Expand Down
Loading
Loading