Rewrite Mission Control to use Engram sessions as task history
This commit is contained in:
parent
495ccb4b05
commit
555665fdea
@ -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>
|
||||||
|
|||||||
@ -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">← 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">×</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
|
||||||
Loading…
Reference in New Issue
Block a user