Rewrite Mission Control to use Engram sessions as task history

This commit is contained in:
Claude Opus 4.6 2026-03-23 13:25:48 +00:00
parent 495ccb4b05
commit 555665fdea
2 changed files with 487 additions and 487 deletions

View File

@ -1467,6 +1467,199 @@
.engram-session-row {
cursor: pointer;
}
/* Mission Control page */
.mc-page {
max-width: 860px;
margin: 0 auto;
padding: 2rem 1.5rem;
}
.mc-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 2rem;
}
.mc-header h1 {
font-size: 1.6rem;
font-weight: 600;
letter-spacing: -0.02em;
}
.mc-subtitle {
display: block;
color: var(--text-muted);
font-size: 0.85rem;
margin-top: 0.2rem;
}
.mc-back-link {
color: var(--text-muted);
font-size: 0.85rem;
padding-top: 0.4rem;
}
.mc-stats-bar {
display: flex;
gap: 2.5rem;
margin-bottom: 1.5rem;
padding: 1rem 1.5rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
}
.mc-stat {
display: flex;
flex-direction: column;
align-items: center;
}
.mc-stat-num {
font-size: 1.4rem;
font-weight: 700;
font-family: var(--mono);
color: var(--blue);
}
.mc-stat-num.mc-active {
color: var(--green);
animation: pulse 2s ease-in-out infinite;
}
.mc-stat-lbl {
font-size: 0.7rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.mc-filters {
display: flex;
gap: 0.5rem;
margin-bottom: 1.25rem;
flex-wrap: wrap;
}
.mc-filter-btn {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 0.8rem;
padding: 0.35rem 0.8rem;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
font-family: var(--mono);
}
.mc-filter-btn:hover {
border-color: var(--blue);
color: var(--text);
}
.mc-filter-btn.active {
background: var(--blue-bg);
border-color: var(--blue);
color: var(--blue);
}
.mc-filter-count {
font-size: 0.7rem;
opacity: 0.7;
margin-left: 0.25rem;
}
.mc-loading {
text-align: center;
padding: 3rem;
color: var(--text-muted);
}
.mc-session-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.mc-session-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1rem 1.25rem;
cursor: pointer;
transition: all 0.15s;
}
.mc-session-card:hover {
background: var(--surface-hover);
border-color: var(--blue);
transform: translateY(-1px);
}
.mc-card-top {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.mc-type-badge {
font-size: 0.65rem;
font-family: var(--mono);
padding: 0.12rem 0.45rem;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.mc-type-cowork { color: var(--blue); background: var(--blue-bg); }
.mc-type-code { color: var(--green); background: var(--green-bg); }
.mc-type-desktop { color: var(--yellow); background: var(--yellow-bg); }
.mc-type-api { color: var(--text-muted); background: rgba(136, 144, 164, 0.1); }
.mc-type-other, .mc-type-test { color: var(--text-muted); background: rgba(136, 144, 164, 0.1); }
.mc-status-pill {
font-size: 0.65rem;
font-family: var(--mono);
padding: 0.12rem 0.45rem;
border-radius: 4px;
text-transform: uppercase;
}
.mc-st-completed { color: var(--green); background: var(--green-bg); }
.mc-st-active { color: var(--blue); background: var(--blue-bg); }
.mc-st-unknown { color: var(--text-muted); background: rgba(136, 144, 164, 0.1); }
.mc-card-date {
margin-left: auto;
font-size: 0.75rem;
color: var(--text-muted);
font-family: var(--mono);
}
.mc-card-summary {
font-size: 0.9rem;
line-height: 1.5;
color: var(--text);
}
.mc-card-outcome {
font-size: 0.8rem;
color: var(--text-muted);
margin-top: 0.4rem;
padding-top: 0.4rem;
border-top: 1px solid var(--border);
line-height: 1.4;
}
.mc-empty {
text-align: center;
padding: 3rem;
color: var(--text-muted);
}
</style>
<script defer phx-track-static type="text/javascript" src={"/assets/app.js"}></script>
</head>

View File

@ -1,13 +1,12 @@
defmodule CortexStatusWeb.TaskLive do
@moduledoc """
Mission Control LiveView for Symbiont compound task orchestration.
Provides real-time visualization of task decomposition, subtask routing,
execution progress, and costs.
Mission Control session history browser powered by Engram.
Shows all recorded sessions with clickable detail views including
activity log timelines.
"""
use CortexStatusWeb, :live_view
@poll_interval 1_000
@auth_token "cortex-tasks-2026"
@http_timeout 5_000
defp symbiont_url do
config = Application.get_env(:cortex_status, :services, [])
@ -16,544 +15,352 @@ defmodule CortexStatusWeb.TaskLive do
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(CortexStatus.PubSub, "service_status")
send(self(), :load_sessions)
end
{:ok,
assign(socket,
page_title: "Mission Control",
authenticated: false,
password_input: "",
auth_error: nil,
prompt: "",
active_task: nil,
task_history: [],
submitting: false,
submit_error: nil,
poll_timer: nil
sessions: [],
stats: %{},
selected_session: nil,
session_logs: [],
modal_loading: false,
filter: "all",
loading: true
)}
end
@impl true
def handle_event("authenticate", %{"password" => password}, socket) do
if password == @auth_token do
# Load recent tasks on auth
task_history = fetch_recent_tasks()
{:noreply,
assign(socket,
authenticated: true,
password_input: "",
auth_error: nil,
task_history: task_history
)}
else
{:noreply, assign(socket, auth_error: "Invalid password", password_input: "")}
end
def handle_info(:load_sessions, socket) do
{stats, sessions} = fetch_engram_data()
{:noreply, assign(socket, sessions: sessions, stats: stats, loading: false)}
end
def handle_event("update_prompt", %{"prompt" => prompt}, socket) do
{:noreply, assign(socket, prompt: prompt)}
def handle_info({:status_update, new_status}, socket) do
# Piggyback on the monitor's engram data to stay fresh
engram = new_status.engram
stats = engram.stats
sessions = engram.sessions
{:noreply, assign(socket, sessions: sessions, stats: stats, loading: false)}
end
def handle_event("submit_task", %{"prompt" => prompt}, socket) do
if String.trim(prompt) == "" do
{:noreply, socket}
else
socket = assign(socket, submitting: true, submit_error: nil)
case submit_compound_task(prompt) do
{:ok, task_data} ->
# The POST response only has {id, status, subtask_count}.
# Build a placeholder with empty subtasks until first poll fills it in.
placeholder = %{
"id" => task_data["id"],
"prompt" => String.trim(prompt),
"status" => task_data["status"] || "planned",
"reasoning" => nil,
"subtasks" => [],
"created_at" => DateTime.to_iso8601(DateTime.utc_now()),
"completed_at" => nil,
"total_cost" => 0.0
}
def handle_info({:fetch_logs, session_id}, socket) do
logs =
case Req.get("#{symbiont_url()}/engram/sessions/#{session_id}/logs?limit=200",
receive_timeout: @http_timeout
) do
{:ok, %{status: 200, body: %{"logs" => logs}}} when is_list(logs) ->
Enum.reverse(logs)
timer = Process.send_after(self(), :poll_task, 200)
{:noreply,
assign(socket,
submitting: false,
active_task: placeholder,
prompt: "",
submit_error: nil,
poll_timer: timer
)}
{:error, reason} ->
{:noreply,
assign(socket,
submitting: false,
active_task: nil,
submit_error: "Failed to submit task: #{inspect(reason)}"
)}
_ ->
[]
end
end
end
def handle_event("logout", _, socket) do
if socket.assigns.poll_timer do
Process.cancel_timer(socket.assigns.poll_timer)
end
{:noreply,
assign(socket,
authenticated: false,
password_input: "",
active_task: nil,
prompt: "",
task_history: [],
poll_timer: nil
)}
{:noreply, assign(socket, session_logs: logs, modal_loading: false)}
end
@impl true
def handle_info(:poll_task, socket) do
case socket.assigns.active_task do
nil ->
{:noreply, socket}
def handle_event("select_session", %{"id" => session_id}, socket) do
session = Enum.find(socket.assigns.sessions, fn s -> gv(s, "id") == session_id end)
task ->
case fetch_task_progress(task["id"]) do
{:ok, updated_task} when is_map(updated_task) ->
status = updated_task["status"] || "unknown"
terminal = status in ["completed", "failed", "partial"]
timer =
if terminal do
# Refresh history when task finishes
nil
else
Process.send_after(self(), :poll_task, @poll_interval)
end
{:noreply,
assign(socket,
active_task: updated_task,
poll_timer: timer
)}
_ ->
# API error or malformed response — keep polling but don't crash
timer = Process.send_after(self(), :poll_task, @poll_interval)
{:noreply, assign(socket, poll_timer: timer)}
end
if session do
send(self(), {:fetch_logs, session_id})
{:noreply, assign(socket, selected_session: session, session_logs: [], modal_loading: true)}
else
{:noreply, socket}
end
end
def handle_event("close_modal", _params, socket) do
{:noreply, assign(socket, selected_session: nil, session_logs: [], modal_loading: false)}
end
def handle_event("filter", %{"type" => type}, socket) do
{:noreply, assign(socket, filter: type)}
end
@impl true
def render(assigns) do
# Apply filter
filtered =
assigns.sessions
|> Enum.reject(fn s -> gv(s, "session_type") == "test" end)
|> then(fn sessions ->
case assigns.filter do
"all" -> sessions
type -> Enum.filter(sessions, fn s -> gv(s, "session_type") == type end)
end
end)
|> Enum.sort_by(fn s -> gv(s, "started_at", "") end, :desc)
# Compute type counts for filter badges
all_real =
assigns.sessions
|> Enum.reject(fn s -> gv(s, "session_type") == "test" end)
type_counts =
all_real
|> Enum.frequencies_by(fn s -> gv(s, "session_type", "other") end)
assigns =
assigns
|> assign(:filtered, filtered)
|> assign(:type_counts, type_counts)
|> assign(:total_real, length(all_real))
~H"""
<div class="mission-control">
<%= if !@authenticated do %>
<.auth_gate auth_error={@auth_error} />
<div class="mc-page">
<header class="mc-header">
<div class="mc-header-left">
<h1>Mission Control</h1>
<span class="mc-subtitle">Engram Session History</span>
</div>
<a href="/" class="mc-back-link">&larr; Status</a>
</header>
<div class="mc-stats-bar">
<div class="mc-stat">
<span class="mc-stat-num"><%= gv(@stats, "total_sessions", 0) %></span>
<span class="mc-stat-lbl">Sessions</span>
</div>
<div class="mc-stat">
<span class="mc-stat-num"><%= gv(@stats, "total_logs", 0) %></span>
<span class="mc-stat-lbl">Log entries</span>
</div>
<div class="mc-stat">
<span class={"mc-stat-num #{if gv(@stats, "active_sessions", 0) > 0, do: "mc-active", else: ""}"}>
<%= gv(@stats, "active_sessions", 0) %>
</span>
<span class="mc-stat-lbl">Active now</span>
</div>
</div>
<div class="mc-filters">
<button
class={"mc-filter-btn #{if @filter == "all", do: "active", else: ""}"}
phx-click="filter"
phx-value-type="all"
>All <span class="mc-filter-count"><%= @total_real %></span></button>
<%= for {type, count} <- Enum.sort(@type_counts) do %>
<button
class={"mc-filter-btn #{if @filter == type, do: "active", else: ""}"}
phx-click="filter"
phx-value-type={type}
><%= type %> <span class="mc-filter-count"><%= count %></span></button>
<% end %>
</div>
<%= if @loading do %>
<div class="mc-loading">Loading sessions...</div>
<% else %>
<.task_interface
prompt={@prompt}
submitting={@submitting}
active_task={@active_task}
task_history={@task_history}
submit_error={@submit_error}
<div class="mc-session-list">
<%= for session <- @filtered do %>
<div
class={"mc-session-card #{gv(session, "status", "unknown")}"}
phx-click="select_session"
phx-value-id={gv(session, "id", "")}
>
<div class="mc-card-top">
<span class={"mc-type-badge mc-type-#{gv(session, "session_type", "other")}"}><%= gv(session, "session_type", "?") %></span>
<span class={"mc-status-pill mc-st-#{gv(session, "status", "unknown")}"}><%= gv(session, "status", "?") %></span>
<span class="mc-card-date"><%= fmt_date(gv(session, "started_at", "")) %></span>
</div>
<div class="mc-card-summary"><%= gv(session, "summary", "\u2014") %></div>
<%= if gv(session, "completion_summary", nil) do %>
<div class="mc-card-outcome"><%= gv(session, "completion_summary", "") %></div>
<% end %>
</div>
<% end %>
<%= if @filtered == [] do %>
<div class="mc-empty">No sessions match this filter.</div>
<% end %>
</div>
<% end %>
<%= if @selected_session do %>
<.session_modal
session={@selected_session}
logs={@session_logs}
loading={@modal_loading}
/>
<% end %>
</div>
"""
end
attr :auth_error, :string, default: nil
# ── Session Detail Modal ──
attr :session, :map, required: true
attr :logs, :list, required: true
attr :loading, :boolean, default: false
defp session_modal(assigns) do
session = assigns.session
assigns =
assigns
|> assign(:sid, gv(session, "id", ""))
|> assign(:stype, gv(session, "session_type", "?"))
|> assign(:status_val, gv(session, "status", "unknown"))
|> assign(:summary, gv(session, "summary", "\u2014"))
|> assign(:started, gv(session, "started_at", ""))
|> assign(:completed, gv(session, "completed_at", nil))
|> assign(:completion_summary, gv(session, "completion_summary", nil))
defp auth_gate(assigns) do
~H"""
<div class="auth-gate">
<div class="auth-container">
<h1>Mission Control</h1>
<p class="auth-subtitle">Symbiont Task Orchestration</p>
<%= if @auth_error do %>
<div class="auth-error">
<%= @auth_error %>
<div class="modal-backdrop" phx-click="close_modal">
<div class="modal-content" phx-click-away="close_modal">
<div class="modal-header">
<div class="modal-title-row">
<span class={"mc-type-badge mc-type-#{@stype}"}><%= @stype %></span>
<span class={"mc-status-pill mc-st-#{@status_val}"}><%= @status_val %></span>
</div>
<% end %>
<form phx-submit="authenticate" id="auth-form" class="auth-form">
<input
type="password"
id="password-input"
name="password"
placeholder="Enter authorization token"
class="auth-input"
autofocus
/>
<button type="submit" class="auth-button">Access Mission Control</button>
</form>
<p class="auth-footer">
<a href="/"> Back to Status</a>
</p>
</div>
</div>
"""
end
attr :prompt, :string, default: ""
attr :submitting, :boolean, default: false
attr :active_task, :any, default: nil
attr :task_history, :list, default: []
attr :submit_error, :string, default: nil
defp task_interface(assigns) do
~H"""
<div class="task-interface">
<header class="task-header">
<h1>Mission Control</h1>
<button phx-click="logout" class="logout-button">Logout</button>
</header>
<div class="task-main">
<div class="task-input-section">
<form phx-submit="submit_task" phx-change="update_prompt" class="task-form">
<label for="prompt-input">Compound Task Prompt</label>
<textarea
id="prompt-input"
name="prompt"
class="task-textarea"
placeholder="Describe the task to decompose..."
><%= @prompt %></textarea>
<%= if @submit_error do %>
<div class="submit-error"><%= @submit_error %></div>
<% end %>
<button type="submit" class="execute-button" disabled={@submitting}>
<%= if @submitting, do: "Submitting...", else: "Execute Task" %>
</button>
</form>
<button class="modal-close" phx-click="close_modal">&times;</button>
</div>
<%= if @active_task do %>
<.task_visualization task={@active_task} />
<% else %>
<.task_history history={@task_history} />
<% end %>
</div>
</div>
"""
end
<div class="modal-body">
<h3 class="modal-summary"><%= @summary %></h3>
defp task_visualization(assigns) do
~H"""
<div class="task-visualization">
<div class="task-metadata">
<div class="metadata-item">
<span class="metadata-label">Task ID</span>
<span class="metadata-value"><%= @task["id"] %></span>
</div>
<div class="metadata-item">
<span class="metadata-label">Status</span>
<span class={"metadata-status status-#{@task["status"]}"}>
<%= String.upcase(@task["status"]) %>
</span>
</div>
<div class="metadata-item">
<span class="metadata-label">Cost</span>
<span class="metadata-value">$<%= format_cost(@task["total_cost"]) %></span>
</div>
<div class="metadata-item">
<span class="metadata-label">Subtasks</span>
<span class="metadata-value"><%= length(@task["subtasks"] || []) %></span>
</div>
</div>
<div class="prompt-card">
<div class="prompt-label">Original Prompt</div>
<div class="prompt-text"><%= @task["prompt"] %></div>
</div>
<%= if @task["reasoning"] do %>
<div class="reasoning-card">
<div class="reasoning-label">Decomposition Reasoning</div>
<div class="reasoning-text"><%= @task["reasoning"] %></div>
</div>
<% end %>
<div class="task-flow">
<div class="flow-header">Task Flow Visualization</div>
<.task_flow_graph subtasks={@task["subtasks"] || []} />
</div>
<div class="task-details">
<div class="details-header">Subtask Details</div>
<div class="subtasks-list">
<%= for subtask <- @task["subtasks"] || [] do %>
<.subtask_detail subtask={subtask} />
<% end %>
</div>
</div>
</div>
"""
end
defp task_flow_graph(assigns) do
# Group subtasks by wave (dependency level)
subtasks = assigns.subtasks || []
waves = compute_waves(subtasks)
~H"""
<div class="flow-container">
<%= for {wave_tasks, wave_num} <- Enum.with_index(waves) do %>
<div class="wave-row">
<div class="wave-label">Wave <%= wave_num %></div>
<div class="wave-tasks">
<%= for subtask <- wave_tasks do %>
<.subtask_card subtask={subtask} />
<div class="modal-meta">
<div class="meta-item">
<span class="meta-label">Session ID</span>
<span class="meta-value mono"><%= @sid %></span>
</div>
<div class="meta-item">
<span class="meta-label">Started</span>
<span class="meta-value"><%= fmt_full(@started) %></span>
</div>
<%= if @completed do %>
<div class="meta-item">
<span class="meta-label">Completed</span>
<span class="meta-value"><%= fmt_full(@completed) %></span>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
"""
end
defp subtask_card(assigns) do
~H"""
<div class={"subtask-card status-#{@subtask["status"]}"}>
<div class="card-header-row">
<div class="card-status-icon">
<%= case @subtask["status"] do %>
<% "completed" -> %>
<% "failed" -> %>
<% "executing" -> %>
<% _ -> %>
<% end %>
</div>
<div class="card-title">
<%= String.slice(@subtask["description"], 0..40) %>
</div>
</div>
<div class="card-meta">
<%= if @subtask["model"] do %>
<span class={"model-badge tier-#{@subtask["tier_assigned"]}"}>
<%= @subtask["model"] %>
</span>
<% end %>
</div>
<%= if @subtask["status"] == "executing" do %>
<div class="executing-indicator">
<span class="pulse-dot"></span>
Executing...
</div>
<% end %>
<%= if @subtask["result"] do %>
<div class="card-result">
<%= String.slice(@subtask["result"], 0..60) %>
</div>
<% end %>
<div class="card-cost">
$<%= format_cost(@subtask["cost"]) %>
</div>
</div>
"""
end
defp subtask_detail(assigns) do
~H"""
<div class={"subtask-item status-#{@subtask["status"]}"}>
<div class="item-header">
<span class="item-index">
Subtask <%= @subtask["index"] %>
</span>
<span class={"item-status status-#{@subtask["status"]}"}>
<%= String.upcase(@subtask["status"]) %>
</span>
</div>
<div class="item-description">
<%= @subtask["description"] %>
</div>
<div class="item-row">
<span class="item-meta">
<strong>Model:</strong> <%= @subtask["model"] %>
</span>
<span class="item-meta">
<strong>Tier:</strong> <%= @subtask["tier_assigned"] %>
</span>
<span class="item-meta">
<strong>Cost:</strong> $<%= format_cost(@subtask["cost"]) %>
</span>
</div>
<%= if @subtask["started_at"] do %>
<div class="item-row">
<span class="item-meta">
<strong>Started:</strong> <%= format_datetime(@subtask["started_at"]) %>
</span>
<%= if @subtask["completed_at"] do %>
<span class="item-meta">
<strong>Completed:</strong> <%= format_datetime(@subtask["completed_at"]) %>
</span>
<% end %>
</div>
<% end %>
<%= if @subtask["result"] do %>
<div class="item-result">
<strong>Result:</strong>
<pre><%= @subtask["result"] %></pre>
</div>
<% end %>
<%= if @subtask["depends_on"] && length(@subtask["depends_on"]) > 0 do %>
<div class="item-deps">
<strong>Depends on:</strong> <%= Enum.join(@subtask["depends_on"], ", ") %>
</div>
<% end %>
</div>
"""
end
defp task_history(assigns) do
~H"""
<div class="task-history">
<div class="history-header">Recent Tasks</div>
<%= if length(@history) == 0 do %>
<div class="history-empty">
<p>No tasks yet. Create your first compound task above.</p>
</div>
<% else %>
<div class="history-list">
<%= for task <- @history do %>
<div class="history-item">
<div class="history-item-header">
<span class="history-item-id"><%= task["id"] %></span>
<span class={"history-item-status status-#{task["status"]}"}>
<%= String.upcase(task["status"]) %>
</span>
</div>
<div class="history-item-prompt">
<%= String.slice(task["prompt"], 0..100) %>
</div>
<div class="history-item-meta">
<span><%= task["subtask_count"] || length(task["subtasks"] || []) %> subtasks</span>
<span class="history-item-cost">
$<%= format_cost(task["total_cost"]) %>
</span>
</div>
<%= if @completion_summary do %>
<div class="modal-completion">
<span class="completion-label">Outcome</span>
<p class="completion-text"><%= @completion_summary %></p>
</div>
<% end %>
<div class="modal-logs-section">
<h4 class="logs-header">
Activity Log
<span class="logs-count"><%= length(@logs) %> entries</span>
</h4>
<%= if @loading do %>
<div class="logs-loading">Loading...</div>
<% else %>
<%= if @logs == [] do %>
<p class="logs-empty">No log entries recorded for this session.</p>
<% else %>
<div class="logs-timeline">
<%= for log <- @logs do %>
<div class="log-entry">
<span class="log-time"><%= fmt_time(gv(log, "timestamp", "")) %></span>
<span class="log-text"><%= gv(log, "entry", "") %></span>
</div>
<% end %>
</div>
<% end %>
<% end %>
</div>
</div>
<% end %>
</div>
</div>
"""
end
# -- Helpers --
# ── Data Fetching ──
defp submit_compound_task(prompt) do
case Req.post("#{symbiont_url()}/task/compound",
json: %{"prompt" => prompt},
headers: [{"authorization", "Bearer #{@auth_token}"}]
) do
{:ok, response} ->
case response.status do
200 -> {:ok, response.body}
_ -> {:error, "HTTP #{response.status}"}
defp fetch_engram_data do
stats_task =
Task.async(fn ->
case Req.get("#{symbiont_url()}/engram/stats", receive_timeout: @http_timeout) do
{:ok, %{status: 200, body: body}} when is_map(body) -> body
_ -> %{}
end
{:error, reason} ->
{:error, reason}
end
end
defp fetch_task_progress(task_id) do
case Req.get("#{symbiont_url()}/task/#{task_id}/progress") do
{:ok, response} ->
case response.status do
200 -> {:ok, response.body}
_ -> {:error, "HTTP #{response.status}"}
end
{:error, reason} ->
{:error, reason}
end
end
defp fetch_recent_tasks do
case Req.get("#{symbiont_url()}/tasks/recent") do
{:ok, response} ->
case response.status do
200 ->
case response.body do
list when is_list(list) -> list
_ -> []
end
_ ->
[]
end
{:error, _} ->
[]
end
end
defp compute_waves(subtasks) do
# Group subtasks by dependency level (wave)
# Wave 0: no dependencies, Wave N: max(dep waves) + 1
# Sort by index first to ensure deps are computed before dependents
sorted = Enum.sort_by(subtasks, & &1["index"])
wave_map =
Enum.reduce(sorted, %{}, fn task, acc ->
deps = task["depends_on"] || []
wave =
if Enum.empty?(deps) do
0
else
deps
|> Enum.map(&Map.get(acc, &1, 0))
|> Enum.max(fn -> 0 end)
|> Kernel.+(1)
end
Map.put(acc, task["index"], wave)
end)
subtasks
|> Enum.group_by(&Map.get(wave_map, &1["index"], 0))
|> Enum.sort_by(fn {wave, _} -> wave end)
|> Enum.map(fn {_, tasks} -> Enum.sort_by(tasks, &(&1["index"])) end)
sessions_task =
Task.async(fn ->
case Req.get("#{symbiont_url()}/engram/sessions/recent?hours=8760",
receive_timeout: @http_timeout
) do
{:ok, %{status: 200, body: %{"sessions" => s}}} when is_list(s) -> s
_ -> []
end
end)
stats = Task.await(stats_task, @http_timeout + 1_000)
sessions = Task.await(sessions_task, @http_timeout + 1_000)
{stats, sessions}
end
defp format_cost(cost) when is_number(cost) do
:io_lib.format("~.4f", [cost]) |> to_string()
end
# ── Helpers ──
defp format_cost(_), do: "0.0000"
defp gv(map, key, default \\ nil) when is_map(map), do: Map.get(map, key, default)
defp gv(_, _, default), do: default
defp format_datetime(iso_string) when is_binary(iso_string) do
case DateTime.from_iso8601(iso_string) do
{:ok, dt, _} ->
dt
|> DateTime.to_naive()
|> NaiveDateTime.to_string()
defp fmt_date(""), do: "\u2014"
{:error, _} ->
iso_string
defp fmt_date(iso) when is_binary(iso) do
case parse_dt(iso) do
{:ok, dt} ->
diff = DateTime.diff(DateTime.utc_now(), dt, :second)
days = div(diff, 86400)
cond do
days == 0 -> "today"
days == 1 -> "yesterday"
days < 7 -> "#{days}d ago"
true -> Calendar.strftime(dt, "%b %d")
end
_ ->
String.slice(iso, 0, 10)
end
end
defp format_datetime(nil), do: ""
defp fmt_date(_), do: "\u2014"
defp fmt_full(nil), do: "\u2014"
defp fmt_full(""), do: "\u2014"
defp fmt_full(iso) when is_binary(iso) do
case parse_dt(iso) do
{:ok, dt} -> Calendar.strftime(dt, "%b %d, %Y at %H:%M UTC")
_ -> iso
end
end
defp fmt_full(_), do: "\u2014"
defp fmt_time(""), do: ""
defp fmt_time(iso) when is_binary(iso) do
case parse_dt(iso) do
{:ok, dt} -> Calendar.strftime(dt, "%H:%M")
_ -> ""
end
end
defp fmt_time(_), do: ""
defp parse_dt(iso) do
case DateTime.from_iso8601(iso) do
{:ok, dt, _} -> {:ok, dt}
_ ->
case NaiveDateTime.from_iso8601(iso) do
{:ok, ndt} -> {:ok, DateTime.from_naive!(ndt, "Etc/UTC")}
_ -> :error
end
end
end
end