Skip to content
Merged
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
3 changes: 3 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -1294,6 +1294,7 @@ SHOULD return:
- `running` (list of running session rows)
- each running row SHOULD include `turn_count`
- `retrying` (list of retry queue rows)
- session and retry rows SHOULD include the tracker-provided issue URL when available
- `codex_totals`
- `input_tokens`
- `output_tokens`
Expand Down Expand Up @@ -1413,6 +1414,7 @@ Minimum endpoints:
{
"issue_id": "abc123",
"issue_identifier": "MT-649",
"issue_url": "https://tracker.example/issues/MT-649",
"state": "In Progress",
"session_id": "thread-1-turn-1",
"turn_count": 7,
Expand All @@ -1431,6 +1433,7 @@ Minimum endpoints:
{
"issue_id": "def456",
"issue_identifier": "MT-650",
"issue_url": "https://tracker.example/issues/MT-650",
"attempt": 3,
"due_at": "2026-02-24T20:16:00Z",
"error": "no available orchestrator slots"
Expand Down
1 change: 1 addition & 0 deletions elixir/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ The observability UI now runs on a minimal Phoenix stack:
- JSON API for operational debugging under `/api/v1/*`
- Bandit as the HTTP server
- Phoenix dependency static assets for the LiveView client bootstrap
- Tracker issue identifiers link to the tracker-provided URL when it uses `http` or `https`

## Project Layout

Expand Down
17 changes: 17 additions & 0 deletions elixir/lib/symphony_elixir/orchestrator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ defmodule SymphonyElixir.Orchestrator do
|> complete_issue(issue_id)
|> schedule_issue_retry(issue_id, 1, %{
identifier: running_entry.identifier,
issue_url: running_entry.issue.url,
delay_type: :continuation,
worker_host: Map.get(running_entry, :worker_host),
workspace_path: Map.get(running_entry, :workspace_path)
Expand Down Expand Up @@ -237,6 +238,7 @@ defmodule SymphonyElixir.Orchestrator do

schedule_issue_retry(state, issue_id, next_attempt, %{
identifier: running_entry.identifier,
issue_url: running_entry.issue.url,
error: "agent exited: #{inspect(reason)}",
worker_host: Map.get(running_entry, :worker_host),
workspace_path: Map.get(running_entry, :workspace_path)
Expand Down Expand Up @@ -620,6 +622,7 @@ defmodule SymphonyElixir.Orchestrator do
|> terminate_running_issue(issue_id, false)
|> schedule_issue_retry(issue_id, next_attempt, %{
identifier: identifier,
issue_url: running_entry.issue.url,
error: "stalled for #{elapsed_ms}ms without codex activity"
})
end
Expand Down Expand Up @@ -982,6 +985,7 @@ defmodule SymphonyElixir.Orchestrator do

schedule_issue_retry(state, issue.id, next_attempt, %{
identifier: issue.identifier,
issue_url: issue.url,
error: "failed to spawn agent: #{inspect(reason)}",
worker_host: worker_host
})
Expand Down Expand Up @@ -1025,6 +1029,7 @@ defmodule SymphonyElixir.Orchestrator do
retry_token = make_ref()
due_at_ms = System.monotonic_time(:millisecond) + delay_ms
identifier = pick_retry_identifier(issue_id, previous_retry, metadata)
issue_url = pick_retry_issue_url(previous_retry, metadata)
error = pick_retry_error(previous_retry, metadata)
worker_host = pick_retry_worker_host(previous_retry, metadata)
workspace_path = pick_retry_workspace_path(previous_retry, metadata)
Expand All @@ -1048,6 +1053,7 @@ defmodule SymphonyElixir.Orchestrator do
retry_token: retry_token,
due_at_ms: due_at_ms,
identifier: identifier,
issue_url: issue_url,
error: error,
worker_host: worker_host,
workspace_path: workspace_path
Expand All @@ -1060,6 +1066,7 @@ defmodule SymphonyElixir.Orchestrator do
%{attempt: attempt, retry_token: ^retry_token} = retry_entry ->
metadata = %{
identifier: Map.get(retry_entry, :identifier),
issue_url: Map.get(retry_entry, :issue_url),
error: Map.get(retry_entry, :error),
worker_host: Map.get(retry_entry, :worker_host),
workspace_path: Map.get(retry_entry, :workspace_path)
Expand Down Expand Up @@ -1209,6 +1216,10 @@ defmodule SymphonyElixir.Orchestrator do
metadata[:identifier] || Map.get(previous_retry, :identifier) || issue_id
end

defp pick_retry_issue_url(previous_retry, metadata) do
metadata[:issue_url] || Map.get(previous_retry, :issue_url)
end

defp pick_retry_error(previous_retry, metadata) do
metadata[:error] || Map.get(previous_retry, :error)
end
Expand Down Expand Up @@ -1366,6 +1377,7 @@ defmodule SymphonyElixir.Orchestrator do
%{
issue_id: issue_id,
identifier: metadata.identifier,
issue_url: metadata.issue.url,
state: metadata.issue.state,
worker_host: Map.get(metadata, :worker_host),
workspace_path: Map.get(metadata, :workspace_path),
Expand All @@ -1391,6 +1403,7 @@ defmodule SymphonyElixir.Orchestrator do
attempt: attempt,
due_in_ms: max(0, due_at_ms - now_ms),
identifier: Map.get(retry, :identifier),
issue_url: Map.get(retry, :issue_url),
error: Map.get(retry, :error),
worker_host: Map.get(retry, :worker_host),
workspace_path: Map.get(retry, :workspace_path)
Expand All @@ -1403,6 +1416,7 @@ defmodule SymphonyElixir.Orchestrator do
%{
issue_id: issue_id,
identifier: Map.get(metadata, :identifier),
issue_url: blocked_issue_url(metadata),
state: blocked_issue_state(metadata),
worker_host: Map.get(metadata, :worker_host),
workspace_path: Map.get(metadata, :workspace_path),
Expand Down Expand Up @@ -1448,6 +1462,9 @@ defmodule SymphonyElixir.Orchestrator do
defp blocked_issue_state(%{issue: %Issue{state: state}}), do: state
defp blocked_issue_state(_metadata), do: nil

defp blocked_issue_url(%{issue: %Issue{url: url}}), do: url
defp blocked_issue_url(_metadata), do: nil

defp integrate_codex_update(running_entry, %{event: event, timestamp: timestamp} = update) do
token_delta = extract_token_delta(running_entry, update)
codex_input_tokens = Map.get(running_entry, :codex_input_tokens, 0)
Expand Down
7 changes: 5 additions & 2 deletions elixir/lib/symphony_elixir_web/components/layouts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ defmodule SymphonyElixirWeb.Layouts do

@spec root(map()) :: Phoenix.LiveView.Rendered.t()
def root(assigns) do
assigns = assign(assigns, :csrf_token, Plug.CSRFProtection.get_csrf_token())
assigns =
assigns
|> assign(:csrf_token, Plug.CSRFProtection.get_csrf_token())
|> assign(:dashboard_css_url, SymphonyElixirWeb.StaticAssets.dashboard_css_url())

~H"""
<!DOCTYPE html>
Expand Down Expand Up @@ -36,7 +39,7 @@ defmodule SymphonyElixirWeb.Layouts do
window.liveSocket = liveSocket;
});
</script>
<link rel="stylesheet" href="/dashboard.css" />
<link rel="stylesheet" href={@dashboard_css_url} />
</head>
<body>
{@inner_content}
Expand Down
42 changes: 39 additions & 3 deletions elixir/lib/symphony_elixir_web/live/dashboard_live.ex
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ defmodule SymphonyElixirWeb.DashboardLive do
<tr :for={entry <- @payload.running}>
<td>
<div class="issue-stack">
<span class="issue-id"><%= entry.issue_identifier %></span>
<.issue_identifier identifier={entry.issue_identifier} url={entry.issue_url} />
<a class="issue-link" href={"/api/v1/#{entry.issue_identifier}"}>JSON details</a>
</div>
</td>
Expand Down Expand Up @@ -239,7 +239,7 @@ defmodule SymphonyElixirWeb.DashboardLive do
<tr :for={entry <- @payload.blocked}>
<td>
<div class="issue-stack">
<span class="issue-id"><%= entry.issue_identifier %></span>
<.issue_identifier identifier={entry.issue_identifier} url={entry.issue_url} />
<a class="issue-link" href={"/api/v1/#{entry.issue_identifier}"}>JSON details</a>
</div>
</td>
Expand Down Expand Up @@ -311,7 +311,7 @@ defmodule SymphonyElixirWeb.DashboardLive do
<tr :for={entry <- @payload.retrying}>
<td>
<div class="issue-stack">
<span class="issue-id"><%= entry.issue_identifier %></span>
<.issue_identifier identifier={entry.issue_identifier} url={entry.issue_url} />
<a class="issue-link" href={"/api/v1/#{entry.issue_identifier}"}>JSON details</a>
</div>
</td>
Expand Down Expand Up @@ -341,6 +341,42 @@ defmodule SymphonyElixirWeb.DashboardLive do
Endpoint.config(:snapshot_timeout_ms) || 15_000
end

attr(:identifier, :string, required: true)
attr(:url, :string, default: nil)

defp issue_identifier(assigns) do
assigns = assign(assigns, :href, external_issue_url(assigns.url))

~H"""
<%= if @href do %>
<a
class="issue-id issue-id-link"
href={@href}
target="_blank"
rel="noopener noreferrer"
aria-label={"Open #{@identifier} in the issue tracker"}
><%= @identifier %></a>
<% else %>
<span class="issue-id"><%= @identifier %></span>
<% end %>
"""
end

defp external_issue_url(url) when is_binary(url) do
url = String.trim(url)

case URI.parse(url) do
%URI{scheme: scheme, host: host}
when scheme in ["http", "https"] and is_binary(host) and host != "" ->
url

_ ->
nil
end
end

defp external_issue_url(_url), do: nil

defp completed_runtime_seconds(payload) do
payload.codex_totals.seconds_running || 0
end
Expand Down
3 changes: 3 additions & 0 deletions elixir/lib/symphony_elixir_web/presenter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ defmodule SymphonyElixirWeb.Presenter do
%{
issue_id: entry.issue_id,
issue_identifier: entry.identifier,
issue_url: Map.get(entry, :issue_url),
state: entry.state,
worker_host: Map.get(entry, :worker_host),
workspace_path: Map.get(entry, :workspace_path),
Expand All @@ -124,6 +125,7 @@ defmodule SymphonyElixirWeb.Presenter do
%{
issue_id: entry.issue_id,
issue_identifier: entry.identifier,
issue_url: Map.get(entry, :issue_url),
attempt: entry.attempt,
due_at: due_at_iso8601(entry.due_in_ms),
error: entry.error,
Expand All @@ -136,6 +138,7 @@ defmodule SymphonyElixirWeb.Presenter do
%{
issue_id: entry.issue_id,
issue_identifier: entry.identifier,
issue_url: Map.get(entry, :issue_url),
state: entry.state,
error: entry.error,
worker_host: Map.get(entry, :worker_host),
Expand Down
6 changes: 6 additions & 0 deletions elixir/lib/symphony_elixir_web/static_assets.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ defmodule SymphonyElixirWeb.StaticAssets do
@external_resource @phoenix_live_view_js_path

@dashboard_css File.read!(@dashboard_css_path)
@dashboard_css_digest :crypto.hash(:sha256, @dashboard_css)
|> Base.encode16(case: :lower)
|> binary_part(0, 12)
@phoenix_html_js File.read!(@phoenix_html_js_path)
@phoenix_js File.read!(@phoenix_js_path)
@phoenix_live_view_js File.read!(@phoenix_live_view_js_path)
Expand All @@ -23,6 +26,9 @@ defmodule SymphonyElixirWeb.StaticAssets do
"/vendor/phoenix_live_view/phoenix_live_view.js" => {"application/javascript", @phoenix_live_view_js}
}

@spec dashboard_css_url() :: String.t()
def dashboard_css_url, do: "/dashboard.css?v=#{@dashboard_css_digest}"

@spec fetch(String.t()) :: {:ok, String.t(), binary()} | :error
def fetch(path) when is_binary(path) do
case Map.fetch(@assets, path) do
Expand Down
8 changes: 8 additions & 0 deletions elixir/priv/static/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,14 @@ pre,
letter-spacing: -0.01em;
}

.issue-id-link {
color: inherit;
text-decoration: underline;
text-decoration-color: currentColor;
text-decoration-thickness: 1px;
text-underline-offset: 0.18em;
}

.issue-link {
color: var(--muted);
font-size: 0.86rem;
Expand Down
16 changes: 15 additions & 1 deletion elixir/test/symphony_elixir/extensions_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ defmodule SymphonyElixir.ExtensionsTest do
%{
"issue_id" => "issue-http",
"issue_identifier" => "MT-HTTP",
"issue_url" => "https://example.org/issues/MT-HTTP",
"state" => "In Progress",
"worker_host" => nil,
"workspace_path" => nil,
Expand All @@ -363,6 +364,7 @@ defmodule SymphonyElixir.ExtensionsTest do
%{
"issue_id" => "issue-retry",
"issue_identifier" => "MT-RETRY",
"issue_url" => "https://example.org/issues/MT-RETRY",
"attempt" => 2,
"due_at" => state_payload["retrying"] |> List.first() |> Map.fetch!("due_at"),
"error" => "boom",
Expand All @@ -374,6 +376,7 @@ defmodule SymphonyElixir.ExtensionsTest do
%{
"issue_id" => "issue-blocked",
"issue_identifier" => "MT-BLOCKED",
"issue_url" => "https://example.org/issues/MT-BLOCKED",
"state" => "In Progress",
"error" => "codex turn requires operator input",
"worker_host" => "dm-dev2",
Expand Down Expand Up @@ -523,7 +526,7 @@ defmodule SymphonyElixir.ExtensionsTest do
start_test_endpoint(orchestrator: orchestrator_name, snapshot_timeout_ms: 50)

html = html_response(get(build_conn(), "/"), 200)
assert html =~ "/dashboard.css"
assert html =~ ~r|/dashboard\.css\?v=[0-9a-f]{12}|
assert html =~ "/vendor/phoenix_html/phoenix_html.js"
assert html =~ "/vendor/phoenix/phoenix.js"
assert html =~ "/vendor/phoenix_live_view/phoenix_live_view.js"
Expand All @@ -535,6 +538,7 @@ defmodule SymphonyElixir.ExtensionsTest do
assert dashboard_css =~ ".status-badge-live"
assert dashboard_css =~ "[data-phx-main].phx-connected .status-badge-live"
assert dashboard_css =~ "[data-phx-main].phx-connected .status-badge-offline"
assert dashboard_css =~ "text-decoration-thickness: 1px"

phoenix_html_js = response(get(build_conn(), "/vendor/phoenix_html/phoenix_html.js"), 200)
assert phoenix_html_js =~ "phoenix.link.click"
Expand Down Expand Up @@ -571,6 +575,10 @@ defmodule SymphonyElixir.ExtensionsTest do
assert html =~ "MT-HTTP"
assert html =~ "MT-RETRY"
assert html =~ "MT-BLOCKED"
assert html =~ ~s(href="https://example.org/issues/MT-HTTP")
assert html =~ ~s(href="https://example.org/issues/MT-RETRY")
assert html =~ ~s(href="https://example.org/issues/MT-BLOCKED")
assert html =~ ~s(aria-label="Open MT-HTTP in the issue tracker")
assert html =~ "rendered"
assert html =~ "turn blocked: waiting for user input"
assert html =~ "Runtime"
Expand All @@ -590,6 +598,7 @@ defmodule SymphonyElixir.ExtensionsTest do
%{
issue_id: "issue-http",
identifier: "MT-HTTP",
issue_url: "javascript:alert('nope')",
state: "In Progress",
session_id: "thread-http",
turn_count: 8,
Expand Down Expand Up @@ -624,6 +633,8 @@ defmodule SymphonyElixir.ExtensionsTest do
assert_eventually(fn ->
render(view) =~ "agent message content streaming: structured update"
end)

refute render(view) =~ "javascript:alert"
end

test "dashboard liveview renders an unavailable state without crashing" do
Expand Down Expand Up @@ -719,6 +730,7 @@ defmodule SymphonyElixir.ExtensionsTest do
%{
issue_id: "issue-http",
identifier: "MT-HTTP",
issue_url: "https://example.org/issues/MT-HTTP",
state: "In Progress",
session_id: "thread-http",
turn_count: 7,
Expand All @@ -736,6 +748,7 @@ defmodule SymphonyElixir.ExtensionsTest do
%{
issue_id: "issue-retry",
identifier: "MT-RETRY",
issue_url: "https://example.org/issues/MT-RETRY",
attempt: 2,
due_in_ms: 2_000,
error: "boom"
Expand All @@ -745,6 +758,7 @@ defmodule SymphonyElixir.ExtensionsTest do
%{
issue_id: "issue-blocked",
identifier: "MT-BLOCKED",
issue_url: "https://example.org/issues/MT-BLOCKED",
state: "In Progress",
error: "codex turn requires operator input",
worker_host: "dm-dev2",
Expand Down
Loading
Loading