diff --git a/elixir/README.md b/elixir/README.md index 6cb3ea98fe..ee1d669001 100644 --- a/elixir/README.md +++ b/elixir/README.md @@ -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 diff --git a/elixir/lib/symphony_elixir/orchestrator.ex b/elixir/lib/symphony_elixir/orchestrator.ex index 3cd814829b..1868b6904a 100644 --- a/elixir/lib/symphony_elixir/orchestrator.ex +++ b/elixir/lib/symphony_elixir/orchestrator.ex @@ -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 %{ @@ -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, @@ -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) @@ -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) @@ -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, @@ -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 diff --git a/elixir/lib/symphony_elixir_web/live/dashboard_live.ex b/elixir/lib/symphony_elixir_web/live/dashboard_live.ex index a30631c113..a4434c0cca 100644 --- a/elixir/lib/symphony_elixir_web/live/dashboard_live.ex +++ b/elixir/lib/symphony_elixir_web/live/dashboard_live.ex @@ -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 @@ -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""" @@ -149,11 +169,21 @@ defmodule SymphonyElixirWeb.DashboardLive do
-Agent details
++ <%= selected_entry.execution.current_stage %> · <%= selected_entry.execution.completed_count %> completed / <%= selected_entry.execution.pending_count %> pending +
+No timestamped Codex events captured yet.
+ <% else %> +Click a running session to inspect live execution details.
+ <% end %> <% end %> @@ -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 diff --git a/elixir/lib/symphony_elixir_web/presenter.ex b/elixir/lib/symphony_elixir_web/presenter.ex index 1063cf7a64..3ac2fafe4d 100644 --- a/elixir/lib/symphony_elixir_web/presenter.ex +++ b/elixir/lib/symphony_elixir_web/presenter.ex @@ -96,6 +96,8 @@ defmodule SymphonyElixirWeb.Presenter do defp issue_status(_running, _retry), do: "running" defp running_entry_payload(entry) do + recent_events = recent_events_payload(entry) + %{ issue_id: entry.issue_id, issue_identifier: entry.identifier, @@ -112,7 +114,9 @@ defmodule SymphonyElixirWeb.Presenter do input_tokens: entry.codex_input_tokens, output_tokens: entry.codex_output_tokens, total_tokens: entry.codex_total_tokens - } + }, + execution: execution_payload(entry, recent_events), + recent_events: recent_events } end @@ -129,6 +133,8 @@ defmodule SymphonyElixirWeb.Presenter do end defp running_issue_payload(running) do + recent_events = recent_events_payload(running) + %{ worker_host: Map.get(running, :worker_host), workspace_path: Map.get(running, :workspace_path), @@ -143,7 +149,8 @@ defmodule SymphonyElixirWeb.Presenter do input_tokens: running.codex_input_tokens, output_tokens: running.codex_output_tokens, total_tokens: running.codex_total_tokens - } + }, + execution: execution_payload(running, recent_events) } end @@ -168,16 +175,177 @@ defmodule SymphonyElixirWeb.Presenter do end defp recent_events_payload(running) do + running + |> codex_updates() + |> Enum.map(&codex_update_payload/1) + |> Enum.reject(&is_nil(&1.at)) + end + + defp codex_updates(running) do + case Map.get(running, :codex_updates) do + updates when is_list(updates) and updates != [] -> + updates + + _ -> + last_codex_update(running) + end + end + + defp last_codex_update(running) do + case Map.get(running, :last_codex_timestamp) do + nil -> + [] + + timestamp -> + [ + %{ + event: Map.get(running, :last_codex_event), + message: Map.get(running, :last_codex_message), + timestamp: timestamp + } + ] + end + end + + defp codex_update_payload(update) do + normalized_update = normalize_codex_update(update) + + %{ + at: iso8601(normalized_update.timestamp), + event: normalized_update.event, + message: summarize_message(normalized_update) + } + end + + defp normalize_codex_update(update) when is_map(update) do + %{ + event: Map.get(update, :event) || Map.get(update, "event"), + message: Map.get(update, :message) || Map.get(update, "message"), + timestamp: Map.get(update, :timestamp) || Map.get(update, "timestamp") + } + end + + defp normalize_codex_update(update) do + %{event: nil, message: update, timestamp: nil} + end + + defp execution_payload(entry, recent_events) do + event_text = execution_event_text(entry, recent_events) + session_id = Map.get(entry, :session_id) + workspace_path = Map.get(entry, :workspace_path) + has_session? = present?(session_id) + has_workspace? = present?(workspace_path) + workspace_ready? = has_workspace? or has_session? + has_blocker? = contains_any?(event_text, ["approval requested", "blocked", "waiting for user input"]) + has_turn_completed? = contains_any?(event_text, ["turn completed"]) + has_work_activity? = contains_any?(event_text, ["command", "tool", "file change", "diff", "patch"]) + has_plan_activity? = contains_any?(event_text, ["plan", "reasoning", "inspect"]) + + steps = [ + execution_step("Dispatched to worker", "done", "Agent task is running."), + execution_step( + "Workspace prepared", + if(workspace_ready?, do: "done", else: "pending"), + workspace_step_detail(workspace_path, has_workspace?, has_session?) + ), + execution_step( + "Codex session started", + if(has_session?, do: "done", else: "active"), + session_id || "Waiting for Codex to start." + ), + execution_step( + "Plan and inspect", + plan_step_status(has_session?, has_plan_activity?, has_work_activity?, has_turn_completed?), + plan_step_detail(has_work_activity?, has_turn_completed?) + ), + execution_step( + "Run commands or edit files", + work_step_status(has_work_activity?, has_turn_completed?), + work_step_detail(has_work_activity?, has_turn_completed?) + ), + execution_step( + "Finish turn", + if(has_turn_completed?, do: "done", else: "pending"), + if(has_turn_completed?, do: "Codex reported a completed turn.", else: "No completed turn reported yet.") + ) + ] + + current_stage = + current_stage( + event_text, + has_session?, + has_blocker?, + has_turn_completed?, + has_work_activity?, + has_plan_activity? + ) + + %{ + current_stage: current_stage, + completed_count: Enum.count(steps, &(&1.status == "done")), + pending_count: Enum.count(steps, &(&1.status == "pending")), + steps: steps + } + end + + defp execution_event_text(entry, recent_events) do [ - %{ - at: iso8601(running.last_codex_timestamp), - event: running.last_codex_event, - message: summarize_message(running.last_codex_message) - } + Map.get(entry, :state), + Map.get(entry, :last_codex_event), + summarize_message(Map.get(entry, :last_codex_message)) + | Enum.flat_map(recent_events, fn event -> [event.event, event.message] end) ] - |> Enum.reject(&is_nil(&1.at)) + |> Enum.reject(&is_nil/1) + |> Enum.map_join(" ", &to_string/1) + |> String.downcase() end + defp execution_step(label, status, detail), do: %{label: label, status: status, detail: detail} + + defp workspace_step_detail(workspace_path, true, _has_session?), do: workspace_path + defp workspace_step_detail(_workspace_path, _has_workspace?, true), do: "Workspace path was not reported." + defp workspace_step_detail(_workspace_path, _has_workspace?, _has_session?), do: "Waiting for worker runtime info." + + defp plan_step_status(false, _has_plan_activity?, _has_work_activity?, _has_turn_completed?), do: "pending" + defp plan_step_status(_has_session?, _has_plan_activity?, true, _has_turn_completed?), do: "done" + defp plan_step_status(_has_session?, _has_plan_activity?, _has_work_activity?, true), do: "done" + defp plan_step_status(_has_session?, true, _has_work_activity?, _has_turn_completed?), do: "done" + defp plan_step_status(_has_session?, _has_plan_activity?, _has_work_activity?, _has_turn_completed?), do: "active" + + defp plan_step_detail(true, _has_turn_completed?), do: "Planning activity already led to command, tool, or diff activity." + defp plan_step_detail(_has_work_activity?, true), do: "Planning activity reached a completed turn." + defp plan_step_detail(_has_work_activity?, _has_turn_completed?), do: "Agent is inspecting or planning next steps." + + defp work_step_status(_has_work_activity?, true), do: "done" + defp work_step_status(true, _has_turn_completed?), do: "active" + defp work_step_status(_has_work_activity?, _has_turn_completed?), do: "pending" + + defp work_step_detail(_has_work_activity?, true), do: "Command or edit activity reached the end of the turn." + defp work_step_detail(true, _has_turn_completed?), do: "Latest activity includes commands, tools, file changes, or diffs." + defp work_step_detail(_has_work_activity?, _has_turn_completed?), do: "Waiting for command, tool, or diff activity." + + defp current_stage(_event_text, _has_session?, true, _has_turn_completed?, _has_work_activity?, _has_plan_activity?), + do: "Waiting for input" + + defp current_stage(_event_text, _has_session?, _has_blocker?, true, _has_work_activity?, _has_plan_activity?), + do: "Turn completed" + + defp current_stage(_event_text, _has_session?, _has_blocker?, _has_turn_completed?, true, _has_plan_activity?), + do: "Running commands or edits" + + defp current_stage(_event_text, _has_session?, _has_blocker?, _has_turn_completed?, _has_work_activity?, true), + do: "Planning / inspecting" + + defp current_stage(_event_text, true, _has_blocker?, _has_turn_completed?, _has_work_activity?, _has_plan_activity?), + do: "Agent working" + + defp current_stage(_event_text, _has_session?, _has_blocker?, _has_turn_completed?, _has_work_activity?, _has_plan_activity?), + do: "Dispatching" + + defp contains_any?(text, needles), do: Enum.any?(needles, &String.contains?(text, &1)) + defp present?(value) when is_binary(value), do: String.trim(value) != "" + defp present?(_value), do: false + defp summarize_message(nil), do: nil defp summarize_message(message), do: StatusDashboard.humanize_codex_message(message) diff --git a/elixir/priv/static/dashboard.css b/elixir/priv/static/dashboard.css index bc191c0ca1..282c83b054 100644 --- a/elixir/priv/static/dashboard.css +++ b/elixir/priv/static/dashboard.css @@ -326,6 +326,20 @@ pre, font-size: 0.94rem; } +.selectable-row { + cursor: pointer; + transition: background 140ms ease; +} + +.selectable-row:hover td { + background: rgba(16, 163, 127, 0.045); +} + +.selectable-row-selected td { + background: var(--accent-soft); + border-top-color: rgba(16, 163, 127, 0.2); +} + .issue-stack, .session-stack, .detail-stack, @@ -412,6 +426,178 @@ pre, color: var(--muted); } +.empty-state-compact { + margin-top: 0.55rem; + font-size: 0.9rem; +} + +.detail-hint { + margin: 0.85rem 0 0; + color: var(--muted); + font-size: 0.9rem; +} + +.agent-detail-panel { + margin-top: 1rem; + padding: 1rem; + border: 1px solid var(--line); + border-radius: 18px; + background: rgba(251, 251, 252, 0.92); +} + +.agent-detail-header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; +} + +.detail-kicker, +.detail-label { + margin: 0; + color: var(--muted); + font-size: 0.76rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0; +} + +.agent-detail-title { + margin: 0.18rem 0 0; + font-size: 1.2rem; + line-height: 1.2; +} + +.agent-stage-band { + display: grid; + gap: 0.24rem; + margin-top: 0.85rem; + padding: 0.85rem; + border: 1px solid rgba(16, 163, 127, 0.16); + border-radius: 14px; + background: var(--accent-soft); +} + +.agent-detail-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.85rem; + margin-top: 0.95rem; +} + +.agent-detail-grid > div { + display: grid; + gap: 0.22rem; + min-width: 0; +} + +.detail-grid-wide { + grid-column: 1 / -1; +} + +.detail-grid-wide .mono, +.agent-detail-grid .mono { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.agent-detail-columns { + display: grid; + grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr); + gap: 1rem; + margin-top: 1rem; +} + +.detail-subtitle { + margin: 0; + font-size: 0.96rem; + line-height: 1.2; +} + +.execution-steps { + display: grid; + gap: 0.55rem; + margin: 0.7rem 0 0; + padding: 0; + list-style: none; +} + +.execution-step { + display: grid; + grid-template-columns: 3.3rem minmax(0, 1fr); + gap: 0.65rem; + align-items: start; + padding: 0.65rem; + border: 1px solid var(--line); + border-radius: 13px; + background: white; +} + +.execution-step-active { + border-color: rgba(16, 163, 127, 0.28); + background: var(--accent-soft); +} + +.execution-step-done .step-state { + background: var(--ink); + color: white; +} + +.execution-step-active .step-state { + background: var(--accent); + color: white; +} + +.step-state { + display: inline-flex; + justify-content: center; + align-items: center; + min-height: 1.55rem; + border-radius: 999px; + background: var(--card-muted); + color: var(--muted); + font-size: 0.72rem; + font-weight: 700; +} + +.step-copy { + display: grid; + gap: 0.16rem; + min-width: 0; +} + +.event-list { + display: grid; + gap: 0.45rem; + margin-top: 0.7rem; +} + +.event-row { + display: grid; + grid-template-columns: 11.5rem minmax(0, 1fr); + gap: 0.65rem; + align-items: start; + padding: 0.55rem 0; + border-top: 1px solid var(--line); +} + +.event-row:first-child { + border-top: 0; + padding-top: 0; +} + +.event-time { + color: var(--muted); + font-size: 0.78rem; +} + +.event-message { + min-width: 0; + overflow-wrap: anywhere; + font-size: 0.88rem; +} + .error-card { border-radius: 24px; padding: 1.25rem; @@ -447,6 +633,15 @@ pre, .metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + + .agent-detail-grid, + .agent-detail-columns { + grid-template-columns: 1fr; + } + + .event-row { + grid-template-columns: 1fr; + } } @media (max-width: 560px) { diff --git a/elixir/test/symphony_elixir/extensions_test.exs b/elixir/test/symphony_elixir/extensions_test.exs index d6309c9662..fc9fe43bcf 100644 --- a/elixir/test/symphony_elixir/extensions_test.exs +++ b/elixir/test/symphony_elixir/extensions_test.exs @@ -339,6 +339,7 @@ defmodule SymphonyElixir.ExtensionsTest do conn = get(build_conn(), "/api/v1/state") state_payload = json_response(conn, 200) + expected_execution = expected_agent_working_execution() assert state_payload == %{ "generated_at" => state_payload["generated_at"], @@ -356,7 +357,9 @@ defmodule SymphonyElixir.ExtensionsTest do "last_message" => "rendered", "started_at" => state_payload["running"] |> List.first() |> Map.fetch!("started_at"), "last_event_at" => nil, - "tokens" => %{"input_tokens" => 4, "output_tokens" => 8, "total_tokens" => 12} + "tokens" => %{"input_tokens" => 4, "output_tokens" => 8, "total_tokens" => 12}, + "execution" => expected_execution, + "recent_events" => [] } ], "retrying" => [ @@ -401,7 +404,8 @@ defmodule SymphonyElixir.ExtensionsTest do "last_event" => "notification", "last_message" => "rendered", "last_event_at" => nil, - "tokens" => %{"input_tokens" => 4, "output_tokens" => 8, "total_tokens" => 12} + "tokens" => %{"input_tokens" => 4, "output_tokens" => 8, "total_tokens" => 12}, + "execution" => expected_execution }, "retry" => nil, "logs" => %{"codex_session_logs" => []}, @@ -548,6 +552,8 @@ defmodule SymphonyElixir.ExtensionsTest do assert html =~ "Offline" assert html =~ "Copy ID" assert html =~ "Codex update" + assert html =~ "Click a running session to inspect live execution details." + refute html =~ "Agent details" refute html =~ "data-runtime-clock=" refute html =~ "setInterval(refreshRuntimeClocks" refute html =~ "Refresh now" @@ -555,6 +561,17 @@ defmodule SymphonyElixir.ExtensionsTest do assert html =~ "status-badge-live" assert html =~ "status-badge-offline" + selected_html = + view + |> element("#running-session-issue-http") + |> render_click() + + assert selected_html =~ "Agent details" + assert selected_html =~ "Execution checklist" + assert selected_html =~ "Current stage" + assert selected_html =~ "Agent working" + assert selected_html =~ "Dispatched to worker" + updated_snapshot = put_in(snapshot.running, [ %{ @@ -716,6 +733,46 @@ defmodule SymphonyElixir.ExtensionsTest do } end + defp expected_agent_working_execution do + %{ + "current_stage" => "Agent working", + "completed_count" => 3, + "pending_count" => 2, + "steps" => [ + %{ + "label" => "Dispatched to worker", + "status" => "done", + "detail" => "Agent task is running." + }, + %{ + "label" => "Workspace prepared", + "status" => "done", + "detail" => "Workspace path was not reported." + }, + %{ + "label" => "Codex session started", + "status" => "done", + "detail" => "thread-http" + }, + %{ + "label" => "Plan and inspect", + "status" => "active", + "detail" => "Agent is inspecting or planning next steps." + }, + %{ + "label" => "Run commands or edit files", + "status" => "pending", + "detail" => "Waiting for command, tool, or diff activity." + }, + %{ + "label" => "Finish turn", + "status" => "pending", + "detail" => "No completed turn reported yet." + } + ] + } + end + defp wait_for_bound_port do assert_eventually(fn -> is_integer(HttpServer.bound_port()) diff --git a/elixir/test/symphony_elixir/orchestrator_status_test.exs b/elixir/test/symphony_elixir/orchestrator_status_test.exs index 4326b80ce3..7ecf1e638b 100644 --- a/elixir/test/symphony_elixir/orchestrator_status_test.exs +++ b/elixir/test/symphony_elixir/orchestrator_status_test.exs @@ -99,6 +99,11 @@ defmodule SymphonyElixir.OrchestratorStatusTest do message: %{method: "some-event"}, timestamp: now } + + assert snapshot_entry.codex_updates == [ + %{event: :session_started, message: nil, timestamp: now}, + %{event: :notification, message: %{method: "some-event"}, timestamp: now} + ] end test "orchestrator snapshot tracks codex thread totals and app-server pid" do