527 lines
19 KiB
Rust
527 lines
19 KiB
Rust
//! Per-container HTTP UI. Phase 6 minimum — a status page on a host port.
|
|
//! Containers share the host's network namespace (privateNetwork = false), so
|
|
//! each instance must bind a distinct port. `HIVE_PORT` is set per agent by
|
|
//! `hive-c0re`'s generated per-agent flake (deterministic from agent name).
|
|
|
|
use std::convert::Infallible;
|
|
use std::net::SocketAddr;
|
|
use std::path::PathBuf;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use anyhow::{Context, Result};
|
|
use axum::{
|
|
Form, Router,
|
|
extract::State,
|
|
response::{
|
|
Html, IntoResponse, Redirect, Response,
|
|
sse::{Event, KeepAlive, Sse},
|
|
},
|
|
routing::{get, post},
|
|
};
|
|
use serde::Deserialize;
|
|
use tokio_stream::{Stream, StreamExt, wrappers::BroadcastStream};
|
|
|
|
use crate::client;
|
|
use crate::events::Bus;
|
|
use crate::login::LoginState;
|
|
use crate::login_session::{LoginSession, drop_if_finished};
|
|
|
|
/// Live login state for the web UI. The harness updates this in place as it
|
|
/// transitions between `NeedsLogin` and `Online`; the UI reads on each
|
|
/// render.
|
|
pub type LoginStateCell = Arc<Mutex<LoginState>>;
|
|
|
|
#[derive(Clone)]
|
|
struct AppState {
|
|
label: String,
|
|
login: LoginStateCell,
|
|
session: Arc<Mutex<Option<Arc<LoginSession>>>>,
|
|
bus: Bus,
|
|
socket: PathBuf,
|
|
flavor: Flavor,
|
|
}
|
|
|
|
/// Which wire protocol the per-agent UI's `/send` handler should speak.
|
|
/// Sub-agent → `AgentRequest::OperatorMsg`; manager → `ManagerRequest::OperatorMsg`.
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub enum Flavor {
|
|
Agent,
|
|
Manager,
|
|
}
|
|
|
|
pub async fn serve(
|
|
label: String,
|
|
port: u16,
|
|
login: LoginStateCell,
|
|
bus: Bus,
|
|
socket: PathBuf,
|
|
flavor: Flavor,
|
|
) -> Result<()> {
|
|
let state = AppState {
|
|
label,
|
|
login,
|
|
session: Arc::new(Mutex::new(None)),
|
|
bus,
|
|
socket,
|
|
flavor,
|
|
};
|
|
let app = Router::new()
|
|
.route("/", get(index))
|
|
.route("/events/stream", get(events_stream))
|
|
.route("/send", post(post_send))
|
|
.route("/login/start", post(post_login_start))
|
|
.route("/login/code", post(post_login_code))
|
|
.route("/login/cancel", post(post_login_cancel))
|
|
.with_state(state);
|
|
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
|
let listener = tokio::net::TcpListener::bind(addr)
|
|
.await
|
|
.with_context(|| format!("bind web UI on port {port}"))?;
|
|
tracing::info!(%port, "web UI listening");
|
|
axum::serve(listener, app).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn index(State(state): State<AppState>) -> Html<String> {
|
|
drop_if_finished(&state.session);
|
|
let login = *state.login.lock().unwrap();
|
|
let session_snapshot = state.session.lock().unwrap().clone();
|
|
let body = match (login, session_snapshot) {
|
|
(LoginState::Online, _) => render_online(&state.label),
|
|
(LoginState::NeedsLogin, None) => render_needs_login_idle(),
|
|
(LoginState::NeedsLogin, Some(session)) => render_login_in_progress(&session),
|
|
};
|
|
let dashboard_port = std::env::var("HIVE_DASHBOARD_PORT")
|
|
.ok()
|
|
.and_then(|s| s.parse::<u16>().ok())
|
|
.unwrap_or(7000);
|
|
Html(format!(
|
|
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>{label} // hyperhive</title>\n{STYLE}\n</head>\n<body>\n<pre class=\"banner\">░▒▓█▓▒░ {label} ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░</pre>\n<h2>◆ {label} ◆ <a href=\"#\" id=\"rebuild-btn\" class=\"btn-rebuild\" data-port=\"{dashboard_port}\" data-label=\"{label}\">↻ R3BU1LD</a></h2>\n<div class=\"divider\">══════════════════════════════════════════════════════════════</div>\n{body}\n<script>\n(function() {{\n const b = document.getElementById('rebuild-btn');\n b.addEventListener('click', function(e) {{\n e.preventDefault();\n if (!confirm('rebuild ' + b.dataset.label + '? container will hot-reload.')) return;\n const url = window.location.protocol + '//' + window.location.hostname + ':' + b.dataset.port + '/rebuild/' + b.dataset.label;\n const form = document.createElement('form');\n form.method = 'POST';\n form.action = url;\n document.body.appendChild(form);\n form.submit();\n }});\n}})();\n</script>\n</body>\n</html>\n",
|
|
label = state.label,
|
|
))
|
|
}
|
|
|
|
fn render_online(label: &str) -> String {
|
|
format!(
|
|
"<p class=\"status-online\">▓█▓▒░ harness alive — turn loop running ▓█▓▒░</p>\n\
|
|
<form method=\"POST\" action=\"/send\" class=\"sendform\">\n \
|
|
<input name=\"body\" placeholder=\"message {label} as operator…\" required autocomplete=\"off\">\n \
|
|
<button type=\"submit\" class=\"btn btn-send\">◆ S3ND</button>\n\
|
|
</form>\n\
|
|
<p class=\"meta\">enqueued with <code>from: operator</code> on this agent's inbox; the next turn picks it up.</p>\n\
|
|
{LIVE_PANEL}",
|
|
)
|
|
}
|
|
|
|
/// Live event tail rendered into every `/` response when the agent is online.
|
|
/// JS opens an `EventSource` on `/events/stream` and appends rows; no full-page
|
|
/// reload, so the login flow and other forms aren't clobbered.
|
|
const LIVE_PANEL: &str = r#"
|
|
<h3>live</h3>
|
|
<div id="live" class="live"><div class="meta">connecting…</div></div>
|
|
<script>
|
|
(function() {
|
|
const log = document.getElementById('live');
|
|
let placeholder = log.firstChild;
|
|
function setPlaceholder(text) {
|
|
log.innerHTML = '';
|
|
const span = document.createElement('div');
|
|
span.className = 'meta';
|
|
span.textContent = text;
|
|
log.appendChild(span);
|
|
placeholder = span;
|
|
}
|
|
function clearPlaceholder() {
|
|
if (placeholder) { log.innerHTML = ''; placeholder = null; }
|
|
}
|
|
function row(cls, text) {
|
|
clearPlaceholder();
|
|
const el = document.createElement('div');
|
|
el.className = 'row ' + (cls || '');
|
|
el.textContent = text;
|
|
log.appendChild(el);
|
|
log.scrollTop = log.scrollHeight;
|
|
return el;
|
|
}
|
|
function trim(s, n) {
|
|
return s.length > n ? s.slice(0, n) + '…' : s;
|
|
}
|
|
function renderStream(v) {
|
|
if (v.type === 'system' && v.subtype === 'init') {
|
|
row('sys', '· session init · tools=' + (v.tools||[]).length + ' model=' + (v.model || '?'));
|
|
return;
|
|
}
|
|
if (v.type === 'rate_limit_event') {
|
|
const u = Math.round((v.rate_limit_info?.utilization || 0) * 100);
|
|
const s = v.rate_limit_info?.status || '';
|
|
row('sys', '· rate-limit util=' + u + '% (' + s + ')');
|
|
return;
|
|
}
|
|
if (v.type === 'assistant' && v.message && v.message.content) {
|
|
for (const c of v.message.content) {
|
|
if (c.type === 'text' && c.text && c.text.trim())
|
|
row('text', c.text);
|
|
else if (c.type === 'thinking')
|
|
row('thinking', '· thinking …');
|
|
else if (c.type === 'tool_use')
|
|
row('tool-use', '→ ' + c.name + ' ' + trim(JSON.stringify(c.input || {}), 240));
|
|
}
|
|
return;
|
|
}
|
|
if (v.type === 'user' && v.message && v.message.content) {
|
|
for (const c of v.message.content) {
|
|
if (c.type === 'tool_result') {
|
|
const txt = Array.isArray(c.content)
|
|
? c.content.map(p => p.text || '').join(' ')
|
|
: (c.content || '');
|
|
row('tool-result', '← ' + trim(txt, 300));
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (v.type === 'result') {
|
|
row('result', '✓ done · ' + (v.subtype || '') + (v.is_error ? ' [error]' : ''));
|
|
return;
|
|
}
|
|
// Fallback: small one-liner for unknown events; don't spam.
|
|
row('sys', '· ' + trim(JSON.stringify(v), 200));
|
|
}
|
|
function handle(ev) {
|
|
if (ev.kind === 'turn_start') {
|
|
const block = row('turn-start', '◆ TURN ← ' + ev.from);
|
|
const body = document.createElement('div');
|
|
body.className = 'turn-body';
|
|
body.textContent = ev.body;
|
|
block.appendChild(body);
|
|
return;
|
|
}
|
|
if (ev.kind === 'turn_end') {
|
|
const cls = ev.ok ? 'turn-end-ok' : 'turn-end-fail';
|
|
const sym = ev.ok ? '✓' : '✗';
|
|
row(cls, sym + ' turn ' + (ev.ok ? 'ok' : 'fail') + (ev.note ? ' — ' + ev.note : ''));
|
|
return;
|
|
}
|
|
if (ev.kind === 'note') {
|
|
row('note', '· ' + ev.text);
|
|
return;
|
|
}
|
|
if (ev.kind === 'stream') {
|
|
const v = Object.assign({}, ev); delete v.kind;
|
|
renderStream(v);
|
|
return;
|
|
}
|
|
row('note', JSON.stringify(ev));
|
|
}
|
|
const es = new EventSource('/events/stream');
|
|
es.onopen = function() { setPlaceholder('(connected — waiting for events)'); };
|
|
es.onmessage = function(e) {
|
|
try { handle(JSON.parse(e.data)); }
|
|
catch (err) { row('note', '[parse err] ' + e.data); }
|
|
};
|
|
es.onerror = function() {
|
|
if (es.readyState === EventSource.CONNECTING) setPlaceholder('(reconnecting…)');
|
|
else row('note', '[disconnected]');
|
|
};
|
|
})();
|
|
</script>
|
|
"#;
|
|
|
|
fn render_needs_login_idle() -> String {
|
|
"<p class=\"status-needs-login\">▓█▓▒░ NEEDS L0G1N ▓█▓▒░</p>\n<p>No Claude session in <code>~/.claude/</code>. The harness is up but the turn loop is paused until you log in.</p>\n<form method=\"POST\" action=\"/login/start\">\n <button type=\"submit\" class=\"btn btn-login\">◆ ST4RT L0G1N</button>\n</form>\n<p class=\"meta\">Spawns <code>claude auth login</code> over plain stdio pipes. The OAuth URL will appear here when claude emits it; paste the resulting code back into the form below.</p>".into()
|
|
}
|
|
|
|
fn render_login_in_progress(session: &Arc<LoginSession>) -> String {
|
|
let url_block = match session.url() {
|
|
Some(url) => format!(
|
|
"<p>▶ <a href=\"{url}\" target=\"_blank\" rel=\"noreferrer\">{url}</a></p>\n<p class=\"meta\">open this URL in a browser, complete the OAuth flow, paste the resulting code below.</p>",
|
|
url = html_escape(&url),
|
|
),
|
|
None => "<p class=\"meta\">waiting for claude to emit an OAuth URL on stdout… (output below)</p>".into(),
|
|
};
|
|
let exit_badge = if session.finished() {
|
|
let note = session.exit_note().unwrap_or_else(|| "exited".into());
|
|
format!(
|
|
"<p class=\"status-needs-login\">claude process exited: {note}. Start over if needed.</p>",
|
|
note = html_escape(¬e),
|
|
)
|
|
} else {
|
|
String::new()
|
|
};
|
|
let output = session.output();
|
|
let code_form = if session.finished() {
|
|
String::new()
|
|
} else {
|
|
"<form method=\"POST\" action=\"/login/code\" class=\"loginform\">\n <input name=\"code\" placeholder=\"paste OAuth code here\" required autocomplete=\"off\">\n <button type=\"submit\" class=\"btn btn-login\">◆ S3ND C0DE</button>\n</form>".into()
|
|
};
|
|
let cancel_form = "<form method=\"POST\" action=\"/login/cancel\" style=\"margin-top: 0.4em;\">\n <button type=\"submit\" class=\"btn btn-cancel\">cancel + kill</button>\n</form>".to_owned();
|
|
format!(
|
|
"<p class=\"status-needs-login\">▓█▓▒░ L0G1N 1N PR0GRESS ▓█▓▒░</p>\n{url_block}\n{code_form}\n{cancel_form}\n{exit_badge}\n<h3>output</h3>\n<pre class=\"diff\">{output}</pre>",
|
|
output = html_escape(&output),
|
|
)
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct SendForm {
|
|
body: String,
|
|
}
|
|
|
|
async fn post_send(State(state): State<AppState>, Form(form): Form<SendForm>) -> Response {
|
|
let body = form.body.trim().to_owned();
|
|
if body.is_empty() {
|
|
return error_response("send: `body` required");
|
|
}
|
|
let result = match state.flavor {
|
|
Flavor::Agent => match client::request::<_, hive_sh4re::AgentResponse>(
|
|
&state.socket,
|
|
&hive_sh4re::AgentRequest::OperatorMsg { body },
|
|
)
|
|
.await
|
|
{
|
|
Ok(hive_sh4re::AgentResponse::Ok) => Ok(()),
|
|
Ok(hive_sh4re::AgentResponse::Err { message }) => Err(message),
|
|
Ok(other) => Err(format!("unexpected response: {other:?}")),
|
|
Err(e) => Err(format!("transport: {e:#}")),
|
|
},
|
|
Flavor::Manager => match client::request::<_, hive_sh4re::ManagerResponse>(
|
|
&state.socket,
|
|
&hive_sh4re::ManagerRequest::OperatorMsg { body },
|
|
)
|
|
.await
|
|
{
|
|
Ok(hive_sh4re::ManagerResponse::Ok) => Ok(()),
|
|
Ok(hive_sh4re::ManagerResponse::Err { message }) => Err(message),
|
|
Ok(other) => Err(format!("unexpected response: {other:?}")),
|
|
Err(e) => Err(format!("transport: {e:#}")),
|
|
},
|
|
};
|
|
match result {
|
|
Ok(()) => Redirect::to("/").into_response(),
|
|
Err(e) => error_response(&format!("send failed: {e}")),
|
|
}
|
|
}
|
|
|
|
async fn events_stream(
|
|
State(state): State<AppState>,
|
|
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
|
|
tracing::info!("sse: client subscribed");
|
|
let rx = state.bus.subscribe();
|
|
// Drop a "hello" note into the bus so every new subscriber sees at
|
|
// least one event immediately and can clear the connecting placeholder.
|
|
state
|
|
.bus
|
|
.emit(crate::events::LiveEvent::Note("live stream attached".into()));
|
|
let stream = BroadcastStream::new(rx).filter_map(|res| {
|
|
let ev = res.ok()?;
|
|
let json = serde_json::to_string(&ev).ok()?;
|
|
Some(Ok(Event::default().data(json)))
|
|
});
|
|
Sse::new(stream).keep_alive(KeepAlive::default())
|
|
}
|
|
|
|
async fn post_login_start(State(state): State<AppState>) -> Response {
|
|
drop_if_finished(&state.session);
|
|
{
|
|
let guard = state.session.lock().unwrap();
|
|
if guard.is_some() {
|
|
return Redirect::to("/").into_response();
|
|
}
|
|
}
|
|
match LoginSession::start() {
|
|
Ok(session) => {
|
|
*state.session.lock().unwrap() = Some(Arc::new(session));
|
|
Redirect::to("/").into_response()
|
|
}
|
|
Err(e) => error_response(&format!("login start failed: {e:#}")),
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct CodeForm {
|
|
code: String,
|
|
}
|
|
|
|
async fn post_login_code(
|
|
State(state): State<AppState>,
|
|
Form(form): Form<CodeForm>,
|
|
) -> Response {
|
|
let session = state.session.lock().unwrap().clone();
|
|
let Some(session) = session else {
|
|
return error_response("no login session running");
|
|
};
|
|
if let Err(e) = session.submit_code(&form.code).await {
|
|
return error_response(&format!("submit code failed: {e:#}"));
|
|
}
|
|
Redirect::to("/").into_response()
|
|
}
|
|
|
|
async fn post_login_cancel(State(state): State<AppState>) -> Response {
|
|
let session = state.session.lock().unwrap().take();
|
|
if let Some(session) = session {
|
|
session.close_stdin().await;
|
|
session.kill();
|
|
}
|
|
Redirect::to("/").into_response()
|
|
}
|
|
|
|
fn error_response(message: &str) -> Response {
|
|
(
|
|
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
|
Html(format!(
|
|
"<!doctype html>\n<html><head>{STYLE}</head><body><h2>error</h2><pre class=\"diff\">{msg}</pre><p><a href=\"/\">← back</a></p></body></html>",
|
|
msg = html_escape(message),
|
|
)),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
fn html_escape(s: &str) -> String {
|
|
s.replace('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
.replace('"', """)
|
|
}
|
|
|
|
const STYLE: &str = r#"
|
|
<style>
|
|
:root {
|
|
--bg: #0a0014;
|
|
--fg: #e0d4ff;
|
|
--muted: #6c5c8c;
|
|
--purple: #cc66ff;
|
|
--purple-dim: #4a1a6a;
|
|
--amber: #ffb84d;
|
|
--green: #66ff99;
|
|
}
|
|
body {
|
|
background: var(--bg);
|
|
color: var(--fg);
|
|
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
|
|
max-width: 70em;
|
|
margin: 1.5em auto;
|
|
padding: 0 1.5em;
|
|
line-height: 1.6;
|
|
}
|
|
.banner {
|
|
color: var(--purple);
|
|
text-align: center;
|
|
margin: 0 0 1em 0;
|
|
font-size: 0.95em;
|
|
text-shadow: 0 0 6px rgba(204, 102, 255, 0.5);
|
|
overflow-x: auto;
|
|
}
|
|
h2, h3 {
|
|
color: var(--purple);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.15em;
|
|
text-shadow: 0 0 8px rgba(204, 102, 255, 0.4);
|
|
}
|
|
.divider {
|
|
color: var(--purple-dim);
|
|
overflow: hidden;
|
|
white-space: nowrap;
|
|
margin-bottom: 0.5em;
|
|
}
|
|
.meta { color: var(--muted); font-size: 0.85em; }
|
|
.status-online { color: var(--green); text-shadow: 0 0 6px rgba(102, 255, 153, 0.5); }
|
|
.status-needs-login { color: var(--amber); text-shadow: 0 0 6px rgba(255, 184, 77, 0.6); }
|
|
code { background: rgba(204, 102, 255, 0.1); padding: 0.05em 0.3em; border-radius: 2px; }
|
|
a { color: #66e0ff; }
|
|
.btn {
|
|
font-family: inherit;
|
|
font-size: 1em;
|
|
background: var(--bg);
|
|
border: 1px solid var(--purple);
|
|
color: var(--purple);
|
|
padding: 0.25em 0.8em;
|
|
cursor: pointer;
|
|
letter-spacing: 0.1em;
|
|
}
|
|
.btn:hover { background: rgba(204, 102, 255, 0.1); }
|
|
.btn-login { color: var(--amber); border-color: var(--amber); }
|
|
.btn-cancel { color: #ff6b6b; border-color: #ff6b6b; font-size: 0.85em; padding: 0.15em 0.6em; }
|
|
.btn-rebuild {
|
|
color: var(--amber);
|
|
border: 1px solid var(--amber);
|
|
padding: 0.15em 0.6em;
|
|
font-size: 0.55em;
|
|
font-family: inherit;
|
|
text-decoration: none;
|
|
letter-spacing: 0.1em;
|
|
margin-left: 0.6em;
|
|
vertical-align: middle;
|
|
cursor: pointer;
|
|
}
|
|
.btn-rebuild:hover { background: rgba(255, 184, 77, 0.1); }
|
|
.btn-send { color: var(--green); border-color: var(--green); }
|
|
.sendform { display: flex; gap: 0.6em; margin-top: 0.5em; }
|
|
.sendform input {
|
|
font-family: inherit; font-size: 1em;
|
|
background: rgba(255, 255, 255, 0.04);
|
|
color: var(--fg);
|
|
border: 1px solid var(--purple-dim);
|
|
padding: 0.4em 0.6em;
|
|
flex: 1;
|
|
}
|
|
.sendform input:focus { outline: 1px solid var(--purple); }
|
|
.loginform { display: flex; gap: 0.6em; margin-top: 0.5em; }
|
|
.loginform input {
|
|
font-family: inherit; font-size: 1em;
|
|
background: rgba(255, 255, 255, 0.04);
|
|
color: var(--fg);
|
|
border: 1px solid var(--purple-dim);
|
|
padding: 0.4em 0.6em;
|
|
flex: 1;
|
|
}
|
|
.loginform input:focus { outline: 1px solid var(--purple); }
|
|
pre.diff {
|
|
background: rgba(255, 255, 255, 0.03);
|
|
border: 1px solid var(--purple-dim);
|
|
padding: 0.6em 0.8em;
|
|
overflow-x: auto;
|
|
white-space: pre-wrap;
|
|
word-break: break-all;
|
|
max-height: 30em;
|
|
}
|
|
.live {
|
|
background: rgba(255, 255, 255, 0.02);
|
|
border: 1px solid var(--purple-dim);
|
|
padding: 0.4em 0.6em;
|
|
overflow-y: auto;
|
|
max-height: 32em;
|
|
font-family: inherit;
|
|
}
|
|
.live .row {
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
padding: 0.05em 0;
|
|
line-height: 1.45;
|
|
border-left: 2px solid transparent;
|
|
padding-left: 0.5em;
|
|
margin: 0.1em 0;
|
|
}
|
|
.live .row + .row { border-top: 0; }
|
|
.live .turn-start {
|
|
color: var(--amber);
|
|
font-weight: bold;
|
|
margin-top: 1em;
|
|
border-left-color: var(--amber);
|
|
padding-top: 0.3em;
|
|
}
|
|
.live .turn-start:first-child { margin-top: 0; }
|
|
.live .turn-body {
|
|
color: var(--fg);
|
|
font-weight: normal;
|
|
margin-top: 0.15em;
|
|
padding-left: 1.2em;
|
|
opacity: 0.85;
|
|
}
|
|
.live .turn-end-ok { color: #66ff99; border-left-color: #66ff99; margin-bottom: 0.4em; }
|
|
.live .turn-end-fail { color: #ff6b6b; border-left-color: #ff6b6b; margin-bottom: 0.4em; }
|
|
.live .text { color: var(--fg); padding-left: 1.2em; }
|
|
.live .thinking { color: var(--muted); font-style: italic; padding-left: 1.2em; }
|
|
.live .tool-use { color: #66e0ff; padding-left: 1.2em; }
|
|
.live .tool-result { color: var(--muted); padding-left: 1.2em; }
|
|
.live .result { color: var(--green); padding-left: 0.5em; }
|
|
.live .sys, .live .note { color: var(--muted); }
|
|
</style>
|
|
"#;
|