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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ codex:
- If `WORKFLOW.md` is missing or has invalid YAML, startup and scheduling are halted until fixed.
- `server.port` or CLI `--port` enables the optional Phoenix LiveView dashboard and JSON API at
`/`, `/api/v1/state`, `/api/v1/<issue_identifier>`, and `/api/v1/refresh`.
- The `SYMPHONY_SERVER_PORT` environment variable can also set the server port. Priority order:
CLI `--port` > WORKFLOW.md `server.port` > `SYMPHONY_SERVER_PORT` env var.

### Issue filtering

Expand Down
18 changes: 6 additions & 12 deletions WORKFLOW.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,12 @@
---
tracker:
kind: linear
project_slug: "symphony-0c79b11b75ea"
active_states:
- Todo
- In Progress
- Merging
- Rework
terminal_states:
- Closed
- Cancelled
- Canceled
- Duplicate
- Done
team_key: "RVR"
labels: ["symphony"]
assignee: "me"
dispatch_states: "Todo, In Progress"
active_states: "Todo, In Progress, Code Review, On Staging"
terminal_states: ["Done", "Canceled"]
polling:
interval_ms: 5000
workspace:
Expand Down
18 changes: 17 additions & 1 deletion lib/symphony_elixir/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,23 @@ defmodule SymphonyElixir.Config do
port

_ ->
get_in(validated_workflow_options(), [:server, :port])
case get_in(validated_workflow_options(), [:server, :port]) do
port when is_integer(port) and port >= 0 -> port
_ -> env_server_port()
end
end
end

defp env_server_port do
case System.get_env("SYMPHONY_SERVER_PORT") do
nil ->
nil

val ->
case Integer.parse(val) do
{port, _} when port >= 0 -> port
_ -> nil
end
end
end

Expand Down
22 changes: 22 additions & 0 deletions lib/symphony_elixir/orchestrator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,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
})

Expand All @@ -120,6 +121,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)}"
})
end
Expand Down Expand Up @@ -398,6 +400,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] && running_entry.issue.url,
error: "stalled for #{elapsed_ms}ms without codex activity"
})
else
Expand Down Expand Up @@ -654,6 +657,7 @@ defmodule SymphonyElixir.Orchestrator do
last_codex_message: nil,
last_codex_timestamp: nil,
last_codex_event: nil,
codex_event_log: [],
codex_app_server_pid: nil,
codex_input_tokens: 0,
codex_output_tokens: 0,
Expand All @@ -679,6 +683,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)}"
})
end
Expand Down Expand Up @@ -720,6 +725,7 @@ defmodule SymphonyElixir.Orchestrator do
old_timer = Map.get(previous_retry, :timer_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)

if is_reference(old_timer) do
Expand All @@ -740,6 +746,7 @@ defmodule SymphonyElixir.Orchestrator do
timer_ref: timer_ref,
due_at_ms: due_at_ms,
identifier: identifier,
issue_url: issue_url,
error: error
})
}
Expand Down Expand Up @@ -846,6 +853,7 @@ defmodule SymphonyElixir.Orchestrator do
attempt + 1,
Map.merge(metadata, %{
identifier: issue.identifier,
issue_url: issue.url,
error: "no available orchestrator slots"
})
)}
Expand Down Expand Up @@ -883,6 +891,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 @@ -969,11 +981,13 @@ defmodule SymphonyElixir.Orchestrator do
codex_input_tokens: metadata.codex_input_tokens,
codex_output_tokens: metadata.codex_output_tokens,
codex_total_tokens: metadata.codex_total_tokens,
codex_event_log: Map.get(metadata, :codex_event_log, []),
turn_count: Map.get(metadata, :turn_count, 0),
started_at: metadata.started_at,
last_codex_timestamp: metadata.last_codex_timestamp,
last_codex_message: metadata.last_codex_message,
last_codex_event: metadata.last_codex_event,
issue_url: metadata.issue.url,
runtime_seconds: running_seconds(metadata.started_at, now)
}
end)
Expand All @@ -986,6 +1000,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)
}
end)
Expand Down Expand Up @@ -1022,6 +1037,8 @@ defmodule SymphonyElixir.Orchestrator do
}, state}
end

