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 { .engram-session-row {
cursor: pointer; 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> </style>
<script defer phx-track-static type="text/javascript" src={"/assets/app.js"}></script> <script defer phx-track-static type="text/javascript" src={"/assets/app.js"}></script>
</head> </head>

View File

@ -1,13 +1,12 @@
defmodule CortexStatusWeb.TaskLive do defmodule CortexStatusWeb.TaskLive do
@moduledoc """ @moduledoc """
Mission Control LiveView for Symbiont compound task orchestration. Mission Control session history browser powered by Engram.
Provides real-time visualization of task decomposition, subtask routing, Shows all recorded sessions with clickable detail views including
execution progress, and costs. activity log timelines.
""" """
use CortexStatusWeb, :live_view use CortexStatusWeb, :live_view
@poll_interval 1_000 @http_timeout 5_000
@auth_token "cortex-tasks-2026"
defp symbiont_url do defp symbiont_url do
config = Application.get_env(:cortex_status, :services, []) config = Application.get_env(:cortex_status, :services, [])
@ -16,544 +15,352 @@ defmodule CortexStatusWeb.TaskLive do
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(CortexStatus.PubSub, "service_status")
send(self(), :load_sessions)
end
{:ok, {:ok,
assign(socket, assign(socket,
page_title: "Mission Control", page_title: "Mission Control",
authenticated: false, sessions: [],
password_input: "", stats: %{},
auth_error: nil, selected_session: nil,
prompt: "", session_logs: [],
active_task: nil, modal_loading: false,
task_history: [], filter: "all",
submitting: false, loading: true
submit_error: nil,
poll_timer: nil
)} )}
end end
@impl true @impl true
def handle_event("authenticate", %{"password" => password}, socket) do def handle_info(:load_sessions, socket) do
if password == @auth_token do {stats, sessions} = fetch_engram_data()
# Load recent tasks on auth {:noreply, assign(socket, sessions: sessions, stats: stats, loading: false)}
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
end end
def handle_event("update_prompt", %{"prompt" => prompt}, socket) do def handle_info({:status_update, new_status}, socket) do
{:noreply, assign(socket, prompt: prompt)} # 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 end
def handle_event("submit_task", %{"prompt" => prompt}, socket) do def handle_info({:fetch_logs, session_id}, socket) do
if String.trim(prompt) == "" do logs =
{:noreply, socket} case Req.get("#{symbiont_url()}/engram/sessions/#{session_id}/logs?limit=200",
else receive_timeout: @http_timeout
socket = assign(socket, submitting: true, submit_error: nil) ) do
case submit_compound_task(prompt) do {:ok, %{status: 200, body: %{"logs" => logs}}} when is_list(logs) ->
{:ok, task_data} -> Enum.reverse(logs)
# 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
}
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
end
def handle_event("logout", _, socket) do {:noreply, assign(socket, session_logs: logs, modal_loading: false)}
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
)}
end end
@impl true @impl true
def handle_info(:poll_task, socket) do def handle_event("select_session", %{"id" => session_id}, socket) do
case socket.assigns.active_task do session = Enum.find(socket.assigns.sessions, fn s -> gv(s, "id") == session_id end)
nil ->
{:noreply, socket}
task -> if session do
case fetch_task_progress(task["id"]) do send(self(), {:fetch_logs, session_id})
{:ok, updated_task} when is_map(updated_task) -> {:noreply, assign(socket, selected_session: session, session_logs: [], modal_loading: true)}
status = updated_task["status"] || "unknown" else
terminal = status in ["completed", "failed", "partial"] {:noreply, socket}
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
end end
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 @impl true
def render(assigns) do 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""" ~H"""
<div class="mission-control"> <div class="mc-page">
<%= if !@authenticated do %> <header class="mc-header">
<.auth_gate auth_error={@auth_error} /> <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 %> <% else %>
<.task_interface <div class="mc-session-list">
prompt={@prompt} <%= for session <- @filtered do %>
submitting={@submitting} <div
active_task={@active_task} class={"mc-session-card #{gv(session, "status", "unknown")}"}
task_history={@task_history} phx-click="select_session"
submit_error={@submit_error} 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 %> <% end %>
</div> </div>
""" """
end 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""" ~H"""
<div class="auth-gate"> <div class="modal-backdrop" phx-click="close_modal">
<div class="auth-container"> <div class="modal-content" phx-click-away="close_modal">
<h1>Mission Control</h1> <div class="modal-header">
<p class="auth-subtitle">Symbiont Task Orchestration</p> <div class="modal-title-row">
<span class={"mc-type-badge mc-type-#{@stype}"}><%= @stype %></span>
<%= if @auth_error do %> <span class={"mc-status-pill mc-st-#{@status_val}"}><%= @status_val %></span>
<div class="auth-error">
<%= @auth_error %>
</div> </div>
<% end %> <button class="modal-close" phx-click="close_modal">&times;</button>
<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>
</div> </div>
<%= if @active_task do %> <div class="modal-body">
<.task_visualization task={@active_task} /> <h3 class="modal-summary"><%= @summary %></h3>
<% else %>
<.task_history history={@task_history} />
<% end %>
</div>
</div>
"""
end
defp task_visualization(assigns) do <div class="modal-meta">
~H""" <div class="meta-item">
<div class="task-visualization"> <span class="meta-label">Session ID</span>
<div class="task-metadata"> <span class="meta-value mono"><%= @sid %></span>
<div class="metadata-item"> </div>
<span class="metadata-label">Task ID</span> <div class="meta-item">
<span class="metadata-value"><%= @task["id"] %></span> <span class="meta-label">Started</span>
</div> <span class="meta-value"><%= fmt_full(@started) %></span>
<div class="metadata-item"> </div>
<span class="metadata-label">Status</span> <%= if @completed do %>
<span class={"metadata-status status-#{@task["status"]}"}> <div class="meta-item">
<%= String.upcase(@task["status"]) %> <span class="meta-label">Completed</span>
</span> <span class="meta-value"><%= fmt_full(@completed) %></span>
</div> </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} />
<% end %> <% end %>
</div> </div>
</div>
<% end %>
</div>
"""
end
defp subtask_card(assigns) do <%= if @completion_summary do %>
~H""" <div class="modal-completion">
<div class={"subtask-card status-#{@subtask["status"]}"}> <span class="completion-label">Outcome</span>
<div class="card-header-row"> <p class="completion-text"><%= @completion_summary %></p>
<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>
</div> </div>
<% end %> <% 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> </div>
<% end %> </div>
</div> </div>
""" """
end end
# -- Helpers -- # ── Data Fetching ──
defp submit_compound_task(prompt) do defp fetch_engram_data do
case Req.post("#{symbiont_url()}/task/compound", stats_task =
json: %{"prompt" => prompt}, Task.async(fn ->
headers: [{"authorization", "Bearer #{@auth_token}"}] case Req.get("#{symbiont_url()}/engram/stats", receive_timeout: @http_timeout) do
) do {:ok, %{status: 200, body: body}} when is_map(body) -> body
{:ok, response} -> _ -> %{}
case response.status do
200 -> {:ok, response.body}
_ -> {:error, "HTTP #{response.status}"}
end 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) end)
subtasks sessions_task =
|> Enum.group_by(&Map.get(wave_map, &1["index"], 0)) Task.async(fn ->
|> Enum.sort_by(fn {wave, _} -> wave end) case Req.get("#{symbiont_url()}/engram/sessions/recent?hours=8760",
|> Enum.map(fn {_, tasks} -> Enum.sort_by(tasks, &(&1["index"])) end) 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 end
defp format_cost(cost) when is_number(cost) do # ── Helpers ──
:io_lib.format("~.4f", [cost]) |> to_string()
end
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 defp fmt_date(""), do: "\u2014"
case DateTime.from_iso8601(iso_string) do
{:ok, dt, _} ->
dt
|> DateTime.to_naive()
|> NaiveDateTime.to_string()
{:error, _} -> defp fmt_date(iso) when is_binary(iso) do
iso_string 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
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 end