@max_event_log_entries 200

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 All @@ -1033,12 +1050,17 @@ defmodule SymphonyElixir.Orchestrator do
last_reported_total = Map.get(running_entry, :codex_last_reported_total_tokens, 0)
turn_count = Map.get(running_entry, :turn_count, 0)

log_entry = %{event: event, timestamp: timestamp, message: summarize_codex_update(update)}
event_log = Map.get(running_entry, :codex_event_log, [])
updated_log = Enum.take([log_entry | event_log], @max_event_log_entries)

{
Map.merge(running_entry, %{
last_codex_timestamp: timestamp,
last_codex_message: summarize_codex_update(update),
session_id: session_id_for_update(running_entry.session_id, update),
last_codex_event: event,
codex_event_log: updated_log,
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 Down
5 changes: 4 additions & 1 deletion lib/symphony_elixir_web/components/layouts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ defmodule SymphonyElixirWeb.Layouts do
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={@csrf_token} />
<title>Symphony Observability</title>
<title>Symphony</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
<script defer src="/vendor/phoenix_html/phoenix_html.js"></script>
<script defer src="/vendor/phoenix/phoenix.js"></script>
<script defer src="/vendor/phoenix_live_view/phoenix_live_view.js"></script>
Expand Down
165 changes: 114 additions & 51 deletions lib/symphony_elixir_web/live/dashboard_live.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ defmodule SymphonyElixirWeb.DashboardLive do
socket
|> assign(:payload, load_payload())
|> assign(:now, DateTime.utc_now())
|> assign(:expanded, MapSet.new())

if connected?(socket) do
:ok = ObservabilityPubSub.subscribe()
Expand All @@ -37,6 +38,23 @@ defmodule SymphonyElixirWeb.DashboardLive do
|> assign(:now, DateTime.utc_now())}
end

@impl true
def handle_event("noop", _params, socket), do: {:noreply, socket}

@impl true
def handle_event("toggle_row", %{"id" => issue_id}, socket) do
expanded = socket.assigns.expanded

expanded =
if MapSet.member?(expanded, issue_id) do
MapSet.delete(expanded, issue_id)
else
MapSet.put(expanded, issue_id)
end

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

@impl true
def render(assigns) do
~H"""
Expand Down Expand Up @@ -149,57 +167,86 @@ defmodule SymphonyElixirWeb.DashboardLive do
</tr>
</thead>
<tbody>
<tr :for={entry <- @payload.running}>
<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>
</div>
</td>
<td>
<span class={state_badge_class(entry.state)}>
<%= entry.state %>
</span>
</td>
<td>
<div class="session-stack">
<%= if entry.session_id do %>
<button
type="button"
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);"
>
Copy ID
</button>
<% else %>
<span class="muted">n/a</span>
<% end %>
</div>
</td>
<td class="numeric"><%= format_runtime_and_turns(entry.started_at, entry.turn_count, @now) %></td>
<td>
<div class="detail-stack">
<span
class="event-text"
title={entry.last_message || to_string(entry.last_event || "n/a")}
><%= entry.last_message || to_string(entry.last_event || "n/a") %></span>
<span class="muted event-meta">
<%= entry.last_event || "n/a" %>
<%= if entry.last_event_at do %>
· <span class="mono numeric"><%= entry.last_event_at %></span>
<%= for entry <- @payload.running do %>
<tr class="expandable-row" phx-click="toggle_row" phx-value-id={entry.issue_id}>
<td>
<div class="issue-stack">
<span class="row-chevron"><%= if MapSet.member?(@expanded, entry.issue_id), do: "\u25BE", else: "\u25B8" %></span>
<%= if entry.issue_url do %>
<a class="issue-id" href={entry.issue_url} target="_blank" phx-click="noop" phx-value-id={entry.issue_id} onclick="event.stopPropagation();"><%= entry.issue_identifier %></a>
<% else %>
<span class="issue-id"><%= entry.issue_identifier %></span>
<% end %>
<a class="issue-link" href={"/api/v1/#{entry.issue_identifier}"} onclick="event.stopPropagation();">JSON</a>
</div>
</td>
<td>
<span class={state_badge_class(entry.state)}>
<%= entry.state %>
</span>
</div>
</td>
<td>
<div class="token-stack numeric">
<span>Total: <%= format_int(entry.tokens.total_tokens) %></span>
<span class="muted">In <%= format_int(entry.tokens.input_tokens) %> / Out <%= format_int(entry.tokens.output_tokens) %></span>
</div>
</td>
</tr>
</td>
<td>
<div class="session-stack">
<%= if entry.session_id do %>
<button
type="button"
class="subtle-button"
data-label="Copy ID"
data-copy={entry.session_id}
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>
<% else %>
<span class="muted">n/a</span>
<% end %>
</div>
</td>
<td class="numeric"><%= format_runtime_and_turns(entry.started_at, entry.turn_count, @now) %></td>
<td>
<div class="detail-stack">
<span
class="event-text"
title={entry.last_message || to_string(entry.last_event || "n/a")}
><%= entry.last_message || to_string(entry.last_event || "n/a") %></span>
<span class="muted event-meta">
<%= entry.last_event || "n/a" %>
<%= if entry.last_event_at do %>
· <span class="mono numeric"><%= entry.last_event_at %></span>
<% end %>
</span>
</div>
</td>
<td>
<div class="token-stack numeric">
<span>Total: <%= format_int(entry.tokens.total_tokens) %></span>
<span class="muted">In <%= format_int(entry.tokens.input_tokens) %> / Out <%= format_int(entry.tokens.output_tokens) %></span>
</div>
</td>
</tr>
<%= if MapSet.member?(@expanded, entry.issue_id) do %>
<tr class="expanded-detail-row">
<td colspan="6">
<div class="event-log-panel">
<h3 class="event-log-title">Session event log</h3>
<%= if entry.event_log == [] do %>
<p class="empty-state">No events recorded yet.</p>
<% else %>
<div class="event-log-list">
<%= for log_entry <- entry.event_log do %>
<div class="event-log-entry">
<span class="event-log-time mono numeric"><%= log_entry.timestamp || "n/a" %></span>
<span class={event_badge_class(log_entry.event)}><%= log_entry.event || "unknown" %></span>
<span class="event-log-message"><%= log_entry.message || "" %></span>
</div>
<% end %>
</div>
<% end %>
</div>
</td>
</tr>
<% end %>
<% end %>
</tbody>
</table>
</div>
Expand Down Expand Up @@ -231,8 +278,12 @@ defmodule SymphonyElixirWeb.DashboardLive do
<tr :for={entry <- @payload.retrying}>
<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>
<%= if entry.issue_url do %>
<a class="issue-id" href={entry.issue_url} target="_blank"><%= entry.issue_identifier %></a>
<% else %>
<span class="issue-id"><%= entry.issue_identifier %></span>
<% end %>
<a class="issue-link" href={"/api/v1/#{entry.issue_identifier}"}>JSON</a>
</div>
</td>
<td><%= entry.attempt %></td>
Expand Down Expand Up @@ -321,6 +372,18 @@ defmodule SymphonyElixirWeb.DashboardLive do
end
end

defp event_badge_class(event) do
base = "event-badge"
normalized = event |> to_string() |> String.downcase()

cond do
String.contains?(normalized, ["failed", "error", "malformed"]) -> "#{base} event-badge-danger"
String.contains?(normalized, ["started", "completed", "approved"]) -> "#{base} event-badge-success"
String.contains?(normalized, ["cancelled", "input_required"]) -> "#{base} event-badge-warning"
true -> base
end
end

defp schedule_runtime_tick do
Process.send_after(self(), :runtime_tick, @runtime_tick_ms)
end
Expand Down
Loading
Loading